Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f456b3d0ad | ||
|
|
de770203ff | ||
|
|
a7a6528b08 | ||
|
|
e751b7c6f9 | ||
|
|
a500a75235 | ||
|
|
71a57c3ec1 | ||
|
|
b67e1527a2 |
@@ -0,0 +1,93 @@
|
||||
Copyright 2011 The Montserrat Project Authors (https://github.com/JulietaUla/Montserrat)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "com.archipelago.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 45
|
||||
versionName = "0.5.25"
|
||||
versionCode = 47
|
||||
versionName = "0.5.27"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
|
||||
@@ -1,5 +1,40 @@
|
||||
package com.archipelago.app
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Looper
|
||||
import android.webkit.WebView
|
||||
import com.archipelago.app.data.ServerPreferences
|
||||
import com.archipelago.app.fips.FipsNative
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ArchipelagoApp : Application()
|
||||
class ArchipelagoApp : Application() {
|
||||
|
||||
private val warmupScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
// Warmups that otherwise land inside the first frame:
|
||||
// - FipsNative.available dlopens the 7 MB Rust core; referenced from
|
||||
// composition (NESMenu, mesh auto-start), it blocked the UI thread.
|
||||
// - The first DataStore read gates the nav graph's start destination;
|
||||
// parsing it here means the launch gate resolves in the first
|
||||
// emission instead of waiting on cold disk IO.
|
||||
warmupScope.launch {
|
||||
FipsNative.available
|
||||
runCatching { ServerPreferences(this@ArchipelagoApp).launchState.first() }
|
||||
}
|
||||
|
||||
// First WebView construction pays Chromium provider load (~150-400 ms
|
||||
// cold). Absorb it while the main thread is idle before the kiosk
|
||||
// needs it, instead of serially after the connection probe.
|
||||
Looper.getMainLooper().queue.addIdleHandler {
|
||||
runCatching { WebView(this).destroy() }
|
||||
false // one-shot
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import com.archipelago.app.ui.navigation.AppNavHost
|
||||
import com.archipelago.app.ui.screens.releaseKioskWebView
|
||||
import com.archipelago.app.ui.theme.ArchipelagoTheme
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
@@ -19,7 +20,13 @@ class MainActivity : ComponentActivity() {
|
||||
private val pendingPairUri = MutableStateFlow<String?>(null)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
installSplashScreen()
|
||||
// Hold the branded system splash until the nav graph has its launch
|
||||
// state — without this the splash dropped at the first composed frame,
|
||||
// which was EMPTY (the DataStore read hadn't landed): splash → black
|
||||
// flash → UI on every launch.
|
||||
var navReady = false
|
||||
val splash = installSplashScreen()
|
||||
splash.setKeepOnScreenCondition { !navReady }
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
pendingPairUri.value = intent?.dataString
|
||||
@@ -29,6 +36,7 @@ class MainActivity : ComponentActivity() {
|
||||
AppNavHost(
|
||||
pairUri = pairUri,
|
||||
onPairUriConsumed = { pendingPairUri.value = null },
|
||||
onReady = { navReady = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -38,4 +46,14 @@ class MainActivity : ComponentActivity() {
|
||||
super.onNewIntent(intent)
|
||||
pendingPairUri.value = intent.dataString
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
// Swiped out of recents (or otherwise finished) — let go of the
|
||||
// retained kiosk WebView so the next launch starts clean. Without
|
||||
// this the FIPS service keeps the process (and the static WebView)
|
||||
// alive, and "close the app" no longer restarted it. isFinishing
|
||||
// keeps config changes (rotation) on the fast reattach path.
|
||||
if (isFinishing) releaseKioskWebView()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "server_prefs")
|
||||
@@ -29,6 +30,18 @@ data class ServerEntry(
|
||||
/** Label to show in lists — the user-given name, or the address if unnamed. */
|
||||
fun displayName(): String = name.ifBlank { address }
|
||||
|
||||
/**
|
||||
* Is this node reachable over the Archipelago FIPS mesh?
|
||||
*
|
||||
* A node that advertised either identity (npub) or a mesh address (ULA)
|
||||
* came from a FIPS-capable pairing QR. Anything else — a hand-entered LAN
|
||||
* box, someone else's server behind their own VPN — is a plain HTTP
|
||||
* target, and the companion must NOT raise its own tunnel for it: Android
|
||||
* allows exactly one VPN at a time, so doing so would silently take the
|
||||
* tunnel away from whatever the user actually uses to reach that node.
|
||||
*/
|
||||
fun isFipsNode(): Boolean = npub.isNotBlank() || meshIp.isNotBlank()
|
||||
|
||||
/** Bracket bare IPv6 literals (the mesh ULA) so they form valid URLs. */
|
||||
private fun urlHost(host: String): String =
|
||||
if (host.contains(":") && !host.startsWith("[")) "[$host]" else host
|
||||
@@ -89,9 +102,9 @@ class ServerPreferences(private val context: Context) {
|
||||
private val introSeenKey = booleanPreferencesKey("intro_seen")
|
||||
private val gestureHintSeenKey = booleanPreferencesKey("gesture_hint_seen")
|
||||
|
||||
val activeServer: Flow<ServerEntry?> = context.dataStore.data.map { prefs ->
|
||||
val address = prefs[activeAddressKey] ?: return@map null
|
||||
ServerEntry(
|
||||
private fun activeServerFrom(prefs: Preferences): ServerEntry? {
|
||||
val address = prefs[activeAddressKey] ?: return null
|
||||
return ServerEntry(
|
||||
address = address,
|
||||
useHttps = prefs[activeHttpsKey] ?: false,
|
||||
port = prefs[activePortKey] ?: "",
|
||||
@@ -102,19 +115,52 @@ class ServerPreferences(private val context: Context) {
|
||||
)
|
||||
}
|
||||
|
||||
// distinctUntilChanged on every flow: DataStore emits on EVERY write to the
|
||||
// file regardless of key, and each spurious emission recomposed whatever
|
||||
// screen collected it (the kiosk recomposed on gesture-hint writes).
|
||||
|
||||
val activeServer: Flow<ServerEntry?> = context.dataStore.data
|
||||
.map { prefs -> activeServerFrom(prefs) }
|
||||
.distinctUntilChanged()
|
||||
|
||||
val savedServers: Flow<List<ServerEntry>> = context.dataStore.data.map { prefs ->
|
||||
val raw = prefs[savedServersKey] ?: emptySet()
|
||||
raw.mapNotNull { ServerEntry.deserialize(it) }
|
||||
}
|
||||
// Sorted so set-iteration order can't produce a structurally different
|
||||
// list for the same servers (which defeats distinctUntilChanged).
|
||||
raw.mapNotNull { ServerEntry.deserialize(it) }.sortedBy { it.displayName() }
|
||||
}.distinctUntilChanged()
|
||||
|
||||
val introSeen: Flow<Boolean> = context.dataStore.data.map { prefs ->
|
||||
prefs[introSeenKey] ?: false
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
|
||||
/** One-shot flag for the three-finger-hold teaching overlay. */
|
||||
val gestureHintSeen: Flow<Boolean> = context.dataStore.data.map { prefs ->
|
||||
prefs[gestureHintSeenKey] ?: false
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
|
||||
/** Everything the nav graph needs to pick a start destination, derived
|
||||
* from ONE DataStore emission. Collecting introSeen and activeServer as
|
||||
* two separate flows let them land in different frames — the intro flag
|
||||
* could resolve first and flash the Connect screen at a paired user
|
||||
* before the active server arrived. */
|
||||
data class LaunchState(
|
||||
val introSeen: Boolean,
|
||||
val activeServer: ServerEntry?,
|
||||
/** Every saved node — the launch gate needs the COUNT to decide
|
||||
* whether to ask which one to connect to. */
|
||||
val savedServers: List<ServerEntry>,
|
||||
)
|
||||
|
||||
val launchState: Flow<LaunchState> = context.dataStore.data.map { prefs ->
|
||||
LaunchState(
|
||||
introSeen = prefs[introSeenKey] ?: false,
|
||||
activeServer = activeServerFrom(prefs),
|
||||
savedServers = (prefs[savedServersKey] ?: emptySet())
|
||||
.mapNotNull { ServerEntry.deserialize(it) }
|
||||
.sortedBy { it.displayName() },
|
||||
)
|
||||
}.distinctUntilChanged()
|
||||
|
||||
suspend fun setActiveServer(server: ServerEntry) {
|
||||
context.dataStore.edit { prefs ->
|
||||
|
||||
@@ -37,6 +37,7 @@ class ArchyVpnService : VpnService() {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var warmerJob: Job? = null
|
||||
private var handoffKickJob: Job? = null
|
||||
|
||||
// Seamless transport handoff (Wi-Fi ⇄ 5G ⇄ future BLE). Without this the
|
||||
// tunnel's underlying network stays pinned to the interface that was
|
||||
@@ -204,14 +205,20 @@ 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:
|
||||
* phone roams (Wi-Fi ⇄ 5G). 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.
|
||||
* rebuild on the new path; 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.
|
||||
*
|
||||
* requestNetwork, NOT registerDefaultNetworkCallback: this app is routed
|
||||
* through its own TUN, so its "default network" IS the VPN — a default
|
||||
* callback fires once with our own tunnel and never again on Wi-Fi ⇄ 5G.
|
||||
* A NetworkRequest's default capabilities include NOT_VPN, so requestNetwork
|
||||
* tracks the best real transport underneath instead.
|
||||
*/
|
||||
private fun registerNetworkHandoff() {
|
||||
if (networkCallback != null) return
|
||||
@@ -236,10 +243,6 @@ class ArchyVpnService : VpnService() {
|
||||
}
|
||||
}
|
||||
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) }
|
||||
}
|
||||
|
||||
@@ -251,13 +254,22 @@ class ArchyVpnService : VpnService() {
|
||||
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()
|
||||
// Coalesced, not immediate: marginal Wi-Fi flaps the default
|
||||
// Wi-Fi ⇄ cell in bursts, and an aggressive warmer pass per flip
|
||||
// meant near-constant session churn — the "reconnects a lot"
|
||||
// report. The re-pin above still happens on every change; only
|
||||
// the rediscovery kick waits for the network to hold still.
|
||||
handoffKickJob?.cancel()
|
||||
handoffKickJob = scope.launch {
|
||||
delay(2_000)
|
||||
if (FipsNative.isRunning()) startSessionWarmer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun unregisterNetworkHandoff() {
|
||||
handoffKickJob?.cancel()
|
||||
handoffKickJob = null
|
||||
val cm = connectivityManager
|
||||
val cb = networkCallback
|
||||
if (cm != null && cb != null) {
|
||||
|
||||
@@ -3,8 +3,10 @@ package com.archipelago.app.fips
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.VpnService
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* Glue between pairing and the mesh: persists the node peer from a scanned
|
||||
@@ -36,20 +38,27 @@ object FipsManager {
|
||||
* No-op on devices without the native lib (non-arm64).
|
||||
*/
|
||||
suspend fun registerNode(context: Context, info: FipsPairInfo?, alias: String) {
|
||||
if (info == null || !FipsNative.available) return
|
||||
val prefs = FipsPreferences(context)
|
||||
ensureIdentity(prefs)
|
||||
prefs.upsertNodePeer(info, alias)
|
||||
peersDirty = true
|
||||
// Restart the mesh with the new peer RIGHT NOW when consent already
|
||||
// exists — relying on the consentNeeded collector left a running
|
||||
// mesh on the OLD peer list whenever the collector wasn't active
|
||||
// (fresh pairings looked dead until a full app restart).
|
||||
if (VpnService.prepare(context) == null) {
|
||||
startService(context)
|
||||
} else {
|
||||
_consentNeeded.value = true
|
||||
}
|
||||
if (info == null) return
|
||||
// Every caller reaches this from a Compose scope — i.e. the MAIN
|
||||
// thread — the instant a pairing QR decodes. Everything below is
|
||||
// main-hostile: touching FipsNative dlopens the 7 MB mesh core,
|
||||
// ensureIdentity runs native ed25519 keygen, and VpnService.prepare
|
||||
// is a binder round-trip. Left on the UI thread it froze the frame
|
||||
// right after the camera got the code, which reads as "the scanner
|
||||
// is slow" when the scan itself already succeeded.
|
||||
val consent = withContext(Dispatchers.IO) {
|
||||
if (!FipsNative.available) return@withContext null
|
||||
val prefs = FipsPreferences(context)
|
||||
ensureIdentity(prefs)
|
||||
prefs.upsertNodePeer(info, alias)
|
||||
peersDirty = true
|
||||
// Restart the mesh with the new peer RIGHT NOW when consent already
|
||||
// exists — relying on the consentNeeded collector left a running
|
||||
// mesh on the OLD peer list whenever the collector wasn't active
|
||||
// (fresh pairings looked dead until a full app restart).
|
||||
VpnService.prepare(context) == null
|
||||
} ?: return
|
||||
if (consent) startService(context) else _consentNeeded.value = true
|
||||
}
|
||||
|
||||
/** Generate-once mesh identity. Returns null only if the RNG/native fails. */
|
||||
@@ -67,11 +76,17 @@ object FipsManager {
|
||||
* through AppNavHost instead.
|
||||
*/
|
||||
suspend fun autoStartIfReady(context: Context) {
|
||||
if (!FipsNative.available) return
|
||||
val prefs = FipsPreferences(context)
|
||||
if (prefs.identity() == null || !prefs.hasPeers()) return
|
||||
if (VpnService.prepare(context) != null) return // consent missing — don't prompt here
|
||||
startService(context)
|
||||
// Self-dispatching for the same reason as registerNode: callers reach
|
||||
// this from Compose scopes, and dlopen + binder must not ride the UI
|
||||
// thread (the connect path calls it while the scanner is still up).
|
||||
val ready = withContext(Dispatchers.IO) {
|
||||
if (!FipsNative.available) return@withContext false
|
||||
val prefs = FipsPreferences(context)
|
||||
if (prefs.identity() == null || !prefs.hasPeers()) return@withContext false
|
||||
// consent missing — don't prompt here
|
||||
VpnService.prepare(context) == null
|
||||
}
|
||||
if (ready) startService(context)
|
||||
}
|
||||
|
||||
fun startService(context: Context) {
|
||||
|
||||
@@ -1,37 +1,46 @@
|
||||
package com.archipelago.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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.res.painterResource
|
||||
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.ui.screens.PixelArtLogo
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.SurfaceBlack
|
||||
import com.archipelago.app.ui.theme.TextMuted
|
||||
import com.archipelago.app.ui.theme.TextPrimary
|
||||
|
||||
/**
|
||||
* The branded "F*CK IPs" full-screen loader — shown whenever the app is
|
||||
* dialing the node over the mesh (relaunch race, post-scan first connect),
|
||||
* instead of an anonymous spinner. The point of the brand: what's loading
|
||||
* is a connection to a cryptographic identity, not an IP.
|
||||
* Full-screen loader shown while the app is dialing a node.
|
||||
*
|
||||
* Two faces, because they are two different promises:
|
||||
* - [mesh] `true` — a FIPS node: the branded "F*CK IPs" screen, because what
|
||||
* is loading really is a connection to a cryptographic identity, not an IP.
|
||||
* - [mesh] `false` — a plain node reached over the network like anything
|
||||
* else. No mesh branding at all: claiming the mesh is carrying a connection
|
||||
* it isn't is worse than an anonymous spinner.
|
||||
*/
|
||||
@Composable
|
||||
fun MeshLoadingScreen(message: String = "Dialing your node by its key — no IPs harmed") {
|
||||
fun MeshLoadingScreen(
|
||||
mesh: Boolean = true,
|
||||
nodeName: String = "",
|
||||
done: Boolean = false,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
@@ -39,39 +48,39 @@ fun MeshLoadingScreen(message: String = "Dialing your node by its key — no IPs
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
// The brand's circle-container logo (as on the connect screen /
|
||||
// web login): pixel-art "a" centered in a black disc.
|
||||
Box(
|
||||
Modifier
|
||||
.size(120.dp)
|
||||
.clip(androidx.compose.foundation.shape.CircleShape)
|
||||
.background(Color.Black)
|
||||
.border(
|
||||
1.dp,
|
||||
Color.White.copy(alpha = 0.14f),
|
||||
androidx.compose.foundation.shape.CircleShape,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
PixelArtLogo(Modifier.size(64.dp))
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
// The app's own badge — the same ringed mark as the launcher icon
|
||||
// and the system splash, so launch → splash → this screen is one
|
||||
// continuous identity.
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_logo),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(112.dp),
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Text(
|
||||
text = "F*CK IPs MESH",
|
||||
text = if (mesh) "F*CK IPS MESH" else "CONNECTING",
|
||||
color = BitcoinOrange,
|
||||
fontSize = 18.sp,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 4.sp,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
text = message,
|
||||
color = TextMuted,
|
||||
text = when {
|
||||
mesh -> "Dialing your node by its key — no IPs harmed"
|
||||
nodeName.isNotBlank() -> "Reaching $nodeName"
|
||||
else -> "Reaching your node"
|
||||
},
|
||||
color = if (done) TextPrimary else TextMuted,
|
||||
fontSize = 13.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(horizontal = 32.dp),
|
||||
)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
SlidingLoader(
|
||||
modifier = Modifier.width(220.dp),
|
||||
done = done,
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
CircularProgressIndicator(color = BitcoinOrange)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ 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.RestartAlt
|
||||
import androidx.compose.material.icons.filled.SportsEsports
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -70,6 +71,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.data.ServerEntry
|
||||
import com.archipelago.app.ui.screens.restartCompanionApp
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.SurfaceDark
|
||||
import com.archipelago.app.ui.theme.TextMuted
|
||||
@@ -221,7 +223,11 @@ private fun MenuPanel(
|
||||
HubCard(Icons.Default.Dns, "Nodes", activeServer?.displayName() ?: "Add or switch servers") {
|
||||
page = HubPage.NODES
|
||||
}
|
||||
if (FipsNative.available) {
|
||||
// Mesh oversight only when this session is actually on the
|
||||
// mesh. Offering "FIPS Mesh" while connected to a plain node
|
||||
// (whose traffic is going nowhere near the tunnel) advertises
|
||||
// a connection the user doesn't have.
|
||||
if (FipsNative.available && activeServer?.isFipsNode() == true) {
|
||||
HubCard(Icons.Default.Bolt, "FIPS Mesh", "Mesh identity & status") { page = HubPage.FIPS }
|
||||
}
|
||||
if (onMeshParty != null) {
|
||||
@@ -229,6 +235,36 @@ private fun MenuPanel(
|
||||
}
|
||||
// Dark/Classic style lives on the remote/keyboard screen next to
|
||||
// the settings button — not here.
|
||||
|
||||
// Small version chip at the hub's foot — the one place a
|
||||
// connected user can always check what build they're on.
|
||||
val hubContext = LocalContext.current
|
||||
|
||||
// Restart: the dashboard WebView is retained across
|
||||
// remote ⇄ dashboard (that's the point), which also means a
|
||||
// wedged page can't be cleared by leaving the screen. This
|
||||
// throws the page away and relaunches the app clean — the mesh
|
||||
// service keeps running.
|
||||
HubCard(Icons.Default.RestartAlt, "Restart", "Reload the app from scratch") {
|
||||
onDismiss()
|
||||
restartCompanionApp(hubContext)
|
||||
}
|
||||
val versionLabel = remember {
|
||||
runCatching {
|
||||
hubContext.packageManager
|
||||
.getPackageInfo(hubContext.packageName, 0).versionName
|
||||
}.getOrNull()?.let { "Companion v$it" } ?: ""
|
||||
}
|
||||
if (versionLabel.isNotEmpty()) {
|
||||
Text(
|
||||
versionLabel,
|
||||
color = TextMuted.copy(alpha = 0.6f),
|
||||
fontSize = 11.sp,
|
||||
letterSpacing = 1.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HubPage.NODES -> {
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
package com.archipelago.app.ui.components
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.hardware.camera2.CameraCharacteristics
|
||||
import android.hardware.camera2.CameraManager
|
||||
import android.hardware.camera2.CameraMetadata
|
||||
import android.hardware.camera2.CaptureRequest
|
||||
import android.os.Process
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.camera.camera2.interop.Camera2Interop
|
||||
import androidx.camera.camera2.interop.ExperimentalCamera2Interop
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.FocusMeteringAction
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.ImageProxy
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.core.SurfaceOrientedMeteringPointFactory
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.view.PreviewView
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
@@ -16,22 +26,26 @@ import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
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.aspectRatio
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
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.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.FlashOff
|
||||
import androidx.compose.material.icons.filled.FlashOn
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -46,10 +60,15 @@ import androidx.compose.runtime.rememberUpdatedState
|
||||
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.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
@@ -59,23 +78,26 @@ import com.archipelago.app.data.PairResult
|
||||
import com.archipelago.app.data.ServerQrParser
|
||||
import com.archipelago.app.ui.screens.GlassButton
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.TextMuted
|
||||
import com.archipelago.app.ui.theme.TextPrimary
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.BinaryBitmap
|
||||
import com.google.zxing.DecodeHintType
|
||||
import com.google.zxing.MultiFormatReader
|
||||
import com.google.zxing.NotFoundException
|
||||
import com.google.zxing.PlanarYUVLuminanceSource
|
||||
import com.google.zxing.common.GlobalHistogramBinarizer
|
||||
import com.google.zxing.common.HybridBinarizer
|
||||
import com.google.zxing.qrcode.QRCodeReader
|
||||
import kotlinx.coroutines.delay
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* Full-screen camera overlay that scans the node pairing QR
|
||||
* (docs/companion-pairing-qr.md) and reports the decoded server entry.
|
||||
* Handles the camera permission itself; foreign/invalid codes show a hint
|
||||
* and scanning continues.
|
||||
* Scans the node pairing QR (docs/companion-pairing-qr.md) and reports the
|
||||
* decoded server entry. Handles the camera permission itself; foreign/invalid
|
||||
* codes show a hint in the status strip and scanning continues.
|
||||
*
|
||||
* Visually this is the SAME glass modal the web wallet uses (neode-ui's
|
||||
* WalletScanModal) — scrim, glass card, square preview, orange viewfinder,
|
||||
* status strip — so pairing from the app and scanning from the web UI look
|
||||
* like one product rather than two different scanners.
|
||||
*/
|
||||
@Composable
|
||||
fun QrScannerOverlay(
|
||||
@@ -83,28 +105,14 @@ fun QrScannerOverlay(
|
||||
onDismiss: () -> Unit,
|
||||
onServerScanned: (PairResult.Success) -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var hasPermission by remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
val haptics = LocalHapticFeedback.current
|
||||
var hintRes by remember { mutableStateOf<Int?>(null) }
|
||||
var handled by remember { mutableStateOf(false) }
|
||||
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted -> hasPermission = granted }
|
||||
|
||||
LaunchedEffect(visible) {
|
||||
if (visible) {
|
||||
handled = false
|
||||
hintRes = null
|
||||
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
hasPermission = granted
|
||||
if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,125 +124,331 @@ fun QrScannerOverlay(
|
||||
}
|
||||
}
|
||||
|
||||
QrGlassModal(
|
||||
visible = visible,
|
||||
title = stringResource(R.string.scan_node_qr),
|
||||
status = hintRes?.let { stringResource(it) to true },
|
||||
idleHint = stringResource(R.string.scan_qr_hint),
|
||||
permissionRationale = stringResource(R.string.camera_permission_needed),
|
||||
onDismiss = onDismiss,
|
||||
onDecoded = { text ->
|
||||
if (!handled) {
|
||||
when (val result = ServerQrParser.parse(text)) {
|
||||
is PairResult.Success -> {
|
||||
handled = true
|
||||
// Confirm the hit in the hand — the eye is still on the
|
||||
// code, not on the screen.
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onServerScanned(result)
|
||||
}
|
||||
is PairResult.UnsupportedVersion -> hintRes = R.string.update_app_for_qr
|
||||
is PairResult.Invalid -> hintRes = R.string.invalid_pairing_qr
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared native scanner shell — one visual contract for every camera the
|
||||
* app opens (pairing, wallet), mirroring neode-ui's WalletScanModal so the
|
||||
* native and web scanners are indistinguishable:
|
||||
* - black/60 scrim, dismiss on tap-outside
|
||||
* - glass card (rounded 24, white/10 hairline) capped at 420dp
|
||||
* - square preview with the 62% orange viewfinder and a darkened surround
|
||||
* - a status strip that carries hints and errors
|
||||
* - an optional footer (the wallet's "Upload image")
|
||||
*/
|
||||
@Composable
|
||||
internal fun QrGlassModal(
|
||||
visible: Boolean,
|
||||
title: String,
|
||||
// message + isError; null falls back to [idleHint].
|
||||
status: Pair<String, Boolean>?,
|
||||
idleHint: String,
|
||||
permissionRationale: String,
|
||||
onDismiss: () -> Unit,
|
||||
onDecoded: (String) -> Unit,
|
||||
footer: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var hasPermission by remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
var torchOn by remember { mutableStateOf(false) }
|
||||
var hasTorch by remember { mutableStateOf(false) }
|
||||
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted -> hasPermission = granted }
|
||||
|
||||
LaunchedEffect(visible) {
|
||||
if (visible) {
|
||||
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
hasPermission = granted
|
||||
if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
} else {
|
||||
torchOn = false
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
|
||||
BackHandler { onDismiss() }
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black),
|
||||
.background(Color.Black.copy(alpha = 0.6f))
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onDismiss,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (hasPermission) {
|
||||
CameraQrPreview(
|
||||
onDecoded = { text ->
|
||||
if (!handled) {
|
||||
when (val result = ServerQrParser.parse(text)) {
|
||||
is PairResult.Success -> {
|
||||
handled = true
|
||||
onServerScanned(result)
|
||||
}
|
||||
is PairResult.UnsupportedVersion -> hintRes = R.string.update_app_for_qr
|
||||
is PairResult.Invalid -> hintRes = R.string.invalid_pairing_qr
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
// Aim frame
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.size(260.dp)
|
||||
.border(2.dp, BitcoinOrange.copy(alpha = 0.85f), RoundedCornerShape(20.dp)),
|
||||
)
|
||||
} else {
|
||||
Column(
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.padding(horizontal = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.camera_permission_needed),
|
||||
color = TextPrimary,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
GlassButton(
|
||||
text = stringResource(R.string.grant_camera_access),
|
||||
onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) },
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Top bar: title + close
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.scan_node_qr),
|
||||
color = TextPrimary,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
)
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Default.Close, stringResource(R.string.close), tint = TextPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom hints
|
||||
Column(
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.padding(horizontal = 32.dp, vertical = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
.padding(16.dp)
|
||||
.widthIn(max = 420.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(Color(0xF212151C))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(24.dp))
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = {}, // swallow — only the scrim dismisses
|
||||
)
|
||||
.padding(24.dp),
|
||||
) {
|
||||
hintRes?.let { res ->
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(res),
|
||||
color = BitcoinOrange,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
stringResource(R.string.close),
|
||||
tint = Color.White.copy(alpha = 0.7f),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (hasPermission) {
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color.Black.copy(alpha = 0.4f))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(12.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (hasPermission) {
|
||||
CameraQrPreview(
|
||||
onDecoded = onDecoded,
|
||||
torchOn = torchOn,
|
||||
onTorchAvailable = { hasTorch = it },
|
||||
)
|
||||
// Viewfinder — 62% of the preview, matching the web
|
||||
// modal's .scan-viewfinder, and matching the ROI the
|
||||
// decoder actually reads (QR_ROI_FRACTION).
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize(QR_ROI_FRACTION)
|
||||
.border(
|
||||
2.dp,
|
||||
BitcoinOrange.copy(alpha = 0.85f),
|
||||
RoundedCornerShape(16.dp),
|
||||
),
|
||||
)
|
||||
if (hasTorch) {
|
||||
IconButton(
|
||||
onClick = { torchOn = !torchOn },
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(6.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(Color.Black.copy(alpha = 0.45f)),
|
||||
) {
|
||||
Icon(
|
||||
if (torchOn) Icons.Default.FlashOn else Icons.Default.FlashOff,
|
||||
stringResource(
|
||||
if (torchOn) R.string.torch_off else R.string.torch_on,
|
||||
),
|
||||
tint = if (torchOn) BitcoinOrange else Color.White.copy(alpha = 0.85f),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
Modifier.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = permissionRationale,
|
||||
color = Color.White.copy(alpha = 0.7f),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
GlassButton(
|
||||
text = stringResource(R.string.grant_camera_access),
|
||||
onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) },
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color.White.copy(alpha = 0.05f))
|
||||
.padding(12.dp)
|
||||
.defaultMinSize(minHeight = 24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.scan_qr_hint),
|
||||
color = TextMuted,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
text = status?.first?.takeIf { it.isNotBlank() } ?: idleHint,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (status?.second == true) {
|
||||
Color(0xFFF87171)
|
||||
} else {
|
||||
Color.White.copy(alpha = 0.6f)
|
||||
},
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
if (footer != null) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
footer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared by the pairing scanner and the wallet scan modal. */
|
||||
/**
|
||||
* Warm the CameraX provider and the ZXing decode path before the user ever
|
||||
* asks for a scan, so opening the scanner doesn't pay provider init + class
|
||||
* loading on the critical path. Does NOT open the camera: no permission is
|
||||
* needed, no LED lights up, nothing is recorded — [ProcessCameraProvider]
|
||||
* init is process-wide and cached, and the synthetic decode below just walks
|
||||
* a blank 32x32 frame to class-load the binarizer/detector.
|
||||
*
|
||||
* Called once per process from the kiosk WebView (first page load) and by the
|
||||
* page via `ArchipelagoQr.prewarm()`.
|
||||
*/
|
||||
internal fun prewarmQrScanner(context: Context) {
|
||||
if (!qrPrewarmed.compareAndSet(false, true)) return
|
||||
val app = context.applicationContext
|
||||
runCatching { ProcessCameraProvider.getInstance(app) }
|
||||
// Off the UI thread: the first decode attempt loads a dozen ZXing classes.
|
||||
Executors.newSingleThreadExecutor().let { exec ->
|
||||
exec.execute {
|
||||
runCatching {
|
||||
val blank = ByteArray(32 * 32)
|
||||
val reader = MultiFormatReader().apply {
|
||||
setHints(mapOf(DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE)))
|
||||
}
|
||||
val source = PlanarYUVLuminanceSource(blank, 32, 32, 0, 0, 32, 32, false)
|
||||
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source)))
|
||||
}
|
||||
}
|
||||
exec.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
private val qrPrewarmed = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||
|
||||
/**
|
||||
* Fraction of the preview's shorter edge that both the on-screen viewfinder
|
||||
* and the decoder's region of interest use. Keeping them identical is the
|
||||
* point: the user aims at the box, and the box is exactly what gets decoded.
|
||||
*/
|
||||
internal const val QR_ROI_FRACTION = 0.62f
|
||||
|
||||
/**
|
||||
* Shared by the pairing scanner and the wallet scan modal.
|
||||
*
|
||||
* [torchOn] drives the flash; [onTorchAvailable] reports whether this camera
|
||||
* has one at all (the caller only draws its toggle when it does).
|
||||
*
|
||||
* ## Why this looks the way it does
|
||||
*
|
||||
* The previous version hunted: a scheduled tick alternated the optical zoom
|
||||
* between 1x and 1.5x and re-fired `startFocusAndMetering(...disableAutoCancel())`
|
||||
* every 2 seconds. Both are camera-hostile:
|
||||
*
|
||||
* - Every zoom step restarts AE/AF convergence, so the sensor spends the
|
||||
* seconds right after it delivering soft frames — precisely the frames the
|
||||
* decoder needs to be sharp. The visible symptom is the "zooms in and out
|
||||
* and takes ages" report.
|
||||
* - `disableAutoCancel()` leaves AF **locked** at whatever it converged on
|
||||
* instead of handing the lens back to continuous AF, so a re-aim never
|
||||
* refocused on its own; the next timer tick then kicked off another full
|
||||
* sweep from a locked position — a lens that hunts forever.
|
||||
*
|
||||
* A stock camera app does neither. It leaves CameraX's continuous AF alone,
|
||||
* refocuses on tap, and never touches zoom. This does the same, with one
|
||||
* concession to the "hand-held QR is a static scene" case: if nothing has
|
||||
* decoded for a few seconds, ONE auto-cancelling focus nudge is issued (and
|
||||
* then not again for a while), which re-arms continuous AF instead of
|
||||
* fighting it.
|
||||
*/
|
||||
@Composable
|
||||
internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
||||
internal fun CameraQrPreview(
|
||||
onDecoded: (String) -> Unit,
|
||||
torchOn: Boolean = false,
|
||||
onTorchAvailable: (Boolean) -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val currentOnDecoded by rememberUpdatedState(onDecoded)
|
||||
val currentOnTorchAvailable by rememberUpdatedState(onTorchAvailable)
|
||||
var camera by remember { mutableStateOf<androidx.camera.core.Camera?>(null) }
|
||||
val previewView = remember {
|
||||
PreviewView(context).apply {
|
||||
scaleType = PreviewView.ScaleType.FILL_CENTER
|
||||
// TextureView, not the SurfaceView default: SurfaceView punches a
|
||||
// hole in the window, which black-flashes inside Compose fades and
|
||||
// ignores rounded-corner clipping (wallet modal).
|
||||
// ignores rounded-corner clipping (the glass modal).
|
||||
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
|
||||
}
|
||||
}
|
||||
// Set by the analyzer on every decode; the focus nudge below reads it to
|
||||
// tell "nothing in view" from "reading fine, leave the camera alone".
|
||||
val lastDecodeAt = remember { java.util.concurrent.atomic.AtomicLong(0L) }
|
||||
// A tap-to-focus wins over the periodic centre AF for a few seconds.
|
||||
val lastTapFocusAt = remember { java.util.concurrent.atomic.AtomicLong(0L) }
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
val analysisExecutor = Executors.newSingleThreadExecutor()
|
||||
// Analysis runs at display priority: the decode thread competes with
|
||||
// the FIPS mesh service's native workers in this same process, and a
|
||||
// background-priority analyzer is exactly how a sharp, well-framed
|
||||
// code still takes seconds to land.
|
||||
val analysisExecutor = Executors.newSingleThreadExecutor { r ->
|
||||
Thread {
|
||||
Process.setThreadPriority(Process.THREAD_PRIORITY_DISPLAY)
|
||||
r.run()
|
||||
}.apply { name = "qr-analyzer" }
|
||||
}
|
||||
val mainExecutor = ContextCompat.getMainExecutor(context)
|
||||
val providerFuture = ProcessCameraProvider.getInstance(context)
|
||||
var provider: ProcessCameraProvider? = null
|
||||
@@ -243,15 +457,18 @@ internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
||||
providerFuture.addListener({
|
||||
val p = providerFuture.get()
|
||||
provider = p
|
||||
val preview = Preview.Builder().build().also {
|
||||
val previewBuilder = Preview.Builder()
|
||||
tuneForBarcodes(previewBuilder, context)
|
||||
val preview = previewBuilder.build().also {
|
||||
it.setSurfaceProvider(previewView.surfaceProvider)
|
||||
}
|
||||
// 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.
|
||||
// sharp focus. 1280x720 left dense invoices undecodable while sparse
|
||||
// address QRs still read — the "scanner doesn't pick up invoices"
|
||||
// report. 1920x1080 roughly doubles module resolution. The analyzer
|
||||
// never binarizes the full 2 MP: it reads the centre ROI at this
|
||||
// resolution (for dense codes) and the whole frame at half of it
|
||||
// (for coverage), so the big frame costs little.
|
||||
@Suppress("DEPRECATION")
|
||||
val analysis = ImageAnalysis.Builder()
|
||||
.setTargetResolution(android.util.Size(1920, 1080))
|
||||
@@ -260,25 +477,47 @@ internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
||||
.also {
|
||||
it.setAnalyzer(
|
||||
analysisExecutor,
|
||||
QrCodeAnalyzer { text -> mainExecutor.execute { currentOnDecoded(text) } },
|
||||
QrCodeAnalyzer { text ->
|
||||
lastDecodeAt.set(System.currentTimeMillis())
|
||||
mainExecutor.execute { currentOnDecoded(text) }
|
||||
},
|
||||
)
|
||||
}
|
||||
try {
|
||||
p.unbindAll()
|
||||
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()
|
||||
camera = cam
|
||||
currentOnTorchAvailable(cam.cameraInfo.hasFlashUnit())
|
||||
// Start the clock at bind time so the nudge below waits for the
|
||||
// user to actually aim before it does anything.
|
||||
lastDecodeAt.set(System.currentTimeMillis())
|
||||
// Centre point, normalized — valid before the view is measured.
|
||||
val point = SurfaceOrientedMeteringPointFactory(1f, 1f).createPoint(0.5f, 0.5f)
|
||||
// A one-shot AF action puts the lens in AUTO — i.e. LOCKED —
|
||||
// until it auto-cancels. The default 5s lock is far too long
|
||||
// here: it spans exactly the window where the user is swinging
|
||||
// the phone towards the code, and a locked lens cannot follow
|
||||
// them. Hand control back after 1s so CONTINUOUS_PICTURE (set
|
||||
// explicitly in tuneForBarcodes) does the real work, which is
|
||||
// what actually tracks a moving aim.
|
||||
val focusAction = FocusMeteringAction.Builder(point, FocusMeteringAction.FLAG_AF)
|
||||
.setAutoCancelDuration(1, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
var lastNudgeAt = 0L
|
||||
focusScheduler.scheduleWithFixedDelay({
|
||||
runCatching { cam.cameraControl.startFocusAndMetering(focusAction) }
|
||||
}, 0, 2, java.util.concurrent.TimeUnit.SECONDS)
|
||||
val now = System.currentTimeMillis()
|
||||
// The nudge only exists for the one case continuous AF
|
||||
// genuinely misses: the phone held perfectly still on a
|
||||
// code while the lens sits at its resting focus, with no
|
||||
// scene change to trigger a sweep.
|
||||
if (now - lastDecodeAt.get() > 2_000 &&
|
||||
now - lastNudgeAt > 3_000 &&
|
||||
now - lastTapFocusAt.get() > 3_000
|
||||
) {
|
||||
lastNudgeAt = now
|
||||
runCatching { cam.cameraControl.startFocusAndMetering(focusAction) }
|
||||
}
|
||||
}, 1, 1, java.util.concurrent.TimeUnit.SECONDS)
|
||||
} catch (_: Exception) {
|
||||
// Camera unavailable — the user can dismiss and enter details manually.
|
||||
}
|
||||
@@ -286,66 +525,251 @@ internal fun CameraQrPreview(onDecoded: (String) -> Unit) {
|
||||
|
||||
onDispose {
|
||||
focusScheduler.shutdownNow()
|
||||
runCatching { camera?.cameraControl?.enableTorch(false) }
|
||||
camera = null
|
||||
provider?.unbindAll()
|
||||
analysisExecutor.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
|
||||
/** ZXing-based QR decoder over the camera's Y (luminance) plane. */
|
||||
private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAnalysis.Analyzer {
|
||||
private val reader = MultiFormatReader().apply {
|
||||
setHints(
|
||||
mapOf(
|
||||
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
|
||||
// Screen-displayed QRs come with moiré, glare, and soft focus at
|
||||
// close range — the exhaustive search is worth the milliseconds.
|
||||
DecodeHintType.TRY_HARDER to true,
|
||||
)
|
||||
)
|
||||
// Torch follows the caller's state (and switches off when the view goes).
|
||||
LaunchedEffect(camera, torchOn) {
|
||||
runCatching { camera?.cameraControl?.enableTorch(torchOn) }
|
||||
}
|
||||
|
||||
private var lastAttempt = 0L
|
||||
AndroidView(
|
||||
factory = { previewView },
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
// Tap-to-focus: the ROI assumes the code is centred; a tap lets the
|
||||
// user point at one that isn't, or re-trigger AF the instant
|
||||
// they've framed it.
|
||||
.pointerInput(camera) {
|
||||
detectTapGestures { offset ->
|
||||
val cam = camera ?: return@detectTapGestures
|
||||
val factory = previewView.meteringPointFactory
|
||||
val action = FocusMeteringAction.Builder(
|
||||
factory.createPoint(offset.x, offset.y),
|
||||
FocusMeteringAction.FLAG_AF or FocusMeteringAction.FLAG_AE,
|
||||
).build()
|
||||
lastTapFocusAt.set(System.currentTimeMillis())
|
||||
runCatching { cam.cameraControl.startFocusAndMetering(action) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the capture session the way a dedicated barcode scanner does,
|
||||
* rather than the way a photo app does.
|
||||
*
|
||||
* The single most valuable knob is **CONTROL_AE_TARGET_FPS_RANGE**. Left
|
||||
* alone, auto-exposure indoors happily drops the sensor to 10–15 fps and
|
||||
* takes 60–100 ms exposures — every hand-held frame is then motion-blurred,
|
||||
* and a blurred QR is not a slow decode, it is *no* decode. The user waves
|
||||
* the phone about waiting for a lock that cannot happen. Pinning the lower
|
||||
* bound of the AE range as high as the device allows caps exposure time
|
||||
* (~33 ms at 30 fps), so frames come out sharp; AE compensates with gain
|
||||
* instead, and ZXing tolerates noise far better than it tolerates blur.
|
||||
* (Dark rooms get grainier as a result — that is what the torch button is
|
||||
* for, and grainy-but-sharp still decodes where smooth-but-smeared never
|
||||
* does.)
|
||||
*
|
||||
* CONTINUOUS_PICTURE is set explicitly so that when a tap-to-focus action
|
||||
* expires, CameraX restores continuous AF rather than whatever the device
|
||||
* defaults to; FAST noise/edge processing shaves ISP latency per frame.
|
||||
*
|
||||
* All of it is best-effort — an OEM that rejects a key just keeps its default.
|
||||
*/
|
||||
@androidx.annotation.OptIn(ExperimentalCamera2Interop::class)
|
||||
private fun tuneForBarcodes(builder: Preview.Builder, context: Context) {
|
||||
runCatching {
|
||||
val ext = Camera2Interop.Extender(builder)
|
||||
ext.setCaptureRequestOption(
|
||||
CaptureRequest.CONTROL_AF_MODE,
|
||||
CameraMetadata.CONTROL_AF_MODE_CONTINUOUS_PICTURE,
|
||||
)
|
||||
ext.setCaptureRequestOption(
|
||||
CaptureRequest.NOISE_REDUCTION_MODE,
|
||||
CameraMetadata.NOISE_REDUCTION_MODE_FAST,
|
||||
)
|
||||
ext.setCaptureRequestOption(
|
||||
CaptureRequest.EDGE_MODE,
|
||||
CameraMetadata.EDGE_MODE_FAST,
|
||||
)
|
||||
highestSteadyFpsRange(context)?.let {
|
||||
ext.setCaptureRequestOption(CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The back camera's AE range with the highest floor, ignoring anything that
|
||||
* runs past 30 fps (those are the high-speed/slow-motion modes, which cost
|
||||
* light for frames we do not need).
|
||||
*/
|
||||
private fun highestSteadyFpsRange(context: Context): android.util.Range<Int>? = runCatching {
|
||||
val manager = context.getSystemService(CameraManager::class.java) ?: return@runCatching null
|
||||
val backId = manager.cameraIdList.firstOrNull { id ->
|
||||
manager.getCameraCharacteristics(id)
|
||||
.get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_BACK
|
||||
} ?: return@runCatching null
|
||||
manager.getCameraCharacteristics(backId)
|
||||
.get(CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES)
|
||||
?.filter { it.upper <= 30 }
|
||||
?.maxWithOrNull(compareBy({ it.lower }, { it.upper }))
|
||||
}.getOrNull()
|
||||
|
||||
/**
|
||||
* ZXing decoder over the camera's Y (luminance) plane.
|
||||
*
|
||||
* ## The rule this class exists to obey
|
||||
*
|
||||
* **Every frame costs the same, and every frame sees the whole scene.**
|
||||
*
|
||||
* That sounds obvious; the previous version violated both halves and produced
|
||||
* a scanner with a very specific failure: it locked on instantly if the code
|
||||
* was already in view when the camera opened, but crawled if you opened it
|
||||
* and then moved to the code. The cause was an escalation ladder — each frame
|
||||
* that failed to decode unlocked progressively more expensive searches, up to
|
||||
* a TRY_HARDER pass over the full 2 MP frame plus an inverted retry, easily
|
||||
* 150–300 ms of work.
|
||||
*
|
||||
* So the moment the user began hunting for the code, the analyzer dropped from
|
||||
* ~30 attempts per second to ~4, each one on a motion-blurred frame. By the
|
||||
* time they framed the code and held still, the pipeline was busy grinding
|
||||
* through an exhaustive search of an old, blurry frame. Escalating on failure
|
||||
* is exactly backwards: failure means the user is still aiming, which is when
|
||||
* the scanner must be at its *fastest*, not its most thorough.
|
||||
*
|
||||
* ## What runs now, on every single frame
|
||||
*
|
||||
* 1. **Centre ROI at full resolution** ([QR_ROI_FRACTION], ~0.45 MP). Full
|
||||
* sensor detail, so dense Lightning invoices keep their pixels-per-module.
|
||||
* 2. **The whole frame at half resolution** (~0.5 MP). This is what fixes the
|
||||
* "move to the code" case: coverage is no longer limited to the viewfinder
|
||||
* box on the fast path, so a code that is merely *near* the middle decodes
|
||||
* immediately instead of waiting for a slow tier to come around. A code
|
||||
* big enough to be off-centre is big enough to survive the 2x downscale.
|
||||
* 3. **One alternating second binarizer** — GlobalHistogram over the ROI on
|
||||
* even frames, over the half-frame on odd ones. Hybrid is tuned for
|
||||
* shadowed paper; most codes this app scans are on a *screen* (the node's
|
||||
* pairing popup, another phone's wallet) where a global threshold is both
|
||||
* cheaper and more reliable. Alternating keeps the per-frame budget flat.
|
||||
*
|
||||
* Two rare extras, both bounded so they can never dent the loop above: an
|
||||
* inverted ROI pass every 8th frame (light-on-dark codes), and one TRY_HARDER
|
||||
* pass over the half-frame at most once a second (skewed/damaged codes).
|
||||
*
|
||||
* Steady-state that is ~35 ms per frame — around 27 attempts per second, and
|
||||
* it does not degrade the longer the user hunts.
|
||||
*
|
||||
* Buffers are allocated once and reused: the original path allocated a fresh
|
||||
* ~2 MB array per frame, 60 MB/s of garbage at 30 fps, with GC pauses landing
|
||||
* mid-decode.
|
||||
*/
|
||||
private class QrCodeAnalyzer(private val onDecoded: (String) -> Unit) : ImageAnalysis.Analyzer {
|
||||
// QRCodeReader directly rather than MultiFormatReader: with a single
|
||||
// format in play the dispatch and per-call state reset are pure overhead.
|
||||
private val reader = QRCodeReader()
|
||||
private val plainHints = mapOf<DecodeHintType, Any>(
|
||||
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
|
||||
)
|
||||
private val hardHints = mapOf<DecodeHintType, Any>(
|
||||
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
|
||||
DecodeHintType.TRY_HARDER to true,
|
||||
)
|
||||
|
||||
private var roiBuffer = ByteArray(0)
|
||||
private var halfBuffer = ByteArray(0)
|
||||
private var frame = 0L
|
||||
private var lastHardAt = 0L
|
||||
|
||||
private fun read(
|
||||
source: PlanarYUVLuminanceSource,
|
||||
global: Boolean = false,
|
||||
hard: Boolean = false,
|
||||
inverted: Boolean = false,
|
||||
): String? {
|
||||
val src = if (inverted) source.invert() else source
|
||||
val bitmap = BinaryBitmap(
|
||||
if (global) GlobalHistogramBinarizer(src) else HybridBinarizer(src),
|
||||
)
|
||||
return runCatching {
|
||||
reader.decode(bitmap, if (hard) hardHints else plainHints).text
|
||||
}.getOrNull().also { reader.reset() }
|
||||
}
|
||||
|
||||
override fun analyze(image: ImageProxy) {
|
||||
// Decode ~7x/s, not on every frame: TRY_HARDER (plus the inverted
|
||||
// retry) pegs a core when run at camera rate, and that CPU contention
|
||||
// is what made the preview itself stutter. KEEP_ONLY_LATEST means the
|
||||
// frames skipped here are simply dropped, so decodes stay current.
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastAttempt < 140) {
|
||||
image.close()
|
||||
return
|
||||
}
|
||||
lastAttempt = now
|
||||
try {
|
||||
val plane = image.planes[0]
|
||||
val buffer = plane.buffer
|
||||
// Copy into a rowStride-wide array; the last row of the plane buffer
|
||||
// may be short of the full stride, so the tail stays zero-padded.
|
||||
val data = ByteArray(plane.rowStride * image.height)
|
||||
buffer.get(data, 0, minOf(buffer.remaining(), data.size))
|
||||
val source = PlanarYUVLuminanceSource(
|
||||
data, plane.rowStride, image.height,
|
||||
0, 0, image.width, image.height,
|
||||
false,
|
||||
)
|
||||
val result = try {
|
||||
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source)))
|
||||
} catch (_: NotFoundException) {
|
||||
// Dark-themed pages can render light-on-dark QRs — retry inverted.
|
||||
reader.reset()
|
||||
reader.decodeWithState(BinaryBitmap(HybridBinarizer(source.invert())))
|
||||
val stride = plane.rowStride
|
||||
// YUV_420_888 permits an interleaved Y plane. Rare, but a device
|
||||
// that does it would otherwise hand the decoder pure noise.
|
||||
val pixelStride = plane.pixelStride
|
||||
val width = image.width
|
||||
val height = image.height
|
||||
frame++
|
||||
|
||||
buffer.rewind()
|
||||
val available = buffer.remaining()
|
||||
|
||||
// ── 1. Centre ROI, full resolution ──────────────────────────────
|
||||
val side = (minOf(width, height) * QR_ROI_FRACTION).toInt().coerceAtLeast(1)
|
||||
val left = (width - side) / 2
|
||||
val top = (height - side) / 2
|
||||
if (roiBuffer.size != side * side) roiBuffer = ByteArray(side * side)
|
||||
for (row in 0 until side) {
|
||||
val srcPos = (top + row) * stride + left * pixelStride
|
||||
if (srcPos + side * pixelStride > available) break
|
||||
if (pixelStride == 1) {
|
||||
buffer.position(srcPos)
|
||||
buffer.get(roiBuffer, row * side, side)
|
||||
} else {
|
||||
val dst = row * side
|
||||
for (col in 0 until side) {
|
||||
roiBuffer[dst + col] = buffer.get(srcPos + col * pixelStride)
|
||||
}
|
||||
}
|
||||
}
|
||||
val roi = PlanarYUVLuminanceSource(roiBuffer, side, side, 0, 0, side, side, false)
|
||||
read(roi)?.let { onDecoded(it); return }
|
||||
|
||||
// ── 2. Whole frame, half resolution ─────────────────────────────
|
||||
val hw = width / 2
|
||||
val hh = height / 2
|
||||
if (halfBuffer.size != hw * hh) halfBuffer = ByteArray(hw * hh)
|
||||
var truncated = false
|
||||
for (row in 0 until hh) {
|
||||
val srcRow = row * 2 * stride
|
||||
val dst = row * hw
|
||||
for (col in 0 until hw) {
|
||||
val srcPos = srcRow + col * 2 * pixelStride
|
||||
if (srcPos >= available) { truncated = true; break }
|
||||
halfBuffer[dst + col] = buffer.get(srcPos)
|
||||
}
|
||||
if (truncated) break
|
||||
}
|
||||
val half = PlanarYUVLuminanceSource(halfBuffer, hw, hh, 0, 0, hw, hh, false)
|
||||
read(half)?.let { onDecoded(it); return }
|
||||
|
||||
// ── 3. Alternating second binarizer ─────────────────────────────
|
||||
val second = if (frame % 2 == 0L) roi else half
|
||||
read(second, global = true)?.let { onDecoded(it); return }
|
||||
|
||||
// ── Bounded extras ──────────────────────────────────────────────
|
||||
if (frame % 8 == 0L) {
|
||||
read(roi, inverted = true)?.let { onDecoded(it); return }
|
||||
}
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastHardAt >= 1_000) {
|
||||
lastHardAt = now
|
||||
read(half, hard = true)?.let { onDecoded(it); return }
|
||||
}
|
||||
onDecoded(result.text)
|
||||
} catch (_: NotFoundException) {
|
||||
// No QR in this frame — keep scanning.
|
||||
} catch (_: Exception) {
|
||||
// Malformed frame; skip it.
|
||||
} finally {
|
||||
reader.reset()
|
||||
image.close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.archipelago.app.ui.components
|
||||
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.keyframes
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
|
||||
/** green-400 — the same "done" colour the web install overlay lands on. */
|
||||
private val DoneGreen = Color(0xFF4ADE80)
|
||||
|
||||
/**
|
||||
* The Archipelago loading bar: a stripe that runs side to side inside a dim
|
||||
* track and lands as a solid green bar when the work completes.
|
||||
*
|
||||
* This is a direct port of the platform's install-progress overlay
|
||||
* (neode-ui SystemUpdate.vue `.install-overlay-bar-anim`): a third-width
|
||||
* orange stripe on a white/10 track, 1.8s ease-in-out, going full green on
|
||||
* success. Using the same loader natively is what makes the companion feel
|
||||
* like the same product as the node UI rather than a stock Android app.
|
||||
*
|
||||
* @param done finished successfully — the bar fills solid green.
|
||||
* @param stalled waiting on the user / something external — the bar parks
|
||||
* half-full in a dimmed orange instead of animating, so it
|
||||
* reads as "this needs you", not "still working".
|
||||
*/
|
||||
@Composable
|
||||
fun SlidingLoader(
|
||||
modifier: Modifier = Modifier,
|
||||
done: Boolean = false,
|
||||
stalled: Boolean = false,
|
||||
height: Dp = 8.dp,
|
||||
) {
|
||||
val doneProgress by animateFloatAsState(
|
||||
targetValue = if (done) 1f else 0f,
|
||||
animationSpec = tween(320),
|
||||
label = "loaderDone",
|
||||
)
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.height(height)
|
||||
.clip(RoundedCornerShape(percent = 50))
|
||||
.background(Color.White.copy(alpha = 0.10f)),
|
||||
) {
|
||||
val trackWidth = maxWidth
|
||||
val stripeWidth = trackWidth / 3
|
||||
val stripePx = with(LocalDensity.current) { stripeWidth.toPx() }
|
||||
|
||||
if (doneProgress < 1f) {
|
||||
if (stalled) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth(0.5f)
|
||||
.fillMaxHeight()
|
||||
.clip(RoundedCornerShape(percent = 50))
|
||||
.background(BitcoinOrange.copy(alpha = 0.6f)),
|
||||
)
|
||||
} else {
|
||||
// Keyframes copied from the web overlay: -100% → 120% → 300%
|
||||
// of the STRIPE's own width, which is what gives the bar its
|
||||
// fast sweep out and lazy re-entry.
|
||||
val transition = rememberInfiniteTransition(label = "loaderSlide")
|
||||
val offset by transition.animateFloat(
|
||||
initialValue = -1f,
|
||||
targetValue = 3f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = keyframes {
|
||||
durationMillis = 1800
|
||||
(-1f) at 0
|
||||
1.2f at 900
|
||||
3f at 1800
|
||||
},
|
||||
repeatMode = RepeatMode.Restart,
|
||||
),
|
||||
label = "loaderOffset",
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth(1f / 3f)
|
||||
.fillMaxHeight()
|
||||
.graphicsLayer { translationX = offset * stripePx }
|
||||
.clip(RoundedCornerShape(percent = 50))
|
||||
.background(BitcoinOrange),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (doneProgress > 0f) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
.graphicsLayer { alpha = doneProgress }
|
||||
.background(DoneGreen),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
-192
@@ -1,58 +1,26 @@
|
||||
package com.archipelago.app.ui.components
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
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.aspectRatio
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
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.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.archipelago.app.R
|
||||
import com.archipelago.app.ui.screens.GlassButton
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.BinaryBitmap
|
||||
import com.google.zxing.DecodeHintType
|
||||
@@ -62,10 +30,10 @@ import com.google.zxing.RGBLuminanceSource
|
||||
import com.google.zxing.common.HybridBinarizer
|
||||
|
||||
/**
|
||||
* Native replacement for the web wallet's scan pane — same visual design as
|
||||
* neode-ui's WalletScanModal (dark glass card, square preview, orange
|
||||
* viewfinder, status strip) but the camera and decoding run natively, so the
|
||||
* preview doesn't lag the way getUserMedia does inside a WebView.
|
||||
* Native replacement for the web wallet's scan pane — the shared [QrGlassModal]
|
||||
* shell (same visual design as neode-ui's WalletScanModal) with the camera and
|
||||
* decoding running natively, so the preview doesn't lag the way getUserMedia
|
||||
* does inside a WebView.
|
||||
*
|
||||
* Decoded text is handed back to the page ([onDecoded]) which does all the
|
||||
* detection/spend logic; the page in turn streams status lines (animated-QR
|
||||
@@ -80,15 +48,7 @@ fun WalletQrScannerModal(
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var hasPermission by remember {
|
||||
mutableStateOf(
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted -> hasPermission = granted }
|
||||
val haptics = LocalHapticFeedback.current
|
||||
|
||||
// Local error from a failed image upload; a fresh web status replaces it.
|
||||
var uploadError by remember { mutableStateOf<String?>(null) }
|
||||
@@ -107,155 +67,50 @@ fun WalletQrScannerModal(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(visible) {
|
||||
if (visible) {
|
||||
uploadError = null
|
||||
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
hasPermission = granted
|
||||
if (!granted) permissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(visible) { if (visible) uploadError = null }
|
||||
LaunchedEffect(status) { if (status != null) uploadError = null }
|
||||
|
||||
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
|
||||
BackHandler { onDismiss() }
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.6f))
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onDismiss,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.padding(16.dp)
|
||||
.widthIn(max = 420.dp)
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(Color(0xF212151C))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(24.dp))
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = {}, // swallow — only the scrim dismisses
|
||||
)
|
||||
.padding(24.dp),
|
||||
) {
|
||||
// Header — mirrors the web modal's title row
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.scan_to_send),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = Color.White,
|
||||
)
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
stringResource(R.string.close),
|
||||
tint = Color.White.copy(alpha = 0.7f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// Square camera preview with the orange viewfinder
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color.Black.copy(alpha = 0.4f))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(12.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (hasPermission) {
|
||||
// Throttle repeat frames: a static QR decodes ~20x/s but
|
||||
// the page only needs one; animated QRs still stream
|
||||
// because each frame's text differs.
|
||||
var lastText by remember { mutableStateOf("") }
|
||||
var lastSentAt by remember { mutableStateOf(0L) }
|
||||
CameraQrPreview(onDecoded = { text ->
|
||||
val now = System.currentTimeMillis()
|
||||
if (text != lastText || now - lastSentAt > 250) {
|
||||
lastText = text
|
||||
lastSentAt = now
|
||||
onDecoded(text)
|
||||
}
|
||||
})
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize(0.62f)
|
||||
.border(
|
||||
2.dp,
|
||||
BitcoinOrange.copy(alpha = 0.85f),
|
||||
RoundedCornerShape(16.dp),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
Column(
|
||||
Modifier.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.camera_permission_needed),
|
||||
color = Color.White.copy(alpha = 0.7f),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
GlassButton(
|
||||
text = stringResource(R.string.grant_camera_access),
|
||||
onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) },
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Status strip — same slot the web modal uses for hints/errors
|
||||
val message = uploadError ?: status?.first
|
||||
val isError = uploadError != null || status?.second == true
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color.White.copy(alpha = 0.05f))
|
||||
.padding(12.dp)
|
||||
.defaultMinSize(minHeight = 24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = message?.takeIf { it.isNotBlank() }
|
||||
?: stringResource(R.string.scan_wallet_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (isError) Color(0xFFF87171) else Color.White.copy(alpha = 0.6f),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
GlassButton(
|
||||
text = stringResource(R.string.upload_qr_image),
|
||||
onClick = { imagePicker.launch("image/*") },
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
)
|
||||
}
|
||||
// Throttle repeat frames: a static QR decodes many times a second but the
|
||||
// page only needs one; animated QRs still stream because each frame's
|
||||
// text differs.
|
||||
var lastText by remember { mutableStateOf("") }
|
||||
var lastSentAt by remember { mutableStateOf(0L) }
|
||||
LaunchedEffect(visible) {
|
||||
if (visible) {
|
||||
lastText = ""
|
||||
lastSentAt = 0L
|
||||
}
|
||||
}
|
||||
|
||||
QrGlassModal(
|
||||
visible = visible,
|
||||
title = stringResource(R.string.scan_to_send),
|
||||
status = uploadError?.let { it to true } ?: status,
|
||||
idleHint = stringResource(R.string.scan_wallet_hint),
|
||||
permissionRationale = stringResource(R.string.camera_permission_needed),
|
||||
onDismiss = onDismiss,
|
||||
onDecoded = { text ->
|
||||
val now = System.currentTimeMillis()
|
||||
if (text != lastText || now - lastSentAt > 250) {
|
||||
// Buzz on the FIRST hit only: an animated QR streams a new
|
||||
// frame every few ms, and one buzz each would be a drill in
|
||||
// the hand.
|
||||
if (lastText.isEmpty()) {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
}
|
||||
lastText = text
|
||||
lastSentAt = now
|
||||
onDecoded(text)
|
||||
}
|
||||
},
|
||||
footer = {
|
||||
GlassButton(
|
||||
text = stringResource(R.string.upload_qr_image),
|
||||
onClick = { imagePicker.launch("image/*") },
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Decode a QR from a picked image, downsampled so huge photos stay cheap. */
|
||||
|
||||
@@ -24,14 +24,18 @@ import com.archipelago.app.data.ServerQrParser
|
||||
import com.archipelago.app.fips.FipsManager
|
||||
import com.archipelago.app.ui.screens.FlareScreen
|
||||
import com.archipelago.app.ui.screens.IntroScreen
|
||||
import com.archipelago.app.ui.screens.NodePickerScreen
|
||||
import com.archipelago.app.ui.screens.PartyScreen
|
||||
import com.archipelago.app.ui.screens.RemoteInputScreen
|
||||
import com.archipelago.app.ui.screens.ServerConnectScreen
|
||||
import com.archipelago.app.ui.screens.WebViewScreen
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
object Routes {
|
||||
const val INTRO = "intro"
|
||||
const val NODE_PICKER = "node_picker"
|
||||
const val SERVER_CONNECT = "server_connect"
|
||||
const val WEB_VIEW = "web_view"
|
||||
const val REMOTE_INPUT = "remote_input"
|
||||
@@ -39,18 +43,38 @@ object Routes {
|
||||
const val FLARE = "flare"
|
||||
}
|
||||
|
||||
/**
|
||||
* Process-scoped "have we already asked which node?" flag.
|
||||
*
|
||||
* The picker is a COLD-START question: opening the app fresh (or after the
|
||||
* mesh service and its process were killed) is exactly when the user may want
|
||||
* a different node than last time. An Activity recreation inside a live
|
||||
* process — rotation, theme change — must not re-ask, and neither must a
|
||||
* simple return from the background, so the flag lives with the process
|
||||
* rather than in saved state.
|
||||
*/
|
||||
private object LaunchGate {
|
||||
@Volatile
|
||||
var nodeChoiceMade: Boolean = false
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppNavHost(
|
||||
pairUri: String? = null,
|
||||
onPairUriConsumed: () -> Unit = {},
|
||||
onReady: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val prefs = remember { ServerPreferences(context) }
|
||||
val navController = rememberNavController()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val introSeen by prefs.introSeen.collectAsState(initial = null)
|
||||
val activeServer by prefs.activeServer.collectAsState(initial = null)
|
||||
// One combined emission — introSeen and activeServer resolving in separate
|
||||
// frames used to flash the Connect screen at paired users on launch.
|
||||
val launchState by prefs.launchState.collectAsState(initial = null)
|
||||
val introSeen = launchState?.introSeen
|
||||
val activeServer = launchState?.activeServer
|
||||
val savedServers = launchState?.savedServers ?: emptyList()
|
||||
|
||||
// Pairing entry from a deep link that carried no password — prefills the
|
||||
// connect form so the user lands on the password prompt for that server.
|
||||
@@ -79,12 +103,30 @@ fun AppNavHost(
|
||||
}
|
||||
}
|
||||
|
||||
// Paired + previously consented → the mesh comes back silently on launch.
|
||||
LaunchedEffect(Unit) {
|
||||
FipsManager.autoStartIfReady(context)
|
||||
if (introSeen == null) return
|
||||
|
||||
// Ask which node when the user keeps more than one and this is a cold
|
||||
// start. Anything else (single node, mid-process Activity recreation,
|
||||
// a pairing deep link) goes straight through as before.
|
||||
val needsNodeChoice = introSeen == true &&
|
||||
!LaunchGate.nodeChoiceMade &&
|
||||
savedServers.size > 1
|
||||
|
||||
// Paired + previously consented → the mesh comes back silently on launch,
|
||||
// but ONLY once the session's node is known to be a FIPS node. Bringing
|
||||
// the tunnel up before that took Android's single VPN slot away from
|
||||
// whatever the user uses to reach a non-mesh node. Off the main
|
||||
// dispatcher: this path dlopens the 7 MB fips core and does a binder
|
||||
// round-trip (VpnService.prepare).
|
||||
LaunchedEffect(needsNodeChoice, activeServer?.npub, activeServer?.meshIp) {
|
||||
if (needsNodeChoice) return@LaunchedEffect
|
||||
if (activeServer?.isFipsNode() != true) return@LaunchedEffect
|
||||
withContext(Dispatchers.IO) { FipsManager.autoStartIfReady(context) }
|
||||
}
|
||||
|
||||
if (introSeen == null) return
|
||||
// Launch state resolved — MainActivity holds the system splash until now,
|
||||
// so the first visible frame is the real UI, never a black gap.
|
||||
LaunchedEffect(Unit) { onReady() }
|
||||
|
||||
// Declared after the introSeen gate so it can't fire before the NavHost
|
||||
// below has set the nav graph; pairUri stays pending until consumed here.
|
||||
@@ -118,6 +160,7 @@ fun AppNavHost(
|
||||
|
||||
val startDestination = when {
|
||||
introSeen == false -> Routes.INTRO
|
||||
needsNodeChoice -> Routes.NODE_PICKER
|
||||
activeServer != null -> Routes.WEB_VIEW
|
||||
else -> Routes.SERVER_CONNECT
|
||||
}
|
||||
@@ -126,6 +169,37 @@ fun AppNavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
) {
|
||||
composable(Routes.NODE_PICKER) {
|
||||
NodePickerScreen(
|
||||
servers = savedServers,
|
||||
lastActive = activeServer,
|
||||
onPick = { server ->
|
||||
LaunchGate.nodeChoiceMade = true
|
||||
scope.launch {
|
||||
prefs.setActiveServer(server)
|
||||
// The mesh follows the choice, and ONLY the choice.
|
||||
// A non-mesh node gets the tunnel taken down: Android
|
||||
// hands out one VPN slot, and holding it hostage is
|
||||
// what broke reaching nodes behind a different VPN.
|
||||
withContext(Dispatchers.IO) {
|
||||
if (server.isFipsNode()) {
|
||||
FipsManager.autoStartIfReady(context)
|
||||
} else {
|
||||
FipsManager.stopService(context)
|
||||
}
|
||||
}
|
||||
navController.navigate(Routes.WEB_VIEW) {
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
}
|
||||
},
|
||||
onAddNode = {
|
||||
LaunchGate.nodeChoiceMade = true
|
||||
navController.navigate(Routes.SERVER_CONNECT)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
composable(Routes.INTRO) {
|
||||
IntroScreen(
|
||||
onMeshParty = {
|
||||
|
||||
@@ -107,7 +107,11 @@ fun FlareScreen(onBack: () -> Unit) {
|
||||
}
|
||||
|
||||
val peer = peers.firstOrNull { it.npub == selectedNpub }
|
||||
val messages = allMessages.filter { it.peerNpub == selectedNpub }
|
||||
// derivedStateOf: filtering inline re-ran over the whole store on every
|
||||
// recomposition — including one per keystroke in the composer.
|
||||
val messages by remember(selectedNpub) {
|
||||
androidx.compose.runtime.derivedStateOf { allMessages.filter { it.peerNpub == selectedNpub } }
|
||||
}
|
||||
val listState = rememberLazyListState()
|
||||
LaunchedEffect(messages.size) {
|
||||
if (messages.isNotEmpty()) listState.animateScrollToItem(messages.size - 1)
|
||||
@@ -305,7 +309,13 @@ private fun MessageBubble(msg: FlareMessage) {
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
if (msg.photoPath.isNotBlank()) {
|
||||
val bmp = remember(msg.photoPath) { BitmapFactory.decodeFile(msg.photoPath) }
|
||||
// Decoded off-main and downsampled to the bubble width —
|
||||
// full-size decode in remember{} ran on the UI thread mid-
|
||||
// scroll and held ~8 MB per visible photo (OOM territory).
|
||||
var bmp by remember(msg.photoPath) { mutableStateOf<android.graphics.Bitmap?>(null) }
|
||||
LaunchedEffect(msg.photoPath) {
|
||||
bmp = withContext(Dispatchers.IO) { decodeSampledPhoto(msg.photoPath, 600) }
|
||||
}
|
||||
bmp?.let {
|
||||
Image(
|
||||
bitmap = it.asImageBitmap(),
|
||||
@@ -336,6 +346,19 @@ private fun MessageBubble(msg: FlareMessage) {
|
||||
}
|
||||
|
||||
/** Decode, downscale (≤1600px) and JPEG-compress a picked photo off-main. */
|
||||
/** Decode a stored beamed photo at roughly [maxPx] on the long edge — the
|
||||
* bubble renders at ~300 dp, so the stored 1600 px original is 25× the
|
||||
* pixels needed. Blocking — call on IO. */
|
||||
private fun decodeSampledPhoto(path: String, maxPx: Int): android.graphics.Bitmap? = try {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(path, bounds)
|
||||
var sample = 1
|
||||
while (maxOf(bounds.outWidth, bounds.outHeight) / (sample * 2) >= maxPx) sample *= 2
|
||||
BitmapFactory.decodeFile(path, BitmapFactory.Options().apply { inSampleSize = sample })
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
private suspend fun compressPhoto(context: android.content.Context, uri: Uri): ByteArray? =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
|
||||
@@ -37,6 +37,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
@@ -65,9 +66,10 @@ fun IntroScreen(
|
||||
var showContent by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
logoAlpha.animateTo(1f, animationSpec = tween(800))
|
||||
delay(300)
|
||||
// Content fades in WITH the logo, not after it — the serial
|
||||
// 800ms + 300ms sequence held "Get Started" off-screen for 1.1s.
|
||||
showContent = true
|
||||
logoAlpha.animateTo(1f, animationSpec = tween(450))
|
||||
}
|
||||
|
||||
Box(
|
||||
@@ -111,7 +113,9 @@ fun IntroScreen(
|
||||
contentDescription = "Archipelago",
|
||||
modifier = Modifier
|
||||
.size(160.dp)
|
||||
.alpha(logoAlpha.value),
|
||||
// graphicsLayer defers the alpha read to the draw phase —
|
||||
// .alpha(value) recomposed the whole screen per frame.
|
||||
.graphicsLayer { alpha = logoAlpha.value },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
package com.archipelago.app.ui.screens
|
||||
|
||||
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.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.filled.Bolt
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.LockOpen
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
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.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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.R
|
||||
import com.archipelago.app.data.ServerEntry
|
||||
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
|
||||
|
||||
/**
|
||||
* "Which node?" — shown at launch when more than one node is saved.
|
||||
*
|
||||
* The companion used to dive straight back into whichever node was last
|
||||
* active, which is wrong the moment a user keeps more than one: they arrive
|
||||
* somewhere they didn't choose, and (worse) the FIPS tunnel came up before
|
||||
* anyone said which network this session belongs to. Picking first makes the
|
||||
* choice explicit and lets the mesh stay down for nodes that aren't on it.
|
||||
*
|
||||
* [onPick] carries the entry; the caller decides what the mesh does about it.
|
||||
*/
|
||||
@Composable
|
||||
fun NodePickerScreen(
|
||||
servers: List<ServerEntry>,
|
||||
lastActive: ServerEntry?,
|
||||
onPick: (ServerEntry) -> Unit,
|
||||
onAddNode: () -> Unit,
|
||||
) {
|
||||
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 = 48.dp, bottom = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically),
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.ic_logo),
|
||||
contentDescription = "Archipelago",
|
||||
modifier = Modifier.size(88.dp),
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.pick_node_title),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = TextPrimary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.pick_node_hint),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = TextMuted,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
servers.forEach { server ->
|
||||
NodeCard(
|
||||
server = server,
|
||||
isLast = lastActive?.sameNode(server) == true,
|
||||
onClick = { onPick(server) },
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
GlassButton(
|
||||
text = stringResource(R.string.pick_node_add),
|
||||
onClick = onAddNode,
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NodeCard(
|
||||
server: ServerEntry,
|
||||
isLast: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(Color.Black.copy(alpha = 0.6f))
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.White.copy(alpha = 0.08f),
|
||||
Color.White.copy(alpha = 0.02f),
|
||||
),
|
||||
)
|
||||
)
|
||||
.border(
|
||||
1.dp,
|
||||
if (isLast) BitcoinOrange.copy(alpha = 0.35f) else Color.White.copy(alpha = 0.1f),
|
||||
RoundedCornerShape(14.dp),
|
||||
)
|
||||
.clickable { onClick() }
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (server.useHttps) Icons.Default.Lock else Icons.Default.LockOpen,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = if (server.useHttps) SuccessGreen else BitcoinOrange,
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = server.displayName(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = TextPrimary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val secondary = buildString {
|
||||
if (server.name.isNotBlank()) append(server.address)
|
||||
if (server.port.isNotBlank()) {
|
||||
if (isNotEmpty()) append(":${server.port}") else append("Port ${server.port}")
|
||||
}
|
||||
}
|
||||
if (secondary.isNotBlank()) {
|
||||
Text(
|
||||
text = secondary,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = TextMuted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
// The one thing that actually changes behaviour on this screen: a mesh
|
||||
// node brings the FIPS tunnel up, a plain one deliberately does not.
|
||||
if (server.isFipsNode()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Bolt,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = BitcoinOrange,
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
text = "FIPS",
|
||||
color = BitcoinOrange,
|
||||
fontSize = 11.sp,
|
||||
letterSpacing = 1.sp,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,9 +123,12 @@ fun PartyScreen(
|
||||
name = prefs.partyName()
|
||||
// The hotspot/WiFi address can change while this screen is open
|
||||
// (e.g. the user flips the hotspot on mid-demo) — keep it fresh.
|
||||
// Tight only at first (the hotspot-flip window); interface walks
|
||||
// allocate, so back off once the screen has been open a while.
|
||||
var round = 0
|
||||
while (true) {
|
||||
localIp = withContext(Dispatchers.IO) { PartyQr.localWifiIpv4() }
|
||||
delay(3_000)
|
||||
delay(if (round++ < 10) 3_000 else 30_000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +141,16 @@ fun PartyScreen(
|
||||
port = PartyQr.PARTY_UDP_PORT,
|
||||
)
|
||||
}
|
||||
val qrBitmap = remember(qrPayload) { qrPayload?.let { renderQr(it) } }
|
||||
// QR encode + bitmap fill off the composition: done in remember{} it ran
|
||||
// on the UI thread PER KEYSTROKE of the name field (the payload embeds the
|
||||
// name) — a ZXing encode plus a megabyte-plus allocation per character.
|
||||
// The 250 ms delay is a free debounce via coroutine cancellation.
|
||||
var qrBitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
|
||||
LaunchedEffect(qrPayload) {
|
||||
if (qrPayload == null) { qrBitmap = null; return@LaunchedEffect }
|
||||
if (qrBitmap != null) delay(250)
|
||||
qrBitmap = withContext(Dispatchers.Default) { renderQr(qrPayload) }
|
||||
}
|
||||
|
||||
BackHandler {
|
||||
when {
|
||||
@@ -337,7 +349,12 @@ fun PartyScreen(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
val dlQr = remember { renderQr(APP_DOWNLOAD_URL) }
|
||||
// Encoded off-main; done in remember{} it dropped the
|
||||
// overlay's first fade-in frame.
|
||||
var dlQr by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
|
||||
LaunchedEffect(Unit) {
|
||||
dlQr = withContext(Dispatchers.Default) { renderQr(APP_DOWNLOAD_URL) }
|
||||
}
|
||||
dlQr?.let { bmp ->
|
||||
Box(
|
||||
Modifier
|
||||
@@ -364,7 +381,7 @@ fun PartyScreen(
|
||||
"…or send the APK file directly",
|
||||
color = BitcoinOrange,
|
||||
fontSize = 13.sp,
|
||||
modifier = Modifier.clickable { shareCompanionApk(context) }.padding(8.dp),
|
||||
modifier = Modifier.clickable { scope.launch { shareCompanionApk(context) } }.padding(8.dp),
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text("Close", color = TextMuted, fontSize = 14.sp, modifier = Modifier.clickable { showShareQr = false }.padding(8.dp))
|
||||
@@ -473,8 +490,9 @@ fun PartyScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/** Render a QR payload as a bitmap (dark modules on white). */
|
||||
private fun renderQr(payload: String, size: Int = 640): Bitmap? = try {
|
||||
/** Render a QR payload as a bitmap (dark modules on white). 512 px covers the
|
||||
* 240.dp display size at any density; 640 was a third more pixels for nothing. */
|
||||
private fun renderQr(payload: String, size: Int = 512): Bitmap? = try {
|
||||
val matrix = QRCodeWriter().encode(
|
||||
payload,
|
||||
BarcodeFormat.QR_CODE,
|
||||
@@ -494,16 +512,23 @@ private fun renderQr(payload: String, size: Int = 640): Bitmap? = try {
|
||||
}
|
||||
|
||||
/** Share this install's own APK via the system share sheet — a nearby friend
|
||||
* gets the companion with no internet at all (Quick Share / Bluetooth). */
|
||||
private fun shareCompanionApk(context: android.content.Context) {
|
||||
* gets the companion with no internet at all (Quick Share / Bluetooth).
|
||||
* The ~27 MB copy runs on IO — inline in the click handler it froze the UI
|
||||
* for seconds (ANR territory on slow flash). Copied once per install; the
|
||||
* cached file is reused while its size still matches the source. */
|
||||
private suspend fun shareCompanionApk(context: android.content.Context) {
|
||||
try {
|
||||
val src = java.io.File(context.applicationInfo.sourceDir)
|
||||
val dir = java.io.File(context.cacheDir, "share").apply { mkdirs() }
|
||||
val out = java.io.File(dir, "archipelago-companion.apk")
|
||||
src.copyTo(out, overwrite = true)
|
||||
val uri = androidx.core.content.FileProvider.getUriForFile(
|
||||
context, "${context.packageName}.fileprovider", out,
|
||||
)
|
||||
val uri = withContext(Dispatchers.IO) {
|
||||
val src = java.io.File(context.applicationInfo.sourceDir)
|
||||
val dir = java.io.File(context.cacheDir, "share").apply { mkdirs() }
|
||||
val out = java.io.File(dir, "archipelago-companion.apk")
|
||||
if (!out.exists() || out.length() != src.length()) {
|
||||
src.copyTo(out, overwrite = true)
|
||||
}
|
||||
androidx.core.content.FileProvider.getUriForFile(
|
||||
context, "${context.packageName}.fileprovider", out,
|
||||
)
|
||||
}
|
||||
val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
|
||||
type = "application/vnd.android.package-archive"
|
||||
putExtra(android.content.Intent.EXTRA_STREAM, uri)
|
||||
|
||||
@@ -33,7 +33,6 @@ import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.LockOpen
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -76,6 +75,7 @@ import com.archipelago.app.data.ServerEntry
|
||||
import com.archipelago.app.data.ServerPreferences
|
||||
import com.archipelago.app.fips.FipsManager
|
||||
import com.archipelago.app.ui.components.MeshLoadingScreen
|
||||
import com.archipelago.app.ui.components.SlidingLoader
|
||||
import com.archipelago.app.ui.components.QrScannerOverlay
|
||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||
import com.archipelago.app.ui.theme.ErrorRed
|
||||
@@ -86,6 +86,7 @@ import com.archipelago.app.ui.theme.TextMuted
|
||||
import com.archipelago.app.ui.theme.TextPrimary
|
||||
import com.archipelago.app.ui.theme.TextSecondary
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -108,6 +109,20 @@ fun ServerConnectScreen(
|
||||
val scope = rememberCoroutineScope()
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
|
||||
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
|
||||
|
||||
// Warm the mesh tunnel the moment the screen appears — starting it only
|
||||
// after the LAN probe failed put full tunnel bring-up + session discovery
|
||||
// inside the user's wait. By connect-tap time it's usually already up.
|
||||
//
|
||||
// Only when there is actually a mesh node to warm for, though: raising the
|
||||
// tunnel on a phone whose saved nodes are all plain HTTP boxes takes
|
||||
// Android's single VPN slot for nothing.
|
||||
LaunchedEffect(savedServers.any { it.isFipsNode() }) {
|
||||
if (savedServers.none { it.isFipsNode() }) return@LaunchedEffect
|
||||
withContext(Dispatchers.IO) { FipsManager.autoStartIfReady(context) }
|
||||
}
|
||||
|
||||
var name by remember { mutableStateOf("") }
|
||||
var address by remember { mutableStateOf("") }
|
||||
var port by remember { mutableStateOf("") }
|
||||
@@ -121,8 +136,13 @@ fun ServerConnectScreen(
|
||||
// Landing shows Scan/Manual choice; the form appears in manual mode or while editing.
|
||||
var manualMode by remember { mutableStateOf(false) }
|
||||
var showScanner by remember { mutableStateOf(false) }
|
||||
|
||||
val savedServers by prefs.savedServers.collectAsState(initial = emptyList())
|
||||
// Is the connect currently running aimed at a mesh node? Drives whether
|
||||
// the loader wears the FIPS brand — see MeshLoadingScreen.
|
||||
var connectingOverMesh by remember { mutableStateOf(false) }
|
||||
var connectingName by remember { mutableStateOf("") }
|
||||
// Brief green landing on the loader before the kiosk takes over, matching
|
||||
// the platform's install overlay.
|
||||
var connectSucceeded by remember { mutableStateOf(false) }
|
||||
|
||||
fun clearForm() {
|
||||
name = ""
|
||||
@@ -171,40 +191,60 @@ fun ServerConnectScreen(
|
||||
}
|
||||
isConnecting = true
|
||||
errorMessage = null
|
||||
connectingOverMesh = server.isFipsNode()
|
||||
connectingName = server.displayName()
|
||||
connectSucceeded = false
|
||||
|
||||
scope.launch {
|
||||
var reachable = testConnection(server)
|
||||
|
||||
// LAN address didn't answer — phone off-LAN (5G) or DHCP moved the
|
||||
// node. The scanned IP was only ever a dial hint; the node's real
|
||||
// LAN and mesh race IN PARALLEL — the serial LAN-then-mesh chain
|
||||
// burned a guaranteed-dead 5 s LAN probe before the mesh path even
|
||||
// started (the off-LAN QR-pairing case, exactly where speed shows).
|
||||
// The scanned IP was only ever a dial hint; the node's real
|
||||
// identity is its npub and its ULA is reachable from anywhere over
|
||||
// the mesh. Bring the tunnel up and probe the ULA before failing.
|
||||
if (!reachable && server.meshIp.isNotBlank()) {
|
||||
// the mesh. Mesh discovery + first session can take 15s+ through
|
||||
// the public tree (per node diagnosis), and on a
|
||||
// first-ever pairing the VPN consent dialog is on screen at the
|
||||
// same time — so the mesh side keeps probing inside its budget
|
||||
// while the tunnel (already started at screen entry, and kicked
|
||||
// again here) warms up underneath.
|
||||
val meshServer = server.meshIp.takeIf { it.isNotBlank() }?.let {
|
||||
FipsManager.autoStartIfReady(context)
|
||||
val meshServer = server.copy(
|
||||
address = server.meshIp,
|
||||
useHttps = false,
|
||||
port = "",
|
||||
)
|
||||
// Mesh discovery + first session can take 15s+ through the
|
||||
// public tree (per node diagnosis), and on a
|
||||
// first-ever pairing the VPN consent dialog is on screen at
|
||||
// the same time — so probe patiently inside a 60s budget with
|
||||
// per-attempt timeouts wide enough to ride out TCP
|
||||
// retransmit backoff. The VPN service pre-warms the session
|
||||
// in parallel (ArchyVpnService.startSessionWarmer).
|
||||
val deadline = System.currentTimeMillis() + 60_000
|
||||
while (!reachable && System.currentTimeMillis() < deadline) {
|
||||
reachable = testConnection(meshServer, timeoutMs = 15_000)
|
||||
if (!reachable) delay(3000)
|
||||
server.copy(address = it, useHttps = false, port = "")
|
||||
}
|
||||
val reachable = kotlinx.coroutines.coroutineScope {
|
||||
val lan = async { testConnection(server, timeoutMs = 4_000) }
|
||||
val mesh = async {
|
||||
if (meshServer == null) return@async false
|
||||
val deadline = System.currentTimeMillis() + 45_000
|
||||
var ok = false
|
||||
while (!ok && System.currentTimeMillis() < deadline) {
|
||||
ok = testConnection(meshServer, timeoutMs = 8_000)
|
||||
if (!ok) delay(2000)
|
||||
}
|
||||
ok
|
||||
}
|
||||
val first = kotlinx.coroutines.selects.select<Boolean> {
|
||||
lan.onAwait { it }
|
||||
mesh.onAwait { it }
|
||||
}
|
||||
if (first) {
|
||||
lan.cancel(); mesh.cancel()
|
||||
true
|
||||
} else {
|
||||
// One side gave up — the verdict is whatever the other says.
|
||||
if (lan.isCompleted) mesh.await() else lan.await()
|
||||
}
|
||||
}
|
||||
isConnecting = false
|
||||
|
||||
if (reachable) {
|
||||
// Land the loader green before handing over, so the last thing
|
||||
// seen is "done", not a bar cut mid-sweep.
|
||||
connectSucceeded = true
|
||||
prefs.setActiveServer(server)
|
||||
delay(320)
|
||||
isConnecting = false
|
||||
onConnected(server.toUrl())
|
||||
} else {
|
||||
isConnecting = false
|
||||
errorMessage = context.getString(R.string.connection_failed)
|
||||
}
|
||||
}
|
||||
@@ -293,7 +333,7 @@ fun ServerConnectScreen(
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = if (editingServer != null) stringResource(R.string.edit_server_title) else "Connect to Server",
|
||||
text = if (editingServer != null) stringResource(R.string.edit_server_title) else stringResource(R.string.connect_to_node),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = TextPrimary,
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -577,10 +617,9 @@ fun ServerConnectScreen(
|
||||
}
|
||||
|
||||
if (isConnecting) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = Color.White.copy(alpha = 0.6f),
|
||||
strokeWidth = 2.dp,
|
||||
SlidingLoader(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
done = connectSucceeded,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -617,7 +656,11 @@ fun ServerConnectScreen(
|
||||
// establishing (LAN probe → tunnel up → ULA probe can take a while).
|
||||
// The small inline spinner stays for context; this owns the screen.
|
||||
if (isConnecting) {
|
||||
MeshLoadingScreen()
|
||||
MeshLoadingScreen(
|
||||
mesh = connectingOverMesh,
|
||||
nodeName = connectingName,
|
||||
done = connectSucceeded,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -686,6 +729,17 @@ private fun sanitizeAddress(input: String): String {
|
||||
.trimEnd('/')
|
||||
}
|
||||
|
||||
// Built once — the connect loop probed up to 20 times, and each attempt was
|
||||
// paying a fresh SSLContext + SecureRandom init.
|
||||
private val trustAllSslFactory: javax.net.ssl.SSLSocketFactory by lazy {
|
||||
val trustAll = arrayOf<javax.net.ssl.TrustManager>(object : X509TrustManager {
|
||||
override fun checkClientTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
|
||||
override fun checkServerTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
|
||||
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = arrayOf()
|
||||
})
|
||||
SSLContext.getInstance("TLS").apply { init(null, trustAll, java.security.SecureRandom()) }.socketFactory
|
||||
}
|
||||
|
||||
/** Test RPC connectivity. Accepts self-signed certs for local LAN servers.
|
||||
* [timeoutMs] is per-phase (connect / read) — mesh probes need far more
|
||||
* patience than LAN ones (first session through the tree can take 15s+). */
|
||||
@@ -697,14 +751,7 @@ private suspend fun testConnection(server: ServerEntry, timeoutMs: Int = 5000):
|
||||
|
||||
// Trust self-signed certs for local HTTPS (Archipelago nodes rarely have CA certs)
|
||||
if (connection is HttpsURLConnection) {
|
||||
val trustAll = arrayOf<javax.net.ssl.TrustManager>(object : X509TrustManager {
|
||||
override fun checkClientTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
|
||||
override fun checkServerTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
|
||||
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = arrayOf()
|
||||
})
|
||||
val sc = SSLContext.getInstance("TLS")
|
||||
sc.init(null, trustAll, java.security.SecureRandom())
|
||||
connection.sslSocketFactory = sc.socketFactory
|
||||
connection.sslSocketFactory = trustAllSslFactory
|
||||
connection.hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true }
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,56 +2,95 @@ package com.archipelago.app.ui.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.archipelago.app.R
|
||||
|
||||
/**
|
||||
* The platform's brand face. neode-ui sets `font-archipelago: Montserrat` and
|
||||
* uses it for every heading, title and button label, with body copy left to
|
||||
* `Avenir Next, system-ui` — which on Android resolves to the system sans
|
||||
* anyway. Mirroring that split exactly is what makes companion text read as
|
||||
* the same product as the node UI.
|
||||
*
|
||||
* Montserrat is SIL OFL 1.1 (see Android/MONTSERRAT-OFL.txt); the files are
|
||||
* the ones already vendored for the web UI, so both halves ship the same
|
||||
* outlines.
|
||||
*/
|
||||
val Montserrat = FontFamily(
|
||||
Font(R.font.montserrat_medium, FontWeight.Medium),
|
||||
Font(R.font.montserrat_semibold, FontWeight.SemiBold),
|
||||
Font(R.font.montserrat_bold, FontWeight.Bold),
|
||||
Font(R.font.montserrat_extrabold, FontWeight.ExtraBold),
|
||||
)
|
||||
|
||||
val Typography = Typography(
|
||||
// ── Display / headings: Montserrat, tight and heavy like the web hero
|
||||
// copy (the platform sets tracking negative on its big type).
|
||||
displayLarge = TextStyle(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = Montserrat,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 32.sp,
|
||||
lineHeight = 40.sp,
|
||||
letterSpacing = (-0.5).sp,
|
||||
letterSpacing = (-0.8).sp,
|
||||
),
|
||||
headlineLarge = TextStyle(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontFamily = Montserrat,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 36.sp,
|
||||
letterSpacing = (-0.5).sp,
|
||||
),
|
||||
headlineMedium = TextStyle(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontFamily = Montserrat,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 24.sp,
|
||||
lineHeight = 32.sp,
|
||||
letterSpacing = (-0.4).sp,
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = Montserrat,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 20.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = (-0.2).sp,
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = Montserrat,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.15.sp,
|
||||
),
|
||||
// ── Body: system sans, exactly as the web falls back to.
|
||||
bodyLarge = TextStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp,
|
||||
letterSpacing = 0.2.sp,
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.25.sp,
|
||||
letterSpacing = 0.1.sp,
|
||||
),
|
||||
bodySmall = TextStyle(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 13.sp,
|
||||
lineHeight = 18.sp,
|
||||
),
|
||||
// ── Buttons / labels: Montserrat again, matching .glass-button.
|
||||
labelLarge = TextStyle(
|
||||
fontFamily = Montserrat,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.1.sp,
|
||||
),
|
||||
labelMedium = TextStyle(
|
||||
fontFamily = Montserrat,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
|
||||
@@ -1,36 +1,52 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Archipelago pixel-art "A" for splash screen -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="1024"
|
||||
android:viewportHeight="1024">
|
||||
<!-- System splash icon — deliberately the SAME mark as the adaptive launcher
|
||||
icon (ic_launcher_background.xml): dark disc + metallic ring + white
|
||||
Archipelago grid. Tapping the icon and watching the splash should show
|
||||
one badge, not two different logos.
|
||||
|
||||
Geometry is copied from the launcher: the Android 12 splash draws its icon
|
||||
on a 288dp canvas whose inner 2/3 is the safe area — the same 0.667 ratio
|
||||
the adaptive-icon mask uses — so the launcher's 0.65 (ring) / 0.55 (grid)
|
||||
group scales land identically here. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="288dp"
|
||||
android:height="288dp"
|
||||
android:viewportWidth="752"
|
||||
android:viewportHeight="752">
|
||||
|
||||
<!-- Dark disc + gradient ring (#000 -> #666), matching logo.svg -->
|
||||
<group
|
||||
android:pivotX="512"
|
||||
android:pivotY="512"
|
||||
android:pivotX="376"
|
||||
android:pivotY="376"
|
||||
android:scaleX="0.65"
|
||||
android:scaleY="0.65">
|
||||
<path
|
||||
android:fillColor="#0A0A0A"
|
||||
android:strokeWidth="22.8834"
|
||||
android:pathData="M11.441,375.669a364.227,364.227 0 1,0 728.454,0a364.227,364.227 0 1,0 -728.454,0z">
|
||||
<aapt:attr name="android:strokeColor">
|
||||
<gradient
|
||||
android:type="linear"
|
||||
android:startX="751.337"
|
||||
android:startY="751.338"
|
||||
android:endX="0"
|
||||
android:endY="0.000976562">
|
||||
<item android:offset="0" android:color="#FF000000" />
|
||||
<item android:offset="1" android:color="#FF666666" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
</group>
|
||||
|
||||
<!-- White Archipelago grid -->
|
||||
<group
|
||||
android:pivotX="376"
|
||||
android:pivotY="376"
|
||||
android:scaleX="0.55"
|
||||
android:scaleY="0.55">
|
||||
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M357.614,318h71.007v70.936h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M436.152,318h72.082v70.936h-72.082z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M515.766,318h72.082v70.936h-72.082z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M595.379,318h71.007v70.936h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M595.379,396.46h71.007v72.011h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M673.917,396.46h72.083v72.011h-72.083z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M278,475.994h72.083v72.012h-72.083z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M357.614,475.994h71.007v72.012h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M436.152,475.994h72.082v72.012h-72.082z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M515.766,475.994h72.082v72.012h-72.082z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M595.379,475.994h71.007v72.012h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M673.917,475.994h72.083v72.012h-72.083z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M278,555.529h72.083v70.936h-72.083z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M357.614,555.529h71.007v70.936h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M595.379,555.529h71.007v70.936h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M673.917,555.529h72.083v70.936h-72.083z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M357.614,633.989h71.007v72.011h-71.007z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M436.152,633.989h72.082v72.011h-72.082z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M515.766,633.989h72.082v72.011h-72.082z" />
|
||||
<path android:fillColor="#FFFFFF" android:pathData="M595.379,633.989h71.007v72.011h-71.007z" />
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M253.805,278.37V222.28H309.853V278.37H253.805ZM315.797,278.37V222.28H372.694V278.37H315.797ZM378.639,278.37V222.28H435.536V278.37H378.639ZM441.481,278.37V222.28H497.529V278.37H441.481ZM441.481,341.259V284.319H497.529V341.259H441.481ZM503.473,341.259V284.319H560.37V341.259H503.473ZM190.963,404.148V347.208H247.86V404.148H190.963ZM253.805,404.148V347.208H309.853V404.148H253.805ZM315.797,404.148V347.208H372.694V404.148H315.797ZM378.639,404.148V347.208H435.536V404.148H378.639ZM441.481,404.148V347.208H497.529V404.148H441.481ZM503.473,404.148V347.208H560.37V404.148H503.473ZM190.963,466.187V410.097H247.86V466.187H190.963ZM253.805,466.187V410.097H309.853V466.187H253.805ZM441.481,466.187V410.097H497.529V466.187H441.481ZM503.473,466.187V410.097H560.37V466.187H503.473ZM253.805,529.076V472.136H309.853V529.076H253.805ZM315.797,529.076V472.136H372.694V529.076H315.797ZM378.639,529.076V472.136H435.536V529.076H378.639ZM441.481,529.076V472.136H497.529V529.076H441.481Z" />
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -49,4 +49,12 @@
|
||||
<string name="scan_wallet_hint">Point the camera at a Lightning invoice, Bitcoin address, Cashu or Fedimint code</string>
|
||||
<string name="upload_qr_image">Upload image</string>
|
||||
<string name="no_qr_in_image">No QR code found in that image — try another, closer and well-lit</string>
|
||||
<string name="torch_on">Turn on the torch</string>
|
||||
<string name="torch_off">Turn off the torch</string>
|
||||
|
||||
<!-- Launch node picker (more than one node saved) -->
|
||||
<string name="pick_node_title">Which node?</string>
|
||||
<string name="pick_node_hint">Choose the Archipelago this session connects to. The FIPS mesh only comes up for mesh nodes.</string>
|
||||
<string name="pick_node_add">Add another node</string>
|
||||
<string name="connect_to_node">Connect to your node</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## v1.8.0-alpha (2026-08-12)
|
||||
|
||||
- **Archipelago is now open source.** The full source code of the node you are running — the orchestrator, the dashboard, the app platform, the mesh, the release tooling — is published for anyone to read, build and audit at source.archipelago-foundation.org/lfg2025/archy. A node that holds your money, your files and your communications should not ask to be taken on faith: from this release onward you, or anyone you trust, can see exactly what it does and follow every change we make in the open.
|
||||
- **Installing an update is reliable again, and tells you what happened when it isn't.** Some nodes could download an update but never apply it — the button stayed on "Install", and no amount of retrying worked. The cause: applying the update consumed the downloaded files as it went, so if any one step hit a snag partway through, the leftover files were incomplete and every later attempt failed the safety re-check forever, needing a technician to recover. Applying no longer consumes the download — a failed apply can always be retried from the same files — and the pieces are now applied in a fixed order with the program itself last, so a hiccup can't leave a half-swapped node. When an apply does fail, the screen now shows the real reason and what to do ("download the update again"), and offers Download again instead of a dead "Install" button, rather than a generic "it failed".
|
||||
- **Video on the kiosk stops tearing.** The kiosk's display had no vertical sync at all, so fast motion — IndeedHub films especially — showed horizontal tearing lines. The display driver now syncs every frame to the panel (no extra hardware needed, existing kiosks pick it up with this update), and on machines with a GPU, video decoding moves off the CPU onto the video hardware — smoother playback that also leaves more headroom for audio, not less.
|
||||
- **The Back button finally does what you expect.** Pressing Back — the mouse's side button on a kiosk, a swipe on a phone, the toolbar button in any browser — used to navigate the screen underneath an open window, or leave the dashboard entirely. Back now closes the topmost open window first, one per press, exactly like a native app; closing a window yourself never leaves a phantom entry that makes you press Back twice.
|
||||
- **No more bare IP addresses in your update or app-registry settings.** The update mirrors and the app registry each listed the same server twice — once by its proper name, once as a raw `http://146…` address left over from before the domain existed. The raw-address entries are retired: new nodes never see them, and existing nodes clean them out of their saved lists automatically on the next read. Everything now goes through the named, TLS-protected origin — which was always the same machine.
|
||||
- **The phone companion app downloads over the proper domain.** The download QR pointed at a raw address over plain HTTP; it now points at the same file on the https domain. Scanning it gets you an encrypted download from a named server.
|
||||
- **The Receive window now tells you when the money is on its way.** Previously it showed a QR code and left you to check elsewhere whether anything happened. Now, the moment the sender's transaction is broadcast, the QR gives way to a clock: the amount, the transaction ID (tap to copy), and a note that the funds arrive on their own — with a single Done button. If you keep the window open, the clock becomes a green check at the first confirmation. Verified live on a real node: payment detected within seconds of broadcast.
|
||||
|
||||
## v1.7.129-alpha (2026-08-10)
|
||||
|
||||
- **Every app is now supervised the same way — the last stragglers moved under systemd.** Five apps (Jellyfin, Nextcloud, Home Assistant, Uptime Kuma, Vaultwarden) still ran outside the node's per-app service management for a technical reason: their networking style died with whatever process started it, so they were kept alive by a separate workaround. That workaround is retired: these apps now migrate themselves onto the same managed units as everything else — own service, restart-on-anything, a ten-second breather between restarts so their networking can release its ports cleanly. The migration happens automatically on the node's next housekeeping pass, touches no app data, and was watched live on a real node: both test apps moved over on the first pass and came back healthy.
|
||||
|
||||
Generated
+1
-1
@@ -104,7 +104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.7.128-alpha"
|
||||
version = "1.8.0-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "archipelago"
|
||||
version = "1.7.129-alpha"
|
||||
version = "1.8.0-alpha"
|
||||
edition = "2021"
|
||||
license.workspace = true
|
||||
description = "Archipelago Bitcoin Node OS - Native backend"
|
||||
|
||||
@@ -64,6 +64,12 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
|
||||
"must be",
|
||||
"cannot",
|
||||
"Password",
|
||||
// OTA apply/download errors are all operator-actionable ("download it
|
||||
// again", "download first") — sanitizing them to "Operation failed"
|
||||
// left users stuck with no idea what to do, and hid the "already
|
||||
// running" text the update UI matches on to join an in-flight apply
|
||||
// instead of showing a false failure. Every such message starts "Update".
|
||||
"Update",
|
||||
// The federation escalation sentinel. "Password" above does NOT cover
|
||||
// it — starts_with is case-sensitive and the sentinel is ALL-CAPS —
|
||||
// so the frontend's isPasswordRequired() never saw it and the
|
||||
|
||||
@@ -120,6 +120,13 @@ pub async fn load_registries(data_dir: &Path) -> Result<RegistryConfig> {
|
||||
config
|
||||
.registries
|
||||
.retain(|r| !r.url.contains(RETIRED_TX1138_HOST));
|
||||
// And the release server's own bare-IP twin (146.59.87.168:3000 — the
|
||||
// same host as source.archipelago-foundation.org): older defaults listed
|
||||
// both, so the registry UI showed one server twice. Bare-IP origins were
|
||||
// retired 2026-08-11; the named entry stays and covers the same pulls.
|
||||
config
|
||||
.registries
|
||||
.retain(|r| !r.url.contains("146.59.87.168"));
|
||||
let mut changed = config.registries.len() != before;
|
||||
|
||||
// Migrate: any default registry URL that isn't already in the
|
||||
|
||||
+112
-64
@@ -84,11 +84,9 @@ fn is_newer(candidate: &str, current: &str) -> bool {
|
||||
const DEFAULT_UPDATE_MANIFEST_URL: &str =
|
||||
"https://source.archipelago-foundation.org/lfg2025/archy/raw/branch/main/releases/manifest.json";
|
||||
|
||||
/// The previous IP-based origin, kept as an automatic fallback so a node
|
||||
/// whose DNS or TLS is broken still updates. Dropped from the mirror list
|
||||
/// once the fleet has moved.
|
||||
const LEGACY_UPDATE_MANIFEST_URL: &str =
|
||||
"http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json";
|
||||
// The previous IP-based origin (http://146.59.87.168:3000/…) was an automatic
|
||||
// DNS/TLS-broken fallback until 2026-08-11, when bare-IP origins were retired
|
||||
// from the update registry. `load_mirrors` strips it from saved lists by host.
|
||||
const UPDATE_STATE_FILE: &str = "update_state.json";
|
||||
const UPDATE_MIRRORS_FILE: &str = "update-mirrors.json";
|
||||
/// Marker written by apply_update() just before the service restart and
|
||||
@@ -131,20 +129,11 @@ fn default_mirrors() -> Vec<UpdateMirror> {
|
||||
url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
|
||||
label: "Archipelago Foundation".to_string(),
|
||||
},
|
||||
// NOT a second server — the SAME host as the entry above, reached by
|
||||
// IP over plain HTTP instead of by name over TLS. It buys nothing if
|
||||
// the origin is down; what it recovers is a node whose **DNS is
|
||||
// broken or whose clock is wrong**, either of which fails TLS while
|
||||
// plain HTTP still works. Safe because the manifest carries an Ed25519
|
||||
// signature verified against the pinned release-root anchor, so
|
||||
// transport integrity is not what protects the update.
|
||||
//
|
||||
// Labelled explicitly so the UI cannot imply redundancy it doesn't
|
||||
// provide. Real redundancy needs a mirror on a different host.
|
||||
UpdateMirror {
|
||||
url: LEGACY_UPDATE_MANIFEST_URL.to_string(),
|
||||
label: "Same server, no DNS/TLS".to_string(),
|
||||
},
|
||||
// The bare-IP plain-HTTP twin of the entry above was retired as a
|
||||
// default on 2026-08-11 (operator decision: no bare-IP origins in the
|
||||
// update registry). Its DNS/TLS-broken recovery value is accepted as
|
||||
// lost; the manifest signature was always what protected the update,
|
||||
// never the transport. `load_mirrors` strips it from saved lists.
|
||||
]
|
||||
}
|
||||
|
||||
@@ -182,8 +171,16 @@ pub async fn load_mirrors(data_dir: &Path) -> Result<Vec<UpdateMirror>> {
|
||||
// ever served a stale manifest as the secondary mirror.
|
||||
// Exception to the usual "explicit removals stick" rule: the user never
|
||||
// chose to add these — they were defaults.
|
||||
// - 146.59.87.168: the release server's own bare-IP HTTP twin, retired
|
||||
// as a default 2026-08-11 — same host as the named origin, so it
|
||||
// provided no redundancy, only a plain-HTTP path the operator no
|
||||
// longer wants advertised in the update registry.
|
||||
let before = list.len();
|
||||
list.retain(|m| !m.url.contains("23.182.128.160") && !m.url.contains("git.tx1138.com"));
|
||||
list.retain(|m| {
|
||||
!m.url.contains("23.182.128.160")
|
||||
&& !m.url.contains("git.tx1138.com")
|
||||
&& !m.url.contains("146.59.87.168")
|
||||
});
|
||||
let mut changed = list.len() != before;
|
||||
|
||||
// Merge in any default URLs the saved config is missing.
|
||||
@@ -216,21 +213,14 @@ fn force_ovh_update_primary(list: &mut Vec<UpdateMirror>) {
|
||||
for mirror in list.iter_mut() {
|
||||
if mirror.url == DEFAULT_UPDATE_MANIFEST_URL {
|
||||
mirror.label = "Archipelago Foundation".to_string();
|
||||
} else if mirror.url == LEGACY_UPDATE_MANIFEST_URL {
|
||||
// Rewritten on every load, so relabelling here reaches nodes that
|
||||
// already have the old "Direct (fallback)" text saved in their
|
||||
// update-mirrors.json — the merge below matches on URL, never on
|
||||
// label, so without this a renamed default would never propagate.
|
||||
mirror.label = "Same server, no DNS/TLS".to_string();
|
||||
}
|
||||
}
|
||||
// Named origin first, its same-host IP fallback second, anything the
|
||||
// operator added after that. Ordering matters: the list is tried in order,
|
||||
// so a stale entry sitting first costs a timeout on every check.
|
||||
// Named origin first, anything the operator added after that. Ordering
|
||||
// matters: the list is tried in order, so a stale entry sitting first
|
||||
// costs a timeout on every check.
|
||||
list.sort_by_key(|m| match m.url.as_str() {
|
||||
u if u == DEFAULT_UPDATE_MANIFEST_URL => 0,
|
||||
u if u == LEGACY_UPDATE_MANIFEST_URL => 1,
|
||||
_ => 2,
|
||||
_ => 1,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1023,7 +1013,7 @@ pub async fn dismiss_update(data_dir: &Path) -> Result<()> {
|
||||
/// partially-corrupt resume still fails cleanly.
|
||||
pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
|
||||
let _op = UPDATE_OP_LOCK.try_lock().map_err(|_| {
|
||||
anyhow::anyhow!("another update operation (download or apply) is already running")
|
||||
anyhow::anyhow!("Update already in progress — another download or apply is already running")
|
||||
})?;
|
||||
let mut state = load_state(data_dir).await?;
|
||||
if state.available_update.is_none() {
|
||||
@@ -1416,8 +1406,8 @@ async fn verify_staged_components(staging_dir: &Path, manifest: &UpdateManifest)
|
||||
.unwrap_or(0);
|
||||
if len != component.size_bytes {
|
||||
anyhow::bail!(
|
||||
"staged component {} is {} bytes but the manifest says {} — \
|
||||
refusing to apply (incomplete or concurrently-rewritten download)",
|
||||
"Update staging is inconsistent: component {} is {} bytes but the manifest says {} — \
|
||||
re-download before applying (incomplete or concurrently-rewritten download)",
|
||||
component.name,
|
||||
len,
|
||||
component.size_bytes
|
||||
@@ -1529,11 +1519,11 @@ pub(crate) async fn host_sudo_output(args: &[&str]) -> Result<std::process::Outp
|
||||
/// Apply a downloaded update. Backs up current binaries, replaces with staged versions.
|
||||
pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
||||
let _op = UPDATE_OP_LOCK.try_lock().map_err(|_| {
|
||||
anyhow::anyhow!("another update operation (download or apply) is already running")
|
||||
anyhow::anyhow!("Update already in progress — another download or apply is already running")
|
||||
})?;
|
||||
let staging_dir = data_dir.join("update-staging");
|
||||
if !staging_dir.exists() {
|
||||
anyhow::bail!("No staged update found. Download first.");
|
||||
anyhow::bail!("Update not staged — download it first, then apply.");
|
||||
}
|
||||
|
||||
// Gate 1: the completion marker is written only after EVERY component
|
||||
@@ -1541,7 +1531,7 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
||||
// or in-flight download — exactly what got installed on .198.
|
||||
if !has_staged_update(data_dir).await {
|
||||
anyhow::bail!(
|
||||
"Staged update is incomplete (no completion marker) — download the update again before applying"
|
||||
"Update download was incomplete (no completion marker) — download the update again before applying"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1550,9 +1540,7 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
||||
.await?
|
||||
.available_update
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"no update manifest in state to verify staged files against — re-download the update"
|
||||
)
|
||||
anyhow::anyhow!("Update manifest missing from state — re-download the update")
|
||||
})?;
|
||||
verify_staged_components(&staging_dir, &manifest).await?;
|
||||
|
||||
@@ -1588,41 +1576,83 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
|
||||
info!("Current binary backed up");
|
||||
}
|
||||
|
||||
// Apply staged components
|
||||
let mut entries = fs::read_dir(&staging_dir)
|
||||
.await
|
||||
.context("Failed to read staging dir")?;
|
||||
// Apply staged components in a DETERMINISTIC order, binary LAST.
|
||||
// read_dir order is filesystem-arbitrary, and each component used to be
|
||||
// consumed destructively — so a mid-apply failure could leave staging
|
||||
// half-emptied and un-reappliable (Gate 2 re-verifies EVERY manifest
|
||||
// component against staging, so a missing one wedges every retry:
|
||||
// "doesn't apply, still says install, can never apply again"). Two
|
||||
// guards against that now: (a) nothing is removed from staging here —
|
||||
// the binary is copied, not moved (see its block) — so a failed apply
|
||||
// is always retryable from the same staged files; (b) the binary, the
|
||||
// one component whose swap changes what runs after restart, is applied
|
||||
// only after the frontend/runtime succeed, so a frontend failure never
|
||||
// leaves a new binary staged to run against an old frontend on the next
|
||||
// restart.
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
{
|
||||
let mut entries = fs::read_dir(&staging_dir)
|
||||
.await
|
||||
.context("Failed to read staging dir")?;
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
names.push(entry.file_name().to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
names.sort_by_key(|n| match n.as_str() {
|
||||
"archipelago" => 2, // binary last
|
||||
n if n.contains("runtime") && n.ends_with(".tar.gz") => 1,
|
||||
_ => 0, // frontend and everything else first
|
||||
});
|
||||
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
let src = entry.path();
|
||||
for name in &names {
|
||||
let name = name.as_str();
|
||||
let src = staging_dir.join(name);
|
||||
|
||||
match name.as_str() {
|
||||
match name {
|
||||
"archipelago" => {
|
||||
// Two namespace gotchas this block works around:
|
||||
// Three constraints this block works around:
|
||||
// 1. We're running FROM /usr/local/bin/archipelago, so
|
||||
// `install`/`cp` (O_TRUNC + write) fail with ETXTBSY.
|
||||
// Use `mv`, which is atomic rename() and tolerates a
|
||||
// busy destination.
|
||||
// rename() over a busy destination is fine.
|
||||
// 2. archipelago.service sets ProtectSystem=strict, so
|
||||
// even `sudo mv` into /usr/local/bin/ fails EROFS —
|
||||
// sudo inherits the service's mount namespace. Route
|
||||
// the rename through systemd-run so it runs in a
|
||||
// transient unit with default protections.
|
||||
// through host_sudo (systemd-run transient unit with
|
||||
// default protections).
|
||||
// 3. The staged binary must SURVIVE this so a later
|
||||
// component's failure leaves the apply retryable. So we
|
||||
// COPY the staged file to a sibling temp in the target
|
||||
// dir, then atomic-rename the temp over the target —
|
||||
// the staging copy is never moved. (mv'ing the staged
|
||||
// file itself was the wedging bug: binary applied, then
|
||||
// frontend fails, staging now missing the binary, every
|
||||
// retry fails re-verification forever.)
|
||||
let staged = src.to_string_lossy().to_string();
|
||||
let _ = host_sudo(&["chmod", "0755", &staged]).await;
|
||||
let _ = host_sudo(&["chown", "root:root", &staged]).await;
|
||||
let status = host_sudo(&["mv", &staged, "/usr/local/bin/archipelago"])
|
||||
let tmp = format!(
|
||||
"/usr/local/bin/.archipelago.new.{}",
|
||||
chrono::Utc::now().timestamp_millis()
|
||||
);
|
||||
let cp = host_sudo(&["cp", "-f", &staged, &tmp])
|
||||
.await
|
||||
.with_context(|| format!("Failed to copy staged binary for {}", name))?;
|
||||
if !cp.success() {
|
||||
let _ = host_sudo(&["rm", "-f", &tmp]).await;
|
||||
anyhow::bail!("copy of staged binary failed for {}", name);
|
||||
}
|
||||
let _ = host_sudo(&["chmod", "0755", &tmp]).await;
|
||||
let _ = host_sudo(&["chown", "root:root", &tmp]).await;
|
||||
let status = host_sudo(&["mv", &tmp, "/usr/local/bin/archipelago"])
|
||||
.await
|
||||
.with_context(|| format!("Failed to spawn mv for {}", name))?;
|
||||
if !status.success() {
|
||||
let _ = host_sudo(&["rm", "-f", &tmp]).await;
|
||||
anyhow::bail!(
|
||||
"mv into /usr/local/bin failed for {} (exit {:?})",
|
||||
name,
|
||||
status.code()
|
||||
);
|
||||
}
|
||||
info!(name = %name, "Backend binary applied");
|
||||
info!(name = %name, "Backend binary applied (staging preserved)");
|
||||
}
|
||||
_ if name.contains("frontend") && name.ends_with(".tar.gz") => {
|
||||
// Tarball contents are the *inside* of web-ui/ (root entries
|
||||
@@ -2428,10 +2458,10 @@ mod tests {
|
||||
async fn test_load_mirrors_returns_defaults_when_absent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let list = load_mirrors(dir.path()).await.unwrap();
|
||||
// The named origin leads, its IP fallback follows. A node with broken
|
||||
// DNS or a wrong clock (both break TLS) must still have a way to
|
||||
// update; the signature is what makes either source trustworthy.
|
||||
assert_eq!(list.len(), 2);
|
||||
// The named https origin is the ONLY default since 2026-08-11:
|
||||
// bare-IP origins were retired from the update registry (the IP
|
||||
// twin bought DNS/TLS-broken recovery, deliberately given up).
|
||||
assert_eq!(list.len(), 1);
|
||||
assert!(
|
||||
list[0]
|
||||
.url
|
||||
@@ -2439,11 +2469,14 @@ mod tests {
|
||||
"the named origin must be primary, got {}",
|
||||
list[0].url
|
||||
);
|
||||
assert!(list[1].url.contains("146.59.87.168"));
|
||||
assert!(
|
||||
!list.iter().any(|m| m.url.contains("git.tx1138.com")),
|
||||
"tx1138 was retired as a release server and must not be a default mirror"
|
||||
);
|
||||
assert!(
|
||||
!list.iter().any(|m| m.url.contains("146.59.87.168")),
|
||||
"bare-IP origins were retired 2026-08-11 and must not be defaults"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2471,7 +2504,11 @@ mod tests {
|
||||
"retired tx1138 mirror should be stripped on load; got {:?}",
|
||||
list
|
||||
);
|
||||
assert!(list.iter().any(|m| m.url.contains("146.59.87.168")));
|
||||
assert!(
|
||||
!list.iter().any(|m| m.url.contains("146.59.87.168")),
|
||||
"the bare-IP twin is retired too and must be stripped on load; got {:?}",
|
||||
list
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2741,9 +2778,20 @@ mod tests {
|
||||
save_state(dir.path(), &state).await.unwrap();
|
||||
let err = apply_update(dir.path()).await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("refusing to apply"),
|
||||
err.to_string().contains("re-download before applying"),
|
||||
"got: {err:#}"
|
||||
);
|
||||
// Resilience: a refused apply must leave the update still available and
|
||||
// still staged, so the user can re-download and retry — never a wedge.
|
||||
let loaded = load_state(dir.path()).await.unwrap();
|
||||
assert!(
|
||||
loaded.available_update.is_some(),
|
||||
"a refused apply must not clear the available update"
|
||||
);
|
||||
assert!(
|
||||
loaded.update_in_progress,
|
||||
"a refused apply must leave the staged-update flag set for retry"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -86,4 +86,5 @@ file.
|
||||
## Roadmap & history
|
||||
|
||||
- [Roadmap](ROADMAP.md) — where the project is going
|
||||
- [TODO](TODO.md) — working backlog of unscoped forward-looking items
|
||||
- [archive/](archive/README.md) — superseded design and status documents, kept for provenance
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# TODO
|
||||
|
||||
Working backlog of forward-looking items not yet scoped into a dedicated plan
|
||||
doc. See [`ROADMAP.md`](ROADMAP.md) for the curated, public-facing direction.
|
||||
|
||||
## Dev & build process (priority)
|
||||
|
||||
- Formalize the contributor workflow: releases, CI, maintainers, automated
|
||||
builds, PR/issue flow, branch naming, and reproducible builds.
|
||||
|
||||
## Federation & peering
|
||||
|
||||
- Peering trust model — define tiers (trusted / public / private / peered)
|
||||
on top of the existing federation DID trust levels.
|
||||
- Federation architecture built on the above peering model.
|
||||
|
||||
## Distributed git & OTA
|
||||
|
||||
- Nostr-hosted git for the alpha (see
|
||||
[`nostr-git-source-hosting.md`](nostr-git-source-hosting.md)).
|
||||
- Distributed git beyond the nostr-hosting case.
|
||||
- Distributed OTA / app delivery.
|
||||
|
||||
## Nostr integration
|
||||
|
||||
- Nostr signer integration.
|
||||
|
||||
## Platform / OS
|
||||
|
||||
- Source-availability ISO — define the build/distribution story.
|
||||
- HW/OS update pipeline.
|
||||
- Deeper OpenWRT integration.
|
||||
- GrapheneOS integration — backups, attestation, profiles.
|
||||
|
||||
## App ecosystem
|
||||
|
||||
- Full pass testing every app in the catalog; expect issues across the board.
|
||||
- App update strategy — finalize the update policy referenced in
|
||||
[`app-developer-guide.md`](app-developer-guide.md) (pinned vs. mutable
|
||||
tags, catalog-vs-disk precedence, rollout/rollback).
|
||||
- App wishlist — candidates not yet packaged: Cashu wallet, phoenixd.
|
||||
(CLN is already shipped as `apps/core-lightning`.)
|
||||
|
||||
## Access & security
|
||||
|
||||
- SSH access strategy — define the access model (keys, rotation, recovery
|
||||
path, remote-support access).
|
||||
|
||||
## Observability
|
||||
|
||||
- Capture error logs to troubleshoot customer issues.
|
||||
- Stats & visualization for traffic, blocked attacks, VPNs, routing.
|
||||
@@ -0,0 +1,134 @@
|
||||
# Companion QR decoder — the zxing-cpp option (deferred)
|
||||
|
||||
*2026-08-11. Status: **NOT actioned.** Held as the next lever if the tuned
|
||||
ZXing-Java pipeline proves insufficient in field testing. Companion-only —
|
||||
touches `Android/` and nothing else.*
|
||||
|
||||
Related: [`qr-scanner-snappiness-handover.md`](qr-scanner-snappiness-handover.md)
|
||||
(web + native survey, 2026-07-29), [`companion-pairing-qr.md`](companion-pairing-qr.md)
|
||||
(the payload being scanned).
|
||||
|
||||
## Where we actually landed first
|
||||
|
||||
Before reaching for a new decoder, the native scanner
|
||||
(`Android/app/src/main/java/com/archipelago/app/ui/components/QrScannerOverlay.kt`)
|
||||
was rebuilt around one rule:
|
||||
|
||||
> **Every frame costs the same, and every frame sees the whole scene.**
|
||||
|
||||
Per frame: centre ROI at full resolution (dense invoices keep their
|
||||
pixels-per-module) + the whole frame at half resolution (coverage) + one
|
||||
alternating `GlobalHistogramBinarizer` pass. Bounded extras only: an inverted
|
||||
ROI every 8th frame, one `TRY_HARDER` pass over the *half*-frame at most once
|
||||
a second.
|
||||
|
||||
Two bugs were fixed on the way, both worth remembering because they are easy
|
||||
to reintroduce:
|
||||
|
||||
1. **Escalation-on-failure is backwards.** An earlier version unlocked
|
||||
progressively more expensive searches on each frame that missed, ending in
|
||||
a `TRY_HARDER` pass over the full 2 MP frame (150–300 ms). The result was a
|
||||
scanner that locked on instantly when the code was already in view at open,
|
||||
and crawled when the user opened the camera and then moved to the code —
|
||||
because hunting collapsed the rate from ~30 attempts/sec to ~4, each on a
|
||||
motion-blurred frame. Failure means the user is still aiming, which is when
|
||||
the scanner must be *fastest*, not most thorough.
|
||||
2. **A one-shot `startFocusAndMetering` locks the lens.** It puts AF in AUTO
|
||||
until auto-cancel; the 5 s default spans exactly the window where the user
|
||||
is swinging the phone toward the code, and a locked lens cannot follow.
|
||||
Auto-cancel is now 1 s so `CONTROL_AF_MODE_CONTINUOUS_PICTURE` does the
|
||||
tracking.
|
||||
|
||||
Plus `CONTROL_AE_TARGET_FPS_RANGE` pinned to the highest floor the back camera
|
||||
offers at ≤30 fps, which caps exposure (~33 ms) and kills the motion blur that
|
||||
indoor auto-exposure otherwise bakes into every hand-held frame.
|
||||
|
||||
That combination tested better on device (2026-08-11). This document covers
|
||||
what to do **if it is still not good enough.**
|
||||
|
||||
## The remaining structural limit
|
||||
|
||||
The decoder engine itself. ZXing's Java implementation is both the slow part
|
||||
and the picky part — most relevantly, it rejects perspective-skewed codes
|
||||
outright, which is much of what the sensor sees while the user is moving. No
|
||||
amount of frame budgeting fixes a decoder that will not accept the frame.
|
||||
|
||||
## The candidate: zxing-cpp
|
||||
|
||||
`io.github.zxing-cpp:android` — the maintained C++ rewrite of ZXing with an
|
||||
official Android/Kotlin wrapper.
|
||||
|
||||
**Why it clears the project's dependency bar** (`~/.claude/CLAUDE.md`):
|
||||
Apache-2.0, established OSS, fully on-device, no telemetry, no Play Services,
|
||||
no account or network dependency. This is the distinguishing point against
|
||||
**ML Kit**, which is the other fast option and is disqualified: it is
|
||||
proprietary and Play-Services-backed.
|
||||
|
||||
**What it buys:**
|
||||
|
||||
- Roughly 5–10× faster than ZXing-Java on the same frames.
|
||||
- Materially better on the cases that actually fail here: perspective/rotation
|
||||
(`tryRotate`, and its detector handles warp rather than rejecting it),
|
||||
blur, low contrast, damaged codes.
|
||||
- Built-in inversion handling (`tryInvert`), removing our alternating
|
||||
inverted-ROI pass.
|
||||
- Accepts an `ImageProxy` directly, so the manual Y-plane crop/copy machinery
|
||||
in `QrCodeAnalyzer` can largely be deleted — including the reused
|
||||
`roiBuffer`/`halfBuffer` and the `pixelStride` handling.
|
||||
|
||||
**Costs / risks:**
|
||||
|
||||
- New native dependency. APK grows ~1–2 MB — limited because the app is
|
||||
already arm64-only (`abiFilters += "arm64-v8a"`), so only one ABI ships.
|
||||
- Adds a native attack/maintenance surface next to the existing Rust FIPS
|
||||
core. Pin the version exactly, per project rules.
|
||||
- The tuned camera work above (AE FPS floor, AF auto-cancel, flat per-frame
|
||||
budget) stays relevant regardless — a faster decoder does not fix a blurred
|
||||
or out-of-focus frame. Do **not** rip that out as part of this change.
|
||||
|
||||
## Integration sketch
|
||||
|
||||
> ⚠️ Coordinates and API surface below are from memory and were **not**
|
||||
> verified against Maven Central — the machine this was written on had no
|
||||
> network. Confirm the current artifact version and wrapper API on the first
|
||||
> online Gradle sync before trusting the snippet.
|
||||
|
||||
`Android/app/build.gradle.kts`:
|
||||
|
||||
```kotlin
|
||||
// Replaces com.google.zxing:core for the live-camera path.
|
||||
implementation("io.github.zxing-cpp:android:<pin-exact-version>")
|
||||
```
|
||||
|
||||
`QrCodeAnalyzer` collapses to roughly:
|
||||
|
||||
```kotlin
|
||||
private val reader = BarcodeReader().apply {
|
||||
options = BarcodeReader.Options(
|
||||
formats = setOf(BarcodeFormat.QR_CODE),
|
||||
tryHarder = true,
|
||||
tryRotate = true,
|
||||
tryInvert = true,
|
||||
)
|
||||
}
|
||||
|
||||
override fun analyze(image: ImageProxy) {
|
||||
try {
|
||||
reader.read(image).firstOrNull()?.text?.let(onDecoded)
|
||||
} finally {
|
||||
image.close()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Keep `com.google.zxing:core` for now regardless: the still-image path
|
||||
(`decodeQrFromUri` in `WalletQrScannerModal.kt`, used by "Upload image") and
|
||||
`prewarmQrScanner` both use it, and neither is on the hot path.
|
||||
|
||||
## Decision trigger
|
||||
|
||||
Action this only if field testing shows the current pipeline still failing the
|
||||
**move-to-the-code** case — open the scanner pointing at nothing, then bring it
|
||||
to a QR at a normal hand-held distance. If that reads within about a second in
|
||||
ordinary room light, the Java decoder is doing its job and this stays on the
|
||||
shelf.
|
||||
@@ -408,6 +408,10 @@ DOCKERFILE_HEAD
|
||||
xorg \
|
||||
xdotool \
|
||||
chromium \
|
||||
mesa-va-drivers \
|
||||
intel-media-va-driver \
|
||||
i965-va-driver \
|
||||
vainfo \
|
||||
pipewire \
|
||||
pipewire-pulse \
|
||||
pipewire-alsa \
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
#!/bin/bash
|
||||
|
||||
# TearFree BEFORE X starts: bare Xorg with the stock modesetting driver has
|
||||
# no vsync and no compositor, so video page-flips land mid-scanout — visible
|
||||
# tearing on every kiosk (reported 2026-08-11, "really bad" on IndeedHub
|
||||
# playback). The modesetting driver's TearFree option double-buffers the
|
||||
# scanout at the driver level: no compositor needed, one frame of latency,
|
||||
# no interaction with the 2026-06-28 choppy-audio GPU decisions. Written
|
||||
# here (idempotently) rather than baked into the image so existing kiosk
|
||||
# nodes pick it up through the launcher's own OTA path (bootstrap.rs
|
||||
# reinstalls this script on every node).
|
||||
XORG_CONF_DIR=/etc/X11/xorg.conf.d
|
||||
XORG_TEARFREE="$XORG_CONF_DIR/20-archipelago-kiosk-tearfree.conf"
|
||||
mkdir -p "$XORG_CONF_DIR"
|
||||
if [ ! -f "$XORG_TEARFREE" ] || ! grep -q TearFree "$XORG_TEARFREE"; then
|
||||
cat > "$XORG_TEARFREE" <<'EOF'
|
||||
# Written by archipelago-kiosk-launcher — vsynced scanout for kiosk video.
|
||||
Section "Device"
|
||||
Identifier "Archipelago Kiosk GPU"
|
||||
Driver "modesetting"
|
||||
Option "TearFree" "true"
|
||||
EndSection
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Start a dedicated X server for the attached kiosk display.
|
||||
/usr/bin/Xorg :0 vt1 -nolisten tcp -keeptty &
|
||||
XPID=$!
|
||||
@@ -157,8 +180,16 @@ sleep 1
|
||||
# On a GPU-less / headless server (no /dev/dri), disable GPU entirely instead.
|
||||
if [ -e /dev/dri/card0 ] || [ -e /dev/dri/renderD128 ]; then
|
||||
GPU_FLAGS="--in-process-gpu --num-raster-threads=1"
|
||||
# Hardware VIDEO DECODE (VA-API) on GPU hardware. Orthogonal to the
|
||||
# GpuRasterization ban above: decode offload REDUCES the CPU pressure
|
||||
# that caused the choppy-audio incident, it doesn't re-create it.
|
||||
# Falls back silently to software decode when the platform lacks a
|
||||
# va driver — never a black player. IgnoreDriverChecks: older Intel
|
||||
# gens (HD 5500-era kiosks) are wrongly blocklisted upstream.
|
||||
ENABLE_FEATURES="OverlayScrollbar,VaapiVideoDecodeLinuxGL,VaapiIgnoreDriverChecks"
|
||||
else
|
||||
GPU_FLAGS="--disable-gpu --num-raster-threads=1"
|
||||
ENABLE_FEATURES="OverlayScrollbar"
|
||||
fi
|
||||
|
||||
ARCHIPELAGO_UID=$(id -u archipelago)
|
||||
@@ -194,7 +225,7 @@ while true; do
|
||||
--no-first-run \
|
||||
--check-for-update-interval=31536000 \
|
||||
--disable-features=TranslateUI,MetricsReporting,AutofillServerCommunication,PasswordManagerEnabled,GpuRasterization \
|
||||
--enable-features=OverlayScrollbar \
|
||||
--enable-features=$ENABLE_FEATURES \
|
||||
--disable-session-crashed-bubble \
|
||||
--disable-save-password-bubble \
|
||||
--disable-suggestions-service \
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.129-alpha",
|
||||
"version": "1.8.0-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "neode-ui",
|
||||
"version": "1.7.129-alpha",
|
||||
"version": "1.8.0-alpha",
|
||||
"dependencies": {
|
||||
"@scure/bip39": "^2.2.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "neode-ui",
|
||||
"private": true,
|
||||
"version": "1.7.129-alpha",
|
||||
"version": "1.8.0-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "./start-dev.sh",
|
||||
|
||||
@@ -390,7 +390,7 @@
|
||||
"author": "Grafana Labs",
|
||||
"category": "data",
|
||||
"tier": "recommended",
|
||||
"dockerImage": "grafana/grafana:10.2.0",
|
||||
"dockerImage": "source.archipelago-foundation.org/lfg2025/grafana:10.2.0",
|
||||
"repoUrl": "https://github.com/grafana/grafana",
|
||||
"containerConfig": {
|
||||
"ports": [
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"versionName": "0.5.27",
|
||||
"versionCode": 47
|
||||
}
|
||||
@@ -353,6 +353,10 @@ function openInNewTab() {
|
||||
// (so the tap silently no-ops). The native bridge is reliable; fall back to
|
||||
// window.open in a plain mobile browser.
|
||||
const native = (window as any).ArchipelagoNative
|
||||
if (native && typeof native.openInAppEx === 'function' && store.title) {
|
||||
native.openInAppEx(store.url, '', store.title)
|
||||
return
|
||||
}
|
||||
if (native && typeof native.openInApp === 'function') {
|
||||
native.openInApp(store.url)
|
||||
return
|
||||
|
||||
@@ -54,6 +54,7 @@ import { ref, computed, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useModalKeyboard } from '@/composables/useModalKeyboard'
|
||||
import { useBodyScrollLock } from '@/composables/useBodyScrollLock'
|
||||
import { useModalHistory } from '@/composables/useModalHistory'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
show: boolean
|
||||
@@ -105,6 +106,10 @@ function close() {
|
||||
|
||||
useModalKeyboard(modalRef, computed(() => props.show), close)
|
||||
useBodyScrollLock(computed(() => props.show))
|
||||
// Browser/mouse/gesture Back closes the modal instead of navigating the
|
||||
// router out from under it — the native-app behaviour kiosk and mobile
|
||||
// browsers expect (the companion webview already provides it natively).
|
||||
useModalHistory(computed(() => props.show), close)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -71,6 +71,11 @@
|
||||
I've installed it
|
||||
</button>
|
||||
</div>
|
||||
<!-- Which version the Download button installs — read from the
|
||||
metadata staged beside the APK; absent file, absent note -->
|
||||
<p v-if="companionVersion" class="text-xs text-white/40 text-center mt-3">
|
||||
Version {{ companionVersion.versionName }}<template v-if="companionVersion.versionCode"> (build {{ companionVersion.versionCode }})</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Screen 2: pair the app with this node -->
|
||||
@@ -144,13 +149,30 @@ import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
const STORAGE_KEY = 'neode_companion_intro_seen'
|
||||
// Absolute URL so the QR works when scanned by a phone (a relative path has no
|
||||
// host to resolve). Points at the companion APK hosted on the 146 release server
|
||||
// (publicly reachable) rather than the local node's /packages copy.
|
||||
// host to resolve). Points at the companion APK on the release server's https
|
||||
// domain (bare-IP origins retired 2026-08-11; /packages/ is proxied to the
|
||||
// same package host that previously answered on the IP).
|
||||
// The demo serves the APK from its own public origin instead, so the QR never
|
||||
// exposes the release-server address.
|
||||
const DEFAULT_DOWNLOAD_URL = IS_DEMO
|
||||
? `${window.location.origin}/packages/archipelago-companion.apk`
|
||||
: 'http://146.59.87.168:2100/packages/archipelago-companion.apk'
|
||||
: 'https://source.archipelago-foundation.org/packages/archipelago-companion.apk'
|
||||
|
||||
// Version note for the download step. Read from the node's own copy of the
|
||||
// metadata (ships in the frontend beside the APK at /packages/), written by
|
||||
// publish-companion-apk.sh from the same gradle config that built the APK.
|
||||
// Best-effort: no file, no note.
|
||||
const companionVersion = ref<{ versionName: string; versionCode: number } | null>(null)
|
||||
async function loadCompanionVersion() {
|
||||
try {
|
||||
const res = await fetch('/packages/archipelago-companion.json', { cache: 'no-store' })
|
||||
if (!res.ok) return
|
||||
const meta = await res.json()
|
||||
if (meta && typeof meta.versionName === 'string' && meta.versionName) {
|
||||
companionVersion.value = { versionName: meta.versionName, versionCode: Number(meta.versionCode) || 0 }
|
||||
}
|
||||
} catch { /* metadata is a nicety — the download works without it */ }
|
||||
}
|
||||
|
||||
// Deep-link scheme the companion app registers; carries the server entry the
|
||||
// app should create (see docs/companion-pairing-qr.md for the contract).
|
||||
@@ -238,6 +260,7 @@ watch(companionIntroRequested, (requested) => {
|
||||
|
||||
watch(visible, async (isVisible) => {
|
||||
if (!isVisible) return
|
||||
if (!companionVersion.value) void loadCompanionVersion()
|
||||
// Generate large and let CSS scale down — at 112px source a ~45-module QR
|
||||
// is 2.5px/module, which camera decoders (the companion app included)
|
||||
// routinely fail on. 512px keeps every module crisp.
|
||||
|
||||
@@ -31,19 +31,50 @@
|
||||
|
||||
<!-- On-chain -->
|
||||
<div v-if="receiveMethod === 'onchain'">
|
||||
<div v-if="note" class="mb-3 p-3 rounded-lg bg-orange-500/10 border border-orange-500/20 text-sm text-white/80 leading-relaxed">
|
||||
{{ note }}
|
||||
</div>
|
||||
<div v-if="onchainAddress" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
|
||||
<canvas ref="onchainQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
|
||||
<p class="text-white/50 text-xs mb-2">{{ t('receiveBitcoin.yourBitcoinAddress') }}</p>
|
||||
<p class="text-sm font-mono text-white/90 break-all">{{ onchainAddress }}</p>
|
||||
<CopyButton :value="onchainAddress" :label="t('common.copy')" class="mt-2" />
|
||||
</div>
|
||||
<div v-else class="mb-3 text-center">
|
||||
<p class="text-white/50 text-sm mb-2">{{ t('web5.generateFreshAddress') }}</p>
|
||||
<p v-if="processing" class="text-xs text-white/40">Checking Lightning wallet readiness...</p>
|
||||
<!-- Payment detected: the QR did its job — show the outcome -->
|
||||
<div v-if="paymentSeen" class="mb-3 p-6 bg-white/5 rounded-lg text-center">
|
||||
<div class="flex justify-center mb-4">
|
||||
<div
|
||||
class="w-16 h-16 rounded-full flex items-center justify-center"
|
||||
:class="paymentSeen.confirmations > 0 ? 'bg-green-500/15' : 'bg-orange-500/15 animate-pulse'"
|
||||
>
|
||||
<!-- Check once confirmed, clock while in the mempool -->
|
||||
<svg v-if="paymentSeen.confirmations > 0" class="w-8 h-8 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<svg v-else class="w-8 h-8 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-lg font-semibold text-white mb-1">
|
||||
{{ paymentSeen.confirmations > 0 ? t('receiveBitcoin.paymentConfirmed') : t('receiveBitcoin.paymentBroadcast') }}
|
||||
</p>
|
||||
<p v-if="paymentSeen.amountSats > 0" class="text-2xl font-semibold text-white/95 mb-2">
|
||||
{{ paymentSeen.amountSats.toLocaleString() }} sats
|
||||
</p>
|
||||
<p v-if="paymentSeen.confirmations === 0" class="text-sm text-white/50 mb-3 max-w-md mx-auto">
|
||||
{{ t('receiveBitcoin.paymentBroadcastHint') }}
|
||||
</p>
|
||||
<p class="text-xs text-white/50 mb-1">{{ t('receiveBitcoin.transactionId') }}</p>
|
||||
<p class="text-xs font-mono text-white/80" :title="paymentSeen.txid">{{ midTxid(paymentSeen.txid) }}</p>
|
||||
<CopyButton :value="paymentSeen.txid" :label="t('common.copy')" class="mt-2" />
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-if="note" class="mb-3 p-3 rounded-lg bg-orange-500/10 border border-orange-500/20 text-sm text-white/80 leading-relaxed">
|
||||
{{ note }}
|
||||
</div>
|
||||
<div v-if="onchainAddress" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
|
||||
<canvas ref="onchainQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
|
||||
<p class="text-white/50 text-xs mb-2">{{ t('receiveBitcoin.yourBitcoinAddress') }}</p>
|
||||
<p class="text-sm font-mono text-white/90 break-all">{{ onchainAddress }}</p>
|
||||
<CopyButton :value="onchainAddress" :label="t('common.copy')" class="mt-2" />
|
||||
</div>
|
||||
<div v-else class="mb-3 text-center">
|
||||
<p class="text-white/50 text-sm mb-2">{{ t('web5.generateFreshAddress') }}</p>
|
||||
<p v-if="processing" class="text-xs text-white/40">Checking Lightning wallet readiness...</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Ark -->
|
||||
@@ -70,7 +101,11 @@
|
||||
|
||||
<div v-if="error" class="mb-3 alert-error">{{ error }}</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<!-- Once the payment is seen there is nothing left to do here -->
|
||||
<div v-if="paymentSeen" class="flex">
|
||||
<button @click="close" class="flex-1 glass-button glass-button-success px-4 py-2 rounded-lg text-sm font-medium">{{ t('common.done') }}</button>
|
||||
</div>
|
||||
<div v-else class="flex gap-3">
|
||||
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
|
||||
<button @click="$emit('scan')" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium flex items-center justify-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -86,7 +121,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, watch } from 'vue'
|
||||
import { ref, nextTick, watch, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
@@ -107,7 +142,11 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{ close: []; received: []; scan: [] }>()
|
||||
|
||||
watch(() => props.show, (open) => {
|
||||
if (!open) return
|
||||
if (!open) {
|
||||
stopWatchingPayment()
|
||||
return
|
||||
}
|
||||
paymentSeen.value = null
|
||||
// Blank slate on every open: a leftover amount/memo/token or a previous
|
||||
// invoice quietly carrying into a new receive flow is exactly the stale-
|
||||
// state class the operator flagged on the send modal (2026-08-05).
|
||||
@@ -140,6 +179,65 @@ const arkQrCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const processing = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
// ── On-chain payment detection ────────────────────────────────────────────
|
||||
// The generated address is FRESH (lnd.newaddress), so any incoming wallet
|
||||
// transaction paying it is this receive — no baseline bookkeeping needed.
|
||||
// Poll while the QR is showing; flip to the success view on first sight
|
||||
// (0-conf, clock), keep polling gently until the first confirmation
|
||||
// upgrades it to a check, then stop.
|
||||
const paymentSeen = ref<null | { txid: string; amountSats: number; confirmations: number }>(null)
|
||||
let watchTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function midTxid(txid: string): string {
|
||||
return txid.length > 24 ? `${txid.slice(0, 10)}…${txid.slice(-10)}` : txid
|
||||
}
|
||||
|
||||
function stopWatchingPayment() {
|
||||
if (watchTimer) {
|
||||
clearInterval(watchTimer)
|
||||
watchTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function startWatchingPayment() {
|
||||
stopWatchingPayment()
|
||||
watchTimer = setInterval(() => void checkForPayment(), 5000)
|
||||
}
|
||||
|
||||
async function checkForPayment() {
|
||||
if (!props.show || !onchainAddress.value) {
|
||||
stopWatchingPayment()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await rpcClient.call<{
|
||||
transactions: Array<{
|
||||
tx_hash: string
|
||||
amount: number
|
||||
num_confirmations: number
|
||||
dest_addresses: string[]
|
||||
direction: string
|
||||
}>
|
||||
}>({ method: 'lnd.gettransactions' })
|
||||
const hit = (res.transactions || []).find(
|
||||
(tx) => tx.direction === 'incoming' && (tx.dest_addresses || []).includes(onchainAddress.value),
|
||||
)
|
||||
if (!hit) return
|
||||
const firstSighting = !paymentSeen.value
|
||||
paymentSeen.value = {
|
||||
txid: hit.tx_hash,
|
||||
amountSats: hit.amount,
|
||||
confirmations: hit.num_confirmations,
|
||||
}
|
||||
if (firstSighting) emit('received')
|
||||
if (hit.num_confirmations > 0) stopWatchingPayment()
|
||||
} catch {
|
||||
// Transient poll failure (daemon busy, LND mid-restart) — keep watching.
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(stopWatchingPayment)
|
||||
|
||||
async function renderQr(data: string, canvas: HTMLCanvasElement | null, prefix = '') {
|
||||
if (!canvas || !data) return
|
||||
try {
|
||||
@@ -153,6 +251,8 @@ async function renderQr(data: string, canvas: HTMLCanvasElement | null, prefix =
|
||||
}
|
||||
|
||||
function close() {
|
||||
stopWatchingPayment()
|
||||
paymentSeen.value = null
|
||||
invoiceResult.value = ''
|
||||
onchainAddress.value = ''
|
||||
arkAddress.value = ''
|
||||
@@ -184,6 +284,8 @@ async function receive() {
|
||||
throw new Error('LND did not return a Bitcoin address')
|
||||
}
|
||||
onchainAddress.value = res.address
|
||||
paymentSeen.value = null
|
||||
startWatchingPayment()
|
||||
nextTick(() => renderQr(res.address, onchainQrCanvas.value, 'bitcoin:'))
|
||||
} else if (receiveMethod.value === 'ark') {
|
||||
const res = await rpcClient.call<{ address: string }>({ method: 'wallet.ark-address' })
|
||||
|
||||
@@ -23,11 +23,11 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="transactions.length === 0" class="flex-1 flex items-center justify-center py-12">
|
||||
<div v-if="transactions.length === 0" class="flex items-center justify-center py-12">
|
||||
<p class="text-white/40 text-sm">{{ t('transactions.noTransactionsYet') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredTransactions.length === 0" class="flex-1 flex items-center justify-center py-12">
|
||||
<div v-else-if="filteredTransactions.length === 0" class="flex items-center justify-center py-12">
|
||||
<p class="text-white/40 text-sm">No {{ activeFilter }} transactions</p>
|
||||
</div>
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
class="text-sm font-medium"
|
||||
:class="tx.direction === 'incoming' ? 'text-green-400' : 'text-red-400'"
|
||||
>
|
||||
{{ tx.direction === 'incoming' ? '+' : '-' }}{{ Math.abs(tx.amount_sats).toLocaleString() }} sats
|
||||
{{ tx.direction === 'incoming' ? '+' : '-' }}{{ displayAmount(tx).toLocaleString() }} sats
|
||||
</span>
|
||||
<span
|
||||
v-if="isOnchain(tx)"
|
||||
@@ -91,6 +91,7 @@
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-0.5">
|
||||
<p class="text-[11px] text-white/40 font-mono truncate">{{ tx.tx_hash }}</p>
|
||||
<span v-if="feeFor(tx)" class="text-[10px] text-white/35 shrink-0">fee {{ feeFor(tx).toLocaleString() }} sats</span>
|
||||
<span v-if="tx.label" class="text-[10px] text-white/30 shrink-0">{{ tx.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -169,6 +170,18 @@ function isOnchain(tx: WalletTransaction): boolean {
|
||||
return !tx.kind || tx.kind === 'onchain'
|
||||
}
|
||||
|
||||
function feeFor(tx: WalletTransaction): number {
|
||||
return tx.direction === 'outgoing' ? (tx.total_fees || 0) : 0
|
||||
}
|
||||
|
||||
/** Outgoing rows show the amount the RECIPIENT got (gross minus fee); the fee
|
||||
* itself is broken out on its own tag. Incoming rows are untouched. */
|
||||
function displayAmount(tx: WalletTransaction): number {
|
||||
const gross = Math.abs(tx.amount_sats)
|
||||
const fee = feeFor(tx)
|
||||
return fee > 0 && gross > fee ? gross - fee : gross
|
||||
}
|
||||
|
||||
function kindLabel(tx: WalletTransaction): string {
|
||||
if (tx.kind === 'lightning') return '⚡ Lightning'
|
||||
if (tx.kind === 'cashu') return 'Cashu'
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// Back/forward integration for modals (kiosk, remote browsers, mobile).
|
||||
//
|
||||
// Without this, the browser's Back control (mouse side-button on kiosk,
|
||||
// gesture on mobile, toolbar button in a remote browser) navigates the
|
||||
// ROUTER while a modal is open — at best closing the whole screen under a
|
||||
// dialog, at worst leaving the app. The native-app expectation, and what
|
||||
// the companion webview already provides, is: Back closes the topmost
|
||||
// dialog first.
|
||||
//
|
||||
// Mechanics: opening a modal pushes one history entry (same URL, a depth
|
||||
// marker in state — router keys are preserved by spreading the existing
|
||||
// state). A popstate that lands BELOW our depth means the user pressed
|
||||
// Back over an open modal: close the topmost one. A UI-side close (X,
|
||||
// backdrop, Esc) consumes its own entry with history.back() so Back never
|
||||
// needs pressing twice — guarded by the depth marker so it can never eat
|
||||
// a router entry. One module-level stack serves every BaseModal instance,
|
||||
// so stacked modals close one per Back, top first.
|
||||
import { watch, type Ref } from 'vue'
|
||||
|
||||
type Entry = { close: () => void }
|
||||
|
||||
const stack: Entry[] = []
|
||||
// Set when a popstate initiated the close: the history entry is already
|
||||
// gone, so the close-side cleanup must not call history.back() again.
|
||||
let poppedClose = false
|
||||
let listening = false
|
||||
|
||||
function modalDepth(state: unknown): number {
|
||||
return (state as { __archyModal?: number } | null)?.__archyModal ?? 0
|
||||
}
|
||||
|
||||
function ensureListener() {
|
||||
if (listening || typeof window === 'undefined') return
|
||||
listening = true
|
||||
window.addEventListener('popstate', (e) => {
|
||||
// Landed at a depth below the open-modal count → this Back was aimed
|
||||
// at the topmost modal. One entry per Back press: close exactly one.
|
||||
// (A popstate at or above our depth is someone else's navigation —
|
||||
// e.g. our own cleanup back, or a forward — leave it alone.)
|
||||
if (modalDepth(e.state) < stack.length) {
|
||||
const top = stack[stack.length - 1]
|
||||
if (top) {
|
||||
poppedClose = true
|
||||
top.close()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Call from a modal component with its visibility and close trigger. */
|
||||
export function useModalHistory(show: Ref<boolean>, close: () => void) {
|
||||
ensureListener()
|
||||
const entry: Entry = { close }
|
||||
watch(show, (open, was) => {
|
||||
if (open === was) return
|
||||
if (open) {
|
||||
stack.push(entry)
|
||||
try {
|
||||
// Preserve vue-router's own keys in state — clobbering them breaks
|
||||
// its scroll restoration and position tracking.
|
||||
window.history.pushState(
|
||||
{ ...(window.history.state ?? {}), __archyModal: stack.length },
|
||||
'',
|
||||
)
|
||||
} catch { /* history can throw in exotic embeds — modal still works */ }
|
||||
} else {
|
||||
const wasTop = stack[stack.length - 1] === entry
|
||||
const i = stack.indexOf(entry)
|
||||
if (i >= 0) stack.splice(i, 1)
|
||||
if (poppedClose) {
|
||||
poppedClose = false
|
||||
return
|
||||
}
|
||||
// UI-side close of the top modal: consume the entry we pushed, but
|
||||
// only if it is still the current one (a route change after opening
|
||||
// moves history past it — backing out then would eat a real entry).
|
||||
if (wasTop && modalDepth(window.history.state) > stack.length) {
|
||||
try { window.history.back() } catch { /* same guard as above */ }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -764,6 +764,10 @@
|
||||
"memoPlaceholder": "Payment for...",
|
||||
"invoiceShareLabel": "Invoice (share with sender):",
|
||||
"yourBitcoinAddress": "Your Bitcoin address:",
|
||||
"paymentBroadcast": "Payment on its way",
|
||||
"paymentBroadcastHint": "The transaction has been broadcast and is waiting for its first confirmation. You can close this — the funds arrive on their own.",
|
||||
"paymentConfirmed": "Payment confirmed",
|
||||
"transactionId": "Transaction ID",
|
||||
"pasteEcashToken": "Paste ecash token",
|
||||
"processing": "Processing...",
|
||||
"generateAddress": "Generate Address",
|
||||
|
||||
@@ -751,6 +751,10 @@
|
||||
"memoPlaceholder": "Pago por...",
|
||||
"invoiceShareLabel": "Factura (compartir con el remitente):",
|
||||
"yourBitcoinAddress": "Su direcci\u00f3n Bitcoin:",
|
||||
"paymentBroadcast": "Pago en camino",
|
||||
"paymentBroadcastHint": "La transacci\u00f3n se ha difundido y espera su primera confirmaci\u00f3n. Puede cerrar esta ventana \u2014 los fondos llegar\u00e1n solos.",
|
||||
"paymentConfirmed": "Pago confirmado",
|
||||
"transactionId": "ID de transacci\u00f3n",
|
||||
"pasteEcashToken": "Pegar token Ecash",
|
||||
"processing": "Procesando...",
|
||||
"generateAddress": "Generar direcci\u00f3n",
|
||||
|
||||
@@ -3,9 +3,10 @@ import { ref, watch } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { recordAppLaunch } from '@/utils/appUsage'
|
||||
import { requestExternalOpen } from '@/api/remote-relay'
|
||||
import { openInAppOrNewTab, isCompanionApp } from '@/utils/openExternal'
|
||||
import { openInAppOrNewTab, isCompanionApp, type InAppLaunchMeta } from '@/utils/openExternal'
|
||||
import { resolveAppUrl } from '@/views/appSession/appSessionConfig'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { resolveAppIcon } from '@/views/apps/appsConfig'
|
||||
import { IS_DEMO, isDemoApp, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro'
|
||||
|
||||
/**
|
||||
@@ -200,6 +201,17 @@ export interface NostrConsentRequest {
|
||||
reject: () => void
|
||||
}
|
||||
|
||||
/** App identity (catalog icon + display name) for the companion's native
|
||||
* branded loader. Undefined when the app isn't in package-data. */
|
||||
function launchMeta(appId: string): InAppLaunchMeta | undefined {
|
||||
const pkg = useAppStore().data?.['package-data']?.[appId]
|
||||
if (!pkg) return undefined
|
||||
return {
|
||||
iconUrl: resolveAppIcon(appId, pkg),
|
||||
name: pkg.manifest?.title || appId,
|
||||
}
|
||||
}
|
||||
|
||||
export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
const isOpen = ref(false)
|
||||
const url = ref('')
|
||||
@@ -225,7 +237,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
const runtimeUrl = useAppStore().data?.['package-data']?.[appId]?.installed?.['interface-addresses']?.main?.['lan-address'] || undefined
|
||||
const launchUrl = directAppUrl(appId) || resolveAppUrl(appId, opts.path, runtimeUrl)
|
||||
if (launchUrl) {
|
||||
openInAppOrNewTab(launchUrl)
|
||||
openInAppOrNewTab(launchUrl, launchMeta(appId))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -235,7 +247,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
if (IS_DEMO && isDemoExternal(appId)) {
|
||||
const ext = demoAppUrl(appId)
|
||||
if (ext) {
|
||||
if (mobile) openInAppOrNewTab(ext)
|
||||
if (mobile) openInAppOrNewTab(ext, launchMeta(appId))
|
||||
else openExternal(ext)
|
||||
return
|
||||
}
|
||||
@@ -248,7 +260,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
if (NEW_TAB_APP_IDS.has(appId) && !(IS_DEMO && isDemoApp(appId))) {
|
||||
const launchUrl = directAppUrl(appId)
|
||||
if (launchUrl) {
|
||||
if (mobile) openInAppOrNewTab(launchUrl)
|
||||
if (mobile) openInAppOrNewTab(launchUrl, launchMeta(appId))
|
||||
else openExternal(launchUrl)
|
||||
return
|
||||
}
|
||||
@@ -327,7 +339,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
|
||||
// Companion app: never fall through to the iframe overlay — hand the URL
|
||||
// to the native in-app WebView instead (see openSession).
|
||||
if (!IS_DEMO && isCompanionApp()) {
|
||||
openInAppOrNewTab(launchUrl)
|
||||
openInAppOrNewTab(launchUrl, resolvedId ? launchMeta(resolvedId) : undefined)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1466,6 +1466,24 @@ html.kiosk-safe-area #app {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Horizontal filter-pill rail: single row, swipes sideways on narrow
|
||||
screens, no visible scrollbar — pills never wrap or squish. */
|
||||
.pill-rail {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.pill-rail::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.pill-rail > * {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for glass containers */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
|
||||
@@ -12,6 +12,15 @@
|
||||
interface ArchipelagoNativeBridge {
|
||||
openExternal?: (url: string) => void
|
||||
openInApp?: (url: string) => void
|
||||
/** Richer launch (companion ≥0.5.26): catalog icon + display name drive the
|
||||
* native branded loader instead of the site favicon. */
|
||||
openInAppEx?: (url: string, iconUrl: string, name: string) => void
|
||||
}
|
||||
|
||||
/** Optional app identity for the native loading screen. */
|
||||
export interface InAppLaunchMeta {
|
||||
iconUrl?: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
function nativeBridge(): ArchipelagoNativeBridge | undefined {
|
||||
@@ -46,9 +55,15 @@ export function openExternalUrl(url: string): void {
|
||||
* inside Archipelago with the native back/forward/reload/close controls.
|
||||
* - Plain mobile browser (PWA): open directly in a new browser tab.
|
||||
*/
|
||||
export function openInAppOrNewTab(url: string): void {
|
||||
export function openInAppOrNewTab(url: string, meta?: InAppLaunchMeta): void {
|
||||
if (!url) return
|
||||
const native = nativeBridge()
|
||||
if (native && typeof native.openInAppEx === 'function' && (meta?.iconUrl || meta?.name)) {
|
||||
// Absolutize the icon path so the native shell can fetch it directly.
|
||||
const icon = meta.iconUrl ? new URL(meta.iconUrl, window.location.origin).href : ''
|
||||
native.openInAppEx(url, icon, meta.name ?? '')
|
||||
return
|
||||
}
|
||||
if (native && typeof native.openInApp === 'function') {
|
||||
native.openInApp(url)
|
||||
return
|
||||
|
||||
@@ -1087,7 +1087,18 @@ async function applyUpdate() {
|
||||
}
|
||||
return
|
||||
}
|
||||
showStatus(t('systemUpdate.applyFailed'), true)
|
||||
// Surface the backend's actual, actionable message when it gave one
|
||||
// ("Update download was incomplete — download again", etc.) instead of a
|
||||
// generic dead end. The staged files are preserved across a failed apply,
|
||||
// so re-download stays available and a retry is always possible.
|
||||
const detail = errorMessage(e)
|
||||
const actionable = /^Update\b/.test(detail)
|
||||
showStatus(actionable ? detail : t('systemUpdate.applyFailed'), true)
|
||||
// A staging inconsistency means the download is the thing to redo — drop
|
||||
// the downloaded flag so the button offers Download again, not Apply.
|
||||
if (/incomplete|not staged|re-download|inconsistent/i.test(detail)) {
|
||||
downloaded.value = false
|
||||
}
|
||||
if (import.meta.env.DEV) console.warn('Apply failed', e)
|
||||
applying.value = false
|
||||
}
|
||||
|
||||
@@ -362,6 +362,22 @@ init()
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
|
||||
<!-- v1.8.0-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.0-alpha</span>
|
||||
<span class="text-xs text-white/40">August 12, 2026</span>
|
||||
</div>
|
||||
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
|
||||
<p>**Archipelago is now open source.** The full source code of the node you are running — the orchestrator, the dashboard, the app platform, the mesh, the release tooling — is published for anyone to read, build and audit at source.archipelago-foundation.org/lfg2025/archy. A node that holds your money, your files and your communications should not ask to be taken on faith: from this release onward you, or anyone you trust, can see exactly what it does and follow every change we make in the open.</p>
|
||||
<p>**Installing an update is reliable again, and tells you what happened when it isn't.** Some nodes could download an update but never apply it — the button stayed on "Install", and no amount of retrying worked. The cause: applying the update consumed the downloaded files as it went, so if any one step hit a snag partway through, the leftover files were incomplete and every later attempt failed the safety re-check forever, needing a technician to recover. Applying no longer consumes the download — a failed apply can always be retried from the same files — and the pieces are now applied in a fixed order with the program itself last, so a hiccup can't leave a half-swapped node. When an apply does fail, the screen now shows the real reason and what to do ("download the update again"), and offers Download again instead of a dead "Install" button, rather than a generic "it failed".</p>
|
||||
<p>**Video on the kiosk stops tearing.** The kiosk's display had no vertical sync at all, so fast motion — IndeedHub films especially — showed horizontal tearing lines. The display driver now syncs every frame to the panel (no extra hardware needed, existing kiosks pick it up with this update), and on machines with a GPU, video decoding moves off the CPU onto the video hardware — smoother playback that also leaves more headroom for audio, not less.</p>
|
||||
<p>**The Back button finally does what you expect.** Pressing Back — the mouse's side button on a kiosk, a swipe on a phone, the toolbar button in any browser — used to navigate the screen underneath an open window, or leave the dashboard entirely. Back now closes the topmost open window first, one per press, exactly like a native app; closing a window yourself never leaves a phantom entry that makes you press Back twice.</p>
|
||||
<p>**No more bare IP addresses in your update or app-registry settings.** The update mirrors and the app registry each listed the same server twice — once by its proper name, once as a raw http://146… address left over from before the domain existed. The raw-address entries are retired: new nodes never see them, and existing nodes clean them out of their saved lists automatically on the next read. Everything now goes through the named, TLS-protected origin — which was always the same machine.</p>
|
||||
<p>**The phone companion app downloads over the proper domain.** The download QR pointed at a raw address over plain HTTP; it now points at the same file on the https domain. Scanning it gets you an encrypted download from a named server.</p>
|
||||
<p>**The Receive window now tells you when the money is on its way.** Previously it showed a QR code and left you to check elsewhere whether anything happened. Now, the moment the sender's transaction is broadcast, the QR gives way to a clock: the amount, the transaction ID (tap to copy), and a note that the funds arrive on their own — with a single Done button. If you keep the window open, the clock becomes a green check at the first confirmation. Verified live on a real node: payment detected within seconds of broadcast.</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- v1.7.129-alpha -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
|
||||
+21
-20
@@ -1,32 +1,33 @@
|
||||
{
|
||||
"changelog": [
|
||||
"**Every app is now supervised the same way — the last stragglers moved under systemd.** Five apps (Jellyfin, Nextcloud, Home Assistant, Uptime Kuma, Vaultwarden) still ran outside the node's per-app service management for a technical reason: their networking style died with whatever process started it, so they were kept alive by a separate workaround. That workaround is retired: these apps now migrate themselves onto the same managed units as everything else — own service, restart-on-anything, a ten-second breather between restarts so their networking can release its ports cleanly. The migration happens automatically on the node's next housekeeping pass, touches no app data, and was watched live on a real node: both test apps moved over on the first pass and came back healthy.",
|
||||
"**Leftover companion screens are cleaned up again — driven by real records this time.** When an app is uninstalled, its helper screen (the UI tile that fronts it) should go too. That cleanup was switched off in an earlier release after it wrongly removed the Bitcoin screen from a node whose Bitcoin was installed — it had been guessing \"installed\" from what happened to be running, and a separate bug made a running app look absent. The node now keeps a durable record of what you have installed, written at install time and cleared only by a real uninstall, and the cleanup consults only that record. If the record can't be read, the cleanup does nothing at all — \"I couldn't check\" is never treated as \"nothing is installed\" — and a helper must be orphaned for a sustained period before it is touched.",
|
||||
"**A warning that fired every minute on every node is gone.** The app catalog and the node disagreed about where Grafana's software comes from, so the node ignored the catalog's answer and logged a complaint roughly every 75 seconds, forever. The catalog was right — Grafana is served from the fleet's own registry, like Bitcoin Knots — and the node's records now agree with it.",
|
||||
"**The federation map became a real map.** The network view is now a 3D orbital scene of your federation — nodes as a point-cloud globe with calm motion, auto-fit centring, and a 2D top-down toggle that portrait and mobile screens use by default, with the scene filling the viewport instead of sitting in a letterbox. Inbound peer requests appear live on the map as blinking nodes you can accept or reject in place, and revisiting the view no longer replays the whole intro — the scene updates in place.",
|
||||
"**An app that's mid-restart shows a page that says so — and comes back by itself.** When an app's screen was briefly unreachable behind the gate, the browser got a bare error; it now gets a named page for that app that retries on its own until the app answers.",
|
||||
"Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release; the supervision migration and the cleanup re-enable were verified live on one node (both apps migrated and healthy, cleanup correctly idle)."
|
||||
"**Archipelago is now open source.** The full source code of the node you are running — the orchestrator, the dashboard, the app platform, the mesh, the release tooling — is published for anyone to read, build and audit at source.archipelago-foundation.org/lfg2025/archy. A node that holds your money, your files and your communications should not ask to be taken on faith: from this release onward you, or anyone you trust, can see exactly what it does and follow every change we make in the open.",
|
||||
"**Installing an update is reliable again, and tells you what happened when it isn't.** Some nodes could download an update but never apply it — the button stayed on \"Install\", and no amount of retrying worked. The cause: applying the update consumed the downloaded files as it went, so if any one step hit a snag partway through, the leftover files were incomplete and every later attempt failed the safety re-check forever, needing a technician to recover. Applying no longer consumes the download — a failed apply can always be retried from the same files — and the pieces are now applied in a fixed order with the program itself last, so a hiccup can't leave a half-swapped node. When an apply does fail, the screen now shows the real reason and what to do (\"download the update again\"), and offers Download again instead of a dead \"Install\" button, rather than a generic \"it failed\".",
|
||||
"**Video on the kiosk stops tearing.** The kiosk's display had no vertical sync at all, so fast motion — IndeedHub films especially — showed horizontal tearing lines. The display driver now syncs every frame to the panel (no extra hardware needed, existing kiosks pick it up with this update), and on machines with a GPU, video decoding moves off the CPU onto the video hardware — smoother playback that also leaves more headroom for audio, not less.",
|
||||
"**The Back button finally does what you expect.** Pressing Back — the mouse's side button on a kiosk, a swipe on a phone, the toolbar button in any browser — used to navigate the screen underneath an open window, or leave the dashboard entirely. Back now closes the topmost open window first, one per press, exactly like a native app; closing a window yourself never leaves a phantom entry that makes you press Back twice.",
|
||||
"**No more bare IP addresses in your update or app-registry settings.** The update mirrors and the app registry each listed the same server twice — once by its proper name, once as a raw `http://146…` address left over from before the domain existed. The raw-address entries are retired: new nodes never see them, and existing nodes clean them out of their saved lists automatically on the next read. Everything now goes through the named, TLS-protected origin — which was always the same machine.",
|
||||
"**The phone companion app downloads over the proper domain.** The download QR pointed at a raw address over plain HTTP; it now points at the same file on the https domain. Scanning it gets you an encrypted download from a named server.",
|
||||
"**The Receive window now tells you when the money is on its way.** Previously it showed a QR code and left you to check elsewhere whether anything happened. Now, the moment the sender's transaction is broadcast, the QR gives way to a clock: the amount, the transaction ID (tap to copy), and a note that the funds arrive on their own — with a single Done button. If you keep the window open, the clock becomes a green check at the first confirmation. Verified live on a real node: payment detected within seconds of broadcast."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.7.129-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.7.129-alpha/archipelago",
|
||||
"current_version": "1.8.0-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.0-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.7.129-alpha",
|
||||
"sha256": "675e7dafc855d59b38c5a12d8b9405894677ed8701580227beca95ec2912e3f2",
|
||||
"size_bytes": 59531400
|
||||
"new_version": "1.8.0-alpha",
|
||||
"sha256": "fd99219ea11ffebc92b83154e1058911ebe72c357c80085310c78aba9714a6b5",
|
||||
"size_bytes": 59369392
|
||||
},
|
||||
{
|
||||
"current_version": "1.7.129-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.7.129-alpha/archipelago-frontend-1.7.129-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.129-alpha.tar.gz",
|
||||
"new_version": "1.7.129-alpha",
|
||||
"sha256": "53af2743308f4ae6a627255aaa288706d331d567c99e1cb615684dc3abba534d",
|
||||
"size_bytes": 95452033
|
||||
"current_version": "1.8.0-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.0-alpha/archipelago-frontend-1.8.0-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.0-alpha.tar.gz",
|
||||
"new_version": "1.8.0-alpha",
|
||||
"sha256": "f6bbf7b3f78a00dd7051abef805c943cfa9a58f624e09e94b6bee6c9b2f22902",
|
||||
"size_bytes": 97608790
|
||||
}
|
||||
],
|
||||
"release_date": "2026-08-10",
|
||||
"signature": "902778710674486d5919e4abb1bf5540521c9ef55b50a44a9d64b750812738d51cc193f6675a9969b1c45ae86c2e9e44ab3d8daf8aa1d439799d7f7846e27d04",
|
||||
"release_date": "2026-08-12",
|
||||
"signature": "be3e3c6f8ed6b8d573a6329bf546ec1aa739214cd391272ce3b2f0a0ef6b10a4a4c4f9dfa4b9b32dad88326c9feff908fbdb3d1b86d055e5cf876f67f195c60b",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.7.129-alpha"
|
||||
"version": "1.8.0-alpha"
|
||||
}
|
||||
|
||||
+21
-20
@@ -1,32 +1,33 @@
|
||||
{
|
||||
"changelog": [
|
||||
"**Every app is now supervised the same way — the last stragglers moved under systemd.** Five apps (Jellyfin, Nextcloud, Home Assistant, Uptime Kuma, Vaultwarden) still ran outside the node's per-app service management for a technical reason: their networking style died with whatever process started it, so they were kept alive by a separate workaround. That workaround is retired: these apps now migrate themselves onto the same managed units as everything else — own service, restart-on-anything, a ten-second breather between restarts so their networking can release its ports cleanly. The migration happens automatically on the node's next housekeeping pass, touches no app data, and was watched live on a real node: both test apps moved over on the first pass and came back healthy.",
|
||||
"**Leftover companion screens are cleaned up again — driven by real records this time.** When an app is uninstalled, its helper screen (the UI tile that fronts it) should go too. That cleanup was switched off in an earlier release after it wrongly removed the Bitcoin screen from a node whose Bitcoin was installed — it had been guessing \"installed\" from what happened to be running, and a separate bug made a running app look absent. The node now keeps a durable record of what you have installed, written at install time and cleared only by a real uninstall, and the cleanup consults only that record. If the record can't be read, the cleanup does nothing at all — \"I couldn't check\" is never treated as \"nothing is installed\" — and a helper must be orphaned for a sustained period before it is touched.",
|
||||
"**A warning that fired every minute on every node is gone.** The app catalog and the node disagreed about where Grafana's software comes from, so the node ignored the catalog's answer and logged a complaint roughly every 75 seconds, forever. The catalog was right — Grafana is served from the fleet's own registry, like Bitcoin Knots — and the node's records now agree with it.",
|
||||
"**The federation map became a real map.** The network view is now a 3D orbital scene of your federation — nodes as a point-cloud globe with calm motion, auto-fit centring, and a 2D top-down toggle that portrait and mobile screens use by default, with the scene filling the viewport instead of sitting in a letterbox. Inbound peer requests appear live on the map as blinking nodes you can accept or reject in place, and revisiting the view no longer replays the whole intro — the scene updates in place.",
|
||||
"**An app that's mid-restart shows a page that says so — and comes back by itself.** When an app's screen was briefly unreachable behind the gate, the browser got a bare error; it now gets a named page for that app that retries on its own until the app answers.",
|
||||
"Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release; the supervision migration and the cleanup re-enable were verified live on one node (both apps migrated and healthy, cleanup correctly idle)."
|
||||
"**Archipelago is now open source.** The full source code of the node you are running — the orchestrator, the dashboard, the app platform, the mesh, the release tooling — is published for anyone to read, build and audit at source.archipelago-foundation.org/lfg2025/archy. A node that holds your money, your files and your communications should not ask to be taken on faith: from this release onward you, or anyone you trust, can see exactly what it does and follow every change we make in the open.",
|
||||
"**Installing an update is reliable again, and tells you what happened when it isn't.** Some nodes could download an update but never apply it — the button stayed on \"Install\", and no amount of retrying worked. The cause: applying the update consumed the downloaded files as it went, so if any one step hit a snag partway through, the leftover files were incomplete and every later attempt failed the safety re-check forever, needing a technician to recover. Applying no longer consumes the download — a failed apply can always be retried from the same files — and the pieces are now applied in a fixed order with the program itself last, so a hiccup can't leave a half-swapped node. When an apply does fail, the screen now shows the real reason and what to do (\"download the update again\"), and offers Download again instead of a dead \"Install\" button, rather than a generic \"it failed\".",
|
||||
"**Video on the kiosk stops tearing.** The kiosk's display had no vertical sync at all, so fast motion — IndeedHub films especially — showed horizontal tearing lines. The display driver now syncs every frame to the panel (no extra hardware needed, existing kiosks pick it up with this update), and on machines with a GPU, video decoding moves off the CPU onto the video hardware — smoother playback that also leaves more headroom for audio, not less.",
|
||||
"**The Back button finally does what you expect.** Pressing Back — the mouse's side button on a kiosk, a swipe on a phone, the toolbar button in any browser — used to navigate the screen underneath an open window, or leave the dashboard entirely. Back now closes the topmost open window first, one per press, exactly like a native app; closing a window yourself never leaves a phantom entry that makes you press Back twice.",
|
||||
"**No more bare IP addresses in your update or app-registry settings.** The update mirrors and the app registry each listed the same server twice — once by its proper name, once as a raw `http://146…` address left over from before the domain existed. The raw-address entries are retired: new nodes never see them, and existing nodes clean them out of their saved lists automatically on the next read. Everything now goes through the named, TLS-protected origin — which was always the same machine.",
|
||||
"**The phone companion app downloads over the proper domain.** The download QR pointed at a raw address over plain HTTP; it now points at the same file on the https domain. Scanning it gets you an encrypted download from a named server.",
|
||||
"**The Receive window now tells you when the money is on its way.** Previously it showed a QR code and left you to check elsewhere whether anything happened. Now, the moment the sender's transaction is broadcast, the QR gives way to a clock: the amount, the transaction ID (tap to copy), and a note that the funds arrive on their own — with a single Done button. If you keep the window open, the clock becomes a green check at the first confirmation. Verified live on a real node: payment detected within seconds of broadcast."
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
"current_version": "1.7.129-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.7.129-alpha/archipelago",
|
||||
"current_version": "1.8.0-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.0-alpha/archipelago",
|
||||
"name": "archipelago",
|
||||
"new_version": "1.7.129-alpha",
|
||||
"sha256": "675e7dafc855d59b38c5a12d8b9405894677ed8701580227beca95ec2912e3f2",
|
||||
"size_bytes": 59531400
|
||||
"new_version": "1.8.0-alpha",
|
||||
"sha256": "fd99219ea11ffebc92b83154e1058911ebe72c357c80085310c78aba9714a6b5",
|
||||
"size_bytes": 59369392
|
||||
},
|
||||
{
|
||||
"current_version": "1.7.129-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.7.129-alpha/archipelago-frontend-1.7.129-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.7.129-alpha.tar.gz",
|
||||
"new_version": "1.7.129-alpha",
|
||||
"sha256": "53af2743308f4ae6a627255aaa288706d331d567c99e1cb615684dc3abba534d",
|
||||
"size_bytes": 95452033
|
||||
"current_version": "1.8.0-alpha",
|
||||
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.0-alpha/archipelago-frontend-1.8.0-alpha.tar.gz",
|
||||
"name": "archipelago-frontend-1.8.0-alpha.tar.gz",
|
||||
"new_version": "1.8.0-alpha",
|
||||
"sha256": "f6bbf7b3f78a00dd7051abef805c943cfa9a58f624e09e94b6bee6c9b2f22902",
|
||||
"size_bytes": 97608790
|
||||
}
|
||||
],
|
||||
"release_date": "2026-08-10",
|
||||
"signature": "902778710674486d5919e4abb1bf5540521c9ef55b50a44a9d64b750812738d51cc193f6675a9969b1c45ae86c2e9e44ab3d8daf8aa1d439799d7f7846e27d04",
|
||||
"release_date": "2026-08-12",
|
||||
"signature": "be3e3c6f8ed6b8d573a6329bf546ec1aa739214cd391272ce3b2f0a0ef6b10a4a4c4f9dfa4b9b32dad88326c9feff908fbdb3d1b86d055e5cf876f67f195c60b",
|
||||
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
|
||||
"version": "1.7.129-alpha"
|
||||
"version": "1.8.0-alpha"
|
||||
}
|
||||
|
||||
@@ -158,6 +158,16 @@ cd "$PROJECT_ROOT/neode-ui"
|
||||
npm run build 2>&1 | tail -3
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# npm run build wipes web/dist — fold AIUI straight back in. The OTA tarball
|
||||
# bakes it from demo/aiui independently, but build-iso-release.sh's
|
||||
# verify-artifacts guard checks web/dist/neode-ui/aiui and failed on two
|
||||
# consecutive releases (.127, .129) because this fold-in was manual.
|
||||
if [ -d "$PROJECT_ROOT/demo/aiui" ] && [ -f "$PROJECT_ROOT/demo/aiui/index.html" ]; then
|
||||
rm -rf "$PROJECT_ROOT/web/dist/neode-ui/aiui"
|
||||
cp -r "$PROJECT_ROOT/demo/aiui" "$PROJECT_ROOT/web/dist/neode-ui/aiui"
|
||||
echo " AIUI folded into web/dist from demo/aiui"
|
||||
fi
|
||||
|
||||
# npm run build can silently no-op (vue-tsc EACCES burned us before) — a stale
|
||||
# dist would ship with a perfectly valid sha256. Require the freshly built
|
||||
# bundle to embed the version we just bumped to before it gets packaged.
|
||||
@@ -264,12 +274,18 @@ fi
|
||||
echo "[7/8] Committing version bump..."
|
||||
git -C "$PROJECT_ROOT" add \
|
||||
core/archipelago/Cargo.toml \
|
||||
core/Cargo.lock \
|
||||
neode-ui/package.json \
|
||||
neode-ui/package-lock.json \
|
||||
neode-ui/public/catalog.json \
|
||||
CHANGELOG.md \
|
||||
releases/manifest.json \
|
||||
release-manifest.json \
|
||||
2>/dev/null || true
|
||||
# Cargo.lock (rewritten by the release build after the version bump) and
|
||||
# neode-ui/public/catalog.json (regenerated by the frontend build) belong in
|
||||
# THIS commit: leaving them dirty failed build-iso-release.sh's clean-tree
|
||||
# preflight on three consecutive releases (.127-.129, 2026-08-09/10).
|
||||
|
||||
git -C "$PROJECT_ROOT" commit -m "chore: release v${VERSION}"
|
||||
|
||||
|
||||
@@ -85,6 +85,20 @@ echo "publish-companion-apk: verified v1 + v2 + v3 signatures." >&2
|
||||
mkdir -p "$(dirname "$DEST")"
|
||||
cp "$SIGNED" "$DEST"
|
||||
|
||||
# Version metadata beside the APK: the dashboard's companion overlay reads
|
||||
# this to show which version the Download button installs. Extracted from
|
||||
# the gradle config that just built the APK, so it can never drift from it.
|
||||
META="${DEST%.apk}.json"
|
||||
V_NAME="$(sed -n 's/^[[:space:]]*versionName = "\(.*\)"/\1/p' Android/app/build.gradle.kts | head -1)"
|
||||
V_CODE="$(sed -n 's/^[[:space:]]*versionCode = \([0-9]*\).*/\1/p' Android/app/build.gradle.kts | head -1)"
|
||||
if [ -n "$V_NAME" ] && [ -n "$V_CODE" ]; then
|
||||
printf '{\n "versionName": "%s",\n "versionCode": %s\n}\n' "$V_NAME" "$V_CODE" > "$META"
|
||||
git add "$META"
|
||||
echo "publish-companion-apk: staged $META (v$V_NAME, build $V_CODE)" >&2
|
||||
else
|
||||
echo "publish-companion-apk: WARNING could not extract version from build.gradle.kts — $META not updated" >&2
|
||||
fi
|
||||
|
||||
# Drop the legacy zipped artifact so the served download is the raw APK only.
|
||||
if [ -f "$OLD_ZIP" ]; then
|
||||
git rm -q --ignore-unmatch "$OLD_ZIP" 2>/dev/null || rm -f "$OLD_ZIP"
|
||||
|
||||
Reference in New Issue
Block a user