diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt index d2f83d0952..397193c7a7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt @@ -20,14 +20,6 @@ */ 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.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.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue import androidx.compose.runtime.key 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.resources.Res 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.quick_zap_amounts 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.viewmodels.AccountViewModel import com.vitorpamplona.amethyst.commons.viewmodels.mockAccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup.getFragmentActivity import com.vitorpamplona.quartz.nip57Zaps.ZapReceiptEvent import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.launch @OptIn(ExperimentalLayoutApi::class) @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, - 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() - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt index cee705213a..f8652808a3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt @@ -20,16 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup -import android.app.Activity import android.content.ClipData import android.content.Context import android.content.ContextWrapper import android.os.PersistableBundle import android.view.WindowManager 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.foundation.background 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.topbars.TopBarWithBackButton 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.QrCodeDrawer import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow import com.vitorpamplona.amethyst.commons.viewmodels.AccountViewModel 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.nip49PrivKeyEnc.Nip49 import kotlinx.coroutines.CoroutineScope @@ -701,13 +696,6 @@ private class KeyAccessGate { var isUnlocked by mutableStateOf(false) 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 fun withAccess(action: () -> Unit) { @@ -728,32 +716,15 @@ private class KeyAccessGate { @Composable private fun rememberKeyAccessGate(accountViewModel: AccountViewModel): KeyAccessGate { - val context = LocalContext.current - val authLabels = rememberAuthPromptLabels() + val authenticator = rememberDeviceAuthenticator() val authTitle = stringRes(Res.string.backup_keys) 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 { gate.prompt = { onApproved -> - gate.pending = onApproved - authenticate( + authenticator.authenticate( title = authTitle, - context = context, - labels = authLabels, - keyguardLauncher = keyguardLauncher, - onApproved = { - gate.pending = null - onApproved() - }, + onApproved = onApproved, onError = { title, message -> accountViewModel.toastManager.toast(title, message) }, ) } diff --git a/commonsUI/build.gradle.kts b/commonsUI/build.gradle.kts index 1551fcaeff..d592bac103 100644 --- a/commonsUI/build.gradle.kts +++ b/commonsUI/build.gradle.kts @@ -216,6 +216,8 @@ kotlin { implementation(libs.androidx.activity.compose) // Camera permission prompt for TakePicture. Apache-2.0, already in the app. implementation(libs.accompanist.permissions) + // BiometricPrompt behind rememberDeviceAuthenticator. Apache-2.0, already in the app. + implementation(libs.androidx.biometric.ktx) } } diff --git a/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/DeviceAuthenticator.android.kt b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/DeviceAuthenticator.android.kt new file mode 100644 index 0000000000..d4ef5180a1 --- /dev/null +++ b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/DeviceAuthenticator.android.kt @@ -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, + 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() + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/DeviceAuthenticator.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/DeviceAuthenticator.kt new file mode 100644 index 0000000000..c06fee8655 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/DeviceAuthenticator.kt @@ -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 diff --git a/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/DeviceAuthenticator.ios.kt b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/DeviceAuthenticator.ios.kt new file mode 100644 index 0000000000..ab75c29bcc --- /dev/null +++ b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/DeviceAuthenticator.ios.kt @@ -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 diff --git a/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/DeviceAuthenticator.jvm.kt b/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/DeviceAuthenticator.jvm.kt new file mode 100644 index 0000000000..ab75c29bcc --- /dev/null +++ b/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/privacylock/DeviceAuthenticator.jvm.kt @@ -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