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:
parent
75edbd5499
commit
da589ae78f
11 changed files with 489 additions and 28 deletions
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
|
|
|
|||
|
|
@ -236,6 +236,7 @@ internal fun OverviewTabViewPreview() = ElementPreview {
|
|||
backupInProgress = false,
|
||||
backupError = null,
|
||||
backupSuccess = null,
|
||||
showDeleteConfirmation = false,
|
||||
eventSink = {},
|
||||
),
|
||||
onSendClick = {},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue