Identities: add deletion and fix sync issues

- Deletion works and clear the corresponding keystore entries.
- No more sync issues with the listing when creating/updating/deleting.
This commit is contained in:
dece 2022-02-04 12:54:18 +01:00
parent f9c341c35a
commit 11bea0e585
12 changed files with 120 additions and 62 deletions

View file

@ -5,6 +5,7 @@ import android.security.keystore.KeyProperties
import android.util.Log
import androidx.room.*
import java.security.KeyPairGenerator
import java.security.KeyStore
object Identities {
@Entity
@ -43,6 +44,9 @@ object Identities {
@Query("SELECT * FROM IdentityUsage WHERE :identityId = identityId")
suspend fun getUsagesFor(identityId: Int): List<IdentityUsage>
@Delete
suspend fun delete(vararg identities: Identity)
}
suspend fun insert(key: String, name: String? = null): Long =
@ -57,6 +61,14 @@ object Identities {
suspend fun update(vararg identities: Identity) =
Database.INSTANCE.identityDao().update(*identities)
suspend fun delete(vararg identities: Identity) {
for (identity in identities) {
if (identity.key.isNotEmpty())
deleteClientCert(identity.key)
}
Database.INSTANCE.identityDao().delete(*identities)
}
fun generateClientCert(alias: String) {
val algo = KeyProperties.KEY_ALGORITHM_RSA
val kpg = KeyPairGenerator.getInstance(algo, "AndroidKeyStore")
@ -69,5 +81,16 @@ object Identities {
Log.i(TAG, "generateClientCert: key pair with alias \"$alias\" has been generated")
}
private fun deleteClientCert(alias: String) {
val keyStore = KeyStore.getInstance("AndroidKeyStore").also { it.load(null) }
Log.i(TAG, keyStore.aliases().toList().joinToString { it })
if (keyStore.containsAlias(alias)) {
keyStore.deleteEntry(alias)
Log.i(TAG, "deleteClientCert: deleted entry with alias \"$alias\"")
} else {
Log.i(TAG, "deleteClientCert: no such alias \"$alias\"")
}
}
private const val TAG = "Identities"
}

View file

@ -2,6 +2,7 @@ package dev.lowrespalmtree.comet
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import dev.lowrespalmtree.comet.Identities.Identity
@ -14,6 +15,7 @@ class IdentitiesAdapter(private val listener: Listener) :
interface Listener {
fun onIdentityClick(identity: Identity)
fun onIdentityLongClick(identity: Identity, view: View)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder =
@ -29,7 +31,13 @@ class IdentitiesAdapter(private val listener: Listener) :
val item = identities[position]
holder.binding.labelText.text = item.name.orEmpty()
holder.binding.keyText.text = item.key
holder.binding.container.setOnClickListener { listener.onIdentityClick(item) }
holder.binding.container.setOnClickListener {
listener.onIdentityClick(item)
}
holder.binding.container.setOnLongClickListener {
listener.onIdentityLongClick(item, holder.itemView);
true
}
}
override fun getItemCount(): Int = identities.size

View file

@ -4,6 +4,7 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.PopupMenu
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.MutableLiveData
@ -13,6 +14,7 @@ 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.utils.confirm
import dev.lowrespalmtree.comet.utils.toast
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -50,6 +52,32 @@ class IdentitiesFragment : Fragment(), IdentitiesAdapter.Listener, IdentityDialo
IdentityDialog(requireContext(), identity, this).show()
}
override fun onIdentityLongClick(identity: Identity, view: View) {
PopupMenu(requireContext(), view)
.apply {
menuInflater.inflate(R.menu.identity, this.menu)
setOnMenuItemClickListener { item ->
when (item.itemId) {
R.id.item_edit -> {
IdentityDialog(
requireContext(),
identity,
this@IdentitiesFragment
).show()
}
R.id.item_delete -> {
confirm(requireContext(), R.string.confirm_identity_delete) {
vm.deleteIdentity(identity)
}
}
else -> {}
}
true
}
}
.show()
}
override fun onSaveIdentity(identity: Identity) {
vm.saveIdentity(identity)
}
@ -57,7 +85,10 @@ class IdentitiesFragment : Fragment(), IdentitiesAdapter.Listener, IdentityDialo
private fun openNewIdentityEditor() {
toast(requireContext(), R.string.generating_keypair)
vm.newIdentity.observe(viewLifecycleOwner) { identity ->
if (identity == null)
return@observe
vm.newIdentity.removeObservers(viewLifecycleOwner)
vm.newIdentity.value = null
IdentityDialog(requireContext(), identity, this).show()
}
vm.createNewIdentity()
@ -74,16 +105,25 @@ class IdentitiesFragment : Fragment(), IdentitiesAdapter.Listener, IdentityDialo
val newIdentityId = Identities.insert(alias)
newIdentity.postValue(Identities.get(newIdentityId))
}
.invokeOnCompletion { refreshIdentities() }
}
fun refreshIdentities() {
viewModelScope.launch(Dispatchers.IO) {
identities.postValue(Identities.getAll())
}
viewModelScope.launch(Dispatchers.IO) { identities.postValue(Identities.getAll()) }
}
fun saveIdentity(identity: Identity) {
viewModelScope.launch(Dispatchers.IO) { Identities.update(identity) }
.invokeOnCompletion { refreshIdentities() }
}
fun deleteIdentity(identity: Identity) {
viewModelScope.launch(Dispatchers.IO) { Identities.delete(identity) }
.invokeOnCompletion { refreshIdentities() }
}
}
companion object {
private const val TAG = "IdentitiesFragment"
}
}

