fix(companion): backup + signer live inside the hub modal, not standalone screens

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).
This commit is contained in:
Dorian
2026-08-31 14:23:04 +01:00
parent 22f8129b52
commit 981296e8b0
11 changed files with 800 additions and 973 deletions
@@ -99,21 +99,16 @@ object BunkerManager {
private val session = AtomicReference<Session?>(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
}
/**
@@ -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<String?>(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<Pair<BackupManager.PayloadSummary, org.json.JSONObject>?>(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)
}
}
@@ -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<String?>(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,
@@ -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<String?>(null)
}
@@ -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<JSONObject?>(null) }
var keyError by remember { mutableStateOf<String?>(null) }
var importText by remember { mutableStateOf("") }
var showNsec by remember { mutableStateOf(false) }
var notice by remember { mutableStateOf<String?>(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))
}
}
}
@@ -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)
},
)
}
}
}
@@ -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<String?>(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<Pair<BackupManager.PayloadSummary, org.json.JSONObject>?>(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()
}
}
@@ -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<JSONObject?>(null) }
var keyError by remember { mutableStateOf<String?>(null) }
var importText by remember { mutableStateOf("") }
var showNsec by remember { mutableStateOf(false) }
var scanVisible by remember { mutableStateOf(false) }
var status by remember { mutableStateOf<String?>(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))
}
}
}
@@ -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<String?>(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
+5 -4
View File
@@ -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
+6 -3
View File
@@ -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)