Compare commits

...
Author SHA1 Message Date
lfg2025 d045f0b499 Merge pull request 'feat(companion): party app-sharing, intro Mesh Party button, kiosk session retention — 0.5.8' (#116) from feat/party-share-intro into main
Demo images / Build & push demo images (push) Successful in 2m44s
2026-07-24 00:20:47 +00:00
DorianandClaude Fable 5 1e20b79c3c feat(companion): kiosk session survives remote ⇄ dashboard navigation
The WEB_VIEW route recreated the WebView on every return from the
remote screen — full reload + re-login every time. The kiosk WebView is
now retained in a holder and reattached live (no race, no reload);
clients/bridges/listeners are re-bound to the new composition on
reattach, and the instance is dropped on retry, disconnect, or server
change so errored/stale sessions still reload for real.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 01:19:57 +01:00
DorianandClaude Fable 5 1bcf6ccadb feat(companion): share-the-app from Mesh Party + party entry on the intro screen — 0.5.8
- Party screen 'Share this app': shares this install's own APK through
  the system sheet (FileProvider over cache/share/), so a nearby friend
  gets the companion via Quick Share/Bluetooth with zero internet — the
  party-mode premise end to end.
- Intro screen: Mesh Party button under Get Started — party works with
  no node at all, so it belongs on the first screen a fresh install
  shows.
- Served APK: 0.5.8 (vc28), ready for the QR/OTA/ISO cut.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 01:16:54 +01:00
lfg2025 9f6294d169 Merge pull request 'feat(companion): mesh party + Flare, keep-warm mesh, restored fast-start, UI fixes — 0.5.7' (#115) from feat/mesh-party-flare-perf into main
Demo images / Build & push demo images (push) Successful in 2m50s
2026-07-24 00:08:13 +00:00
Dorian a0f688b522 Merge main into mesh-party branch (dev-box app-ports work)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

# Conflicts:
#	Android/app/build.gradle.kts
#	Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt
#	neode-ui/public/packages/archipelago-companion.apk
2026-07-24 01:08:07 +01:00
DorianandClaude Fable 5 f0926ece94 feat(companion): mesh party + Flare, keep-warm mesh, restored fast-start, UI fixes — 0.5.7
Combined session state from both agents (shared checkout):

- Mesh Party / Flare (experimental): phone-to-phone FIPS via party QR,
  Flare chat/beam on :5680, optional fixed UDP listen (2121); party-only
  phones get the baked-in Archipelago anchor so two bare phones reach
  each other across the internet, not just on a hotspot.
- Keep-warm mesh: a running healthy node survives app opens (restart
  only when dead or peers changed — peersDirty); requestMeshRestart for
  config changes. Cold start measured on-device: session in ~3.1s.
- Restored after regression: startup LAN-vs-mesh race (the party work
  was based on a pre-#114 file copy) and same-node routing (BTCPay/
  Pine/HA had returned to opening in the external browser).
- UI: top status-bar scrim removed (bottom-only bars); web app-session
  mobile bar is solid black — app theme colour no longer bleeds through
  the bar and its safe-area strip.

Served APK: 0.5.7 (vc27).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 01:04:36 +01:00
archipelago b28e5f2ef6 Merge remote-tracking branch 'gitea-ai/main' 2026-07-23 19:13:20 -04:00
archipelagoandClaude Fable 5 3037e9808e docs(handoff): app direct ports over mesh — DONE, mesh-verified
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 19:12:46 -04:00
lfg2025 881b9ee0bf Merge pull request 'feat(companion): instant off-LAN startup + same-node apps stay in-app' (#114) from feat/fast-start-same-node into main
Demo images / Build & push demo images (push) Successful in 3m2s
2026-07-23 23:12:15 +00:00
DorianandClaude Fable 5 5ba3c161c4 feat(companion): instant off-LAN startup + same-node apps stay in-app
Two field reports from tonight's 5G session:

- 'Stuck connecting': the kiosk WebView always loaded the LAN URL first,
  and Chromium burns a minute+ of connect retries on a dead LAN IP before
  the mesh fallback fires. WebViewScreen now races a raw TCP probe — LAN
  gets 2.5s, else the mesh ULA (patient, 12s) — BEFORE creating the
  WebView, so startup lands on the right origin in seconds either side.
  Retry re-races instead of blindly reloading the LAN URL.
- Pine/Home Assistant/BTCPay opened in the external browser over the
  mesh: same-node detection compared one host, but the node has two (LAN
  origin + mesh ULA) — kiosk loaded via the ULA meant app links failed
  isSameHost and routeOutbound bounced them to the browser. Same-node now
  matches either address, in the kiosk router, SSL allowances and the
  InAppBrowser.

Served APK: 0.5.6 (vc26). Note: the binary also carries the in-flight
listen_port groundwork from the dev-box session's archy-fips-core WIP
(compiles clean, default posture unchanged); its source lands separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 00:11:33 +01:00
archipelagoandClaude Fable 5 2ad57c63f1 feat(mesh): app direct ports reachable over the mesh — IPv4 listeners mirrored onto [::]
The companion reaches the node at its fips0 ULA, and the web UI builds app
links as http://[<ULA>]:<direct port> — but rootless-podman published ports
bind 0.0.0.0 only, so every app URL was refused over the mesh (:8334 first).
A reconcile loop in the backend mirrors public IPv4 listeners: any port
>=1024 on 0.0.0.0 without an IPv6 any-listener gets a v6-ONLY [::] forwarder
to 127.0.0.1, following /proc/net/tcp* so install/remove and hardcoded
companion ports are covered without touching a single container. Purely
additive: IPv4/LAN/Tor access paths are untouched, v6only listeners cannot
collide with or intercept v4 traffic, and foreign IPv6 listeners win.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 19:09:14 -04:00
lfg2025 6c48de20e3 Merge pull request 'docs(handoff): app direct ports are IPv4-only over the mesh — node-side task' (#113) from docs/mesh-app-ports-v2 into main 2026-07-23 22:56:40 +00:00
DorianandClaude Fable 5 e17de63016 docs(handoff): app direct ports are IPv4-only over the mesh — node-side task
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 23:55:43 +01:00
archipelagoandClaude Fable 5 6ba4540c53 Merge vc25 (PR #111) — handoff reconciled: vps2 daemon restart resolved the mesh path; private-tree withdrawn
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 18:42:15 -04:00
archipelagoandClaude Fable 5 5454bed038 docs(handoff): RESOLVED — vps2 daemon degradation was the mesh blocker; privatization withdrawn
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 18:40:51 -04:00
lfg2025 128b78d083 Merge pull request 'feat(companion): 60s mesh probe window + session pre-warm from the VPN service' (#111) from feat/mesh-probe-window-prewarm into main
Demo images / Build & push demo images (push) Successful in 2m47s
2026-07-23 22:36:45 +00:00
DorianandClaude Fable 5 75ecd1d3ea feat(companion): 60s mesh probe window + session pre-warm from the VPN service
Implements the app-side recommendations from the node diagnosis
(HANDOFF-2026-07-23): first session through the public tree takes 15s+
when it works at all, far beyond the old ~8s probe window.

- connect(): mesh ULA probed inside a 60s budget, 15s per-phase timeouts.
- ArchyVpnService: session warmer probes every saved node ULA as soon as
  the tunnel is up (5s cadence first minute, then 60s keep-warm) so the
  UI hits a warm session and it never idles out.
- Served APK: 0.5.5 (vc25); handoff updated with 25-ping evidence that
  public-tree discovery currently fails outright (supports the
  private-tree infra decision).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 23:36:34 +01:00
archipelagoandClaude Fable 5 82fcccd113 docs(handoff): node-side mesh diagnosis — nginx v6 fixed, real blocker is session path quality
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 18:25:18 -04:00
archipelagoandClaude Fable 5 1e89362e71 fix(nginx): IPv6 listeners — companion mesh HTTP to the node's ULA never connected
Phones on the FIPS mesh reach the node at http://[<fips0 ULA>], but every
shipped nginx config listened on IPv4 only — nothing answered [::]:80/443,
so mesh HTTP failed with the tunnel fully healthy (found live on
framework-pt during the vc24 5G session, 2026-07-23). Canonical conf gains
listen [::] lines and patch_nginx_conf self-heals deployed nodes (covers
the sites-enabled -> archipelago-http dev variant via the existing
canonicalize pass); validated by the existing nginx -t + rollback flow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:57:58 -04:00
lfg2025 908e6185c8 Merge pull request 'fix(companion): TUN reader no longer dies on non-blocking fd + VPN not metered' (#110) from fix/tun-blocking-fd-metered into main
Demo images / Build & push demo images (push) Successful in 2m54s
2026-07-23 21:51:11 +00:00
DorianandClaude Fable 5 0064cfccac chore(companion): lockfile for libc dep
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 22:48:42 +01:00
DorianandClaude Fable 5 53037b2b71 fix(companion): TUN reader no longer dies on non-blocking fd + VPN not metered
On-device diagnosis (phone on adb, 5G): mesh sessions established but no
packet ever entered the tunnel — Android hands the VpnService TUN fd over
non-blocking, and the fips fork's dedicated blocking-read thread treats
EAGAIN as fatal, dying at startup. archy-fips-core now forces the fd into
blocking mode before Node::start_with_tun_fd. Verified on-device: reader
survives, packets flow into the mesh, and the 30s anchor-link flap is
gone (stable 8+ min on 5G).

Also setMetered(false): Android 10+ defaults VPNs to metered, flipping
the phone into data-restricted behaviour whenever the mesh is up.

Served APK: 0.5.4 (vc24). Handoff updated with the remaining node-side
blocker: phone->anchor works, phone->node ULA gets no reply — needs
fipsctl introspection on Framework PT (suspect fork/daemon session or
routing mismatch).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 22:47:26 +01:00
lfg2025 db2cafc657 Merge pull request 'fix(companion): guarantee dual-path peering — LAN p2p + public anchor, always' (#109) from fix/guaranteed-anchor-peering into main
Demo images / Build & push demo images (push) Successful in 2m56s
2026-07-23 21:18:00 +00:00
DorianandClaude Fable 5 e49af6ff40 fix(companion): guarantee dual-path peering — LAN p2p + public anchor, always
The whole point of FIPS: the phone peers with the node's npub using the
LAN endpoint as a direct p2p dial hint AND rendezvouses via a public
anchor when away — neither LAN nor 5G location may matter. The anchor
half only existed when the scanned QR carried fanchors, so pairing
against an older node left the phone LAN-only and everything died on 5G.

The Archipelago vps2 anchor (npub + 146.59.87.168:8444/tcp, lockstep
with core fips/anchors.rs) is now baked into upsertNodePeer as a
guaranteed peer. Mesh connect retry window widened so a first-ever
pairing survives the VPN consent dialog. Served APK: 0.5.3 (vc23).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 22:17:24 +01:00
lfg2025 a865306488 Merge pull request 'fix(companion): mesh VPN no longer blocks IPv4 + off-LAN connect via mesh ULA' (#108) from fix/mesh-vpn-and-offlan-connect into main
Demo images / Build & push demo images (push) Successful in 2m54s
2026-07-23 21:00:39 +00:00
DorianandClaude Fable 5 979113c598 fix(companion): mesh VPN no longer blocks IPv4 + off-LAN connect via mesh ULA
Field test on 5G (Framework PT): pairing scanned fine but connect
hard-failed on the scanned LAN IP, and enabling the mesh VPN killed the
phone's internet.

- ArchyVpnService: the TUN is IPv6-only, and Android blocks every
  address family the VPN holds no address for — allowFamily(AF_INET)
  (+ allowBypass) so normal traffic flows while only fd00::/8 is routed.
- ServerConnectScreen.connect(): when the LAN address doesn't answer and
  the entry carries a mesh ULA, bring the tunnel up (autoStartIfReady)
  and probe the ULA with retries before reporting failure — the scanned
  IP is a dial hint, the npub/ULA is the identity (npub-first contract).
- Served companion APK refreshed as 0.5.2 (vc22); handoff doc updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 22:00:15 +01:00
lfg2025 420f756947 Merge pull request 'chore(android): companion 0.5.1 (vc21) — refresh served APK + deploy handoff' (#107) from chore/companion-apk-0.5.1 into main
Demo images / Build & push demo images (push) Successful in 2m57s
2026-07-23 20:55:28 +00:00
DorianandClaude Fable 5 35849c88c6 chore(android): companion 0.5.1 (vc21) — refresh served APK + deploy handoff
The APK served from the pairing QR predated today's scanner fixes, so
the download-then-scan flow still hit the laggy scanner. Bumps the
version so phones update in place, refreshes the served artifact
(signed v1+v2+v3, verified), and adds the archi-dev-box deploy handoff.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 21:53:29 +01:00
lfg2025 08927b88a8 Merge pull request 'feat(companion): saved servers keyed by node npub — pairing contract item 1' (#106) from feat/npub-keyed-server-entries into main 2026-07-23 20:47:52 +00:00
archipelago 0b7a1b84e4 Merge remote-tracking branch 'gitea-ai/main'
Demo images / Build & push demo images (push) Successful in 2m51s
2026-07-23 16:47:13 -04:00
DorianandClaude Fable 5 bb4166954a feat(companion): saved servers keyed by node npub — pairing contract item 1
Re-scanning a node after a LAN renumber updated the FIPS peer (npub-
matched) but duplicated the saved-server entry (address-matched).
ServerEntry now carries the node's fnpub as a trailing serialization
field (legacy entries still parse) and every saved/active-entry match
goes through sameNode: npub identity first, address/port/scheme only as
the LAN-only fallback. Edit forms that don't carry the npub keep the
stored one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 21:47:10 +01:00
archipelagoandClaude Fable 5 ed2c14c88a fix(companion): pairing QR scannable again — cap anchors at 2, EC level L, 768px source
Three npub-bearing anchors pushed the pairing URI to ~600 chars (QR ~v23 at
EC-M) — phone cameras couldn't lock onto it off a screen. The phone only
needs the node itself (LAN p2p) plus one public rendezvous (vps2 preferred),
and screen scans don't need EC-M's damage tolerance — L drops a full
version tier.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:46:51 -04:00
lfg2025 42e98d6a86 Merge pull request 'fix(ci): demo-images path filter watched .github, file lives in .gitea' (#105) from fix/demo-images-path-filter into main
Demo images / Build & push demo images (push) Successful in 3m4s
2026-07-23 20:30:13 +00:00
35 changed files with 2242 additions and 87 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "com.archipelago.app"
minSdk = 26
targetSdk = 35
versionCode = 20
versionName = "0.5.0"
versionCode = 28
versionName = "0.5.8"
vectorDrawables {
useSupportLibrary = true
+12
View File
@@ -23,6 +23,18 @@
android:usesCleartextTraffic="true"
tools:targetApi="35">
<!-- Party-screen "Share this app": exposes the copied APK from
cache/share/ to the system share sheet, nothing else. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<activity
android:name=".MainActivity"
android:exported="true"
@@ -21,6 +21,10 @@ data class ServerEntry(
val name: String = "",
/** Node's FIPS mesh ULA (IPv6) — reachable from anywhere once meshed. */
val meshIp: String = "",
/** Node's FIPS npub — the durable identity. When present it, not the
* address, is what identifies the entry: FIPS peers on npubs, IPs are
* only dial hints (docs/companion-pairing-qr.md, npub-first contract). */
val npub: String = "",
) {
/** Label to show in lists — the user-given name, or the address if unnamed. */
fun displayName(): String = name.ifBlank { address }
@@ -45,9 +49,15 @@ data class ServerEntry(
fun toMeshUrl(): String? =
meshIp.takeIf { it.isNotBlank() }?.let { "http://${urlHost(it)}" }
// name/meshIp are trailing fields so entries saved before they existed
// (4/5 fields) still deserialize, defaulting to "".
fun serialize(): String = "$address|$useHttps|$port|$password|$name|$meshIp"
// name/meshIp/npub are trailing fields so entries saved before they
// existed (4/5/6 fields) still deserialize, defaulting to "".
fun serialize(): String = "$address|$useHttps|$port|$password|$name|$meshIp|$npub"
/** Same node as [other]? npub identity wins; address/port/scheme is the
* fallback for LAN-only entries that never advertised FIPS. */
fun sameNode(other: ServerEntry): Boolean =
(npub.isNotBlank() && npub == other.npub) ||
(address == other.address && port == other.port && useHttps == other.useHttps)
companion object {
fun deserialize(raw: String): ServerEntry? {
@@ -60,6 +70,7 @@ data class ServerEntry(
password = parts.getOrElse(3) { "" },
name = parts.getOrElse(4) { "" },
meshIp = parts.getOrElse(5) { "" },
npub = parts.getOrElse(6) { "" },
)
}
}
@@ -73,6 +84,7 @@ class ServerPreferences(private val context: Context) {
private val activePasswordKey = stringPreferencesKey("active_password")
private val activeNameKey = stringPreferencesKey("active_name")
private val activeMeshIpKey = stringPreferencesKey("active_mesh_ip")
private val activeNpubKey = stringPreferencesKey("active_npub")
private val savedServersKey = stringSetPreferencesKey("saved_servers")
private val introSeenKey = booleanPreferencesKey("intro_seen")
private val gestureHintSeenKey = booleanPreferencesKey("gesture_hint_seen")
@@ -86,6 +98,7 @@ class ServerPreferences(private val context: Context) {
password = prefs[activePasswordKey] ?: "",
name = prefs[activeNameKey] ?: "",
meshIp = prefs[activeMeshIpKey] ?: "",
npub = prefs[activeNpubKey] ?: "",
)
}
@@ -111,6 +124,7 @@ class ServerPreferences(private val context: Context) {
prefs[activePasswordKey] = server.password
prefs[activeNameKey] = server.name
prefs[activeMeshIpKey] = server.meshIp
prefs[activeNpubKey] = server.npub
}
addSavedServer(server)
}
@@ -123,6 +137,7 @@ class ServerPreferences(private val context: Context) {
prefs.remove(activePasswordKey)
prefs.remove(activeNameKey)
prefs.remove(activeMeshIpKey)
prefs.remove(activeNpubKey)
}
}
@@ -134,65 +149,66 @@ class ServerPreferences(private val context: Context) {
}
/**
* Replace a saved server in place. Matches the existing entry by connection
* identity (address/port/scheme) so edits that change the name or password —
* or that touch a legacy 4-field entry — still update the right record. If the
* edited server is also the active one, the active record is kept in sync.
* Replace a saved server in place. Matches the existing entry by node
* identity — npub first, address/port/scheme as the LAN-only fallback
* (ServerEntry.sameNode) — so edits that change the name, password or even
* every address still update the right record. An edit form that doesn't
* carry the npub keeps the stored one. If the edited server is also the
* active one, the active record is kept in sync.
*/
suspend fun updateSavedServer(original: ServerEntry, updated: ServerEntry) {
val toStore = updated.copy(npub = updated.npub.ifBlank { original.npub })
context.dataStore.edit { prefs ->
val current = prefs[savedServersKey] ?: emptySet()
val filtered = current.filterNot { raw ->
val e = ServerEntry.deserialize(raw)
e != null &&
e.address == original.address &&
e.port == original.port &&
e.useHttps == original.useHttps
ServerEntry.deserialize(raw)?.sameNode(original) == true
}.toSet()
prefs[savedServersKey] = filtered + updated.serialize()
prefs[savedServersKey] = filtered + toStore.serialize()
val isActive = prefs[activeAddressKey] == original.address &&
(prefs[activePortKey] ?: "") == original.port &&
(prefs[activeHttpsKey] ?: false) == original.useHttps
val activeNpub = prefs[activeNpubKey] ?: ""
val isActive = (activeNpub.isNotBlank() && activeNpub == original.npub) ||
(
prefs[activeAddressKey] == original.address &&
(prefs[activePortKey] ?: "") == original.port &&
(prefs[activeHttpsKey] ?: false) == original.useHttps
)
if (isActive) {
prefs[activeAddressKey] = updated.address
prefs[activeHttpsKey] = updated.useHttps
prefs[activePortKey] = updated.port
prefs[activePasswordKey] = updated.password
prefs[activeNameKey] = updated.name
prefs[activeMeshIpKey] = updated.meshIp
prefs[activeAddressKey] = toStore.address
prefs[activeHttpsKey] = toStore.useHttps
prefs[activePortKey] = toStore.port
prefs[activePasswordKey] = toStore.password
prefs[activeNameKey] = toStore.name
prefs[activeMeshIpKey] = toStore.meshIp
prefs[activeNpubKey] = toStore.npub
}
}
}
/**
* Add a server, or update the entry with the same connection identity
* (address/port/scheme) — used by QR pairing so re-scanning a node never
* duplicates it. A blank incoming password/name keeps the stored value
* (a real node's QR never carries the password). Returns the merged entry.
* Add a server, or update the entry for the same node — npub first,
* address/port/scheme as the LAN-only fallback (ServerEntry.sameNode) —
* used by QR pairing so re-scanning a node never duplicates it, even after
* the LAN renumbered and every address changed (npub-first contract in
* docs/companion-pairing-qr.md). A blank incoming password/name keeps the
* stored value (a real node's QR never carries the password). Returns the
* merged entry.
*/
suspend fun upsertServer(server: ServerEntry): ServerEntry {
var merged = server
context.dataStore.edit { prefs ->
val current = prefs[savedServersKey] ?: emptySet()
val existing = current.mapNotNull { ServerEntry.deserialize(it) }.firstOrNull {
it.address == server.address &&
it.port == server.port &&
it.useHttps == server.useHttps
}
val existing = current.mapNotNull { ServerEntry.deserialize(it) }
.firstOrNull { it.sameNode(server) }
if (existing != null) {
merged = server.copy(
password = server.password.ifBlank { existing.password },
name = server.name.ifBlank { existing.name },
meshIp = server.meshIp.ifBlank { existing.meshIp },
npub = server.npub.ifBlank { existing.npub },
)
}
val filtered = current.filterNot { raw ->
val e = ServerEntry.deserialize(raw)
e != null &&
e.address == server.address &&
e.port == server.port &&
e.useHttps == server.useHttps
ServerEntry.deserialize(raw)?.sameNode(merged) == true
}.toSet()
prefs[savedServersKey] = filtered + merged.serialize()
}
@@ -202,15 +218,11 @@ class ServerPreferences(private val context: Context) {
suspend fun removeSavedServer(server: ServerEntry) {
context.dataStore.edit { prefs ->
val current = prefs[savedServersKey] ?: emptySet()
// Match by connection identity (address/port/scheme) rather than the
// exact serialized string, so a rename — or the legacy 4-field format
// saved before names existed — still removes the right entry.
// Match by node identity (npub, else address/port/scheme) rather
// than the exact serialized string, so a rename — or a legacy
// short-format entry — still removes the right record.
prefs[savedServersKey] = current.filterNot { raw ->
val e = ServerEntry.deserialize(raw)
e != null &&
e.address == server.address &&
e.port == server.port &&
e.useHttps == server.useHttps
ServerEntry.deserialize(raw)?.sameNode(server) == true
}.toSet()
}
}
@@ -78,6 +78,9 @@ object ServerQrParser {
password = credential,
name = uri.getQueryParameter("name") ?: "",
meshIp = fips?.ula ?: "",
// npub is the durable identity — saved-server upserts match on
// it, so re-scanning after a LAN renumber updates in place.
npub = fips?.npub ?: "",
),
fips = fips,
)
@@ -10,10 +10,15 @@ import android.os.Build
import android.util.Log
import com.archipelago.app.MainActivity
import com.archipelago.app.R
import com.archipelago.app.data.ServerPreferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
@@ -27,6 +32,7 @@ import kotlinx.coroutines.launch
class ArchyVpnService : VpnService() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var warmerJob: Job? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) {
@@ -45,13 +51,27 @@ class ArchyVpnService : VpnService() {
}
val prefs = FipsPreferences(this)
val identity = FipsManager.ensureIdentity(prefs)
val peersJson = prefs.peersJson()
val peersJson = prefs.combinedPeersJson()
if (identity == null || peersJson == "[]") {
Log.w(TAG, "mesh not configured — stopping")
shutdown()
return
}
// Party mode: fixed inbound UDP bind so a nearby phone can dial us
// directly over a shared LAN/hotspot (no internet required).
val listenPort = if (prefs.partyListen()) PartyQr.PARTY_UDP_PORT else 0
// A fresh app open re-triggers the service. Tearing a HEALTHY mesh
// down to rebuild it costs ~8s of anchor+session bring-up on every
// launch (observed live: stop 00:34:38 → session back 00:34:49) and
// is what made "freshly loading the app" slow. Keep a running node;
// restart only when it's dead or a pairing changed the peer set.
if (FipsNative.isRunning() && !FipsManager.peersDirty) {
Log.i(TAG, "mesh already running — keeping warm sessions")
startSessionWarmer()
return
}
FipsManager.peersDirty = false
// Re-establishing while running would strand the old fd; restart clean.
if (FipsNative.isRunning()) FipsNative.stop()
@@ -61,6 +81,21 @@ class ArchyVpnService : VpnService() {
.setMtu(1280)
.addAddress(identity.address, 128)
.addRoute("fd00::", 8)
// The TUN is IPv6-only. Android blocks every address family
// the VPN has no address for — without this, bringing the
// mesh up cut ALL of the phone's IPv4 internet.
.allowFamily(android.system.OsConstants.AF_INET)
// And let apps that bind their own network skip the TUN
// entirely — this is mesh reachability, not a privacy VPN.
.allowBypass()
.apply {
// Android 10+ treats VPN networks as METERED by default,
// which flips the whole phone into data-saver behaviour
// (background sync off, "metered" warnings) while the
// mesh is up. It inherits the underlying network's real
// metered state instead.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) setMetered(false)
}
.establish()
} catch (e: Exception) {
Log.e(TAG, "VPN establish failed", e)
@@ -72,12 +107,67 @@ class ArchyVpnService : VpnService() {
}
val fd = pfd.detachFd()
val result = FipsNative.start(identity.secret, peersJson, fd)
Log.i(TAG, "mesh start: $result")
if (result.contains("\"error\"")) shutdown()
val result = FipsNative.start(identity.secret, peersJson, fd, listenPort)
Log.i(TAG, "mesh start: $result (listen=$listenPort)")
if (result.contains("\"error\"")) {
shutdown()
} else {
startSessionWarmer()
// Phone-to-phone chat/beam + the phone's own mesh-served page.
FlareServer.start(this, identity.address, identity.npub, prefs.partyName())
}
}
/**
* Pre-warm + keep-warm mesh sessions to every known node ULA.
*
* Discovery + first session through the public tree can take 15s+
* (HANDOFF-2026-07-23 node diagnosis) — paying that cost here, the
* moment the tunnel is up, means the connect probe and WebView hit an
* established session instead of timing out on a cold one. The periodic
* touch afterwards keeps the session from idling out. Failed connects
* are expected and cheap; the attempt itself is what drives discovery.
*/
private fun startSessionWarmer() {
warmerJob?.cancel()
warmerJob = scope.launch {
val prefs = ServerPreferences(this@ArchyVpnService)
val fipsPrefs = FipsPreferences(this@ArchyVpnService)
var round = 0
while (isActive && FipsNative.isRunning()) {
val targets = try {
prefs.savedServers.first()
.mapNotNull { it.meshIp.ifBlank { null } }
.map { it to 80 } +
// Party phones answer on the flare port, not :80.
fipsPrefs.partyPeers().map { it.ula to PartyQr.FLARE_PORT }
} catch (_: Exception) {
emptyList()
}.distinct()
for ((ula, port) in targets) {
try {
java.net.Socket().use { s ->
s.connect(
java.net.InetSocketAddress(java.net.InetAddress.getByName(ula), port),
20_000,
)
}
} catch (_: Exception) {
// Cold path / node away — the connect attempt still
// drove session establishment; try again next round.
}
}
round++
// Aggressive for the first ~minute (session bring-up), then a
// slow keep-warm tick that costs nearly nothing.
delay(if (round < 12) 5_000 else 60_000)
}
}
}
private fun shutdown() {
warmerJob?.cancel()
FlareServer.stop()
FipsNative.stop()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
@@ -21,6 +21,12 @@ object FipsManager {
private val _consentNeeded = MutableStateFlow(false)
val consentNeeded: StateFlow<Boolean> = _consentNeeded
/** True after a pairing changed the peer set while the node was running —
* tells the service a restart is genuinely needed (the ONLY case; a
* routine app open must keep the warm mesh, not rebuild it). */
@Volatile
var peersDirty: Boolean = false
fun consentHandled() {
_consentNeeded.value = false
}
@@ -34,6 +40,7 @@ object FipsManager {
val prefs = FipsPreferences(context)
ensureIdentity(prefs)
prefs.upsertNodePeer(info, alias)
peersDirty = true
_consentNeeded.value = true
}
@@ -64,6 +71,22 @@ object FipsManager {
context.startForegroundService(intent)
}
/**
* Re-run the mesh with current prefs (party listen toggled, peer added).
* Marks the peer set dirty so startMesh genuinely restarts the node —
* otherwise the keep-warm fast path would skip the new config.
* First-timers go through the consent flow.
*/
fun requestMeshRestart(context: Context) {
if (!FipsNative.available) return
peersDirty = true
if (VpnService.prepare(context) == null) {
startService(context)
} else {
_consentNeeded.value = true
}
}
fun stopService(context: Context) {
val intent = Intent(context, ArchyVpnService::class.java)
.setAction(ArchyVpnService.ACTION_STOP)
@@ -21,7 +21,13 @@ object FipsNative {
external fun generateIdentity(): String
external fun deriveIdentity(secret: String): String
external fun start(secret: String, peersJson: String, tunFd: Int): String
/**
* [listenPort] 0 = outbound-only (default posture). Non-zero binds UDP on
* that port so a nearby phone can dial us directly (party mode); the node
* stays leaf-only either way.
*/
external fun start(secret: String, peersJson: String, tunFd: Int, listenPort: Int): String
external fun stop()
external fun isRunning(): Boolean
external fun statusJson(): String
@@ -3,15 +3,28 @@ package com.archipelago.app.fips
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import org.json.JSONArray
import org.json.JSONObject
import androidx.datastore.preferences.preferencesDataStore
private val Context.fipsDataStore: DataStore<Preferences> by preferencesDataStore(name = "fips_prefs")
// Archipelago-operated public anchor (vps2). Baked in so EVERY pairing yields
// both paths — direct LAN p2p to the node AND a public rendezvous for
// away-from-home — even when the scanned node is old enough that its QR
// carries no fanchors. Keep in lockstep with
// core/archipelago/src/fips/anchors.rs (ARCHY_ANCHOR_*).
internal const val ARCHY_ANCHOR_NPUB =
"npub1dptaktwxv0mm245g2lqjykwm5ll0jpc6m3r4242ydfa9z7qe6urs3jvrak"
internal const val ARCHY_ANCHOR_ADDR = "146.59.87.168:8444"
internal const val ARCHY_ANCHOR_TRANSPORT = "tcp"
/**
* Mesh identity + known node peers. Follows the same plaintext-DataStore
* storage model as ServerPreferences (the server password lives there the
@@ -24,6 +37,12 @@ class FipsPreferences(private val context: Context) {
private val addressKey = stringPreferencesKey("fips_address")
/** JSON array of node peers in fips PeerConfig shape (see NodePeer). */
private val peersKey = stringPreferencesKey("fips_node_peers")
/** JSON array of phone party peers (PartyPeer shape, NOT PeerConfig). */
private val partyPeersKey = stringPreferencesKey("fips_party_peers")
/** Party mode: accept a direct inbound mesh link (UDP 2121). */
private val partyListenKey = booleanPreferencesKey("fips_party_listen")
/** Name shown in this phone's party QR and outgoing flares. */
private val partyNameKey = stringPreferencesKey("fips_party_name")
suspend fun identity(): FipsNative.Identity? {
val prefs = context.fipsDataStore.data.first()
@@ -50,6 +69,126 @@ class FipsPreferences(private val context: Context) {
suspend fun hasPeers(): Boolean = JSONArray(peersJson()).length() > 0
// ── Mesh Party (phone↔phone) ────────────────────────────────────────────
suspend fun partyListen(): Boolean =
context.fipsDataStore.data.first()[partyListenKey] ?: false
val partyListenFlow: Flow<Boolean>
get() = context.fipsDataStore.data.map { it[partyListenKey] ?: false }
suspend fun setPartyListen(enabled: Boolean) {
context.fipsDataStore.edit { it[partyListenKey] = enabled }
}
suspend fun partyName(): String =
context.fipsDataStore.data.first()[partyNameKey]
?: android.os.Build.MODEL.orEmpty().ifBlank { "Phone" }
suspend fun setPartyName(name: String) {
context.fipsDataStore.edit { it[partyNameKey] = name.trim() }
}
val partyPeersFlow: Flow<List<PartyPeer>>
get() = context.fipsDataStore.data.map { parsePartyPeers(it[partyPeersKey] ?: "[]") }
suspend fun partyPeers(): List<PartyPeer> =
parsePartyPeers(context.fipsDataStore.data.first()[partyPeersKey] ?: "[]")
/** Matched by npub, so re-scanning updates the direct-dial address in place. */
suspend fun upsertPartyPeer(peer: PartyPeer) {
context.fipsDataStore.edit { prefs ->
val kept = parsePartyPeers(prefs[partyPeersKey] ?: "[]").filter { it.npub != peer.npub }
prefs[partyPeersKey] = toJson(kept + peer)
}
}
suspend fun removePartyPeer(npub: String) {
context.fipsDataStore.edit { prefs ->
val kept = parsePartyPeers(prefs[partyPeersKey] ?: "[]").filter { it.npub != npub }
prefs[partyPeersKey] = toJson(kept)
}
}
/**
* Node peers + direct-dial party peers, in the fips PeerConfig JSON the
* Rust node deserializes. Party peers get the best priority: on a shared
* LAN/hotspot the direct link beats every anchor path, and while off-LAN
* the failed dial is cheap (auto-reconnect keeps retrying, which is
* exactly what makes the link snap up the moment both phones share WiFi).
* Party peers without an underlay address are mesh-routed and need no
* entry here at all.
*/
suspend fun combinedPeersJson(): String {
val merged = JSONArray(peersJson())
val party = partyPeers()
for (peer in party) {
if (peer.ip.isBlank() || peer.port <= 0) continue
merged.put(JSONObject().apply {
put("npub", peer.npub)
put("alias", peer.name.ifBlank { "Party phone" })
put("addresses", JSONArray().put(JSONObject().apply {
put("transport", "udp")
put("addr", "${peer.ip}:${peer.port}")
put("priority", 5)
}))
})
}
// A party-only phone (never paired with a node) still needs a public
// rendezvous to reach its peers ACROSS the internet — without it, two
// bare phones would be hotspot/LAN-only. Node pairing normally bakes
// this anchor in; do the same when there are party peers.
if (party.isNotEmpty() &&
(0 until merged.length()).none {
merged.optJSONObject(it)?.optString("npub") == ARCHY_ANCHOR_NPUB
}
) {
merged.put(JSONObject().apply {
put("npub", ARCHY_ANCHOR_NPUB)
put("alias", "Archipelago anchor")
put("addresses", JSONArray().put(JSONObject().apply {
put("transport", ARCHY_ANCHOR_TRANSPORT)
put("addr", ARCHY_ANCHOR_ADDR)
put("priority", 40)
}))
})
}
return merged.toString()
}
private fun parsePartyPeers(json: String): List<PartyPeer> = try {
val arr = JSONArray(json)
(0 until arr.length()).mapNotNull { i ->
val o = arr.optJSONObject(i) ?: return@mapNotNull null
val npub = o.optString("npub")
val ula = o.optString("ula")
if (npub.isBlank() || ula.isBlank()) return@mapNotNull null
PartyPeer(
npub = npub,
ula = ula,
name = o.optString("name").ifBlank { "Phone" },
ip = o.optString("ip"),
port = o.optInt("port"),
)
}
} catch (_: Exception) {
emptyList()
}
private fun toJson(peers: List<PartyPeer>): String {
val arr = JSONArray()
for (p in peers) {
arr.put(JSONObject().apply {
put("npub", p.npub)
put("ula", p.ula)
put("name", p.name)
put("ip", p.ip)
put("port", p.port)
})
}
return arr.toString()
}
/**
* Add or update the node peer plus its rendezvous anchors (each matched
* by npub, so re-pairing updates addresses instead of duplicating).
@@ -91,6 +230,21 @@ class FipsPreferences(private val context: Context) {
}))
}
}
// Guarantee the public anchor: without it, a QR from an older node
// leaves the phone LAN-only and pairing/connecting dies off-LAN.
if (info.npub != ARCHY_ANCHOR_NPUB &&
incoming.none { it.optString("npub") == ARCHY_ANCHOR_NPUB }
) {
incoming += JSONObject().apply {
put("npub", ARCHY_ANCHOR_NPUB)
put("alias", "Archipelago anchor")
put("addresses", JSONArray().put(JSONObject().apply {
put("transport", ARCHY_ANCHOR_TRANSPORT)
put("addr", ARCHY_ANCHOR_ADDR)
put("priority", 40)
}))
}
}
val incomingNpubs = incoming.map { it.optString("npub") }.toSet()
context.fipsDataStore.edit { prefs ->
val current = JSONArray(prefs[peersKey] ?: "[]")
@@ -0,0 +1,328 @@
package com.archipelago.app.fips
import android.content.Context
import android.util.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.io.BufferedInputStream
import java.io.File
import java.io.InputStream
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.ServerSocket
import java.net.Socket
import java.util.UUID
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
/** One chat/photo message in a party conversation, keyed by the peer's npub. */
data class FlareMessage(
val id: String,
val peerNpub: String,
val fromMe: Boolean,
val name: String,
val text: String = "",
val photoPath: String = "",
val ts: Long,
val status: Status = Status.RECEIVED,
) {
enum class Status { SENDING, SENT, FAILED, RECEIVED }
}
/** In-memory conversation store (demo scope — nothing persists across restarts). */
object FlareStore {
private val _messages = MutableStateFlow<List<FlareMessage>>(emptyList())
val messages: StateFlow<List<FlareMessage>> = _messages
fun add(message: FlareMessage) {
_messages.value = _messages.value + message
}
fun setStatus(id: String, status: FlareMessage.Status) {
_messages.value = _messages.value.map { if (it.id == id) it.copy(status = status) else it }
}
}
/**
* Minimal HTTP listener bound ONLY on this phone's mesh ULA — plain HTTP is
* fine there because FIPS is the encryption + peer-identity layer (same
* stance as the node's ULA-only peer listener). This is what makes the phone
* a *server* on the mesh: another phone (or `curl -6` from any mesh node)
* reaches it by npub-derived address with no port forwarding, DNS, or CA.
*
* FIPS authenticates the node, not the request (project doctrine), so inputs
* are still validated at this boundary: size caps, JSON shape, no
* client-controlled paths.
*/
object FlareServer {
private const val TAG = "FlareServer"
private const val MAX_PHOTO_BYTES = 8 * 1024 * 1024
private const val MAX_TEXT_CHARS = 4_000
private const val MAX_HEADER_BYTES = 16 * 1024
private var socket: ServerSocket? = null
private var pool: ExecutorService? = null
@Volatile private var identityName = "Phone"
@Volatile private var identityNpub = ""
@Volatile private var photoDir: File? = null
@Synchronized
fun start(context: Context, ula: String, myNpub: String, myName: String) {
stop()
identityNpub = myNpub
identityName = myName
photoDir = File(context.cacheDir, "flare").apply { mkdirs() }
val pool = Executors.newCachedThreadPool().also { this.pool = it }
pool.execute {
try {
val server = ServerSocket().apply {
reuseAddress = true
bind(InetSocketAddress(InetAddress.getByName(ula), PartyQr.FLARE_PORT))
}
socket = server
Log.i(TAG, "flare listening on [$ula]:${PartyQr.FLARE_PORT}")
while (!server.isClosed) {
val client = try {
server.accept()
} catch (_: Exception) {
break
}
pool.execute { handle(client) }
}
} catch (e: Exception) {
Log.e(TAG, "flare server died: $e")
}
}
}
@Synchronized
fun stop() {
try {
socket?.close()
} catch (_: Exception) {
}
socket = null
pool?.shutdownNow()
pool = null
}
private fun handle(client: Socket) {
client.use { sock ->
sock.soTimeout = 30_000
try {
val input = BufferedInputStream(sock.getInputStream())
val requestLine = readLine(input) ?: return
val parts = requestLine.trim().split(" ")
if (parts.size < 2) return respond(sock, 400, json("bad_request"))
val (method, path) = parts[0] to parts[1]
var contentLength = 0
var from = ""
var fromName = ""
var headerBytes = requestLine.length
while (true) {
val line = readLine(input) ?: return
if (line.isEmpty()) break
headerBytes += line.length
if (headerBytes > MAX_HEADER_BYTES) return respond(sock, 431, json("headers_too_large"))
val idx = line.indexOf(':')
if (idx <= 0) continue
val key = line.substring(0, idx).trim().lowercase()
val value = line.substring(idx + 1).trim()
when (key) {
"content-length" -> contentLength = value.toIntOrNull() ?: 0
"x-from" -> from = value.take(80)
"x-name" -> fromName = value.take(80)
}
}
when {
method == "GET" && (path == "/" || path.startsWith("/?")) ->
respondHtml(sock, profilePage())
method == "POST" && path == "/flare" -> {
if (contentLength !in 1..MAX_HEADER_BYTES) return respond(sock, 413, json("too_large"))
val body = readExactly(input, contentLength) ?: return
receiveFlare(String(body, Charsets.UTF_8))
respond(sock, 200, """{"ok":true}""")
}
method == "POST" && path == "/photo" -> {
if (contentLength !in 1..MAX_PHOTO_BYTES) return respond(sock, 413, json("too_large"))
if (!from.startsWith("npub1")) return respond(sock, 400, json("bad_request"))
val body = readExactly(input, contentLength) ?: return
receivePhoto(from, fromName, body)
respond(sock, 200, """{"ok":true}""")
}
else -> respond(sock, 404, json("not_found"))
}
} catch (e: Exception) {
Log.w(TAG, "request failed: $e")
}
}
}
private fun receiveFlare(body: String) {
val o = try {
JSONObject(body)
} catch (_: Exception) {
return
}
val from = o.optString("from")
if (!from.startsWith("npub1")) return
val text = o.optString("text").take(MAX_TEXT_CHARS)
if (text.isBlank()) return
FlareStore.add(
FlareMessage(
id = UUID.randomUUID().toString(),
peerNpub = from,
fromMe = false,
name = o.optString("name").take(80).ifBlank { "Phone" },
text = text,
ts = System.currentTimeMillis(),
)
)
}
private fun receivePhoto(from: String, fromName: String, bytes: ByteArray) {
// Server-generated filename — the sender never controls the path.
val dir = photoDir ?: return
val file = File(dir, "${UUID.randomUUID()}.jpg")
file.writeBytes(bytes)
FlareStore.add(
FlareMessage(
id = UUID.randomUUID().toString(),
peerNpub = from,
fromMe = false,
name = fromName.ifBlank { "Phone" },
photoPath = file.absolutePath,
ts = System.currentTimeMillis(),
)
)
}
private fun profilePage(): String {
val npub = identityNpub
val name = identityName
return """
<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>$name — on the mesh</title>
<style>
body{background:#0a0a0a;color:#eee;font-family:monospace;
display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0}
.card{border:1px solid rgba(255,255,255,.12);border-radius:20px;padding:32px;
max-width:560px;background:rgba(255,255,255,.04)}
h1{color:#f7931a;margin:0 0 8px;font-size:22px}
.npub{word-break:break-all;color:#888;font-size:12px;margin:12px 0}
p{line-height:1.5}
</style></head><body><div class="card">
<h1>⚡ $name</h1>
<div class="npub">$npub</div>
<p>This page is being served <b>by a phone</b>, addressed by its
cryptographic identity over the FIPS mesh.</p>
<p>No port forwarding. No DNS. No certificate authority. No cloud.
The key <i>is</i> the address — and the transport underneath can be
5G, WiFi, or a hotspot with no internet at all.</p>
</div></body></html>
""".trimIndent()
}
// ── tiny HTTP plumbing ──────────────────────────────────────────────────
/** Read one CRLF-terminated header line as ISO-8859-1; null on EOF. */
private fun readLine(input: InputStream): String? {
val sb = StringBuilder()
while (true) {
val b = input.read()
if (b == -1) return if (sb.isEmpty()) null else sb.toString()
if (b == '\n'.code) return sb.toString().trimEnd('\r')
sb.append(b.toChar())
if (sb.length > MAX_HEADER_BYTES) return null
}
}
private fun readExactly(input: InputStream, length: Int): ByteArray? {
val buf = ByteArray(length)
var off = 0
while (off < length) {
val n = input.read(buf, off, length - off)
if (n == -1) return null
off += n
}
return buf
}
private fun json(code: String) = """{"error":{"code":"$code","message":"request rejected"}}"""
private fun respond(sock: Socket, status: Int, body: String) =
writeResponse(sock, status, "application/json", body.toByteArray(Charsets.UTF_8))
private fun respondHtml(sock: Socket, body: String) =
writeResponse(sock, 200, "text/html; charset=utf-8", body.toByteArray(Charsets.UTF_8))
private fun writeResponse(sock: Socket, status: Int, contentType: String, body: ByteArray) {
val reason = when (status) {
200 -> "OK"; 400 -> "Bad Request"; 404 -> "Not Found"
413 -> "Payload Too Large"; 431 -> "Headers Too Large"
else -> "Error"
}
val head = "HTTP/1.1 $status $reason\r\n" +
"Content-Type: $contentType\r\n" +
"Content-Length: ${body.size}\r\n" +
"Connection: close\r\n\r\n"
sock.getOutputStream().apply {
write(head.toByteArray(Charsets.ISO_8859_1))
write(body)
flush()
}
}
}
/** Outbound flares: plain HTTP to the peer's ULA — FIPS encrypts underneath. */
object FlareClient {
// Connect timeout must outlive cold mesh-session establishment (~15s via
// the public tree per HANDOFF-2026-07-23); the attempt itself drives
// session setup, same trick as the VPN service's session warmer.
private val http = OkHttpClient.Builder()
.connectTimeout(25, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.build()
private fun base(peer: PartyPeer) = "http://[${peer.ula}]:${PartyQr.FLARE_PORT}"
/** Blocking — call from Dispatchers.IO. */
fun sendText(peer: PartyPeer, myNpub: String, myName: String, text: String): Boolean = try {
val body = JSONObject()
.put("from", myNpub)
.put("name", myName)
.put("text", text)
.put("ts", System.currentTimeMillis())
.toString()
.toRequestBody("application/json".toMediaType())
http.newCall(
Request.Builder().url("${base(peer)}/flare").post(body).build()
).execute().use { it.isSuccessful }
} catch (_: Exception) {
false
}
/** Blocking — call from Dispatchers.IO. */
fun sendPhoto(peer: PartyPeer, myNpub: String, myName: String, jpeg: ByteArray): Boolean = try {
http.newCall(
Request.Builder()
.url("${base(peer)}/photo")
.header("X-From", myNpub)
.header("X-Name", myName)
.post(jpeg.toRequestBody("image/jpeg".toMediaType()))
.build()
).execute().use { it.isSuccessful }
} catch (_: Exception) {
false
}
}
@@ -0,0 +1,102 @@
package com.archipelago.app.fips
import android.net.Uri
import java.net.Inet4Address
import java.net.NetworkInterface
/**
* Phone↔phone mesh pairing ("Mesh Party") QR contract:
*
* archipelago://party?v=1&npub=<npub>&ula=<fd..>&name=<name>[&ip=<v4>&port=<udp>]
*
* npub + ula alone are enough to chat *through* the mesh (anchors route by
* node address, no underlay info needed). ip/port are present only while the
* showing phone has its inbound UDP listener up (party mode) — the scanner
* then also gets a direct-dial link that works on a shared LAN/hotspot with
* no internet at all. Same versioning stance as the node pairing QR
* (docs/companion-pairing-qr.md): unknown params tolerated under v=1.
*/
data class PartyPeer(
val npub: String,
val ula: String,
val name: String,
/** Direct-dial underlay endpoint; empty when the peer wasn't listening. */
val ip: String = "",
val port: Int = 0,
)
object PartyQr {
const val SCHEME_HOST = "party"
private const val SUPPORTED_MAJOR = 1
/** UDP port a party-mode phone listens on (matches the node's mesh port). */
const val PARTY_UDP_PORT = 2121
/** Application-layer chat/beam port, bound only on the mesh ULA. */
const val FLARE_PORT = 5680
fun build(npub: String, ula: String, name: String, ip: String?, port: Int): String {
val b = Uri.Builder()
.scheme("archipelago")
.authority(SCHEME_HOST)
.appendQueryParameter("v", "1")
.appendQueryParameter("npub", npub)
.appendQueryParameter("ula", ula)
.appendQueryParameter("name", name)
if (!ip.isNullOrBlank() && port > 0) {
b.appendQueryParameter("ip", ip)
b.appendQueryParameter("port", port.toString())
}
return b.build().toString()
}
/** Null when [raw] is not a valid party QR (foreign codes just keep scanning). */
fun parse(raw: String): PartyPeer? {
val uri = try {
Uri.parse(raw.trim())
} catch (_: Exception) {
return null
}
if (!"archipelago".equals(uri.scheme, ignoreCase = true)) return null
if (uri.isOpaque || !SCHEME_HOST.equals(uri.host, ignoreCase = true)) return null
val major = uri.getQueryParameter("v")?.takeWhile { it.isDigit() }?.toIntOrNull() ?: return null
if (major != SUPPORTED_MAJOR) return null
val npub = uri.getQueryParameter("npub")?.trim().orEmpty()
val ula = uri.getQueryParameter("ula")?.trim().orEmpty()
if (!npub.startsWith("npub1") || !ula.startsWith("fd")) return null
return PartyPeer(
npub = npub,
ula = ula,
name = uri.getQueryParameter("name")?.trim().orEmpty().ifBlank { "Phone" },
ip = uri.getQueryParameter("ip")?.trim().orEmpty(),
port = uri.getQueryParameter("port")?.toIntOrNull() ?: 0,
)
}
/**
* This phone's private IPv4 on WiFi or its own hotspot, for the QR's
* direct-dial hint. Hotspot interfaces (ap/swlan/softap) win over wlan so
* the hotspot-host phone advertises the address its guests can reach.
*/
fun localWifiIpv4(): String? {
val candidates = mutableListOf<Pair<String, String>>() // ifname → addr
try {
for (nif in NetworkInterface.getNetworkInterfaces()) {
if (!nif.isUp || nif.isLoopback) continue
for (addr in nif.inetAddresses) {
if (addr is Inet4Address && addr.isSiteLocalAddress) {
candidates += nif.name to addr.hostAddress.orEmpty()
}
}
}
} catch (_: Exception) {
return null
}
val hotspot = candidates.firstOrNull {
it.first.startsWith("ap") || it.first.startsWith("swlan") || it.first.startsWith("softap")
}
return (hotspot ?: candidates.firstOrNull { it.first.startsWith("wlan") } ?: candidates.firstOrNull())
?.second
}
}
@@ -86,6 +86,7 @@ fun NESMenu(
onToggleMode: () -> Unit,
onToggleStyle: () -> Unit,
onBackToWebView: (() -> Unit)? = null,
onMeshParty: (() -> Unit)? = null,
) {
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
Box(
@@ -94,7 +95,7 @@ fun NESMenu(
contentAlignment = Alignment.Center,
) {
AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) {
MenuPanel(servers, activeServer, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView)
MenuPanel(servers, activeServer, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onScanQr, onEditServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView, onMeshParty)
}
}
}
@@ -115,6 +116,7 @@ private fun MenuPanel(
onToggleMode: () -> Unit,
onToggleStyle: () -> Unit,
onBackToWebView: (() -> Unit)?,
onMeshParty: (() -> Unit)?,
) {
var showAdd by remember { mutableStateOf(false) }
// The saved server being edited, or null when adding a new one.
@@ -284,6 +286,11 @@ private fun MenuPanel(
onClick = onToggleStyle,
)
// Phone↔phone mesh pairing + chat
if (onMeshParty != null) {
MenuItem(label = "Mesh Party", labelColor = BitcoinOrange, onClick = onMeshParty)
}
// Back to dashboard
if (onBackToWebView != null) {
MenuItem(label = "Back to Dashboard", onClick = onBackToWebView)
@@ -20,7 +20,9 @@ import com.archipelago.app.data.ServerEntry
import com.archipelago.app.data.ServerPreferences
import com.archipelago.app.data.ServerQrParser
import com.archipelago.app.fips.FipsManager
import com.archipelago.app.ui.screens.FlareScreen
import com.archipelago.app.ui.screens.IntroScreen
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
@@ -31,6 +33,8 @@ object Routes {
const val SERVER_CONNECT = "server_connect"
const val WEB_VIEW = "web_view"
const val REMOTE_INPUT = "remote_input"
const val MESH_PARTY = "mesh_party"
const val FLARE = "flare"
}
@Composable
@@ -122,6 +126,9 @@ fun AppNavHost(
) {
composable(Routes.INTRO) {
IntroScreen(
onMeshParty = {
navController.navigate(Routes.MESH_PARTY)
},
onContinue = {
scope.launch {
prefs.markIntroSeen()
@@ -179,6 +186,22 @@ fun AppNavHost(
onBack = {
navController.popBackStack()
},
onMeshParty = {
navController.navigate(Routes.MESH_PARTY)
},
)
}
composable(Routes.MESH_PARTY) {
PartyScreen(
onBack = { navController.popBackStack() },
onOpenChat = { navController.navigate(Routes.FLARE) },
)
}
composable(Routes.FLARE) {
FlareScreen(
onBack = { navController.popBackStack() },
)
}
}
@@ -0,0 +1,359 @@
package com.archipelago.app.ui.screens
import android.graphics.Bitmap
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.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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Image
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.fips.FipsManager
import com.archipelago.app.fips.FipsNative
import com.archipelago.app.fips.FipsPreferences
import com.archipelago.app.fips.FlareClient
import com.archipelago.app.fips.FlareMessage
import com.archipelago.app.fips.FlareStore
import com.archipelago.app.fips.PartyPeer
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.SurfaceDark
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
import java.io.File
import java.util.UUID
private val BubbleTheirs = Color.White.copy(alpha = 0.07f)
private val BubbleBorder = Color.White.copy(alpha = 0.08f)
/**
* Flare — phone↔phone chat and photo beam over the FIPS mesh. Every byte is
* E2E encrypted by the mesh layer and addressed by npub; whether it travels
* via a public anchor (5G) or a direct hotspot link is invisible up here —
* which is the entire point.
*/
@Composable
fun FlareScreen(onBack: () -> Unit) {
val context = LocalContext.current
val prefs = remember { FipsPreferences(context) }
val scope = rememberCoroutineScope()
var identity by remember { mutableStateOf<FipsNative.Identity?>(null) }
var myName by remember { mutableStateOf("Phone") }
val peers by prefs.partyPeersFlow.collectAsState(initial = emptyList())
var selectedNpub by remember { mutableStateOf<String?>(null) }
val allMessages by FlareStore.messages.collectAsState()
var draft by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
identity = FipsManager.ensureIdentity(prefs)
myName = prefs.partyName()
}
LaunchedEffect(peers) {
if (selectedNpub == null || peers.none { it.npub == selectedNpub }) {
selectedNpub = peers.firstOrNull()?.npub
}
}
val peer = peers.firstOrNull { it.npub == selectedNpub }
val messages = allMessages.filter { it.peerNpub == selectedNpub }
val listState = rememberLazyListState()
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) listState.animateScrollToItem(messages.size - 1)
}
fun sendText() {
val target = peer ?: return
val me = identity ?: return
val text = draft.trim()
if (text.isEmpty()) return
draft = ""
val msg = FlareMessage(
id = UUID.randomUUID().toString(),
peerNpub = target.npub,
fromMe = true,
name = myName,
text = text,
ts = System.currentTimeMillis(),
status = FlareMessage.Status.SENDING,
)
FlareStore.add(msg)
scope.launch(Dispatchers.IO) {
val ok = FlareClient.sendText(target, me.npub, myName, text)
FlareStore.setStatus(msg.id, if (ok) FlareMessage.Status.SENT else FlareMessage.Status.FAILED)
}
}
fun sendPhoto(uri: Uri) {
val target = peer ?: return
val me = identity ?: return
scope.launch(Dispatchers.IO) {
val jpeg = compressPhoto(context, uri) ?: return@launch
// Local copy so our own bubble renders the sent photo.
val dir = File(context.cacheDir, "flare").apply { mkdirs() }
val local = File(dir, "${UUID.randomUUID()}.jpg").apply { writeBytes(jpeg) }
val msg = FlareMessage(
id = UUID.randomUUID().toString(),
peerNpub = target.npub,
fromMe = true,
name = myName,
photoPath = local.absolutePath,
ts = System.currentTimeMillis(),
status = FlareMessage.Status.SENDING,
)
FlareStore.add(msg)
val ok = FlareClient.sendPhoto(target, me.npub, myName, jpeg)
FlareStore.setStatus(msg.id, if (ok) FlareMessage.Status.SENT else FlareMessage.Status.FAILED)
}
}
val photoPicker = rememberLauncherForActivityResult(
ActivityResultContracts.GetContent()
) { uri -> uri?.let { sendPhoto(it) } }
BackHandler { onBack() }
Column(
Modifier
.fillMaxSize()
.background(SurfaceDark)
.statusBarsPadding()
.navigationBarsPadding()
.imePadding(),
) {
// Header
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(" Back", color = TextMuted, fontSize = 15.sp, modifier = Modifier.clickable { onBack() }.padding(6.dp))
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("FLARE", color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 3.sp)
peer?.let {
Text(
it.name + if (it.ip.isNotBlank()) " · direct+mesh" else " · mesh",
color = BitcoinOrange,
fontSize = 11.sp,
)
}
}
Spacer(Modifier.size(48.dp))
}
// Peer tabs when chatting with more than one phone
if (peers.size > 1) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
peers.forEach { p ->
val active = p.npub == selectedNpub
Text(
p.name,
color = if (active) BitcoinOrange else TextMuted,
fontSize = 13.sp,
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.background(if (active) BitcoinOrange.copy(alpha = 0.12f) else Color.Transparent)
.border(
1.dp,
if (active) BitcoinOrange.copy(alpha = 0.4f) else BubbleBorder,
RoundedCornerShape(10.dp),
)
.clickable { selectedNpub = p.npub }
.padding(horizontal = 12.dp, vertical = 6.dp),
)
}
}
}
if (peer == null) {
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
Text("No party peers yet — scan a phone first", color = TextMuted, fontSize = 14.sp)
}
} else {
LazyColumn(
state = listState,
modifier = Modifier.weight(1f).fillMaxWidth(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(messages, key = { it.id }) { msg ->
MessageBubble(msg)
}
}
// Composer
Row(
Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
Modifier
.size(48.dp)
.clip(RoundedCornerShape(12.dp))
.background(BubbleTheirs)
.border(1.dp, BubbleBorder, RoundedCornerShape(12.dp))
.clickable { photoPicker.launch("image/*") },
contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.Image, "Beam a photo", tint = BitcoinOrange, modifier = Modifier.size(22.dp))
}
OutlinedTextField(
value = draft,
onValueChange = { draft = it },
placeholder = { Text("Send a flare…", color = TextMuted, fontSize = 14.sp) },
modifier = Modifier.weight(1f),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { sendText() }),
textStyle = TextStyle(color = TextPrimary, fontSize = 15.sp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.White.copy(alpha = 0.3f),
unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
cursorColor = BitcoinOrange,
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary,
),
shape = RoundedCornerShape(12.dp),
)
Box(
Modifier
.size(48.dp)
.clip(RoundedCornerShape(12.dp))
.background(BitcoinOrange.copy(alpha = 0.15f))
.border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp))
.clickable { sendText() },
contentAlignment = Alignment.Center,
) {
Text("", color = BitcoinOrange, fontSize = 18.sp)
}
}
}
}
}
@Composable
private fun MessageBubble(msg: FlareMessage) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = if (msg.fromMe) Arrangement.End else Arrangement.Start,
) {
Column(
Modifier
.widthIn(max = 300.dp)
.clip(RoundedCornerShape(16.dp))
.background(if (msg.fromMe) BitcoinOrange.copy(alpha = 0.14f) else BubbleTheirs)
.border(
1.dp,
if (msg.fromMe) BitcoinOrange.copy(alpha = 0.35f) else BubbleBorder,
RoundedCornerShape(16.dp),
)
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
if (msg.photoPath.isNotBlank()) {
val bmp = remember(msg.photoPath) { BitmapFactory.decodeFile(msg.photoPath) }
bmp?.let {
Image(
bitmap = it.asImageBitmap(),
contentDescription = "Beamed photo",
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp)),
contentScale = ContentScale.FillWidth,
)
}
}
if (msg.text.isNotBlank()) {
Text(msg.text, color = TextPrimary, fontSize = 15.sp)
}
Text(
when (msg.status) {
FlareMessage.Status.SENDING -> "sending…"
FlareMessage.Status.SENT -> "sent · E2E via mesh"
FlareMessage.Status.FAILED -> "failed — tap to retry later"
FlareMessage.Status.RECEIVED -> msg.name
},
color = if (msg.status == FlareMessage.Status.FAILED) BitcoinOrange else TextMuted,
fontSize = 10.sp,
modifier = Modifier.padding(top = 2.dp),
)
}
}
}
/** Decode, downscale (≤1600px) and JPEG-compress a picked photo off-main. */
private suspend fun compressPhoto(context: android.content.Context, uri: Uri): ByteArray? =
withContext(Dispatchers.IO) {
try {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
context.contentResolver.openInputStream(uri)?.use {
BitmapFactory.decodeStream(it, null, bounds)
}
var sample = 1
while (maxOf(bounds.outWidth, bounds.outHeight) / sample > 1600) sample *= 2
val opts = BitmapFactory.Options().apply { inSampleSize = sample }
val bitmap = context.contentResolver.openInputStream(uri)?.use {
BitmapFactory.decodeStream(it, null, opts)
} ?: return@withContext null
val out = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, out)
bitmap.recycle()
out.toByteArray()
} catch (_: Exception) {
null
}
}
@@ -55,7 +55,12 @@ import com.archipelago.app.ui.theme.TextPrimary
import kotlinx.coroutines.delay
@Composable
fun IntroScreen(onContinue: () -> Unit) {
fun IntroScreen(
onContinue: () -> Unit,
// Mesh Party works with no node at all (phone↔phone) — offered right on
// the first screen so a friend who just got the app can join a party.
onMeshParty: () -> Unit = {},
) {
val logoAlpha = remember { Animatable(0f) }
var showContent by remember { mutableStateOf(false) }
@@ -143,6 +148,14 @@ fun IntroScreen(onContinue: () -> Unit) {
onClick = onContinue,
modifier = Modifier.fillMaxWidth().height(56.dp),
)
Spacer(modifier = Modifier.height(12.dp))
GlassButton(
text = stringResource(R.string.mesh_party),
onClick = onMeshParty,
modifier = Modifier.fillMaxWidth().height(48.dp),
)
}
}
}
@@ -0,0 +1,401 @@
package com.archipelago.app.ui.screens
import android.graphics.Bitmap
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.fips.FipsManager
import com.archipelago.app.fips.FipsNative
import com.archipelago.app.fips.FipsPreferences
import com.archipelago.app.fips.PartyPeer
import com.archipelago.app.fips.PartyQr
import com.archipelago.app.ui.components.CameraQrPreview
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.SurfaceDark
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private val CardBg = Color.White.copy(alpha = 0.05f)
private val CardBorder = Color.White.copy(alpha = 0.08f)
/**
* Mesh Party phonephone FIPS pairing. Show your QR, scan theirs, and the
* two embedded mesh nodes link up: through anchors when there's internet,
* directly over any shared WiFi/hotspot when there isn't.
*/
@Composable
fun PartyScreen(
onBack: () -> Unit,
onOpenChat: () -> Unit,
) {
val context = LocalContext.current
val prefs = remember { FipsPreferences(context) }
val scope = rememberCoroutineScope()
var identity by remember { mutableStateOf<FipsNative.Identity?>(null) }
var name by remember { mutableStateOf("") }
var localIp by remember { mutableStateOf<String?>(null) }
var showScanner by remember { mutableStateOf(false) }
var scanHint by remember { mutableStateOf<String?>(null) }
val listenOn by prefs.partyListenFlow.collectAsState(initial = false)
val peers by prefs.partyPeersFlow.collectAsState(initial = emptyList())
LaunchedEffect(Unit) {
identity = FipsManager.ensureIdentity(prefs)
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.
while (true) {
localIp = withContext(Dispatchers.IO) { PartyQr.localWifiIpv4() }
delay(3_000)
}
}
val qrPayload = identity?.let { id ->
PartyQr.build(
npub = id.npub,
ula = id.address,
name = name.ifBlank { "Phone" },
ip = if (listenOn) localIp else null,
port = PartyQr.PARTY_UDP_PORT,
)
}
val qrBitmap = remember(qrPayload) { qrPayload?.let { renderQr(it) } }
BackHandler { if (showScanner) showScanner = false else onBack() }
Box(Modifier.fillMaxSize().background(SurfaceDark)) {
Column(
Modifier
.fillMaxSize()
.statusBarsPadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(" Back", color = TextMuted, fontSize = 15.sp, modifier = Modifier.clickable { onBack() }.padding(8.dp))
Text("MESH PARTY", color = TextPrimary, fontSize = 17.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 3.sp)
Spacer(Modifier.size(56.dp))
}
// My QR card
Column(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(20.dp))
.background(CardBg)
.border(1.dp, CardBorder, RoundedCornerShape(20.dp))
.padding(18.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
qrBitmap?.let { bmp ->
Box(
Modifier
.clip(RoundedCornerShape(16.dp))
.background(Color.White)
.padding(12.dp),
) {
Image(
bitmap = bmp.asImageBitmap(),
contentDescription = "My mesh party QR",
modifier = Modifier.size(220.dp),
)
}
} ?: Text("Mesh identity unavailable on this device", color = TextMuted, fontSize = 14.sp)
identity?.let {
Text(
it.npub.take(16) + "" + it.npub.takeLast(6),
color = TextMuted,
fontSize = 12.sp,
textAlign = TextAlign.Center,
)
}
OutlinedTextField(
value = name,
onValueChange = {
name = it.take(24)
scope.launch { prefs.setPartyName(name) }
},
placeholder = { Text("Your name", color = TextMuted, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()) },
modifier = Modifier.fillMaxWidth().height(56.dp),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
textStyle = TextStyle(color = TextPrimary, fontSize = 15.sp, textAlign = TextAlign.Center),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.White.copy(alpha = 0.3f),
unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
cursorColor = BitcoinOrange,
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary,
),
shape = RoundedCornerShape(12.dp),
)
// Direct-link toggle (hotspot mode)
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(Modifier.padding(end = 12.dp)) {
Text("Accept direct links", color = TextPrimary, fontSize = 15.sp, fontWeight = FontWeight.Medium)
Text(
when {
listenOn && localIp != null -> "Dialable at $localIp:${PartyQr.PARTY_UDP_PORT} — no internet needed"
listenOn -> "Waiting for a WiFi/hotspot address…"
else -> "Off — mesh routes via anchors only"
},
color = if (listenOn) BitcoinOrange else TextMuted,
fontSize = 12.sp,
)
}
Switch(
checked = listenOn,
onCheckedChange = { on ->
scope.launch {
prefs.setPartyListen(on)
FipsManager.requestMeshRestart(context)
}
},
colors = SwitchDefaults.colors(
checkedTrackColor = BitcoinOrange,
checkedThumbColor = Color.White,
),
)
}
}
GlassButton(
text = "Scan a Phone",
onClick = { showScanner = true },
modifier = Modifier.fillMaxWidth().height(56.dp),
)
if (peers.isNotEmpty()) {
Text("PARTY PEERS", color = TextMuted, fontSize = 12.sp, letterSpacing = 2.sp)
peers.forEach { peer ->
Row(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(CardBg)
.border(1.dp, CardBorder, RoundedCornerShape(14.dp))
.clickable { onOpenChat() }
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(Modifier.padding(end = 8.dp)) {
Text(peer.name, color = TextPrimary, fontSize = 15.sp, fontWeight = FontWeight.Medium)
Text(
peer.npub.take(14) + "" + if (peer.ip.isNotBlank()) " · direct ${peer.ip}" else " · via mesh",
color = TextMuted,
fontSize = 11.sp,
)
}
Text(
"",
color = TextMuted,
fontSize = 16.sp,
modifier = Modifier.clickable {
scope.launch {
prefs.removePartyPeer(peer.npub)
FipsManager.requestMeshRestart(context)
}
}.padding(8.dp),
)
}
}
GlassButton(
text = "Open Flare Chat",
onClick = onOpenChat,
modifier = Modifier.fillMaxWidth().height(56.dp),
)
} else {
Text(
"Scan another phone's party QR (or let them scan yours) to link your mesh nodes — works over 5G via anchors, or over any shared WiFi/hotspot with zero internet.",
color = TextMuted,
fontSize = 13.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
)
}
Spacer(Modifier.height(12.dp))
// Hand the app itself to a friend: shares this install's own APK
// through the system sheet (Quick Share/Bluetooth), so a nearby
// phone gets the companion with zero internet — the whole party
// premise.
GlassButton(
text = "Share this app",
onClick = { shareCompanionApk(context) },
modifier = Modifier.fillMaxWidth().height(48.dp),
)
Spacer(Modifier.height(12.dp))
}
// Party QR scanner overlay
AnimatedVisibility(visible = showScanner, enter = fadeIn(), exit = fadeOut()) {
Box(Modifier.fillMaxSize().background(Color.Black)) {
CameraQrPreview(onDecoded = { text ->
val peer = PartyQr.parse(text)
when {
peer == null -> scanHint = "Not a mesh party QR"
peer.npub == identity?.npub -> scanHint = "That's your own QR"
else -> {
showScanner = false
scanHint = null
scope.launch {
prefs.upsertPartyPeer(peer)
// Pick up the direct-dial PeerConfig (and the
// listener, if ours is on) immediately.
FipsManager.requestMeshRestart(context)
onOpenChat()
}
}
}
})
Box(
Modifier
.align(Alignment.Center)
.size(260.dp)
.border(2.dp, BitcoinOrange.copy(alpha = 0.85f), RoundedCornerShape(20.dp)),
)
Text(
"Close",
color = TextPrimary,
fontSize = 16.sp,
modifier = Modifier
.align(Alignment.TopEnd)
.statusBarsPadding()
.clickable { showScanner = false }
.padding(20.dp),
)
scanHint?.let {
Text(
it,
color = BitcoinOrange,
fontSize = 14.sp,
textAlign = TextAlign.Center,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 48.dp)
.fillMaxWidth(),
)
}
}
}
}
// Scan hints fade so the camera feels live again.
LaunchedEffect(scanHint) {
if (scanHint != null) {
delay(2500)
scanHint = null
}
}
}
/** Render a QR payload as a bitmap (dark modules on white). */
private fun renderQr(payload: String, size: Int = 640): Bitmap? = try {
val matrix = QRCodeWriter().encode(
payload,
BarcodeFormat.QR_CODE,
size,
size,
mapOf(EncodeHintType.MARGIN to 1),
)
val pixels = IntArray(size * size)
for (y in 0 until size) {
for (x in 0 until size) {
pixels[y * size + x] = if (matrix[x, y]) 0xFF0A0A0A.toInt() else 0xFFFFFFFF.toInt()
}
}
Bitmap.createBitmap(pixels, size, size, Bitmap.Config.ARGB_8888)
} catch (_: Exception) {
null
}
/** 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) {
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 send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
type = "application/vnd.android.package-archive"
putExtra(android.content.Intent.EXTRA_STREAM, uri)
addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(
android.content.Intent.createChooser(send, "Share Archipelago Companion")
.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK),
)
} catch (_: Exception) {
// No share targets / copy failed — nothing sensible to do here.
}
}
@@ -54,7 +54,7 @@ import com.archipelago.app.ui.theme.TextMuted
import kotlinx.coroutines.launch
@Composable
fun RemoteInputScreen(onBack: () -> Unit) {
fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) {
val context = LocalContext.current
val prefs = remember { ServerPreferences(context) }
val scope = rememberCoroutineScope()
@@ -250,6 +250,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC
},
onBackToWebView = { showModal = false; onBack() },
onMeshParty = onMeshParty?.let { open -> { showModal = false; open() } },
)
// Pairing-QR scan launched from the menu's Add Server row. The menu stays
@@ -85,6 +85,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.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.HttpURLConnection
@@ -171,10 +172,35 @@ fun ServerConnectScreen(
errorMessage = null
scope.launch {
val result = testConnection(server)
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
// 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()) {
FipsManager.autoStartIfReady(context)
val meshServer = server.copy(
address = server.meshIp,
useHttps = false,
port = "",
)
// Mesh discovery + first session can take 15s+ through the
// public tree (HANDOFF-2026-07-23 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)
}
}
isConnecting = false
if (result) {
if (reachable) {
prefs.setActiveServer(server)
onConnected(server.toUrl())
} else {
@@ -651,8 +677,10 @@ private fun sanitizeAddress(input: String): String {
.trimEnd('/')
}
/** Test RPC connectivity. Accepts self-signed certs for local LAN servers. */
private suspend fun testConnection(server: ServerEntry): Boolean {
/** 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+). */
private suspend fun testConnection(server: ServerEntry, timeoutMs: Int = 5000): Boolean {
return withContext(Dispatchers.IO) {
try {
val url = URL("${server.toUrl()}/rpc/v1")
@@ -672,8 +700,8 @@ private suspend fun testConnection(server: ServerEntry): Boolean {
}
connection.requestMethod = "POST"
connection.connectTimeout = 5000
connection.readTimeout = 5000
connection.connectTimeout = timeoutMs
connection.readTimeout = timeoutMs
connection.setRequestProperty("Content-Type", "application/json")
connection.doOutput = true
val body = """{"method":"server.echo","params":{"message":"ping"}}"""
@@ -35,8 +35,10 @@ 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.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.windowInsetsTopHeight
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
@@ -67,9 +69,14 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import android.webkit.ValueCallback
import com.archipelago.app.R
@@ -77,6 +84,7 @@ import com.archipelago.app.data.ServerPreferences
import com.archipelago.app.ui.components.GestureHintOverlay
import com.archipelago.app.ui.components.WalletQrScannerModal
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.ErrorRed
import com.archipelago.app.ui.theme.SurfaceBlack
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
@@ -115,6 +123,45 @@ private fun isSameHost(url: String, base: String): Boolean {
}
}
/** Kiosk WebView retained across navigation (remote dashboard) so leaving
* the kiosk and coming back reattaches the LIVE page no reload, no
* re-login, no reconnect. Dropped on retry/disconnect/server change. */
private object KioskWebView {
var instance: WebView? = null
var url: String? = null
fun drop() {
instance?.let {
(it.parent as? ViewGroup)?.removeView(it)
it.destroy()
}
instance = null
url = null
}
}
/** True when a TCP listener answers at [base]'s host:port within [timeoutMs]. */
private fun tcpAnswers(base: String, timeoutMs: Int): Boolean = try {
val u = android.net.Uri.parse(base)
val port = if (u.port != -1) u.port else if (u.scheme == "https") 443 else 80
java.net.Socket().use {
it.connect(java.net.InetSocketAddress(u.host, port), timeoutMs)
true
}
} catch (_: Exception) {
false
}
/** Fastest answering origin: LAN inside a short window, else the mesh ULA
* (patient a cold session may still be establishing), else LAN anyway so
* the existing error/fallback path handles it. */
private suspend fun pickStartUrl(lanUrl: String, meshUrl: String?): String =
withContext(Dispatchers.IO) {
if (tcpAnswers(lanUrl, 2500)) return@withContext lanUrl
if (meshUrl != null && tcpAnswers(meshUrl, 12_000)) return@withContext meshUrl
lanUrl
}
/** Apply the WebView settings shared by the kiosk view and the in-app browser.
* These are tuned for SPA performance and parity with the mobile browser;
* none of them alter how a page renders visually. */
@@ -163,11 +210,39 @@ fun WebViewScreen(
meshFallbackUrl: String? = null,
) {
var isLoading by remember { mutableStateOf(true) }
// First kiosk load (often over the FIPS mesh) gets the full branded
// loader; later navigations keep just the slim top progress bar.
var firstLoadDone by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
snapshotFlow { isLoading }.first { !it }
firstLoadDone = true
}
var loadProgress by remember { mutableIntStateOf(0) }
var triedMeshFallback by remember { mutableStateOf(false) }
var hasError by remember { mutableStateOf(false) }
var webView by remember { mutableStateOf<WebView?>(null) }
// Race LAN vs mesh BEFORE the WebView exists: Chromium pointed at an
// unreachable LAN IP burns a minute+ in connect retries before
// onReceivedError fires the mesh fallback — the "stuck connecting"
// stall. A raw TCP probe answers in milliseconds at home and fails in
// ~2.5s off-LAN, so startup lands on the right origin in seconds.
var startUrl by remember(serverUrl) { mutableStateOf<String?>(null) }
var raceNonce by remember { mutableIntStateOf(0) }
LaunchedEffect(serverUrl, meshFallbackUrl, raceNonce) {
// A retained live session exists — reattach instantly: no race, no
// reload, no re-login (remote ⇄ dashboard round trip).
if (KioskWebView.instance != null && KioskWebView.url == serverUrl) {
isLoading = false
startUrl = serverUrl
return@LaunchedEffect
}
val picked = pickStartUrl(serverUrl, meshFallbackUrl)
// Starting on the mesh: don't bounce back to it on error (it IS it).
if (picked != serverUrl) triedMeshFallback = true
startUrl = picked
}
// Web-page camera access (wallet QR scanner). The WebView's default
// WebChromeClient silently denies getUserMedia, so grant video capture —
// asking for the app-level CAMERA permission first when needed.
@@ -187,6 +262,14 @@ fun WebViewScreen(
// while this is shown, so closing it returns instantly with no reload.
var inAppUrl by remember { mutableStateOf<String?>(null) }
// Same node = EITHER of its addresses. Over the mesh the kiosk's host is
// the ULA while app links may carry the LAN IP (and vice versa) —
// comparing against one host bounced same-node apps (Pine, Home
// Assistant, BTCPay) out to the phone's external browser.
fun isSameNode(url: String): Boolean =
isSameHost(url, serverUrl) ||
(meshFallbackUrl != null && isSameHost(url, meshFallbackUrl))
// Native wallet QR scanner, opened by the web UI via the ArchipelagoQr
// bridge; status lines stream back from the page while it's up.
var walletScannerVisible by remember { mutableStateOf(false) }
@@ -268,9 +351,16 @@ fun WebViewScreen(
GlassButton(
text = stringResource(R.string.retry),
onClick = {
// Re-race LAN vs mesh — the network we're on may have
// changed since the last pick. Drop the retained view:
// an errored session must genuinely reload.
KioskWebView.drop()
webView = null
hasError = false
isLoading = true
webView?.loadUrl(serverUrl)
triedMeshFallback = false
startUrl = null
raceNonce++
},
modifier = Modifier.fillMaxWidth().height(56.dp),
)
@@ -279,17 +369,35 @@ fun WebViewScreen(
GlassButton(
text = stringResource(R.string.disconnect),
onClick = onDisconnect,
onClick = {
KioskWebView.drop()
onDisconnect()
},
modifier = Modifier.fillMaxWidth().height(48.dp),
)
}
} else if (startUrl == null) {
// Racing LAN vs mesh (≤2.5s at home, a few seconds off-LAN) —
// far cheaper than letting Chromium retry a dead LAN IP.
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = BitcoinOrange)
}
} else {
// Edge-to-edge WebView — background bleeds behind status bar.
// Safe area values injected as CSS env() polyfill on each page load.
val initialUrl = startUrl ?: serverUrl
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
WebView(context).apply {
// Reattach the retained kiosk WebView (remote ⇄ dashboard
// must not reload the node UI). Everything configured
// below is idempotent, and re-running it rebinds clients,
// bridges and listeners to THIS composition's state —
// stale closures from the previous visit are replaced.
if (KioskWebView.url != serverUrl) KioskWebView.drop()
val reused = KioskWebView.instance
(reused ?: WebView(context)).apply {
(parent as? ViewGroup)?.removeView(this)
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
@@ -319,7 +427,7 @@ fun WebViewScreen(
// kiosk couldn't iframe — keep the user inside the app)
// - different host → the phone's real browser
fun routeOutbound(url: String) {
if (isSameHost(url, serverUrl)) {
if (isSameNode(url)) {
inAppUrl = url
} else {
openExternalUrl(context, url)
@@ -458,7 +566,7 @@ fun WebViewScreen(
error: android.net.http.SslError?,
) {
val u = error?.url
if (u != null && isSameHost(u, serverUrl)) {
if (u != null && isSameNode(u)) {
handler?.proceed()
} else {
handler?.cancel()
@@ -595,7 +703,11 @@ fun WebViewScreen(
}
webView = this
loadUrl(serverUrl)
if (reused == null) {
KioskWebView.instance = this
KioskWebView.url = serverUrl
loadUrl(initialUrl)
}
}
},
)
@@ -614,12 +726,46 @@ fun WebViewScreen(
)
}
// Branded first-load screen while the mesh session comes up.
AnimatedVisibility(
visible = isLoading && !firstLoadDone,
enter = fadeIn(),
exit = fadeOut(),
) {
Column(
Modifier.fillMaxSize().background(SurfaceBlack),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
buildAnnotatedString {
withStyle(SpanStyle(color = ErrorRed)) { append("F*CK") }
withStyle(SpanStyle(color = TextPrimary)) { append(" IPS") }
},
fontSize = 40.sp,
fontWeight = FontWeight.Black,
letterSpacing = 4.sp,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(10.dp))
Text(
"connecting to your archipelago",
color = TextMuted,
fontSize = 13.sp,
letterSpacing = 1.sp,
)
Spacer(Modifier.height(28.dp))
CircularProgressIndicator(color = BitcoinOrange)
}
}
// In-app browser overlay for non-iframeable node apps. Rendered last
// so it sits above the kiosk WebView, which stays alive underneath.
inAppUrl?.let { target ->
InAppBrowser(
url = target,
serverUrl = serverUrl,
meshUrl = meshFallbackUrl,
onClose = { inAppUrl = null },
)
}
@@ -693,9 +839,14 @@ private fun fetchFavicon(pageUrl: String): Bitmap? {
private fun InAppBrowser(
url: String,
serverUrl: String,
meshUrl: String? = null,
onClose: () -> Unit,
) {
val context = LocalContext.current
// Same-node check across BOTH node addresses (LAN + mesh ULA) — see the
// kiosk's isSameNode; a mismatch here bounced app links to the browser.
fun isSameNode(u: String): Boolean =
isSameHost(u, serverUrl) || (meshUrl != null && isSameHost(u, meshUrl))
var browser by remember { mutableStateOf<WebView?>(null) }
var title by remember { mutableStateOf(android.net.Uri.parse(url).host ?: url) }
var favicon by remember { mutableStateOf<Bitmap?>(null) }
@@ -809,7 +960,7 @@ private fun InAppBrowser(
error: android.net.http.SslError?,
) {
val u = error?.url
if (u != null && isSameHost(u, serverUrl)) {
if (u != null && isSameNode(u)) {
handler?.proceed()
} else {
handler?.cancel()
@@ -823,7 +974,7 @@ private fun InAppBrowser(
val u = request?.url?.toString() ?: return false
// Stay in the overlay for same-node navigation;
// hand genuinely external links to the real browser.
if (isSameHost(u, serverUrl)) return false
if (isSameNode(u)) return false
openExternalUrl(ctx, u)
return true
}
@@ -11,6 +11,7 @@
<string name="welcome_title">Your Sovereign\nPersonal Server</string>
<string name="welcome_subtitle">Bitcoin node, app platform, and private cloud — all in one box you control.</string>
<string name="get_started">Get Started</string>
<string name="mesh_party">Mesh Party</string>
<string name="use_https">Use HTTPS</string>
<string name="port_label">Port (optional)</string>
<string name="saved_servers">Saved Servers</string>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- FileProvider scope for the party-screen "Share this app" APK handoff. -->
<paths>
<cache-path name="share" path="share/" />
</paths>
+1
View File
@@ -86,6 +86,7 @@ dependencies = [
"getrandom 0.2.17",
"hex",
"jni",
"libc",
"paranoid-android",
"serde_json",
"tokio",
+2
View File
@@ -31,6 +31,8 @@ hex = "0.4"
getrandom = "0.2"
tokio = { version = "1", features = ["rt-multi-thread", "sync", "time", "macros"] }
tracing = "0.1"
# fcntl: force the VpnService TUN fd into blocking mode (see mesh::start).
libc = "0.2"
# The JNI surface only exists on Android; host builds skip it and drive the
# mesh module directly (tests).
+5 -2
View File
@@ -76,8 +76,9 @@ pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_deriveIdentity(
out(&env, json)
}
/// Kotlin: `external fun start(secret: String, peersJson: String, tunFd: Int): String`
/// Kotlin: `external fun start(secret: String, peersJson: String, tunFd: Int, listenPort: Int): String`
/// Returns `{"npub": "...", "address": "..."}` or `{"error": "..."}`.
/// `listenPort` 0 = outbound-only; non-zero = fixed UDP bind (party mode).
#[no_mangle]
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_start(
mut env: JNIEnv,
@@ -85,11 +86,13 @@ pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_start(
secret: JString,
peers_json: JString,
tun_fd: jint,
listen_port: jint,
) -> jstring {
init_logging();
let secret = jstr(&mut env, &secret);
let peers = jstr(&mut env, &peers_json);
let json = match mesh::start(&secret, &peers, tun_fd) {
let listen_port = u16::try_from(listen_port).unwrap_or(0);
let json = match mesh::start(&secret, &peers, tun_fd, listen_port) {
Ok((npub, address)) => {
serde_json::json!({ "npub": npub, "address": address }).to_string()
}
+37 -8
View File
@@ -64,9 +64,14 @@ pub fn derive_identity(secret: &str) -> Result<IdentityInfo> {
}
/// Build the phone-side node config: leaf-only (never routes third-party
/// traffic — battery), ephemeral outbound-only transports, no DNS responder,
/// TUN enabled but attached to the VpnService fd rather than created.
pub fn build_config(secret: &str, peers: Vec<PeerConfig>) -> Config {
/// traffic — battery), no DNS responder, TUN enabled but attached to the
/// VpnService fd rather than created.
///
/// `listen_port` 0 = ephemeral UDP (outbound-only, the default posture).
/// Non-zero = fixed UDP bind so a nearby phone can dial us directly over a
/// local link (party mode); leaf_only still guarantees we never carry
/// third-party transit even while accepting an inbound link.
pub fn build_config(secret: &str, peers: Vec<PeerConfig>, listen_port: u16) -> Config {
let mut cfg = Config::default();
cfg.node.identity.nsec = Some(secret.to_string());
cfg.node.identity.persistent = false;
@@ -74,9 +79,8 @@ pub fn build_config(secret: &str, peers: Vec<PeerConfig>) -> Config {
cfg.tun.enabled = true;
cfg.tun.mtu = Some(1280);
cfg.dns.enabled = false;
// Ephemeral UDP port: outbound dialing works, nothing predictable listens.
cfg.transports.udp = TransportInstances::Single(UdpConfig {
bind_addr: Some("0.0.0.0:0".to_string()),
bind_addr: Some(format!("0.0.0.0:{listen_port}")),
..Default::default()
});
// TCP with no bind_addr = outbound-only (fallback when UDP is blocked).
@@ -94,11 +98,24 @@ pub fn parse_peers(peers_json: &str) -> Result<Vec<PeerConfig>> {
/// Start the mesh node on the given TUN fd (from `VpnService.establish()`,
/// detached — the node owns it from here). Returns (npub, ula) on success.
/// Any previously running node is stopped first.
pub fn start(secret: &str, peers_json: &str, tun_fd: i32) -> Result<(String, String)> {
pub fn start(secret: &str, peers_json: &str, tun_fd: i32, listen_port: u16) -> Result<(String, String)> {
stop();
// Android hands the VpnService TUN fd over in non-blocking mode on some
// OS builds. The fips TUN reader is a dedicated blocking-read thread that
// treats EAGAIN as fatal — the loop died at startup ("TUN read error …
// Try again (os error 11)" on-device), so sessions came up but no packet
// ever entered the mesh. Force the fd into the blocking mode the reader
// is designed for.
unsafe {
let flags = libc::fcntl(tun_fd, libc::F_GETFL);
if flags >= 0 && (flags & libc::O_NONBLOCK) != 0 {
libc::fcntl(tun_fd, libc::F_SETFL, flags & !libc::O_NONBLOCK);
}
}
let peers = parse_peers(peers_json)?;
let config = build_config(secret, peers);
let config = build_config(secret, peers, listen_port);
let mut node = Node::new(config).map_err(|e| anyhow!("node init: {e}"))?;
let npub = node.npub();
let address = node.identity().address().to_ipv6().to_string();
@@ -234,7 +251,7 @@ mod tests {
#[test]
fn config_is_leaf_only_with_tun() {
let id = generate_identity().unwrap();
let cfg = build_config(&id.secret_hex, vec![]);
let cfg = build_config(&id.secret_hex, vec![], 0);
assert!(cfg.node.leaf_only);
assert!(cfg.tun.enabled);
assert_eq!(cfg.tun.mtu(), 1280);
@@ -242,4 +259,16 @@ mod tests {
assert!(!cfg.transports.udp.is_empty());
assert!(!cfg.transports.tcp.is_empty());
}
#[test]
fn listen_port_sets_fixed_udp_bind() {
let id = generate_identity().unwrap();
let cfg = build_config(&id.secret_hex, vec![], 2121);
// Party mode keeps leaf_only — accepting a link is not routing transit.
assert!(cfg.node.leaf_only);
let TransportInstances::Single(udp) = &cfg.transports.udp else {
panic!("expected single UDP transport");
};
assert_eq!(udp.bind_addr.as_deref(), Some("0.0.0.0:2121"));
}
}
+1
View File
@@ -145,6 +145,7 @@ dependencies = [
"serde_yaml",
"serial2-tokio",
"sha2 0.10.9",
"socket2 0.5.10",
"tar",
"tempfile",
"thiserror 1.0.69",
+3
View File
@@ -22,6 +22,9 @@ iroh-swarm = ["dep:iroh", "dep:iroh-blobs"]
[dependencies]
# Core dependencies
tokio = { version = "1", features = ["full"] }
# Mesh port mirror: needs IPV6_V6ONLY on [::] listeners so they coexist with
# the containers' own 0.0.0.0 binds (std/tokio don't expose the sockopt).
socket2 = "0.5"
libc = "0.2" # process-group signalling for the supervised reticulum daemon
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
+22
View File
@@ -991,6 +991,13 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
// B13: fedimint block present but lacking the asset-rewrite sub_filters.
let needs_fedimint_css = content.contains("location /app/fedimint/")
&& !content.contains("'href=\"/' 'href=\"/app/fedimint/'");
// Companion mesh access: phones reach this node over FIPS at its fips0
// ULA (http://[fdxx:…]). Configs shipped before 2026-07-23 listened on
// IPv4 only, so the ULA could never connect — nothing answered [::]:80.
let missing_v6_http = content.contains("listen 80 default_server;")
&& !content.contains("listen [::]:80");
let missing_v6_https = content.contains("listen 443 ssl default_server;")
&& !content.contains("listen [::]:443");
if !missing_app_catalog
&& !missing_bitcoin_status
&& !missing_lnd_proxy
@@ -998,12 +1005,27 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
&& !missing_pine_status
&& !has_lnd_dup_cors
&& !needs_fedimint_css
&& !missing_v6_http
&& !missing_v6_https
{
return Ok(false);
}
let mut patched = content.clone();
if missing_v6_http {
patched = patched.replace(
"listen 80 default_server;",
"listen 80 default_server;\n listen [::]:80 default_server;",
);
}
if missing_v6_https {
patched = patched.replace(
"listen 443 ssl default_server;",
"listen 443 ssl default_server;\n listen [::]:443 ssl default_server;",
);
}
if has_lnd_dup_cors {
// Drop the redundant nginx-side CORS headers so the backend's single
// validated Access-Control-Allow-Origin is the only one returned.
+5
View File
@@ -34,6 +34,7 @@ mod bitcoin_rpc;
mod bitcoin_status;
mod blobs;
mod bootstrap;
mod mesh_ports;
mod ceremony;
mod config;
mod constants;
@@ -395,6 +396,10 @@ async fn main() -> Result<()> {
// iframe on kiosk nodes (docs/tv-input-iframe-apps.md).
tokio::spawn(bootstrap::ensure_gamepad_keys());
// Mesh access: mirror IPv4-published app ports onto [::] so direct-port
// app URLs (http://[<fips0 ULA>]:<port>) work from the companion.
tokio::spawn(mesh_ports::run_mesh_port_mirror());
// Pine voice: re-point IP-pinned Wyoming satellite entries (speakers) when
// DHCP renumbering strands them — HA never re-resolves on its own.
tokio::spawn(api::rpc::wyoming_satellite_keeper());
+145
View File
@@ -0,0 +1,145 @@
//! Mirror IPv4-published app ports onto IPv6 for mesh access.
//!
//! The companion (and anything else on the FIPS mesh) reaches this node at
//! its fips0 ULA. The web UI builds app links from the current host + each
//! app's DIRECT port (Direct Port Rule) — but rootless-podman published
//! ports bind 0.0.0.0 only, so `http://[<ULA>]:8334` was refused for every
//! catalog app even after nginx :80 learned IPv6 (2026-07-23, third
//! "the node wasn't listening on v6" bug of the night).
//!
//! Instead of touching the container layer (port-publish changes would
//! recreate every container), a reconcile loop mirrors the host's public
//! IPv4 listeners: any port ≥1024 listening on 0.0.0.0 without an IPv6
//! any-address listener gets a v6-only `[::]:<port>` forwarder to
//! `127.0.0.1:<port>`. Ports appear/disappear with app install/remove, so
//! the mirror follows `/proc/net/tcp*` rather than the catalog — companion
//! containers with hardcoded ports (bitcoin-ui :8334) are covered the same
//! as manifest-declared ones. Nothing is exposed that IPv4 didn't already
//! expose to the LAN; the ULA is the established remote-access surface.
use std::collections::{HashMap, HashSet};
use std::net::{Ipv6Addr, SocketAddrV6};
use std::time::Duration;
use anyhow::{Context, Result};
use tokio::task::JoinHandle;
use tracing::{debug, info, warn};
const RECONCILE_INTERVAL: Duration = Duration::from_secs(15);
/// Never mirror system ports; nginx (80/443) handles its own v6 listeners.
const MIN_PORT: u16 = 1024;
/// Entry point — spawned from main; runs for the process lifetime.
pub async fn run_mesh_port_mirror() {
let mut active: HashMap<u16, JoinHandle<()>> = HashMap::new();
loop {
match reconcile(&mut active).await {
Ok(()) => {}
Err(e) => debug!("mesh-ports: reconcile skipped: {e:#}"),
}
tokio::time::sleep(RECONCILE_INTERVAL).await;
}
}
async fn reconcile(active: &mut HashMap<u16, JoinHandle<()>>) -> Result<()> {
let v4 = listening_ports("/proc/net/tcp", 8).await?;
let v6 = listening_ports("/proc/net/tcp6", 32).await?;
// Drop mirrors whose backing IPv4 listener vanished (app removed/stopped).
active.retain(|port, handle| {
if v4.contains(port) && !handle.is_finished() {
true
} else {
handle.abort();
info!("mesh-ports: released [::]:{port} (backing 0.0.0.0 listener gone)");
false
}
});
for port in v4 {
if port < MIN_PORT || active.contains_key(&port) {
continue;
}
// A foreign IPv6 any-listener already serves this port (our own
// mirrors are excluded above via `active`).
if v6.contains(&port) {
continue;
}
match spawn_forwarder(port) {
Ok(handle) => {
info!("mesh-ports: mirroring [::]:{port} -> 127.0.0.1:{port}");
active.insert(port, handle);
}
Err(e) => debug!("mesh-ports: cannot mirror port {port}: {e:#}"),
}
}
Ok(())
}
/// Ports in LISTEN state bound to the any-address, parsed from /proc/net/tcp
/// (`addr_hex_len` 8) or /proc/net/tcp6 (32).
async fn listening_ports(path: &str, addr_hex_len: usize) -> Result<HashSet<u16>> {
let raw = tokio::fs::read_to_string(path)
.await
.with_context(|| format!("read {path}"))?;
let any_addr = "0".repeat(addr_hex_len);
let mut ports = HashSet::new();
for line in raw.lines().skip(1) {
let mut cols = line.split_whitespace();
let (Some(_sl), Some(local), Some(_rem), Some(state)) =
(cols.next(), cols.next(), cols.next(), cols.next())
else {
continue;
};
if state != "0A" {
continue; // not LISTEN
}
let Some((addr, port_hex)) = local.split_once(':') else {
continue;
};
if addr != any_addr {
continue;
}
if let Ok(port) = u16::from_str_radix(port_hex, 16) {
ports.insert(port);
}
}
Ok(ports)
}
/// A v6-only listener on [::]:port forwarding each connection to 127.0.0.1:port.
fn spawn_forwarder(port: u16) -> Result<JoinHandle<()>> {
use socket2::{Domain, Protocol, Socket, Type};
let socket = Socket::new(Domain::IPV6, Type::STREAM, Some(Protocol::TCP))
.context("create v6 socket")?;
// v6only so we coexist with the app's own 0.0.0.0:<port> bind.
socket.set_only_v6(true).context("set v6only")?;
socket.set_reuse_address(true).ok();
socket.set_nonblocking(true).context("set nonblocking")?;
let addr = SocketAddrV6::new(Ipv6Addr::UNSPECIFIED, port, 0, 0);
socket.bind(&addr.into()).context("bind [::]")?;
socket.listen(128).context("listen")?;
let listener = tokio::net::TcpListener::from_std(socket.into())
.context("register with tokio")?;
Ok(tokio::spawn(async move {
loop {
let (mut inbound, _peer) = match listener.accept().await {
Ok(conn) => conn,
Err(e) => {
warn!("mesh-ports: accept failed on [::]:{port}: {e}");
tokio::time::sleep(Duration::from_millis(500)).await;
continue;
}
};
tokio::spawn(async move {
match tokio::net::TcpStream::connect(("127.0.0.1", port)).await {
Ok(mut upstream) => {
let _ = tokio::io::copy_bidirectional(&mut inbound, &mut upstream).await;
}
Err(e) => debug!("mesh-ports: upstream 127.0.0.1:{port} refused: {e}"),
}
});
}
}))
}
@@ -0,0 +1,205 @@
# HANDOFF — deploy companion APK 0.5.1 (vc21) to nodes
**For: the agent on archi-dev-box.** User-reported failure this evening:
pairing flow on Framework PT — downloaded the companion from the node's
QR, then the pairing scan didn't work. The APK the node serves predates
today's scanner fixes; the pipeline below gets the fixed build into the
user's hands.
## What changed on main today (all merged)
- **Pairing-scanner fix** (`QrScannerOverlay.kt`): ZXing decode attempts are
frame-gated (~7/s, was every frame — the CPU contention made the preview
stutter badly enough to never decode) and PreviewView uses TextureView (no
more black flash on open). This is the likely fix for "doesn't scan".
- Three-finger menu gesture (was two-finger, collided with scroll) + one-time
teaching overlay ~2 min after login.
- Native wallet QR scanner behind `window.ArchipelagoQr` + WebView file-chooser
support; web scan modal hands live scanning to it.
- npub-keyed saved servers (pairing contract item 1, PR #106).
- Served APK refreshed: `neode-ui/public/packages/archipelago-companion.apk`
is now **0.5.3 / versionCode 23**. On top of the 0.5.1 scanner fixes it
guarantees dual-path peering — the node's LAN endpoint (direct p2p, npub-
keyed dial hints) AND the Archipelago public anchor (vps2, baked into the
app so even an old node's QR can't leave the phone LAN-only) — and fixes
the two field failures from the user's 5G test (screenshots, 21:54):
- **Mesh VPN no longer kills the phone's internet** — the IPv6-only TUN
never called `allowFamily(AF_INET)`, so Android blocked all IPv4 while
the mesh was up. Now allowed (+ `allowBypass`).
- **Off-LAN connect works**`connect()` no longer hard-fails when the
scanned LAN IP doesn't answer; it brings the mesh up and probes the
node's ULA (`meshIp`) with retries before reporting failure.
## What to do
1. Redeploy the web-ui bundle from current main to the active nodes —
web root `/opt/archipelago/web-ui/` (NOT a neode-ui/ subfolder), all
nodes the user pairs against, at minimum the one Framework PT scans.
2. Verify the served artifact really updated:
`curl -sI http://<node>/packages/archipelago-companion.apk` — size should
change (~27 MB build of 2026-07-23), or pull it and check
`aapt dump badging` shows `versionCode='21' versionName='0.5.1'`.
3. The demo stack gets its images from CI (run 100 pushed today with the new
web bundle) — confirm the Portainer stack re-pulled, or trigger its
redeploy, so the demo QR also serves vc21.
4. **Node side is half the 5G story**: away-from-home reachability needs the
NODE connected to the public anchor too. On Framework PT (and any test
node): deploy current main (node-side npub-first `fips.pair-info`), then
verify `sudo -n fipsctl show status` reports the anchor connected —
`fips.reconnect` RPC if not. A phone can dial the anchor perfectly and
still fail if the node never enrolled with it.
5. Re-test the user's exact flows with **vc24** (updates any older install in
place): (a) pair ON the LAN, then switch the phone to 5G — the UI must
come up via the mesh ULA; (b) pair while ALREADY on 5G (never on the
node's LAN) — scan, VPN consent, and the connect must succeed through
the anchor.
## Live diagnosis update (22:3022:50, phone on adb — Mac agent)
vc23 on-device testing found and fixed the phone-side blocker, and narrowed
what remains to the node side. State as of vc24:
- **Fixed: TUN reader died at startup.** Android hands the VpnService fd over
non-blocking; the fips fork's blocking reader thread treats EAGAIN as fatal
("TUN read error … Try again (os error 11)") — so mesh sessions came up but
NO packet ever entered the tunnel. archy-fips-core now forces the fd
blocking before `start_with_tun_fd`. Verified on-device: reader survives,
and the 30s anchor-link flap disappeared with it (stable 8+ min on 5G).
- **Fixed: VPN marked not-metered** (`setMetered(false)`) — Android 10+
defaults VPNs to metered, putting the phone into data-restricted behaviour
while the mesh is up. Note the user's phone also has system **always-on
VPN** enabled for the app (`always_on_vpn_app`), a Settings-side toggle.
- **Verified good on-device**: peer store has node (LAN udp/tcp hints) + vps2
anchor; saved server is npub-keyed with ULA; anchor session establishes
from 5G in ~6s; VPN is bypassable, VALIDATED, only fd00::/8 routed.
- **REMAINING BLOCKER (node side)**: from the phone (app uid), ping6 and
HTTP to the node's ULA `fd79:1aa:b9e9:4c9f:1f80:5376:9385:1824` get no
reply — packets enter the mesh, nothing returns. Phone↔anchor works, so
suspect phone-fork ↔ node-daemon session/routing mismatch (FIPS wire
format is not stable across revs; phone pins fips-native fork 46494a74).
From Framework PT please capture:
- `fipsctl show status` (daemon version + anchor state)
- `fipsctl show sessions` and `show bloom` while the phone pings
- `ping6 <phone ULA fd68:496d:fe34:a06d:cf1:6e4:b6a4:3586>` from the
node (tests the reverse path)
- `ip addr show fips0` + confirm the web server listens on `[::]:80`
Report whether the node ever sees a session attempt from
`npub132c5whrsa6ccs0eylcpzaejq9uxul5ldvczz0axq78dh7fxkqj9st4uvzu`.
## Node-side diagnosis complete (23:0023:30, archi-dev-box agent)
Chain of findings, each verified live:
1. **FIXED: nginx had no IPv6 listener anywhere** — every shipped config
listened on 0.0.0.0 only, so `http://[<ULA>]` could NEVER connect on any
node, ever. Live-fixed on framework-pt + .116, canonical conf + bootstrap
self-heal shipped (`1e89362e`), heal binary deployed. ULA HTTP verified
answering on both nodes (local + over-mesh).
2. Firewall clean, fd00::/8 routes correct both ends, wire compat proven
(node + vps2 anchor both run fips 0.4.1 rev 15db6471db — the latest
upstream stable; nothing newer exists).
3. **THE REMAINING PROBLEM IS MESH SESSION PATH QUALITY.** From the vps2
anchor — a DIRECT connected peer — `GET /health` on the node's ULA takes
**1517 s per request and intermittently fails outright** (nginx logs
show 499 client-gave-up then 200; TCP SYN-retransmit backoff signature).
Session MMP is wildly asymmetric: node→vps2 srtt 204 ms, **vps2→node
srtt 4270 ms** — on a direct link whose raw RTT is 4 ms. Session traffic
is not riding the direct link; it appears to route through the ~1271-node
public tree (node's tree root is the public 00001a8c, depth 8; the node's
log also shows chronic "Discovery lookup timed out" for other targets).
4. The phone's npub never appears in the node's sessions — consistent with
discovery/handshake dying on the same degraded tree path, and the app's
~8 s probe window being far smaller than the observed 15 s+ first-request
latency even on the GOOD path.
### Recommendations
- **App side (Mac agent):** widen the ULA probe/connect window to ≥30 s
with retransmit-friendly pacing, and PRE-WARM the mesh session (start
pinging the node ULA as soon as the VPN is up, decoupled from the UI
probe) so the WebView hits a warm session.
- **Infra decision (user):** consider detaching the fleet from the public
v0l mesh — private tree rooted at the vps2 anchor (drop the legacy
185.18.221.160 seed anchor fleet-wide AND vps2's public peering). A
2-hop private tree would make session paths ride the direct links and
should collapse latency to ms. Trade-off: no reachability to/from the
broader public mesh.
- **Upstream:** report the direct-peer session-path asymmetry to
jmcorgan/fips (0.4.1).
## App-side recommendations implemented (23:3023:50, Mac agent — 0.5.5/vc25)
- Connect probe: mesh ULA now probed inside a **60s budget** with 15s
per-phase timeouts (rides out TCP retransmit backoff), replacing the old
~8s window.
- **Session pre-warm**: the VPN service starts probing every saved node ULA
the moment the tunnel is up (5s cadence for the first minute, then a 60s
keep-warm tick) — discovery/handshake cost is paid in the background, and
the session never idles out while the mesh is connected.
- (A phone-side ping test in this window still showed zero replies — that
measurement predated the vps2 daemon restart below and is superseded.)
## RESOLVED — root cause was vps2's degraded daemon, NOT the public tree (23:45)
The privatize-the-mesh recommendation above is WITHDRAWN. Final diagnosis:
vps2's fips daemon (3 days uptime, 0.2% CPU, idle box) had internally
degraded — EVERY link it carried showed ~4.5 s RTT (even to peers 30 ms
away), and since the anchor sits on the phone↔node path, everything through
it inherited that. `systemctl restart fips` on vps2 restored link RTTs to
40340 ms, and anchor→node mesh HTTP went from 1417 s (intermittent hard
fails) to a steady **165275 ms**. Node↔node direct sessions were always
fine (.116→framework-pt ULA HTTP: 894 ms cold, sub-second warm) — the
user's read was correct.
Actions taken: dead legacy anchor (185.18.221.160) removed from
framework-pt + .116 seed files (fleet keeps vps2 + public-mesh membership
via vps2 — we stay in the open mesh); fresh daemons on both nodes;
**vps2 fips now has RuntimeMaxSec=1d + Restart=always** so a wedged anchor
daemon can never rot for days again. Report the slow-degradation behaviour
upstream (jmcorgan/fips, 0.4.1): long-running daemon in a ~1400-node mesh
accumulates multi-second link latency at idle CPU, cleared by restart.
Phone side: vc25's 60 s probe + pre-warm now has a millisecond-latency mesh
to work with. Ready for the user's 5G test.
## NEXT (00:05, Mac agent → dev-box agent): app direct ports are IPv4-only over the mesh
The kiosk loads over the ULA now — but opening any APP dies with
`ERR_CONNECTION_REFUSED` at `http://[<ULA>]:<port>/`. User-hit first on
**Bitcoin Knots (:8334)**, and it will be every catalog app: the web UI
builds app URLs from the current host + the app's DIRECT port (Direct Port
Rule), and container-published ports only bind 0.0.0.0. Verified:
`192.168.63.249:8334` → HTTP 200 (nginx), ULA:8334 → refused. Same disease
as your :80 nginx fix, one layer down.
Fix must cover EVERY catalog app port and survive app install/remove. Two
shapes; pick what fits the container layer best:
1. **IPv6 publish at the container layer** — publish on `[::]` too
(pasta/rootless podman support address-specific `-p`), wired into the
container manager so new apps inherit it; or
2. **Host-side v6→v4 forwarders** — generated nginx `stream {}` (or
systemd-socket) units: `listen [::]:<port>``127.0.0.1:<port>`, one per
catalog app port, regenerated on app install/remove, boot-time
self-healed like the :80 fix. Keeps the Direct Port Rule URL contract
without touching containers.
Either way: extend the bootstrap self-heal, and verify from the MESH side
(curl the ULA on 23 app ports incl. :8334 from vps2 or .116) — not just
from the LAN.
## DONE (00:30, dev-box agent): app direct ports live over the mesh
Shape 2-variant implemented INSIDE the backend (`mesh_ports.rs`, `2ad57c63`):
a reconcile loop mirrors every public IPv4 listener (>=1024, bound 0.0.0.0,
no existing IPv6 any-listener) as a v6-ONLY `[::]:<port>` forwarder to
`127.0.0.1:<port>`, following `/proc/net/tcp*` every 15s — so app
install/remove and hardcoded companion ports (bitcoin-ui :8334) are covered
with zero container changes and no generated units; self-healing because it
lives in the binary. Strictly ADDITIVE: IPv4/LAN/Tor paths untouched, v6only
cannot intercept v4, foreign IPv6 listeners win.
Verified FROM THE MESH (vps2 → node ULA): :8334 HTTP 200 (466ms),
:18083 200 (306ms), :50002 200 (239ms); LAN :8334 still 200. Deployed to
framework-pt + .116 (binary sha 52ac0d8a…). Direct-port apps should now
open in the companion over 5G.
+7
View File
@@ -112,6 +112,13 @@ broke as soon as the LAN renumbered or the phone left home. FIPS peers on
the vps2 public anchor, and the web UI prepends the node's self-anchor to
`fanchors`.)
App-side status: items 2/3 were already covered by `FipsPreferences.
upsertNodePeer` (npub-matched peers, self-anchor dedup) and item 4 by the
WebView's `meshFallbackUrl` retry. Item 1 shipped 2026-07-23: `ServerEntry`
carries `npub` (trailing serialization field, legacy entries still parse) and
`ServerPreferences` matches saved/active entries via `sameNode` — npub first,
address/port/scheme only as the LAN-only fallback.
## Testing checklist (app side)
- [ ] Scan demo QR from https://demo.archipelago-foundation.org → auto-connected demo session.
@@ -9,6 +9,10 @@ resolver_timeout 5s;
server {
listen 80 default_server;
# IPv6 listener is REQUIRED: companion phones reach this node over the
# FIPS mesh at its fips0 ULA (http://[fdxx:…]) — without [::]:80 that
# address can never connect (found live 2026-07-23, framework-pt).
listen [::]:80 default_server;
server_name _;
root /opt/archipelago/web-ui;
@@ -901,6 +905,7 @@ server {
# HTTPS - required for PWA install (Add to Home Screen) from dev servers
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
server_name _;
ssl_certificate /etc/archipelago/ssl/archipelago.crt;
Binary file not shown.
@@ -357,10 +357,15 @@ async function buildPairingUrl(): Promise<string> {
const selfAnchor = info.tcp_port
? [{ npub: info.npub, addr: `${params.get('fhost')}:${info.tcp_port}`, transport: 'tcp' }]
: []
const anchors = [
...selfAnchor,
...(info.anchors || []).filter((a) => a.npub !== info.npub),
].slice(0, 4)
// HARD CAP at 2 anchors: every extra npub adds ~100 URL-encoded chars
// and pushed the QR past what phone cameras decode off a screen
// (3 anchors 600 chars QR v23 observed unscannable 2026-07-23).
// Self + one public rendezvous is all the phone needs; prefer the
// Archipelago-operated vps2 anchor as the public one.
const others = (info.anchors || []).filter((a) => a.npub !== info.npub)
const publicAnchor =
others.find((a) => a.addr.startsWith('146.59.87.168')) ?? others[0]
const anchors = [...selfAnchor, ...(publicAnchor ? [publicAnchor] : [])].slice(0, 2)
if (anchors.length) {
params.set(
'fanchors',
@@ -385,10 +390,13 @@ async function showPairScreen() {
pairingUrl.value = await buildPairingUrl()
// Large source + a real quiet zone; this QR is scanned by the companion
// app's camera, so give it every advantage (see download QR note above).
// EC level L: the payload is long (token + npub + anchors) and screen
// scans don't suffer the damage EC-M protects against L drops the
// module count a full version tier, which is what makes it scannable.
pairQrDataUrl.value = await QRCode.toDataURL(pairingUrl.value, {
width: 512,
width: 768,
margin: 3,
errorCorrectionLevel: 'M',
errorCorrectionLevel: 'L',
color: {
dark: '#111111',
light: '#ffffff',
+3 -3
View File
@@ -685,9 +685,9 @@ onBeforeUnmount(() => {
min-height: var(--app-session-mobile-bar-height, 84px);
padding: 10px 16px;
padding-bottom: calc(10px + max(var(--safe-area-bottom, 0px), env(safe-area-inset-bottom, 0px), 10px));
background: rgba(0, 0, 0, 0.25);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
/* Solid black, not translucent: the app iframe's theme colour bled
through the bar and its safe-area strip on phones. */
background: #000;
border-top: 1px solid rgba(255, 255, 255, 0.06);
transform: translateZ(0);
}