refactor: shared device-credential prompt for key access

rememberDeviceAuthenticator (commonsUI) replaces the app's authenticate()
helper: Android keeps BiometricPrompt with the keyguard fallback in its
actual; desktop and iOS approve at once for now, as Android does on a
device without a screen lock. UpdateZapAmountDialog no longer carries the
prompt code, and AccountBackupScreen asks through the port. commonsUI's
Android source set takes androidx.biometric (Apache-2.0, already shipped).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S7FuNBSKiyVecARSoE4B9P
This commit is contained in:
Claude
2026-09-30 04:31:21 +00:00
parent 9e4ca4a9c8
commit b8ad9c72f3
7 changed files with 315 additions and 163 deletions
@@ -20,14 +20,6 @@
*/ */
package com.vitorpamplona.amethyst.ui.note package com.vitorpamplona.amethyst.ui.note
import android.app.KeyguardManager
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.result.ActivityResult
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Spring import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
@@ -59,7 +51,6 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateMapOf
@@ -86,10 +77,6 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.add import com.vitorpamplona.amethyst.commons.resources.add
import com.vitorpamplona.amethyst.commons.resources.app_name
import com.vitorpamplona.amethyst.commons.resources.biometric_authentication_failed
import com.vitorpamplona.amethyst.commons.resources.biometric_authentication_failed_explainer
import com.vitorpamplona.amethyst.commons.resources.biometric_authentication_failed_explainer_with_error
import com.vitorpamplona.amethyst.commons.resources.new_amount_in_sats import com.vitorpamplona.amethyst.commons.resources.new_amount_in_sats
import com.vitorpamplona.amethyst.commons.resources.quick_zap_amounts import com.vitorpamplona.amethyst.commons.resources.quick_zap_amounts
import com.vitorpamplona.amethyst.commons.resources.quick_zap_amounts_explainer import com.vitorpamplona.amethyst.commons.resources.quick_zap_amounts_explainer
@@ -121,10 +108,8 @@ import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.commons.util.showAmount import com.vitorpamplona.amethyst.commons.util.showAmount
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.commons.viewmodels.mockAccountViewModel import com.vitorpamplona.amethyst.commons.viewmodels.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup.getFragmentActivity
import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
@@ -449,118 +434,3 @@ private fun ZapAmountPresetChip(
} }
} }
} }
/**
* Labels the device-credential and biometric prompts need. Resolved in
* composition and passed down because [authenticate] runs from an onClick
* lambda and from Android's own biometric callbacks, neither of which can
* reach a Compose resource.
*/
@Immutable
data class AuthPromptLabels(
val appName: String,
val failedTitle: String,
val failedExplainer: String,
val failedExplainerWithError: String,
)
@Composable
fun rememberAuthPromptLabels(): AuthPromptLabels =
AuthPromptLabels(
appName = stringRes(Res.string.app_name),
failedTitle = stringRes(Res.string.biometric_authentication_failed),
failedExplainer = stringRes(Res.string.biometric_authentication_failed_explainer),
failedExplainerWithError = stringRes(Res.string.biometric_authentication_failed_explainer_with_error),
)
fun authenticate(
title: String,
context: Context,
labels: AuthPromptLabels,
keyguardLauncher: ManagedActivityResultLauncher<Intent, ActivityResult>,
onApproved: () -> Unit,
onError: (String, String) -> Unit,
) {
val fragmentContext = context.getFragmentActivity()!!
val keyguardManager =
fragmentContext.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
if (!keyguardManager.isDeviceSecure) {
onApproved()
return
}
@Suppress("DEPRECATION")
fun keyguardPrompt() {
val intent =
keyguardManager.createConfirmDeviceCredentialIntent(
labels.appName,
title,
)
keyguardLauncher.launch(intent)
}
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
keyguardPrompt()
return
}
val biometricManager = BiometricManager.from(context)
val authenticators =
BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
val promptInfo =
BiometricPrompt.PromptInfo
.Builder()
.setTitle(labels.appName)
.setSubtitle(title)
.setAllowedAuthenticators(authenticators)
.build()
val biometricPrompt =
BiometricPrompt(
fragmentContext,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(
errorCode: Int,
errString: CharSequence,
) {
super.onAuthenticationError(errorCode, errString)
when (errorCode) {
BiometricPrompt.ERROR_NEGATIVE_BUTTON -> {
keyguardPrompt()
}
BiometricPrompt.ERROR_LOCKOUT -> {
keyguardPrompt()
}
else -> {
onError(
labels.failedTitle,
labels.failedExplainerWithError.format(errString.toString()),
)
}
}
}
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
onError(labels.failedTitle, labels.failedExplainer)
}
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
onApproved()
}
},
)
when (biometricManager.canAuthenticate(authenticators)) {
BiometricManager.BIOMETRIC_SUCCESS -> biometricPrompt.authenticate(promptInfo)
else -> keyguardPrompt()
}
}
@@ -20,16 +20,12 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup package com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup
import android.app.Activity
import android.content.ClipData import android.content.ClipData
import android.content.Context import android.content.Context
import android.content.ContextWrapper import android.content.ContextWrapper
import android.os.PersistableBundle import android.os.PersistableBundle
import android.view.WindowManager import android.view.WindowManager
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -136,14 +132,13 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes import com.vitorpamplona.amethyst.commons.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.ui.privacylock.rememberDeviceAuthenticator
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.qrcode.BackButton import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.qrcode.BackButton
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.qrcode.QrCodeDrawer import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow
import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel
import com.vitorpamplona.amethyst.commons.viewmodels.mockAccountViewModel import com.vitorpamplona.amethyst.commons.viewmodels.mockAccountViewModel
import com.vitorpamplona.amethyst.ui.note.authenticate
import com.vitorpamplona.amethyst.ui.note.rememberAuthPromptLabels
import com.vitorpamplona.quartz.nip19Bech32.toNsec import com.vitorpamplona.quartz.nip19Bech32.toNsec
import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49 import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -701,13 +696,6 @@ private class KeyAccessGate {
var isUnlocked by mutableStateOf(false) var isUnlocked by mutableStateOf(false)
private set private set
/**
* The action waiting on the keyguard fallback activity to return. Only a new request or
* that activity's result replaces it: the activity stops this screen (so ON_STOP must not
* clear it), and a wrong fingerprint before the lockout fallback is not a final failure.
*/
var pending: (() -> Unit)? = null
var prompt: ((onApproved: () -> Unit) -> Unit)? = null var prompt: ((onApproved: () -> Unit) -> Unit)? = null
fun withAccess(action: () -> Unit) { fun withAccess(action: () -> Unit) {
@@ -728,32 +716,15 @@ private class KeyAccessGate {
@Composable @Composable
private fun rememberKeyAccessGate(accountViewModel: AccountViewModel): KeyAccessGate { private fun rememberKeyAccessGate(accountViewModel: AccountViewModel): KeyAccessGate {
val context = LocalContext.current val authenticator = rememberDeviceAuthenticator()
val authLabels = rememberAuthPromptLabels()
val authTitle = stringRes(Res.string.backup_keys) val authTitle = stringRes(Res.string.backup_keys)
val gate = remember { KeyAccessGate() } val gate = remember { KeyAccessGate() }
val keyguardLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
val action = gate.pending
gate.pending = null
if (result.resultCode == Activity.RESULT_OK) {
action?.invoke()
}
}
SideEffect { SideEffect {
gate.prompt = { onApproved -> gate.prompt = { onApproved ->
gate.pending = onApproved authenticator.authenticate(
authenticate(
title = authTitle, title = authTitle,
context = context, onApproved = onApproved,
labels = authLabels,
keyguardLauncher = keyguardLauncher,
onApproved = {
gate.pending = null
onApproved()
},
onError = { title, message -> accountViewModel.toastManager.toast(title, message) }, onError = { title, message -> accountViewModel.toastManager.toast(title, message) },
) )
} }
+2
View File
@@ -216,6 +216,8 @@ kotlin {
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
// Camera permission prompt for TakePicture. Apache-2.0, already in the app. // Camera permission prompt for TakePicture. Apache-2.0, already in the app.
implementation(libs.accompanist.permissions) implementation(libs.accompanist.permissions)
// BiometricPrompt behind rememberDeviceAuthenticator. Apache-2.0, already in the app.
implementation(libs.androidx.biometric.ktx)
} }
} }
@@ -0,0 +1,207 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.privacylock
import android.app.Activity
import android.app.KeyguardManager
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.os.Build
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.fragment.app.FragmentActivity
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_name
import com.vitorpamplona.amethyst.commons.resources.biometric_authentication_failed
import com.vitorpamplona.amethyst.commons.resources.biometric_authentication_failed_explainer
import com.vitorpamplona.amethyst.commons.resources.biometric_authentication_failed_explainer_with_error
import com.vitorpamplona.amethyst.commons.ui.stringRes
/** The action waiting on the keyguard activity, which only its result may run or replace. */
private class PendingApproval {
var action: (() -> Unit)? = null
}
@Composable
actual fun rememberDeviceAuthenticator(): DeviceAuthenticator {
val context = LocalContext.current
val labels = rememberAuthPromptLabels()
val pending = remember { PendingApproval() }
val keyguardLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
val action = pending.action
pending.action = null
if (result.resultCode == Activity.RESULT_OK) {
action?.invoke()
}
}
return remember(context, labels, keyguardLauncher) {
DeviceAuthenticator { title, onApproved, onError ->
pending.action = onApproved
authenticate(
title = title,
context = context,
labels = labels,
keyguardLauncher = keyguardLauncher,
onApproved = {
pending.action = null
onApproved()
},
onError = onError,
)
}
}
}
private fun Context.findFragmentActivity(): FragmentActivity? {
var current: Context = this
while (current is ContextWrapper) {
if (current is FragmentActivity) return current
current = current.baseContext
}
return null
}
/**
* Labels the device-credential and biometric prompts need. Resolved in
* composition and passed down because [authenticate] runs from an onClick
* lambda and from Android's own biometric callbacks, neither of which can
* reach a Compose resource.
*/
@Immutable
private data class AuthPromptLabels(
val appName: String,
val failedTitle: String,
val failedExplainer: String,
val failedExplainerWithError: String,
)
@Composable
private fun rememberAuthPromptLabels(): AuthPromptLabels =
AuthPromptLabels(
appName = stringRes(Res.string.app_name),
failedTitle = stringRes(Res.string.biometric_authentication_failed),
failedExplainer = stringRes(Res.string.biometric_authentication_failed_explainer),
failedExplainerWithError = stringRes(Res.string.biometric_authentication_failed_explainer_with_error),
)
private fun authenticate(
title: String,
context: Context,
labels: AuthPromptLabels,
keyguardLauncher: ManagedActivityResultLauncher<Intent, ActivityResult>,
onApproved: () -> Unit,
onError: (String, String) -> Unit,
) {
val fragmentContext = context.findFragmentActivity() ?: return onApproved()
val keyguardManager =
fragmentContext.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
if (!keyguardManager.isDeviceSecure) {
onApproved()
return
}
@Suppress("DEPRECATION")
fun keyguardPrompt() {
val intent =
keyguardManager.createConfirmDeviceCredentialIntent(
labels.appName,
title,
)
keyguardLauncher.launch(intent)
}
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.Q) {
keyguardPrompt()
return
}
val biometricManager = BiometricManager.from(context)
val authenticators =
BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
val promptInfo =
BiometricPrompt.PromptInfo
.Builder()
.setTitle(labels.appName)
.setSubtitle(title)
.setAllowedAuthenticators(authenticators)
.build()
val biometricPrompt =
BiometricPrompt(
fragmentContext,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(
errorCode: Int,
errString: CharSequence,
) {
super.onAuthenticationError(errorCode, errString)
when (errorCode) {
BiometricPrompt.ERROR_NEGATIVE_BUTTON -> {
keyguardPrompt()
}
BiometricPrompt.ERROR_LOCKOUT -> {
keyguardPrompt()
}
else -> {
onError(
labels.failedTitle,
labels.failedExplainerWithError.format(errString.toString()),
)
}
}
}
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
onError(labels.failedTitle, labels.failedExplainer)
}
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
onApproved()
}
},
)
when (biometricManager.canAuthenticate(authenticators)) {
BiometricManager.BIOMETRIC_SUCCESS -> biometricPrompt.authenticate(promptInfo)
else -> keyguardPrompt()
}
}
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.privacylock
import androidx.compose.runtime.Composable
/** Asks the user to prove it is them (biometrics or the device PIN) before a sensitive action. */
fun interface DeviceAuthenticator {
/**
* Prompts with [title] and runs [onApproved] once the user passes. A device without a screen
* lock approves at once. [onError] gets a title and a message for a failed attempt.
*/
fun authenticate(
title: String,
onApproved: () -> Unit,
onError: (title: String, message: String) -> Unit,
)
}
/**
* The platform's device-credential prompt. Android shows BiometricPrompt (strong biometrics or
* the device credential) and falls back to the keyguard screen; desktop and iOS have no prompt
* wired yet and approve at once, as Android does on a device without a screen lock.
*/
@Composable
expect fun rememberDeviceAuthenticator(): DeviceAuthenticator
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.privacylock
import androidx.compose.runtime.Composable
private val ApproveAtOnce = DeviceAuthenticator { _, onApproved, _ -> onApproved() }
/** No device-credential prompt wired on this platform yet; see the common declaration. */
@Composable
actual fun rememberDeviceAuthenticator(): DeviceAuthenticator = ApproveAtOnce
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.privacylock
import androidx.compose.runtime.Composable
private val ApproveAtOnce = DeviceAuthenticator { _, onApproved, _ -> onApproved() }
/** No device-credential prompt wired on this platform yet; see the common declaration. */
@Composable
actual fun rememberDeviceAuthenticator(): DeviceAuthenticator = ApproveAtOnce