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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user