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:
parent
0388cd7d06
commit
1308a8299a
3 changed files with 213 additions and 21 deletions
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue