Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1593c55894 | ||
|
|
1cc18e5b72 | ||
|
|
24582128c5 | ||
|
|
0b038434b1 | ||
|
|
d576f77435 | ||
|
|
7e8d3314d0 | ||
|
|
07772b563f | ||
|
|
d500214766 | ||
|
|
9cd507269c | ||
|
|
aa272bfcf4 | ||
|
|
3b6a32b2f8 | ||
|
|
c66ef048f4 | ||
|
|
32962db6a9 | ||
|
|
4edc50ae47 |
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "com.archipelago.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 39
|
||||
versionName = "0.5.19"
|
||||
versionCode = 45
|
||||
versionName = "0.5.25"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
|
||||
@@ -5,6 +5,10 @@ import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.net.VpnService
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
@@ -34,6 +38,20 @@ class ArchyVpnService : VpnService() {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var warmerJob: Job? = null
|
||||
|
||||
// Seamless transport handoff (Wi-Fi ⇄ 5G ⇄ future BLE). Without this the
|
||||
// tunnel's underlying network stays pinned to the interface that was
|
||||
// default when the VPN came up; when the phone leaves Wi-Fi for 5G the
|
||||
// mesh sockets ride a dead network and sessions never recover until the
|
||||
// app is restarted (user-reported 2026-07-27). The callback (a) re-pins
|
||||
// the tunnel to the new default network via setUnderlyingNetworks and
|
||||
// (b) forces an immediate mesh re-home so discovery + sessions rebuild
|
||||
// on the new path within seconds instead of waiting out dead-link
|
||||
// timeouts.
|
||||
private var connectivityManager: ConnectivityManager? = null
|
||||
private var networkCallback: ConnectivityManager.NetworkCallback? = null
|
||||
@Volatile
|
||||
private var currentUnderlying: Network? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action == ACTION_STOP) {
|
||||
shutdown()
|
||||
@@ -113,6 +131,7 @@ class ArchyVpnService : VpnService() {
|
||||
shutdown()
|
||||
} else {
|
||||
startSessionWarmer()
|
||||
registerNetworkHandoff()
|
||||
// Phone-to-phone chat/beam + the phone's own mesh-served page.
|
||||
FlareServer.start(this, identity.address, identity.npub, prefs.partyName())
|
||||
// Mutual pairing: when a phone that scanned OUR QR announces
|
||||
@@ -183,8 +202,75 @@ class ArchyVpnService : VpnService() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track the phone's default network and hand the mesh over to it as the
|
||||
* phone roams (Wi-Fi ⇄ 5G, and later BLE). Two actions per change:
|
||||
* 1. setUnderlyingNetworks(new) — the tunnel's packets follow the live
|
||||
* network instead of dying on the one it launched with.
|
||||
* 2. re-home the mesh — kick the session warmer so discovery + sessions
|
||||
* rebuild on the new path immediately; the node's own fast-reconnect
|
||||
* (1s) redials peers over the new route.
|
||||
* onAvailable also fires for the FIRST network, which is how the initial
|
||||
* underlying network gets set.
|
||||
*/
|
||||
private fun registerNetworkHandoff() {
|
||||
if (networkCallback != null) return
|
||||
val cm = getSystemService(ConnectivityManager::class.java) ?: return
|
||||
connectivityManager = cm
|
||||
val request = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
val cb = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
handoffTo(network)
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
// The lost network was our underlying one — clear the pin so
|
||||
// the system falls back to whatever default remains; the next
|
||||
// onAvailable re-pins explicitly.
|
||||
if (network == currentUnderlying) {
|
||||
currentUnderlying = null
|
||||
runCatching { setUnderlyingNetworks(null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
networkCallback = cb
|
||||
// requestNetwork tracks the BEST network of the request; when the
|
||||
// phone moves Wi-Fi→5G the callback re-fires onAvailable with the new
|
||||
// one. (registerDefaultNetworkCallback would also work; requestNetwork
|
||||
// lets us extend to BLE-capable transports later.)
|
||||
runCatching { cm.requestNetwork(request, cb) }
|
||||
}
|
||||
|
||||
private fun handoffTo(network: Network) {
|
||||
val changed = network != currentUnderlying
|
||||
currentUnderlying = network
|
||||
// Always re-assert; cheap and covers capability changes on the same
|
||||
// Network object.
|
||||
runCatching { setUnderlyingNetworks(arrayOf(network)) }
|
||||
if (changed && FipsNative.isRunning()) {
|
||||
Log.i(TAG, "network handoff → re-homing mesh on new default network")
|
||||
// Fresh warmer pass drives immediate rediscovery/session rebuild
|
||||
// on the new path instead of waiting out dead-link timeouts.
|
||||
startSessionWarmer()
|
||||
}
|
||||
}
|
||||
|
||||
private fun unregisterNetworkHandoff() {
|
||||
val cm = connectivityManager
|
||||
val cb = networkCallback
|
||||
if (cm != null && cb != null) {
|
||||
runCatching { cm.unregisterNetworkCallback(cb) }
|
||||
}
|
||||
networkCallback = null
|
||||
connectivityManager = null
|
||||
currentUnderlying = null
|
||||
}
|
||||
|
||||
private fun shutdown() {
|
||||
warmerJob?.cancel()
|
||||
unregisterNetworkHandoff()
|
||||
FlareServer.stop()
|
||||
FipsNative.stop()
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
@@ -192,6 +278,7 @@ class ArchyVpnService : VpnService() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
unregisterNetworkHandoff()
|
||||
FipsNative.stop()
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
|
||||
@@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Palette
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
@@ -108,6 +109,7 @@ fun NESController(
|
||||
onKey: (String) -> Unit,
|
||||
onMenu: () -> Unit,
|
||||
onPlayerToggle: () -> Unit = {},
|
||||
onToggleStyle: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val c = paletteFor(style)
|
||||
@@ -205,6 +207,7 @@ fun NESController(
|
||||
) {
|
||||
PlayerPill(c, playerId, onPlayerToggle)
|
||||
SettingsBtn(c, Modifier, onMenu)
|
||||
onToggleStyle?.let { StyleBtn(c, Modifier, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -431,6 +434,23 @@ fun SettingsBtn(c: NESPalette, modifier: Modifier = Modifier, onClick: () -> Uni
|
||||
}
|
||||
}
|
||||
|
||||
/** Dark/Classic style toggle — lives next to the settings gear (the menu hub
|
||||
* no longer carries it). */
|
||||
@Composable
|
||||
fun StyleBtn(c: NESPalette, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
var p by remember { mutableStateOf(false) }
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (p) c.capsulePress else c.capsule)
|
||||
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(Icons.Default.Palette, "Controller style", Modifier.size(26.dp), tint = c.labelMuted)
|
||||
}
|
||||
}
|
||||
|
||||
/** Player ID toggle pill (P1/P2/ALL) */
|
||||
@Composable
|
||||
fun PlayerPill(c: NESPalette, playerId: Int, onToggle: () -> Unit) {
|
||||
|
||||
@@ -21,20 +21,40 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material.icons.filled.Dashboard
|
||||
import androidx.compose.material.icons.filled.Dns
|
||||
import androidx.compose.material.icons.filled.Groups
|
||||
import androidx.compose.material.icons.filled.Keyboard
|
||||
import androidx.compose.material.icons.filled.SportsEsports
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
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.Close
|
||||
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||
import androidx.compose.material3.Icon
|
||||
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.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import com.archipelago.app.fips.FipsManager
|
||||
import com.archipelago.app.fips.FipsNative
|
||||
import com.archipelago.app.fips.FipsPreferences
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -51,7 +71,6 @@ import androidx.compose.ui.unit.sp
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.data.ServerEntry
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.ControllerStyle
|
||||
import com.archipelago.app.ui.theme.SurfaceDark
|
||||
import com.archipelago.app.ui.theme.TextMuted
|
||||
import com.archipelago.app.ui.theme.TextPrimary
|
||||
@@ -75,27 +94,29 @@ fun NESMenu(
|
||||
visible: Boolean,
|
||||
servers: List<ServerEntry>,
|
||||
activeServer: ServerEntry?,
|
||||
isGamepadMode: Boolean,
|
||||
controllerStyle: ControllerStyle,
|
||||
onDismiss: () -> Unit,
|
||||
onSelectServer: (ServerEntry) -> Unit,
|
||||
onAddServer: (ServerEntry) -> Unit,
|
||||
onScanQr: (() -> Unit)? = null,
|
||||
onEditServer: (ServerEntry, ServerEntry) -> Unit,
|
||||
onRemoveServer: (ServerEntry) -> Unit,
|
||||
onToggleMode: () -> Unit,
|
||||
onToggleStyle: () -> Unit,
|
||||
onRemote: () -> Unit,
|
||||
onKeyboard: () -> Unit,
|
||||
onBackToWebView: (() -> Unit)? = null,
|
||||
onMeshParty: (() -> Unit)? = null,
|
||||
) {
|
||||
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.
|
||||
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, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView, onMeshParty)
|
||||
MenuPanel(servers, activeServer, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onRemote, onKeyboard, onBackToWebView, onMeshParty)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,16 +126,14 @@ fun NESMenu(
|
||||
private fun MenuPanel(
|
||||
servers: List<ServerEntry>,
|
||||
activeServer: ServerEntry?,
|
||||
isGamepadMode: Boolean,
|
||||
controllerStyle: ControllerStyle,
|
||||
onDismiss: () -> Unit,
|
||||
onSelectServer: (ServerEntry) -> Unit,
|
||||
onAddServer: (ServerEntry) -> Unit,
|
||||
onScanQr: (() -> Unit)?,
|
||||
onEditServer: (ServerEntry, ServerEntry) -> Unit,
|
||||
onRemoveServer: (ServerEntry) -> Unit,
|
||||
onToggleMode: () -> Unit,
|
||||
onToggleStyle: () -> Unit,
|
||||
onRemote: () -> Unit,
|
||||
onKeyboard: () -> Unit,
|
||||
onBackToWebView: (() -> Unit)?,
|
||||
onMeshParty: (() -> Unit)?,
|
||||
) {
|
||||
@@ -124,14 +143,15 @@ private fun MenuPanel(
|
||||
var nm by remember { mutableStateOf("") }
|
||||
var addr by remember { mutableStateOf("") }
|
||||
var pwd by remember { mutableStateOf("") }
|
||||
var https by remember { mutableStateOf(false) }
|
||||
|
||||
fun resetForm() {
|
||||
nm = ""; addr = ""; pwd = ""; showAdd = false; editing = null
|
||||
nm = ""; addr = ""; pwd = ""; https = false; showAdd = false; editing = null
|
||||
}
|
||||
|
||||
fun startEdit(server: ServerEntry) {
|
||||
editing = server
|
||||
nm = server.name; addr = server.address; pwd = server.password
|
||||
nm = server.name; addr = server.address; pwd = server.password; https = server.useHttps
|
||||
showAdd = false
|
||||
}
|
||||
|
||||
@@ -139,161 +159,381 @@ private fun MenuPanel(
|
||||
if (addr.isBlank()) return
|
||||
val orig = editing
|
||||
if (orig != null) {
|
||||
// Preserve fields the compact form doesn't expose (scheme, port).
|
||||
onEditServer(orig, orig.copy(address = addr, password = pwd, name = nm))
|
||||
// Preserve port (compact form doesn't expose it); scheme is now editable.
|
||||
onEditServer(orig, orig.copy(address = addr, useHttps = https, password = pwd, name = nm))
|
||||
} else {
|
||||
onAddServer(ServerEntry(addr, false, password = pwd, name = nm))
|
||||
onAddServer(ServerEntry(addr, https, password = pwd, name = nm))
|
||||
}
|
||||
resetForm()
|
||||
}
|
||||
|
||||
var page by remember { mutableStateOf(HubPage.HUB) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.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)
|
||||
.clip(RoundedCornerShape(PANEL_R))
|
||||
.background(PanelBg)
|
||||
.background(PanelBg.copy(alpha = 0.86f))
|
||||
.border(1.dp, PanelBorder, RoundedCornerShape(PANEL_R))
|
||||
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {}
|
||||
.padding(22.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
// Title
|
||||
Text(
|
||||
"Menu",
|
||||
color = TextPrimary,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
letterSpacing = 2.sp,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
|
||||
// Servers
|
||||
servers.forEach { server ->
|
||||
val active = server.serialize() == activeServer?.serialize()
|
||||
MenuItem(
|
||||
label = server.displayName(),
|
||||
selected = active,
|
||||
onClick = { onSelectServer(server) },
|
||||
onEdit = { startEdit(server) },
|
||||
onRemove = { onRemoveServer(server) },
|
||||
)
|
||||
}
|
||||
|
||||
if (servers.isEmpty()) {
|
||||
Text("No servers", color = TextMuted, fontSize = 14.sp, modifier = Modifier.padding(vertical = 4.dp))
|
||||
}
|
||||
|
||||
// Add / edit server
|
||||
if (showAdd || editing != null) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(ROW_R))
|
||||
.background(FieldBg)
|
||||
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
// Header: back (on sub-pages) or title, and a close on the hub.
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
if (page == HubPage.HUB) {
|
||||
Text("Menu", color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 2.sp)
|
||||
IconRound(Icons.Default.Close, "Close") { onDismiss() }
|
||||
} else {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconRound(Icons.AutoMirrored.Filled.ArrowBack, "Back") { resetForm(); page = HubPage.HUB }
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
if (editing != null) "Edit Server" else "Add Server",
|
||||
color = TextMuted,
|
||||
fontSize = 13.sp,
|
||||
letterSpacing = 1.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
"Cancel",
|
||||
color = TextMuted,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.clickable { resetForm() }.padding(start = 8.dp),
|
||||
if (page == HubPage.NODES) "Nodes" else "FIPS Mesh",
|
||||
color = TextPrimary, fontSize = 20.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.sp,
|
||||
)
|
||||
}
|
||||
GlassField(
|
||||
value = nm, onValueChange = { nm = it },
|
||||
placeholder = "Name (optional)",
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next),
|
||||
)
|
||||
GlassField(
|
||||
value = addr, onValueChange = { addr = it.trim() },
|
||||
placeholder = "192.168.1.100",
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
GlassField(
|
||||
value = pwd, onValueChange = { pwd = it },
|
||||
placeholder = "Password",
|
||||
modifier = Modifier.weight(1f),
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
|
||||
keyboardActions = KeyboardActions(onGo = { submit() }),
|
||||
)
|
||||
Box(
|
||||
Modifier.size(FIELD_H).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.15f))
|
||||
.border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp))
|
||||
.clickable { submit() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) { Text("OK", color = BitcoinOrange, fontSize = 14.sp, fontWeight = FontWeight.Bold) }
|
||||
}
|
||||
IconRound(Icons.Default.Close, "Close") { onDismiss() }
|
||||
}
|
||||
} else {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Box(Modifier.weight(1f)) {
|
||||
MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true })
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
when (page) {
|
||||
HubPage.HUB -> {
|
||||
// Card page — one card per destination. Dashboard first: it's a
|
||||
// peer of the others so three-finger → hub → Dashboard returns
|
||||
// to the node UI, same shape as every other option.
|
||||
if (onBackToWebView != null) {
|
||||
HubCard(Icons.Default.Dashboard, "Dashboard", "The node's web interface") { onBackToWebView() }
|
||||
}
|
||||
if (onScanQr != null) {
|
||||
// Add server by scanning the node's pairing QR
|
||||
Box(
|
||||
HubCard(Icons.Default.SportsEsports, "Remote", "Game controller for the node") { onRemote() }
|
||||
HubCard(Icons.Default.Keyboard, "Keyboard", "Type into the node") { onKeyboard() }
|
||||
HubCard(Icons.Default.Dns, "Nodes", activeServer?.displayName() ?: "Add or switch servers") {
|
||||
page = HubPage.NODES
|
||||
}
|
||||
if (FipsNative.available) {
|
||||
HubCard(Icons.Default.Bolt, "FIPS Mesh", "Mesh identity & status") { page = HubPage.FIPS }
|
||||
}
|
||||
if (onMeshParty != null) {
|
||||
HubCard(Icons.Default.Groups, "Mesh Party", "Phone-to-phone chat & beam") { onMeshParty() }
|
||||
}
|
||||
// Dark/Classic style lives on the remote/keyboard screen next to
|
||||
// the settings button — not here.
|
||||
}
|
||||
|
||||
HubPage.NODES -> {
|
||||
servers.forEach { server ->
|
||||
val active = server.serialize() == activeServer?.serialize()
|
||||
MenuItem(
|
||||
label = server.displayName(),
|
||||
selected = active,
|
||||
onClick = { onSelectServer(server) },
|
||||
onEdit = { startEdit(server) },
|
||||
onRemove = { onRemoveServer(server) },
|
||||
)
|
||||
}
|
||||
if (servers.isEmpty()) {
|
||||
Text("No servers", color = TextMuted, fontSize = 14.sp, modifier = Modifier.padding(vertical = 4.dp))
|
||||
}
|
||||
|
||||
if (showAdd || editing != null) {
|
||||
Column(
|
||||
Modifier
|
||||
.size(ROW_H)
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(ROW_R))
|
||||
.background(RowBg)
|
||||
.background(FieldBg)
|
||||
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
|
||||
.clickable { onScanQr() },
|
||||
contentAlignment = Alignment.Center,
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.QrCodeScanner,
|
||||
contentDescription = stringResource(R.string.add_server_qr),
|
||||
tint = BitcoinOrange,
|
||||
modifier = Modifier.size(24.dp),
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
if (editing != null) "Edit Server" else "Add Server",
|
||||
color = TextMuted, fontSize = 13.sp, letterSpacing = 1.sp, fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
"Cancel", color = TextMuted, fontSize = 13.sp,
|
||||
modifier = Modifier.clickable { resetForm() }.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
GlassField(
|
||||
value = nm, onValueChange = { nm = it },
|
||||
placeholder = "Name (optional)",
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next),
|
||||
)
|
||||
GlassField(
|
||||
value = addr, onValueChange = { addr = it.trim() },
|
||||
placeholder = "192.168.1.100",
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
GlassField(
|
||||
value = pwd, onValueChange = { pwd = it },
|
||||
placeholder = "Password",
|
||||
modifier = Modifier.weight(1f),
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
|
||||
keyboardActions = KeyboardActions(onGo = { submit() }),
|
||||
)
|
||||
Box(
|
||||
Modifier.size(FIELD_H).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.15f))
|
||||
.border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp))
|
||||
.clickable { submit() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) { Text("OK", color = BitcoinOrange, fontSize = 14.sp, fontWeight = FontWeight.Bold) }
|
||||
}
|
||||
// HTTPS scheme toggle (available on both add and edit).
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(ROW_R))
|
||||
.clickable { https = !https }
|
||||
.padding(vertical = 2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text("Use HTTPS", color = TextMuted, fontSize = 13.sp)
|
||||
Box(
|
||||
Modifier
|
||||
.width(46.dp).height(26.dp)
|
||||
.clip(RoundedCornerShape(13.dp))
|
||||
.background(if (https) BitcoinOrange.copy(alpha = 0.9f) else RowBg)
|
||||
.border(1.dp, if (https) BitcoinOrange else RowBorder, RoundedCornerShape(13.dp)),
|
||||
contentAlignment = if (https) Alignment.CenterEnd else Alignment.CenterStart,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(horizontal = 3.dp)
|
||||
.size(20.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(if (https) Color.White else TextMuted),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Box(Modifier.weight(1f)) {
|
||||
MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true })
|
||||
}
|
||||
if (onScanQr != null) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(ROW_H)
|
||||
.clip(RoundedCornerShape(ROW_R))
|
||||
.background(RowBg)
|
||||
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
|
||||
.clickable { onScanQr() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.QrCodeScanner,
|
||||
contentDescription = stringResource(R.string.add_server_qr),
|
||||
tint = BitcoinOrange,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HubPage.FIPS -> {
|
||||
FipsSection(embedded = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(PanelBorder))
|
||||
Spacer(Modifier.height(2.dp))
|
||||
private enum class HubPage { HUB, NODES, FIPS }
|
||||
|
||||
// Mode toggle
|
||||
MenuItem(
|
||||
label = if (isGamepadMode) "Switch to Keyboard" else "Switch to Gamepad",
|
||||
onClick = onToggleMode,
|
||||
)
|
||||
|
||||
// Style toggle
|
||||
MenuItem(
|
||||
label = if (controllerStyle == ControllerStyle.CLASSIC) "Style: Classic" else "Style: Dark",
|
||||
onClick = onToggleStyle,
|
||||
)
|
||||
|
||||
// Phone↔phone mesh pairing + chat
|
||||
if (onMeshParty != null) {
|
||||
MenuItem(label = "Mesh Party", labelColor = BitcoinOrange, onClick = onMeshParty)
|
||||
/** Big tappable destination card for the hub page: icon + title + subtitle. */
|
||||
@Composable
|
||||
private fun HubCard(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(ROW_R))
|
||||
.background(RowBg)
|
||||
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
|
||||
.clickable { onClick() }
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier.size(40.dp).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.14f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(icon, contentDescription = title, tint = BitcoinOrange, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(title, color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
|
||||
Text(subtitle, color = TextMuted, fontSize = 12.sp, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Back to dashboard
|
||||
if (onBackToWebView != null) {
|
||||
MenuItem(label = "Back to Dashboard", onClick = onBackToWebView)
|
||||
/** Small circular icon button used in the hub header. */
|
||||
@Composable
|
||||
private fun IconRound(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
desc: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.size(40.dp)
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(RowBg)
|
||||
.border(1.dp, RowBorder, RoundedCornerShape(20.dp))
|
||||
.clickable { onClick() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(icon, contentDescription = desc, tint = TextPrimary, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
|
||||
/** Snapshot of the phone's mesh identity + state for the FIPS menu section. */
|
||||
private data class FipsInfo(
|
||||
val available: Boolean,
|
||||
val running: Boolean,
|
||||
val npub: String,
|
||||
val meshAddress: String,
|
||||
val peerCount: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* FIPS mesh oversight: shows what the phone's embedded mesh node is doing —
|
||||
* running state, its mesh identity (npub), its mesh address (fd…ULA), how
|
||||
* many peers/anchors it's configured with — and a one-tap Reconnect that
|
||||
* re-homes the mesh (also the manual fix if a network handoff ever misses).
|
||||
* Collapsed by default so the menu stays compact.
|
||||
*/
|
||||
@Composable
|
||||
private fun FipsSection(embedded: Boolean = false) {
|
||||
if (!FipsNative.available) return
|
||||
val context = LocalContext.current
|
||||
val clipboard = LocalClipboardManager.current
|
||||
var expanded by remember { mutableStateOf(embedded) }
|
||||
var info by remember { mutableStateOf<FipsInfo?>(null) }
|
||||
|
||||
// Load identity/state when the section opens (cheap DataStore + JSON read).
|
||||
LaunchedEffect(expanded) {
|
||||
if (expanded && info == null) {
|
||||
val prefs = FipsPreferences(context)
|
||||
val id = prefs.identity()
|
||||
val peers = runCatching {
|
||||
org.json.JSONArray(prefs.peersJson()).length()
|
||||
}.getOrDefault(0)
|
||||
info = FipsInfo(
|
||||
available = true,
|
||||
running = FipsNative.isRunning(),
|
||||
npub = id?.npub.orEmpty(),
|
||||
meshAddress = id?.address.orEmpty(),
|
||||
peerCount = peers,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
// Embedded in the hub's FIPS sub-page the header row would duplicate
|
||||
// the page title, so only the standalone (collapsible) form shows it.
|
||||
if (!embedded) MenuItem(
|
||||
label = "FIPS Mesh",
|
||||
labelColor = BitcoinOrange,
|
||||
onClick = { expanded = !expanded },
|
||||
)
|
||||
if (expanded) {
|
||||
val i = info
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 6.dp)
|
||||
.clip(RoundedCornerShape(ROW_R))
|
||||
.background(FieldBg)
|
||||
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
|
||||
.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
if (i == null) {
|
||||
Text("Loading…", color = TextMuted, fontSize = 13.sp)
|
||||
} else {
|
||||
FipsRow("Status", if (i.running) "Connected" else "Stopped",
|
||||
valueColor = if (i.running) BitcoinOrange else TextMuted)
|
||||
if (i.meshAddress.isNotBlank()) {
|
||||
FipsRow("Mesh address", i.meshAddress, mono = true,
|
||||
onCopy = { clipboard.setText(AnnotatedString(i.meshAddress)) })
|
||||
}
|
||||
if (i.npub.isNotBlank()) {
|
||||
FipsRow("Identity (npub)", i.npub, mono = true,
|
||||
onCopy = { clipboard.setText(AnnotatedString(i.npub)) })
|
||||
}
|
||||
FipsRow("Peers & anchors", i.peerCount.toString())
|
||||
Text(
|
||||
"Your node reaches this phone over the mesh by its npub — no ports opened to the internet.",
|
||||
color = TextMuted, fontSize = 11.sp,
|
||||
)
|
||||
MenuItem(
|
||||
label = "Reconnect mesh",
|
||||
labelColor = BitcoinOrange,
|
||||
onClick = {
|
||||
FipsManager.requestMeshRestart(context)
|
||||
info = null
|
||||
if (!embedded) expanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FipsRow(
|
||||
label: String,
|
||||
value: String,
|
||||
valueColor: Color = TextPrimary,
|
||||
mono: Boolean = false,
|
||||
onCopy: (() -> Unit)? = null,
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (onCopy != null) Modifier.clickable { onCopy() } else Modifier),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(label, color = TextMuted, fontSize = 12.sp, modifier = Modifier.width(120.dp))
|
||||
Text(
|
||||
value,
|
||||
color = valueColor,
|
||||
fontSize = if (mono) 11.sp else 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.End,
|
||||
)
|
||||
if (onCopy != null) {
|
||||
Text("⧉", color = TextMuted, fontSize = 13.sp, modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ fun NESPortraitController(
|
||||
onMouseScroll: (Int) -> Unit = { _ -> },
|
||||
onMenu: () -> Unit,
|
||||
onPlayerToggle: () -> Unit = {},
|
||||
onToggleStyle: (() -> Unit)? = null,
|
||||
) {
|
||||
val c = paletteFor(style)
|
||||
val isClassic = style == ControllerStyle.CLASSIC
|
||||
@@ -151,6 +152,10 @@ fun NESPortraitController(
|
||||
PlayerPill(c, playerId, onPlayerToggle)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
SettingsBtn(c, Modifier, onMenu)
|
||||
onToggleStyle?.let {
|
||||
Spacer(Modifier.width(10.dp))
|
||||
StyleBtn(c, Modifier, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,6 +238,7 @@ internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
||||
val mainExecutor = ContextCompat.getMainExecutor(context)
|
||||
val providerFuture = ProcessCameraProvider.getInstance(context)
|
||||
var provider: ProcessCameraProvider? = null
|
||||
val focusScheduler = Executors.newSingleThreadScheduledExecutor()
|
||||
|
||||
providerFuture.addListener({
|
||||
val p = providerFuture.get()
|
||||
@@ -245,12 +246,15 @@ internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
||||
val preview = Preview.Builder().build().also {
|
||||
it.setSurfaceProvider(previewView.surfaceProvider)
|
||||
}
|
||||
// CameraX's analysis default is 640x480 — too few pixels per module
|
||||
// to decode a modal-sized QR at arm's length. 1280x720 more than
|
||||
// doubles the pixel density at negligible analysis cost.
|
||||
// Dense Lightning-invoice QRs need BOTH enough pixels per module and
|
||||
// sharp focus. 1280x720 + a far-focused camera (e.g. Pixel 9a's main
|
||||
// lens, which won't focus close) left dense invoices undecodable
|
||||
// while sparse address QRs still read — the "scanner doesn't pick up
|
||||
// invoices" report. 1920x1080 roughly doubles module resolution so a
|
||||
// QR held at the camera's actual focus distance still resolves.
|
||||
@Suppress("DEPRECATION")
|
||||
val analysis = ImageAnalysis.Builder()
|
||||
.setTargetResolution(android.util.Size(1280, 720))
|
||||
.setTargetResolution(android.util.Size(1920, 1080))
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
.also {
|
||||
@@ -261,13 +265,27 @@ internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
||||
}
|
||||
try {
|
||||
p.unbindAll()
|
||||
p.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis)
|
||||
val cam = p.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis)
|
||||
// Force a centre autofocus on a repeating tick. A hand-held QR is
|
||||
// a static scene, so continuous-AF often never retriggers and the
|
||||
// lens sits at its resting (far) focus — fatal for dense codes.
|
||||
// A normalized centre point works before the view is measured.
|
||||
val point = androidx.camera.core.SurfaceOrientedMeteringPointFactory(1f, 1f)
|
||||
.createPoint(0.5f, 0.5f)
|
||||
val focusAction = androidx.camera.core.FocusMeteringAction.Builder(
|
||||
point,
|
||||
androidx.camera.core.FocusMeteringAction.FLAG_AF,
|
||||
).disableAutoCancel().build()
|
||||
focusScheduler.scheduleWithFixedDelay({
|
||||
runCatching { cam.cameraControl.startFocusAndMetering(focusAction) }
|
||||
}, 0, 2, java.util.concurrent.TimeUnit.SECONDS)
|
||||
} catch (_: Exception) {
|
||||
// Camera unavailable — the user can dismiss and enter details manually.
|
||||
}
|
||||
}, mainExecutor)
|
||||
|
||||
onDispose {
|
||||
focusScheduler.shutdownNow()
|
||||
provider?.unbindAll()
|
||||
analysisExecutor.shutdown()
|
||||
}
|
||||
|
||||
@@ -12,9 +12,11 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.archipelago.app.data.PairResult
|
||||
import com.archipelago.app.data.ServerEntry
|
||||
import com.archipelago.app.data.ServerPreferences
|
||||
@@ -177,11 +179,25 @@ fun AppNavHost(
|
||||
onRemoteInput = {
|
||||
navController.navigate(Routes.REMOTE_INPUT)
|
||||
},
|
||||
onRemoteKeyboard = {
|
||||
navController.navigate("${Routes.REMOTE_INPUT}?keyboard=true")
|
||||
},
|
||||
onMeshParty = {
|
||||
navController.navigate(Routes.MESH_PARTY)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composable(Routes.REMOTE_INPUT) {
|
||||
composable(
|
||||
"${Routes.REMOTE_INPUT}?keyboard={keyboard}",
|
||||
arguments = listOf(
|
||||
navArgument("keyboard") {
|
||||
type = NavType.BoolType
|
||||
defaultValue = false
|
||||
},
|
||||
),
|
||||
) { entry ->
|
||||
RemoteInputScreen(
|
||||
onBack = {
|
||||
navController.popBackStack()
|
||||
@@ -189,6 +205,7 @@ fun AppNavHost(
|
||||
onMeshParty = {
|
||||
navController.navigate(Routes.MESH_PARTY)
|
||||
},
|
||||
startInKeyboard = entry.arguments?.getBoolean("keyboard") == true,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@ import android.content.res.Configuration
|
||||
import androidx.activity.compose.BackHandler
|
||||
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.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -54,7 +56,12 @@ import com.archipelago.app.ui.theme.TextMuted
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) {
|
||||
fun RemoteInputScreen(
|
||||
onBack: () -> Unit,
|
||||
onMeshParty: (() -> Unit)? = null,
|
||||
// Land on the keyboard instead of the gamepad (hub menu's Keyboard card).
|
||||
startInKeyboard: Boolean = false,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val prefs = remember { ServerPreferences(context) }
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -63,7 +70,7 @@ fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) {
|
||||
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
|
||||
val activeServer by prefs.activeServer.collectAsState(initial = null)
|
||||
|
||||
var isGamepadMode by remember { mutableStateOf(true) }
|
||||
var isGamepadMode by remember { mutableStateOf(!startInKeyboard) }
|
||||
var showModal by remember { mutableStateOf(false) }
|
||||
var showQrScanner by remember { mutableStateOf(false) }
|
||||
var controllerStyle by remember { mutableStateOf(ControllerStyle.DARK) }
|
||||
@@ -90,6 +97,9 @@ fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) {
|
||||
playerId = when (playerId) { 0 -> 1; 1 -> 2; else -> 0 }
|
||||
ws.playerId = playerId
|
||||
}
|
||||
fun toggleStyle() {
|
||||
controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC
|
||||
}
|
||||
val connectionState by ws.state.collectAsState()
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
@@ -153,6 +163,7 @@ fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) {
|
||||
onKey = { ws.sendKey(it) },
|
||||
onMenu = { showModal = true },
|
||||
onPlayerToggle = ::togglePlayer,
|
||||
onToggleStyle = ::toggleStyle,
|
||||
)
|
||||
isGamepadMode && !isLandscape -> NESPortraitController(
|
||||
style = controllerStyle,
|
||||
@@ -163,6 +174,7 @@ fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) {
|
||||
onMouseScroll = { ws.sendScroll(it) },
|
||||
onMenu = { showModal = true },
|
||||
onPlayerToggle = ::togglePlayer,
|
||||
onToggleStyle = ::toggleStyle,
|
||||
)
|
||||
else -> {
|
||||
// Keyboard mode: trackpad fills top, keyboard pinned bottom
|
||||
@@ -182,12 +194,20 @@ fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) {
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
// Settings icon top-right in keyboard mode
|
||||
com.archipelago.app.ui.components.SettingsBtn(
|
||||
c = com.archipelago.app.ui.components.paletteFor(controllerStyle),
|
||||
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
|
||||
onClick = { showModal = true },
|
||||
)
|
||||
// Settings + style icons top-right in keyboard mode
|
||||
Row(
|
||||
Modifier.align(Alignment.TopEnd).padding(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
com.archipelago.app.ui.components.SettingsBtn(
|
||||
c = com.archipelago.app.ui.components.paletteFor(controllerStyle),
|
||||
onClick = { showModal = true },
|
||||
)
|
||||
com.archipelago.app.ui.components.StyleBtn(
|
||||
c = com.archipelago.app.ui.components.paletteFor(controllerStyle),
|
||||
onClick = ::toggleStyle,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -210,8 +230,6 @@ fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) {
|
||||
visible = showModal,
|
||||
servers = savedServers,
|
||||
activeServer = activeServer,
|
||||
isGamepadMode = isGamepadMode,
|
||||
controllerStyle = controllerStyle,
|
||||
onDismiss = { showModal = false },
|
||||
onSelectServer = { server ->
|
||||
scope.launch { ws.disconnect(); prefs.setActiveServer(server) }; showModal = false
|
||||
@@ -245,10 +263,8 @@ fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) {
|
||||
}
|
||||
}
|
||||
},
|
||||
onToggleMode = { isGamepadMode = !isGamepadMode; showModal = false },
|
||||
onToggleStyle = {
|
||||
controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC
|
||||
},
|
||||
onRemote = { isGamepadMode = true; showModal = false },
|
||||
onKeyboard = { isGamepadMode = false; showModal = false },
|
||||
onBackToWebView = { showModal = false; onBack() },
|
||||
onMeshParty = onMeshParty?.let { open -> { showModal = false; open() } },
|
||||
)
|
||||
|
||||
@@ -88,8 +88,11 @@ import androidx.compose.ui.viewinterop.AndroidView
|
||||
import android.webkit.ValueCallback
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.data.ServerPreferences
|
||||
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.QrScannerOverlay
|
||||
import com.archipelago.app.ui.components.WalletQrScannerModal
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.ErrorRed
|
||||
@@ -179,6 +182,38 @@ private fun injectSafeAreaVars(view: WebView) {
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
style.textContent = ':root { --safe-area-top: ${sat}px; --safe-area-bottom: ${sab}px; }';
|
||||
// Vue components sample the var into reactive state; tell them it
|
||||
// changed (an authenticated session can mount before we run).
|
||||
window.dispatchEvent(new CustomEvent('archy-insets', { detail: { top: ${sat}, bottom: ${sab} } }));
|
||||
})();
|
||||
""".trimIndent(),
|
||||
null,
|
||||
)
|
||||
}
|
||||
|
||||
/** In-app browser pages (node apps + same-node links) don't consume the
|
||||
* neode-ui `--safe-area-top` var, so with the WebView drawing edge-to-edge
|
||||
* their content ran up under the status bar. Pad the document body down by
|
||||
* the status-bar height: the padded strip shows the page's OWN background
|
||||
* (padding is inside the element), so the bar keeps the page colour while
|
||||
* content starts below it — the pre-edge-to-edge look, without the black bar.
|
||||
* Idempotent; runs on start (early) and finish (after the app rewrites head). */
|
||||
private fun injectTopInset(view: WebView) {
|
||||
val insets = view.rootWindowInsets ?: return
|
||||
val density = view.resources.displayMetrics.density
|
||||
val sat = (insets.getInsets(android.view.WindowInsets.Type.statusBars()).top / density).toInt()
|
||||
if (sat <= 0) return
|
||||
view.evaluateJavascript(
|
||||
"""
|
||||
(function() {
|
||||
var s = document.getElementById('archy-top-inset');
|
||||
if (!s) {
|
||||
s = document.createElement('style');
|
||||
s.id = 'archy-top-inset';
|
||||
(document.head || document.documentElement).appendChild(s);
|
||||
}
|
||||
s.textContent =
|
||||
'body{padding-top:${sat}px!important;box-sizing:border-box!important;}';
|
||||
})();
|
||||
""".trimIndent(),
|
||||
null,
|
||||
@@ -198,13 +233,17 @@ private fun tcpAnswers(base: String, timeoutMs: Int): Boolean = try {
|
||||
}
|
||||
|
||||
/** Fastest answering origin: LAN inside a short window, else the mesh ULA
|
||||
* (patient — a cold session may still be establishing), else LAN anyway so
|
||||
* the existing error/fallback path handles it. */
|
||||
* (patient — a cold session may still be establishing). If NEITHER answers,
|
||||
* fall back to the mesh URL when we have one — off-LAN the LAN IP is
|
||||
* unreachable, and loading it just produced a confusing "can't reach
|
||||
* 192.168.x.x" error page (user-reported 2026-07-27). Targeting the mesh URL
|
||||
* instead means the load retries against the path that's actually coming up,
|
||||
* and any error shows the mesh address rather than a dead LAN IP. */
|
||||
private suspend fun pickStartUrl(lanUrl: String, meshUrl: String?): String =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (tcpAnswers(lanUrl, 2500)) return@withContext lanUrl
|
||||
if (meshUrl != null && tcpAnswers(meshUrl, 12_000)) return@withContext meshUrl
|
||||
lanUrl
|
||||
meshUrl ?: lanUrl
|
||||
}
|
||||
|
||||
/** Apply the WebView settings shared by the kiosk view and the in-app browser.
|
||||
@@ -245,6 +284,10 @@ fun WebViewScreen(
|
||||
serverUrl: String,
|
||||
onDisconnect: () -> Unit,
|
||||
onRemoteInput: () -> Unit = {},
|
||||
// Like onRemoteInput but landing on the keyboard (the hub menu's Keyboard card).
|
||||
onRemoteKeyboard: () -> Unit = {},
|
||||
// Opens the phone-to-phone Mesh Party screen; null hides its hub card.
|
||||
onMeshParty: (() -> 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.
|
||||
@@ -323,6 +366,14 @@ fun WebViewScreen(
|
||||
// One-time three-finger-hold teaching overlay (initial=true: never flash
|
||||
// it while DataStore is still loading).
|
||||
val prefs = remember { ServerPreferences(webViewContext) }
|
||||
|
||||
// Hub menu overlay state — the three-finger hold opens the menu right here
|
||||
// over the dashboard (it used to jump to the remote screen).
|
||||
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
|
||||
val activeServer by prefs.activeServer.collectAsState(initial = null)
|
||||
var showHubMenu by remember { mutableStateOf(false) }
|
||||
var showPairScanner by remember { mutableStateOf(false) }
|
||||
|
||||
val gestureHintSeen by prefs.gestureHintSeen.collectAsState(initial = true)
|
||||
var gestureHintDismissed by remember { mutableStateOf(false) }
|
||||
// Don't teach the gesture on top of the login/splash — arm the overlay
|
||||
@@ -708,7 +759,8 @@ fun WebViewScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Three-finger hold (500ms) → navigate to remote input.
|
||||
// Three-finger hold (500ms) → open the hub menu overlay
|
||||
// in place (Remote/Keyboard cards do the navigating).
|
||||
// Three fingers, not two: two-finger scroll/pinch on the
|
||||
// page collided with the old two-finger hold.
|
||||
var threeFingerStart = 0L
|
||||
@@ -726,7 +778,7 @@ fun WebViewScreen(
|
||||
if (pointerCount >= 3 && !threeFingerFired && threeFingerStart > 0) {
|
||||
if (System.currentTimeMillis() - threeFingerStart > 500) {
|
||||
threeFingerFired = true
|
||||
onRemoteInput()
|
||||
showHubMenu = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -851,6 +903,68 @@ 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).
|
||||
NESMenu(
|
||||
visible = showHubMenu,
|
||||
servers = savedServers,
|
||||
activeServer = activeServer,
|
||||
onDismiss = { showHubMenu = false },
|
||||
onSelectServer = { server ->
|
||||
showHubMenu = false
|
||||
scope.launch { prefs.setActiveServer(server) }
|
||||
},
|
||||
onAddServer = { server ->
|
||||
scope.launch {
|
||||
prefs.addSavedServer(server)
|
||||
if (activeServer == null) prefs.setActiveServer(server)
|
||||
}
|
||||
},
|
||||
onScanQr = { showPairScanner = true },
|
||||
onEditServer = { original, updated ->
|
||||
scope.launch {
|
||||
prefs.updateSavedServer(original, updated)
|
||||
// Editing the live server reloads the kiosk with the new
|
||||
// address/credentials via the activeServer recomposition.
|
||||
if (original.serialize() == activeServer?.serialize()) {
|
||||
prefs.setActiveServer(updated)
|
||||
}
|
||||
}
|
||||
},
|
||||
onRemoveServer = { server ->
|
||||
scope.launch {
|
||||
prefs.removeSavedServer(server)
|
||||
// Nothing left to show — back to the Connect screen.
|
||||
val remaining = savedServers.count { it.serialize() != server.serialize() }
|
||||
if (remaining == 0) {
|
||||
prefs.clearActiveServer()
|
||||
showHubMenu = false
|
||||
onDisconnect()
|
||||
}
|
||||
}
|
||||
},
|
||||
onRemote = { showHubMenu = false; onRemoteInput() },
|
||||
onKeyboard = { showHubMenu = false; onRemoteKeyboard() },
|
||||
onBackToWebView = { showHubMenu = false },
|
||||
onMeshParty = onMeshParty?.let { open -> { showHubMenu = false; open() } },
|
||||
)
|
||||
|
||||
// Pairing-QR scan launched from the menu's Nodes page; the menu stays
|
||||
// open behind it so the new entry appears as soon as it closes.
|
||||
QrScannerOverlay(
|
||||
visible = showPairScanner,
|
||||
onDismiss = { showPairScanner = false },
|
||||
onServerScanned = { scan ->
|
||||
showPairScanner = false
|
||||
scope.launch {
|
||||
val merged = prefs.upsertServer(scan.server)
|
||||
FipsManager.registerNode(webViewContext, scan.fips, merged.displayName())
|
||||
if (activeServer == null) prefs.setActiveServer(merged)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1011,12 +1125,14 @@ private fun InAppBrowser(
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) {
|
||||
loading = true
|
||||
view?.let { injectTopInset(it) }
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView?, u: String?) {
|
||||
loading = false
|
||||
canGoBack = view?.canGoBack() == true
|
||||
canGoForward = view?.canGoForward() == true
|
||||
view?.let { injectTopInset(it) }
|
||||
}
|
||||
|
||||
override fun doUpdateVisitedHistory(view: WebView?, u: String?, isReload: Boolean) {
|
||||
|
||||
@@ -132,6 +132,7 @@ impl RpcHandler {
|
||||
"lnd.estimatefee" => self.handle_lnd_estimatefee(params).await,
|
||||
"lnd.createinvoice" => self.handle_lnd_createinvoice(params).await,
|
||||
"lnd.payinvoice" => self.handle_lnd_payinvoice(params).await,
|
||||
"lnd.paymentstatus" => self.handle_lnd_paymentstatus(params).await,
|
||||
"lnd.create-psbt" => self.handle_lnd_create_psbt(params).await,
|
||||
"lnd.finalize-psbt" => self.handle_lnd_finalize_psbt(params).await,
|
||||
"lnd.create-raw-tx" => self.handle_lnd_create_raw_tx(params).await,
|
||||
|
||||
@@ -36,6 +36,33 @@ impl RpcHandler {
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
|
||||
// Decode the invoice up front (fast, local) so we know its payment
|
||||
// hash BEFORE handing it to LND. If the payment outlives our wait
|
||||
// below, the hash is what lets the UI keep tracking it instead of
|
||||
// declaring a false failure. Best-effort: a decode hiccup must not
|
||||
// block the payment itself.
|
||||
let (decoded_hash, decoded_amt) = match client
|
||||
.get(format!("{LND_REST_BASE_URL}/v1/payreq/{payment_request}"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => match r.json::<serde_json::Value>().await {
|
||||
Ok(d) => (
|
||||
d.get("payment_hash")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
d.get("num_satoshis")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.unwrap_or(0),
|
||||
),
|
||||
Err(_) => (String::new(), 0),
|
||||
},
|
||||
Err(_) => (String::new(), 0),
|
||||
};
|
||||
|
||||
let mut pay_body = serde_json::json!({
|
||||
"payment_request": payment_request,
|
||||
});
|
||||
@@ -43,13 +70,46 @@ impl RpcHandler {
|
||||
pay_body["amt"] = serde_json::json!(amt.to_string());
|
||||
}
|
||||
|
||||
let resp = client
|
||||
// `/v1/channels/transactions` is SYNCHRONOUS: it blocks until the
|
||||
// payment settles or definitively fails, and multi-hop routing with
|
||||
// retries routinely takes longer than the shared client's 15s budget.
|
||||
// That 15s abort used to surface as "Payment failed" while LND kept
|
||||
// paying in the background — the payment then succeeded and appeared
|
||||
// in history a minute later. Wait up to 120s on a dedicated client,
|
||||
// and treat a post-connect timeout as IN FLIGHT (status: pending),
|
||||
// never as failure — only LND may declare a payment failed.
|
||||
let pay_client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
let resp = match pay_client
|
||||
.post(format!("{LND_REST_BASE_URL}/v1/channels/transactions"))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.json(&pay_body)
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to pay invoice")?;
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) if e.is_connect() => {
|
||||
// Never reached LND — nothing was sent; this IS a hard error.
|
||||
return Err(anyhow::anyhow!("Could not reach LND to pay: {e}"));
|
||||
}
|
||||
Err(_) => {
|
||||
// Timed out (or lost the connection) AFTER the payment was
|
||||
// handed to LND — it may well still succeed. Report pending
|
||||
// with the hash so the caller can poll lnd.paymentstatus.
|
||||
info!("payinvoice wait elapsed; payment still in flight");
|
||||
return Ok(serde_json::json!({
|
||||
"status": "pending",
|
||||
"payment_hash": decoded_hash,
|
||||
"amount_sats": decoded_amt,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp
|
||||
@@ -86,20 +146,105 @@ impl RpcHandler {
|
||||
.and_then(|r| r.get("total_amt"))
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.unwrap_or(0);
|
||||
.unwrap_or(decoded_amt);
|
||||
|
||||
let payment_hash = body
|
||||
.get("payment_hash")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or(decoded_hash);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"status": "succeeded",
|
||||
"payment_hash": payment_hash,
|
||||
"amount_sats": amount_sat,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Status of an outgoing Lightning payment by hex payment hash. Lets the
|
||||
/// UI resolve a payinvoice that outlived its synchronous wait (`status:
|
||||
/// "pending"`) to a real terminal state instead of guessing.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_paymentstatus(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or_default();
|
||||
let payment_hash = params
|
||||
.get("payment_hash")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing 'payment_hash' parameter"))?;
|
||||
if payment_hash.len() != 64 || !payment_hash.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(anyhow::anyhow!("Invalid payment hash"));
|
||||
}
|
||||
|
||||
let (client, macaroon_hex) = self.lnd_client().await?;
|
||||
let resp = client
|
||||
.get(format!(
|
||||
"{LND_REST_BASE_URL}/v1/payments?include_incomplete=true&max_payments=100&reversed=true"
|
||||
))
|
||||
.header("Grpc-Metadata-macaroon", &macaroon_hex)
|
||||
.send()
|
||||
.await
|
||||
.context("LND REST connection failed")?;
|
||||
let body: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse payments response")?;
|
||||
|
||||
let hash_lower = payment_hash.to_lowercase();
|
||||
let found = body
|
||||
.get("payments")
|
||||
.and_then(|v| v.as_array())
|
||||
.and_then(|arr| {
|
||||
arr.iter().find(|p| {
|
||||
p.get("payment_hash").and_then(|v| v.as_str())
|
||||
== Some(hash_lower.as_str())
|
||||
})
|
||||
});
|
||||
|
||||
let Some(p) = found else {
|
||||
// Not in the latest window — either very old or LND never saw it.
|
||||
return Ok(serde_json::json!({ "status": "unknown" }));
|
||||
};
|
||||
|
||||
let lnd_status = p.get("status").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let status = match lnd_status {
|
||||
"SUCCEEDED" => "succeeded",
|
||||
"FAILED" => "failed",
|
||||
_ => "in_flight",
|
||||
};
|
||||
let failure_reason = match p
|
||||
.get("failure_reason")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
{
|
||||
"FAILURE_REASON_NO_ROUTE" => "No route to the recipient",
|
||||
"FAILURE_REASON_INSUFFICIENT_BALANCE" => "Insufficient channel balance",
|
||||
"FAILURE_REASON_TIMEOUT" => "Payment timed out in the network",
|
||||
"FAILURE_REASON_INCORRECT_PAYMENT_DETAILS" => {
|
||||
"Recipient rejected the payment (wrong details or expired invoice)"
|
||||
}
|
||||
"FAILURE_REASON_ERROR" => "Payment failed",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
fn amt(p: &serde_json::Value, key: &str) -> i64 {
|
||||
p.get(key)
|
||||
.and_then(|f| f.as_str())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.or_else(|| p.get(key).and_then(|f| f.as_i64()))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"status": status,
|
||||
"failure_reason": failure_reason,
|
||||
"amount_sats": amt(p, "value_sat"),
|
||||
"fee_sats": amt(p, "fee_sat"),
|
||||
}))
|
||||
}
|
||||
|
||||
/// List on-chain transactions from LND.
|
||||
/// Returns all transactions, with incoming (amount > 0) flagged.
|
||||
pub(in crate::api::rpc) async fn handle_lnd_gettransactions(
|
||||
|
||||
@@ -15,6 +15,26 @@ those are marked ✅ below with the commit that did it, so we stop re-litigating
|
||||
|
||||
## Tier 0 — Quick / mechanical, no blockers
|
||||
|
||||
- [ ] **Ship the lightning payment false-failure fix in the next release** (fixed
|
||||
on main 2026-07-27, needs OTA). Slow multi-hop payments (>15s) surfaced as
|
||||
"Payment failed" while LND settled them in the background — the shared LND
|
||||
REST client's 15s timeout aborted the synchronous `/v1/channels/transactions`
|
||||
wait. Now: payinvoice decodes the invoice first for its payment hash, waits
|
||||
up to 120s on a dedicated client, returns `status: "pending"` (never a
|
||||
failure) on timeout, and the new `lnd.paymentstatus` RPC + frontend
|
||||
`payLightningInvoice()` helper poll to a real terminal state (all 5 UI call
|
||||
sites migrated). Verify on Framework PT with a real multi-hop payment.
|
||||
- [ ] **Show the app version on the companion mobile-app banner in the app store
|
||||
and on its install/pairing modal** (user request 2026-07-27) — so it's
|
||||
obvious at a glance whether the node is serving the latest APK build.
|
||||
- [ ] **Optimise the companion QR scan — quicker + better** (user request
|
||||
2026-07-27; deferred to a later session on purpose). The pairing/scan QR
|
||||
flow works (user-verified on-device 2026-07-27) but should get faster and
|
||||
smoother: quicker camera start + decode (scan resolution/framerate,
|
||||
continuous autofocus), more forgiving in low light / at an angle, and
|
||||
snappier feedback once the code locks. Touch the native-scan path from
|
||||
PR #104 and the in-app scan modal together so both benefit.
|
||||
|
||||
- [ ] **Update `tests/lifecycle/TESTING.md`'s stale Release Gates checklist** (lines
|
||||
289–296) — several boxes are unchecked but actually true now:
|
||||
- #1 bitcoin-stops: covered by `tests/lifecycle/bats/bitcoin-knots.bats` stop/restart
|
||||
|
||||
Binary file not shown.
@@ -400,6 +400,76 @@ class RPCClient {
|
||||
})
|
||||
}
|
||||
|
||||
/** Pay a Lightning invoice and resolve it to a REAL terminal state.
|
||||
*
|
||||
* The backend waits up to 120s on LND's synchronous pay endpoint; if the
|
||||
* payment is still routing after that it answers `status: "pending"` with
|
||||
* the payment hash instead of an error. This helper then polls
|
||||
* lnd.paymentstatus until LND itself reports succeeded/failed, so callers
|
||||
* never show "failed" for a payment that is merely slow — the bug where a
|
||||
* settling payment was declared failed and then appeared in history a
|
||||
* minute later. Returns `pending` only if the payment is STILL in flight
|
||||
* after the polling window (rare; caller should say "still settling",
|
||||
* not "failed"). */
|
||||
async payLightningInvoice(params: {
|
||||
payment_request: string
|
||||
amount_sats?: number
|
||||
}): Promise<{
|
||||
status: 'succeeded' | 'failed' | 'pending'
|
||||
payment_hash: string
|
||||
amount_sats: number
|
||||
failure_reason?: string
|
||||
}> {
|
||||
const res = await this.call<{
|
||||
status?: string
|
||||
payment_hash?: string
|
||||
amount_sats?: number
|
||||
}>({
|
||||
method: 'lnd.payinvoice',
|
||||
params,
|
||||
// Above the backend's 120s wait so the backend always answers first.
|
||||
timeout: 130000,
|
||||
})
|
||||
|
||||
const hash = res.payment_hash || ''
|
||||
const amount = res.amount_sats || 0
|
||||
// Older backends have no status field — a plain response was a success.
|
||||
if (res.status !== 'pending') {
|
||||
return { status: 'succeeded', payment_hash: hash, amount_sats: amount }
|
||||
}
|
||||
if (!hash) return { status: 'pending', payment_hash: '', amount_sats: amount }
|
||||
|
||||
// Poll to a terminal state: every 3s for up to 2 minutes.
|
||||
for (let i = 0; i < 40; i++) {
|
||||
await new Promise((r) => setTimeout(r, 3000))
|
||||
try {
|
||||
const st = await this.call<{
|
||||
status: string
|
||||
failure_reason?: string
|
||||
amount_sats?: number
|
||||
}>({
|
||||
method: 'lnd.paymentstatus',
|
||||
params: { payment_hash: hash },
|
||||
timeout: 15000,
|
||||
})
|
||||
if (st.status === 'succeeded') {
|
||||
return { status: 'succeeded', payment_hash: hash, amount_sats: st.amount_sats || amount }
|
||||
}
|
||||
if (st.status === 'failed') {
|
||||
return {
|
||||
status: 'failed',
|
||||
payment_hash: hash,
|
||||
amount_sats: amount,
|
||||
failure_reason: st.failure_reason || 'Payment failed',
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Transient poll error — keep trying; only LND decides failure.
|
||||
}
|
||||
}
|
||||
return { status: 'pending', payment_hash: hash, amount_sats: amount }
|
||||
}
|
||||
|
||||
async publishNostrIdentity(): Promise<{ event_id: string; success: number; failed: number }> {
|
||||
return this.call({
|
||||
method: 'node.nostr-publish',
|
||||
|
||||
@@ -525,10 +525,10 @@ async function approvePayment() {
|
||||
receipt = { method: 'ecash', token: res.token, amount_sats: res.amount_sats }
|
||||
} else if (method === 'lightning') {
|
||||
if (pay.invoice) {
|
||||
const res = await rpcClient.call<{ payment_hash: string; amount_sats: number }>({
|
||||
method: 'lnd.payinvoice',
|
||||
params: { payment_request: pay.invoice },
|
||||
})
|
||||
// Tracked to a real terminal state — slow routing is not a failure.
|
||||
const res = await rpcClient.payLightningInvoice({ payment_request: pay.invoice })
|
||||
if (res.status === 'failed') throw new Error(res.failure_reason || 'Payment failed')
|
||||
if (res.status === 'pending') throw new Error('Payment is still settling — check your wallet transactions before retrying.')
|
||||
receipt = { method: 'lightning', payment_hash: res.payment_hash, amount_sats: res.amount_sats }
|
||||
} else {
|
||||
// Create and immediately return an invoice for the requester to display
|
||||
|
||||
@@ -620,11 +620,21 @@ async function send() {
|
||||
ecashToken.value = res.token
|
||||
} else if (method === 'lightning') {
|
||||
if (!dest.value.trim()) { error.value = t('web5.pasteInvoice'); return }
|
||||
const res = await rpcClient.call<{ payment_hash: string }>({
|
||||
method: 'lnd.payinvoice',
|
||||
params: { payment_request: dest.value.trim() },
|
||||
})
|
||||
successInfo.value = { amount: paidAmount, methodLabel: 'Paid over Lightning', hash: res.payment_hash }
|
||||
// Waits out slow multi-hop routing and only reports failure when LND
|
||||
// itself declares the payment failed — never on a timeout.
|
||||
const res = await rpcClient.payLightningInvoice({ payment_request: dest.value.trim() })
|
||||
if (res.status === 'failed') {
|
||||
error.value = res.failure_reason || t('web5.sendFailed')
|
||||
return
|
||||
}
|
||||
successInfo.value = {
|
||||
amount: paidAmount,
|
||||
methodLabel: res.status === 'pending' ? 'Payment in flight' : 'Paid over Lightning',
|
||||
hash: res.payment_hash || undefined,
|
||||
...(res.status === 'pending'
|
||||
? { note: 'This payment is taking longer than usual to settle. It will appear in your transactions once it completes.' }
|
||||
: {}),
|
||||
}
|
||||
} else {
|
||||
if (!dest.value.trim()) { error.value = t('web5.enterBitcoinAddress'); return }
|
||||
const res = await rpcClient.call<{ txid: string }>({
|
||||
|
||||
@@ -701,16 +701,17 @@ async function confirmSend() {
|
||||
error.value = ''
|
||||
try {
|
||||
if (action.value === 'pay-invoice') {
|
||||
const params: Record<string, unknown> = { payment_request: dest.value }
|
||||
const params: { payment_request: string; amount_sats?: number } = { payment_request: dest.value }
|
||||
if (!amountLocked.value && effectiveAmount.value > 0) params.amount_sats = effectiveAmount.value
|
||||
const res = await rpcClient.call<{ payment_hash: string; amount_sats: number }>({
|
||||
method: 'lnd.payinvoice',
|
||||
params,
|
||||
timeout: 60000,
|
||||
})
|
||||
// Waits out slow multi-hop routing and only reports failure when LND
|
||||
// itself declares the payment failed — never on a timeout.
|
||||
const res = await rpcClient.payLightningInvoice(params)
|
||||
if (res.status === 'failed') throw new Error(res.failure_reason || 'Payment failed')
|
||||
successAmount.value = res.amount_sats || effectiveAmount.value
|
||||
successVerb.value = 'PAID'
|
||||
successDetail.value = 'Lightning invoice paid'
|
||||
successVerb.value = res.status === 'pending' ? 'SENDING' : 'PAID'
|
||||
successDetail.value = res.status === 'pending'
|
||||
? 'Payment in flight — it will appear in your transactions once it settles'
|
||||
: 'Lightning invoice paid'
|
||||
successRef.value = res.payment_hash
|
||||
} else if (action.value === 'send-onchain') {
|
||||
const res = await rpcClient.call<{ txid: string }>({
|
||||
|
||||
@@ -1320,8 +1320,8 @@ async function payWithInvoice() {
|
||||
|
||||
/**
|
||||
* Pay the seller's invoice straight from THIS node's Lightning wallet, then
|
||||
* release the file. No QR/polling: lnd.payinvoice only returns once the payment
|
||||
* settles, so the payment_hash is immediately valid as the download gate token.
|
||||
* release the file. payLightningInvoice resolves to a real terminal state, so
|
||||
* on success the payment_hash is immediately valid as the download gate token.
|
||||
*/
|
||||
async function payWithLightning() {
|
||||
const item = payItem.value
|
||||
@@ -1341,14 +1341,15 @@ async function payWithLightning() {
|
||||
lnError.value = inv?.error || 'The seller could not create an invoice (is its Lightning node running?).'
|
||||
return
|
||||
}
|
||||
// 2. Pay it from our own node. Returns only after settlement.
|
||||
const pay = await rpcClient.call<{ payment_hash?: string; payment_error?: string }>({
|
||||
method: 'lnd.payinvoice',
|
||||
params: { payment_request: inv.bolt11 },
|
||||
timeout: 120000,
|
||||
})
|
||||
if (pay?.payment_error) {
|
||||
lnError.value = `Payment failed: ${pay.payment_error}`
|
||||
// 2. Pay it from our own node. Tracked to a REAL terminal state — a slow
|
||||
// multi-hop route resolves via status polling instead of a false failure.
|
||||
const pay = await rpcClient.payLightningInvoice({ payment_request: inv.bolt11 })
|
||||
if (pay.status === 'failed') {
|
||||
lnError.value = `Payment failed: ${pay.failure_reason || 'unknown reason'}`
|
||||
return
|
||||
}
|
||||
if (pay.status === 'pending') {
|
||||
lnError.value = 'Payment is still settling — this can take a few minutes. Check your wallet transactions before paying again.'
|
||||
return
|
||||
}
|
||||
// 3. Settled — pull the file using the payment hash as the gate token.
|
||||
|
||||
@@ -208,6 +208,11 @@ function onResize() {
|
||||
updateTabBarHeight()
|
||||
}
|
||||
|
||||
function onInsetsInjected() {
|
||||
readSafeAreaTop()
|
||||
updateTabBarHeight()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateTabBarHeight()
|
||||
// Re-measure after the first paint: on mount the bar may not have its final
|
||||
@@ -216,13 +221,21 @@ onMounted(() => {
|
||||
requestAnimationFrame(updateTabBarHeight)
|
||||
readSafeAreaTop()
|
||||
window.addEventListener('resize', onResize)
|
||||
// Re-read after WebView injection has had time to run. The injected
|
||||
// safe-area-bottom padding changes the bar's height, so re-measure too.
|
||||
setTimeout(() => { readSafeAreaTop(); updateTabBarHeight() }, 500)
|
||||
// The Android WebView injects --safe-area-top asynchronously and fires this
|
||||
// event when it lands. An authenticated session mounts the dashboard BEFORE
|
||||
// the injection (fresh installs mount after login, long after it), so a
|
||||
// one-shot read here bakes in 0 and content slides under the growing fixed
|
||||
// tab bar — the update-install-only overlap bug.
|
||||
window.addEventListener('archy-insets', onInsetsInjected)
|
||||
// Fallback retry ladder for APKs that predate the event.
|
||||
for (const delay of [500, 1500, 3000, 6000]) {
|
||||
setTimeout(onInsetsInjected, delay)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
window.removeEventListener('archy-insets', onInsetsInjected)
|
||||
})
|
||||
|
||||
// Re-measure on route changes
|
||||
|
||||
@@ -291,10 +291,10 @@ async function unifiedSend() {
|
||||
unifiedSendError.value = t('web5.pasteInvoice')
|
||||
return
|
||||
}
|
||||
const res = await rpcClient.call<{ payment_hash: string; amount_sats: number }>({
|
||||
method: 'lnd.payinvoice',
|
||||
params: { payment_request: unifiedSendDest.value.trim() },
|
||||
})
|
||||
// Waits out slow multi-hop routing and only reports failure when LND
|
||||
// itself declares the payment failed — never on a timeout.
|
||||
const res = await rpcClient.payLightningInvoice({ payment_request: unifiedSendDest.value.trim() })
|
||||
if (res.status === 'failed') throw new Error(res.failure_reason || 'Payment failed')
|
||||
sendResultHash.value = res.payment_hash
|
||||
} else {
|
||||
if (!unifiedSendDest.value.trim()) {
|
||||
|
||||
Reference in New Issue
Block a user