From 981296e8b00f0fc0c10046347931f8f92e471f9c Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 31 Aug 2026 14:23:04 +0100 Subject: [PATCH] fix(companion): backup + signer live inside the hub modal, not standalone screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field feedback on 0.5.28: the standalone Backup/Signer screens were hard to read over the synthwave background, back left the app instead of the menu, and they broke the hub's one-container interaction model. Both are now hub sub-pages exactly like Nodes/FIPS: - BackupSection / SignerSection (ui/components) render inside the NESMenu panel with the menu's own dark glass surface, scrim, and palette — the readability and theming problem disappears with the standalone surface. - The header back arrow returns to the hub card page (same as Nodes). - The panel height cap drops from 92% to 70% of the screen — ~15% breathing margin top and bottom; content scrolls inside. - The pairing QR scanner is hosted by NESMenu OUTSIDE the panel (QrGlassModal is a full-screen Box, not a Dialog — inside the panel's scroll it would clip), and decoded nostrconnect:// URIs funnel into the signer section through the same latch as the deep link. - The nostrconnect:// deep link now routes to the session and pops the hub open on the signer sub-page (SignerLaunch singleton) instead of a dedicated route; standalone screens and routes removed. - BunkerManager.refreshState is now a proper suspend fun (was runBlocking on the caller's dispatcher). Docs updated to the new locations. Rebuilt for on-device testing (v0.5.28-debug/vc48, same signing cert). --- .../archipelago/app/nostr/BunkerManager.kt | 15 +- .../app/ui/components/BackupSection.kt | 294 ++++++++++ .../archipelago/app/ui/components/NESMenu.kt | 94 +++- .../app/ui/components/SignerLaunch.kt | 14 + .../app/ui/components/SignerSection.kt | 381 +++++++++++++ .../archipelago/app/ui/navigation/NavGraph.kt | 42 +- .../app/ui/screens/BackupRestoreScreen.kt | 385 ------------- .../app/ui/screens/SignerScreen.kt | 511 ------------------ .../app/ui/screens/WebViewScreen.kt | 19 +- docs/companion-backup-restore.md | 9 +- docs/companion-nip46-remote-signer.md | 9 +- 11 files changed, 800 insertions(+), 973 deletions(-) create mode 100644 Android/app/src/main/java/com/archipelago/app/ui/components/BackupSection.kt create mode 100644 Android/app/src/main/java/com/archipelago/app/ui/components/SignerLaunch.kt create mode 100644 Android/app/src/main/java/com/archipelago/app/ui/components/SignerSection.kt delete mode 100644 Android/app/src/main/java/com/archipelago/app/ui/screens/BackupRestoreScreen.kt delete mode 100644 Android/app/src/main/java/com/archipelago/app/ui/screens/SignerScreen.kt diff --git a/Android/app/src/main/java/com/archipelago/app/nostr/BunkerManager.kt b/Android/app/src/main/java/com/archipelago/app/nostr/BunkerManager.kt index b8d9e644..86b0a1b9 100644 --- a/Android/app/src/main/java/com/archipelago/app/nostr/BunkerManager.kt +++ b/Android/app/src/main/java/com/archipelago/app/nostr/BunkerManager.kt @@ -99,21 +99,16 @@ object BunkerManager { private val session = AtomicReference(null) - fun refreshState(context: Context) { + /** Refresh Idle/NoKey state (suspend; call from a coroutine — DataStore reads hit disk). */ + suspend fun refreshState(context: Context) { if (!NativeCore.available) { _state.value = SignerState.Unavailable return } if (session.get() != null) return - // Called from a LaunchedEffect (IO dispatcher); DataStore read is quick - // but still a disk access — never on Main. - kotlinx.coroutines.runBlocking { - withContext(Dispatchers.IO) { - val prefs = NostrSignerPreferences(context.applicationContext) - _state.value = - if (prefs.secret() == null) SignerState.NoKey else SignerState.Idle - } - } + val prefs = NostrSignerPreferences(context.applicationContext) + _state.value = + if (prefs.secret() == null) SignerState.NoKey else SignerState.Idle } /** diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/BackupSection.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/BackupSection.kt new file mode 100644 index 00000000..805fadc0 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/BackupSection.kt @@ -0,0 +1,294 @@ +package com.archipelago.app.ui.components + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Restore +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.archipelago.app.data.BackupManager +import com.archipelago.app.ui.theme.BitcoinOrange +import com.archipelago.app.ui.theme.SuccessGreen +import com.archipelago.app.ui.theme.TextMuted +import com.archipelago.app.ui.theme.TextPrimary +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Backup & Restore (#128) — the hub's BACKUP sub-page (same container as + * Nodes/FIPS), the phone side of losing your phone or wiping it to cross a + * border. See docs/companion-backup-restore.md for the envelope and merge + * semantics; this composable is the flow only. + */ +@Composable +internal fun BackupSection() { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val manager = remember { BackupManager(context) } + + var passphrase by remember { mutableStateOf("") } + var confirm by remember { mutableStateOf("") } + var status by remember { mutableStateOf(null) } + var statusError by remember { mutableStateOf(false) } + var busy by remember { mutableStateOf(false) } + + // Decrypted backup awaiting the user's go-ahead (restore flow). + var restorePreview by remember { mutableStateOf?>(null) } + + fun say(msg: String, error: Boolean) { + status = msg + statusError = error + } + + val exportLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.CreateDocument("application/json") + ) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + scope.launch { + busy = true + try { + val envelope = manager.createBackup(passphrase) + withContext(Dispatchers.IO) { + context.contentResolver.openOutputStream(uri)?.use { out -> + out.write(envelope.toByteArray()) + } ?: throw BackupManager.BackupException("could not open the destination file") + } + say("Saved — keep the file and the passphrase somewhere safe.", false) + } catch (e: Exception) { + say(e.message ?: "backup failed", true) + } finally { + busy = false + } + } + } + + val importLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + scope.launch { + busy = true + try { + val envelope = withContext(Dispatchers.IO) { + context.contentResolver.openInputStream(uri)?.use { it.readBytes().decodeToString() } + ?: throw BackupManager.BackupException("could not read the selected file") + } + val (summary, payload) = manager.readBackup(envelope, passphrase) + restorePreview = summary to payload + } catch (e: Exception) { + say(e.message ?: "restore failed", true) + } finally { + busy = false + } + } + } + + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + SectionCopy( + "An encrypted copy of everything this phone holds — nodes and their passwords, " + + "your mesh identity, the remote-signer key. Same envelope your node uses (ADR-005), " + + "one passphrase, no cloud." + ) + + // ── Create a backup ────────────────────────────────────────────── + SectionHeader(Icons.Default.Save, "Create a backup") + GlassField( + value = passphrase, + onValueChange = { passphrase = it }, + placeholder = "Passphrase", + visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(), + ) + GlassField( + value = confirm, + onValueChange = { confirm = it }, + placeholder = "Repeat passphrase", + visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(), + ) + SectionHint("The passphrase cannot be recovered — a backup nobody can open is a paperweight.") + WideAction( + text = if (busy) "Working…" else "Save backup file", + onClick = { + if (busy) return@WideAction + if (passphrase.length < 8) { + say("Use at least 8 characters — this passphrase guards every secret in the app.", true) + return@WideAction + } + if (passphrase != confirm) { + say("The two passphrases don't match.", true) + return@WideAction + } + val stamp = SimpleDateFormat("yyyyMMdd-HHmm", Locale.US).format(Date()) + exportLauncher.launch("archy-companion-backup-$stamp.json") + }, + ) + + Spacer(Modifier.height(2.dp)) + + // ── Restore a backup ───────────────────────────────────────────── + SectionHeader(Icons.Default.Restore, "Restore a backup") + SectionHint( + "Nothing is overwritten: nodes merge by identity, and the mesh identity and " + + "signer key only restore when this phone has none." + ) + WideAction( + text = if (busy) "Working…" else "Choose backup file", + onClick = { + if (busy) return@WideAction + if (passphrase.isEmpty()) { + say("Enter the backup's passphrase first.", true) + return@WideAction + } + importLauncher.launch(arrayOf("application/json")) + }, + ) + + restorePreview?.let { (summary, payload) -> + Spacer(Modifier.height(2.dp)) + Column( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(Color.White.copy(alpha = 0.04f)) + .border(1.dp, Color.White.copy(alpha = 0.08f), RoundedCornerShape(14.dp)) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + "Backup verified${if (summary.appVersion.isNotBlank()) " (made by v${summary.appVersion})" else ""}", + color = SuccessGreen, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, + ) + SummaryRow("Nodes", summary.serverCount.toString()) + if (summary.hasFipsIdentity) SummaryRow("Mesh identity", "included") + if (summary.hasSignerKey) SummaryRow("Remote-signer key", "included") + WideAction( + text = if (busy) "Restoring…" else "Restore onto this phone", + onClick = { + if (busy) return@WideAction + scope.launch { + busy = true + try { + val result = manager.restoreBackup(payload) + restorePreview = null + passphrase = "" + confirm = "" + say( + "Restored ${result.serversRestored} node(s)" + + (if (result.activeSet) ", set active" else "") + + (if (result.fipsIdentityRestored) ", mesh identity" else "") + + (if (result.signerKeyRestored) ", signer key" else "") + + ". Restart the app to reconnect.", + false, + ) + } catch (e: Exception) { + say(e.message ?: "restore failed", true) + } finally { + busy = false + } + } + }, + ) + } + } + + status?.takeIf { it.isNotBlank() }?.let { msg -> + Text( + msg, + color = if (statusError) Color(0xFFFF6B6B) else SuccessGreen, + fontSize = 12.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@Composable +internal fun SectionHeader(icon: androidx.compose.ui.graphics.vector.ImageVector, title: String) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon(icon, contentDescription = null, tint = BitcoinOrange, modifier = Modifier.size(18.dp)) + Text(title, color = TextPrimary, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) + } +} + +@Composable +internal fun SectionCopy(text: String) { + Text(text, color = TextMuted, fontSize = 12.sp, lineHeight = 16.sp) +} + +@Composable +internal fun SectionHint(text: String) { + Text(text, color = TextMuted.copy(alpha = 0.8f), fontSize = 10.sp, lineHeight = 13.sp) +} + +/** Wide orange-outline action button in the menu's visual language. */ +@Composable +internal fun WideAction( + text: String, + onClick: () -> Unit, + icon: androidx.compose.ui.graphics.vector.ImageVector? = null, +) { + Row( + Modifier + .fillMaxWidth() + .height(44.dp) + .clip(RoundedCornerShape(12.dp)) + .background(BitcoinOrange.copy(alpha = 0.15f)) + .border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp)) + .clickable { onClick() }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + if (icon != null) { + Icon(icon, contentDescription = null, tint = BitcoinOrange, modifier = Modifier.size(16.dp)) + Spacer(Modifier.size(8.dp)) + } + Text(text, color = BitcoinOrange, fontSize = 13.sp, fontWeight = FontWeight.Bold) + } +} + +@Composable +internal fun SummaryRow(label: String, value: String) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(label, color = TextMuted, fontSize = 12.sp) + Text(value, color = TextPrimary, fontSize = 12.sp, fontWeight = FontWeight.Medium) + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt index 5c70b140..b1666ea2 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt @@ -108,24 +108,62 @@ fun NESMenu( onKeyboard: () -> Unit, onBackToWebView: (() -> Unit)? = null, onMeshParty: (() -> Unit)? = null, - onBackupRestore: (() -> Unit)? = null, - onSigner: (() -> Unit)? = null, + // Remote-signer pairing request (nostrconnect://… deep link, or a scan): + // non-null opens the hub on the signer sub-page and pairs. Consumed once + // the signer section hands it back via [onSignerPairHandled]. + signerPairRequest: String? = null, + onSignerPairHandled: () -> Unit = {}, ) { + // Pairing state is latched here (not passed straight through) so the + // source can clear itself while the request stays alive until consumed. + var pendingSignerPair by remember { mutableStateOf(null) } + var signerScan by remember { mutableStateOf(false) } + LaunchedEffect(signerPairRequest) { + if (signerPairRequest != null) pendingSignerPair = signerPairRequest + } + AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) { // Contained hub overlay: a centred glass panel (not full-screen) that - // holds the card page and its sub-pages (Nodes, FIPS) and scrolls - // inside its own bounds when content is tall. Tapping the dimmed - // backdrop dismisses. + // holds the card page and its sub-pages (Nodes, FIPS, Backup, Signer) + // and scrolls inside its own bounds when content is tall. Tapping the + // dimmed backdrop dismisses. Box( Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.7f)) .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onDismiss() }, contentAlignment = Alignment.Center, ) { AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) { - MenuPanel(servers, activeServer, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onRemote, onKeyboard, onBackToWebView, onMeshParty, onBackupRestore, onSigner) + MenuPanel( + servers, activeServer, onDismiss, onSelectServer, onAddServer, onScanQr, + onEditServer, onRemoveServer, onRemote, onKeyboard, onBackToWebView, onMeshParty, + signerPairUri = pendingSignerPair, + onSignerScan = { signerScan = true }, + onSignerPairHandled = { + pendingSignerPair = null + onSignerPairHandled() + }, + ) } } } + + // Pairing-QR scanner for the signer sub-page — a full-screen glass + // modal hosted OUTSIDE the hub panel so it isn't clipped to the panel's + // bounds (same layering the pairing scanner gets from WebViewScreen). + QrGlassModal( + visible = signerScan && visible, + title = "Scan pairing QR", + status = null, + idleHint = "Point at the nostrconnect QR the node or client shows", + permissionRationale = "Camera access is needed to scan the pairing code", + onDismiss = { signerScan = false }, + onDecoded = { text -> + if (text.startsWith("nostrconnect://")) { + signerScan = false + pendingSignerPair = text + } + }, + ) } @Composable @@ -142,8 +180,9 @@ private fun MenuPanel( onKeyboard: () -> Unit, onBackToWebView: (() -> Unit)?, onMeshParty: (() -> Unit)?, - onBackupRestore: (() -> Unit)?, - onSigner: (() -> Unit)?, + signerPairUri: String?, + onSignerScan: () -> Unit, + onSignerPairHandled: () -> Unit, ) { var showAdd by remember { mutableStateOf(false) } // The saved server being edited, or null when adding a new one. @@ -182,9 +221,10 @@ private fun MenuPanel( .widthIn(max = 420.dp) .fillMaxWidth() .padding(horizontal = 20.dp) - // Cap height just short of the full screen; the panel wraps short - // content and only scrolls in the rare case it outgrows this. - .heightIn(max = (LocalConfiguration.current.screenHeightDp * 0.92f).dp) + // Cap height at 70% of the screen — a ~15% breathing margin top + // and bottom — the panel wraps short content and scrolls inside + // its own bounds when a sub-page outgrows this. + .heightIn(max = (LocalConfiguration.current.screenHeightDp * 0.70f).dp) .clip(RoundedCornerShape(PANEL_R)) .background(PanelBg.copy(alpha = 0.86f)) .border(1.dp, PanelBorder, RoundedCornerShape(PANEL_R)) @@ -207,7 +247,13 @@ private fun MenuPanel( IconRound(Icons.AutoMirrored.Filled.ArrowBack, "Back") { resetForm(); page = HubPage.HUB } Spacer(Modifier.width(12.dp)) Text( - if (page == HubPage.NODES) "Nodes" else "FIPS Mesh", + when (page) { + HubPage.NODES -> "Nodes" + HubPage.FIPS -> "FIPS Mesh" + HubPage.BACKUP -> "Backup & Restore" + HubPage.SIGNER -> "Remote Signer" + HubPage.HUB -> "Menu" + }, color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.sp, ) } @@ -241,14 +287,10 @@ private fun MenuPanel( } // Backup & Restore (#128): the phone side of losing your phone // or wiping it to cross a border — encrypted export file, no cloud. - if (onBackupRestore != null) { - HubCard(Icons.Default.SettingsBackupRestore, "Backup & Restore", "Encrypted export for a wiped phone") { onBackupRestore() } - } + HubCard(Icons.Default.SettingsBackupRestore, "Backup & Restore", "Encrypted export for a wiped phone") { page = HubPage.BACKUP } // Remote Signer (#139): hold a nostr key on the phone and // approve/deny remote signature requests (NIP-46). - if (onSigner != null) { - HubCard(Icons.Default.Key, "Remote Signer", "Approve signatures for your node") { onSigner() } - } + HubCard(Icons.Default.Key, "Remote Signer", "Approve signatures for your node") { page = HubPage.SIGNER } // Dark/Classic style lives on the remote/keyboard screen next to // the settings button — not here. @@ -407,11 +449,23 @@ private fun MenuPanel( HubPage.FIPS -> { FipsSection(embedded = true) } + + HubPage.BACKUP -> { + BackupSection() + } + + HubPage.SIGNER -> { + SignerSection( + pairUri = signerPairUri, + onScan = onSignerScan, + onPairHandled = onSignerPairHandled, + ) + } } } } -private enum class HubPage { HUB, NODES, FIPS } +private enum class HubPage { HUB, NODES, FIPS, BACKUP, SIGNER } /** Big tappable destination card for the hub page: icon + title + subtitle. */ @Composable @@ -639,7 +693,7 @@ private fun MenuItem( /** Glass text field with centered input text. */ @Composable -private fun GlassField( +internal fun GlassField( value: String, onValueChange: (String) -> Unit, placeholder: String, diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/SignerLaunch.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/SignerLaunch.kt new file mode 100644 index 00000000..e62fb94f --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/SignerLaunch.kt @@ -0,0 +1,14 @@ +package com.archipelago.app.ui.components + +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * Cross-layer handoff for remote-signer pairing (#139): NavGraph's + * `nostrconnect://` deep link drops the URI here and routes to the session; + * WebViewScreen collects it, opens the hub menu, and NESMenu opens the + * signer sub-page with the request. Cleared once the signer section has + * consumed it (via NESMenu's onSignerPairHandled). + */ +object SignerLaunch { + val pendingUri = MutableStateFlow(null) +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/SignerSection.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/SignerSection.kt new file mode 100644 index 00000000..87e2c980 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/SignerSection.kt @@ -0,0 +1,381 @@ +package com.archipelago.app.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Key +import androidx.compose.material.icons.filled.QrCodeScanner +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.archipelago.app.NativeCore +import com.archipelago.app.nostr.BunkerManager +import com.archipelago.app.nostr.NostrSignerPreferences +import com.archipelago.app.ui.theme.BitcoinOrange +import com.archipelago.app.ui.theme.SuccessGreen +import com.archipelago.app.ui.theme.TextMuted +import com.archipelago.app.ui.theme.TextPrimary +import kotlinx.coroutines.launch +import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Remote Signer (#139) — the hub's SIGNER sub-page (same container as + * Nodes/FIPS). The phone holds a nostr key; a NIP-46 client (the node's + * login QR, any nostrconnect:// app) pairs via [pairUri] or the scanner + * (hosted by NESMenu outside this panel), and every `sign_event` request + * lands as a legible approve/deny card. See + * docs/companion-nip46-remote-signer.md. + */ +@Composable +internal fun SignerSection( + pairUri: String?, + onScan: () -> Unit, + onPairHandled: () -> Unit, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val clipboard = LocalClipboardManager.current + val prefs = remember { NostrSignerPreferences(context) } + + var keyInfo by remember { mutableStateOf(null) } + var keyError by remember { mutableStateOf(null) } + var importText by remember { mutableStateOf("") } + var showNsec by remember { mutableStateOf(false) } + var notice by remember { mutableStateOf(null) } + var noticeError by remember { mutableStateOf(false) } + + val bunkerState by BunkerManager.state.collectAsState() + val pending by BunkerManager.pending.collectAsState() + + fun say(msg: String, error: Boolean) { + notice = msg + noticeError = error + } + + suspend fun loadKey() { + val secret = prefs.secret() + keyInfo = secret?.let { + val json = NativeCore.nostrSecretFromAny(it) + if (NativeCore.isErr(json)) null else JSONObject(json) + } + } + + LaunchedEffect(Unit) { + BunkerManager.refreshState(context) + loadKey() + } + + // Consume a pairing request (deep link or scanner) exactly once. + LaunchedEffect(pairUri) { + val uri = pairUri?.takeIf { it.isNotBlank() } ?: return@LaunchedEffect + if (keyInfo == null) loadKey() + val err = BunkerManager.pair(context, uri) + if (err != null) say(err, true) else say("Pairing started…", false) + onPairHandled() + } + + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + SectionCopy( + "Hold a nostr key on this phone and sign for it remotely — pair with your " + + "node's login QR (or any NIP-46 client), then approve each signature " + + "request as it arrives. Nothing signs without you." + ) + + if (bunkerState is BunkerManager.SignerState.Unavailable) { + Text( + "Signing is unavailable on this device (native core missing).", + color = Color(0xFFFF6B6B), fontSize = 12.sp, + ) + } + + val info = keyInfo + if (info == null) { + // ── No key yet: generate or import ────────────────────────── + keyError?.let { Text(it, color = Color(0xFFFF6B6B), fontSize = 11.sp) } + WideAction(text = "Generate signer key", onClick = { + scope.launch { + try { + keyInfo = prefs.generateSecret() + keyError = null + BunkerManager.refreshState(context) + } catch (e: Exception) { + keyError = e.message ?: "could not generate a key" + } + } + }) + GlassField( + value = importText, + onValueChange = { importText = it }, + placeholder = "or import nsec…", + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onGo = { + if (importText.isNotBlank()) { + scope.launch { + try { + keyInfo = prefs.importSecret(importText) + importText = "" + keyError = null + BunkerManager.refreshState(context) + } catch (e: Exception) { + keyError = e.message ?: "not a valid nsec" + } + } + } + }), + ) + WideAction(text = "Import", onClick = { + if (importText.isBlank()) return@WideAction + scope.launch { + try { + keyInfo = prefs.importSecret(importText) + importText = "" + keyError = null + BunkerManager.refreshState(context) + } catch (e: Exception) { + keyError = e.message ?: "not a valid nsec" + } + } + }) + } else { + // ── Identity ───────────────────────────────────────────────── + SectionHeader(Icons.Default.Key, "Signer identity") + MonoValue("npub", info.optString("npub")) { + clipboard.setText(AnnotatedString(info.optString("npub"))) + } + if (showNsec) { + MonoValue("nsec", info.optString("nsec"), secret = true) { + clipboard.setText(AnnotatedString(info.optString("nsec"))) + } + SectionHint("Anyone with the nsec can sign as you — clear the clipboard after copying.") + } else { + Text( + "Show nsec", + color = TextMuted, fontSize = 11.sp, + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .clickable { showNsec = true } + .padding(vertical = 2.dp, horizontal = 6.dp), + ) + } + + // ── Session ────────────────────────────────────────────────── + Spacer(Modifier.height(2.dp)) + val label = when (val s = bunkerState) { + BunkerManager.SignerState.Unavailable -> "Unavailable on this device" + BunkerManager.SignerState.NoKey -> "No signer key yet" + BunkerManager.SignerState.Idle -> "Idle — pair to start" + is BunkerManager.SignerState.Connecting -> "Connecting to ${s.relay}…" + is BunkerManager.SignerState.AwaitingClient -> "Paired with \"${s.clientName}\" — waiting for the handshake to finish" + is BunkerManager.SignerState.Ready -> "Ready for \"${s.clientName}\"" + is BunkerManager.SignerState.Failed -> s.reason + } + Text("Session", color = TextMuted, fontSize = 11.sp) + Text( + label, + color = if (bunkerState is BunkerManager.SignerState.Failed) Color(0xFFFF6B6B) + else if (bunkerState is BunkerManager.SignerState.Ready) SuccessGreen + else TextPrimary, + fontSize = 13.sp, + lineHeight = 17.sp, + ) + WideAction( + text = "Scan pairing QR", + onClick = { + if (bunkerState is BunkerManager.SignerState.NoKey) { + say("Generate or import a signer key first.", true) + return@WideAction + } + onScan() + }, + icon = Icons.Default.QrCodeScanner, + ) + if (bunkerState is BunkerManager.SignerState.Ready || + bunkerState is BunkerManager.SignerState.AwaitingClient || + bunkerState is BunkerManager.SignerState.Connecting + ) { + Text( + "End session", + color = TextMuted, fontSize = 11.sp, + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .clickable { BunkerManager.unpair() } + .padding(vertical = 2.dp, horizontal = 6.dp), + ) + } + + // ── Pending signature request — the whole point ────────────── + pending?.let { req -> + Spacer(Modifier.height(2.dp)) + Column( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(Color.White.copy(alpha = 0.04f)) + .border(1.dp, BitcoinOrange.copy(alpha = 0.35f), RoundedCornerShape(14.dp)) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text("Signature request", color = BitcoinOrange, fontSize = 13.sp, fontWeight = FontWeight.Bold) + SummaryRow("Client", req.clientName.ifBlank { req.clientPubkey.take(12) + "…" }) + SummaryRow("Kind", kindLabel(req.kind)) + req.createdAt?.let { + SummaryRow("Time", SimpleDateFormat("HH:mm:ss", Locale.US).format(Date(it * 1000))) + } + req.content?.takeIf { it.isNotBlank() }?.let { content -> + Text( + content, + color = TextPrimary, fontSize = 10.sp, lineHeight = 14.sp, + fontFamily = FontFamily.Monospace, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(Color.Black.copy(alpha = 0.45f)) + .padding(8.dp) + .heightIn(max = 160.dp), + ) + } + if (req.tags.isNotEmpty()) { + Column( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(Color.Black.copy(alpha = 0.45f)) + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + req.tags.take(6).forEach { + Text( + it, + color = TextMuted, fontSize = 9.sp, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (req.tags.size > 6) { + Text("+${req.tags.size - 6} more", color = TextMuted, fontSize = 9.sp) + } + } + } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Box( + Modifier + .weight(1f) + .height(40.dp) + .clip(RoundedCornerShape(12.dp)) + .background(Color(0xFFE5484D).copy(alpha = 0.16f)) + .border(1.dp, Color(0xFFE5484D).copy(alpha = 0.5f), RoundedCornerShape(12.dp)) + .clickable { BunkerManager.deny() }, + contentAlignment = Alignment.Center, + ) { Text("Deny", color = Color(0xFFFF8A8D), fontSize = 13.sp, fontWeight = FontWeight.Bold) } + Box( + Modifier + .weight(1f) + .height(40.dp) + .clip(RoundedCornerShape(12.dp)) + .background(BitcoinOrange.copy(alpha = 0.2f)) + .border(1.dp, BitcoinOrange.copy(alpha = 0.6f), RoundedCornerShape(12.dp)) + .clickable { + scope.launch { + val ok = BunkerManager.approve() + say(if (ok) "Signed and sent." else "Could not send the signature.", !ok) + } + }, + contentAlignment = Alignment.Center, + ) { Text("Approve", color = BitcoinOrange, fontSize = 13.sp, fontWeight = FontWeight.Bold) } + } + } + } + } + + notice?.takeIf { it.isNotBlank() }?.let { msg -> + Text( + msg, + color = if (noticeError) Color(0xFFFF6B6B) else SuccessGreen, + fontSize = 12.sp, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +/** Kind number → legible label, so the approve/deny card reads like a sentence. */ +private fun kindLabel(kind: Long?): String = when (kind) { + 0L -> "Metadata (kind 0)" + 1L -> "Text note (kind 1)" + 3L -> "Contact list (kind 3)" + 4L -> "Direct message (kind 4)" + 7L -> "Reaction (kind 7)" + 14L -> "Chat message (kind 14)" + 22242L -> "Client authentication (kind 22242)" + 30078L -> "App-stored data (kind 30078)" + null -> "Unknown kind" + else -> "Kind $kind" +} + +/** Monospace value chip with a copy affordance (tap the row). */ +@Composable +private fun MonoValue(label: String, value: String, secret: Boolean = false, onCopy: () -> Unit) { + Column(Modifier.fillMaxWidth()) { + Text(label, color = TextMuted, fontSize = 10.sp) + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(Color.Black.copy(alpha = 0.45f)) + .clickable { onCopy() } + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + value, + color = if (secret) Color(0xFFFFB86B) else TextPrimary, + fontSize = 10.sp, + fontFamily = FontFamily.Monospace, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text("⧉", color = TextMuted, fontSize = 13.sp, modifier = Modifier.padding(start = 8.dp)) + } + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/navigation/NavGraph.kt b/Android/app/src/main/java/com/archipelago/app/ui/navigation/NavGraph.kt index 6126b139..3c39ca0a 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/navigation/NavGraph.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/navigation/NavGraph.kt @@ -22,14 +22,13 @@ import com.archipelago.app.data.ServerEntry import com.archipelago.app.data.ServerPreferences import com.archipelago.app.data.ServerQrParser import com.archipelago.app.fips.FipsManager -import com.archipelago.app.ui.screens.BackupRestoreScreen +import com.archipelago.app.ui.components.SignerLaunch import com.archipelago.app.ui.screens.FlareScreen import com.archipelago.app.ui.screens.IntroScreen import com.archipelago.app.ui.screens.NodePickerScreen import com.archipelago.app.ui.screens.PartyScreen import com.archipelago.app.ui.screens.RemoteInputScreen import com.archipelago.app.ui.screens.ServerConnectScreen -import com.archipelago.app.ui.screens.SignerScreen import com.archipelago.app.ui.screens.WebViewScreen import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -43,8 +42,6 @@ object Routes { const val REMOTE_INPUT = "remote_input" const val MESH_PARTY = "mesh_party" const val FLARE = "flare" - const val BACKUP_RESTORE = "backup_restore" - const val SIGNER = "signer" } /** @@ -140,9 +137,15 @@ fun AppNavHost( when { // Remote-signer pairing deep link (NIP-46): nostrconnect://… // from the node's login QR — any QR scanner app can hand it over. + // The signer UI lives inside the hub menu: drop the URI where + // WebViewScreen picks it up and route to the session, which opens + // the hub on its signer sub-page. raw.startsWith("nostrconnect://") -> { prefs.markIntroSeen() - navController.navigate("${Routes.SIGNER}?uri=${android.net.Uri.encode(raw)}") + SignerLaunch.pendingUri.value = raw + navController.navigate(Routes.WEB_VIEW) { + popUpTo(0) { inclusive = true } + } } else -> when (val result = ServerQrParser.parse(raw)) { is PairResult.Success -> { @@ -271,12 +274,6 @@ fun AppNavHost( onMeshParty = { navController.navigate(Routes.MESH_PARTY) }, - onBackupRestore = { - navController.navigate(Routes.BACKUP_RESTORE) - }, - onSigner = { - navController.navigate(Routes.SIGNER) - }, ) } } @@ -313,28 +310,5 @@ fun AppNavHost( onBack = { navController.popBackStack() }, ) } - - composable(Routes.BACKUP_RESTORE) { - BackupRestoreScreen( - onBack = { navController.popBackStack() }, - ) - } - - composable( - "${Routes.SIGNER}?uri={uri}", - arguments = listOf( - navArgument("uri") { - type = NavType.StringType - defaultValue = "" - }, - ), - ) { entry -> - SignerScreen( - onBack = { navController.popBackStack() }, - initialPairUri = entry.arguments?.getString("uri")?.let { uri -> - if (uri.isBlank()) null else android.net.Uri.decode(uri) - }, - ) - } } } diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/BackupRestoreScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/BackupRestoreScreen.kt deleted file mode 100644 index 6a1b09af..00000000 --- a/Android/app/src/main/java/com/archipelago/app/ui/screens/BackupRestoreScreen.kt +++ /dev/null @@ -1,385 +0,0 @@ -package com.archipelago.app.ui.screens - -import android.net.Uri -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Key -import androidx.compose.material.icons.filled.Restore -import androidx.compose.material.icons.filled.Save -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -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.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.archipelago.app.R -import com.archipelago.app.data.BackupManager -import com.archipelago.app.ui.theme.BitcoinOrange -import com.archipelago.app.ui.theme.SuccessGreen -import com.archipelago.app.ui.theme.SurfaceBlack -import com.archipelago.app.ui.theme.TextMuted -import com.archipelago.app.ui.theme.TextPrimary -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale - -/** - * Backup & Restore (#128) — the phone side of "losing your phone, or wiping - * it to cross a border". - * - * Export seals everything the companion holds (servers, FIPS identity and - * peers, the remote-signer key) into the node's ADR-005 envelope and hands it - * to the system file picker; import decrypts, previews and merges it. On - * GrapheneOS there is no cloud anything — the .json goes wherever the user - * saves it (USB drive, computer, a folder synced their way), and the - * passphrase is the only way back in. - */ -@Composable -fun BackupRestoreScreen(onBack: () -> Unit) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - val manager = remember { BackupManager(context) } - - var passphrase by remember { mutableStateOf("") } - var confirm by remember { mutableStateOf("") } - var status by remember { mutableStateOf(null) } - var statusError by remember { mutableStateOf(false) } - var busy by remember { mutableStateOf(false) } - - // Decrypted preview awaiting the user's go-ahead (restore flow). - var restorePreview by remember { mutableStateOf?>(null) } - - fun say(msg: String, error: Boolean) { - status = msg - statusError = error - } - - val exportLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.CreateDocument("application/json") - ) { uri -> - if (uri == null) return@rememberLauncherForActivityResult - scope.launch { - busy = true - try { - val envelope = manager.createBackup(passphrase) - withContext(Dispatchers.IO) { - context.contentResolver.openOutputStream(uri)?.use { out -> - out.write(envelope.toByteArray()) - } ?: throw BackupManager.BackupException("could not open the destination file") - } - say("Backup saved — keep the file and the passphrase somewhere safe.", false) - } catch (e: Exception) { - say(e.message ?: "backup failed", true) - } finally { - busy = false - } - } - } - - val importLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.OpenDocument() - ) { uri -> - if (uri == null) return@rememberLauncherForActivityResult - scope.launch { - busy = true - try { - val envelope = withContext(Dispatchers.IO) { - context.contentResolver.openInputStream(uri)?.use { it.readBytes().decodeToString() } - ?: throw BackupManager.BackupException("could not read the selected file") - } - val (summary, payload) = manager.readBackup(envelope, passphrase) - restorePreview = summary to payload - say("", false) - } catch (e: Exception) { - say(e.message ?: "restore failed", true) - } finally { - busy = false - } - } - } - - Box( - modifier = Modifier - .fillMaxSize() - .background(SurfaceBlack), - ) { - Image( - painter = painterResource(id = R.drawable.bg_synthwave), - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) - Box( - modifier = Modifier - .fillMaxSize() - .background( - Brush.verticalGradient( - colors = listOf( - Color.Black.copy(alpha = 0.65f), - Color.Black.copy(alpha = 0.5f), - Color.Black.copy(alpha = 0.85f), - ), - ) - ), - ) - Column( - modifier = Modifier - .fillMaxSize() - .windowInsetsPadding(WindowInsets.safeDrawing) - .verticalScroll(rememberScrollState()) - .padding(horizontal = 24.dp) - .padding(top = 16.dp, bottom = 32.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - // Header - Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = onBack) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - tint = TextPrimary, - ) - } - Text( - "Backup & Restore", - style = androidx.compose.material3.MaterialTheme.typography.headlineSmall, - color = TextPrimary, - ) - } - Text( - "An encrypted copy of everything this phone holds — nodes and their passwords, your mesh identity, the remote-signer key. Same envelope your node uses (ADR-005), one passphrase, no cloud.", - style = androidx.compose.material3.MaterialTheme.typography.bodyMedium, - color = TextMuted, - ) - - GlassCard { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Icon(Icons.Default.Save, contentDescription = null, tint = BitcoinOrange, modifier = Modifier.size(20.dp)) - Text("Create a backup", color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.SemiBold) - } - PassphraseField( - value = passphrase, - onValueChange = { passphrase = it }, - placeholder = "Passphrase", - ) - PassphraseField( - value = confirm, - onValueChange = { confirm = it }, - placeholder = "Repeat passphrase", - ) - Text( - "The passphrase cannot be recovered — a backup nobody can open is a paperweight.", - color = TextMuted, fontSize = 11.sp, - ) - GlassButton( - text = if (busy) "Working…" else "Save backup file", - onClick = { - if (busy) return@GlassButton - if (passphrase.length < 8) { - say("Use at least 8 characters — this passphrase guards every secret in the app.", true) - return@GlassButton - } - if (passphrase != confirm) { - say("The two passphrases don't match.", true) - return@GlassButton - } - val stamp = SimpleDateFormat("yyyyMMdd-HHmm", Locale.US).format(Date()) - exportLauncher.launch("archy-companion-backup-$stamp.json") - }, - modifier = Modifier.fillMaxWidth().height(48.dp), - ) - } - } - - GlassCard { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Icon(Icons.Default.Restore, contentDescription = null, tint = BitcoinOrange, modifier = Modifier.size(20.dp)) - Text("Restore a backup", color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.SemiBold) - } - Text( - "Pick a backup file and enter its passphrase. Nothing is overwritten: nodes merge by identity, and the mesh identity and signer key only restore when this phone has none.", - color = TextMuted, fontSize = 11.sp, - ) - PassphraseField( - value = passphrase, - onValueChange = { passphrase = it }, - placeholder = "Backup passphrase", - ) - GlassButton( - text = if (busy) "Working…" else "Choose backup file", - onClick = { - if (busy) return@GlassButton - if (passphrase.isEmpty()) { - say("Enter the backup's passphrase first.", true) - return@GlassButton - } - importLauncher.launch(arrayOf("application/json")) - }, - modifier = Modifier.fillMaxWidth().height(48.dp), - ) - } - } - - restorePreview?.let { (summary, payload) -> - GlassCard { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - Text( - "Backup verified${if (summary.appVersion.isNotBlank()) " (made by v${summary.appVersion})" else ""}", - color = SuccessGreen, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, - ) - SummaryRow("Nodes", summary.serverCount.toString()) - if (summary.hasFipsIdentity) SummaryRow("Mesh identity", "included") - if (summary.hasSignerKey) SummaryRow("Remote-signer key", "included") - GlassButton( - text = if (busy) "Restoring…" else "Restore onto this phone", - onClick = { - if (busy) return@GlassButton - scope.launch { - busy = true - try { - val result = manager.restoreBackup(payload) - restorePreview = null - passphrase = "" - confirm = "" - say( - "Restored ${result.serversRestored} node(s)" + - (if (result.activeSet) ", set active" else "") + - (if (result.fipsIdentityRestored) ", mesh identity" else "") + - (if (result.signerKeyRestored) ", signer key" else "") + - ". Restart the app to reconnect.", - false, - ) - } catch (e: Exception) { - say(e.message ?: "restore failed", true) - } finally { - busy = false - } - } - }, - modifier = Modifier.fillMaxWidth().height(48.dp), - ) - } - } - } - - status?.takeIf { it.isNotBlank() }?.let { msg -> - Text( - msg, - color = if (statusError) Color(0xFFFF6B6B) else SuccessGreen, - fontSize = 13.sp, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - } - } - } -} - -@Composable -private fun PassphraseField( - value: String, - onValueChange: (String) -> Unit, - placeholder: String, -) { - OutlinedTextField( - value = value, - onValueChange = onValueChange, - placeholder = { Text(placeholder, color = TextMuted, fontSize = 14.sp) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done, - ), - keyboardActions = KeyboardActions(), - textStyle = TextStyle(color = TextPrimary, fontSize = 15.sp), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = BitcoinOrange, - unfocusedBorderColor = Color.White.copy(alpha = 0.15f), - cursorColor = BitcoinOrange, - focusedTextColor = TextPrimary, - unfocusedTextColor = TextPrimary, - ), - shape = RoundedCornerShape(12.dp), - ) -} - -@Composable -private fun SummaryRow(label: String, value: String) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text(label, color = TextMuted, fontSize = 13.sp) - Text(value, color = TextPrimary, fontSize = 13.sp, fontWeight = FontWeight.Medium) - } -} - -/** House glass card: translucent panel, hairline border. */ -@Composable -fun GlassCard(content: @Composable () -> Unit) { - Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(16.dp)) - .background(Color.White.copy(alpha = 0.06f)) - .padding(16.dp), - ) { - content() - } -} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/SignerScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/SignerScreen.kt deleted file mode 100644 index 8eba7235..00000000 --- a/Android/app/src/main/java/com/archipelago/app/ui/screens/SignerScreen.kt +++ /dev/null @@ -1,511 +0,0 @@ -package com.archipelago.app.ui.screens - -import android.content.Intent -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Key -import androidx.compose.material.icons.filled.QrCodeScanner -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -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.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalClipboardManager -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.archipelago.app.NativeCore -import com.archipelago.app.R -import com.archipelago.app.nostr.BunkerManager -import com.archipelago.app.nostr.NostrSignerPreferences -import com.archipelago.app.ui.components.QrGlassModal -import com.archipelago.app.ui.theme.BitcoinOrange -import com.archipelago.app.ui.theme.SuccessGreen -import com.archipelago.app.ui.theme.SurfaceBlack -import com.archipelago.app.ui.theme.TextMuted -import com.archipelago.app.ui.theme.TextPrimary -import kotlinx.coroutines.launch -import org.json.JSONObject -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale - -/** - * Remote Signer (#139) — the phone side of NIP-46. - * - * The phone holds a nostr key; the node's login page (or any NIP-46 client) - * shows a `nostrconnect://` QR, this screen scans it, and every signature - * request lands here as a legible approve/deny card — kind, content, tags — - * before anything is signed. Nothing signs without a thumb on Approve. - */ -@Composable -fun SignerScreen( - onBack: () -> Unit, - initialPairUri: String? = null, -) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - val clipboard = LocalClipboardManager.current - val prefs = remember { NostrSignerPreferences(context) } - - var keyInfo by remember { mutableStateOf(null) } - var keyError by remember { mutableStateOf(null) } - var importText by remember { mutableStateOf("") } - var showNsec by remember { mutableStateOf(false) } - var scanVisible by remember { mutableStateOf(false) } - var status by remember { mutableStateOf(null) } - var statusError by remember { mutableStateOf(false) } - - val bunkerState by BunkerManager.state.collectAsState() - val pending by BunkerManager.pending.collectAsState() - - fun say(msg: String, error: Boolean) { - status = msg - statusError = error - } - - suspend fun loadKey() { - val secret = prefs.secret() - keyInfo = secret?.let { - val json = NativeCore.nostrSecretFromAny(it) - if (NativeCore.isErr(json)) null else JSONObject(json) - } - } - - LaunchedEffect(Unit) { - BunkerManager.refreshState(context) - loadKey() - } - - // Deep link (nostrconnect://…) may arrive with the route. - LaunchedEffect(initialPairUri) { - val uri = initialPairUri?.takeIf { it.isNotBlank() } ?: return@LaunchedEffect - if (keyInfo == null) loadKey() - val err = BunkerManager.pair(context, uri) - if (err != null) say(err, true) else say("Pairing started…", false) - } - - fun pairWith(uri: String) { - scope.launch { - val err = BunkerManager.pair(context, uri) - if (err != null) say(err, true) - } - } - - Box( - modifier = Modifier - .fillMaxSize() - .background(SurfaceBlack), - ) { - Image( - painter = painterResource(id = R.drawable.bg_synthwave), - contentDescription = null, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) - Box( - modifier = Modifier - .fillMaxSize() - .background( - Brush.verticalGradient( - colors = listOf( - Color.Black.copy(alpha = 0.65f), - Color.Black.copy(alpha = 0.5f), - Color.Black.copy(alpha = 0.85f), - ), - ) - ), - ) - Column( - modifier = Modifier - .fillMaxSize() - .windowInsetsPadding(WindowInsets.safeDrawing) - .verticalScroll(rememberScrollState()) - .padding(horizontal = 24.dp) - .padding(top = 16.dp, bottom = 32.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", tint = TextPrimary) - } - Text( - "Remote Signer", - style = androidx.compose.material3.MaterialTheme.typography.headlineSmall, - color = TextPrimary, - ) - } - Text( - "Hold a nostr key on this phone and sign for it remotely — scan a pairing QR from your node's login page (or any NIP-46 client), then approve each signature request as it arrives. Nothing signs without you.", - style = androidx.compose.material3.MaterialTheme.typography.bodyMedium, - color = TextMuted, - ) - - if (bunkerState is BunkerManager.SignerState.Unavailable) { - GlassCard { - Text( - "Signing is unavailable on this device (native core missing).", - color = Color(0xFFFF6B6B), fontSize = 13.sp, - ) - } - } - - val info = keyInfo - if (info == null) { - // ── No key yet ───────────────────────────────────────────── - GlassCard { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - Text("Create your signer key", color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.SemiBold) - Text( - "Generate a fresh key here, or import an existing nsec. The key never leaves this phone except inside an encrypted backup.", - color = TextMuted, fontSize = 11.sp, - ) - keyError?.let { Text(it, color = Color(0xFFFF6B6B), fontSize = 12.sp) } - GlassButton( - text = "Generate key", - onClick = { - scope.launch { - try { - keyInfo = prefs.generateSecret() - keyError = null - BunkerManager.refreshState(context) - } catch (e: Exception) { - keyError = e.message ?: "could not generate a key" - } - } - }, - modifier = Modifier.fillMaxWidth().height(48.dp), - ) - OutlinedTextField( - value = importText, - onValueChange = { importText = it }, - placeholder = { Text("or import nsec…", color = TextMuted, fontSize = 14.sp) }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - textStyle = TextStyle(color = TextPrimary, fontSize = 13.sp), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = BitcoinOrange, - unfocusedBorderColor = Color.White.copy(alpha = 0.15f), - cursorColor = BitcoinOrange, - focusedTextColor = TextPrimary, - unfocusedTextColor = TextPrimary, - ), - shape = RoundedCornerShape(12.dp), - ) - GlassButton( - text = "Import", - onClick = { - if (importText.isBlank()) return@GlassButton - scope.launch { - try { - keyInfo = prefs.importSecret(importText) - importText = "" - keyError = null - BunkerManager.refreshState(context) - } catch (e: Exception) { - keyError = e.message ?: "not a valid nsec" - } - } - }, - modifier = Modifier.fillMaxWidth().height(48.dp), - ) - } - } - } else { - // ── Key present: identity + session + pairing ────────────── - GlassCard { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Icon(Icons.Default.Key, contentDescription = null, tint = BitcoinOrange, modifier = Modifier.size(18.dp)) - Text("Signer identity", color = TextPrimary, fontSize = 15.sp, fontWeight = FontWeight.SemiBold) - } - MonoRow("npub", info.optString("npub")) { - clipboard.setText(AnnotatedString(info.optString("npub"))) - } - if (showNsec) { - MonoRow("nsec", info.optString("nsec"), secret = true) { - clipboard.setText(AnnotatedString(info.optString("nsec"))) - } - Text( - "Anyone with the nsec can sign as you — clear the clipboard after copying.", - color = Color(0xFFFF6B6B), fontSize = 10.sp, - ) - } else { - Text( - "Show nsec", - color = TextMuted, fontSize = 12.sp, - modifier = Modifier - .clip(RoundedCornerShape(8.dp)) - .clickable { showNsec = true } - .padding(vertical = 4.dp, horizontal = 8.dp), - ) - } - } - } - - // Session status - GlassCard { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - val label = when (val s = bunkerState) { - BunkerManager.SignerState.Unavailable -> "Unavailable on this device" - BunkerManager.SignerState.NoKey -> "No signer key yet" - BunkerManager.SignerState.Idle -> "Idle — scan a pairing QR to start" - is BunkerManager.SignerState.Connecting -> "Connecting to ${s.relay}…" - is BunkerManager.SignerState.AwaitingClient -> "Paired with \"${s.clientName}\" — waiting for it to finish the handshake" - is BunkerManager.SignerState.Ready -> "Ready for \"${s.clientName}\" via ${s.relay}" - is BunkerManager.SignerState.Failed -> s.reason - } - Text("Session", color = TextMuted, fontSize = 12.sp) - Text( - label, - color = if (bunkerState is BunkerManager.SignerState.Failed) Color(0xFFFF6B6B) - else if (bunkerState is BunkerManager.SignerState.Ready) SuccessGreen - else TextPrimary, - fontSize = 14.sp, - ) - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - Box( - Modifier - .weight(1f) - .height(44.dp) - .clip(RoundedCornerShape(12.dp)) - .background(Color.White.copy(alpha = 0.06f)) - .border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(12.dp)) - .clickable { scanVisible = true }, - contentAlignment = Alignment.Center, - ) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon(Icons.Default.QrCodeScanner, contentDescription = null, tint = BitcoinOrange, modifier = Modifier.size(18.dp)) - Text("Scan pairing QR", color = BitcoinOrange, fontSize = 13.sp, fontWeight = FontWeight.Medium) - } - } - } - if (bunkerState is BunkerManager.SignerState.Ready || - bunkerState is BunkerManager.SignerState.AwaitingClient || - bunkerState is BunkerManager.SignerState.Connecting - ) { - Text( - "End session", - color = TextMuted, fontSize = 12.sp, - modifier = Modifier - .clip(RoundedCornerShape(8.dp)) - .clickable { BunkerManager.unpair() } - .padding(vertical = 4.dp, horizontal = 8.dp), - ) - } - } - } - - // Pending signature request — the whole point. - pending?.let { req -> - GlassCard { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - "Signature request", - color = BitcoinOrange, fontSize = 15.sp, fontWeight = FontWeight.Bold, - ) - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text("Client", color = TextMuted, fontSize = 12.sp) - Text(req.clientName.ifBlank { req.clientPubkey.take(12) + "…" }, color = TextPrimary, fontSize = 12.sp) - } - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text("Kind", color = TextMuted, fontSize = 12.sp) - Text(kindLabel(req.kind), color = TextPrimary, fontSize = 12.sp, fontWeight = FontWeight.Medium) - } - req.createdAt?.let { - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - Text("Time", color = TextMuted, fontSize = 12.sp) - Text( - SimpleDateFormat("HH:mm:ss", Locale.US).format(Date(it * 1000)), - color = TextPrimary, fontSize = 12.sp, - ) - } - } - req.content?.takeIf { it.isNotBlank() }?.let { content -> - Text("Content", color = TextMuted, fontSize = 12.sp) - Text( - content, - color = TextPrimary, - fontSize = 11.sp, - lineHeight = 15.sp, - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)) - .background(Color.Black.copy(alpha = 0.4f)) - .padding(10.dp) - .heightIn(max = 220.dp), - ) - } - if (req.tags.isNotEmpty()) { - Text("Tags", color = TextMuted, fontSize = 12.sp) - Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)) - .background(Color.Black.copy(alpha = 0.4f)) - .padding(10.dp), - verticalArrangement = Arrangement.spacedBy(3.dp), - ) { - req.tags.take(6).forEach { - Text( - it, - color = TextMuted, fontSize = 10.sp, - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, - maxLines = 1, - ) - } - if (req.tags.size > 6) { - Text("+${req.tags.size - 6} more", color = TextMuted, fontSize = 10.sp) - } - } - } - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - Box( - Modifier - .weight(1f) - .height(46.dp) - .clip(RoundedCornerShape(12.dp)) - .background(Color(0xFFE5484D).copy(alpha = 0.18f)) - .border(1.dp, Color(0xFFE5484D).copy(alpha = 0.5f), RoundedCornerShape(12.dp)) - .clickable { BunkerManager.deny() }, - contentAlignment = Alignment.Center, - ) { Text("Deny", color = Color(0xFFFF8A8D), fontSize = 14.sp, fontWeight = FontWeight.Bold) } - Box( - Modifier - .weight(1f) - .height(46.dp) - .clip(RoundedCornerShape(12.dp)) - .background(BitcoinOrange.copy(alpha = 0.2f)) - .border(1.dp, BitcoinOrange.copy(alpha = 0.6f), RoundedCornerShape(12.dp)) - .clickable { - scope.launch { - val ok = BunkerManager.approve() - say(if (ok) "Signed and sent." else "Could not send the signature.", !ok) - } - }, - contentAlignment = Alignment.Center, - ) { Text("Approve", color = BitcoinOrange, fontSize = 14.sp, fontWeight = FontWeight.Bold) } - } - } - } - } - } - - status?.takeIf { it.isNotBlank() }?.let { msg -> - Text( - msg, - color = if (statusError) Color(0xFFFF6B6B) else SuccessGreen, - fontSize = 13.sp, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth(), - ) - } - } - - // Pairing-QR scan — reuses the pairing scanner's glass modal, decodes - // any text and treats nostrconnect:// (or a raw bunker:// error) here. - QrGlassModal( - visible = scanVisible, - title = "Scan pairing QR", - status = null, - idleHint = "Point at the nostrconnect QR your node or client shows", - permissionRationale = "Camera access is needed to scan the pairing code", - onDismiss = { scanVisible = false }, - onDecoded = { text -> - if (text.startsWith("nostrconnect://")) { - scanVisible = false - pairWith(text) - } - // Anything else keeps scanning with the hint showing. - }, - ) - } -} - -/** Kind number → legible label, so approve/deny cards read like sentences. */ -private fun kindLabel(kind: Long?): String = when (kind) { - 0L -> "Metadata (kind 0)" - 1L -> "Text note (kind 1)" - 3L -> "Contact list (kind 3)" - 4L -> "Direct message (kind 4)" - 7L -> "Reaction (kind 7)" - 14L -> "Chat message (kind 14)" - 22242L -> "Client authentication (kind 22242)" - 30078L -> "App-stored data (kind 30078)" - null -> "Unknown kind" - else -> "Kind $kind" -} - -/** Label + monospace value + copy affordance. */ -@Composable -private fun MonoRow(label: String, value: String, secret: Boolean = false, onCopy: () -> Unit) { - Column(Modifier.fillMaxWidth()) { - Text(label, color = TextMuted, fontSize = 11.sp) - Row( - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)) - .background(Color.Black.copy(alpha = 0.4f)) - .clickable { onCopy() } - .padding(10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - value, - color = if (secret) Color(0xFFFFB86B) else TextPrimary, - fontSize = 11.sp, - fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, - modifier = Modifier.weight(1f), - maxLines = 1, - overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis, - ) - Text("⧉", color = TextMuted, fontSize = 14.sp, modifier = Modifier.padding(start = 8.dp)) - } - } -} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt index 9f67b4c8..b043486e 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt @@ -101,6 +101,7 @@ import com.archipelago.app.fips.FipsManager import com.archipelago.app.ui.components.GestureHintOverlay import com.archipelago.app.ui.components.MeshLoadingScreen import com.archipelago.app.ui.components.NESMenu +import com.archipelago.app.ui.components.SignerLaunch import com.archipelago.app.ui.components.QrScannerOverlay import com.archipelago.app.ui.components.SlidingLoader import com.archipelago.app.ui.components.WalletQrScannerModal @@ -599,10 +600,6 @@ fun WebViewScreen( onRemoteKeyboard: () -> Unit = {}, // Opens the phone-to-phone Mesh Party screen; null hides its hub card. onMeshParty: (() -> Unit)? = null, - // Backup & Restore (companion 0.5.28, #128); null hides its hub card. - onBackupRestore: (() -> Unit)? = null, - // Remote Signer (companion 0.5.28, #139); null hides its hub card. - onSigner: (() -> Unit)? = null, // Stored password for this server (from QR pairing or manual entry). When // non-blank, the login page is auto-filled and submitted — the one-step // demo flow from docs/companion-pairing-qr.md. @@ -1377,6 +1374,16 @@ fun WebViewScreen( // Hub menu overlay — opened by the three-finger hold, drawn above // everything (also reachable from the error screen, where switching // servers is exactly what's needed). + // Remote-signer deep link: route to the session and pop the hub open + // on its signer sub-page (the request itself is consumed by NESMenu). + var signerPairRequest by remember { mutableStateOf(null) } + val signerLaunch by SignerLaunch.pendingUri.collectAsState() + LaunchedEffect(signerLaunch) { + val uri = signerLaunch ?: return@LaunchedEffect + signerPairRequest = uri + SignerLaunch.pendingUri.value = null + showHubMenu = true + } NESMenu( visible = showHubMenu, servers = savedServers, @@ -1431,8 +1438,8 @@ fun WebViewScreen( onKeyboard = { showHubMenu = false; onRemoteKeyboard() }, onBackToWebView = { showHubMenu = false }, onMeshParty = onMeshParty?.let { open -> { showHubMenu = false; open() } }, - onBackupRestore = onBackupRestore?.let { open -> { showHubMenu = false; open() } }, - onSigner = onSigner?.let { open -> { showHubMenu = false; open() } }, + signerPairRequest = signerPairRequest, + onSignerPairHandled = { signerPairRequest = null }, ) // Pairing-QR scan launched from the menu's Nodes page; the menu stays diff --git a/docs/companion-backup-restore.md b/docs/companion-backup-restore.md index 186b619d..846bf6de 100644 --- a/docs/companion-backup-restore.md +++ b/docs/companion-backup-restore.md @@ -50,10 +50,11 @@ The encrypted payload is the companion's own JSON: wrong-passphrase, tampered-blob, node-shape envelopes, and salt/nonce freshness. - **Payload/merge:** `BackupManager` (`Android/app/src/main/java/com/archipelago/app/data/BackupManager.kt`). -- **UI:** hub menu (three-finger) → **Backup & Restore** → SAF file picker - (`CreateDocument` for export, `OpenDocument` for import), passphrase fields, - verified-backup preview, result summary. The suggested export name is - `archy-companion-backup-YYYYMMDD-HHmmss.json`. +- **UI:** a hub sub-page (`ui/components/BackupSection.kt`, opened from the + three-finger hub menu like Nodes/FIPS) — SAF file picker + (`CreateDocument` for export, `OpenDocument` for import), passphrase + fields, verified-backup preview, result summary. The suggested export + name is `archy-companion-backup-YYYYMMDD-HHmmss.json`. ## Restore semantics — never silently destructive diff --git a/docs/companion-nip46-remote-signer.md b/docs/companion-nip46-remote-signer.md index f267665e..7bf26e9d 100644 --- a/docs/companion-nip46-remote-signer.md +++ b/docs/companion-nip46-remote-signer.md @@ -50,9 +50,12 @@ still spoken by real clients); all sending is NIP-44 v2. - **JNI:** `com.archipelago.app.NativeCore` (same .so as the FIPS mesh). - **Session:** `nostr/BunkerManager.kt` — OkHttp WebSocket relay client, JSON-RPC dispatch, approve/deny state. -- **UI:** `ui/screens/SignerScreen.kt` — key setup, npub/nsec display, - pairing scan, session status, the approve/deny card. -- **Deep link:** `nostrconnect://` intent filter → SignerScreen. +- **UI:** a hub sub-page (`ui/components/SignerSection.kt`, opened from the + three-finger hub menu like Nodes/FIPS) — key setup, npub/nsec display, + pairing scan, session status, the approve/deny card. The full-screen + pairing scanner (`QrGlassModal`) is hosted by NESMenu so it isn't clipped + to the panel's bounds. The `nostrconnect://` deep link routes to the + session and pops the hub open on the signer sub-page (`SignerLaunch`). ## Security notes (conscious deviations, reviewed)