feat: Add dialog

This commit is contained in:
J-Jamet
2025-09-17 13:58:12 +02:00
parent d5c378ac85
commit 1e7e464e65
5 changed files with 244 additions and 78 deletions

View File

@@ -52,6 +52,28 @@ object EntrySelectionHelper {
private const val KEY_SEARCH_INFO = "com.kunzisoft.keepass.extra.SEARCH_INFO"
private const val KEY_REGISTER_INFO = "com.kunzisoft.keepass.extra.REGISTER_INFO"
/**
* Finish the activity by passing the result code and by locking the database if necessary
*/
fun Activity.setActivityResult(
lockDatabase: Boolean = false,
resultCode: Int,
data: Intent? = null,
) {
when (resultCode) {
Activity.RESULT_OK ->
this.setResult(resultCode, data)
Activity.RESULT_CANCELED ->
this.setResult(resultCode)
}
this.finish()
if (lockDatabase && PreferencesUtil.isAutofillCloseDatabaseEnable(this)) {
// Close the database
this.sendBroadcast(Intent(LOCK_ACTION))
}
}
/**
* Utility method to build a registerForActivityResult,
* Used recursively, close each activity with return data
@@ -63,19 +85,11 @@ object EntrySelectionHelper {
return this.registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
val resultCode = it.resultCode
if (resultCode == Activity.RESULT_OK) {
this.setResult(resultCode, dataTransformation(it.data))
}
if (resultCode == Activity.RESULT_CANCELED) {
this.setResult(Activity.RESULT_CANCELED)
}
this.finish()
if (lockDatabase && PreferencesUtil.isAutofillCloseDatabaseEnable(this)) {
// Close the database
this.sendBroadcast(Intent(LOCK_ACTION))
}
setActivityResult(
lockDatabase,
it.resultCode,
dataTransformation(it.data)
)
}
}

View File

@@ -27,12 +27,16 @@ import android.os.Bundle
import android.util.Log
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AlertDialog
import androidx.credentials.GetCredentialResponse
import androidx.credentials.exceptions.GetCredentialUnknownException
import androidx.credentials.provider.PendingIntentHandler
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.kunzisoft.keepass.R
import com.kunzisoft.keepass.activities.FileDatabaseSelectActivity
import com.kunzisoft.keepass.activities.GroupActivity
@@ -40,8 +44,10 @@ import com.kunzisoft.keepass.activities.legacy.DatabaseModeActivity
import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.addSpecialMode
import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.addTypeMode
import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.buildActivityResultLauncher
import com.kunzisoft.keepass.credentialprovider.EntrySelectionHelper.setActivityResult
import com.kunzisoft.keepass.credentialprovider.SpecialMode
import com.kunzisoft.keepass.credentialprovider.TypeMode
import com.kunzisoft.keepass.credentialprovider.passkey.data.AndroidPrivilegedApp
import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialCreationParameters
import com.kunzisoft.keepass.credentialprovider.passkey.data.PublicKeyCredentialUsageParameters
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.addAppOrigin
@@ -62,6 +68,7 @@ import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retri
import com.kunzisoft.keepass.credentialprovider.passkey.util.PasskeyHelper.retrieveSearchInfo
import com.kunzisoft.keepass.credentialprovider.passkey.util.PrivilegedAllowLists
import com.kunzisoft.keepass.credentialprovider.passkey.util.PrivilegedAllowLists.saveCustomPrivilegedApps
import com.kunzisoft.keepass.credentialprovider.viewmodel.PasskeyLauncherViewModel
import com.kunzisoft.keepass.database.ContextualDatabase
import com.kunzisoft.keepass.database.element.node.NodeIdUUID
import com.kunzisoft.keepass.database.helper.SearchHelper
@@ -69,6 +76,7 @@ import com.kunzisoft.keepass.model.AppOrigin
import com.kunzisoft.keepass.model.Passkey
import com.kunzisoft.keepass.model.RegisterInfo
import com.kunzisoft.keepass.model.SearchInfo
import com.kunzisoft.keepass.model.SignatureNotFoundException
import com.kunzisoft.keepass.settings.PreferencesUtil
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.launch
@@ -79,6 +87,7 @@ import java.util.UUID
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
class PasskeyLauncherActivity : DatabaseModeActivity() {
private val passkeyLauncherViewModel: PasskeyLauncherViewModel by viewModels()
private var mUsageParameters: PublicKeyCredentialUsageParameters? = null
private var mCreationParameters: PublicKeyCredentialCreationParameters? = null
private var mPasskey: Passkey? = null
@@ -87,11 +96,13 @@ class PasskeyLauncherActivity : DatabaseModeActivity() {
private var mBackupState: Boolean = false
private var mPasskeySelectionActivityResultLauncher: ActivityResultLauncher<Intent>? =
this.buildActivityResultLauncher(
lockDatabase = true,
dataTransformation = { intent ->
this.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
val intent = it.data
val resultCode = it.resultCode
// Build a new formatted response from the selection response
val responseIntent = Intent()
when (resultCode) {
RESULT_OK -> {
try {
Log.d(TAG, "Passkey selection result")
if (intent == null)
@@ -122,14 +133,32 @@ class PasskeyLauncherActivity : DatabaseModeActivity() {
} ?: run {
throw IOException("Usage parameters is null")
}
setActivityResult(
lockDatabase = passkeyLauncherViewModel.lockDatabase,
resultCode = RESULT_OK,
data = responseIntent
)
} catch (e: SignatureNotFoundException) {
passkeyLauncherViewModel.showAppSignatureDialog(e.temptingApp)
} catch (e: Exception) {
Log.e(TAG, "Unable to create selection response for passkey", e)
showError(e)
}
// Return the response
responseIntent
}
setActivityResult(
lockDatabase = passkeyLauncherViewModel.lockDatabase,
resultCode = RESULT_CANCELED
)
}
}
RESULT_CANCELED -> {
setActivityResult(
lockDatabase = passkeyLauncherViewModel.lockDatabase,
resultCode = RESULT_CANCELED
)
}
}
// Remove the launcher register
passkeyLauncherViewModel.isResultLauncherRegistered = false
}
private var mPasskeyRegistrationActivityResultLauncher: ActivityResultLauncher<Intent>? =
this.buildActivityResultLauncher(
@@ -177,6 +206,24 @@ class PasskeyLauncherActivity : DatabaseModeActivity() {
super.onCreate(savedInstanceState)
mBackupEligibility = PreferencesUtil.isPasskeyBackupEligibilityEnable(applicationContext)
mBackupState = PreferencesUtil.isPasskeyBackupStateEnable(applicationContext)
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
passkeyLauncherViewModel.uiState.collect { uiState ->
when (uiState) {
is PasskeyLauncherViewModel.UIState.Loading -> {
// Nothing to do
}
is PasskeyLauncherViewModel.UIState.ShowAppPrivilegedDialog -> {
showAppPrivilegedDialog(uiState.temptingApp)
}
is PasskeyLauncherViewModel.UIState.ShowAppSignatureDialog -> {
showAppSignatureDialog( uiState.temptingApp)
}
}
}
}
}
}
private fun cancelRequest() {
@@ -194,6 +241,7 @@ class PasskeyLauncherActivity : DatabaseModeActivity() {
* Launch the main action to manage Passkey
*/
private suspend fun launchPasskeyAction(database: ContextualDatabase?) {
passkeyLauncherViewModel.isResultLauncherRegistered = true
val searchInfo = intent.retrieveSearchInfo() ?: SearchInfo()
val appOrigin = intent.retrieveAppOrigin() ?: AppOrigin(verified = false)
val nodeId = intent.retrieveNodeId()
@@ -216,7 +264,7 @@ class PasskeyLauncherActivity : DatabaseModeActivity() {
/**
* Display a dialog that asks the user to add an app to the list of privileged apps.
*/
private fun showAppPrivilegedDialog(e: PrivilegedAllowLists.PrivilegedException) {
private fun showAppPrivilegedDialog(temptingApp: AndroidPrivilegedApp) {
Log.w(javaClass.simpleName, "No privileged apps file found")
AlertDialog.Builder(this@PasskeyLauncherActivity).apply {
setTitle(getString(R.string.passkeys_privileged_apps_ask_title))
@@ -224,7 +272,7 @@ class PasskeyLauncherActivity : DatabaseModeActivity() {
.append(
getString(
R.string.passkeys_privileged_apps_ask_message,
e.temptingApp.toString()
temptingApp.toString()
)
)
.append("\n\n")
@@ -237,7 +285,7 @@ class PasskeyLauncherActivity : DatabaseModeActivity() {
}) {
saveCustomPrivilegedApps(
context = application,
privilegedApps = listOf(e.temptingApp)
privilegedApps = listOf(temptingApp)
)
launchPasskeyAction(mDatabase)
}
@@ -251,18 +299,65 @@ class PasskeyLauncherActivity : DatabaseModeActivity() {
}.create().show()
}
/**
* Display a dialog that asks the user to add an app signature in an existing passkey
*/
private fun showAppSignatureDialog(appOrigin: AppOrigin) {
AlertDialog.Builder(this@PasskeyLauncherActivity).apply {
setTitle(getString(R.string.passkeys_missing_signature_app_ask_title))
setMessage(StringBuilder()
.append(
getString(
R.string.passkeys_missing_signature_app_ask_message,
appOrigin.toName()
)
)
.append("\n\n")
.append(getString(R.string.passkeys_missing_signature_app_ask_explanation))
.toString()
)
setPositiveButton(android.R.string.ok) { _, _ ->
lifecycleScope.launch(CoroutineExceptionHandler { _, e ->
cancelRequest(e)
}) {
// TODO
setActivityResult(
lockDatabase = passkeyLauncherViewModel.lockDatabase,
resultCode = RESULT_OK
)
}
}
setNegativeButton(android.R.string.cancel) { _, _ ->
setActivityResult(
lockDatabase = passkeyLauncherViewModel.lockDatabase,
resultCode = RESULT_CANCELED
)
}
setOnCancelListener {
setActivityResult(
lockDatabase = passkeyLauncherViewModel.lockDatabase,
resultCode = RESULT_CANCELED
)
}
}.create().show()
}
override fun onDatabaseRetrieved(database: ContextualDatabase?) {
super.onDatabaseRetrieved(database)
if (passkeyLauncherViewModel.isResultLauncherRegistered.not()) {
lifecycleScope.launch(CoroutineExceptionHandler { _, e ->
when (e) {
is PrivilegedAllowLists.PrivilegedException -> showAppPrivilegedDialog(e)
is PrivilegedAllowLists.PrivilegedException -> {
passkeyLauncherViewModel.showAppPrivilegedDialog(e.temptingApp)
}
else -> cancelRequest(e)
}
}) {
launchPasskeyAction(database)
}
}
}
private fun autoSelectPasskeyAndSetResult(
database: ContextualDatabase?,
@@ -278,6 +373,7 @@ class PasskeyLauncherActivity : DatabaseModeActivity() {
?: throw GetCredentialUnknownException("No passkey with nodeId $nodeId found")
val result = Intent()
try {
PendingIntentHandler.setGetCredentialResponse(
result,
GetCredentialResponse(
@@ -293,12 +389,20 @@ class PasskeyLauncherActivity : DatabaseModeActivity() {
)
)
)
setResult(RESULT_OK, result)
finish()
setActivityResult(
lockDatabase = passkeyLauncherViewModel.lockDatabase,
resultCode = RESULT_OK,
data = result
)
} catch (e: SignatureNotFoundException) {
passkeyLauncherViewModel.showAppSignatureDialog(e.temptingApp)
}
} ?: run {
Log.e(TAG, "Unable to auto select passkey, usage parameters are empty")
setResult(RESULT_CANCELED)
finish()
setActivityResult(
lockDatabase = passkeyLauncherViewModel.lockDatabase,
resultCode = RESULT_CANCELED
)
}
}

View File

@@ -0,0 +1,34 @@
package com.kunzisoft.keepass.credentialprovider.viewmodel
import androidx.lifecycle.ViewModel
import com.kunzisoft.keepass.credentialprovider.passkey.data.AndroidPrivilegedApp
import com.kunzisoft.keepass.model.AppOrigin
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class PasskeyLauncherViewModel: ViewModel() {
var isResultLauncherRegistered: Boolean = false
val lockDatabase = true
private val _uiState = MutableStateFlow<UIState>(UIState.Loading)
val uiState: StateFlow<UIState> = _uiState
fun showAppPrivilegedDialog(temptingApp: AndroidPrivilegedApp) {
_uiState.value = UIState.ShowAppPrivilegedDialog(temptingApp)
}
fun showAppSignatureDialog(temptingApp: AppOrigin) {
_uiState.value = UIState.ShowAppSignatureDialog(temptingApp)
}
sealed class UIState {
object Loading : UIState()
data class ShowAppPrivilegedDialog(
val temptingApp: AndroidPrivilegedApp
): UIState()
data class ShowAppSignatureDialog(
val temptingApp: AppOrigin
): UIState()
}
}

View File

@@ -426,7 +426,10 @@
<string name="passkeys_privileged_apps_summary">Manage browsers in the custom list of privileged apps</string>
<string name="passkeys_privileged_apps_explanation">WARNING: A privileged app acts as a gateway to retrieve the origin of an authentication. Ensure its legitimacy to avoid security issues.</string>
<string name="passkeys_privileged_apps_ask_title">App not recognized</string>
<string name="passkeys_privileged_apps_ask_message">%1$s attempts to perform a Passkey action.\n\nWould you like to add it to the list of privileged apps?</string>
<string name="passkeys_privileged_apps_ask_message">%1$s attempts to perform a Passkey action.\n\nAdd it to the list of privileged apps?</string>
<string name="passkeys_missing_signature_app_ask_title">Signature missing</string>
<string name="passkeys_missing_signature_app_ask_explanation">WARNING: The passkey was created from another client or the signature has been deleted. Ensure the app you want to authenticate is part of the same service and is legitimate to avoid security issues.</string>
<string name="passkeys_missing_signature_app_ask_message">%1$s with an unknown signature attempts to authenticate with an existing passkey.\n\nAdd app signature to passkey entry?</string>
<string name="passkeys_backup_eligibility_title">Backup Eligibility</string>
<string name="passkeys_backup_eligibility_summary">Determine at creation time whether the public key credential source is allowed to be backed up</string>
<string name="passkeys_backup_state_title">Backup State</string>

View File

@@ -49,6 +49,9 @@ data class AppOrigin(
* return the first verified origin or throw an exception if none is found
*/
fun checkAppOrigin(compare: AppOrigin): String {
if (compare.androidOrigins.isNotEmpty()) {
throw SignatureNotFoundException(this, "Android origin not found")
}
return androidOrigins.firstOrNull { androidOrigin ->
compare.androidOrigins.any {
it.packageName == androidOrigin.packageName
@@ -100,6 +103,14 @@ data class AppOrigin(
}
}
/**
* Exception indicating that no signature is present for the Android origin
*/
class SignatureNotFoundException(
val temptingApp: AppOrigin,
message: String
) : Exception(message)
/**
* Represents an Android app origin, the [packageName] is the applicationId of the app
* and the [fingerprint] is the