feat(wallet): implement import wallet from mnemonic

Users can now import an existing wallet by entering their
12 or 24-word recovery phrase.

Features:
- New IMPORT_MNEMONIC step in wallet setup flow
- Live word count display (12/24 words)
- Clear button for input field
- Validates BIP39 mnemonic using cardano-client-lib
- FLAG_SECURE on import screen (mnemonic is sensitive)
- Paste-friendly single text area
- Inline error messages for invalid phrases

The imported wallet skips the backup prompt since the user
already has their recovery phrase.
This commit is contained in:
Kayos 2026-03-28 17:29:11 -07:00
parent 0388cd7d06
commit 1308a8299a
3 changed files with 213 additions and 21 deletions

View file

@ -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,
)
}

View file

@ -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

View file

@ -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))
}