Compare commits

...

2 commits

Author SHA1 Message Date
dece e077a3f4c5 Identities: WIP, the list does not look so bad now 2022-02-02 22:40:50 +01:00
dece f45c3facfd update kotlin plugin and some deps 2022-02-02 16:26:10 +01:00
27 changed files with 421 additions and 124 deletions

View file

@ -45,18 +45,18 @@ android {
} }
dependencies { dependencies {
def nav_version = "2.3.5" def nav_version = "2.4.0"
def room_version = "2.4.1" def room_version = "2.4.1"
implementation 'androidx.appcompat:appcompat:1.4.1' implementation 'androidx.appcompat:appcompat:1.4.1'
implementation "androidx.cardview:cardview:1.0.0" implementation "androidx.cardview:cardview:1.0.0"
implementation 'androidx.constraintlayout:constraintlayout:2.1.3' implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
implementation 'androidx.core:core-ktx:1.7.0' implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.fragment:fragment-ktx:1.4.0' implementation 'androidx.fragment:fragment-ktx:1.4.1'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0' implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0'
implementation 'androidx.legacy:legacy-support-v4:1.0.0' implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version" implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version" implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
implementation 'androidx.preference:preference-ktx:1.1.1' implementation 'androidx.preference:preference-ktx:1.2.0'
implementation 'androidx.recyclerview:recyclerview:1.2.1' implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation "androidx.room:room-runtime:$room_version" implementation "androidx.room:room-runtime:$room_version"
implementation "androidx.room:room-ktx:$room_version" implementation "androidx.room:room-ktx:$room_version"

View file

@ -13,27 +13,36 @@ class UriUtilsTest {
fun joinUrls() { fun joinUrls() {
assertEquals( assertEquals(
"gemini://dece.space/some-file.gmi", "gemini://dece.space/some-file.gmi",
joinUrls("gemini://dece.space/", "some-file.gmi").toString() dev.lowrespalmtree.comet.utils.joinUrls("gemini://dece.space/", "some-file.gmi").toString()
) )
assertEquals( assertEquals(
"gemini://dece.space/some-file.gmi", "gemini://dece.space/some-file.gmi",
joinUrls("gemini://dece.space/", "./some-file.gmi").toString() dev.lowrespalmtree.comet.utils.joinUrls("gemini://dece.space/", "./some-file.gmi").toString()
) )
assertEquals( assertEquals(
"gemini://dece.space/some-file.gmi", "gemini://dece.space/some-file.gmi",
joinUrls("gemini://dece.space/dir1", "/some-file.gmi").toString() dev.lowrespalmtree.comet.utils.joinUrls("gemini://dece.space/dir1", "/some-file.gmi").toString()
) )
assertEquals( assertEquals(
"gemini://dece.space/dir1/other-file.gmi", "gemini://dece.space/dir1/other-file.gmi",
joinUrls("gemini://dece.space/dir1/file.gmi", "other-file.gmi").toString() dev.lowrespalmtree.comet.utils.joinUrls(
"gemini://dece.space/dir1/file.gmi",
"other-file.gmi"
).toString()
) )
assertEquals( assertEquals(
"gemini://dece.space/top-level.gmi", "gemini://dece.space/top-level.gmi",
joinUrls("gemini://dece.space/dir1/file.gmi", "../top-level.gmi").toString() dev.lowrespalmtree.comet.utils.joinUrls(
"gemini://dece.space/dir1/file.gmi",
"../top-level.gmi"
).toString()
) )
assertEquals( assertEquals(
"s://hard/test/b/d/a.gmi", "s://hard/test/b/d/a.gmi",
joinUrls("s://hard/dir/a", "./../test/b/c/../d/e/f/../.././a.gmi").toString() dev.lowrespalmtree.comet.utils.joinUrls(
"s://hard/dir/a",
"./../test/b/c/../d/e/f/../.././a.gmi"
).toString()
) )
} }
@ -49,28 +58,28 @@ class UriUtilsTest {
Pair("mid/content=5/../6", "mid/6"), Pair("mid/content=5/../6", "mid/6"),
Pair("../../../../g", "g") Pair("../../../../g", "g")
).forEach { (path, expected) -> ).forEach { (path, expected) ->
assertEquals(expected, removeDotSegments(path)) assertEquals(expected, dev.lowrespalmtree.comet.utils.removeDotSegments(path))
} }
} }
@Test @Test
fun removeLastSegment() { fun removeLastSegment() {
assertEquals("", removeLastSegment("")) assertEquals("", dev.lowrespalmtree.comet.utils.removeLastSegment(""))
assertEquals("", removeLastSegment("/")) assertEquals("", dev.lowrespalmtree.comet.utils.removeLastSegment("/"))
assertEquals("", removeLastSegment("/a")) assertEquals("", dev.lowrespalmtree.comet.utils.removeLastSegment("/a"))
assertEquals("/a", removeLastSegment("/a/")) assertEquals("/a", dev.lowrespalmtree.comet.utils.removeLastSegment("/a/"))
assertEquals("/a", removeLastSegment("/a/b")) assertEquals("/a", dev.lowrespalmtree.comet.utils.removeLastSegment("/a/b"))
assertEquals("/a/b/c", removeLastSegment("/a/b/c/d")) assertEquals("/a/b/c", dev.lowrespalmtree.comet.utils.removeLastSegment("/a/b/c/d"))
assertEquals("//", removeLastSegment("///")) assertEquals("//", dev.lowrespalmtree.comet.utils.removeLastSegment("///"))
} }
@Test @Test
fun popFirstSegment() { fun popFirstSegment() {
assertEquals(Pair("", ""), popFirstSegment("")) assertEquals(Pair("", ""), dev.lowrespalmtree.comet.utils.popFirstSegment(""))
assertEquals(Pair("a", ""), popFirstSegment("a")) assertEquals(Pair("a", ""), dev.lowrespalmtree.comet.utils.popFirstSegment("a"))
assertEquals(Pair("/a", ""), popFirstSegment("/a")) assertEquals(Pair("/a", ""), dev.lowrespalmtree.comet.utils.popFirstSegment("/a"))
assertEquals(Pair("/a", "/"), popFirstSegment("/a/")) assertEquals(Pair("/a", "/"), dev.lowrespalmtree.comet.utils.popFirstSegment("/a/"))
assertEquals(Pair("/a", "/b"), popFirstSegment("/a/b")) assertEquals(Pair("/a", "/b"), dev.lowrespalmtree.comet.utils.popFirstSegment("/a/b"))
assertEquals(Pair("a", "/b"), popFirstSegment("a/b")) assertEquals(Pair("a", "/b"), dev.lowrespalmtree.comet.utils.popFirstSegment("a/b"))
} }
} }

