diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelPresenter.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelPresenter.kt index 5de8d97cd1..da1b484ee6 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelPresenter.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelPresenter.kt @@ -44,6 +44,12 @@ class WalletPanelPresenter @Inject constructor( var isLoading by remember { mutableStateOf(true) } var error by remember { mutableStateOf(null) } + // Mnemonic dialog state + var requestBiometricAuth by remember { mutableStateOf(false) } + var showMnemonicDialog by remember { mutableStateOf(false) } + var mnemonicWords by remember { mutableStateOf?>(null) } + var mnemonicError by remember { mutableStateOf(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, ) } diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelState.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelState.kt index 971b20aad9..ee2acb94ec 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelState.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelState.kt @@ -24,6 +24,10 @@ data class WalletPanelState( val transactions: List, val isTestnet: Boolean, val error: String?, + val requestBiometricAuth: Boolean, + val showMnemonicDialog: Boolean, + val mnemonicWords: List?, + 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 diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelView.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelView.kt index 6391f7ac07..75d79c3511 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelView.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelView.kt @@ -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, + 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 = {}, diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/tabs/OverviewTabView.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/tabs/OverviewTabView.kt index 1dfd589120..06f797c127 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/tabs/OverviewTabView.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/tabs/OverviewTabView.kt @@ -226,6 +226,10 @@ internal fun OverviewTabViewPreview() = ElementPreview { transactions = emptyList(), isTestnet = true, error = null, + requestBiometricAuth = false, + showMnemonicDialog = false, + mnemonicWords = null, + mnemonicError = null, eventSink = {}, ), onSendClick = {},