diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/WalletSetupPresenter.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/WalletSetupPresenter.kt index f1c336db13..45cd4d5313 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/WalletSetupPresenter.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/WalletSetupPresenter.kt @@ -8,6 +8,7 @@ package io.element.android.features.wallet.impl.setup import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -30,6 +31,7 @@ class WalletSetupPresenter @Inject constructor( companion object { private const val TAG = "WalletSetupPresenter" + private val VALID_WORD_COUNTS = listOf(12, 15, 18, 21, 24) } @Composable @@ -45,6 +47,10 @@ class WalletSetupPresenter @Inject constructor( var hasConfirmedBackup by remember { mutableStateOf(false) } var isBackingUp by remember { mutableStateOf(false) } var recoveryKeyInput by remember { mutableStateOf("") } + // Import state + var importMnemonicInput by remember { mutableStateOf("") } + var importWordCount by remember { mutableIntStateOf(0) } + var isImporting by remember { mutableStateOf(false) } fun handleEvent(event: WalletSetupEvent) { when (event) { @@ -72,8 +78,61 @@ class WalletSetupPresenter @Inject constructor( } WalletSetupEvent.ImportExistingWallet -> { - // TODO: Navigate to import flow - error = "Import not yet supported. Please create a new wallet." + step = SetupStep.IMPORT_MNEMONIC + importMnemonicInput = "" + importWordCount = 0 + error = null + } + + is WalletSetupEvent.UpdateImportMnemonic -> { + importMnemonicInput = event.text + // Count words (split by whitespace) + val words = event.text.trim().split(Regex("\\s+")).filter { it.isNotEmpty() } + importWordCount = words.size + // Clear error on input change + error = null + } + + WalletSetupEvent.ClearImportMnemonic -> { + importMnemonicInput = "" + importWordCount = 0 + error = null + } + + WalletSetupEvent.ConfirmImport -> { + val words = importMnemonicInput.trim().lowercase().split(Regex("\\s+")).filter { it.isNotEmpty() } + + if (words.size !in VALID_WORD_COUNTS) { + error = "Invalid recovery phrase. Expected 12 or 24 words, got ${words.size}." + return + } + + isImporting = true + error = null + + scope.launch { + keyStorage.importWallet(sessionId, words) + .onSuccess { address -> + Timber.tag(TAG).i("Wallet imported: ${address.take(20)}...") + generatedMnemonic = words + generatedAddress = address + isImporting = false + // Skip to address confirmation (no backup prompt for imported wallets + // since user already has their phrase) + step = SetupStep.SHOW_ADDRESS + } + .onFailure { e -> + Timber.tag(TAG).e(e, "Failed to import wallet") + error = when { + e.message?.contains("invalid", ignoreCase = true) == true -> + "Invalid recovery phrase. Check your words and try again." + e.message?.contains("already exists", ignoreCase = true) == true -> + "A wallet already exists for this account." + else -> e.message ?: "Failed to import wallet" + } + isImporting = false + } + } } WalletSetupEvent.ProceedToBackup -> { @@ -86,7 +145,6 @@ class WalletSetupPresenter @Inject constructor( } WalletSetupEvent.SkipBackupToMatrix -> { - // User chose manual backup only - mark as confirmed hasConfirmedBackup = true step = SetupStep.COMPLETE scope.launch { @@ -144,6 +202,7 @@ class WalletSetupPresenter @Inject constructor( WalletSetupEvent.Back -> { when (step) { + SetupStep.IMPORT_MNEMONIC -> step = SetupStep.WELCOME SetupStep.SHOW_ADDRESS -> step = SetupStep.WELCOME SetupStep.BACKUP_PROMPT -> step = SetupStep.SHOW_ADDRESS SetupStep.BACKUP_TO_MATRIX -> step = SetupStep.BACKUP_PROMPT @@ -166,6 +225,9 @@ class WalletSetupPresenter @Inject constructor( hasConfirmedBackup = hasConfirmedBackup, isBackingUp = isBackingUp, recoveryKeyInput = recoveryKeyInput, + importMnemonicInput = importMnemonicInput, + importWordCount = importWordCount, + isImporting = isImporting, eventSink = ::handleEvent, ) } diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/WalletSetupState.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/WalletSetupState.kt index 10139d51c9..02e5621976 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/WalletSetupState.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/WalletSetupState.kt @@ -18,26 +18,36 @@ data class WalletSetupState( val hasConfirmedBackup: Boolean, val isBackingUp: Boolean, val recoveryKeyInput: String, + // Import flow state + val importMnemonicInput: String, + val importWordCount: Int, + val isImporting: Boolean, val eventSink: (WalletSetupEvent) -> Unit, ) enum class SetupStep { - WELCOME, // "Create New Wallet" or "Import Existing" - GENERATING, // Spinning while generating keys - SHOW_ADDRESS, // Display the derived address - BACKUP_PROMPT, // Show mnemonic with backup options - BACKUP_TO_MATRIX, // Enter recovery key for SSSS backup - COMPLETE, // Done - ready to close + WELCOME, // "Create New Wallet" or "Import Existing" + GENERATING, // Spinning while generating keys + IMPORT_MNEMONIC, // Enter recovery phrase to import + SHOW_ADDRESS, // Display the derived address + BACKUP_PROMPT, // Show mnemonic with backup options + BACKUP_TO_MATRIX, // Enter recovery key for SSSS backup + COMPLETE, // Done - ready to close } sealed interface WalletSetupEvent { data object CreateNewWallet : WalletSetupEvent data object ImportExistingWallet : WalletSetupEvent + // Import events + data class UpdateImportMnemonic(val text: String) : WalletSetupEvent + data object ClearImportMnemonic : WalletSetupEvent + data object ConfirmImport : WalletSetupEvent + // Backup events data object ProceedToBackup : WalletSetupEvent - data object SkipBackupToMatrix : WalletSetupEvent // User chooses manual backup only - data object ProceedToMatrixBackup : WalletSetupEvent // User wants SSSS backup + data object SkipBackupToMatrix : WalletSetupEvent + data object ProceedToMatrixBackup : WalletSetupEvent data class UpdateRecoveryKeyInput(val key: String) : WalletSetupEvent - data object ConfirmMatrixBackup : WalletSetupEvent // Submit the recovery key + data object ConfirmMatrixBackup : WalletSetupEvent data object ConfirmBackup : WalletSetupEvent data object Complete : WalletSetupEvent data object Back : WalletSetupEvent diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/WalletSetupView.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/WalletSetupView.kt index 0e3b9b36fb..4ee58e8211 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/WalletSetupView.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/setup/WalletSetupView.kt @@ -28,6 +28,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Key @@ -70,10 +71,15 @@ fun WalletSetupView( onBack: () -> Unit, modifier: Modifier = Modifier, ) { - // FLAG_SECURE when showing mnemonic or recovery key input + // FLAG_SECURE when showing sensitive data val view = LocalView.current DisposableEffect(state.step) { - if (state.step in listOf(SetupStep.BACKUP_PROMPT, SetupStep.BACKUP_TO_MATRIX)) { + val sensitiveSteps = listOf( + SetupStep.BACKUP_PROMPT, + SetupStep.BACKUP_TO_MATRIX, + SetupStep.IMPORT_MNEMONIC + ) + if (state.step in sensitiveSteps) { val window = (view.context as? android.app.Activity)?.window window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE) onDispose { window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) } @@ -86,7 +92,7 @@ fun WalletSetupView( modifier = modifier.fillMaxSize().systemBarsPadding(), topBar = { TopAppBar( - title = { Text("Set Up Wallet") }, + title = { Text(if (state.step == SetupStep.IMPORT_MNEMONIC) "Import Wallet" else "Set Up Wallet") }, navigationIcon = { if (state.step != SetupStep.COMPLETE) { IconButton(onClick = { @@ -113,6 +119,7 @@ fun WalletSetupView( when (state.step) { SetupStep.WELCOME -> WelcomeContent(state) SetupStep.GENERATING -> GeneratingContent() + SetupStep.IMPORT_MNEMONIC -> ImportMnemonicContent(state) SetupStep.SHOW_ADDRESS -> AddressContent(state) SetupStep.BACKUP_PROMPT -> BackupContent(state) SetupStep.BACKUP_TO_MATRIX -> MatrixBackupContent(state) @@ -198,6 +205,106 @@ private fun ColumnScope.GeneratingContent() { Spacer(modifier = Modifier.weight(1f)) } +@Composable +private fun ColumnScope.ImportMnemonicContent(state: WalletSetupState) { + val isValidWordCount = state.importWordCount in listOf(12, 24) + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(16.dp)) + + Icon( + imageVector = Icons.Default.Download, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.primary, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Import Existing Wallet", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Enter your 12 or 24-word recovery phrase, separated by spaces.", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + OutlinedTextField( + value = state.importMnemonicInput, + onValueChange = { state.eventSink(WalletSetupEvent.UpdateImportMnemonic(it)) }, + label = { Text("Recovery Phrase") }, + placeholder = { Text("word1 word2 word3 ...") }, + modifier = Modifier.fillMaxWidth(), + minLines = 4, + maxLines = 6, + enabled = !state.isImporting, + trailingIcon = { + if (state.importMnemonicInput.isNotEmpty()) { + IconButton(onClick = { state.eventSink(WalletSetupEvent.ClearImportMnemonic) }) { + Icon(Icons.Default.Clear, contentDescription = "Clear") + } + } + }, + supportingText = { + val color = when { + state.importWordCount == 0 -> MaterialTheme.colorScheme.onSurfaceVariant + isValidWordCount -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.error + } + Text( + text = "${state.importWordCount}/24 words", + color = color, + ) + }, + isError = state.error != null, + ) + + state.error?.let { error -> + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + if (state.isImporting) { + CircularProgressIndicator(modifier = Modifier.size(32.dp)) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Verifying and importing...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Button( + text = "Restore Wallet", + onClick = { state.eventSink(WalletSetupEvent.ConfirmImport) }, + enabled = isValidWordCount, + modifier = Modifier.fillMaxWidth(), + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + } +} + @Composable private fun ColumnScope.AddressContent(state: WalletSetupState) { Spacer(modifier = Modifier.height(48.dp)) @@ -212,7 +319,7 @@ private fun ColumnScope.AddressContent(state: WalletSetupState) { Spacer(modifier = Modifier.height(16.dp)) Text( - text = "Wallet Created!", + text = "Wallet Ready!", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, ) @@ -241,11 +348,24 @@ private fun ColumnScope.AddressContent(state: WalletSetupState) { Spacer(modifier = Modifier.weight(1f)) - Button( - text = "Continue to Backup", - onClick = { state.eventSink(WalletSetupEvent.ProceedToBackup) }, - modifier = Modifier.fillMaxWidth(), - ) + // For imported wallets, go directly to complete + // For generated wallets, show backup prompt + if (state.generatedMnemonic.size == 24 && state.step == SetupStep.SHOW_ADDRESS) { + Button( + text = "Continue to Backup", + onClick = { state.eventSink(WalletSetupEvent.ProceedToBackup) }, + modifier = Modifier.fillMaxWidth(), + ) + } else { + Button( + text = "Done", + onClick = { + state.eventSink(WalletSetupEvent.ConfirmBackup) + // eventSink will trigger Complete + }, + modifier = Modifier.fillMaxWidth(), + ) + } Spacer(modifier = Modifier.height(32.dp)) }