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.
This commit is contained in:
parent
1308a8299a
commit
75edbd5499
6 changed files with 316 additions and 0 deletions
|
|
@ -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<WalletPanelState> {
|
||||
|
||||
@Composable
|
||||
|
|
@ -50,6 +54,13 @@ class WalletPanelPresenter @Inject constructor(
|
|||
var mnemonicWords by remember { mutableStateOf<List<String>?>(null) }
|
||||
var mnemonicError by remember { mutableStateOf<String?>(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<String?>(null) }
|
||||
var backupSuccess by remember { mutableStateOf<String?>(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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,12 @@ data class WalletPanelState(
|
|||
val showMnemonicDialog: Boolean,
|
||||
val mnemonicWords: List<String>?,
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,4 +47,20 @@
|
|||
<string name="wallet_payment_no_wallet_message">Set up your wallet to send ADA</string>
|
||||
<string name="wallet_payment_no_wallet_button">Set Up Wallet</string>
|
||||
<string name="wallet_payment_insufficient_balance">Insufficient balance (%s ADA available)</string>
|
||||
|
||||
<!-- SSSS Backup -->
|
||||
<string name="wallet_settings_backup_matrix">Backup to Matrix</string>
|
||||
<string name="wallet_settings_backup_matrix_description">Encrypt and store your wallet in Matrix account data</string>
|
||||
<string name="wallet_settings_restore_matrix">Restore from Matrix</string>
|
||||
<string name="wallet_settings_restore_matrix_description">Restore wallet from Matrix backup</string>
|
||||
<string name="wallet_backup_dialog_title">Enter Recovery Key</string>
|
||||
<string name="wallet_backup_dialog_message">Enter your Matrix recovery key to encrypt your wallet backup. This is the same key used to unlock your encrypted messages.</string>
|
||||
<string name="wallet_backup_dialog_hint">Recovery key</string>
|
||||
<string name="wallet_backup_dialog_backup">Backup</string>
|
||||
<string name="wallet_backup_dialog_restore">Restore</string>
|
||||
<string name="wallet_backup_dialog_cancel">Cancel</string>
|
||||
<string name="wallet_backup_success">Wallet backed up successfully</string>
|
||||
<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>
|
||||
</resources>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue