feat(companion): mesh party + Flare, keep-warm mesh, restored fast-start, UI fixes — 0.5.7 #115
@ -11,8 +11,8 @@ android {
|
|||||||
applicationId = "com.archipelago.app"
|
applicationId = "com.archipelago.app"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 26
|
versionCode = 27
|
||||||
versionName = "0.5.6"
|
versionName = "0.5.7"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
|
|||||||
@ -51,13 +51,27 @@ class ArchyVpnService : VpnService() {
|
|||||||
}
|
}
|
||||||
val prefs = FipsPreferences(this)
|
val prefs = FipsPreferences(this)
|
||||||
val identity = FipsManager.ensureIdentity(prefs)
|
val identity = FipsManager.ensureIdentity(prefs)
|
||||||
val peersJson = prefs.peersJson()
|
val peersJson = prefs.combinedPeersJson()
|
||||||
if (identity == null || peersJson == "[]") {
|
if (identity == null || peersJson == "[]") {
|
||||||
Log.w(TAG, "mesh not configured — stopping")
|
Log.w(TAG, "mesh not configured — stopping")
|
||||||
shutdown()
|
shutdown()
|
||||||
return
|
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.
|
// Re-establishing while running would strand the old fd; restart clean.
|
||||||
if (FipsNative.isRunning()) FipsNative.stop()
|
if (FipsNative.isRunning()) FipsNative.stop()
|
||||||
|
|
||||||
@ -93,12 +107,14 @@ class ArchyVpnService : VpnService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val fd = pfd.detachFd()
|
val fd = pfd.detachFd()
|
||||||
val result = FipsNative.start(identity.secret, peersJson, fd)
|
val result = FipsNative.start(identity.secret, peersJson, fd, listenPort)
|
||||||
Log.i(TAG, "mesh start: $result")
|
Log.i(TAG, "mesh start: $result (listen=$listenPort)")
|
||||||
if (result.contains("\"error\"")) {
|
if (result.contains("\"error\"")) {
|
||||||
shutdown()
|
shutdown()
|
||||||
} else {
|
} else {
|
||||||
startSessionWarmer()
|
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?.cancel()
|
||||||
warmerJob = scope.launch {
|
warmerJob = scope.launch {
|
||||||
val prefs = ServerPreferences(this@ArchyVpnService)
|
val prefs = ServerPreferences(this@ArchyVpnService)
|
||||||
|
val fipsPrefs = FipsPreferences(this@ArchyVpnService)
|
||||||
var round = 0
|
var round = 0
|
||||||
while (isActive && FipsNative.isRunning()) {
|
while (isActive && FipsNative.isRunning()) {
|
||||||
val ulas = try {
|
val targets = try {
|
||||||
prefs.savedServers.first().mapNotNull { it.meshIp.ifBlank { null } }.distinct()
|
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) {
|
} catch (_: Exception) {
|
||||||
emptyList()
|
emptyList()
|
||||||
}
|
}.distinct()
|
||||||
for (ula in ulas) {
|
for ((ula, port) in targets) {
|
||||||
try {
|
try {
|
||||||
java.net.Socket().use { s ->
|
java.net.Socket().use { s ->
|
||||||
s.connect(
|
s.connect(
|
||||||
java.net.InetSocketAddress(java.net.InetAddress.getByName(ula), 80),
|
java.net.InetSocketAddress(java.net.InetAddress.getByName(ula), port),
|
||||||
20_000,
|
20_000,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -146,6 +167,7 @@ class ArchyVpnService : VpnService() {
|
|||||||
|
|
||||||
private fun shutdown() {
|
private fun shutdown() {
|
||||||
warmerJob?.cancel()
|
warmerJob?.cancel()
|
||||||
|
FlareServer.stop()
|
||||||
FipsNative.stop()
|
FipsNative.stop()
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
stopSelf()
|
stopSelf()
|
||||||
|
|||||||
@ -21,6 +21,12 @@ object FipsManager {
|
|||||||
private val _consentNeeded = MutableStateFlow(false)
|
private val _consentNeeded = MutableStateFlow(false)
|
||||||
val consentNeeded: StateFlow<Boolean> = _consentNeeded
|
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() {
|
fun consentHandled() {
|
||||||
_consentNeeded.value = false
|
_consentNeeded.value = false
|
||||||
}
|
}
|
||||||
@ -34,6 +40,7 @@ object FipsManager {
|
|||||||
val prefs = FipsPreferences(context)
|
val prefs = FipsPreferences(context)
|
||||||
ensureIdentity(prefs)
|
ensureIdentity(prefs)
|
||||||
prefs.upsertNodePeer(info, alias)
|
prefs.upsertNodePeer(info, alias)
|
||||||
|
peersDirty = true
|
||||||
_consentNeeded.value = true
|
_consentNeeded.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -64,6 +71,22 @@ object FipsManager {
|
|||||||
context.startForegroundService(intent)
|
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) {
|
fun stopService(context: Context) {
|
||||||
val intent = Intent(context, ArchyVpnService::class.java)
|
val intent = Intent(context, ArchyVpnService::class.java)
|
||||||
.setAction(ArchyVpnService.ACTION_STOP)
|
.setAction(ArchyVpnService.ACTION_STOP)
|
||||||
|
|||||||
@ -21,7 +21,13 @@ object FipsNative {
|
|||||||
|
|
||||||
external fun generateIdentity(): String
|
external fun generateIdentity(): String
|
||||||
external fun deriveIdentity(secret: String): 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 stop()
|
||||||
external fun isRunning(): Boolean
|
external fun isRunning(): Boolean
|
||||||
external fun statusJson(): String
|
external fun statusJson(): String
|
||||||
|
|||||||
@ -3,9 +3,12 @@ package com.archipelago.app.fips
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.datastore.core.DataStore
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
@ -34,6 +37,12 @@ class FipsPreferences(private val context: Context) {
|
|||||||
private val addressKey = stringPreferencesKey("fips_address")
|
private val addressKey = stringPreferencesKey("fips_address")
|
||||||
/** JSON array of node peers in fips PeerConfig shape (see NodePeer). */
|
/** JSON array of node peers in fips PeerConfig shape (see NodePeer). */
|
||||||
private val peersKey = stringPreferencesKey("fips_node_peers")
|
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? {
|
suspend fun identity(): FipsNative.Identity? {
|
||||||
val prefs = context.fipsDataStore.data.first()
|
val prefs = context.fipsDataStore.data.first()
|
||||||
@ -60,6 +69,126 @@ class FipsPreferences(private val context: Context) {
|
|||||||
|
|
||||||
suspend fun hasPeers(): Boolean = JSONArray(peersJson()).length() > 0
|
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
|
* Add or update the node peer plus its rendezvous anchors (each matched
|
||||||
* by npub, so re-pairing updates addresses instead of duplicating).
|
* by npub, so re-pairing updates addresses instead of duplicating).
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
102
Android/app/src/main/java/com/archipelago/app/fips/PartyQr.kt
Normal file
102
Android/app/src/main/java/com/archipelago/app/fips/PartyQr.kt
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
package com.archipelago.app.fips
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import java.net.Inet4Address
|
||||||
|
import java.net.NetworkInterface
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phone↔phone mesh pairing ("Mesh Party") QR contract:
|
||||||
|
*
|
||||||
|
* archipelago://party?v=1&npub=<npub>&ula=<fd..>&name=<name>[&ip=<v4>&port=<udp>]
|
||||||
|
*
|
||||||
|
* npub + ula alone are enough to chat *through* the mesh (anchors route by
|
||||||
|
* node address, no underlay info needed). ip/port are present only while the
|
||||||
|
* showing phone has its inbound UDP listener up (party mode) — the scanner
|
||||||
|
* then also gets a direct-dial link that works on a shared LAN/hotspot with
|
||||||
|
* no internet at all. Same versioning stance as the node pairing QR
|
||||||
|
* (docs/companion-pairing-qr.md): unknown params tolerated under v=1.
|
||||||
|
*/
|
||||||
|
data class PartyPeer(
|
||||||
|
val npub: String,
|
||||||
|
val ula: String,
|
||||||
|
val name: String,
|
||||||
|
/** Direct-dial underlay endpoint; empty when the peer wasn't listening. */
|
||||||
|
val ip: String = "",
|
||||||
|
val port: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
object PartyQr {
|
||||||
|
const val SCHEME_HOST = "party"
|
||||||
|
private const val SUPPORTED_MAJOR = 1
|
||||||
|
|
||||||
|
/** UDP port a party-mode phone listens on (matches the node's mesh port). */
|
||||||
|
const val PARTY_UDP_PORT = 2121
|
||||||
|
|
||||||
|
/** Application-layer chat/beam port, bound only on the mesh ULA. */
|
||||||
|
const val FLARE_PORT = 5680
|
||||||
|
|
||||||
|
fun build(npub: String, ula: String, name: String, ip: String?, port: Int): String {
|
||||||
|
val b = Uri.Builder()
|
||||||
|
.scheme("archipelago")
|
||||||
|
.authority(SCHEME_HOST)
|
||||||
|
.appendQueryParameter("v", "1")
|
||||||
|
.appendQueryParameter("npub", npub)
|
||||||
|
.appendQueryParameter("ula", ula)
|
||||||
|
.appendQueryParameter("name", name)
|
||||||
|
if (!ip.isNullOrBlank() && port > 0) {
|
||||||
|
b.appendQueryParameter("ip", ip)
|
||||||
|
b.appendQueryParameter("port", port.toString())
|
||||||
|
}
|
||||||
|
return b.build().toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Null when [raw] is not a valid party QR (foreign codes just keep scanning). */
|
||||||
|
fun parse(raw: String): PartyPeer? {
|
||||||
|
val uri = try {
|
||||||
|
Uri.parse(raw.trim())
|
||||||
|
} catch (_: Exception) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (!"archipelago".equals(uri.scheme, ignoreCase = true)) return null
|
||||||
|
if (uri.isOpaque || !SCHEME_HOST.equals(uri.host, ignoreCase = true)) return null
|
||||||
|
val major = uri.getQueryParameter("v")?.takeWhile { it.isDigit() }?.toIntOrNull() ?: return null
|
||||||
|
if (major != SUPPORTED_MAJOR) return null
|
||||||
|
|
||||||
|
val npub = uri.getQueryParameter("npub")?.trim().orEmpty()
|
||||||
|
val ula = uri.getQueryParameter("ula")?.trim().orEmpty()
|
||||||
|
if (!npub.startsWith("npub1") || !ula.startsWith("fd")) return null
|
||||||
|
return PartyPeer(
|
||||||
|
npub = npub,
|
||||||
|
ula = ula,
|
||||||
|
name = uri.getQueryParameter("name")?.trim().orEmpty().ifBlank { "Phone" },
|
||||||
|
ip = uri.getQueryParameter("ip")?.trim().orEmpty(),
|
||||||
|
port = uri.getQueryParameter("port")?.toIntOrNull() ?: 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This phone's private IPv4 on WiFi or its own hotspot, for the QR's
|
||||||
|
* direct-dial hint. Hotspot interfaces (ap/swlan/softap) win over wlan so
|
||||||
|
* the hotspot-host phone advertises the address its guests can reach.
|
||||||
|
*/
|
||||||
|
fun localWifiIpv4(): String? {
|
||||||
|
val candidates = mutableListOf<Pair<String, String>>() // ifname → addr
|
||||||
|
try {
|
||||||
|
for (nif in NetworkInterface.getNetworkInterfaces()) {
|
||||||
|
if (!nif.isUp || nif.isLoopback) continue
|
||||||
|
for (addr in nif.inetAddresses) {
|
||||||
|
if (addr is Inet4Address && addr.isSiteLocalAddress) {
|
||||||
|
candidates += nif.name to addr.hostAddress.orEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val hotspot = candidates.firstOrNull {
|
||||||
|
it.first.startsWith("ap") || it.first.startsWith("swlan") || it.first.startsWith("softap")
|
||||||
|
}
|
||||||
|
return (hotspot ?: candidates.firstOrNull { it.first.startsWith("wlan") } ?: candidates.firstOrNull())
|
||||||
|
?.second
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -86,6 +86,7 @@ fun NESMenu(
|
|||||||
onToggleMode: () -> Unit,
|
onToggleMode: () -> Unit,
|
||||||
onToggleStyle: () -> Unit,
|
onToggleStyle: () -> Unit,
|
||||||
onBackToWebView: (() -> Unit)? = null,
|
onBackToWebView: (() -> Unit)? = null,
|
||||||
|
onMeshParty: (() -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
|
AnimatedVisibility(visible = visible, enter = fadeIn(), exit = fadeOut()) {
|
||||||
Box(
|
Box(
|
||||||
@ -94,7 +95,7 @@ fun NESMenu(
|
|||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) {
|
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,
|
onToggleMode: () -> Unit,
|
||||||
onToggleStyle: () -> Unit,
|
onToggleStyle: () -> Unit,
|
||||||
onBackToWebView: (() -> Unit)?,
|
onBackToWebView: (() -> Unit)?,
|
||||||
|
onMeshParty: (() -> Unit)?,
|
||||||
) {
|
) {
|
||||||
var showAdd by remember { mutableStateOf(false) }
|
var showAdd by remember { mutableStateOf(false) }
|
||||||
// The saved server being edited, or null when adding a new one.
|
// The saved server being edited, or null when adding a new one.
|
||||||
@ -284,6 +286,11 @@ private fun MenuPanel(
|
|||||||
onClick = onToggleStyle,
|
onClick = onToggleStyle,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Phone↔phone mesh pairing + chat
|
||||||
|
if (onMeshParty != null) {
|
||||||
|
MenuItem(label = "Mesh Party", labelColor = BitcoinOrange, onClick = onMeshParty)
|
||||||
|
}
|
||||||
|
|
||||||
// Back to dashboard
|
// Back to dashboard
|
||||||
if (onBackToWebView != null) {
|
if (onBackToWebView != null) {
|
||||||
MenuItem(label = "Back to Dashboard", onClick = onBackToWebView)
|
MenuItem(label = "Back to Dashboard", onClick = onBackToWebView)
|
||||||
|
|||||||
@ -20,7 +20,9 @@ import com.archipelago.app.data.ServerEntry
|
|||||||
import com.archipelago.app.data.ServerPreferences
|
import com.archipelago.app.data.ServerPreferences
|
||||||
import com.archipelago.app.data.ServerQrParser
|
import com.archipelago.app.data.ServerQrParser
|
||||||
import com.archipelago.app.fips.FipsManager
|
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.IntroScreen
|
||||||
|
import com.archipelago.app.ui.screens.PartyScreen
|
||||||
import com.archipelago.app.ui.screens.RemoteInputScreen
|
import com.archipelago.app.ui.screens.RemoteInputScreen
|
||||||
import com.archipelago.app.ui.screens.ServerConnectScreen
|
import com.archipelago.app.ui.screens.ServerConnectScreen
|
||||||
import com.archipelago.app.ui.screens.WebViewScreen
|
import com.archipelago.app.ui.screens.WebViewScreen
|
||||||
@ -31,6 +33,8 @@ object Routes {
|
|||||||
const val SERVER_CONNECT = "server_connect"
|
const val SERVER_CONNECT = "server_connect"
|
||||||
const val WEB_VIEW = "web_view"
|
const val WEB_VIEW = "web_view"
|
||||||
const val REMOTE_INPUT = "remote_input"
|
const val REMOTE_INPUT = "remote_input"
|
||||||
|
const val MESH_PARTY = "mesh_party"
|
||||||
|
const val FLARE = "flare"
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@ -179,6 +183,22 @@ fun AppNavHost(
|
|||||||
onBack = {
|
onBack = {
|
||||||
navController.popBackStack()
|
navController.popBackStack()
|
||||||
},
|
},
|
||||||
|
onMeshParty = {
|
||||||
|
navController.navigate(Routes.MESH_PARTY)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(Routes.MESH_PARTY) {
|
||||||
|
PartyScreen(
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
onOpenChat = { navController.navigate(Routes.FLARE) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(Routes.FLARE) {
|
||||||
|
FlareScreen(
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,359 @@
|
|||||||
|
package com.archipelago.app.ui.screens
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Image
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.archipelago.app.fips.FipsManager
|
||||||
|
import com.archipelago.app.fips.FipsNative
|
||||||
|
import com.archipelago.app.fips.FipsPreferences
|
||||||
|
import com.archipelago.app.fips.FlareClient
|
||||||
|
import com.archipelago.app.fips.FlareMessage
|
||||||
|
import com.archipelago.app.fips.FlareStore
|
||||||
|
import com.archipelago.app.fips.PartyPeer
|
||||||
|
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||||
|
import com.archipelago.app.ui.theme.SurfaceDark
|
||||||
|
import com.archipelago.app.ui.theme.TextMuted
|
||||||
|
import com.archipelago.app.ui.theme.TextPrimary
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.File
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
private val BubbleTheirs = Color.White.copy(alpha = 0.07f)
|
||||||
|
private val BubbleBorder = Color.White.copy(alpha = 0.08f)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flare — phone↔phone chat and photo beam over the FIPS mesh. Every byte is
|
||||||
|
* E2E encrypted by the mesh layer and addressed by npub; whether it travels
|
||||||
|
* via a public anchor (5G) or a direct hotspot link is invisible up here —
|
||||||
|
* which is the entire point.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun FlareScreen(onBack: () -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val prefs = remember { FipsPreferences(context) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
var identity by remember { mutableStateOf<FipsNative.Identity?>(null) }
|
||||||
|
var myName by remember { mutableStateOf("Phone") }
|
||||||
|
val peers by prefs.partyPeersFlow.collectAsState(initial = emptyList())
|
||||||
|
var selectedNpub by remember { mutableStateOf<String?>(null) }
|
||||||
|
val allMessages by FlareStore.messages.collectAsState()
|
||||||
|
var draft by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
identity = FipsManager.ensureIdentity(prefs)
|
||||||
|
myName = prefs.partyName()
|
||||||
|
}
|
||||||
|
LaunchedEffect(peers) {
|
||||||
|
if (selectedNpub == null || peers.none { it.npub == selectedNpub }) {
|
||||||
|
selectedNpub = peers.firstOrNull()?.npub
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val peer = peers.firstOrNull { it.npub == selectedNpub }
|
||||||
|
val messages = allMessages.filter { it.peerNpub == selectedNpub }
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
LaunchedEffect(messages.size) {
|
||||||
|
if (messages.isNotEmpty()) listState.animateScrollToItem(messages.size - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendText() {
|
||||||
|
val target = peer ?: return
|
||||||
|
val me = identity ?: return
|
||||||
|
val text = draft.trim()
|
||||||
|
if (text.isEmpty()) return
|
||||||
|
draft = ""
|
||||||
|
val msg = FlareMessage(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
peerNpub = target.npub,
|
||||||
|
fromMe = true,
|
||||||
|
name = myName,
|
||||||
|
text = text,
|
||||||
|
ts = System.currentTimeMillis(),
|
||||||
|
status = FlareMessage.Status.SENDING,
|
||||||
|
)
|
||||||
|
FlareStore.add(msg)
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
val ok = FlareClient.sendText(target, me.npub, myName, text)
|
||||||
|
FlareStore.setStatus(msg.id, if (ok) FlareMessage.Status.SENT else FlareMessage.Status.FAILED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendPhoto(uri: Uri) {
|
||||||
|
val target = peer ?: return
|
||||||
|
val me = identity ?: return
|
||||||
|
scope.launch(Dispatchers.IO) {
|
||||||
|
val jpeg = compressPhoto(context, uri) ?: return@launch
|
||||||
|
// Local copy so our own bubble renders the sent photo.
|
||||||
|
val dir = File(context.cacheDir, "flare").apply { mkdirs() }
|
||||||
|
val local = File(dir, "${UUID.randomUUID()}.jpg").apply { writeBytes(jpeg) }
|
||||||
|
val msg = FlareMessage(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
peerNpub = target.npub,
|
||||||
|
fromMe = true,
|
||||||
|
name = myName,
|
||||||
|
photoPath = local.absolutePath,
|
||||||
|
ts = System.currentTimeMillis(),
|
||||||
|
status = FlareMessage.Status.SENDING,
|
||||||
|
)
|
||||||
|
FlareStore.add(msg)
|
||||||
|
val ok = FlareClient.sendPhoto(target, me.npub, myName, jpeg)
|
||||||
|
FlareStore.setStatus(msg.id, if (ok) FlareMessage.Status.SENT else FlareMessage.Status.FAILED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val photoPicker = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.GetContent()
|
||||||
|
) { uri -> uri?.let { sendPhoto(it) } }
|
||||||
|
|
||||||
|
BackHandler { onBack() }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(SurfaceDark)
|
||||||
|
.statusBarsPadding()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.imePadding(),
|
||||||
|
) {
|
||||||
|
// Header
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text("‹ Back", color = TextMuted, fontSize = 15.sp, modifier = Modifier.clickable { onBack() }.padding(6.dp))
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text("FLARE", color = TextPrimary, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 3.sp)
|
||||||
|
peer?.let {
|
||||||
|
Text(
|
||||||
|
it.name + if (it.ip.isNotBlank()) " · direct+mesh" else " · mesh",
|
||||||
|
color = BitcoinOrange,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.size(48.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Peer tabs when chatting with more than one phone
|
||||||
|
if (peers.size > 1) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
peers.forEach { p ->
|
||||||
|
val active = p.npub == selectedNpub
|
||||||
|
Text(
|
||||||
|
p.name,
|
||||||
|
color = if (active) BitcoinOrange else TextMuted,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
|
.background(if (active) BitcoinOrange.copy(alpha = 0.12f) else Color.Transparent)
|
||||||
|
.border(
|
||||||
|
1.dp,
|
||||||
|
if (active) BitcoinOrange.copy(alpha = 0.4f) else BubbleBorder,
|
||||||
|
RoundedCornerShape(10.dp),
|
||||||
|
)
|
||||||
|
.clickable { selectedNpub = p.npub }
|
||||||
|
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (peer == null) {
|
||||||
|
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
|
Text("No party peers yet — scan a phone first", color = TextMuted, fontSize = 14.sp)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||||
|
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
items(messages, key = { it.id }) { msg ->
|
||||||
|
MessageBubble(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Composer
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(BubbleTheirs)
|
||||||
|
.border(1.dp, BubbleBorder, RoundedCornerShape(12.dp))
|
||||||
|
.clickable { photoPicker.launch("image/*") },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Image, "Beam a photo", tint = BitcoinOrange, modifier = Modifier.size(22.dp))
|
||||||
|
}
|
||||||
|
OutlinedTextField(
|
||||||
|
value = draft,
|
||||||
|
onValueChange = { draft = it },
|
||||||
|
placeholder = { Text("Send a flare…", color = TextMuted, fontSize = 14.sp) },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
|
||||||
|
keyboardActions = KeyboardActions(onSend = { sendText() }),
|
||||||
|
textStyle = TextStyle(color = TextPrimary, fontSize = 15.sp),
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = Color.White.copy(alpha = 0.3f),
|
||||||
|
unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
|
||||||
|
cursorColor = BitcoinOrange,
|
||||||
|
focusedTextColor = TextPrimary,
|
||||||
|
unfocusedTextColor = TextPrimary,
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(BitcoinOrange.copy(alpha = 0.15f))
|
||||||
|
.border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp))
|
||||||
|
.clickable { sendText() },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text("➤", color = BitcoinOrange, fontSize = 18.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MessageBubble(msg: FlareMessage) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = if (msg.fromMe) Arrangement.End else Arrangement.Start,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.widthIn(max = 300.dp)
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(if (msg.fromMe) BitcoinOrange.copy(alpha = 0.14f) else BubbleTheirs)
|
||||||
|
.border(
|
||||||
|
1.dp,
|
||||||
|
if (msg.fromMe) BitcoinOrange.copy(alpha = 0.35f) else BubbleBorder,
|
||||||
|
RoundedCornerShape(16.dp),
|
||||||
|
)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
if (msg.photoPath.isNotBlank()) {
|
||||||
|
val bmp = remember(msg.photoPath) { BitmapFactory.decodeFile(msg.photoPath) }
|
||||||
|
bmp?.let {
|
||||||
|
Image(
|
||||||
|
bitmap = it.asImageBitmap(),
|
||||||
|
contentDescription = "Beamed photo",
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(10.dp)),
|
||||||
|
contentScale = ContentScale.FillWidth,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (msg.text.isNotBlank()) {
|
||||||
|
Text(msg.text, color = TextPrimary, fontSize = 15.sp)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
when (msg.status) {
|
||||||
|
FlareMessage.Status.SENDING -> "sending…"
|
||||||
|
FlareMessage.Status.SENT -> "sent · E2E via mesh"
|
||||||
|
FlareMessage.Status.FAILED -> "failed — tap to retry later"
|
||||||
|
FlareMessage.Status.RECEIVED -> msg.name
|
||||||
|
},
|
||||||
|
color = if (msg.status == FlareMessage.Status.FAILED) BitcoinOrange else TextMuted,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decode, downscale (≤1600px) and JPEG-compress a picked photo off-main. */
|
||||||
|
private suspend fun compressPhoto(context: android.content.Context, uri: Uri): ByteArray? =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
context.contentResolver.openInputStream(uri)?.use {
|
||||||
|
BitmapFactory.decodeStream(it, null, bounds)
|
||||||
|
}
|
||||||
|
var sample = 1
|
||||||
|
while (maxOf(bounds.outWidth, bounds.outHeight) / sample > 1600) sample *= 2
|
||||||
|
val opts = BitmapFactory.Options().apply { inSampleSize = sample }
|
||||||
|
val bitmap = context.contentResolver.openInputStream(uri)?.use {
|
||||||
|
BitmapFactory.decodeStream(it, null, opts)
|
||||||
|
} ?: return@withContext null
|
||||||
|
val out = ByteArrayOutputStream()
|
||||||
|
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, out)
|
||||||
|
bitmap.recycle()
|
||||||
|
out.toByteArray()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<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
|
||||||
|
}
|
||||||
@ -54,7 +54,7 @@ import com.archipelago.app.ui.theme.TextMuted
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RemoteInputScreen(onBack: () -> Unit) {
|
fun RemoteInputScreen(onBack: () -> Unit, onMeshParty: (() -> Unit)? = null) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val prefs = remember { ServerPreferences(context) }
|
val prefs = remember { ServerPreferences(context) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@ -250,6 +250,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
|
|||||||
controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC
|
controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC
|
||||||
},
|
},
|
||||||
onBackToWebView = { showModal = false; onBack() },
|
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
|
// Pairing-QR scan launched from the menu's Add Server row. The menu stays
|
||||||
|
|||||||
@ -35,8 +35,10 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.safeDrawing
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBars
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsTopHeight
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
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.graphics.asImageBitmap
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
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.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import android.webkit.ValueCallback
|
import android.webkit.ValueCallback
|
||||||
import com.archipelago.app.R
|
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.GestureHintOverlay
|
||||||
import com.archipelago.app.ui.components.WalletQrScannerModal
|
import com.archipelago.app.ui.components.WalletQrScannerModal
|
||||||
import com.archipelago.app.ui.theme.BitcoinOrange
|
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.SurfaceBlack
|
||||||
import com.archipelago.app.ui.theme.TextMuted
|
import com.archipelago.app.ui.theme.TextMuted
|
||||||
import com.archipelago.app.ui.theme.TextPrimary
|
import com.archipelago.app.ui.theme.TextPrimary
|
||||||
@ -87,28 +95,6 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.json.JSONObject
|
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). */
|
/** Open a URL in the phone's default browser (genuinely external links). */
|
||||||
private fun openExternalUrl(context: android.content.Context, url: String) {
|
private fun openExternalUrl(context: android.content.Context, url: String) {
|
||||||
try {
|
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.
|
/** 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;
|
* These are tuned for SPA performance and parity with the mobile browser;
|
||||||
* none of them alter how a page renders visually. */
|
* none of them alter how a page renders visually. */
|
||||||
@ -185,6 +193,13 @@ fun WebViewScreen(
|
|||||||
meshFallbackUrl: String? = null,
|
meshFallbackUrl: String? = null,
|
||||||
) {
|
) {
|
||||||
var isLoading by remember { mutableStateOf(true) }
|
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 loadProgress by remember { mutableIntStateOf(0) }
|
||||||
var triedMeshFallback by remember { mutableStateOf(false) }
|
var triedMeshFallback by remember { mutableStateOf(false) }
|
||||||
var hasError 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
|
// 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) —
|
// the ULA while app links may carry the LAN IP (and vice versa) —
|
||||||
// comparing against one host bounced same-node apps (Pine, Home
|
// 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 =
|
fun isSameNode(url: String): Boolean =
|
||||||
isSameHost(url, serverUrl) ||
|
isSameHost(url, serverUrl) ||
|
||||||
(meshFallbackUrl != null && isSameHost(url, meshFallbackUrl))
|
(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
|
// In-app browser overlay for non-iframeable node apps. Rendered last
|
||||||
// so it sits above the kiosk WebView, which stays alive underneath.
|
// so it sits above the kiosk WebView, which stays alive underneath.
|
||||||
inAppUrl?.let { target ->
|
inAppUrl?.let { target ->
|
||||||
|
|||||||
@ -76,8 +76,9 @@ pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_deriveIdentity(
|
|||||||
out(&env, json)
|
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": "..."}`.
|
/// Returns `{"npub": "...", "address": "..."}` or `{"error": "..."}`.
|
||||||
|
/// `listenPort` 0 = outbound-only; non-zero = fixed UDP bind (party mode).
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_start(
|
pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_start(
|
||||||
mut env: JNIEnv,
|
mut env: JNIEnv,
|
||||||
@ -85,11 +86,13 @@ pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_start(
|
|||||||
secret: JString,
|
secret: JString,
|
||||||
peers_json: JString,
|
peers_json: JString,
|
||||||
tun_fd: jint,
|
tun_fd: jint,
|
||||||
|
listen_port: jint,
|
||||||
) -> jstring {
|
) -> jstring {
|
||||||
init_logging();
|
init_logging();
|
||||||
let secret = jstr(&mut env, &secret);
|
let secret = jstr(&mut env, &secret);
|
||||||
let peers = jstr(&mut env, &peers_json);
|
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)) => {
|
Ok((npub, address)) => {
|
||||||
serde_json::json!({ "npub": npub, "address": address }).to_string()
|
serde_json::json!({ "npub": npub, "address": address }).to_string()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -64,9 +64,14 @@ pub fn derive_identity(secret: &str) -> Result<IdentityInfo> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build the phone-side node config: leaf-only (never routes third-party
|
/// Build the phone-side node config: leaf-only (never routes third-party
|
||||||
/// traffic — battery), ephemeral outbound-only transports, no DNS responder,
|
/// traffic — battery), no DNS responder, TUN enabled but attached to the
|
||||||
/// TUN enabled but attached to the VpnService fd rather than created.
|
/// VpnService fd rather than created.
|
||||||
pub fn build_config(secret: &str, peers: Vec<PeerConfig>) -> Config {
|
///
|
||||||
|
/// `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();
|
let mut cfg = Config::default();
|
||||||
cfg.node.identity.nsec = Some(secret.to_string());
|
cfg.node.identity.nsec = Some(secret.to_string());
|
||||||
cfg.node.identity.persistent = false;
|
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.enabled = true;
|
||||||
cfg.tun.mtu = Some(1280);
|
cfg.tun.mtu = Some(1280);
|
||||||
cfg.dns.enabled = false;
|
cfg.dns.enabled = false;
|
||||||
// Ephemeral UDP port: outbound dialing works, nothing predictable listens.
|
|
||||||
cfg.transports.udp = TransportInstances::Single(UdpConfig {
|
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()
|
..Default::default()
|
||||||
});
|
});
|
||||||
// TCP with no bind_addr = outbound-only (fallback when UDP is blocked).
|
// 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()`,
|
/// Start the mesh node on the given TUN fd (from `VpnService.establish()`,
|
||||||
/// detached — the node owns it from here). Returns (npub, ula) on success.
|
/// detached — the node owns it from here). Returns (npub, ula) on success.
|
||||||
/// Any previously running node is stopped first.
|
/// 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();
|
stop();
|
||||||
|
|
||||||
// Android hands the VpnService TUN fd over in non-blocking mode on some
|
// 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 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 mut node = Node::new(config).map_err(|e| anyhow!("node init: {e}"))?;
|
||||||
let npub = node.npub();
|
let npub = node.npub();
|
||||||
let address = node.identity().address().to_ipv6().to_string();
|
let address = node.identity().address().to_ipv6().to_string();
|
||||||
@ -247,7 +251,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn config_is_leaf_only_with_tun() {
|
fn config_is_leaf_only_with_tun() {
|
||||||
let id = generate_identity().unwrap();
|
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.node.leaf_only);
|
||||||
assert!(cfg.tun.enabled);
|
assert!(cfg.tun.enabled);
|
||||||
assert_eq!(cfg.tun.mtu(), 1280);
|
assert_eq!(cfg.tun.mtu(), 1280);
|
||||||
@ -255,4 +259,16 @@ mod tests {
|
|||||||
assert!(!cfg.transports.udp.is_empty());
|
assert!(!cfg.transports.udp.is_empty());
|
||||||
assert!(!cfg.transports.tcp.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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@ -685,9 +685,9 @@ onBeforeUnmount(() => {
|
|||||||
min-height: var(--app-session-mobile-bar-height, 84px);
|
min-height: var(--app-session-mobile-bar-height, 84px);
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
padding-bottom: calc(10px + max(var(--safe-area-bottom, 0px), env(safe-area-inset-bottom, 0px), 10px));
|
padding-bottom: calc(10px + max(var(--safe-area-bottom, 0px), env(safe-area-inset-bottom, 0px), 10px));
|
||||||
background: rgba(0, 0, 0, 0.25);
|
/* Solid black, not translucent: the app iframe's theme colour bled
|
||||||
backdrop-filter: blur(18px);
|
through the bar and its safe-area strip on phones. */
|
||||||
-webkit-backdrop-filter: blur(18px);
|
background: #000;
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
transform: translateZ(0);
|
transform: translateZ(0);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user