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,22 +99,17 @@ object BunkerManager {
|
|||||||
|
|
||||||
private val session = AtomicReference<Session?>(null)
|
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) {
|
if (!NativeCore.available) {
|
||||||
_state.value = SignerState.Unavailable
|
_state.value = SignerState.Unavailable
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (session.get() != null) 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)
|
val prefs = NostrSignerPreferences(context.applicationContext)
|
||||||
_state.value =
|
_state.value =
|
||||||
if (prefs.secret() == null) SignerState.NoKey else SignerState.Idle
|
if (prefs.secret() == null) SignerState.NoKey else SignerState.Idle
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pair from a scanned or deep-linked `nostrconnect://…` URI. Returns a
|
* Pair from a scanned or deep-linked `nostrconnect://…` URI. Returns a
|
||||||
|
|||||||
@@ -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,
|
onKeyboard: () -> Unit,
|
||||||
onBackToWebView: (() -> Unit)? = null,
|
onBackToWebView: (() -> Unit)? = null,
|
||||||
onMeshParty: (() -> Unit)? = null,
|
onMeshParty: (() -> Unit)? = null,
|
||||||
onBackupRestore: (() -> Unit)? = null,
|
// Remote-signer pairing request (nostrconnect://… deep link, or a scan):
|
||||||
onSigner: (() -> Unit)? = null,
|
// 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()) {
|
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
|
||||||
// Contained hub overlay: a centred glass panel (not full-screen) that
|
// Contained hub overlay: a centred glass panel (not full-screen) that
|
||||||
// holds the card page and its sub-pages (Nodes, FIPS) and scrolls
|
// holds the card page and its sub-pages (Nodes, FIPS, Backup, Signer)
|
||||||
// inside its own bounds when content is tall. Tapping the dimmed
|
// and scrolls inside its own bounds when content is tall. Tapping the
|
||||||
// backdrop dismisses.
|
// dimmed backdrop dismisses.
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.7f))
|
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.7f))
|
||||||
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onDismiss() },
|
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onDismiss() },
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) {
|
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
|
@Composable
|
||||||
@@ -142,8 +180,9 @@ private fun MenuPanel(
|
|||||||
onKeyboard: () -> Unit,
|
onKeyboard: () -> Unit,
|
||||||
onBackToWebView: (() -> Unit)?,
|
onBackToWebView: (() -> Unit)?,
|
||||||
onMeshParty: (() -> Unit)?,
|
onMeshParty: (() -> Unit)?,
|
||||||
onBackupRestore: (() -> Unit)?,
|
signerPairUri: String?,
|
||||||
onSigner: (() -> Unit)?,
|
onSignerScan: () -> Unit,
|
||||||
|
onSignerPairHandled: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var showAdd by remember { mutableStateOf(false) }
|
var showAdd by remember { mutableStateOf(false) }
|
||||||
// The saved server being edited, or null when adding a new one.
|
// The saved server being edited, or null when adding a new one.
|
||||||
@@ -182,9 +221,10 @@ private fun MenuPanel(
|
|||||||
.widthIn(max = 420.dp)
|
.widthIn(max = 420.dp)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 20.dp)
|
.padding(horizontal = 20.dp)
|
||||||
// Cap height just short of the full screen; the panel wraps short
|
// Cap height at 70% of the screen — a ~15% breathing margin top
|
||||||
// content and only scrolls in the rare case it outgrows this.
|
// and bottom — the panel wraps short content and scrolls inside
|
||||||
.heightIn(max = (LocalConfiguration.current.screenHeightDp * 0.92f).dp)
|
// its own bounds when a sub-page outgrows this.
|
||||||
|
.heightIn(max = (LocalConfiguration.current.screenHeightDp * 0.70f).dp)
|
||||||
.clip(RoundedCornerShape(PANEL_R))
|
.clip(RoundedCornerShape(PANEL_R))
|
||||||
.background(PanelBg.copy(alpha = 0.86f))
|
.background(PanelBg.copy(alpha = 0.86f))
|
||||||
.border(1.dp, PanelBorder, RoundedCornerShape(PANEL_R))
|
.border(1.dp, PanelBorder, RoundedCornerShape(PANEL_R))
|
||||||
@@ -207,7 +247,13 @@ private fun MenuPanel(
|
|||||||
IconRound(Icons.AutoMirrored.Filled.ArrowBack, "Back") { resetForm(); page = HubPage.HUB }
|
IconRound(Icons.AutoMirrored.Filled.ArrowBack, "Back") { resetForm(); page = HubPage.HUB }
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
Text(
|
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,
|
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
|
// Backup & Restore (#128): the phone side of losing your phone
|
||||||
// or wiping it to cross a border — encrypted export file, no cloud.
|
// 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") { page = HubPage.BACKUP }
|
||||||
HubCard(Icons.Default.SettingsBackupRestore, "Backup & Restore", "Encrypted export for a wiped phone") { onBackupRestore() }
|
|
||||||
}
|
|
||||||
// Remote Signer (#139): hold a nostr key on the phone and
|
// Remote Signer (#139): hold a nostr key on the phone and
|
||||||
// approve/deny remote signature requests (NIP-46).
|
// approve/deny remote signature requests (NIP-46).
|
||||||
if (onSigner != null) {
|
HubCard(Icons.Default.Key, "Remote Signer", "Approve signatures for your node") { page = HubPage.SIGNER }
|
||||||
HubCard(Icons.Default.Key, "Remote Signer", "Approve signatures for your node") { onSigner() }
|
|
||||||
}
|
|
||||||
// Dark/Classic style lives on the remote/keyboard screen next to
|
// Dark/Classic style lives on the remote/keyboard screen next to
|
||||||
// the settings button — not here.
|
// the settings button — not here.
|
||||||
|
|
||||||
@@ -407,11 +449,23 @@ private fun MenuPanel(
|
|||||||
HubPage.FIPS -> {
|
HubPage.FIPS -> {
|
||||||
FipsSection(embedded = true)
|
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. */
|
/** Big tappable destination card for the hub page: icon + title + subtitle. */
|
||||||
@Composable
|
@Composable
|
||||||
@@ -639,7 +693,7 @@ private fun MenuItem(
|
|||||||
|
|
||||||
/** Glass text field with centered input text. */
|
/** Glass text field with centered input text. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun GlassField(
|
internal fun GlassField(
|
||||||
value: String,
|
value: String,
|
||||||
onValueChange: (String) -> Unit,
|
onValueChange: (String) -> Unit,
|
||||||
placeholder: String,
|
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.ServerPreferences
|
||||||
import com.archipelago.app.data.ServerQrParser
|
import com.archipelago.app.data.ServerQrParser
|
||||||
import com.archipelago.app.fips.FipsManager
|
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.FlareScreen
|
||||||
import com.archipelago.app.ui.screens.IntroScreen
|
import com.archipelago.app.ui.screens.IntroScreen
|
||||||
import com.archipelago.app.ui.screens.NodePickerScreen
|
import com.archipelago.app.ui.screens.NodePickerScreen
|
||||||
import com.archipelago.app.ui.screens.PartyScreen
|
import com.archipelago.app.ui.screens.PartyScreen
|
||||||
import com.archipelago.app.ui.screens.RemoteInputScreen
|
import com.archipelago.app.ui.screens.RemoteInputScreen
|
||||||
import com.archipelago.app.ui.screens.ServerConnectScreen
|
import com.archipelago.app.ui.screens.ServerConnectScreen
|
||||||
import com.archipelago.app.ui.screens.SignerScreen
|
|
||||||
import com.archipelago.app.ui.screens.WebViewScreen
|
import com.archipelago.app.ui.screens.WebViewScreen
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -43,8 +42,6 @@ object Routes {
|
|||||||
const val REMOTE_INPUT = "remote_input"
|
const val REMOTE_INPUT = "remote_input"
|
||||||
const val MESH_PARTY = "mesh_party"
|
const val MESH_PARTY = "mesh_party"
|
||||||
const val FLARE = "flare"
|
const val FLARE = "flare"
|
||||||
const val BACKUP_RESTORE = "backup_restore"
|
|
||||||
const val SIGNER = "signer"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -140,9 +137,15 @@ fun AppNavHost(
|
|||||||
when {
|
when {
|
||||||
// Remote-signer pairing deep link (NIP-46): nostrconnect://…
|
// Remote-signer pairing deep link (NIP-46): nostrconnect://…
|
||||||
// from the node's login QR — any QR scanner app can hand it over.
|
// 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://") -> {
|
raw.startsWith("nostrconnect://") -> {
|
||||||
prefs.markIntroSeen()
|
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)) {
|
else -> when (val result = ServerQrParser.parse(raw)) {
|
||||||
is PairResult.Success -> {
|
is PairResult.Success -> {
|
||||||
@@ -271,12 +274,6 @@ fun AppNavHost(
|
|||||||
onMeshParty = {
|
onMeshParty = {
|
||||||
navController.navigate(Routes.MESH_PARTY)
|
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() },
|
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.GestureHintOverlay
|
||||||
import com.archipelago.app.ui.components.MeshLoadingScreen
|
import com.archipelago.app.ui.components.MeshLoadingScreen
|
||||||
import com.archipelago.app.ui.components.NESMenu
|
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.QrScannerOverlay
|
||||||
import com.archipelago.app.ui.components.SlidingLoader
|
import com.archipelago.app.ui.components.SlidingLoader
|
||||||
import com.archipelago.app.ui.components.WalletQrScannerModal
|
import com.archipelago.app.ui.components.WalletQrScannerModal
|
||||||
@@ -599,10 +600,6 @@ fun WebViewScreen(
|
|||||||
onRemoteKeyboard: () -> Unit = {},
|
onRemoteKeyboard: () -> Unit = {},
|
||||||
// Opens the phone-to-phone Mesh Party screen; null hides its hub card.
|
// Opens the phone-to-phone Mesh Party screen; null hides its hub card.
|
||||||
onMeshParty: (() -> Unit)? = null,
|
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
|
// 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
|
// non-blank, the login page is auto-filled and submitted — the one-step
|
||||||
// demo flow from docs/companion-pairing-qr.md.
|
// 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
|
// Hub menu overlay — opened by the three-finger hold, drawn above
|
||||||
// everything (also reachable from the error screen, where switching
|
// everything (also reachable from the error screen, where switching
|
||||||
// servers is exactly what's needed).
|
// 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(
|
NESMenu(
|
||||||
visible = showHubMenu,
|
visible = showHubMenu,
|
||||||
servers = savedServers,
|
servers = savedServers,
|
||||||
@@ -1431,8 +1438,8 @@ fun WebViewScreen(
|
|||||||
onKeyboard = { showHubMenu = false; onRemoteKeyboard() },
|
onKeyboard = { showHubMenu = false; onRemoteKeyboard() },
|
||||||
onBackToWebView = { showHubMenu = false },
|
onBackToWebView = { showHubMenu = false },
|
||||||
onMeshParty = onMeshParty?.let { open -> { showHubMenu = false; open() } },
|
onMeshParty = onMeshParty?.let { open -> { showHubMenu = false; open() } },
|
||||||
onBackupRestore = onBackupRestore?.let { open -> { showHubMenu = false; open() } },
|
signerPairRequest = signerPairRequest,
|
||||||
onSigner = onSigner?.let { open -> { showHubMenu = false; open() } },
|
onSignerPairHandled = { signerPairRequest = null },
|
||||||
)
|
)
|
||||||
|
|
||||||
// Pairing-QR scan launched from the menu's Nodes page; the menu stays
|
// 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
|
wrong-passphrase, tampered-blob, node-shape envelopes, and salt/nonce
|
||||||
freshness.
|
freshness.
|
||||||
- **Payload/merge:** `BackupManager` (`Android/app/src/main/java/com/archipelago/app/data/BackupManager.kt`).
|
- **Payload/merge:** `BackupManager` (`Android/app/src/main/java/com/archipelago/app/data/BackupManager.kt`).
|
||||||
- **UI:** hub menu (three-finger) → **Backup & Restore** → SAF file picker
|
- **UI:** a hub sub-page (`ui/components/BackupSection.kt`, opened from the
|
||||||
(`CreateDocument` for export, `OpenDocument` for import), passphrase fields,
|
three-finger hub menu like Nodes/FIPS) — SAF file picker
|
||||||
verified-backup preview, result summary. The suggested export name is
|
(`CreateDocument` for export, `OpenDocument` for import), passphrase
|
||||||
`archy-companion-backup-YYYYMMDD-HHmmss.json`.
|
fields, verified-backup preview, result summary. The suggested export
|
||||||
|
name is `archy-companion-backup-YYYYMMDD-HHmmss.json`.
|
||||||
|
|
||||||
## Restore semantics — never silently destructive
|
## 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).
|
- **JNI:** `com.archipelago.app.NativeCore` (same .so as the FIPS mesh).
|
||||||
- **Session:** `nostr/BunkerManager.kt` — OkHttp WebSocket relay client,
|
- **Session:** `nostr/BunkerManager.kt` — OkHttp WebSocket relay client,
|
||||||
JSON-RPC dispatch, approve/deny state.
|
JSON-RPC dispatch, approve/deny state.
|
||||||
- **UI:** `ui/screens/SignerScreen.kt` — key setup, npub/nsec display,
|
- **UI:** a hub sub-page (`ui/components/SignerSection.kt`, opened from the
|
||||||
pairing scan, session status, the approve/deny card.
|
three-finger hub menu like Nodes/FIPS) — key setup, npub/nsec display,
|
||||||
- **Deep link:** `nostrconnect://` intent filter → SignerScreen.
|
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)
|
## Security notes (conscious deviations, reviewed)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user