View file

@ -1,7 +1,6 @@
package dev.lowrespalmtree.comet package dev.lowrespalmtree.comet
import android.content.Context import android.content.Context
import androidx.room.AutoMigration
import androidx.room.Database import androidx.room.Database
import androidx.room.Room import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase

View file

@ -7,12 +7,13 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import dev.lowrespalmtree.comet.History.HistoryEntry import dev.lowrespalmtree.comet.History.HistoryEntry
import dev.lowrespalmtree.comet.databinding.FragmentHistoryItemBinding import dev.lowrespalmtree.comet.databinding.FragmentHistoryItemBinding
import dev.lowrespalmtree.comet.utils.getFancySelectBgRes
class HistoryAdapter(private val listener: HistoryItemAdapterListener) : class HistoryAdapter(private val listener: Listener) :
RecyclerView.Adapter<HistoryAdapter.ViewHolder>() { RecyclerView.Adapter<HistoryAdapter.ViewHolder>() {
private var items = listOf<HistoryEntry>() private var items = listOf<HistoryEntry>()
interface HistoryItemAdapterListener { interface Listener {
fun onItemClick(url: String) fun onItemClick(url: String)
} }
@ -31,7 +32,7 @@ class HistoryAdapter(private val listener: HistoryItemAdapterListener) :
holder.binding.titleText.visibility = holder.binding.titleText.visibility =
if (item.title.isNullOrBlank()) View.GONE else View.VISIBLE if (item.title.isNullOrBlank()) View.GONE else View.VISIBLE
holder.binding.titleText.text = item.title ?: "" holder.binding.titleText.text = item.title ?: ""
holder.binding.card.setOnClickListener { listener.onItemClick(item.uri) } holder.binding.container.setOnClickListener { listener.onItemClick(item.uri) }
} }
override fun getItemCount(): Int = items.size override fun getItemCount(): Int = items.size
@ -43,5 +44,9 @@ class HistoryAdapter(private val listener: HistoryItemAdapterListener) :
} }
inner class ViewHolder(val binding: FragmentHistoryItemBinding) : inner class ViewHolder(val binding: FragmentHistoryItemBinding) :
RecyclerView.ViewHolder(binding.root) RecyclerView.ViewHolder(binding.root) {
init {
itemView.setBackgroundResource(getFancySelectBgRes(itemView.context))
}
}
} }

