diff --git a/app/src/main/kotlin/com/wisp/app/nostr/Nip78.kt b/app/src/main/kotlin/com/wisp/app/nostr/Nip78.kt index 77261327..d06d9712 100644 --- a/app/src/main/kotlin/com/wisp/app/nostr/Nip78.kt +++ b/app/src/main/kotlin/com/wisp/app/nostr/Nip78.kt @@ -128,4 +128,48 @@ object Nip78 { /** Extract the d-tag value from an event, or null. */ fun extractDTag(event: NostrEvent): String? = event.tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1) + + // ─── NWC connection backup ──────────────────────────────────────── + // + // Cross-platform NWC URI backup per `NWC_BACKUP_PARITY.md`. iOS + // and Android both publish to / read from the same flat `d` tag + // (`nwc-wallet-backup` — no `wisp-` prefix, intentional, for + // cross-platform interop). Content is the raw NWC URI string, + // NIP-44 v2 encrypted to self. + + const val NWC_BACKUP_D_TAG = "nwc-wallet-backup" + + /** Build + sign a kind-30078 event carrying the encrypted NWC URI. */ + suspend fun createNwcBackupEvent(signer: NostrSigner, uri: String): NostrEvent { + val encrypted = signer.nip44Encrypt(uri.trim(), signer.pubkeyHex) + val tags = listOf( + listOf("d", NWC_BACKUP_D_TAG), + listOf("client", "Wisp"), + listOf("encryption", "nip44") + ) + return signer.signEvent(kind = KIND, content = encrypted, tags = tags) + } + + /** + * Decrypt a kind-30078 NWC-backup event and return the raw URI. + * Returns null when the content is empty, decrypt fails, or the + * plaintext doesn't look like a `nostr+walletconnect://` URI. + */ + suspend fun decryptNwcBackup(signer: NostrSigner, event: NostrEvent): String? { + if (event.content.isBlank()) return null + return try { + val decrypted = signer.nip44Decrypt(event.content, event.pubkey).trim() + if (decrypted.startsWith("nostr+walletconnect://", ignoreCase = true)) decrypted else null + } catch (_: Exception) { + null + } + } + + /** Filter to fetch the user's NWC backup (single addressable event). */ + fun nwcBackupFilter(pubkeyHex: String): Filter = Filter( + kinds = listOf(KIND), + authors = listOf(pubkeyHex), + dTags = listOf(NWC_BACKUP_D_TAG), + limit = 1 + ) } diff --git a/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt b/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt index a6f1f93d..03453fff 100644 --- a/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt +++ b/app/src/main/kotlin/com/wisp/app/ui/screen/WalletScreen.kt @@ -47,10 +47,12 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState @@ -119,11 +121,15 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.compose.ui.res.painterResource @@ -156,6 +162,7 @@ import com.wisp.app.ui.component.NsecPasteGuard import com.wisp.app.ui.component.SatsNumpad import com.wisp.app.ui.util.AmountFormatter import com.wisp.app.viewmodel.AutoCheckState +import com.wisp.app.viewmodel.NwcRestoreState import com.wisp.app.viewmodel.FeeState import com.wisp.app.viewmodel.BackupStatus import com.wisp.app.viewmodel.DeleteBackupStatus @@ -183,9 +190,10 @@ fun WalletScreen( // Hide the wallet app bar on the Home dashboard — the bottom-nav wallet // tab is the entry point, and the dashboard's own top row (brand logo + // refresh + settings) plays the role of the toolbar. The mode picker - // and the wallet-setup sub-screens (NWC, Spark, Spark restore-seed) - // render their own top-right Close pill instead of an app bar to match - // iOS — leaves more headroom for the centered logo + title layout. + // and the wallet-setup sub-screens (NWC, Spark, Spark restore-seed, + // Spark backup) render their own top-right Close pill instead of an + // app bar to match iOS — leaves more headroom for the centered logo + // + title layout. val hideAppBar = currentPage is WalletPage.Home || currentPage is WalletPage.ModeSelection || currentPage is WalletPage.NwcSetup || @@ -269,9 +277,12 @@ fun WalletScreen( walletState = walletState, connectionString = viewModel.connectionString.collectAsState().value, statusLines = viewModel.statusLines.collectAsState().value, + nwcRestoreState = viewModel.nwcRestoreState.collectAsState().value, onConnectionStringChange = { viewModel.updateConnectionString(it) }, onConnect = { viewModel.connectNwcWallet() }, onDisconnect = { viewModel.disconnectWallet() }, + onRestoreFromBackup = { viewModel.restoreFromNwcBackup() }, + onDismissRestore = { viewModel.dismissNwcRestore() }, onClose = { viewModel.navigateHome() } ) is WalletPage.SparkSetup -> SparkSetupContent( @@ -604,9 +615,12 @@ private fun WalletConnectionContent( walletState: WalletState, connectionString: String, statusLines: List, + nwcRestoreState: NwcRestoreState = NwcRestoreState.Idle, onConnectionStringChange: (String) -> Unit, onConnect: () -> Unit, onDisconnect: () -> Unit, + onRestoreFromBackup: () -> Unit = {}, + onDismissRestore: () -> Unit = {}, onClose: () -> Unit = {} ) { val context = LocalContext.current @@ -672,6 +686,80 @@ private fun WalletConnectionContent( .padding(horizontal = 16.dp) ) + if (nwcRestoreState is NwcRestoreState.Searching) { + Spacer(Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.width(8.dp)) + Text( + stringResource(R.string.wallet_nwc_restore_searching), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + if (nwcRestoreState is NwcRestoreState.Found) { + Spacer(Modifier.height(16.dp)) + val dateStr = remember(nwcRestoreState.createdAt) { + val fmt = java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM) + fmt.format(java.util.Date(nwcRestoreState.createdAt * 1000L)) + } + Surface( + shape = RoundedCornerShape(14.dp), + color = accent.copy(alpha = 0.16f), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(14.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Outlined.CloudDownload, + contentDescription = null, + tint = accent, + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + stringResource(R.string.wallet_nwc_restore_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface + ) + } + Spacer(Modifier.height(6.dp)) + Text( + stringResource(R.string.wallet_nwc_restore_body, dateStr), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.height(12.dp)) + Button( + onClick = onRestoreFromBackup, + enabled = !isConnecting, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = accent, + contentColor = Color.White + ) + ) { + Text( + stringResource(R.string.wallet_nwc_restore_action), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + } + } + } + } + Spacer(Modifier.height(24.dp)) // Paste / Scan card — top half displays the pasted string (or @@ -770,14 +858,14 @@ private fun WalletConnectionContent( Spacer(Modifier.height(12.dp)) Row( - verticalAlignment = Alignment.CenterVertically, + verticalAlignment = Alignment.Top, modifier = Modifier.fillMaxWidth() ) { Icon( Icons.Outlined.Info, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(14.dp) + modifier = Modifier.size(14.dp).padding(top = 2.dp) ) Spacer(Modifier.width(6.dp)) Text( @@ -787,6 +875,25 @@ private fun WalletConnectionContent( ) } + Spacer(Modifier.height(8.dp)) + Row( + verticalAlignment = Alignment.Top, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + Icons.Outlined.CloudDownload, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(14.dp).padding(top = 2.dp) + ) + Spacer(Modifier.width(6.dp)) + Text( + stringResource(R.string.wallet_nwc_backup_footer), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (walletState is WalletState.Error) { Spacer(Modifier.height(8.dp)) Text( @@ -819,18 +926,12 @@ private fun WalletConnectionContent( } } - if (statusLines.isNotEmpty()) { - Spacer(Modifier.height(12.dp)) - Column(modifier = Modifier.fillMaxWidth()) { - statusLines.forEach { line -> - Text( - line, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } + // Status-line stream (per-relay connect/relay-status output) is + // intentionally suppressed on the NWC setup screen — the Connect + // button's own spinner + "Connecting…" label covers the happy path, + // and any failure surfaces via WalletState.Error above. Showing + // verbose connect logs here just looked like noise (especially + // after a one-tap backup restore). if (isConnecting) { Spacer(Modifier.height(12.dp)) @@ -1142,12 +1243,21 @@ private fun WalletHomeContent( // fiat currency but does NOT flip the app-wide // [FiatPreferences.isFiatMode] flag (still respected by feed // counts / timestamps elsewhere). + // Fixed-height container so the balance occupies the same vertical + // space regardless of which mode is showing — keeps the lightning- + // address pill + Send/Receive row anchored at a stable Y across the + // sats/BTC/⚡/fiat/hidden cycle. One-line variants center vertically + // within the reserved height; two-line variants (sats + subtitle, + // hidden + "tap to reveal") fill it naturally. Column( horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.clickable { - balanceDisplay = balanceDisplay.next() - WalletBalanceDisplayMode.write(prefs, pubkey, balanceDisplay) - } + verticalArrangement = Arrangement.Center, + modifier = Modifier + .heightIn(min = 96.dp) + .clickable { + balanceDisplay = balanceDisplay.next() + WalletBalanceDisplayMode.write(prefs, pubkey, balanceDisplay) + } ) { when (balanceDisplay) { WalletBalanceDisplayMode.HIDDEN -> { @@ -1203,25 +1313,76 @@ private fun WalletHomeContent( color = MaterialTheme.colorScheme.onSurface ) } else { - Text( - "%,d".format(balanceSats), - style = MaterialTheme.typography.displayLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface - ) - Spacer(Modifier.height(4.dp)) - Text( - stringResource(R.string.wallet_sats), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + // BalanceUnit selects between three sat denominations + // — SATS ("1,234" + "sats" subtitle), BITCOIN ("₿ 0.00001234" + // BTC-denominated), LIGHTNING (⚡ + sats inline). + // The setting lives in Wallet Settings → Display Unit. + when (balanceUnit) { + BalanceUnit.SATS -> { + Text( + "%,d".format(balanceSats), + style = MaterialTheme.typography.displayLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(Modifier.height(4.dp)) + Text( + stringResource(R.string.wallet_sats), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + BalanceUnit.BITCOIN -> { + // Symbol rendered in onSurfaceVariant so the + // number stays the prominent reading — matches + // the muted "sats" subtitle in the SATS branch. + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "₿", + style = MaterialTheme.typography.displayLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.width(8.dp)) + Text( + "%,d".format(balanceSats), + style = MaterialTheme.typography.displayLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + BalanceUnit.LIGHTNING -> { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(R.drawable.ic_bolt), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.height(40.dp) + ) + Spacer(Modifier.width(6.dp)) + Text( + "%,d".format(balanceSats), + style = MaterialTheme.typography.displayLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + } } } } } // ── Lightning address pill ───────────────────────────────── - if (walletMode == WalletMode.SPARK && lightningAddress != null) { + // Shown for any wallet mode that carries a lud16 — Spark wallets + // expose one via the Breez SDK; NWC URIs may include `lud16=...` + // which connectNwcWallet copies into `lightningAddress`. The old + // NWC-only logo + "Nostr Wallet Connect" footer below the balance + // was redundant (the dashboard header already brands the mode), + // so it's removed. + if (!lightningAddress.isNullOrBlank()) { Spacer(Modifier.height(16.dp)) Surface( modifier = Modifier.clickable { @@ -1248,7 +1409,9 @@ private fun WalletHomeContent( ) } } - } else if (walletMode == WalletMode.SPARK && lightningAddress == null) { + } else if (walletMode == WalletMode.SPARK) { + // Spark-only setup CTA. NWC users can't register an address + // from inside Wisp — it comes from the NWC URI or not at all. Spacer(Modifier.height(16.dp)) Surface( modifier = Modifier.clickable(onClick = onSetupAddress), @@ -1274,28 +1437,6 @@ private fun WalletHomeContent( ) } } - } else if (walletMode == WalletMode.NWC) { - Spacer(Modifier.height(16.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - modifier = Modifier.fillMaxWidth() - ) { - Image( - painter = painterResource(R.drawable.ic_nwc_logo), - contentDescription = "NWC", - modifier = Modifier.height(16.dp), - colorFilter = androidx.compose.ui.graphics.ColorFilter.tint( - MaterialTheme.colorScheme.onSurfaceVariant - ) - ) - Spacer(Modifier.width(6.dp)) - Text( - stringResource(R.string.wallet_nwc_title), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } } // ── Send / Receive ───────────────────────────────────────── @@ -1354,7 +1495,20 @@ private fun WalletHomeContent( HorizontalDivider( color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f) ) - recentTransactions.take(1).forEach { tx -> + // Match iOS — show recent transactions inline, "View all" + // expands to the full screen. Row count scales with the + // device's available height so a compact phone (e.g. + // ~640dp tall) doesn't crowd out the balance + Send/ + // Receive controls, while bigger displays still get up + // to 5 rows like iOS. + val screenHeightDp = LocalConfiguration.current.screenHeightDp + val txCount = when { + screenHeightDp >= 800 -> 5 + screenHeightDp >= 720 -> 4 + screenHeightDp >= 640 -> 3 + else -> 2 + } + recentTransactions.take(txCount).forEach { tx -> TransactionRow(tx, profileLookup, balanceDisplay) } } @@ -2566,11 +2720,12 @@ private fun WalletModeSelectionContent( painter = painterResource(R.drawable.ic_spark_logo), contentDescription = null, modifier = Modifier.size(28.dp), - colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(accent) + colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(Color.White) ) }, title = stringResource(R.string.wallet_spark_title), - subtitle = stringResource(R.string.wallet_spark_subtitle), + subtitle = stringResource(R.string.wallet_spark_subtitle_recommended), + highlighted = true, onClick = onSelectSpark ) Spacer(Modifier.height(12.dp)) @@ -2595,14 +2750,31 @@ private fun WalletModeRow( leadingIcon: @Composable () -> Unit, title: String, subtitle: String, - onClick: () -> Unit + onClick: () -> Unit, + highlighted: Boolean = false ) { + val accent = WispThemeColors.zapColor + val shape = RoundedCornerShape(14.dp) + val containerColor = if (highlighted) accent else MaterialTheme.colorScheme.surfaceVariant + val titleColor = if (highlighted) Color.White else MaterialTheme.colorScheme.onSurface + val subtitleColor = if (highlighted) Color.White.copy(alpha = 0.85f) else MaterialTheme.colorScheme.onSurfaceVariant + val arrowColor = if (highlighted) Color.White.copy(alpha = 0.85f) else MaterialTheme.colorScheme.onSurfaceVariant + // Layered shadows produce the iOS soft-glow halo: wide outer bleed + + // tighter inner glow, both tinted with the accent. Colored shadows + // only honor `ambientColor`/`spotColor` on API 28+; below that the + // device falls back to the system grey shadow. + val highlightModifier = if (highlighted) { + Modifier + .shadow(elevation = 24.dp, shape = shape, ambientColor = accent, spotColor = accent) + .shadow(elevation = 10.dp, shape = shape, ambientColor = accent, spotColor = accent) + } else Modifier Surface( modifier = Modifier .fillMaxWidth() + .then(highlightModifier) .clickable(onClick = onClick), - color = MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(14.dp) + color = containerColor, + shape = shape ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -2618,20 +2790,20 @@ private fun WalletModeRow( title, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface + color = titleColor ) Spacer(Modifier.height(2.dp)) Text( subtitle, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = subtitleColor ) } Spacer(Modifier.width(8.dp)) Icon( Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, + tint = arrowColor, modifier = Modifier.size(20.dp) ) } @@ -2768,36 +2940,84 @@ private fun SparkSetupContent( Spacer(Modifier.height(28.dp)) - // Option rows + // Primary "Use my default wallet" gets the orange+glow treatment + // so the obvious next step (re-derive the user's existing wallet) + // reads as the default. Other paths collapse under "More options" + // — but if there's no default option visible, expand by default + // so users still see every path immediately. if (canUseDefaultWallet) { - SparkOptionRow( - icon = Icons.Outlined.VpnKey, + WalletModeRow( + leadingIcon = { + Icon( + Icons.Outlined.VpnKey, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(28.dp) + ) + }, title = stringResource(R.string.wallet_use_default), subtitle = stringResource(R.string.wallet_default_subtitle), + highlighted = true, onClick = onUseDefaultWallet ) - Spacer(Modifier.height(12.dp)) + Spacer(Modifier.height(20.dp)) } - SparkOptionRow( - icon = Icons.Outlined.Add, - title = stringResource(R.string.wallet_create_title), - subtitle = stringResource(R.string.wallet_create_subtitle), - onClick = onCreateWallet - ) - Spacer(Modifier.height(12.dp)) - SparkOptionRow( - icon = Icons.Outlined.History, - title = stringResource(R.string.wallet_restore_seed_title), - subtitle = stringResource(R.string.wallet_restore_seed_subtitle), - onClick = onRestoreFromSeed - ) - Spacer(Modifier.height(12.dp)) - SparkOptionRow( - icon = Icons.Outlined.CloudDownload, - title = stringResource(R.string.wallet_restore_relays_title), - subtitle = stringResource(R.string.wallet_restore_relays_subtitle), - onClick = onRestoreFromRelay + + var moreOptionsExpanded by remember { mutableStateOf(!canUseDefaultWallet) } + val chevronRotation by animateFloatAsState( + targetValue = if (moreOptionsExpanded) 180f else 0f, + label = "more-options-chevron" ) + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { moreOptionsExpanded = !moreOptionsExpanded } + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + stringResource(R.string.wallet_more_options), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.weight(1f)) + Icon( + Icons.Filled.KeyboardArrowDown, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .size(20.dp) + .graphicsLayer { rotationZ = chevronRotation } + ) + } + AnimatedVisibility( + visible = moreOptionsExpanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Column { + SparkOptionRow( + icon = Icons.Outlined.Add, + title = stringResource(R.string.wallet_create_title), + subtitle = stringResource(R.string.wallet_create_subtitle), + onClick = onCreateWallet + ) + Spacer(Modifier.height(12.dp)) + SparkOptionRow( + icon = Icons.Outlined.History, + title = stringResource(R.string.wallet_restore_seed_title), + subtitle = stringResource(R.string.wallet_restore_seed_subtitle), + onClick = onRestoreFromSeed + ) + Spacer(Modifier.height(12.dp)) + SparkOptionRow( + icon = Icons.Outlined.CloudDownload, + title = stringResource(R.string.wallet_restore_relays_title), + subtitle = stringResource(R.string.wallet_restore_relays_subtitle), + onClick = onRestoreFromRelay + ) + } + } if (walletState is WalletState.Error) { Spacer(Modifier.height(16.dp)) @@ -3324,6 +3544,9 @@ private fun WalletSettingsContent( Text(if (isDefaultWallet) "View Recovery Phrase" else "Backup Recovery Phrase") } + // Relay backup is only offered for non-default Spark wallets. + // For default wallets the nsec is already the canonical backup + // and relay-backup adds no value while cluttering the screen. if (!isDefaultWallet) { Spacer(Modifier.height(8.dp)) @@ -3489,22 +3712,68 @@ private fun WalletSettingsContent( Spacer(Modifier.height(32.dp)) - Button( - onClick = onDeleteWallet, - modifier = Modifier.fillMaxWidth(), - colors = if (isDefaultWallet) ButtonDefaults.buttonColors() - else ButtonDefaults.buttonColors( - containerColor = Color(0xFFD32F2F), - contentColor = Color.White - ) - ) { + // iOS treats both the default-Spark "Switch" and the NWC + // "Disconnect" cases as quiet, recoverable affordances — a + // card row with red text + swap icon plus a caption. Only the + // truly destructive Delete (non-default Spark whose seed can't + // be re-derived from nsec) keeps the filled-red CTA so the user + // notices the irreversibility. + val isRecoverable = walletMode == WalletMode.NWC || isDefaultWallet + if (isRecoverable) { Text( - when { - walletMode == WalletMode.NWC -> "Disconnect" - isDefaultWallet -> stringResource(R.string.wallet_switch_wallet) - else -> "Delete Wallet" + stringResource(R.string.wallet_disconnect_section), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(start = 4.dp, bottom = 6.dp) + ) + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onDeleteWallet), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.SwapHoriz, + contentDescription = null, + tint = Color(0xFFFF3B30), + modifier = Modifier.size(22.dp) + ) + Spacer(Modifier.width(12.dp)) + Text( + stringResource(R.string.wallet_switch_wallet), + style = MaterialTheme.typography.bodyLarge, + color = Color(0xFFFF3B30) + ) } + } + Spacer(Modifier.height(8.dp)) + Text( + stringResource(R.string.wallet_switch_wallet_caption), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 4.dp) ) + } else { + Button( + onClick = onDeleteWallet, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFFD32F2F), + contentColor = Color.White + ) + ) { + Text("Delete Wallet") + } } // Footer @@ -3851,18 +4120,20 @@ private fun DeleteWalletConfirmContent( ) { Spacer(Modifier.height(32.dp)) + // Confirmation page uses the same iOS-red `#FF3B30` for all + // three flows (default-switch, NWC-disconnect, non-default + // delete) so the user doesn't see one orange and one red CTA + // for what's the same conceptual action — "stop using this + // wallet." Matches the iOS-red used on the entry-point card. + val confirmAccent = Color(0xFFFF3B30) Icon( if (isDefault) Icons.Default.SwapHoriz else Icons.Default.Close, contentDescription = null, modifier = Modifier .size(64.dp) - .background( - (if (isDefault) MaterialTheme.colorScheme.primary else Color(0xFFD32F2F)) - .copy(alpha = 0.1f), - CircleShape - ) + .background(confirmAccent.copy(alpha = 0.1f), CircleShape) .padding(16.dp), - tint = if (isDefault) MaterialTheme.colorScheme.primary else Color(0xFFD32F2F) + tint = confirmAccent ) Spacer(Modifier.height(24.dp)) @@ -3874,7 +4145,7 @@ private fun DeleteWalletConfirmContent( else -> "Delete Wallet" }, style = MaterialTheme.typography.headlineMedium, - color = if (isDefault) MaterialTheme.colorScheme.onSurface else Color(0xFFD32F2F) + color = if (isDefault) MaterialTheme.colorScheme.onSurface else confirmAccent ) Spacer(Modifier.height(16.dp)) @@ -3896,7 +4167,7 @@ private fun DeleteWalletConfirmContent( Text( "Make sure you have backed up your recovery phrase before proceeding.", style = MaterialTheme.typography.bodyMedium, - color = Color(0xFFD32F2F), + color = confirmAccent, textAlign = TextAlign.Center ) @@ -3917,11 +4188,10 @@ private fun DeleteWalletConfirmContent( onClick = onDelete, modifier = Modifier.fillMaxWidth(), enabled = isNwc || isDefault || confirmText == "DELETE", - colors = if (isDefault) ButtonDefaults.buttonColors() - else ButtonDefaults.buttonColors( - containerColor = Color(0xFFD32F2F), - contentColor = Color.White - ) + colors = ButtonDefaults.buttonColors( + containerColor = confirmAccent, + contentColor = Color.White + ) ) { Text( when { @@ -4455,11 +4725,21 @@ private fun WalletInfoRow( .padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically ) { + // Fixed-width label column so values across rows align to the + // same x-coordinate regardless of label length, and the value + // text always has a stable container to ellipsize within. + // Without this, "Lightning address" pushes its value column 30dp + // to the right of "Relay" — the iOS settings panel uses the + // same column-aligned layout. Text( label, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f) + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .widthIn(min = 110.dp) + .padding(end = 12.dp) ) Text( value, @@ -4467,7 +4747,8 @@ private fun WalletInfoRow( color = MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(2f) + textAlign = TextAlign.End, + modifier = Modifier.weight(1f) ) if (onCopy != null) { Spacer(Modifier.width(8.dp)) diff --git a/app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt b/app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt index 1aa20571..ab2d5452 100644 --- a/app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt +++ b/app/src/main/kotlin/com/wisp/app/viewmodel/WalletViewModel.kt @@ -81,6 +81,13 @@ sealed class AutoCheckState { object NotFound : AutoCheckState() } +sealed class NwcRestoreState { + object Idle : NwcRestoreState() + object Searching : NwcRestoreState() + data class Found(val uri: String, val createdAt: Long) : NwcRestoreState() + object NotFound : NwcRestoreState() +} + sealed class FeeState { object Idle : FeeState() object Loading : FeeState() @@ -254,6 +261,11 @@ class WalletViewModel( private val _autoCheckState = MutableStateFlow(AutoCheckState.Idle) val autoCheckState: StateFlow = _autoCheckState + // NWC connection-string restore from NIP-78 backup (per NWC_BACKUP_PARITY.md) + private val _nwcRestoreState = MutableStateFlow(NwcRestoreState.Idle) + val nwcRestoreState: StateFlow = _nwcRestoreState + private var nwcRestoreJob: Job? = null + // Per-relay backup status private val _relayBackupStatuses = MutableStateFlow>(emptyList()) val relayBackupStatuses: StateFlow> = _relayBackupStatuses @@ -332,6 +344,7 @@ class WalletViewModel( relayPool.registerDedupBypass("auto-check-") relayPool.registerDedupBypass("wallet-backup-") relayPool.registerDedupBypass("delete-backup-") + relayPool.registerDedupBypass("nwc-restore-") val mode = walletModeRepo.getMode() when (mode) { @@ -464,6 +477,7 @@ class WalletViewModel( fun selectNwcMode() { navigateTo(WalletPage.NwcSetup) + searchNwcBackup() } fun selectSparkMode() { @@ -607,6 +621,114 @@ class WalletViewModel( _autoCheckState.value = AutoCheckState.Idle } + // --- NWC backup (NIP-78 kind 30078, d=nwc-wallet-backup) --- + // + // Cross-platform with iOS per `NWC_BACKUP_PARITY.md`. Publish on every + // successful connect (best-effort); search on NWC setup screen open and + // surface a "Restore previous wallet" affordance when found. + + private fun searchNwcBackup() { + val signer = buildSigner() ?: run { + _nwcRestoreState.value = NwcRestoreState.NotFound + return + } + // Don't re-search while one is in flight or already has a result the + // user hasn't dismissed yet. + if (_nwcRestoreState.value is NwcRestoreState.Searching) return + nwcRestoreJob?.cancel() + _nwcRestoreState.value = NwcRestoreState.Searching + nwcRestoreJob = viewModelScope.launch { + try { + relayPool.ensureWriteRelaysConnected() + val pubkey = signer.pubkeyHex + val subId = "nwc-restore-${System.currentTimeMillis()}" + val filter = Nip78.nwcBackupFilter(pubkey) + val seenIds = mutableSetOf() + val events = mutableListOf() + var eoseCount = 0 + val collectJob = launch { + relayPool.relayEvents.collect { relayEvent: RelayEvent -> + if (relayEvent.subscriptionId == subId && seenIds.add(relayEvent.event.id)) { + events.add(relayEvent.event) + } + } + } + val eoseJob = launch { + relayPool.eoseSignals.collect { id -> + if (id == subId) eoseCount++ + } + } + yield() + val allCount = relayPool.getRelayUrls().size + val minEose = (allCount * 2 + 2) / 3 + relayPool.sendToAll(ClientMessage.req(subId, filter)) + withTimeoutOrNull(10_000) { + while (eoseCount < allCount) { + delay(200) + if (eoseCount >= minEose && events.isNotEmpty()) break + } + } + collectJob.cancel() + eoseJob.cancel() + relayPool.closeOnAllRelays(subId) + + val newest = events + .filter { !it.content.isBlank() } + .maxByOrNull { it.created_at } + if (newest == null) { + _nwcRestoreState.value = NwcRestoreState.NotFound + return@launch + } + val uri = withContext(Dispatchers.Default) { + Nip78.decryptNwcBackup(signer, newest) + } + if (uri.isNullOrBlank()) { + _nwcRestoreState.value = NwcRestoreState.NotFound + } else { + // Don't offer to restore if it's already the active connection. + val active = nwcRepo.getConnectionString() + if (active != null && active.trim() == uri.trim()) { + _nwcRestoreState.value = NwcRestoreState.NotFound + } else { + _nwcRestoreState.value = NwcRestoreState.Found(uri, newest.created_at) + } + } + } catch (_: Exception) { + _nwcRestoreState.value = NwcRestoreState.NotFound + } + } + } + + fun restoreFromNwcBackup() { + val state = _nwcRestoreState.value + if (state is NwcRestoreState.Found) { + _nwcRestoreState.value = NwcRestoreState.Idle + _connectionString.value = state.uri + connectNwcWallet(state.uri) + } + } + + fun dismissNwcRestore() { + nwcRestoreJob?.cancel() + _nwcRestoreState.value = NwcRestoreState.Idle + } + + private suspend fun publishNwcBackup(uri: String) { + val signer = buildSigner() ?: return + val trimmed = uri.trim() + if (trimmed.isEmpty()) return + try { + relayPool.ensureWriteRelaysConnected() + val event = withContext(Dispatchers.Default) { + Nip78.createNwcBackupEvent(signer, trimmed) + } + val sent = relayPool.sendToWriteRelays(ClientMessage.event(event)) + Log.d("NwcBackup", "publish: sent to $sent relays") + } catch (e: Exception) { + Log.d("NwcBackup", "publish failed (non-fatal): ${e.message}") + } + } + // --- NWC Connection --- fun updateConnectionString(value: String) { @@ -760,6 +882,25 @@ class WalletViewModel( // needed there. if (provider === nwcRepo) { launch { nwcRepo.fetchNodeInfo() } + // Best-effort cross-device backup of the URI per + // NWC_BACKUP_PARITY.md. Publish each connect so a + // reconnect / wallet swap replaces the prior backup. + val uri = nwcRepo.getConnectionString() + if (!uri.isNullOrBlank()) { + launch { publishNwcBackup(uri) } + } + } + // Once the wallet finishes connecting, leave the setup + // screen and land on Home — otherwise currentPage stays + // at NwcSetup/SparkSetup and the Scaffold's "Wallet" + // TopAppBar keeps showing over the dashboard. Guarded + // to the setup pages so a connect-during-navigation + // doesn't pop the user out of an unrelated sub-page. + val page = _currentPage.value + if (page is WalletPage.NwcSetup || page is WalletPage.SparkSetup) { + pageStack.clear() + pageStack.add(WalletPage.Home) + _currentPage.value = WalletPage.Home } } } @@ -818,16 +959,18 @@ class WalletViewModel( // Suppress the auto-create on the next navigateHome — the user // explicitly disconnected to choose a different wallet. They'll land - // on the SparkSetup screen with the three options. Persist so the - // choice survives app restarts. + // on the wallet-mode picker (Spark vs NWC), matching iOS. Persist + // so the choice survives app restarts. skipAutoCreate = true walletModeRepo.setAutoCreateSkipped(true) pageStack.clear() pageStack.add(WalletPage.Home) if (wasSpark && keyRepo.hasKeypair()) { - pageStack.add(WalletPage.SparkSetup) - _currentPage.value = WalletPage.SparkSetup + // Drop the user at the top-level wallet picker so they can + // re-enter via Spark or NWC, not just Spark. iOS does the + // same — Switch is a true "start over" affordance. + _currentPage.value = WalletPage.Home } else { _currentPage.value = WalletPage.Home } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 565d7414..736570a5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -732,10 +732,17 @@ Send and receive Lightning payments, and zap anyone on Nostr. Spark wallet Self-custody, embedded. Use your default wallet or restore from seed/relays. + Self-custody, embedded. Recommended. Nostr Wallet Connect Paste a connection string from Alby, Zeus, Rizful, Minibits, etc. Paste the connection string from your NWC-compatible wallet. Connection string starts with nostr+walletconnect:// + Backup found + Searching for backup… + Encrypted NWC backup from another device + A connection backed up on %1$s is available. Restore it to reconnect without pasting a string. + Restore connection + Connecting saves an encrypted backup to your Nostr relays so you can restore this connection on other devices. Self-custodial Lightning, powered by Spark and Breez. Create new wallet Generate a fresh 12-word seed phrase @@ -889,7 +896,10 @@ Generate invoice Use my default wallet Derived from your Nostr key — no extra backup needed. - Switch Wallet + More options + Switch to a different wallet Disconnect this wallet so you can use your default wallet or restore a different one. Your funds stay safe — you can reconnect this wallet anytime by entering its recovery phrase. + Disconnect Wallet + Your default wallet is linked to your key and can always be restored. Switching connects a different wallet instead. This wallet is derived from your Nostr key, so the phrase below is just for export to other wallet apps. You don\'t need to back it up — your nsec is the backup.