Merge pull request 'feat(companion): night-test polish sweep — 0.5.9' (#117) from feat/final-polish into main
All checks were successful
Demo images / Build & push demo images (push) Successful in 2m45s
All checks were successful
Demo images / Build & push demo images (push) Successful in 2m45s
This commit is contained in:
commit
f2b6028ca2
@ -11,8 +11,8 @@ android {
|
|||||||
applicationId = "com.archipelago.app"
|
applicationId = "com.archipelago.app"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 28
|
versionCode = 29
|
||||||
versionName = "0.5.8"
|
versionName = "0.5.9"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
|
|||||||
@ -115,6 +115,15 @@ class ArchyVpnService : VpnService() {
|
|||||||
startSessionWarmer()
|
startSessionWarmer()
|
||||||
// Phone-to-phone chat/beam + the phone's own mesh-served page.
|
// Phone-to-phone chat/beam + the phone's own mesh-served page.
|
||||||
FlareServer.start(this, identity.address, identity.npub, prefs.partyName())
|
FlareServer.start(this, identity.address, identity.npub, prefs.partyName())
|
||||||
|
// Mutual pairing: when a phone that scanned OUR QR announces
|
||||||
|
// itself, store it as a party peer and re-run the mesh config so
|
||||||
|
// this side gets the peer + chat entry without scanning back.
|
||||||
|
FlareServer.onHello = { peer ->
|
||||||
|
scope.launch {
|
||||||
|
prefs.upsertPartyPeer(peer)
|
||||||
|
FipsManager.requestMeshRestart(this@ArchyVpnService)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -72,6 +72,12 @@ object FlareServer {
|
|||||||
@Volatile private var identityNpub = ""
|
@Volatile private var identityNpub = ""
|
||||||
@Volatile private var photoDir: File? = null
|
@Volatile private var photoDir: File? = null
|
||||||
|
|
||||||
|
/** Invoked when a peer announces itself (POST /hello) — pairing used to
|
||||||
|
* be one-way: only the SCANNING phone learned the other side, so the
|
||||||
|
* scanned phone had no peer, no chat entry, no way in. The VPN service
|
||||||
|
* wires this to upsert the peer + restart the mesh config. */
|
||||||
|
@Volatile var onHello: ((PartyPeer) -> Unit)? = null
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun start(context: Context, ula: String, myNpub: String, myName: String) {
|
fun start(context: Context, ula: String, myNpub: String, myName: String) {
|
||||||
stop()
|
stop()
|
||||||
@ -145,6 +151,12 @@ object FlareServer {
|
|||||||
when {
|
when {
|
||||||
method == "GET" && (path == "/" || path.startsWith("/?")) ->
|
method == "GET" && (path == "/" || path.startsWith("/?")) ->
|
||||||
respondHtml(sock, profilePage())
|
respondHtml(sock, profilePage())
|
||||||
|
method == "POST" && path == "/hello" -> {
|
||||||
|
if (contentLength !in 1..MAX_HEADER_BYTES) return respond(sock, 413, json("too_large"))
|
||||||
|
val body = readExactly(input, contentLength) ?: return
|
||||||
|
receiveHello(String(body, Charsets.UTF_8))
|
||||||
|
respond(sock, 200, """{"ok":true}""")
|
||||||
|
}
|
||||||
method == "POST" && path == "/flare" -> {
|
method == "POST" && path == "/flare" -> {
|
||||||
if (contentLength !in 1..MAX_HEADER_BYTES) return respond(sock, 413, json("too_large"))
|
if (contentLength !in 1..MAX_HEADER_BYTES) return respond(sock, 413, json("too_large"))
|
||||||
val body = readExactly(input, contentLength) ?: return
|
val body = readExactly(input, contentLength) ?: return
|
||||||
@ -166,6 +178,38 @@ object FlareServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A peer that scanned OUR QR announces itself — mutual pairing. */
|
||||||
|
private fun receiveHello(body: String) {
|
||||||
|
val o = try {
|
||||||
|
JSONObject(body)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val from = o.optString("from")
|
||||||
|
val ula = o.optString("ula")
|
||||||
|
if (!from.startsWith("npub1") || !ula.startsWith("fd")) return
|
||||||
|
if (from == identityNpub) return
|
||||||
|
val peer = PartyPeer(
|
||||||
|
npub = from.take(80),
|
||||||
|
ula = ula.take(64),
|
||||||
|
name = o.optString("name").take(24).ifBlank { "Phone" },
|
||||||
|
ip = o.optString("ip").take(40),
|
||||||
|
port = o.optInt("port", 0),
|
||||||
|
)
|
||||||
|
onHello?.invoke(peer)
|
||||||
|
// Seed the conversation so the chat has a visible entry on this side.
|
||||||
|
FlareStore.add(
|
||||||
|
FlareMessage(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
peerNpub = peer.npub,
|
||||||
|
fromMe = false,
|
||||||
|
name = peer.name,
|
||||||
|
text = "👋 ${peer.name} joined the party",
|
||||||
|
ts = System.currentTimeMillis(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun receiveFlare(body: String) {
|
private fun receiveFlare(body: String) {
|
||||||
val o = try {
|
val o = try {
|
||||||
JSONObject(body)
|
JSONObject(body)
|
||||||
@ -296,6 +340,32 @@ object FlareClient {
|
|||||||
|
|
||||||
private fun base(peer: PartyPeer) = "http://[${peer.ula}]:${PartyQr.FLARE_PORT}"
|
private fun base(peer: PartyPeer) = "http://[${peer.ula}]:${PartyQr.FLARE_PORT}"
|
||||||
|
|
||||||
|
/** Announce myself to a freshly scanned peer so pairing becomes MUTUAL —
|
||||||
|
* their phone gets me as a peer + a chat entry without scanning back.
|
||||||
|
* Blocking — call from Dispatchers.IO. */
|
||||||
|
fun sendHello(
|
||||||
|
peer: PartyPeer,
|
||||||
|
myNpub: String,
|
||||||
|
myName: String,
|
||||||
|
myUla: String,
|
||||||
|
myIp: String?,
|
||||||
|
myPort: Int,
|
||||||
|
): Boolean = try {
|
||||||
|
val body = JSONObject()
|
||||||
|
.put("from", myNpub)
|
||||||
|
.put("name", myName)
|
||||||
|
.put("ula", myUla)
|
||||||
|
.put("ip", myIp ?: "")
|
||||||
|
.put("port", if (myIp != null) myPort else 0)
|
||||||
|
.toString()
|
||||||
|
.toRequestBody("application/json".toMediaType())
|
||||||
|
http.newCall(
|
||||||
|
Request.Builder().url("${base(peer)}/hello").post(body).build()
|
||||||
|
).execute().use { it.isSuccessful }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
/** Blocking — call from Dispatchers.IO. */
|
/** Blocking — call from Dispatchers.IO. */
|
||||||
fun sendText(peer: PartyPeer, myNpub: String, myName: String, text: String): Boolean = try {
|
fun sendText(peer: PartyPeer, myNpub: String, myName: String, text: String): Boolean = try {
|
||||||
val body = JSONObject()
|
val body = JSONObject()
|
||||||
|
|||||||
@ -0,0 +1,60 @@
|
|||||||
|
package com.archipelago.app.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.archipelago.app.ui.screens.PixelArtLogo
|
||||||
|
import com.archipelago.app.ui.theme.BitcoinOrange
|
||||||
|
import com.archipelago.app.ui.theme.SurfaceBlack
|
||||||
|
import com.archipelago.app.ui.theme.TextMuted
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The branded "F*CK IPs" full-screen loader — shown whenever the app is
|
||||||
|
* dialing the node over the mesh (relaunch race, post-scan first connect),
|
||||||
|
* instead of an anonymous spinner. The point of the brand: what's loading
|
||||||
|
* is a connection to a cryptographic identity, not an IP.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MeshLoadingScreen(message: String = "Dialing your node by its key — no IPs harmed") {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(SurfaceBlack),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
PixelArtLogo(Modifier.size(96.dp))
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
Text(
|
||||||
|
text = "F*CK IPs MESH",
|
||||||
|
color = BitcoinOrange,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
letterSpacing = 4.sp,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
color = TextMuted,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
CircularProgressIndicator(color = BitcoinOrange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -52,6 +52,7 @@ import androidx.compose.ui.unit.sp
|
|||||||
import com.archipelago.app.fips.FipsManager
|
import com.archipelago.app.fips.FipsManager
|
||||||
import com.archipelago.app.fips.FipsNative
|
import com.archipelago.app.fips.FipsNative
|
||||||
import com.archipelago.app.fips.FipsPreferences
|
import com.archipelago.app.fips.FipsPreferences
|
||||||
|
import com.archipelago.app.fips.FlareClient
|
||||||
import com.archipelago.app.fips.PartyPeer
|
import com.archipelago.app.fips.PartyPeer
|
||||||
import com.archipelago.app.fips.PartyQr
|
import com.archipelago.app.fips.PartyQr
|
||||||
import com.archipelago.app.ui.components.CameraQrPreview
|
import com.archipelago.app.ui.components.CameraQrPreview
|
||||||
@ -70,6 +71,11 @@ import kotlinx.coroutines.withContext
|
|||||||
private val CardBg = Color.White.copy(alpha = 0.05f)
|
private val CardBg = Color.White.copy(alpha = 0.05f)
|
||||||
private val CardBorder = Color.White.copy(alpha = 0.08f)
|
private val CardBorder = Color.White.copy(alpha = 0.08f)
|
||||||
|
|
||||||
|
/** Public companion download (vps2 demo host — serves the same APK as the
|
||||||
|
* nodes' QR link). Rendered as the "Share this app" QR. */
|
||||||
|
private const val APP_DOWNLOAD_URL =
|
||||||
|
"http://146.59.87.168:2100/packages/archipelago-companion.apk"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mesh Party — phone↔phone FIPS pairing. Show your QR, scan theirs, and the
|
* 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,
|
* two embedded mesh nodes link up: through anchors when there's internet,
|
||||||
@ -88,7 +94,27 @@ fun PartyScreen(
|
|||||||
var name by remember { mutableStateOf("") }
|
var name by remember { mutableStateOf("") }
|
||||||
var localIp by remember { mutableStateOf<String?>(null) }
|
var localIp by remember { mutableStateOf<String?>(null) }
|
||||||
var showScanner by remember { mutableStateOf(false) }
|
var showScanner by remember { mutableStateOf(false) }
|
||||||
|
var showShareQr by remember { mutableStateOf(false) }
|
||||||
var scanHint by remember { mutableStateOf<String?>(null) }
|
var scanHint by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
// Camera permission — fresh installs (and reinstalls: uninstall wipes
|
||||||
|
// grants) land here with no CAMERA grant, and the raw preview just showed
|
||||||
|
// black. Ask the moment the scanner opens.
|
||||||
|
var hasCamera by remember {
|
||||||
|
mutableStateOf(
|
||||||
|
androidx.core.content.ContextCompat.checkSelfPermission(
|
||||||
|
context, android.Manifest.permission.CAMERA,
|
||||||
|
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val cameraPermLauncher = androidx.activity.compose.rememberLauncherForActivityResult(
|
||||||
|
androidx.activity.result.contract.ActivityResultContracts.RequestPermission()
|
||||||
|
) { hasCamera = it }
|
||||||
|
LaunchedEffect(showScanner) {
|
||||||
|
if (showScanner && !hasCamera) {
|
||||||
|
cameraPermLauncher.launch(android.Manifest.permission.CAMERA)
|
||||||
|
}
|
||||||
|
}
|
||||||
val listenOn by prefs.partyListenFlow.collectAsState(initial = false)
|
val listenOn by prefs.partyListenFlow.collectAsState(initial = false)
|
||||||
val peers by prefs.partyPeersFlow.collectAsState(initial = emptyList())
|
val peers by prefs.partyPeersFlow.collectAsState(initial = emptyList())
|
||||||
|
|
||||||
@ -114,7 +140,13 @@ fun PartyScreen(
|
|||||||
}
|
}
|
||||||
val qrBitmap = remember(qrPayload) { qrPayload?.let { renderQr(it) } }
|
val qrBitmap = remember(qrPayload) { qrPayload?.let { renderQr(it) } }
|
||||||
|
|
||||||
BackHandler { if (showScanner) showScanner = false else onBack() }
|
BackHandler {
|
||||||
|
when {
|
||||||
|
showScanner -> showScanner = false
|
||||||
|
showShareQr -> showShareQr = false
|
||||||
|
else -> onBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Box(Modifier.fillMaxSize().background(SurfaceDark)) {
|
Box(Modifier.fillMaxSize().background(SurfaceDark)) {
|
||||||
Column(
|
Column(
|
||||||
@ -287,16 +319,81 @@ fun PartyScreen(
|
|||||||
// premise.
|
// premise.
|
||||||
GlassButton(
|
GlassButton(
|
||||||
text = "Share this app",
|
text = "Share this app",
|
||||||
onClick = { shareCompanionApk(context) },
|
onClick = { showShareQr = true },
|
||||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "Share this app" — a QR of the public download link, so the other
|
||||||
|
// phone scans it with its normal camera and installs over any
|
||||||
|
// internet. (The vps2 demo host serves the same APK the nodes do.)
|
||||||
|
AnimatedVisibility(visible = showShareQr, enter = fadeIn(), exit = fadeOut()) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black.copy(alpha = 0.94f))
|
||||||
|
.clickable { showShareQr = false },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
val dlQr = remember { renderQr(APP_DOWNLOAD_URL) }
|
||||||
|
dlQr?.let { bmp ->
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(Color.White)
|
||||||
|
.padding(14.dp),
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
bitmap = bmp.asImageBitmap(),
|
||||||
|
contentDescription = "Companion download QR",
|
||||||
|
modifier = Modifier.size(240.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(18.dp))
|
||||||
|
Text(
|
||||||
|
"Scan with any camera to download\nthe Archipelago Companion",
|
||||||
|
color = TextPrimary,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Text(
|
||||||
|
"…or send the APK file directly",
|
||||||
|
color = BitcoinOrange,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
modifier = Modifier.clickable { shareCompanionApk(context) }.padding(8.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
Text("Close", color = TextMuted, fontSize = 14.sp, modifier = Modifier.clickable { showShareQr = false }.padding(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Party QR scanner overlay
|
// Party QR scanner overlay
|
||||||
AnimatedVisibility(visible = showScanner, enter = fadeIn(), exit = fadeOut()) {
|
AnimatedVisibility(visible = showScanner, enter = fadeIn(), exit = fadeOut()) {
|
||||||
Box(Modifier.fillMaxSize().background(Color.Black)) {
|
Box(Modifier.fillMaxSize().background(Color.Black)) {
|
||||||
CameraQrPreview(onDecoded = { text ->
|
if (!hasCamera) {
|
||||||
|
Column(
|
||||||
|
Modifier.align(Alignment.Center).padding(horizontal = 32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Camera access is needed to scan a party QR.",
|
||||||
|
color = TextPrimary,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
GlassButton(
|
||||||
|
text = "Grant camera access",
|
||||||
|
onClick = { cameraPermLauncher.launch(android.Manifest.permission.CAMERA) },
|
||||||
|
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else CameraQrPreview(onDecoded = { text ->
|
||||||
val peer = PartyQr.parse(text)
|
val peer = PartyQr.parse(text)
|
||||||
when {
|
when {
|
||||||
peer == null -> scanHint = "Not a mesh party QR"
|
peer == null -> scanHint = "Not a mesh party QR"
|
||||||
@ -309,6 +406,27 @@ fun PartyScreen(
|
|||||||
// Pick up the direct-dial PeerConfig (and the
|
// Pick up the direct-dial PeerConfig (and the
|
||||||
// listener, if ours is on) immediately.
|
// listener, if ours is on) immediately.
|
||||||
FipsManager.requestMeshRestart(context)
|
FipsManager.requestMeshRestart(context)
|
||||||
|
// Pairing must be MUTUAL: announce ourselves so
|
||||||
|
// the scanned phone gets us as a peer + a chat
|
||||||
|
// entry without scanning back. Retried while
|
||||||
|
// the fresh link/session comes up.
|
||||||
|
val me = identity
|
||||||
|
if (me != null) {
|
||||||
|
launch(Dispatchers.IO) {
|
||||||
|
for (attempt in 0 until 6) {
|
||||||
|
val ok = FlareClient.sendHello(
|
||||||
|
peer = peer,
|
||||||
|
myNpub = me.npub,
|
||||||
|
myName = name.ifBlank { "Phone" },
|
||||||
|
myUla = me.address,
|
||||||
|
myIp = if (listenOn) localIp else null,
|
||||||
|
myPort = PartyQr.PARTY_UDP_PORT,
|
||||||
|
)
|
||||||
|
if (ok) break
|
||||||
|
delay(3_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
onOpenChat()
|
onOpenChat()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -75,6 +75,7 @@ import com.archipelago.app.data.PairResult
|
|||||||
import com.archipelago.app.data.ServerEntry
|
import com.archipelago.app.data.ServerEntry
|
||||||
import com.archipelago.app.data.ServerPreferences
|
import com.archipelago.app.data.ServerPreferences
|
||||||
import com.archipelago.app.fips.FipsManager
|
import com.archipelago.app.fips.FipsManager
|
||||||
|
import com.archipelago.app.ui.components.MeshLoadingScreen
|
||||||
import com.archipelago.app.ui.components.QrScannerOverlay
|
import com.archipelago.app.ui.components.QrScannerOverlay
|
||||||
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.ErrorRed
|
||||||
@ -610,6 +611,14 @@ fun ServerConnectScreen(
|
|||||||
onDismiss = { showScanner = false },
|
onDismiss = { showScanner = false },
|
||||||
onServerScanned = { onQrScanned(it) },
|
onServerScanned = { onQrScanned(it) },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Full-screen branded loader while the first connect runs — most
|
||||||
|
// visibly right after a pairing-QR scan, when the mesh may still be
|
||||||
|
// establishing (LAN probe → tunnel up → ULA probe can take a while).
|
||||||
|
// The small inline spinner stays for context; this owns the screen.
|
||||||
|
if (isConnecting) {
|
||||||
|
MeshLoadingScreen()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -82,6 +82,7 @@ import android.webkit.ValueCallback
|
|||||||
import com.archipelago.app.R
|
import com.archipelago.app.R
|
||||||
import com.archipelago.app.data.ServerPreferences
|
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.MeshLoadingScreen
|
||||||
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.ErrorRed
|
||||||
@ -130,6 +131,15 @@ private object KioskWebView {
|
|||||||
var instance: WebView? = null
|
var instance: WebView? = null
|
||||||
var url: String? = null
|
var url: String? = null
|
||||||
|
|
||||||
|
// Live-composition delegates for the JS bridges (see the factory) — the
|
||||||
|
// registered interface objects call through these, so reattaching the
|
||||||
|
// retained view re-points them instead of leaving stale closures.
|
||||||
|
var onRouteOutbound: (String) -> Unit = {}
|
||||||
|
var onOpenInApp: (String) -> Unit = {}
|
||||||
|
var onQrOpen: () -> Unit = {}
|
||||||
|
var onQrStatus: (String, Boolean) -> Unit = { _, _ -> }
|
||||||
|
var onQrClose: () -> Unit = {}
|
||||||
|
|
||||||
fun drop() {
|
fun drop() {
|
||||||
instance?.let {
|
instance?.let {
|
||||||
(it.parent as? ViewGroup)?.removeView(it)
|
(it.parent as? ViewGroup)?.removeView(it)
|
||||||
@ -379,9 +389,7 @@ fun WebViewScreen(
|
|||||||
} else if (startUrl == null) {
|
} else if (startUrl == null) {
|
||||||
// Racing LAN vs mesh (≤2.5s at home, a few seconds off-LAN) —
|
// Racing LAN vs mesh (≤2.5s at home, a few seconds off-LAN) —
|
||||||
// far cheaper than letting Chromium retry a dead LAN IP.
|
// far cheaper than letting Chromium retry a dead LAN IP.
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
MeshLoadingScreen()
|
||||||
CircularProgressIndicator(color = BitcoinOrange)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Edge-to-edge WebView — background bleeds behind status bar.
|
// Edge-to-edge WebView — background bleeds behind status bar.
|
||||||
// Safe area values injected as CSS env() polyfill on each page load.
|
// Safe area values injected as CSS env() polyfill on each page load.
|
||||||
@ -434,20 +442,38 @@ fun WebViewScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bridge callbacks are DELEGATED through the holder:
|
||||||
|
// the interface objects registered on a retained
|
||||||
|
// WebView survive recomposition, and re-registering
|
||||||
|
// them doesn't reliably swap the JS-visible object
|
||||||
|
// without a reload — with direct closures, a
|
||||||
|
// remote ⇄ dashboard round trip left the bridges
|
||||||
|
// writing to a dead composition's state ("apps don't
|
||||||
|
// launch"). These assignments re-point the live
|
||||||
|
// interface objects at THIS composition every attach.
|
||||||
|
KioskWebView.onRouteOutbound = { url -> routeOutbound(url) }
|
||||||
|
KioskWebView.onOpenInApp = { url -> inAppUrl = url }
|
||||||
|
KioskWebView.onQrOpen = {
|
||||||
|
walletScannerStatus = null
|
||||||
|
walletScannerVisible = true
|
||||||
|
}
|
||||||
|
KioskWebView.onQrStatus = { msg, err -> walletScannerStatus = msg to err }
|
||||||
|
KioskWebView.onQrClose = { walletScannerVisible = false }
|
||||||
|
|
||||||
// JS bridge. The web UI calls:
|
// JS bridge. The web UI calls:
|
||||||
// window.ArchipelagoNative.openExternal(url) — host-routed
|
// window.ArchipelagoNative.openExternal(url) — host-routed
|
||||||
// window.ArchipelagoNative.openInApp(url) — force in-app
|
// window.ArchipelagoNative.openInApp(url) — force in-app
|
||||||
// Falls back to window.open in a plain mobile browser.
|
// Falls back to window.open in a plain mobile browser.
|
||||||
addJavascriptInterface(
|
if (reused == null) addJavascriptInterface(
|
||||||
object {
|
object {
|
||||||
@android.webkit.JavascriptInterface
|
@android.webkit.JavascriptInterface
|
||||||
fun openExternal(url: String) {
|
fun openExternal(url: String) {
|
||||||
webViewRef.post { routeOutbound(url) }
|
webViewRef.post { KioskWebView.onRouteOutbound(url) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@android.webkit.JavascriptInterface
|
@android.webkit.JavascriptInterface
|
||||||
fun openInApp(url: String) {
|
fun openInApp(url: String) {
|
||||||
webViewRef.post { inAppUrl = url }
|
webViewRef.post { KioskWebView.onOpenInApp(url) }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ArchipelagoNative",
|
"ArchipelagoNative",
|
||||||
@ -459,24 +485,21 @@ fun WebViewScreen(
|
|||||||
// window.ArchipelagoQr.close() — code accepted, tear down
|
// window.ArchipelagoQr.close() — code accepted, tear down
|
||||||
// Decodes flow back through window.__archyQrResult(text);
|
// Decodes flow back through window.__archyQrResult(text);
|
||||||
// a user cancel calls window.__archyQrCancelled().
|
// a user cancel calls window.__archyQrCancelled().
|
||||||
addJavascriptInterface(
|
if (reused == null) addJavascriptInterface(
|
||||||
object {
|
object {
|
||||||
@android.webkit.JavascriptInterface
|
@android.webkit.JavascriptInterface
|
||||||
fun open() {
|
fun open() {
|
||||||
webViewRef.post {
|
webViewRef.post { KioskWebView.onQrOpen() }
|
||||||
walletScannerStatus = null
|
|
||||||
walletScannerVisible = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@android.webkit.JavascriptInterface
|
@android.webkit.JavascriptInterface
|
||||||
fun setStatus(message: String, isError: Boolean) {
|
fun setStatus(message: String, isError: Boolean) {
|
||||||
webViewRef.post { walletScannerStatus = message to isError }
|
webViewRef.post { KioskWebView.onQrStatus(message, isError) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@android.webkit.JavascriptInterface
|
@android.webkit.JavascriptInterface
|
||||||
fun close() {
|
fun close() {
|
||||||
webViewRef.post { walletScannerVisible = false }
|
webViewRef.post { KioskWebView.onQrClose() }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ArchipelagoQr",
|
"ArchipelagoQr",
|
||||||
@ -707,6 +730,14 @@ fun WebViewScreen(
|
|||||||
KioskWebView.instance = this
|
KioskWebView.instance = this
|
||||||
KioskWebView.url = serverUrl
|
KioskWebView.url = serverUrl
|
||||||
loadUrl(initialUrl)
|
loadUrl(initialUrl)
|
||||||
|
} else {
|
||||||
|
// Reattached views keep stale measurements until an
|
||||||
|
// input event — that was the top/bottom UI being
|
||||||
|
// wrong until a tap. Force a fresh pass.
|
||||||
|
post {
|
||||||
|
requestLayout()
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -106,7 +106,7 @@ impl RpcHandler {
|
|||||||
&self,
|
&self,
|
||||||
params: Option<serde_json::Value>,
|
params: Option<serde_json::Value>,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
let name = params
|
let mut name = params
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|p| p.get("name"))
|
.and_then(|p| p.get("name"))
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
@ -116,6 +116,14 @@ impl RpcHandler {
|
|||||||
if name.is_empty() || name.len() > 64 {
|
if name.is_empty() || name.len() > 64 {
|
||||||
return Err(anyhow::anyhow!("Device name must be 1-64 characters"));
|
return Err(anyhow::anyhow!("Device name must be 1-64 characters"));
|
||||||
}
|
}
|
||||||
|
// The default name was a single shared slot: every pairing popup
|
||||||
|
// replaced the previous phone's token, silently logging out the
|
||||||
|
// first phone the moment a second one paired. Default-named mints
|
||||||
|
// get a unique suffix so each device keeps its own credential;
|
||||||
|
// explicitly named devices keep replace-in-place semantics.
|
||||||
|
if name == "companion" {
|
||||||
|
name = format!("companion-{}", hex::encode(rand::random::<[u8; 2]>()));
|
||||||
|
}
|
||||||
let token = crate::device_tokens::create(&self.config.data_dir, &name).await?;
|
let token = crate::device_tokens::create(&self.config.data_dir, &name).await?;
|
||||||
Ok(serde_json::json!({ "name": name, "token": token }))
|
Ok(serde_json::json!({ "name": name, "token": token }))
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user