View file

@ -12,16 +12,14 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import dev.lowrespalmtree.comet.History.HistoryEntry import dev.lowrespalmtree.comet.History.HistoryEntry
import dev.lowrespalmtree.comet.HistoryAdapter.HistoryItemAdapterListener
import dev.lowrespalmtree.comet.databinding.FragmentHistoryListBinding import dev.lowrespalmtree.comet.databinding.FragmentHistoryListBinding
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ExperimentalCoroutinesApi class HistoryFragment : Fragment(), HistoryAdapter.Listener {
class HistoryFragment : Fragment(), HistoryItemAdapterListener {
private val vm: HistoryViewModel by viewModels() private val vm: HistoryViewModel by viewModels()
private lateinit var binding: FragmentHistoryListBinding private lateinit var binding: FragmentHistoryListBinding
private lateinit var adapter: HistoryAdapter private lateinit var adapter: HistoryAdapter
@ -36,11 +34,13 @@ class HistoryFragment : Fragment(), HistoryItemAdapterListener {
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val lm = LinearLayoutManager(requireContext())
binding.list.layoutManager = lm
binding.list.addItemDecoration(DividerItemDecoration(context, lm.orientation))
adapter = HistoryAdapter(this) adapter = HistoryAdapter(this)
binding.list.layoutManager = LinearLayoutManager(requireContext())
binding.list.adapter = adapter binding.list.adapter = adapter
vm.items.observe(viewLifecycleOwner, { adapter.setItems(it) }) vm.items.observe(viewLifecycleOwner) { adapter.setItems(it) }
vm.refreshHistory() vm.refreshHistory()
} }
@ -50,7 +50,6 @@ class HistoryFragment : Fragment(), HistoryItemAdapterListener {
findNavController().navigate(R.id.action_global_pageFragment, bundle) findNavController().navigate(R.id.action_global_pageFragment, bundle)
} }
@ExperimentalCoroutinesApi
class HistoryViewModel( class HistoryViewModel(
@Suppress("unused") private val savedStateHandle: SavedStateHandle @Suppress("unused") private val savedStateHandle: SavedStateHandle
) : ViewModel() { ) : ViewModel() {

View file

@ -1,6 +1,10 @@
package dev.lowrespalmtree.comet package dev.lowrespalmtree.comet
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Log
import androidx.room.* import androidx.room.*
import java.security.KeyPairGenerator
object Identities { object Identities {
@Entity @Entity
@ -10,7 +14,7 @@ object Identities {
/** Key to retrieve certificate from the keystore. */ /** Key to retrieve certificate from the keystore. */
val key: String, val key: String,
/** Label for this identity. */ /** Label for this identity. */
val name: String?, var name: String?,
) )
@Entity @Entity
@ -26,13 +30,44 @@ object Identities {
@Dao @Dao
interface IdentityDao { interface IdentityDao {
@Insert @Insert
suspend fun insert(vararg entries: Identity) suspend fun insert(identity: Identity): Long
@Query("SELECT * FROM Identity WHERE :id = id")
suspend fun get(id: Long): Identity?
@Query("SELECT * FROM Identity ORDER BY id")
suspend fun getAll(): List<Identity>
@Update
suspend fun update(vararg identities: Identity)
@Query("SELECT * FROM IdentityUsage WHERE :identityId = identityId") @Query("SELECT * FROM IdentityUsage WHERE :identityId = identityId")
fun getUsagesFor(identityId: Int): List<IdentityUsage> suspend fun getUsagesFor(identityId: Int): List<IdentityUsage>
} }
suspend fun insert(key: String, name: String? = null) { suspend fun insert(key: String, name: String? = null): Long =
Database.INSTANCE.identityDao().insert(Identity(0, key, name)) Database.INSTANCE.identityDao().insert(Identity(0, key, name))
suspend fun get(id: Long): Identity? =
Database.INSTANCE.identityDao().get(id)
suspend fun getAll(): List<Identity> =
Database.INSTANCE.identityDao().getAll()
suspend fun update(vararg identities: Identity) =
Database.INSTANCE.identityDao().update(*identities)
fun generateClientCert(alias: String) {
val algo = KeyProperties.KEY_ALGORITHM_RSA
val kpg = KeyPairGenerator.getInstance(algo, "AndroidKeyStore")
val purposes = KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY
val spec = KeyGenParameterSpec.Builder(alias, purposes)
.setDigests(KeyProperties.DIGEST_SHA256)
.build()
kpg.initialize(spec)
kpg.generateKeyPair()
Log.i(TAG, "generateClientCert: key pair with alias \"$alias\" has been generated")
} }
private const val TAG = "Identities"
} }

View file

@ -1,24 +1,49 @@
package dev.lowrespalmtree.comet package dev.lowrespalmtree.comet
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import dev.lowrespalmtree.comet.Identities.Identity import dev.lowrespalmtree.comet.Identities.Identity
import dev.lowrespalmtree.comet.databinding.FragmentIdentityBinding import dev.lowrespalmtree.comet.databinding.FragmentIdentityBinding
import dev.lowrespalmtree.comet.utils.getFancySelectBgRes
class IdentitiesAdapter : RecyclerView.Adapter<HistoryAdapter.ViewHolder>() { class IdentitiesAdapter(private val listener: Listener) :
RecyclerView.Adapter<IdentitiesAdapter.ViewHolder>() {
private var identities = listOf<Identity>() private var identities = listOf<Identity>()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HistoryAdapter.ViewHolder { interface Listener {
TODO("Not yet implemented") fun onIdentityClick(identity: Identity)
} }
override fun onBindViewHolder(holder: HistoryAdapter.ViewHolder, position: Int) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder =
TODO("Not yet implemented") ViewHolder(
FragmentIdentityBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = identities[position]
holder.binding.labelText.text = item.name.orEmpty()
holder.binding.keyText.text = item.key
holder.binding.container.setOnClickListener { listener.onIdentityClick(item) }
} }
override fun getItemCount(): Int { override fun getItemCount(): Int = identities.size
TODO("Not yet implemented")
@SuppressLint("NotifyDataSetChanged")
fun setIdentities(newIdentities: List<Identity>) {
identities = newIdentities.toList()
notifyDataSetChanged()
} }
inner class ViewHolder(val binding: FragmentIdentityBinding) : RecyclerView.ViewHolder(binding.root) inner class ViewHolder(val binding: FragmentIdentityBinding) :
RecyclerView.ViewHolder(binding.root) {
init {
itemView.setBackgroundResource(getFancySelectBgRes(itemView.context))
}
}
} }

View file

@ -1,14 +1,27 @@
package dev.lowrespalmtree.comet package dev.lowrespalmtree.comet
import android.os.Bundle import android.os.Bundle
import androidx.fragment.app.Fragment
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import dev.lowrespalmtree.comet.Identities.Identity
import dev.lowrespalmtree.comet.databinding.FragmentIdentitiesBinding import dev.lowrespalmtree.comet.databinding.FragmentIdentitiesBinding
import dev.lowrespalmtree.comet.utils.toast
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.util.*
class IdentitiesFragment : Fragment() { class IdentitiesFragment : Fragment(), IdentitiesAdapter.Listener, IdentityDialog.Listener {
private val vm: IdentitiesViewModel by viewModels()
private lateinit var binding: FragmentIdentitiesBinding private lateinit var binding: FragmentIdentitiesBinding
private lateinit var adapter: IdentitiesAdapter
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
@ -18,4 +31,59 @@ class IdentitiesFragment : Fragment() {
binding = FragmentIdentitiesBinding.inflate(layoutInflater) binding = FragmentIdentitiesBinding.inflate(layoutInflater)
return binding.root return binding.root
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val lm = LinearLayoutManager(requireContext())
binding.list.layoutManager = lm
binding.list.addItemDecoration(DividerItemDecoration(context, lm.orientation))
adapter = IdentitiesAdapter(this)
binding.list.adapter = adapter
binding.floatingActionButton.setOnClickListener { openNewIdentityEditor() }
vm.identities.observe(viewLifecycleOwner) { adapter.setIdentities(it) }
vm.refreshIdentities()
}
override fun onIdentityClick(identity: Identity) {
IdentityDialog(requireContext(), identity, this).show()
}
override fun onSaveIdentity(identity: Identity) {
vm.saveIdentity(identity)
}
private fun openNewIdentityEditor() {
toast(requireContext(), R.string.generating_keypair)
vm.newIdentity.observe(viewLifecycleOwner) { identity ->
vm.newIdentity.removeObservers(viewLifecycleOwner)
IdentityDialog(requireContext(), identity, this).show()
}
vm.createNewIdentity()
}
class IdentitiesViewModel : ViewModel() {
val identities: MutableLiveData<List<Identity>> by lazy { MutableLiveData<List<Identity>>() }
val newIdentity: MutableLiveData<Identity> by lazy { MutableLiveData<Identity>() }
fun createNewIdentity() {
viewModelScope.launch(Dispatchers.IO) {
val alias = "identity-${UUID.randomUUID()}"
Identities.generateClientCert(alias)
val newIdentityId = Identities.insert(alias)
newIdentity.postValue(Identities.get(newIdentityId))
}
}
fun refreshIdentities() {
viewModelScope.launch(Dispatchers.IO) {
identities.postValue(Identities.getAll())
}
}
fun saveIdentity(identity: Identity) {
viewModelScope.launch(Dispatchers.IO) { Identities.update(identity) }
}
}
} }

View file

@ -0,0 +1,33 @@
package dev.lowrespalmtree.comet
import android.app.AlertDialog
import android.content.Context
import android.view.LayoutInflater
import dev.lowrespalmtree.comet.databinding.DialogIdentityBinding
class IdentityDialog(
private val context: Context,
private val identity: Identities.Identity,
private val listener: Listener
) {
private lateinit var binding: DialogIdentityBinding
interface Listener {
fun onSaveIdentity(identity: Identities.Identity)
}
fun show() {
binding = DialogIdentityBinding.inflate(LayoutInflater.from(context))
binding.labelInput.setText(identity.name)
binding.aliasText.text = identity.key
AlertDialog.Builder(context)
.setTitle(R.string.edit_identity)
.setView(binding.root)
.setPositiveButton(android.R.string.ok) { _, _ ->
identity.name = binding.labelInput.text.toString()
listener.onSaveIdentity(identity)
}
.create()
.show()
}
}

View file

@ -0,0 +1,41 @@
package dev.lowrespalmtree.comet
import android.os.Bundle
import androidx.fragment.app.viewModels
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.preference.EditTextPreference
import androidx.preference.PreferenceFragmentCompat
import dev.lowrespalmtree.comet.Identities.Identity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.coroutines.coroutineContext
class IdentityEditorFragment : PreferenceFragmentCompat() {
private val vm: IdentityEditorViewModel by viewModels()
private lateinit var namePref: EditTextPreference
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(R.xml.identity_preferences, rootKey)
namePref = findPreference<EditTextPreference>("name")!!
// vm.identity.observe(viewLifecycleOwner) {
// namePref.apply {
// // TODO
// }
// }
//
// arguments?.getLong("id")?.also { vm.loadIdentity(it) }
}
class IdentityEditorViewModel : ViewModel() {
val identity: MutableLiveData<Identity> by lazy { MutableLiveData<Identity>() }
fun loadIdentity(id: Long) {
viewModelScope.launch(Dispatchers.IO) {
identity.postValue(Identities.get(id))
}
}
}
}

