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 android.util.Log
import androidx.room.* import androidx.room.*
import java.security.KeyPairGenerator import java.security.KeyPairGenerator
import java.security.KeyStore
object Identities { object Identities {
@Entity @Entity
@ -43,6 +44,9 @@ object Identities {
@Query("SELECT * FROM IdentityUsage WHERE :identityId = identityId") @Query("SELECT * FROM IdentityUsage WHERE :identityId = identityId")
suspend fun getUsagesFor(identityId: Int): List<IdentityUsage> suspend fun getUsagesFor(identityId: Int): List<IdentityUsage>
@Delete
suspend fun delete(vararg identities: Identity)
} }
suspend fun insert(key: String, name: String? = null): Long = suspend fun insert(key: String, name: String? = null): Long =
@ -57,6 +61,14 @@ object Identities {
suspend fun update(vararg identities: Identity) = suspend fun update(vararg identities: Identity) =
Database.INSTANCE.identityDao().update(*identities) 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) { fun generateClientCert(alias: String) {
val algo = KeyProperties.KEY_ALGORITHM_RSA val algo = KeyProperties.KEY_ALGORITHM_RSA
val kpg = KeyPairGenerator.getInstance(algo, "AndroidKeyStore") val kpg = KeyPairGenerator.getInstance(algo, "AndroidKeyStore")
@ -69,5 +81,16 @@ object Identities {
Log.i(TAG, "generateClientCert: key pair with alias \"$alias\" has been generated") 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" private const val TAG = "Identities"
} }

View file

@ -2,6 +2,7 @@ package dev.lowrespalmtree.comet
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
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
@ -14,6 +15,7 @@ class IdentitiesAdapter(private val listener: Listener) :
interface Listener { interface Listener {
fun onIdentityClick(identity: Identity) fun onIdentityClick(identity: Identity)
fun onIdentityLongClick(identity: Identity, view: View)
} }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder = override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder =
@ -29,7 +31,13 @@ class IdentitiesAdapter(private val listener: Listener) :
val item = identities[position] val item = identities[position]
holder.binding.labelText.text = item.name.orEmpty() holder.binding.labelText.text = item.name.orEmpty()
holder.binding.keyText.text = item.key 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 override fun getItemCount(): Int = identities.size

View file

@ -4,6 +4,7 @@ import android.os.Bundle
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 android.widget.PopupMenu
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
@ -13,6 +14,7 @@ import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import dev.lowrespalmtree.comet.Identities.Identity import dev.lowrespalmtree.comet.Identities.Identity
import dev.lowrespalmtree.comet.databinding.FragmentIdentitiesBinding import dev.lowrespalmtree.comet.databinding.FragmentIdentitiesBinding
import dev.lowrespalmtree.comet.utils.confirm
import dev.lowrespalmtree.comet.utils.toast import dev.lowrespalmtree.comet.utils.toast
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -50,6 +52,32 @@ class IdentitiesFragment : Fragment(), IdentitiesAdapter.Listener, IdentityDialo
IdentityDialog(requireContext(), identity, this).show() 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) { override fun onSaveIdentity(identity: Identity) {
vm.saveIdentity(identity) vm.saveIdentity(identity)
} }
@ -57,7 +85,10 @@ class IdentitiesFragment : Fragment(), IdentitiesAdapter.Listener, IdentityDialo
private fun openNewIdentityEditor() { private fun openNewIdentityEditor() {
toast(requireContext(), R.string.generating_keypair) toast(requireContext(), R.string.generating_keypair)
vm.newIdentity.observe(viewLifecycleOwner) { identity -> vm.newIdentity.observe(viewLifecycleOwner) { identity ->
if (identity == null)
return@observe
vm.newIdentity.removeObservers(viewLifecycleOwner) vm.newIdentity.removeObservers(viewLifecycleOwner)
vm.newIdentity.value = null
IdentityDialog(requireContext(), identity, this).show() IdentityDialog(requireContext(), identity, this).show()
} }
vm.createNewIdentity() vm.createNewIdentity()
@ -74,16 +105,25 @@ class IdentitiesFragment : Fragment(), IdentitiesAdapter.Listener, IdentityDialo
val newIdentityId = Identities.insert(alias) val newIdentityId = Identities.insert(alias)
newIdentity.postValue(Identities.get(newIdentityId)) newIdentity.postValue(Identities.get(newIdentityId))
} }
.invokeOnCompletion { refreshIdentities() }
} }
fun refreshIdentities() { fun refreshIdentities() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) { identities.postValue(Identities.getAll()) }
identities.postValue(Identities.getAll())
}
} }
fun saveIdentity(identity: Identity) { fun saveIdentity(identity: Identity) {
viewModelScope.launch(Dispatchers.IO) { Identities.update(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() { fun show() {
binding = DialogIdentityBinding.inflate(LayoutInflater.from(context)) binding = DialogIdentityBinding.inflate(LayoutInflater.from(context))
binding.labelInput.setText(identity.name) binding.labelInput.setText(identity.name.orEmpty())
binding.aliasText.text = identity.key binding.aliasText.text = identity.key
AlertDialog.Builder(context) AlertDialog.Builder(context)
.setTitle(R.string.edit_identity) .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 package dev.lowrespalmtree.comet.utils
import android.app.AlertDialog
import android.content.Context import android.content.Context
import android.util.TypedValue import android.util.TypedValue
import android.view.LayoutInflater
import android.widget.Toast import android.widget.Toast
import androidx.annotation.AttrRes import androidx.annotation.AttrRes
import androidx.annotation.StringRes
import dev.lowrespalmtree.comet.R import dev.lowrespalmtree.comet.R
import dev.lowrespalmtree.comet.databinding.DialogConfirmBinding
fun toast(context: Context, stringId: Int, length: Int = Toast.LENGTH_SHORT) = fun toast(context: Context, stringId: Int, length: Int = Toast.LENGTH_SHORT) =
Toast.makeText(context, stringId, length).show() Toast.makeText(context, stringId, length).show()
@ -16,3 +20,14 @@ fun getDrawableFromAttr(context: Context, @AttrRes attr: Int) =
fun getFancySelectBgRes(context: Context) = fun getFancySelectBgRes(context: Context) =
getDrawableFromAttr(context, R.attr.selectableItemBackground) 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 <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"

View file

@ -14,7 +14,7 @@
<color name="url_bar">#002b36</color> <color name="url_bar">#002b36</color>
<color name="url_bar_loading">#fdf6e3</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 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 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 base02 #073642 0/4 black 235 #262626 20 -12 -12 7 54 66 192 90 26

View file

@ -1,5 +1,7 @@
<resources> <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="error_alert_title">Error</string>
<string name="url">URL</string> <string name="url">URL</string>
<string name="settings">Settings</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="pref_home_set">Set last visited page as home page</string>
<string name="no_current_url">No current URL.</string> <string name="no_current_url">No current URL.</string>
<!-- Preference Protocol --> <!-- Preference Protocol -->
<string name="pref_protocol_header">Protocol</string> <string name="pref_protocol_header">Protocol</string>
<string name="pref_connection_timeout_title">Connection timeout (seconds)</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="generating_keypair">Generating client certificate…</string>
<string name="identity_alias_title">Keystore reference</string> <string name="identity_alias_title">Keystore reference</string>
<string name="edit_identity">Edit identity</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> </resources>