From 75edbd549929908b3a260eba254c6d51a00dade7 Mon Sep 17 00:00:00 2001 From: Kayos Date: Sun, 29 Mar 2026 05:02:25 -0700 Subject: [PATCH] feat(wallet): Add SSSS backup functionality - Add "Backup to Matrix" button to wallet Settings tab - Implement BackupRecoveryKeyDialog for entering recovery key - Wire up WalletBackupService for SSSS encryption - Add backup state to WalletPanelState and WalletPanelEvent - Add localized strings for backup UI Backup flow: 1. User taps "Backup to Matrix" in wallet settings 2. Dialog prompts for Matrix recovery key 3. Wallet mnemonic is encrypted with SSSS 4. Stored in Matrix account data as com.sulkta.cardano.wallet_seed Tested: Successfully backed up wallet to SSSS on testnet. --- .../wallet/impl/panel/WalletPanelPresenter.kt | 105 ++++++++++++++++ .../wallet/impl/panel/WalletPanelState.kt | 38 ++++++ .../wallet/impl/panel/WalletPanelView.kt | 112 ++++++++++++++++++ .../wallet/impl/panel/tabs/OverviewTabView.kt | 6 + .../wallet/impl/panel/tabs/SettingsTabView.kt | 39 ++++++ .../impl/src/main/res/values/strings.xml | 16 +++ 6 files changed, 316 insertions(+) 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 da1b484ee6..978acc68d9 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 @@ -18,6 +18,8 @@ import dev.zacsweers.metro.Inject import io.element.android.features.wallet.api.CardanoClient import io.element.android.features.wallet.api.NativeAsset import io.element.android.features.wallet.api.TxSummary +import io.element.android.features.wallet.api.backup.WalletBackupService +import io.element.android.features.wallet.api.storage.CardanoKeyStorage import io.element.android.features.wallet.impl.cardano.CardanoNetworkConfig import io.element.android.features.wallet.impl.cardano.CardanoWalletManager import io.element.android.libraries.architecture.Presenter @@ -32,6 +34,8 @@ class WalletPanelPresenter @Inject constructor( private val walletManager: CardanoWalletManager, private val cardanoClient: CardanoClient, private val matrixClient: MatrixClient, + private val walletBackupService: WalletBackupService, + private val keyStorage: CardanoKeyStorage, ) : Presenter { @Composable @@ -50,6 +54,13 @@ class WalletPanelPresenter @Inject constructor( var mnemonicWords by remember { mutableStateOf?>(null) } var mnemonicError by remember { mutableStateOf(null) } + // SSSS Backup state + var showBackupDialog by remember { mutableStateOf(false) } + var backupMode by remember { mutableStateOf(BackupMode.BACKUP) } + var backupInProgress by remember { mutableStateOf(false) } + var backupError by remember { mutableStateOf(null) } + var backupSuccess by remember { mutableStateOf(null) } + // Initialize wallet on first composition LaunchedEffect(Unit) { walletManager.initialize(matrixClient.sessionId) @@ -133,6 +144,95 @@ class WalletPanelPresenter @Inject constructor( WalletPanelEvent.Close -> { // Navigation handled by node callback } + // SSSS Backup events + WalletPanelEvent.ShowBackupDialog -> { + backupMode = BackupMode.BACKUP + backupError = null + backupSuccess = null + showBackupDialog = true + } + WalletPanelEvent.ShowRestoreDialog -> { + backupMode = BackupMode.RESTORE + backupError = null + backupSuccess = null + showBackupDialog = true + } + WalletPanelEvent.DismissBackupDialog -> { + showBackupDialog = false + backupError = null + } + is WalletPanelEvent.ConfirmBackup -> { + scope.launch { + backupInProgress = true + backupError = null + + // Normalize recovery key: remove spaces and convert to lowercase + val normalizedKey = event.recoveryKey.replace("\\s+".toRegex(), "").lowercase() + + walletManager.getMnemonic(matrixClient.sessionId) + .onSuccess { mnemonic -> + walletBackupService.backupSeed(normalizedKey, mnemonic) + .onSuccess { + Timber.i("Wallet backed up to SSSS successfully") + backupSuccess = "Wallet backed up successfully" + showBackupDialog = false + } + .onFailure { e -> + Timber.e(e, "Failed to backup wallet to SSSS") + backupError = e.message ?: "Failed to backup wallet" + } + } + .onFailure { e -> + Timber.e(e, "Failed to get mnemonic for backup") + backupError = e.message ?: "Failed to retrieve wallet data" + } + + backupInProgress = false + } + } + is WalletPanelEvent.ConfirmRestore -> { + scope.launch { + backupInProgress = true + backupError = null + + // Normalize recovery key: remove spaces and convert to lowercase + val normalizedKey = event.recoveryKey.replace("\\s+".toRegex(), "").lowercase() + + walletBackupService.restoreSeed(normalizedKey) + .onSuccess { mnemonic -> + if (mnemonic != null) { + // First delete existing wallet if any + keyStorage.deleteWallet(matrixClient.sessionId) + + // Import the restored mnemonic + keyStorage.importWallet(matrixClient.sessionId, mnemonic) + .onSuccess { + Timber.i("Wallet restored from SSSS successfully") + backupSuccess = "Wallet restored successfully" + showBackupDialog = false + // Reinitialize wallet state + walletManager.initialize(matrixClient.sessionId) + } + .onFailure { e -> + Timber.e(e, "Failed to import restored wallet") + backupError = e.message ?: "Failed to import wallet" + } + } else { + backupError = "No wallet backup found in Matrix" + } + } + .onFailure { e -> + Timber.e(e, "Failed to restore wallet from SSSS") + backupError = e.message ?: "Failed to restore wallet" + } + + backupInProgress = false + } + } + WalletPanelEvent.ClearBackupMessage -> { + backupError = null + backupSuccess = null + } else -> { // Other events handled elsewhere } @@ -153,6 +253,11 @@ class WalletPanelPresenter @Inject constructor( showMnemonicDialog = showMnemonicDialog, mnemonicWords = mnemonicWords, mnemonicError = mnemonicError, + showBackupDialog = showBackupDialog, + backupMode = backupMode, + backupInProgress = backupInProgress, + backupError = backupError, + backupSuccess = backupSuccess, 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 ee2acb94ec..1b5f432602 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 @@ -28,6 +28,12 @@ data class WalletPanelState( val showMnemonicDialog: Boolean, val mnemonicWords: List?, val mnemonicError: String?, + // SSSS Backup state + val showBackupDialog: Boolean, + val backupMode: BackupMode, + val backupInProgress: Boolean, + val backupError: String?, + val backupSuccess: String?, val eventSink: (WalletPanelEvent) -> Unit, ) { companion object { @@ -45,6 +51,11 @@ data class WalletPanelState( showMnemonicDialog = false, mnemonicWords = null, mnemonicError = null, + showBackupDialog = false, + backupMode = BackupMode.BACKUP, + backupInProgress = false, + backupError = null, + backupSuccess = null, eventSink = {}, ) } @@ -62,6 +73,14 @@ data class WalletPanelState( } } +/** + * Backup operation mode. + */ +enum class BackupMode { + BACKUP, + RESTORE +} + /** * Events that can be triggered from the wallet panel UI. */ @@ -104,4 +123,23 @@ sealed interface WalletPanelEvent { /** Close the panel. */ data object Close : WalletPanelEvent + + // SSSS Backup events + /** Show backup dialog to enter recovery key. */ + data object ShowBackupDialog : WalletPanelEvent + + /** Show restore dialog to enter recovery key. */ + data object ShowRestoreDialog : WalletPanelEvent + + /** Dismiss the backup/restore dialog. */ + data object DismissBackupDialog : WalletPanelEvent + + /** Confirm backup with the provided recovery key. */ + data class ConfirmBackup(val recoveryKey: String) : WalletPanelEvent + + /** Confirm restore with the provided recovery key. */ + data class ConfirmRestore(val recoveryKey: String) : WalletPanelEvent + + /** Clear backup success/error message. */ + data object ClearBackupMessage : WalletPanelEvent } 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 75d79c3511..fb79c0ba3b 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 @@ -24,8 +24,10 @@ import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Tab import androidx.compose.material3.TabRow import androidx.compose.material3.Text @@ -33,7 +35,11 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -133,6 +139,22 @@ fun WalletPanelView( ) } + // Show backup/restore dialog + if (state.showBackupDialog) { + BackupRecoveryKeyDialog( + mode = state.backupMode, + isLoading = state.backupInProgress, + error = state.backupError, + onConfirm = { recoveryKey -> + when (state.backupMode) { + BackupMode.BACKUP -> state.eventSink(WalletPanelEvent.ConfirmBackup(recoveryKey)) + BackupMode.RESTORE -> state.eventSink(WalletPanelEvent.ConfirmRestore(recoveryKey)) + } + }, + onDismiss = { state.eventSink(WalletPanelEvent.DismissBackupDialog) } + ) + } + Scaffold( modifier = modifier, topBar = { @@ -203,6 +225,7 @@ fun WalletPanelView( isTestnet = state.isTestnet, onCopyAddress = { state.eventSink(WalletPanelEvent.CopyAddress) }, onExportPhrase = { state.eventSink(WalletPanelEvent.ExportRecoveryPhrase) }, + onBackupToMatrix = { state.eventSink(WalletPanelEvent.ShowBackupDialog) }, onDeleteWallet = { state.eventSink(WalletPanelEvent.DeleteWallet) }, modifier = Modifier.fillMaxSize(), ) @@ -213,6 +236,90 @@ fun WalletPanelView( } } +@Composable +private fun BackupRecoveryKeyDialog( + mode: BackupMode, + isLoading: Boolean, + error: String?, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, +) { + var recoveryKey by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = { if (!isLoading) onDismiss() }, + properties = DialogProperties( + dismissOnBackPress = !isLoading, + dismissOnClickOutside = !isLoading, + ), + title = { + Text( + text = stringResource(R.string.wallet_backup_dialog_title), + style = MaterialTheme.typography.headlineSmall, + ) + }, + text = { + Column { + Text( + text = stringResource(R.string.wallet_backup_dialog_message), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(bottom = 16.dp), + ) + + OutlinedTextField( + value = recoveryKey, + onValueChange = { recoveryKey = it }, + label = { Text(stringResource(R.string.wallet_backup_dialog_hint)) }, + enabled = !isLoading, + singleLine = false, + minLines = 2, + maxLines = 4, + modifier = Modifier.fillMaxWidth(), + isError = error != null, + ) + + if (error != null) { + Text( + text = error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = 8.dp), + ) + } + + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier + .padding(top = 16.dp) + .align(Alignment.CenterHorizontally), + ) + } + } + }, + confirmButton = { + Button( + onClick = { onConfirm(recoveryKey) }, + enabled = !isLoading && recoveryKey.isNotBlank(), + ) { + Text( + text = when (mode) { + BackupMode.BACKUP -> stringResource(R.string.wallet_backup_dialog_backup) + BackupMode.RESTORE -> stringResource(R.string.wallet_backup_dialog_restore) + } + ) + } + }, + dismissButton = { + TextButton( + onClick = onDismiss, + enabled = !isLoading, + ) { + Text(stringResource(R.string.wallet_backup_dialog_cancel)) + } + }, + ) +} + @OptIn(ExperimentalLayoutApi::class) @Composable private fun MnemonicDisplayDialog( @@ -366,6 +473,11 @@ internal fun WalletPanelViewPreview() = ElementPreview { showMnemonicDialog = false, mnemonicWords = null, mnemonicError = null, + showBackupDialog = false, + backupMode = BackupMode.BACKUP, + backupInProgress = false, + backupError = null, + backupSuccess = null, 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 06f797c127..7bcfed07f2 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 @@ -47,6 +47,7 @@ import com.google.zxing.qrcode.QRCodeWriter import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.wallet.impl.R import io.element.android.features.wallet.impl.panel.WalletPanelEvent +import io.element.android.features.wallet.impl.panel.BackupMode import io.element.android.features.wallet.impl.panel.WalletPanelState import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -230,6 +231,11 @@ internal fun OverviewTabViewPreview() = ElementPreview { showMnemonicDialog = false, mnemonicWords = null, mnemonicError = null, + showBackupDialog = false, + backupMode = BackupMode.BACKUP, + backupInProgress = false, + backupError = null, + backupSuccess = null, eventSink = {}, ), onSendClick = {}, diff --git a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/tabs/SettingsTabView.kt b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/tabs/SettingsTabView.kt index 9ad1d99976..4b24bfbe93 100644 --- a/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/tabs/SettingsTabView.kt +++ b/features/wallet/impl/src/main/kotlin/io/element/android/features/wallet/impl/panel/tabs/SettingsTabView.kt @@ -38,6 +38,7 @@ fun SettingsTabView( isTestnet: Boolean, onCopyAddress: () -> Unit, onExportPhrase: () -> Unit, + onBackupToMatrix: () -> Unit, onDeleteWallet: () -> Unit, modifier: Modifier = Modifier, ) { @@ -176,6 +177,43 @@ fun SettingsTabView( HorizontalDivider() + // Backup to Matrix + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onBackupToMatrix) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = CompoundIcons.Cloud(), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + Column( + modifier = Modifier + .weight(1f) + .padding(start = 16.dp), + ) { + Text( + text = stringResource(R.string.wallet_settings_backup_matrix), + style = MaterialTheme.typography.bodyLarge, + ) + Text( + text = stringResource(R.string.wallet_settings_backup_matrix_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + imageVector = CompoundIcons.ChevronRight(), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + HorizontalDivider() + Row( modifier = Modifier .fillMaxWidth() @@ -218,6 +256,7 @@ internal fun SettingsTabViewPreview() = ElementPreview { isTestnet = true, onCopyAddress = {}, onExportPhrase = {}, + onBackupToMatrix = {}, onDeleteWallet = {}, ) } diff --git a/features/wallet/impl/src/main/res/values/strings.xml b/features/wallet/impl/src/main/res/values/strings.xml index 7d9032f75e..f277662d3a 100644 --- a/features/wallet/impl/src/main/res/values/strings.xml +++ b/features/wallet/impl/src/main/res/values/strings.xml @@ -47,4 +47,20 @@ Set up your wallet to send ADA Set Up Wallet Insufficient balance (%s ADA available) + + + Backup to Matrix + Encrypt and store your wallet in Matrix account data + Restore from Matrix + Restore wallet from Matrix backup + Enter Recovery Key + Enter your Matrix recovery key to encrypt your wallet backup. This is the same key used to unlock your encrypted messages. + Recovery key + Backup + Restore + Cancel + Wallet backed up successfully + Wallet restored successfully + Backup failed: %s + Restore failed: %s