View file

@ -18,7 +18,7 @@ class IdentityDialog(
fun show() {
binding = DialogIdentityBinding.inflate(LayoutInflater.from(context))
binding.labelInput.setText(identity.name)
binding.labelInput.setText(identity.name.orEmpty())
binding.aliasText.text = identity.key
AlertDialog.Builder(context)
.setTitle(R.string.edit_identity)

View file

@ -1,41 +0,0 @@
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

@ -1,10 +1,14 @@
package dev.lowrespalmtree.comet.utils
import android.app.AlertDialog
import android.content.Context
import android.util.TypedValue
import android.view.LayoutInflater
import android.widget.Toast
import androidx.annotation.AttrRes
import androidx.annotation.StringRes
import dev.lowrespalmtree.comet.R
import dev.lowrespalmtree.comet.databinding.DialogConfirmBinding
fun toast(context: Context, stringId: Int, length: Int = Toast.LENGTH_SHORT) =
Toast.makeText(context, stringId, length).show()
@ -16,3 +20,14 @@ fun getDrawableFromAttr(context: Context, @AttrRes attr: Int) =
fun getFancySelectBgRes(context: Context) =
getDrawableFromAttr(context, R.attr.selectableItemBackground)
fun confirm(context: Context, @StringRes prompt: Int, onOk: () -> Unit) {
val binding = DialogConfirmBinding.inflate(LayoutInflater.from(context))
binding.textView.setText(prompt)
AlertDialog.Builder(context)
.setTitle(R.string.confirm)
.setView(binding.root)
.setPositiveButton(android.R.string.ok) { _, _ -> onOk() }
.create()
.show()
}

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:id="@+id/text_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16sp" />
</LinearLayout>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@+id/item_edit" android:title="@string/edit" />
<item android:id="@+id/item_delete" android:title="@string/delete" />
</menu>

View file

@ -31,16 +31,7 @@
<fragment
android:id="@+id/identitiesFragment"
android:name="dev.lowrespalmtree.comet.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" />
android:label="IdentitiesFragment" />
<fragment
android:id="@+id/settingsFragment"

View file

@ -14,7 +14,7 @@
<color name="url_bar">#002b36</color>
<color name="url_bar_loading">#fdf6e3</color>
<!--
Based on Solarized
Based on Solarized - https://ethanschoonover.com/solarized/
SOLARIZED HEX 16/8 TERMCOL XTERM/HEX L*A*B RGB HSB
base03 #002b36 8/4 brblack 234 #1c1c1c 15 -12 -12 0 43 54 193 100 21
base02 #073642 0/4 black 235 #262626 20 -12 -12 7 54 66 192 90 26

View file

@ -1,5 +1,7 @@
<resources>
<string name="app_name">Comet</string>
<string name="app_name" translatable="false">Comet</string>
<string name="todo" translatable="false">TODO</string>
<string name="error_alert_title">Error</string>
<string name="url">URL</string>
<string name="settings">Settings</string>
@ -11,7 +13,6 @@
<string name="pref_home_set">Set last visited page as home page</string>
<string name="no_current_url">No current URL.</string>
<!-- Preference Protocol -->
<string name="pref_protocol_header">Protocol</string>
<string name="pref_connection_timeout_title">Connection timeout (seconds)</string>
@ -39,6 +40,9 @@
<string name="generating_keypair">Generating client certificate…</string>
<string name="identity_alias_title">Keystore reference</string>
<string name="edit_identity">Edit identity</string>
<string name="delete">Delete</string>
<string name="confirm">Confirm</string>
<string name="confirm_identity_delete">Are you sure you want to delete this identity? The client certificate cannot be retrieved afterwards.</string>
<string name="edit">Edit</string>
</resources>