diff --git a/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/backup/WalletBackupService.kt b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/backup/WalletBackupService.kt index dbab0c5ff2..6d5aba7a34 100644 --- a/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/backup/WalletBackupService.kt +++ b/features/wallet/api/src/main/kotlin/io/element/android/features/wallet/api/backup/WalletBackupService.kt @@ -46,4 +46,14 @@ interface WalletBackupService { * @return True if a backup exists, false otherwise */ suspend fun hasBackup(recoveryKey: String): Result + + /** + * 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 } diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/backup/WalletBackupServiceImpl.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/backup/WalletBackupServiceImpl.kt index cdd88d4600..ee3b0dc03b 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/backup/WalletBackupServiceImpl.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/backup/WalletBackupServiceImpl.kt @@ -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): Result { 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?> { 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 { return restoreSeed(recoveryKey).map { it != null } } + + override suspend fun hasBackupWithoutKey(): Result { + 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 + } + } + } } /** diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletDeleteConfirmationDialog.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletDeleteConfirmationDialog.kt new file mode 100644 index 0000000000..ff86333fac --- /dev/null +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletDeleteConfirmationDialog.kt @@ -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") + } + }, + ) +} diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelPresenter.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelPresenter.kt index 978acc68d9..be058e7e89 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelPresenter.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelPresenter.kt @@ -61,6 +61,9 @@ class WalletPanelPresenter @Inject constructor( var backupError by remember { mutableStateOf(null) } var backupSuccess by remember { mutableStateOf(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, ) } diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelState.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelState.kt index 1b5f432602..2d44391675 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelState.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelState.kt @@ -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. */ diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelView.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelView.kt index fb79c0ba3b..7dde957b20 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelView.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/WalletPanelView.kt @@ -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 = {}, diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/tabs/OverviewTabView.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/tabs/OverviewTabView.kt index 7bcfed07f2..5f2a7311d7 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/tabs/OverviewTabView.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/tabs/OverviewTabView.kt @@ -236,6 +236,7 @@ internal fun OverviewTabViewPreview() = ElementPreview { backupInProgress = false, backupError = null, backupSuccess = null, + showDeleteConfirmation = false, eventSink = {}, ), onSendClick = {}, 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 45cd4d5313..06ca724123 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 @@ -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, ) } 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 02e5621976..23ad6860bf 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 @@ -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 } 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 4ee58e8211..0861633c61 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 @@ -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(), ) diff --git a/features/wallet/impl/src/main/res/values/strings.xml b/features/wallet/impl/src/main/res/values/strings.xml index f277662d3a..c39a93a16d 100644 --- a/features/wallet/impl/src/main/res/values/strings.xml +++ b/features/wallet/impl/src/main/res/values/strings.xml @@ -42,6 +42,7 @@ Set up your wallet Your Cardano wallet keys will be stored securely on your device and backed up via your Matrix account. Get Started + Restore from Matrix Backup Set up your wallet to send ADA @@ -63,4 +64,13 @@ Wallet restored successfully Backup failed: %s Restore failed: %s + + + Delete Wallet? + 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. + Make sure you have: + • Written down your 24-word recovery phrase, OR\n• Backed up to Matrix + Delete Wallet + Cancel + Wallet deleted