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:
parent
c1b927380f
commit
f56f124a39
4 changed files with 253 additions and 12 deletions
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
|
|
|
|||
|
|
@ -226,6 +226,10 @@ internal fun OverviewTabViewPreview() = ElementPreview {
|
|||
transactions = emptyList(),
|
||||
isTestnet = true,
|
||||
error = null,
|
||||
requestBiometricAuth = false,
|
||||
showMnemonicDialog = false,
|
||||
mnemonicWords = null,
|
||||
mnemonicError = null,
|
||||
eventSink = {},
|
||||
),
|
||||
onSendClick = {},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue