diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts index 732693b2..47fe25b4 100644 --- a/Android/app/build.gradle.kts +++ b/Android/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "com.archipelago.app" minSdk = 26 targetSdk = 35 - versionCode = 28 - versionName = "0.5.8" + versionCode = 29 + versionName = "0.5.9" vectorDrawables { useSupportLibrary = true diff --git a/Android/app/src/main/java/com/archipelago/app/fips/ArchyVpnService.kt b/Android/app/src/main/java/com/archipelago/app/fips/ArchyVpnService.kt index b13cb22a..7badffdb 100644 --- a/Android/app/src/main/java/com/archipelago/app/fips/ArchyVpnService.kt +++ b/Android/app/src/main/java/com/archipelago/app/fips/ArchyVpnService.kt @@ -115,6 +115,15 @@ class ArchyVpnService : VpnService() { startSessionWarmer() // Phone-to-phone chat/beam + the phone's own mesh-served page. 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) + } + } } } diff --git a/Android/app/src/main/java/com/archipelago/app/fips/FlareServer.kt b/Android/app/src/main/java/com/archipelago/app/fips/FlareServer.kt index 58567ecb..a48a2294 100644 --- a/Android/app/src/main/java/com/archipelago/app/fips/FlareServer.kt +++ b/Android/app/src/main/java/com/archipelago/app/fips/FlareServer.kt @@ -72,6 +72,12 @@ object FlareServer { @Volatile private var identityNpub = "" @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 fun start(context: Context, ula: String, myNpub: String, myName: String) { stop() @@ -145,6 +151,12 @@ object FlareServer { when { method == "GET" && (path == "/" || path.startsWith("/?")) -> 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" -> { if (contentLength !in 1..MAX_HEADER_BYTES) return respond(sock, 413, json("too_large")) 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) { val o = try { JSONObject(body) @@ -296,6 +340,32 @@ object FlareClient { 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. */ fun sendText(peer: PartyPeer, myNpub: String, myName: String, text: String): Boolean = try { val body = JSONObject() diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/MeshLoadingScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/MeshLoadingScreen.kt new file mode 100644 index 00000000..afc15944 --- /dev/null +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/MeshLoadingScreen.kt @@ -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) + } + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/PartyScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/PartyScreen.kt index e16ebfb9..31494993 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/screens/PartyScreen.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/PartyScreen.kt @@ -52,6 +52,7 @@ 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.PartyPeer import com.archipelago.app.fips.PartyQr 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 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 * two embedded mesh nodes link up: through anchors when there's internet, @@ -88,7 +94,27 @@ fun PartyScreen( var name by remember { mutableStateOf("") } var localIp by remember { mutableStateOf(null) } var showScanner by remember { mutableStateOf(false) } + var showShareQr by remember { mutableStateOf(false) } var scanHint by remember { mutableStateOf(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 peers by prefs.partyPeersFlow.collectAsState(initial = emptyList()) @@ -114,7 +140,13 @@ fun PartyScreen( } 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)) { Column( @@ -287,16 +319,81 @@ fun PartyScreen( // premise. GlassButton( text = "Share this app", - onClick = { shareCompanionApk(context) }, + onClick = { showShareQr = true }, modifier = Modifier.fillMaxWidth().height(48.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 AnimatedVisibility(visible = showScanner, enter = fadeIn(), exit = fadeOut()) { 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) when { peer == null -> scanHint = "Not a mesh party QR" @@ -309,6 +406,27 @@ fun PartyScreen( // Pick up the direct-dial PeerConfig (and the // listener, if ours is on) immediately. 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() } } diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt index 0eb1de37..166f527f 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt @@ -75,6 +75,7 @@ import com.archipelago.app.data.PairResult import com.archipelago.app.data.ServerEntry import com.archipelago.app.data.ServerPreferences 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.theme.BitcoinOrange import com.archipelago.app.ui.theme.ErrorRed @@ -610,6 +611,14 @@ fun ServerConnectScreen( onDismiss = { showScanner = false }, 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() + } } } diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt index 2cc9b247..9cadc86d 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt @@ -82,6 +82,7 @@ import android.webkit.ValueCallback import com.archipelago.app.R import com.archipelago.app.data.ServerPreferences 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.theme.BitcoinOrange import com.archipelago.app.ui.theme.ErrorRed @@ -130,6 +131,15 @@ private object KioskWebView { var instance: WebView? = 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() { instance?.let { (it.parent as? ViewGroup)?.removeView(it) @@ -379,9 +389,7 @@ fun WebViewScreen( } else if (startUrl == null) { // Racing LAN vs mesh (≀2.5s at home, a few seconds off-LAN) β€” // far cheaper than letting Chromium retry a dead LAN IP. - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator(color = BitcoinOrange) - } + MeshLoadingScreen() } else { // Edge-to-edge WebView β€” background bleeds behind status bar. // 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: // window.ArchipelagoNative.openExternal(url) β€” host-routed // window.ArchipelagoNative.openInApp(url) β€” force in-app // Falls back to window.open in a plain mobile browser. - addJavascriptInterface( + if (reused == null) addJavascriptInterface( object { @android.webkit.JavascriptInterface fun openExternal(url: String) { - webViewRef.post { routeOutbound(url) } + webViewRef.post { KioskWebView.onRouteOutbound(url) } } @android.webkit.JavascriptInterface fun openInApp(url: String) { - webViewRef.post { inAppUrl = url } + webViewRef.post { KioskWebView.onOpenInApp(url) } } }, "ArchipelagoNative", @@ -459,24 +485,21 @@ fun WebViewScreen( // window.ArchipelagoQr.close() β€” code accepted, tear down // Decodes flow back through window.__archyQrResult(text); // a user cancel calls window.__archyQrCancelled(). - addJavascriptInterface( + if (reused == null) addJavascriptInterface( object { @android.webkit.JavascriptInterface fun open() { - webViewRef.post { - walletScannerStatus = null - walletScannerVisible = true - } + webViewRef.post { KioskWebView.onQrOpen() } } @android.webkit.JavascriptInterface fun setStatus(message: String, isError: Boolean) { - webViewRef.post { walletScannerStatus = message to isError } + webViewRef.post { KioskWebView.onQrStatus(message, isError) } } @android.webkit.JavascriptInterface fun close() { - webViewRef.post { walletScannerVisible = false } + webViewRef.post { KioskWebView.onQrClose() } } }, "ArchipelagoQr", @@ -707,6 +730,14 @@ fun WebViewScreen( KioskWebView.instance = this KioskWebView.url = serverUrl 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() + } } } }, diff --git a/core/archipelago/src/api/rpc/auth.rs b/core/archipelago/src/api/rpc/auth.rs index d4ecf320..de499aab 100644 --- a/core/archipelago/src/api/rpc/auth.rs +++ b/core/archipelago/src/api/rpc/auth.rs @@ -106,7 +106,7 @@ impl RpcHandler { &self, params: Option, ) -> Result { - let name = params + let mut name = params .as_ref() .and_then(|p| p.get("name")) .and_then(|v| v.as_str()) @@ -116,6 +116,14 @@ impl RpcHandler { if name.is_empty() || name.len() > 64 { 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?; Ok(serde_json::json!({ "name": name, "token": token })) } diff --git a/neode-ui/public/packages/archipelago-companion.apk b/neode-ui/public/packages/archipelago-companion.apk index a343162a..42cb1a62 100644 Binary files a/neode-ui/public/packages/archipelago-companion.apk and b/neode-ui/public/packages/archipelago-companion.apk differ