diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts index 2786e3a2..de832e18 100644 --- a/Android/app/build.gradle.kts +++ b/Android/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "com.archipelago.app" minSdk = 26 targetSdk = 35 - versionCode = 25 - versionName = "0.5.5" + versionCode = 27 + versionName = "0.5.7" vectorDrawables { useSupportLibrary = true diff --git a/Android/app/src/main/java/com/archipelago/app/fips/ArchyVpnService.kt b/Android/app/src/main/java/com/archipelago/app/fips/ArchyVpnService.kt index c54ae4c1..b13cb22a 100644 --- a/Android/app/src/main/java/com/archipelago/app/fips/ArchyVpnService.kt +++ b/Android/app/src/main/java/com/archipelago/app/fips/ArchyVpnService.kt @@ -51,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() @@ -93,12 +107,14 @@ class ArchyVpnService : VpnService() { } val fd = pfd.detachFd() - val result = FipsNative.start(identity.secret, peersJson, fd) - Log.i(TAG, "mesh start: $result") + 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()) } } @@ -116,18 +132,23 @@ class ArchyVpnService : VpnService() { warmerJob?.cancel() warmerJob = scope.launch { val prefs = ServerPreferences(this@ArchyVpnService) + val fipsPrefs = FipsPreferences(this@ArchyVpnService) var round = 0 while (isActive && FipsNative.isRunning()) { - val ulas = try { - prefs.savedServers.first().mapNotNull { it.meshIp.ifBlank { null } }.distinct() + 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() - } - for (ula in ulas) { + }.distinct() + for ((ula, port) in targets) { try { java.net.Socket().use { s -> s.connect( - java.net.InetSocketAddress(java.net.InetAddress.getByName(ula), 80), + java.net.InetSocketAddress(java.net.InetAddress.getByName(ula), port), 20_000, ) } @@ -146,6 +167,7 @@ class ArchyVpnService : VpnService() { private fun shutdown() { warmerJob?.cancel() + FlareServer.stop() FipsNative.stop() stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() diff --git a/Android/app/src/main/java/com/archipelago/app/fips/FipsManager.kt b/Android/app/src/main/java/com/archipelago/app/fips/FipsManager.kt index 346089ca..a10d473a 100644 --- a/Android/app/src/main/java/com/archipelago/app/fips/FipsManager.kt +++ b/Android/app/src/main/java/com/archipelago/app/fips/FipsManager.kt @@ -21,6 +21,12 @@ object FipsManager { private val _consentNeeded = MutableStateFlow(false) val consentNeeded: StateFlow = _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) diff --git a/Android/app/src/main/java/com/archipelago/app/fips/FipsNative.kt b/Android/app/src/main/java/com/archipelago/app/fips/FipsNative.kt index f81734eb..cd52a13a 100644 --- a/Android/app/src/main/java/com/archipelago/app/fips/FipsNative.kt +++ b/Android/app/src/main/java/com/archipelago/app/fips/FipsNative.kt @@ -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 diff --git a/Android/app/src/main/java/com/archipelago/app/fips/FipsPreferences.kt b/Android/app/src/main/java/com/archipelago/app/fips/FipsPreferences.kt index d0d4df8e..e4392529 100644 --- a/Android/app/src/main/java/com/archipelago/app/fips/FipsPreferences.kt +++ b/Android/app/src/main/java/com/archipelago/app/fips/FipsPreferences.kt @@ -3,9 +3,12 @@ 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 @@ -34,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() @@ -60,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 + 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> + get() = context.fipsDataStore.data.map { parsePartyPeers(it[partyPeersKey] ?: "[]") } + + suspend fun partyPeers(): List = + 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 = 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): 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). diff --git a/Android/app/src/main/java/com/archipelago/app/fips/FlareServer.kt b/Android/app/src/main/java/com/archipelago/app/fips/FlareServer.kt new file mode 100644 index 00000000..58567ecb --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/fips/FlareServer.kt @@ -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>(emptyList()) + val messages: StateFlow> = _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 """ + + + $name — on the mesh +
+

⚡ $name

+
$npub
+

This page is being served by a phone, addressed by its + cryptographic identity over the FIPS mesh.

+

No port forwarding. No DNS. No certificate authority. No cloud. + The key is the address — and the transport underneath can be + 5G, WiFi, or a hotspot with no internet at all.

+
+ """.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 + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/fips/PartyQr.kt b/Android/app/src/main/java/com/archipelago/app/fips/PartyQr.kt new file mode 100644 index 00000000..b79e148e --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/fips/PartyQr.kt @@ -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=&ula=&name=[&ip=&port=] + * + * 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>() // 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 + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt index 929573a3..b05b6da5 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt @@ -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) diff --git a/Android/app/src/main/java/com/archipelago/app/ui/navigation/NavGraph.kt b/Android/app/src/main/java/com/archipelago/app/ui/navigation/NavGraph.kt index 8ec48d87..ed394cd8 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/navigation/NavGraph.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/navigation/NavGraph.kt @@ -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 @@ -179,6 +183,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() }, ) } } diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/FlareScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/FlareScreen.kt new file mode 100644 index 00000000..f9f24a30 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/FlareScreen.kt @@ -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(null) } + var myName by remember { mutableStateOf("Phone") } + val peers by prefs.partyPeersFlow.collectAsState(initial = emptyList()) + var selectedNpub by remember { mutableStateOf(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 + } + } diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/PartyScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/PartyScreen.kt new file mode 100644 index 00000000..a8042182 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/PartyScreen.kt @@ -0,0 +1,366 @@ +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 — phone↔phone 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(null) } + var name by remember { mutableStateOf("") } + var localIp by remember { mutableStateOf(null) } + var showScanner by remember { mutableStateOf(false) } + var scanHint by remember { mutableStateOf(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)) + } + + // 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 +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/RemoteInputScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/RemoteInputScreen.kt index f38cc485..4642dcce 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/screens/RemoteInputScreen.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/RemoteInputScreen.kt @@ -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 diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt index 039e0be2..fd7808ab 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt @@ -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,28 @@ private fun isSameHost(url: String, base: String): Boolean { } } +/** 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 +193,32 @@ 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(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(null) } + var raceNonce by remember { mutableIntStateOf(0) } + LaunchedEffect(serverUrl, meshFallbackUrl, raceNonce) { + 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 +238,14 @@ fun WebViewScreen( // while this is shown, so closing it returns instantly with no reload. var inAppUrl by remember { mutableStateOf(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 +327,13 @@ 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. hasError = false isLoading = true - webView?.loadUrl(serverUrl) + triedMeshFallback = false + startUrl = null + raceNonce++ }, modifier = Modifier.fillMaxWidth().height(56.dp), ) @@ -283,9 +346,16 @@ fun WebViewScreen( 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 -> @@ -319,7 +389,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 +528,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 +665,7 @@ fun WebViewScreen( } webView = this - loadUrl(serverUrl) + loadUrl(initialUrl) } }, ) @@ -614,12 +684,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 +797,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(null) } var title by remember { mutableStateOf(android.net.Uri.parse(url).host ?: url) } var favicon by remember { mutableStateOf(null) } @@ -809,7 +918,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 +932,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 } diff --git a/Android/rust/archy-fips-core/src/jni_glue.rs b/Android/rust/archy-fips-core/src/jni_glue.rs index c433651f..5f891135 100644 --- a/Android/rust/archy-fips-core/src/jni_glue.rs +++ b/Android/rust/archy-fips-core/src/jni_glue.rs @@ -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() } diff --git a/Android/rust/archy-fips-core/src/mesh.rs b/Android/rust/archy-fips-core/src/mesh.rs index 2a76090a..86c1058a 100644 --- a/Android/rust/archy-fips-core/src/mesh.rs +++ b/Android/rust/archy-fips-core/src/mesh.rs @@ -64,9 +64,14 @@ pub fn derive_identity(secret: &str) -> Result { } /// 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) -> 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, 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) -> 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,7 +98,7 @@ pub fn parse_peers(peers_json: &str) -> Result> { /// 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 @@ -111,7 +115,7 @@ pub fn start(secret: &str, peers_json: &str, tun_fd: i32) -> Result<(String, Str } 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(); @@ -247,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); @@ -255,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")); + } } diff --git a/neode-ui/public/packages/archipelago-companion.apk b/neode-ui/public/packages/archipelago-companion.apk index 10d295ec..d48fe5b3 100644 Binary files a/neode-ui/public/packages/archipelago-companion.apk and b/neode-ui/public/packages/archipelago-companion.apk differ diff --git a/neode-ui/src/views/AppSession.vue b/neode-ui/src/views/AppSession.vue index 16e6a8bf..75a84b81 100644 --- a/neode-ui/src/views/AppSession.vue +++ b/neode-ui/src/views/AppSession.vue @@ -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); }