feat(wallet): complete SSSS round-trip with delete and restore

Delete Wallet feature:
- Add showDeleteConfirmation state to WalletPanelState
- Add WalletDeleteConfirmationDialog composable with warning
- Non-dismissible dialog with clear warning about backup
- Wire DeleteWallet/ConfirmDeleteWallet/CancelDeleteWallet events
- Call keyStorage.deleteWallet() and clear wallet state on confirm
- Panel shows setup screen after deletion

Restore from SSSS feature:
- Add hasBackupWithoutKey() to WalletBackupService for checking backup existence
- Uses raw Matrix account data API to check if secret key exists
- Add RESTORE_FROM_CLOUD step to SetupStep enum
- Check for cloud backup on setup init (non-blocking)
- Show "Restore from Matrix Backup" button when backup exists
- Add recovery key input flow for cloud restore
- Restore decrypts mnemonic from SSSS and imports wallet

Both features enable complete wallet backup/restore round-trip via Matrix SSSS.
This commit is contained in:
Kayos 2026-03-29 05:18:45 -07:00
parent 75edbd5499
commit da589ae78f
11 changed files with 489 additions and 28 deletions

View file

@ -46,4 +46,14 @@ interface WalletBackupService {
* @return True if a backup exists, false otherwise
*/
suspend fun hasBackup(recoveryKey: String): Result<Boolean>
/**
* Check if a wallet backup exists in account data WITHOUT decrypting.
*
* This checks the raw Matrix account data to see if the secret key exists,
* without needing the recovery key. Useful for UI to show restore option.
*
* @return True if the account data key exists, false otherwise
*/
suspend fun hasBackupWithoutKey(): Result<Boolean>
}

View file

