feat(companion): seamless transport handoff + FIPS mesh settings section — 0.5.20
- Transport handoff (Wi-Fi ⇄ 5G, BLE-ready): ArchyVpnService registers a ConnectivityManager callback that re-pins the tunnel to the new default network (setUnderlyingNetworks) and re-homes the mesh (fresh warmer pass → discovery/sessions rebuild on the new path in seconds). Previously the tunnel stayed pinned to the network it launched on and roaming stranded the mesh until an app restart. - FIPS Mesh section in the NESMenu settings modal: status, mesh address (fd… ULA), identity npub (both copyable), configured peer/anchor count, and a one-tap Reconnect (manual re-home). Gives users oversight of what the embedded mesh node is doing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
245fb1a815
commit
4edc50ae47
@ -11,8 +11,8 @@ android {
|
|||||||
applicationId = "com.archipelago.app"
|
applicationId = "com.archipelago.app"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 39
|
versionCode = 40
|
||||||
versionName = "0.5.19"
|
versionName = "0.5.20"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
|
|||||||
@ -5,6 +5,10 @@ import android.app.NotificationChannel
|
|||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Intent
|
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.net.VpnService
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
@ -34,6 +38,20 @@ class ArchyVpnService : VpnService() {
|
|||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
private var warmerJob: Job? = null
|
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 {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
if (intent?.action == ACTION_STOP) {
|
if (intent?.action == ACTION_STOP) {
|
||||||
shutdown()
|
shutdown()
|
||||||
@ -113,6 +131,7 @@ class ArchyVpnService : VpnService() {
|
|||||||
shutdown()
|
shutdown()
|
||||||
} else {
|
} else {
|
||||||
startSessionWarmer()
|
startSessionWarmer()
|
||||||
|
registerNetworkHandoff()
|
||||||
// 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
|
// 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() {
|
private fun shutdown() {
|
||||||
warmerJob?.cancel()
|
warmerJob?.cancel()
|
||||||
|
unregisterNetworkHandoff()
|
||||||
FlareServer.stop()
|
FlareServer.stop()
|
||||||
FipsNative.stop()
|
FipsNative.stop()
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
@ -192,6 +278,7 @@ class ArchyVpnService : VpnService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
unregisterNetworkHandoff()
|
||||||
FipsNative.stop()
|
FipsNative.stop()
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
|
|||||||
@ -31,10 +31,17 @@ import androidx.compose.material3.OutlinedTextField
|
|||||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@ -286,6 +293,9 @@ private fun MenuPanel(
|
|||||||
onClick = onToggleStyle,
|
onClick = onToggleStyle,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// FIPS mesh oversight — identity, mesh address, link status, controls.
|
||||||
|
FipsSection()
|
||||||
|
|
||||||
// Phone↔phone mesh pairing + chat
|
// Phone↔phone mesh pairing + chat
|
||||||
if (onMeshParty != null) {
|
if (onMeshParty != null) {
|
||||||
MenuItem(label = "Mesh Party", labelColor = BitcoinOrange, onClick = onMeshParty)
|
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<FipsInfo?>(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
|
@Composable
|
||||||
private fun MenuItem(
|
private fun MenuItem(
|
||||||
label: String,
|
label: String,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user