View file

@ -17,7 +17,7 @@ import dev.lowrespalmtree.comet.databinding.*
* it could be a n-to-1 relation: many lines belong to the same block. This of course changes a bit * it could be a n-to-1 relation: many lines belong to the same block. This of course changes a bit
* the way we have to render things. * the way we have to render things.
*/ */
class PageAdapter(private val listener: ContentAdapterListener) : class PageAdapter(private val listener: Listener) :
RecyclerView.Adapter<PageAdapter.ContentViewHolder>() { RecyclerView.Adapter<PageAdapter.ContentViewHolder>() {
private var lines = listOf<Line>() private var lines = listOf<Line>()
@ -25,7 +25,7 @@ class PageAdapter(private val listener: ContentAdapterListener) :
private var blocks = mutableListOf<ContentBlock>() private var blocks = mutableListOf<ContentBlock>()
private var lastBlockCount = 0 private var lastBlockCount = 0
interface ContentAdapterListener { interface Listener {
fun onLinkClick(url: String) fun onLinkClick(url: String)
} }
@ -153,7 +153,7 @@ class PageAdapter(private val listener: ContentAdapterListener) :
} }
} }
is ContentBlock.Link -> { is ContentBlock.Link -> {
val label = if (block.label.isNotBlank()) block.label else block.url val label = block.label.ifBlank { block.url }
(holder as ContentViewHolder.Link).binding.textView.text = label (holder as ContentViewHolder.Link).binding.textView.text = label
holder.binding.root.setOnClickListener { listener.onLinkClick(block.url) } holder.binding.root.setOnClickListener { listener.onLinkClick(block.url) }
} }