@ -10,8 +10,7 @@ import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.features.wallet.api.backup.WalletBackupService
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.MatrixClient
import timber.log.Timber
/**
@ -20,14 +19,12 @@ import timber.log.Timber
*/
@ContributesBinding(AppScope::class)
class WalletBackupServiceImpl @Inject constructor(
private val matrixClientProvider: MatrixClientProvider,
private val activeSessionId: SessionId,
private val matrixClient: MatrixClient,
) : WalletBackupService {
override suspend fun backupSeed(recoveryKey: String, mnemonic: List<String>): Result<Unit> {
return runCatching {
val client = matrixClientProvider.getOrRestore(activeSessionId).getOrThrow()
val secretStore = client.secretStorage.openSecretStore(recoveryKey)
val secretStore = matrixClient.secretStorage.openSecretStore(recoveryKey)
?: throw WalletBackupException.InvalidRecoveryKey()
// Store mnemonic as space-separated string
@ -40,8 +37,7 @@ class WalletBackupServiceImpl @Inject constructor(
override suspend fun restoreSeed(recoveryKey: String): Result<List<String>?> {
return runCatching {
val client = matrixClientProvider.getOrRestore(activeSessionId).getOrThrow()
val secretStore = client.secretStorage.openSecretStore(recoveryKey)
val secretStore = matrixClient.secretStorage.openSecretStore(recoveryKey)
?: throw WalletBackupException.InvalidRecoveryKey()
val seedString = secretStore.getSecret(WalletBackupService.SECRET_NAME).getOrThrow()
@ -53,6 +49,29 @@ class WalletBackupServiceImpl @Inject constructor(
override suspend fun hasBackup(recoveryKey: String): Result<Boolean> {
return restoreSeed(recoveryKey).map { it != null }
}
override suspend fun hasBackupWithoutKey(): Result<Boolean> {
return runCatching {
// Build the account data URL for the wallet secret
val userId = matrixClient.sessionId.value
val url = "/_matrix/client/v3/user/$userId/account_data/${WalletBackupService.SECRET_NAME}"
try {
// Try to fetch the account data - if it exists, we get content back
val response = matrixClient.getUrl(url).getOrThrow()
// If we got a non-empty response, the backup exists
// Even if encrypted, the account data key existing means a backup was made
val content = response.decodeToString()
Timber.d("Account data check response: ${content.take(100)}")
// Check if it's a valid JSON object with content (not empty {} or error)
content.isNotEmpty() && content != "{}" && !content.contains("\"errcode\"")
} catch (e: Exception) {
Timber.d(e, "Account data not found or error checking")
// 404 or other error means no backup exists
false
}
}
}
}
/**

View file

@ -0,0 +1,111 @@
/*
* Copyright (c) 2026 Sulkta Coop.
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
package io.element.android.features.wallet.impl.panel
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
/**
* A non-dismissible confirmation dialog for wallet deletion with a clear warning.
*/
@Composable
fun WalletDeleteConfirmationDialog(
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
// Block back button - must explicitly choose Cancel or Delete
BackHandler(enabled = true) {
// Intentionally empty - prevent back press from dismissing
}
AlertDialog(
onDismissRequest = {
// Cannot dismiss by tapping outside - must choose an action
},
icon = {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.error,
)
},
title = {
Text(
text = "Delete Wallet?",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
},
text = {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
) {
Text(
text = "This will permanently remove your wallet from this device. If you haven't backed up your recovery phrase, " +
"you will lose access to your funds forever.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Make sure you have:",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "• Written down your 24-word recovery phrase, OR\n• Backed up to Matrix",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = {
TextButton(
onClick = onConfirm,
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error,
),
) {
Text(
text = "Delete Wallet",
fontWeight = FontWeight.Bold,
)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
},
)
}

View file

@ -61,6 +61,9 @@ class WalletPanelPresenter @Inject constructor(
var backupError by remember { mutableStateOf<String?>(null) }
var backupSuccess by remember { mutableStateOf<String?>(null) }
// Delete confirmation state
var showDeleteConfirmation by remember { mutableStateOf(false) }
// Initialize wallet on first composition
LaunchedEffect(Unit) {
walletManager.initialize(matrixClient.sessionId)
@ -130,13 +133,28 @@ class WalletPanelPresenter @Inject constructor(
mnemonicError = null
}
WalletPanelEvent.DeleteWallet -> {
// Show confirmation dialog - handled elsewhere
// Show confirmation dialog
showDeleteConfirmation = true
}
WalletPanelEvent.ConfirmDeleteWallet -> {
// Handled by separate action
scope.launch {
Timber.i("Deleting wallet for session ${matrixClient.sessionId}")
keyStorage.deleteWallet(matrixClient.sessionId)
.onSuccess {
Timber.i("Wallet deleted successfully")
showDeleteConfirmation = false
// Reset wallet state - this will cause the panel to show setup prompt
walletManager.clearState()
}
.onFailure { e ->
Timber.e(e, "Failed to delete wallet")
error = e.message ?: "Failed to delete wallet"
showDeleteConfirmation = false
}
}
}
WalletPanelEvent.CancelDeleteWallet -> {
// Dismiss dialog
showDeleteConfirmation = false
}
is WalletPanelEvent.OpenTransaction -> {
// Handled by view via intent
@ -258,6 +276,7 @@ class WalletPanelPresenter @Inject constructor(
backupInProgress = backupInProgress,
backupError = backupError,
backupSuccess = backupSuccess,
showDeleteConfirmation = showDeleteConfirmation,
eventSink = ::handleEvent,
)
}

View file

@ -34,6 +34,8 @@ data class WalletPanelState(
val backupInProgress: Boolean,
val backupError: String?,
val backupSuccess: String?,
// Delete confirmation state
val showDeleteConfirmation: Boolean,
val eventSink: (WalletPanelEvent) -> Unit,
) {
companion object {
@ -56,6 +58,7 @@ data class WalletPanelState(
backupInProgress = false,
backupError = null,
backupSuccess = null,
showDeleteConfirmation = false,
eventSink = {},
)
}
@ -109,13 +112,13 @@ sealed interface WalletPanelEvent {
/** Dismiss the mnemonic dialog. */
data object DismissMnemonicDialog : WalletPanelEvent
/** Delete wallet. */
/** Show delete confirmation dialog. */
data object DeleteWallet : WalletPanelEvent
/** Confirm wallet deletion. */
data object ConfirmDeleteWallet : WalletPanelEvent
/** Cancel wallet deletion. */
/** Cancel wallet deletion / dismiss dialog. */
data object CancelDeleteWallet : WalletPanelEvent
/** Open transaction in block explorer. */

View file

@ -155,6 +155,14 @@ fun WalletPanelView(
)
}
// Show delete confirmation dialog
if (state.showDeleteConfirmation) {
WalletDeleteConfirmationDialog(
onConfirm = { state.eventSink(WalletPanelEvent.ConfirmDeleteWallet) },
onDismiss = { state.eventSink(WalletPanelEvent.CancelDeleteWallet) }
)
}
Scaffold(
modifier = modifier,
topBar = {
@ -361,7 +369,8 @@ private fun MnemonicDisplayDialog(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "Write down these 24 words in order and store them safely. Never share your recovery phrase with anyone.",
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,
@ -478,6 +487,7 @@ internal fun WalletPanelViewPreview() = ElementPreview {
backupInProgress = false,
backupError = null,
backupSuccess = null,
showDeleteConfirmation = false,
eventSink = {},
),
onBackClick = {},

View file

@ -236,6 +236,7 @@ internal fun OverviewTabViewPreview() = ElementPreview {
backupInProgress = false,
backupError = null,
backupSuccess = null,
showDeleteConfirmation = false,
eventSink = {},
),
onSendClick = {},

View file

@ -7,6 +7,7 @@
package io.element.android.features.wallet.impl.setup
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@ -51,6 +52,26 @@ class WalletSetupPresenter @Inject constructor(
var importMnemonicInput by remember { mutableStateOf("") }
var importWordCount by remember { mutableIntStateOf(0) }
var isImporting by remember { mutableStateOf(false) }
// Cloud backup state
var hasCloudBackup by remember { mutableStateOf(false) }
var isCheckingCloudBackup by remember { mutableStateOf(true) }
var cloudRestoreRecoveryKey by remember { mutableStateOf("") }
var isRestoringFromCloud by remember { mutableStateOf(false) }
// Check for cloud backup on init
LaunchedEffect(Unit) {
Timber.tag(TAG).d("Checking for cloud backup...")
walletBackupService.hasBackupWithoutKey()
.onSuccess { exists ->
Timber.tag(TAG).d("Cloud backup exists: $exists")
hasCloudBackup = exists
}
.onFailure { e ->
Timber.tag(TAG).w(e, "Failed to check for cloud backup")
hasCloudBackup = false
}
isCheckingCloudBackup = false
}
fun handleEvent(event: WalletSetupEvent) {
when (event) {
@ -84,6 +105,12 @@ class WalletSetupPresenter @Inject constructor(
error = null
}
WalletSetupEvent.RestoreFromCloud -> {
step = SetupStep.RESTORE_FROM_CLOUD
cloudRestoreRecoveryKey = ""
error = null
}
is WalletSetupEvent.UpdateImportMnemonic -> {
importMnemonicInput = event.text
// Count words (split by whitespace)
@ -135,6 +162,63 @@ class WalletSetupPresenter @Inject constructor(
}
}
is WalletSetupEvent.UpdateCloudRestoreRecoveryKey -> {
cloudRestoreRecoveryKey = event.key
error = null
}
WalletSetupEvent.ConfirmCloudRestore -> {
if (cloudRestoreRecoveryKey.isBlank()) {
error = "Please enter your Matrix recovery key"
return
}
isRestoringFromCloud = true
error = null
scope.launch {
// Normalize recovery key: remove spaces and convert to lowercase
val normalizedKey = cloudRestoreRecoveryKey.replace("\\s+".toRegex(), "").lowercase()
walletBackupService.restoreSeed(normalizedKey)
.onSuccess { mnemonic ->
if (mnemonic != null) {
Timber.tag(TAG).i("Restored mnemonic from SSSS: ${mnemonic.size} words")
// Import the restored mnemonic
keyStorage.importWallet(sessionId, mnemonic)
.onSuccess { address ->
Timber.tag(TAG).i("Wallet restored from cloud: ${address.take(20)}...")
generatedMnemonic = mnemonic
generatedAddress = address
isRestoringFromCloud = false
// Go directly to address confirmation
step = SetupStep.SHOW_ADDRESS
}
.onFailure { e ->
Timber.tag(TAG).e(e, "Failed to import restored wallet")
error = e.message ?: "Failed to import restored wallet"
isRestoringFromCloud = false
}
} else {
error = "No wallet backup found in Matrix"
isRestoringFromCloud = false
}
}
.onFailure { e ->
Timber.tag(TAG).e(e, "Failed to restore from cloud")
error = when {
e.message?.contains("invalid", ignoreCase = true) == true ->
"Invalid recovery key. Please check and try again."
e.message?.contains("not set up", ignoreCase = true) == true ->
"Matrix recovery is not set up for this account."
else -> e.message ?: "Failed to restore from Matrix"
}
isRestoringFromCloud = false
}
}
}
WalletSetupEvent.ProceedToBackup -> {
step = SetupStep.BACKUP_PROMPT
}
@ -203,6 +287,7 @@ class WalletSetupPresenter @Inject constructor(
WalletSetupEvent.Back -> {
when (step) {
SetupStep.IMPORT_MNEMONIC -> step = SetupStep.WELCOME
SetupStep.RESTORE_FROM_CLOUD -> step = SetupStep.WELCOME
SetupStep.SHOW_ADDRESS -> step = SetupStep.WELCOME
SetupStep.BACKUP_PROMPT -> step = SetupStep.SHOW_ADDRESS
SetupStep.BACKUP_TO_MATRIX -> step = SetupStep.BACKUP_PROMPT
@ -228,6 +313,10 @@ class WalletSetupPresenter @Inject constructor(
importMnemonicInput = importMnemonicInput,
importWordCount = importWordCount,
isImporting = isImporting,
hasCloudBackup = hasCloudBackup,
isCheckingCloudBackup = isCheckingCloudBackup,
cloudRestoreRecoveryKey = cloudRestoreRecoveryKey,
isRestoringFromCloud = isRestoringFromCloud,
eventSink = ::handleEvent,
)
}

View file

@ -8,6 +8,9 @@ package io.element.android.features.wallet.impl.setup
import androidx.compose.runtime.Immutable
/**
* UI state for wallet setup flow.
*/
@Immutable
data class WalletSetupState(
val step: SetupStep,
@ -18,38 +21,90 @@ 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 hasCloudBackup: Boolean,
val isCheckingCloudBackup: Boolean,
val cloudRestoreRecoveryKey: String,
val isRestoringFromCloud: Boolean,
val eventSink: (WalletSetupEvent) -> Unit,
)
/**
* Steps in the wallet setup flow.
*/
enum class SetupStep {
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
/** Initial screen with Create/Import/Restore options. */
WELCOME,
/** Generating wallet keys. */
GENERATING,
/** Display the generated address. */
SHOW_ADDRESS,
/** Prompt to backup recovery phrase. */
BACKUP_PROMPT,
/** Backup to Matrix SSSS. */
BACKUP_TO_MATRIX,
/** Setup complete. */
COMPLETE,
/** Import existing wallet by entering mnemonic. */
IMPORT_MNEMONIC,
/** Restore from Matrix cloud backup - enter recovery key. */
RESTORE_FROM_CLOUD,
}
/**
* Events that can be triggered from the wallet setup UI.
*/
sealed interface WalletSetupEvent {
/** User wants to create a new wallet. */
data object CreateNewWallet : WalletSetupEvent
/** User wants to import an existing wallet. */
data object ImportExistingWallet : WalletSetupEvent
// Import events
/** User wants to restore from Matrix cloud backup. */
data object RestoreFromCloud : WalletSetupEvent
/** Update the import mnemonic text. */
data class UpdateImportMnemonic(val text: String) : WalletSetupEvent
/** Clear the import mnemonic input. */
data object ClearImportMnemonic : WalletSetupEvent
/** Confirm import of the entered mnemonic. */
data object ConfirmImport : WalletSetupEvent
// Backup events
/** Proceed from address display to backup prompt. */
data object ProceedToBackup : WalletSetupEvent
data object SkipBackupToMatrix : WalletSetupEvent
/** User wants to backup to Matrix SSSS. */
data object ProceedToMatrixBackup : WalletSetupEvent
/** User chose to skip Matrix backup. */
data object SkipBackupToMatrix : WalletSetupEvent
/** Update the recovery key input for Matrix backup. */
data class UpdateRecoveryKeyInput(val key: String) : WalletSetupEvent
/** Confirm Matrix backup with the entered recovery key. */
data object ConfirmMatrixBackup : WalletSetupEvent
/** User confirmed they've backed up their phrase. */
data object ConfirmBackup : WalletSetupEvent
/** Setup flow is complete. */
data object Complete : WalletSetupEvent
/** Navigate back within the flow. */
data object Back : WalletSetupEvent
/** Dismiss any error dialog. */
data object DismissError : WalletSetupEvent
/** Update the cloud restore recovery key input. */
data class UpdateCloudRestoreRecoveryKey(val key: String) : WalletSetupEvent
/** Confirm cloud restore with the entered recovery key. */
data object ConfirmCloudRestore : WalletSetupEvent
}

View file

@ -30,6 +30,7 @@ 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.CloudSync
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Key
import androidx.compose.material3.Card
@ -88,11 +89,17 @@ fun WalletSetupView(
}
}
val title = when (state.step) {
SetupStep.IMPORT_MNEMONIC -> "Import Wallet"
SetupStep.RESTORE_FROM_CLOUD -> "Restore from Matrix"
else -> "Set Up Wallet"
}
Scaffold(
modifier = modifier.fillMaxSize().systemBarsPadding(),
topBar = {
TopAppBar(
title = { Text(if (state.step == SetupStep.IMPORT_MNEMONIC) "Import Wallet" else "Set Up Wallet") },
title = { Text(title) },
navigationIcon = {
if (state.step != SetupStep.COMPLETE) {
IconButton(onClick = {
@ -120,6 +127,7 @@ fun WalletSetupView(
SetupStep.WELCOME -> WelcomeContent(state)
SetupStep.GENERATING -> GeneratingContent()
SetupStep.IMPORT_MNEMONIC -> ImportMnemonicContent(state)
SetupStep.RESTORE_FROM_CLOUD -> RestoreFromCloudContent(state)
SetupStep.SHOW_ADDRESS -> AddressContent(state)
SetupStep.BACKUP_PROMPT -> BackupContent(state)
SetupStep.BACKUP_TO_MATRIX -> MatrixBackupContent(state)
@ -166,6 +174,36 @@ private fun ColumnScope.WelcomeContent(state: WalletSetupState) {
leadingIcon = IconSource.Vector(Icons.Default.Download),
)
// Show "Restore from Matrix Backup" if cloud backup exists
if (state.hasCloudBackup && !state.isCheckingCloudBackup) {
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(
text = "Restore from Matrix Backup",
onClick = { state.eventSink(WalletSetupEvent.RestoreFromCloud) },
modifier = Modifier.fillMaxWidth(),
leadingIcon = IconSource.Vector(Icons.Default.CloudSync),
)
}
// Show loading indicator while checking for cloud backup
if (state.isCheckingCloudBackup) {
Spacer(modifier = Modifier.height(12.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth(),
) {
CircularProgressIndicator(modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.size(8.dp))
Text(
text = "Checking for cloud backup...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Spacer(modifier = Modifier.height(32.dp))
state.error?.let { error ->
@ -305,6 +343,103 @@ private fun ColumnScope.ImportMnemonicContent(state: WalletSetupState) {
}
}
@Composable
private fun ColumnScope.RestoreFromCloudContent(state: WalletSetupState) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(24.dp))
Icon(
imageVector = Icons.Default.CloudSync,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Restore from Matrix",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Your wallet backup was found in your Matrix account. Enter your recovery key to restore it.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(24.dp))
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)
),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "Enter the same Matrix recovery key you used when setting up Security & Privacy.",
modifier = Modifier.padding(12.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
Spacer(modifier = Modifier.height(24.dp))
OutlinedTextField(
value = state.cloudRestoreRecoveryKey,
onValueChange = { state.eventSink(WalletSetupEvent.UpdateCloudRestoreRecoveryKey(it)) },
label = { Text("Recovery Key") },
placeholder = { Text("AAAA BBBB CCCC ...") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
enabled = !state.isRestoringFromCloud,
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.isRestoringFromCloud) {
CircularProgressIndicator(modifier = Modifier.size(32.dp))
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Decrypting and restoring...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
Button(
text = "Restore Wallet",
onClick = { state.eventSink(WalletSetupEvent.ConfirmCloudRestore) },
enabled = state.cloudRestoreRecoveryKey.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
)
}
Spacer(modifier = Modifier.height(32.dp))
}
}
@Composable
private fun ColumnScope.AddressContent(state: WalletSetupState) {
Spacer(modifier = Modifier.height(48.dp))
@ -359,9 +494,8 @@ private fun ColumnScope.AddressContent(state: WalletSetupState) {
} else {
Button(
text = "Done",
onClick = {
onClick = {
state.eventSink(WalletSetupEvent.ConfirmBackup)
// eventSink will trigger Complete
},
modifier = Modifier.fillMaxWidth(),
)

View file

@ -42,6 +42,7 @@
<string name="wallet_setup_title">Set up your wallet</string>
<string name="wallet_setup_description">Your Cardano wallet keys will be stored securely on your device and backed up via your Matrix account.</string>
<string name="wallet_setup_button">Get Started</string>
<string name="wallet_setup_restore_from_cloud">Restore from Matrix Backup</string>
<!-- Payment -->
<string name="wallet_payment_no_wallet_message">Set up your wallet to send ADA</string>
@ -63,4 +64,13 @@
<string name="wallet_restore_success">Wallet restored successfully</string>
<string name="wallet_backup_error">Backup failed: %s</string>
<string name="wallet_restore_error">Restore failed: %s</string>
<!-- Delete Wallet -->
<string name="wallet_delete_dialog_title">Delete Wallet?</string>
<string name="wallet_delete_dialog_message">This will permanently remove your wallet from this device. If you haven\'t backed up your recovery phrase, you will lose access to your funds forever.</string>
<string name="wallet_delete_dialog_checklist_header">Make sure you have:</string>
<string name="wallet_delete_dialog_checklist">• Written down your 24-word recovery phrase, OR\n• Backed up to Matrix</string>
<string name="wallet_delete_dialog_confirm">Delete Wallet</string>
<string name="wallet_delete_dialog_cancel">Cancel</string>
<string name="wallet_deleted_toast">Wallet deleted</string>
</resources>