diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts index 40bcbfc8..cfc5c8b5 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 = 39 - versionName = "0.5.19" + versionCode = 40 + versionName = "0.5.20" 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 cfb51e1a..f322d2fe 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 @@ -5,6 +5,10 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Intent +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest import android.net.VpnService import android.os.Build import android.util.Log @@ -34,6 +38,20 @@ class ArchyVpnService : VpnService() { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private var warmerJob: Job? = null + // Seamless transport handoff (Wi-Fi ⇄ 5G ⇄ future BLE). Without this the + // tunnel's underlying network stays pinned to the interface that was + // default when the VPN came up; when the phone leaves Wi-Fi for 5G the + // mesh sockets ride a dead network and sessions never recover until the + // app is restarted (user-reported 2026-07-27). The callback (a) re-pins + // the tunnel to the new default network via setUnderlyingNetworks and + // (b) forces an immediate mesh re-home so discovery + sessions rebuild + // on the new path within seconds instead of waiting out dead-link + // timeouts. + private var connectivityManager: ConnectivityManager? = null + private var networkCallback: ConnectivityManager.NetworkCallback? = null + @Volatile + private var currentUnderlying: Network? = null + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { if (intent?.action == ACTION_STOP) { shutdown() @@ -113,6 +131,7 @@ class ArchyVpnService : VpnService() { shutdown() } else { startSessionWarmer() + registerNetworkHandoff() // 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 @@ -183,8 +202,75 @@ class ArchyVpnService : VpnService() { } } + /** + * Track the phone's default network and hand the mesh over to it as the + * phone roams (Wi-Fi ⇄ 5G, and later BLE). Two actions per change: + * 1. setUnderlyingNetworks(new) — the tunnel's packets follow the live + * network instead of dying on the one it launched with. + * 2. re-home the mesh — kick the session warmer so discovery + sessions + * rebuild on the new path immediately; the node's own fast-reconnect + * (1s) redials peers over the new route. + * onAvailable also fires for the FIRST network, which is how the initial + * underlying network gets set. + */ + private fun registerNetworkHandoff() { + if (networkCallback != null) return + val cm = getSystemService(ConnectivityManager::class.java) ?: return + connectivityManager = cm + val request = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + val cb = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + handoffTo(network) + } + + override fun onLost(network: Network) { + // The lost network was our underlying one — clear the pin so + // the system falls back to whatever default remains; the next + // onAvailable re-pins explicitly. + if (network == currentUnderlying) { + currentUnderlying = null + runCatching { setUnderlyingNetworks(null) } + } + } + } + networkCallback = cb + // requestNetwork tracks the BEST network of the request; when the + // phone moves Wi-Fi→5G the callback re-fires onAvailable with the new + // one. (registerDefaultNetworkCallback would also work; requestNetwork + // lets us extend to BLE-capable transports later.) + runCatching { cm.requestNetwork(request, cb) } + } + + private fun handoffTo(network: Network) { + val changed = network != currentUnderlying + currentUnderlying = network + // Always re-assert; cheap and covers capability changes on the same + // Network object. + runCatching { setUnderlyingNetworks(arrayOf(network)) } + if (changed && FipsNative.isRunning()) { + Log.i(TAG, "network handoff → re-homing mesh on new default network") + // Fresh warmer pass drives immediate rediscovery/session rebuild + // on the new path instead of waiting out dead-link timeouts. + startSessionWarmer() + } + } + + private fun unregisterNetworkHandoff() { + val cm = connectivityManager + val cb = networkCallback + if (cm != null && cb != null) { + runCatching { cm.unregisterNetworkCallback(cb) } + } + networkCallback = null + connectivityManager = null + currentUnderlying = null + } + private fun shutdown() { warmerJob?.cancel() + unregisterNetworkHandoff() FlareServer.stop() FipsNative.stop() stopForeground(STOP_FOREGROUND_REMOVE) @@ -192,6 +278,7 @@ class ArchyVpnService : VpnService() { } override fun onDestroy() { + unregisterNetworkHandoff() FipsNative.stop() scope.cancel() super.onDestroy() diff --git a/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt b/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt index b05b6da5..1fb283cc 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/components/NESMenu.kt @@ -31,10 +31,17 @@ 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.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import com.archipelago.app.fips.FipsManager +import com.archipelago.app.fips.FipsNative +import com.archipelago.app.fips.FipsPreferences import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -286,6 +293,9 @@ private fun MenuPanel( onClick = onToggleStyle, ) + // FIPS mesh oversight — identity, mesh address, link status, controls. + FipsSection() + // Phone↔phone mesh pairing + chat if (onMeshParty != null) { MenuItem(label = "Mesh Party", labelColor = BitcoinOrange, onClick = onMeshParty) @@ -298,6 +308,129 @@ private fun MenuPanel( } } +/** Snapshot of the phone's mesh identity + state for the FIPS menu section. */ +private data class FipsInfo( + val available: Boolean, + val running: Boolean, + val npub: String, + val meshAddress: String, + val peerCount: Int, +) + +/** + * FIPS mesh oversight: shows what the phone's embedded mesh node is doing — + * running state, its mesh identity (npub), its mesh address (fd…ULA), how + * many peers/anchors it's configured with — and a one-tap Reconnect that + * re-homes the mesh (also the manual fix if a network handoff ever misses). + * Collapsed by default so the menu stays compact. + */ +@Composable +private fun FipsSection() { + if (!FipsNative.available) return + val context = LocalContext.current + val clipboard = LocalClipboardManager.current + var expanded by remember { mutableStateOf(false) } + var info by remember { mutableStateOf(null) } + + // Load identity/state when the section opens (cheap DataStore + JSON read). + LaunchedEffect(expanded) { + if (expanded && info == null) { + val prefs = FipsPreferences(context) + val id = prefs.identity() + val peers = runCatching { + org.json.JSONArray(prefs.peersJson()).length() + }.getOrDefault(0) + info = FipsInfo( + available = true, + running = FipsNative.isRunning(), + npub = id?.npub.orEmpty(), + meshAddress = id?.address.orEmpty(), + peerCount = peers, + ) + } + } + + Column(Modifier.fillMaxWidth()) { + MenuItem( + label = "FIPS Mesh", + labelColor = BitcoinOrange, + onClick = { expanded = !expanded }, + ) + if (expanded) { + val i = info + Column( + Modifier + .fillMaxWidth() + .padding(top = 6.dp) + .clip(RoundedCornerShape(ROW_R)) + .background(FieldBg) + .border(1.dp, RowBorder, RoundedCornerShape(ROW_R)) + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (i == null) { + Text("Loading…", color = TextMuted, fontSize = 13.sp) + } else { + FipsRow("Status", if (i.running) "Connected" else "Stopped", + valueColor = if (i.running) BitcoinOrange else TextMuted) + if (i.meshAddress.isNotBlank()) { + FipsRow("Mesh address", i.meshAddress, mono = true, + onCopy = { clipboard.setText(AnnotatedString(i.meshAddress)) }) + } + if (i.npub.isNotBlank()) { + FipsRow("Identity (npub)", i.npub, mono = true, + onCopy = { clipboard.setText(AnnotatedString(i.npub)) }) + } + FipsRow("Peers & anchors", i.peerCount.toString()) + Text( + "Your node reaches this phone over the mesh by its npub — no ports opened to the internet.", + color = TextMuted, fontSize = 11.sp, + ) + MenuItem( + label = "Reconnect mesh", + labelColor = BitcoinOrange, + onClick = { + FipsManager.requestMeshRestart(context) + info = null + expanded = false + }, + ) + } + } + } + } +} + +@Composable +private fun FipsRow( + label: String, + value: String, + valueColor: Color = TextPrimary, + mono: Boolean = false, + onCopy: (() -> Unit)? = null, +) { + Row( + Modifier + .fillMaxWidth() + .then(if (onCopy != null) Modifier.clickable { onCopy() } else Modifier), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(label, color = TextMuted, fontSize = 12.sp, modifier = Modifier.width(120.dp)) + Text( + value, + color = valueColor, + fontSize = if (mono) 11.sp else 13.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f), + textAlign = TextAlign.End, + ) + if (onCopy != null) { + Text("⧉", color = TextMuted, fontSize = 13.sp, modifier = Modifier.padding(start = 8.dp)) + } + } +} + @Composable private fun MenuItem( label: String,