View file

@ -22,12 +22,14 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import dev.lowrespalmtree.comet.PageAdapter.ContentAdapterListener
import dev.lowrespalmtree.comet.databinding.FragmentPageViewBinding import dev.lowrespalmtree.comet.databinding.FragmentPageViewBinding
import dev.lowrespalmtree.comet.utils.isConnectedToNetwork
import dev.lowrespalmtree.comet.utils.joinUrls
import dev.lowrespalmtree.comet.utils.toGeminiUri
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@ExperimentalCoroutinesApi @ExperimentalCoroutinesApi
class PageFragment : Fragment(), ContentAdapterListener { class PageFragment : Fragment(), PageAdapter.Listener {
private val vm: PageViewModel by viewModels() private val vm: PageViewModel by viewModels()
private lateinit var binding: FragmentPageViewBinding private lateinit var binding: FragmentPageViewBinding
private lateinit var adapter: PageAdapter private lateinit var adapter: PageAdapter
@ -44,17 +46,17 @@ class PageFragment : Fragment(), ContentAdapterListener {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
Log.d(TAG, "onViewCreated") Log.d(TAG, "onViewCreated")
adapter = PageAdapter(this)
binding.contentRecycler.layoutManager = LinearLayoutManager(requireContext()) binding.contentRecycler.layoutManager = LinearLayoutManager(requireContext())
adapter = PageAdapter(this)
binding.contentRecycler.adapter = adapter binding.contentRecycler.adapter = adapter
binding.addressBar.setOnEditorActionListener { v, id, _ -> onAddressBarAction(v, id) } binding.addressBar.setOnEditorActionListener { v, id, _ -> onAddressBarAction(v, id) }
binding.contentSwipeLayout.setOnRefreshListener { openUrl(vm.currentUrl) } binding.contentSwipeLayout.setOnRefreshListener { openUrl(vm.currentUrl) }
vm.state.observe(viewLifecycleOwner, { updateState(it) }) vm.state.observe(viewLifecycleOwner) { updateState(it) }
vm.lines.observe(viewLifecycleOwner, { updateLines(it) }) vm.lines.observe(viewLifecycleOwner) { updateLines(it) }
vm.event.observe(viewLifecycleOwner, { handleEvent(it) }) vm.event.observe(viewLifecycleOwner) { handleEvent(it) }
activity?.onBackPressedDispatcher?.addCallback(viewLifecycleOwner) { onBackPressed() } activity?.onBackPressedDispatcher?.addCallback(viewLifecycleOwner) { onBackPressed() }
@ -71,7 +73,7 @@ class PageFragment : Fragment(), ContentAdapterListener {
} }
override fun onLinkClick(url: String) { override fun onLinkClick(url: String) {
openUrl(url, base = if (vm.currentUrl.isNotEmpty()) vm.currentUrl else null) openUrl(url, base = vm.currentUrl.ifEmpty { null })
} }
private fun onBackPressed() { private fun onBackPressed() {
@ -208,7 +210,7 @@ class PageFragment : Fragment(), ContentAdapterListener {
}) })
} }
AlertDialog.Builder(requireContext()) AlertDialog.Builder(requireContext())
.setMessage(if (prompt.isNotEmpty()) prompt else "Input required") .setMessage(prompt.ifEmpty { "Input required" })
.setView(inputView) .setView(inputView)
.setPositiveButton(android.R.string.ok) { _, _ -> .setPositiveButton(android.R.string.ok) { _, _ ->
val newUri = uri.buildUpon().query(editText.text.toString()).build() val newUri = uri.buildUpon().query(editText.text.toString()).build()

View file

@ -13,7 +13,6 @@ import java.net.SocketTimeoutException
import java.net.UnknownHostException import java.net.UnknownHostException
import java.nio.charset.Charset import java.nio.charset.Charset
@ExperimentalCoroutinesApi
class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedStateHandle) : class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedStateHandle) :
ViewModel() { ViewModel() {
/** Currently viewed page URL. */ /** Currently viewed page URL. */
@ -52,6 +51,7 @@ class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedState
* *
* The URI must be valid, absolute and with a gemini scheme. * The URI must be valid, absolute and with a gemini scheme.
*/ */
@ExperimentalCoroutinesApi
fun sendGeminiRequest(uri: Uri, connectionTimeout: Int, readTimeout: Int, redirects: Int = 0) { fun sendGeminiRequest(uri: Uri, connectionTimeout: Int, readTimeout: Int, redirects: Int = 0) {
Log.d(TAG, "sendRequest: URI \"$uri\"") Log.d(TAG, "sendRequest: URI \"$uri\"")
loadingUrl = uri loadingUrl = uri
@ -110,6 +110,7 @@ class PageViewModel(@Suppress("unused") private val savedStateHandle: SavedState
event.postValue(InputEvent(uri, response.meta)) event.postValue(InputEvent(uri, response.meta))
} }
@ExperimentalCoroutinesApi
private suspend fun handleSuccessResponse(response: Response, uri: Uri) { private suspend fun handleSuccessResponse(response: Response, uri: Uri) {
state.postValue(State.RECEIVING) state.postValue(State.RECEIVING)

View file

@ -5,6 +5,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.preference.EditTextPreference import androidx.preference.EditTextPreference
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceFragmentCompat
import dev.lowrespalmtree.comet.utils.toast
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch

View file

@ -1,7 +0,0 @@
package dev.lowrespalmtree.comet
import android.content.Context
import android.widget.Toast
fun toast(context: Context, stringId: Int, length: Int = Toast.LENGTH_SHORT) =
Toast.makeText(context, stringId, length).show()

View file

@ -1,4 +1,4 @@
package dev.lowrespalmtree.comet package dev.lowrespalmtree.comet.utils
import android.content.Context import android.content.Context
import android.net.ConnectivityManager import android.net.ConnectivityManager

View file

@ -0,0 +1,18 @@
package dev.lowrespalmtree.comet.utils
import android.content.Context
import android.util.TypedValue
import android.widget.Toast
import androidx.annotation.AttrRes
import dev.lowrespalmtree.comet.R
fun toast(context: Context, stringId: Int, length: Int = Toast.LENGTH_SHORT) =
Toast.makeText(context, stringId, length).show()
fun getDrawableFromAttr(context: Context, @AttrRes attr: Int) =
TypedValue()
.apply { context.theme.resolveAttribute(attr, this, true) }
.resourceId
fun getFancySelectBgRes(context: Context) =
getDrawableFromAttr(context, R.attr.selectableItemBackground)

View file

@ -1,4 +1,4 @@
package dev.lowrespalmtree.comet package dev.lowrespalmtree.comet.utils
import android.net.Uri import android.net.Uri

View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/identity_name" />
<EditText
android:id="@+id/labelInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/identity_name"
android:importantForAutofill="no"
android:inputType="text" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/identity_alias_title" />
<TextView
android:id="@+id/aliasText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textIsSelectable="true" />
</LinearLayout>

View file

@ -1,43 +1,33 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView <LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" android:id="@+id/container"
android:id="@+id/card"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:clickable="true" android:clickable="true"
android:focusable="true" android:focusable="true"
android:layout_margin="4dp" android:orientation="vertical">
app:cardCornerRadius="4dp"
app:cardElevation="4dp"
android:foreground="?android:selectableItemBackground">
<LinearLayout <TextView
android:id="@+id/uriText"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical"> android:textSize="14sp"
android:textColor="@color/text"
android:layout_margin="8dp"
android:fontFamily="@font/preformatted"
android:typeface="monospace" />
<TextView <TextView
android:id="@+id/uriText" android:id="@+id/titleText"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textSize="16sp" android:textSize="16sp"
android:textColor="@color/text" android:textColor="@color/text"
android:layout_margin="8dp" android:layout_marginStart="8dp"
android:fontFamily="@font/preformatted" android:layout_marginEnd="8dp"
android:typeface="monospace" /> android:layout_marginTop="0dp"
android:layout_marginBottom="8dp"
android:textAppearance="?attr/textAppearanceListItem" />
<TextView </LinearLayout>
android:id="@+id/titleText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="14sp"
android:textColor="@color/text"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:layout_marginTop="0dp"
android:layout_marginBottom="8dp"
android:textAppearance="?attr/textAppearanceListItem" />
</LinearLayout>
</androidx.cardview.widget.CardView>

View file

@ -1,21 +1,32 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView <LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" android:id="@+id/container"
android:id="@+id/card"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:clickable="true" android:clickable="true"
android:focusable="true" android:focusable="true"
android:layout_margin="4dp" android:orientation="vertical">
app:cardCornerRadius="4dp"
app:cardElevation="4dp"
android:foreground="?android:selectableItemBackground">
<androidx.constraintlayout.widget.ConstraintLayout <TextView
android:id="@+id/labelText"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="wrap_content"
android:layout_margin="8dp"
android:textSize="16sp"
android:textColor="@color/text" />
</androidx.constraintlayout.widget.ConstraintLayout> <TextView
android:id="@+id/keyText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:layout_marginTop="0dp"
android:layout_marginBottom="8dp"
android:textSize="12sp"
android:textColor="@color/text_light"
android:fontFamily="@font/preformatted"
android:typeface="monospace" />
</androidx.cardview.widget.CardView> </LinearLayout>

View file

@ -31,7 +31,16 @@
<fragment <fragment
android:id="@+id/identitiesFragment" android:id="@+id/identitiesFragment"
android:name="dev.lowrespalmtree.comet.IdentitiesFragment" android:name="dev.lowrespalmtree.comet.IdentitiesFragment"
android:label="IdentitiesFragment" /> android:label="IdentitiesFragment" >
<action
android:id="@+id/action_identitiesFragment_to_identityEditorFragment"
app:destination="@id/identityEditorFragment" />
</fragment>
<fragment
android:id="@+id/identityEditorFragment"
android:name="dev.lowrespalmtree.comet.IdentityEditorFragment"
android:label="IdentityEditorFragment" />
<fragment <fragment
android:id="@+id/settingsFragment" android:id="@+id/settingsFragment"
@ -44,4 +53,5 @@
app:exitAnim="@anim/nav_default_exit_anim" app:exitAnim="@anim/nav_default_exit_anim"
app:popEnterAnim="@anim/nav_default_pop_enter_anim" app:popEnterAnim="@anim/nav_default_pop_enter_anim"
app:popExitAnim="@anim/nav_default_pop_exit_anim" /> app:popExitAnim="@anim/nav_default_pop_exit_anim" />
</navigation> </navigation>

View file

@ -31,6 +31,14 @@
<!-- TODO: Remove or change this placeholder text --> <!-- TODO: Remove or change this placeholder text -->
<string name="hello_blank_fragment">Hello blank fragment</string> <string name="hello_blank_fragment">Hello blank fragment</string>
<string name="identities">Identities</string> <string name="identities">Identities</string>
<!-- Preference Titles -->
<string name="messages_header">Messages</string>
<string name="sync_header">Sync</string>
<string name="identity_key_setup">Setup key pair for this identity</string>
<string name="identity_name">Label</string>
<string name="generating_keypair">Generating client certificate…</string>
<string name="identity_alias_title">Keystore reference</string>
<string name="edit_identity">Edit identity</string>
</resources> </resources>

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:persistent="false">
<EditTextPreference
android:key="name"
android:title="@string/identity_name"
app:useSimpleSummaryProvider="true" />
<Preference
android:key="key_setup"
android:title="@string/identity_key_setup" />
</androidx.preference.PreferenceScreen>

View file

@ -1,5 +1,6 @@
<androidx.preference.PreferenceScreen xmlns:app="http://schemas.android.com/apk/res-auto" <androidx.preference.PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android"> xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<PreferenceCategory app:title="@string/pref_general_header"> <PreferenceCategory app:title="@string/pref_general_header">
@ -25,20 +26,20 @@
<PreferenceCategory app:title="@string/pref_protocol_header"> <PreferenceCategory app:title="@string/pref_protocol_header">
<SeekBarPreference <SeekBarPreference
android:defaultValue="10"
android:max="60"
app:key="connection_timeout" app:key="connection_timeout"
app:title="@string/pref_connection_timeout_title"
app:seekBarIncrement="1" app:seekBarIncrement="1"
app:showSeekBarValue="true" app:showSeekBarValue="true"
android:max="60" app:title="@string/pref_connection_timeout_title" />
android:defaultValue="10" />
<SeekBarPreference <SeekBarPreference
android:defaultValue="10"
android:max="60"
app:key="read_timeout" app:key="read_timeout"
app:title="@string/pref_read_timeout_title"
app:seekBarIncrement="1" app:seekBarIncrement="1"
app:showSeekBarValue="true" app:showSeekBarValue="true"
android:max="60" app:title="@string/pref_read_timeout_title" />
android:defaultValue="10" />
<SwitchPreferenceCompat <SwitchPreferenceCompat
app:key="sync" app:key="sync"

View file

@ -6,7 +6,7 @@ buildscript {
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath "com.android.tools.build:gradle:7.0.4" classpath 'com.android.tools.build:gradle:7.1.0'
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.10' classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.10'
classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.3.5" classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.3.5"
} }

View file

@ -1,6 +1,6 @@
#Mon Nov 29 12:01:36 CET 2021 #Mon Nov 29 12:01:36 CET 2021
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME