feat: implement export recovery phrase with biometric auth

- Add biometric/device credential auth before showing mnemonic
- Display 24 words in 4x6 grid with word numbers
- Set FLAG_SECURE on dialog to prevent screenshots
- Mnemonic is cleared from memory when dialog dismissed
This commit is contained in:
Kayos 2026-03-28 16:21:42 -07:00
parent c1b927380f
commit f56f124a39
4 changed files with 253 additions and 12 deletions

View file

@ -44,6 +44,12 @@ class WalletPanelPresenter @Inject constructor(
var isLoading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
// Mnemonic dialog state
var requestBiometricAuth by remember { mutableStateOf(false) }
var showMnemonicDialog by remember { mutableStateOf(false) }
var mnemonicWords by remember { mutableStateOf<List<String>?>(null) }
var mnemonicError by remember { mutableStateOf<String?>(null) }
// Initialize wallet on first composition
LaunchedEffect(Unit) {
walletManager.initialize(matrixClient.sessionId)
@ -83,15 +89,37 @@ class WalletPanelPresenter @Inject constructor(
fun handleEvent(event: WalletPanelEvent) {
when (event) {
WalletPanelEvent.Refresh -> {
}
}
// Handled by separate flow with biometric
// Trigger refresh - handled by LaunchedEffect
}
WalletPanelEvent.ExportRecoveryPhrase -> {
// TODO: Implement biometric auth then display mnemonic
// Signal the view to trigger biometric auth
requestBiometricAuth = true
}
WalletPanelEvent.CancelBiometricAuth -> {
requestBiometricAuth = false
}
WalletPanelEvent.LoadMnemonic -> {
requestBiometricAuth = false
scope.launch {
mnemonicError = null
walletManager.getMnemonic(matrixClient.sessionId)
.onSuccess { words ->
mnemonicWords = words
showMnemonicDialog = true
}
.onFailure { e ->
Timber.e(e, "Failed to get mnemonic")
mnemonicError = e.message ?: "Failed to retrieve recovery phrase"
}
}
}
WalletPanelEvent.DismissMnemonicDialog -> {
showMnemonicDialog = false
mnemonicWords = null
mnemonicError = null
}
WalletPanelEvent.DeleteWallet -> {
// Show confirmation dialog
// Show confirmation dialog - handled elsewhere
}
WalletPanelEvent.ConfirmDeleteWallet -> {
// Handled by separate action
@ -105,6 +133,9 @@ class WalletPanelPresenter @Inject constructor(
WalletPanelEvent.Close -> {
// Navigation handled by node callback
}
else -> {
// Other events handled elsewhere
}
}
}
@ -118,6 +149,10 @@ class WalletPanelPresenter @Inject constructor(
transactions = transactions,
isTestnet = CardanoNetworkConfig.NETWORK_NAME != "mainnet",
error = error ?: walletState.error,
requestBiometricAuth = requestBiometricAuth,
showMnemonicDialog = showMnemonicDialog,
mnemonicWords = mnemonicWords,
mnemonicError = mnemonicError,
eventSink = ::handleEvent,
)
}

View file

@ -24,6 +24,10 @@ data class WalletPanelState(
val transactions: List<TxSummary>,
val isTestnet: Boolean,
val error: String?,
val requestBiometricAuth: Boolean,
val showMnemonicDialog: Boolean,
val mnemonicWords: List<String>?,
val mnemonicError: String?,
val eventSink: (WalletPanelEvent) -> Unit,
) {
companion object {
@ -37,6 +41,10 @@ data class WalletPanelState(
transactions = emptyList(),
isTestnet = true,
error = null,
requestBiometricAuth = false,
showMnemonicDialog = false,
mnemonicWords = null,
mnemonicError = null,
eventSink = {},
)
}
@ -70,9 +78,18 @@ sealed interface WalletPanelEvent {
/** Navigate to wallet setup flow. */
data object SetupWallet : WalletPanelEvent
/** Export recovery phrase. */
/** Export recovery phrase (triggers biometric auth). */
data object ExportRecoveryPhrase : WalletPanelEvent
/** Called after successful biometric auth to load mnemonic. */
data object LoadMnemonic : WalletPanelEvent
/** Cancel the biometric auth request. */
data object CancelBiometricAuth : WalletPanelEvent
/** Dismiss the mnemonic dialog. */
data object DismissMnemonicDialog : WalletPanelEvent
/** Delete wallet. */
data object DeleteWallet : WalletPanelEvent

View file

@ -6,20 +6,45 @@
package io.element.android.features.wallet.impl.panel
import android.view.WindowManager
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.DialogProperties
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.wallet.impl.R
import io.element.android.features.wallet.impl.panel.tabs.AssetsTabView
@ -32,6 +57,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import kotlinx.coroutines.launch
import timber.log.Timber
private enum class WalletTab(val titleRes: Int) {
Overview(R.string.wallet_tab_overview),
@ -52,6 +78,60 @@ fun WalletPanelView(
val tabs = WalletTab.entries
val pagerState = rememberPagerState(pageCount = { tabs.size })
val scope = rememberCoroutineScope()
val context = LocalContext.current
val activity = context as? FragmentActivity
// Handle biometric authentication request
LaunchedEffect(state.requestBiometricAuth) {
if (state.requestBiometricAuth && activity != null) {
val biometricManager = BiometricManager.from(context)
val canAuth = biometricManager.canAuthenticate(
BiometricManager.Authenticators.BIOMETRIC_WEAK or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
) == BiometricManager.BIOMETRIC_SUCCESS
if (canAuth) {
val executor = ContextCompat.getMainExecutor(context)
val callback = object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
state.eventSink(WalletPanelEvent.LoadMnemonic)
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
Timber.w("Biometric auth error: $errorCode - $errString")
state.eventSink(WalletPanelEvent.CancelBiometricAuth)
}
override fun onAuthenticationFailed() {
// User can retry
}
}
val biometricPrompt = BiometricPrompt(activity, executor, callback)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Confirm your identity")
.setSubtitle("Authenticate to view recovery phrase")
.setAllowedAuthenticators(
BiometricManager.Authenticators.BIOMETRIC_WEAK or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
)
.build()
biometricPrompt.authenticate(promptInfo)
} else {
// No biometric/credential available, proceed directly
state.eventSink(WalletPanelEvent.LoadMnemonic)
}
}
}
// Show mnemonic dialog
if (state.showMnemonicDialog && state.mnemonicWords != null) {
MnemonicDisplayDialog(
words = state.mnemonicWords,
onDismiss = { state.eventSink(WalletPanelEvent.DismissMnemonicDialog) }
)
}
Scaffold(
modifier = modifier,
@ -133,6 +213,107 @@ fun WalletPanelView(
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun MnemonicDisplayDialog(
words: List<String>,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
val activity = context as? android.app.Activity
// Set FLAG_SECURE to prevent screenshots while dialog is shown
DisposableEffect(Unit) {
activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
onDispose {
activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
AlertDialog(
onDismissRequest = onDismiss,
properties = DialogProperties(
dismissOnBackPress = true,
dismissOnClickOutside = false,
usePlatformDefaultWidth = false,
),
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
title = {
Text(
text = "Recovery Phrase",
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
},
text = {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "Write down these 24 words in order and store them safely. Never share your recovery phrase with anyone.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 16.dp),
)
// 4 columns x 6 rows grid
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
verticalArrangement = Arrangement.spacedBy(8.dp),
maxItemsInEachRow = 4,
) {
words.forEachIndexed { index, word ->
WordChip(
number = index + 1,
word = word,
)
}
}
}
},
confirmButton = {
Button(
onClick = onDismiss,
modifier = Modifier.fillMaxWidth(),
) {
Text("Done")
}
},
)
}
@Composable
private fun WordChip(
number: Int,
word: String,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.size(width = 80.dp, height = 36.dp)
.background(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(8.dp),
)
.padding(horizontal = 8.dp, vertical = 4.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = "$number. $word",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
maxLines = 1,
)
}
}
@Composable
private fun WalletSetupPromptView(
onSetupClick: () -> Unit,
@ -140,8 +321,8 @@ private fun WalletSetupPromptView(
) {
Column(
modifier = modifier.padding(24.dp),
horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally,
verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
androidx.compose.material3.Icon(
imageVector = CompoundIcons.Chart(),
@ -152,16 +333,16 @@ private fun WalletSetupPromptView(
)
Text(
text = stringResource(R.string.wallet_setup_title),
style = androidx.compose.material3.MaterialTheme.typography.headlineMedium,
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 8.dp),
)
Text(
text = stringResource(R.string.wallet_setup_description),
style = androidx.compose.material3.MaterialTheme.typography.bodyMedium,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 24.dp),
)
androidx.compose.material3.Button(onClick = onSetupClick) {
Button(onClick = onSetupClick) {
Text(stringResource(R.string.wallet_setup_button))
}
}
@ -181,6 +362,10 @@ internal fun WalletPanelViewPreview() = ElementPreview {
transactions = emptyList(),
isTestnet = true,
error = null,
requestBiometricAuth = false,
showMnemonicDialog = false,
mnemonicWords = null,
mnemonicError = null,
eventSink = {},
),
onBackClick = {},

View file

@ -226,6 +226,10 @@ internal fun OverviewTabViewPreview() = ElementPreview {
transactions = emptyList(),
isTestnet = true,
error = null,
requestBiometricAuth = false,
showMnemonicDialog = false,
mnemonicWords = null,
mnemonicError = null,
eventSink = {},
),
onSendClick = {},