commit 1d7b35ffa2d4423f87b1861e609ff168b9d1dcc0
parent 1b5aca4447239c625c6c73ae3ee783e4e37b2978
Author: triesap <tyson@radroots.org>
Date: Sat, 21 Mar 2026 19:08:06 +0000
android: require auth before revealing recovery key
- add an async home-action poll path in app core so Android backup reveal can complete after system auth
- start Android device authentication before the backup action reveals the current nsec
- add Kotlin bridge support for biometric or device-credential verification and result polling
- keep the Android host build and emulator launch green with the new security flow
Diffstat:
7 files changed, 498 insertions(+), 13 deletions(-)
diff --git a/crates/android/src/lib.rs b/crates/android/src/lib.rs
@@ -80,12 +80,13 @@ impl RadrootsAppBackend for AndroidBackend {
fn home_action_states(&self) -> Vec<HomeActionState> {
#[cfg(target_os = "android")]
{
+ let recovery_key_export_pending = Self::recovery_key_export_pending();
return vec![
HomeActionState {
kind: HomeActionKind::BackupRecoveryKey,
label: "Back Up Recovery Key".to_owned(),
- enabled: true,
- pending: false,
+ enabled: !recovery_key_export_pending,
+ pending: recovery_key_export_pending,
},
HomeActionState {
kind: HomeActionKind::RemoveLocalKey,
@@ -111,15 +112,17 @@ impl RadrootsAppBackend for AndroidBackend {
fn request_home_action(&self, action: HomeActionKind) -> Result<HomeActionResult, String> {
#[cfg(target_os = "android")]
{
- let manager = Self::accounts_manager()?;
return match action {
HomeActionKind::BackupRecoveryKey => {
- Self::export_selected_local_recovery_key(&manager)
- .map(|nsec| HomeActionResult::RevealRecoveryKey { nsec })
+ Self::begin_recovery_key_export().map(|()| HomeActionResult::None)
+ }
+ HomeActionKind::RemoveLocalKey => {
+ let manager = Self::accounts_manager()?;
+ Self::remove_selected_local_identity(&manager)
+ .map(HomeActionResult::IdentityState)
}
- HomeActionKind::RemoveLocalKey => Self::remove_selected_local_identity(&manager)
- .map(HomeActionResult::IdentityState),
HomeActionKind::ResetDevice => {
+ let manager = Self::accounts_manager()?;
let accounts_path = storage::accounts_path()?;
Self::reset_local_device_state(&manager, accounts_path.as_path())
.map(HomeActionResult::IdentityState)
@@ -134,6 +137,18 @@ impl RadrootsAppBackend for AndroidBackend {
Ok(HomeActionResult::None)
}
}
+
+ fn poll_home_action_result(&self) -> Result<Option<HomeActionResult>, String> {
+ #[cfg(target_os = "android")]
+ {
+ return Self::poll_recovery_key_export();
+ }
+
+ #[cfg(not(target_os = "android"))]
+ {
+ Ok(None)
+ }
+ }
}
#[cfg(any(target_os = "android", test))]
@@ -222,6 +237,47 @@ impl AndroidBackend {
Ok(identity.nsec())
}
+ #[cfg(target_os = "android")]
+ fn begin_recovery_key_export() -> Result<(), String> {
+ security::begin_user_presence_verification("reveal the current recovery key")
+ .map_err(|source| source.to_string())
+ }
+
+ #[cfg(not(target_os = "android"))]
+ fn begin_recovery_key_export() -> Result<(), String> {
+ Ok(())
+ }
+
+ #[cfg(target_os = "android")]
+ fn recovery_key_export_pending() -> bool {
+ security::is_user_presence_verification_pending().unwrap_or(false)
+ }
+
+ #[cfg(not(target_os = "android"))]
+ fn recovery_key_export_pending() -> bool {
+ false
+ }
+
+ #[cfg(target_os = "android")]
+ fn poll_recovery_key_export() -> Result<Option<HomeActionResult>, String> {
+ match security::take_user_presence_verification_result()
+ .map_err(|source| source.to_string())?
+ {
+ Some(security::AndroidUserPresenceVerificationResult::Verified) => {
+ let manager = Self::accounts_manager()?;
+ Self::export_selected_local_recovery_key(&manager)
+ .map(|nsec| Some(HomeActionResult::RevealRecoveryKey { nsec }))
+ }
+ Some(security::AndroidUserPresenceVerificationResult::Failed(message)) => Err(message),
+ None => Ok(None),
+ }
+ }
+
+ #[cfg(not(target_os = "android"))]
+ fn poll_recovery_key_export() -> Result<Option<HomeActionResult>, String> {
+ Ok(None)
+ }
+
fn remove_selected_local_identity(
manager: &RadrootsNostrAccountsManager,
) -> Result<IdentityGateState, String> {
diff --git a/crates/android/src/security.rs b/crates/android/src/security.rs
@@ -39,6 +39,34 @@ impl AndroidSecretStatus {
}
}
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) enum AndroidUserPresenceVerificationResult {
+ Verified,
+ Failed(String),
+}
+
+#[cfg(target_os = "android")]
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum AndroidUserPresenceResultStatus {
+ None,
+ Success,
+ Error,
+}
+
+#[cfg(target_os = "android")]
+impl AndroidUserPresenceResultStatus {
+ fn from_raw(value: i32) -> Result<Self, RadrootsNostrAccountsError> {
+ match value {
+ 0 => Ok(Self::None),
+ 1 => Ok(Self::Success),
+ 2 => Ok(Self::Error),
+ other => Err(RadrootsNostrAccountsError::Vault(format!(
+ "unknown android user presence status {other}"
+ ))),
+ }
+ }
+}
+
#[cfg(target_os = "android")]
pub(crate) fn store_secret(
service: &str,
@@ -252,6 +280,117 @@ pub(crate) fn resolve_nostr_storage_root() -> Result<PathBuf, RadrootsNostrAccou
Ok(PathBuf::from(path))
}
+#[cfg(target_os = "android")]
+pub(crate) fn begin_user_presence_verification(
+ reason: &str,
+) -> Result<(), RadrootsNostrAccountsError> {
+ let java_vm = android_java_vm()?;
+ let mut env = java_vm.attach_current_thread().map_err(jni_error)?;
+ let bridge_class = bridge_class(&mut env)?;
+ let reason = java_string_arg(&mut env, reason)?;
+
+ let status = env
+ .call_static_method(
+ &bridge_class,
+ "beginUserPresenceVerification",
+ "(Ljava/lang/String;)I",
+ &[JValue::Object(&reason)],
+ )
+ .and_then(|value| value.i())
+ .map_err(jni_error)?;
+
+ match AndroidSecretStatus::from_raw(status)? {
+ AndroidSecretStatus::Success => Ok(()),
+ AndroidSecretStatus::NotFound => Err(bridge_vault_error(
+ &mut env,
+ &bridge_class,
+ "android security bridge reported no user presence result",
+ )),
+ AndroidSecretStatus::InvalidInput => Err(bridge_vault_error(
+ &mut env,
+ &bridge_class,
+ "android security bridge rejected the user presence request",
+ )),
+ AndroidSecretStatus::Error => Err(bridge_vault_error(
+ &mut env,
+ &bridge_class,
+ "android user presence verification failed to start",
+ )),
+ }
+}
+
+#[cfg(not(target_os = "android"))]
+pub(crate) fn begin_user_presence_verification(
+ reason: &str,
+) -> Result<(), RadrootsNostrAccountsError> {
+ let _ = reason;
+ Err(RadrootsNostrAccountsError::Vault(
+ "android user presence verification is only available on android".to_owned(),
+ ))
+}
+
+#[cfg(target_os = "android")]
+pub(crate) fn is_user_presence_verification_pending() -> Result<bool, RadrootsNostrAccountsError> {
+ let java_vm = android_java_vm()?;
+ let mut env = java_vm.attach_current_thread().map_err(jni_error)?;
+ let bridge_class = bridge_class(&mut env)?;
+
+ env.call_static_method(
+ &bridge_class,
+ "isUserPresenceVerificationPending",
+ "()Z",
+ &[],
+ )
+ .and_then(|value| value.z())
+ .map_err(jni_error)
+}
+
+#[cfg(not(target_os = "android"))]
+pub(crate) fn is_user_presence_verification_pending() -> Result<bool, RadrootsNostrAccountsError> {
+ Err(RadrootsNostrAccountsError::Vault(
+ "android user presence verification is only available on android".to_owned(),
+ ))
+}
+
+#[cfg(target_os = "android")]
+pub(crate) fn take_user_presence_verification_result()
+-> Result<Option<AndroidUserPresenceVerificationResult>, RadrootsNostrAccountsError> {
+ let java_vm = android_java_vm()?;
+ let mut env = java_vm.attach_current_thread().map_err(jni_error)?;
+ let bridge_class = bridge_class(&mut env)?;
+
+ let status = env
+ .call_static_method(
+ &bridge_class,
+ "takeUserPresenceVerificationResult",
+ "()I",
+ &[],
+ )
+ .and_then(|value| value.i())
+ .map_err(jni_error)?;
+
+ match AndroidUserPresenceResultStatus::from_raw(status)? {
+ AndroidUserPresenceResultStatus::None => Ok(None),
+ AndroidUserPresenceResultStatus::Success => {
+ Ok(Some(AndroidUserPresenceVerificationResult::Verified))
+ }
+ AndroidUserPresenceResultStatus::Error => {
+ Ok(Some(AndroidUserPresenceVerificationResult::Failed(
+ take_last_error_message(&mut env, &bridge_class)?
+ .unwrap_or_else(|| "android device authentication failed".to_owned()),
+ )))
+ }
+ }
+}
+
+#[cfg(not(target_os = "android"))]
+pub(crate) fn take_user_presence_verification_result()
+-> Result<Option<AndroidUserPresenceVerificationResult>, RadrootsNostrAccountsError> {
+ Err(RadrootsNostrAccountsError::Vault(
+ "android user presence verification is only available on android".to_owned(),
+ ))
+}
+
#[cfg(not(target_os = "android"))]
#[allow(dead_code)]
pub(crate) fn resolve_nostr_storage_root() -> Result<PathBuf, RadrootsNostrAccountsError> {
diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs
@@ -67,6 +67,9 @@ pub trait RadrootsAppBackend {
fn request_home_action(&self, _action: HomeActionKind) -> Result<HomeActionResult, String> {
Ok(HomeActionResult::None)
}
+ fn poll_home_action_result(&self) -> Result<Option<HomeActionResult>, String> {
+ Ok(None)
+ }
fn poll_identity_state(&self) -> Result<Option<IdentityGateState>, String> {
Ok(None)
}
@@ -169,18 +172,24 @@ impl RadrootsApp {
self.status_message = None;
self.revealed_recovery_key = None;
match self.backend.request_home_action(action) {
- Ok(HomeActionResult::IdentityState(state)) => self.apply_identity_state(state),
- Ok(HomeActionResult::RevealRecoveryKey { nsec }) => {
- self.revealed_recovery_key = Some(nsec);
- self.pending_home_confirmation = None;
- }
- Ok(HomeActionResult::None) => {}
+ Ok(result) => self.apply_home_action_result(result),
Err(err) => {
self.status_message = Some(err);
}
}
}
+ fn apply_home_action_result(&mut self, result: HomeActionResult) {
+ match result {
+ HomeActionResult::IdentityState(state) => self.apply_identity_state(state),
+ HomeActionResult::RevealRecoveryKey { nsec } => {
+ self.revealed_recovery_key = Some(nsec);
+ self.pending_home_confirmation = None;
+ }
+ HomeActionResult::None => {}
+ }
+ }
+
fn home_action_requires_confirmation(action: HomeActionKind) -> bool {
!matches!(action, HomeActionKind::BackupRecoveryKey)
}
@@ -203,6 +212,13 @@ impl RadrootsApp {
}
fn sync_backend(&mut self) {
+ match self.backend.poll_home_action_result() {
+ Ok(Some(result)) => self.apply_home_action_result(result),
+ Ok(None) => {}
+ Err(err) => {
+ self.status_message = Some(err);
+ }
+ }
match self.backend.poll_identity_state() {
Ok(Some(state)) => self.apply_identity_state(state),
Ok(None) => {}
@@ -380,6 +396,7 @@ mod tests {
request: Rc<RefCell<VecDeque<Result<Option<IdentityGateState>, String>>>>,
recovery_request: Rc<RefCell<VecDeque<Result<Option<IdentityGateState>, String>>>>,
home_request: Rc<RefCell<VecDeque<(HomeActionKind, Result<HomeActionResult, String>)>>>,
+ home_poll: Rc<RefCell<VecDeque<Result<Option<HomeActionResult>, String>>>>,
poll: Rc<RefCell<VecDeque<Result<Option<IdentityGateState>, String>>>>,
}
@@ -398,6 +415,7 @@ mod tests {
request: Rc::new(RefCell::new(request.into())),
recovery_request: Rc::new(RefCell::new(VecDeque::new())),
home_request: Rc::new(RefCell::new(VecDeque::new())),
+ home_poll: Rc::new(RefCell::new(VecDeque::new())),
poll: Rc::new(RefCell::new(poll.into())),
}
}
@@ -427,6 +445,14 @@ mod tests {
);
self
}
+
+ fn with_home_action_poll(
+ self,
+ poll: Vec<Result<Option<HomeActionResult>, String>>,
+ ) -> Self {
+ self.home_poll.borrow_mut().extend(poll);
+ self
+ }
}
impl RadrootsAppBackend for MockBackend {
@@ -477,6 +503,10 @@ mod tests {
response
}
+ fn poll_home_action_result(&self) -> Result<Option<HomeActionResult>, String> {
+ self.home_poll.borrow_mut().pop_front().unwrap_or(Ok(None))
+ }
+
fn poll_identity_state(&self) -> Result<Option<IdentityGateState>, String> {
self.poll.borrow_mut().pop_front().unwrap_or(Ok(None))
}
@@ -824,4 +854,44 @@ mod tests {
assert_eq!(app.pending_home_confirmation, None);
assert_eq!(app.revealed_recovery_key.as_deref(), Some("nsec1example"));
}
+
+ #[test]
+ fn deferred_backup_home_action_reveals_recovery_key_after_poll() {
+ let mut app = RadrootsApp::new(Box::new(
+ MockBackend::new(
+ Ok(IdentityGateState::Ready {
+ account_id: "abc".into(),
+ npub: "npub1abc".into(),
+ }),
+ vec![],
+ vec![],
+ SetupActionState {
+ label: "Generate New Key".into(),
+ enabled: true,
+ pending: false,
+ },
+ )
+ .with_home_action(
+ HomeActionState {
+ kind: HomeActionKind::BackupRecoveryKey,
+ label: "Back Up Recovery Key".into(),
+ enabled: true,
+ pending: true,
+ },
+ vec![Ok(HomeActionResult::None)],
+ )
+ .with_home_action_poll(vec![Ok(Some(
+ HomeActionResult::RevealRecoveryKey {
+ nsec: "nsec1example".into(),
+ },
+ ))]),
+ ));
+
+ app.request_home_action(HomeActionKind::BackupRecoveryKey);
+ assert_eq!(app.revealed_recovery_key, None);
+
+ app.sync_backend();
+
+ assert_eq!(app.revealed_recovery_key.as_deref(), Some("nsec1example"));
+ }
}
diff --git a/native/android/kotlin/RadRootsAndroidSecurity/build.gradle.kts b/native/android/kotlin/RadRootsAndroidSecurity/build.gradle.kts
@@ -27,5 +27,6 @@ android {
}
dependencies {
+ implementation("androidx.biometric:biometric:1.1.0")
testImplementation("junit:junit:4.13.2")
}
diff --git a/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidSecurityBridge.kt b/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidSecurityBridge.kt
@@ -1,6 +1,7 @@
package org.radroots.app.android.security
import android.content.Context
+import androidx.fragment.app.FragmentActivity
object RadRootsAndroidSecurityBridge {
const val STATUS_SUCCESS = 0
@@ -8,15 +9,29 @@ object RadRootsAndroidSecurityBridge {
const val STATUS_INVALID_INPUT = 2
const val STATUS_ERROR = 3
+ const val USER_PRESENCE_RESULT_NONE = 0
+ const val USER_PRESENCE_RESULT_SUCCESS = 1
+ const val USER_PRESENCE_RESULT_ERROR = 2
+
@Volatile
private var applicationContext: Context? = null
@Volatile
+ private var currentActivity: FragmentActivity? = null
+
+ @Volatile
private var lastErrorMessage: String? = null
+ @Volatile
+ private var userPresenceVerificationPending: Boolean = false
+
+ @Volatile
+ private var userPresenceVerificationResult: Int = USER_PRESENCE_RESULT_NONE
+
@JvmStatic
fun initialize(context: Context) {
applicationContext = context.applicationContext
+ currentActivity = context as? FragmentActivity
clearError()
}
@@ -93,6 +108,54 @@ object RadRootsAndroidSecurityBridge {
}
@JvmStatic
+ fun beginUserPresenceVerification(reason: String): Int {
+ return try {
+ if (reason.isBlank()) {
+ throw RadRootsAndroidSecurityError.InvalidInput("verification reason must not be blank")
+ }
+ if (userPresenceVerificationPending) {
+ throw RadRootsAndroidSecurityError.InvalidInput("device authentication is already in progress")
+ }
+ val activity = currentActivity
+ ?: throw RadRootsAndroidSecurityError.InvalidInput("android security bridge has no active activity")
+
+ clearError()
+ userPresenceVerificationPending = true
+ userPresenceVerificationResult = USER_PRESENCE_RESULT_NONE
+
+ RadRootsAndroidUserPresenceVerifier(activity).beginVerification(
+ reason = reason,
+ onSuccess = {
+ clearError()
+ userPresenceVerificationPending = false
+ userPresenceVerificationResult = USER_PRESENCE_RESULT_SUCCESS
+ },
+ onFailure = { cause ->
+ lastErrorMessage = cause.message ?: cause.toString()
+ userPresenceVerificationPending = false
+ userPresenceVerificationResult = USER_PRESENCE_RESULT_ERROR
+ },
+ )
+
+ STATUS_SUCCESS
+ } catch (cause: Throwable) {
+ userPresenceVerificationPending = false
+ userPresenceVerificationResult = USER_PRESENCE_RESULT_NONE
+ captureError(cause)
+ }
+ }
+
+ @JvmStatic
+ fun isUserPresenceVerificationPending(): Boolean = userPresenceVerificationPending
+
+ @JvmStatic
+ fun takeUserPresenceVerificationResult(): Int {
+ val result = userPresenceVerificationResult
+ userPresenceVerificationResult = USER_PRESENCE_RESULT_NONE
+ return result
+ }
+
+ @JvmStatic
fun takeLastErrorMessage(): String? {
val message = lastErrorMessage
lastErrorMessage = null
diff --git a/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidSecurityError.kt b/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidSecurityError.kt
@@ -13,4 +13,11 @@ sealed class RadRootsAndroidSecurityError(
class StorageFailure(message: String, cause: Throwable? = null) :
RadRootsAndroidSecurityError(message, cause)
+
+ class UserCancelled(message: String) : RadRootsAndroidSecurityError(message)
+
+ class UserPresenceUnavailable(message: String) : RadRootsAndroidSecurityError(message)
+
+ class UserPresenceFailure(message: String, cause: Throwable? = null) :
+ RadRootsAndroidSecurityError(message, cause)
}
diff --git a/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidUserPresenceVerifier.kt b/native/android/kotlin/RadRootsAndroidSecurity/src/main/kotlin/org/radroots/app/android/security/RadRootsAndroidUserPresenceVerifier.kt
@@ -0,0 +1,149 @@
+package org.radroots.app.android.security
+
+import android.app.KeyguardManager
+import android.os.Build
+import androidx.biometric.BiometricManager
+import androidx.biometric.BiometricPrompt
+import androidx.fragment.app.FragmentActivity
+import androidx.core.content.ContextCompat
+
+class RadRootsAndroidUserPresenceVerifier(
+ private val activity: FragmentActivity,
+) {
+ fun beginVerification(
+ reason: String,
+ onSuccess: () -> Unit,
+ onFailure: (RadRootsAndroidSecurityError) -> Unit,
+ ) {
+ if (reason.isBlank()) {
+ onFailure(RadRootsAndroidSecurityError.InvalidInput("verification reason must not be blank"))
+ return
+ }
+
+ val promptInfo = try {
+ buildPromptInfo(reason)
+ } catch (error: RadRootsAndroidSecurityError) {
+ onFailure(error)
+ return
+ }
+
+ val executor = ContextCompat.getMainExecutor(activity)
+ val prompt = BiometricPrompt(
+ activity,
+ executor,
+ object : BiometricPrompt.AuthenticationCallback() {
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
+ onSuccess()
+ }
+
+ override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
+ onFailure(mapAuthenticationError(errorCode, errString))
+ }
+
+ override fun onAuthenticationFailed() {
+ onFailure(
+ RadRootsAndroidSecurityError.UserPresenceFailure(
+ "device authentication failed",
+ ),
+ )
+ }
+ },
+ )
+
+ activity.runOnUiThread {
+ prompt.authenticate(promptInfo)
+ }
+ }
+
+ private fun buildPromptInfo(reason: String): BiometricPrompt.PromptInfo {
+ ensureAuthenticationAvailable()
+
+ val builder = BiometricPrompt.PromptInfo.Builder()
+ .setTitle("Rad Roots")
+ .setSubtitle("Authenticate to $reason")
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ builder.setAllowedAuthenticators(
+ BiometricManager.Authenticators.BIOMETRIC_STRONG or
+ BiometricManager.Authenticators.DEVICE_CREDENTIAL,
+ )
+ } else if (deviceCredentialAvailable()) {
+ builder.setDeviceCredentialAllowed(true)
+ } else {
+ builder.setNegativeButtonText("Cancel")
+ }
+
+ return builder.build()
+ }
+
+ private fun ensureAuthenticationAvailable() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ when (
+ BiometricManager.from(activity).canAuthenticate(
+ BiometricManager.Authenticators.BIOMETRIC_STRONG or
+ BiometricManager.Authenticators.DEVICE_CREDENTIAL,
+ )
+ ) {
+ BiometricManager.BIOMETRIC_SUCCESS -> return
+ BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED ->
+ throw RadRootsAndroidSecurityError.UserPresenceUnavailable(
+ "no device authentication method is enrolled",
+ )
+ BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE,
+ BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE ->
+ throw RadRootsAndroidSecurityError.UserPresenceUnavailable(
+ "device authentication is unavailable",
+ )
+ else ->
+ throw RadRootsAndroidSecurityError.UserPresenceFailure(
+ "failed to prepare device authentication",
+ )
+ }
+ }
+
+ val biometricStatus = BiometricManager.from(activity).canAuthenticate()
+ if (biometricStatus == BiometricManager.BIOMETRIC_SUCCESS || deviceCredentialAvailable()) {
+ return
+ }
+
+ throw when (biometricStatus) {
+ BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED ->
+ RadRootsAndroidSecurityError.UserPresenceUnavailable(
+ "no biometric or device credential is available",
+ )
+ BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE,
+ BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE ->
+ RadRootsAndroidSecurityError.UserPresenceUnavailable(
+ "device authentication is unavailable",
+ )
+ else ->
+ RadRootsAndroidSecurityError.UserPresenceFailure(
+ "failed to prepare device authentication",
+ )
+ }
+ }
+
+ private fun deviceCredentialAvailable(): Boolean {
+ val keyguardManager = activity.getSystemService(KeyguardManager::class.java)
+ return keyguardManager?.isDeviceSecure == true
+ }
+
+ private fun mapAuthenticationError(
+ errorCode: Int,
+ errString: CharSequence,
+ ): RadRootsAndroidSecurityError {
+ val message = errString.toString()
+ return when (errorCode) {
+ BiometricPrompt.ERROR_NEGATIVE_BUTTON,
+ BiometricPrompt.ERROR_USER_CANCELED,
+ BiometricPrompt.ERROR_CANCELED ->
+ RadRootsAndroidSecurityError.UserCancelled(message)
+ BiometricPrompt.ERROR_HW_NOT_PRESENT,
+ BiometricPrompt.ERROR_HW_UNAVAILABLE,
+ BiometricPrompt.ERROR_NO_BIOMETRICS,
+ BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL ->
+ RadRootsAndroidSecurityError.UserPresenceUnavailable(message)
+ else -> RadRootsAndroidSecurityError.UserPresenceFailure(message)
+ }
+ }
+}