feat(companion): mesh party + Flare, keep-warm mesh, restored fast-start, UI fixes — 0.5.7 #115

Merged
lfg2025 merged 2 commits from feat/mesh-party-flare-perf into main 2026-07-24 00:08:14 +00:00
17 changed files with 1479 additions and 49 deletions

View File

@ -11,8 +11,8 @@ android {
applicationId = "com.archipelago.app"
minSdk = 26
targetSdk = 35
versionCode = 26
versionName = "0.5.6"
versionCode = 27
versionName = "0.5.7"
vectorDrawables {
useSupportLibrary = true

View File

@ -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()

View File

@ -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)

View File

@ -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

View File

@ -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<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).

View File

@ -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
}
}

View File

@ -0,0 +1,102 @@
package com.archipelago.app.fips
import android.net.Uri
import java.net.Inet4Address
import java.net.NetworkInterface
/**
* Phonephone 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
}
}

View File

@ -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)

View File

@ -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() },
)
}
}

View File

@ -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 phonephone 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
}
}

View File

@ -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 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))
}
// 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
}

View File

@ -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

View File

@ -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
@ -87,28 +95,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
/** 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
}
/** Open a URL in the phone's default browser (genuinely external links). */
private fun openExternalUrl(context: android.content.Context, url: String) {
try {
@ -137,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. */
@ -185,6 +193,13 @@ 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) }
@ -226,7 +241,7 @@ fun WebViewScreen(
// 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) out to the phone's external browser.
// Assistant, BTCPay) out to the phone's external browser.
fun isSameNode(url: String): Boolean =
isSameHost(url, serverUrl) ||
(meshFallbackUrl != null && isSameHost(url, meshFallbackUrl))
@ -669,6 +684,39 @@ 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 ->

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()
}

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,7 +98,7 @@ 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
@ -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"));
}
}

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);
}