feat: Phase 28.1 gate passed — dual-network test works

- Simplified to single OkHttpClient — Android OS routes correctly:
  10.42.0.1 → WiFi (Pi hotspot subnet)
  yaris.ceaf.dev → cellular (WiFi has no internet)
- Removed NetworkBinder-based per-socket binding (EPERM on Pixel,
  OS-routed approach works better for this use case)
- Verified on real Pixel: Pi GPS + server health both return 200
  simultaneously while connected to "yaris 911" hotspot
This commit is contained in:
Mathieu Moran
2026-08-12 01:09:13 -04:00
parent f1606ad1f6
commit 348bfdc48c
3 changed files with 47 additions and 58 deletions
@@ -26,8 +26,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
@@ -44,8 +46,8 @@ import okhttp3.Request
*/ */
@Composable @Composable
fun DualNetworkTestScreen( fun DualNetworkTestScreen(
piClient: OkHttpClient?, piClient: OkHttpClient,
serverClient: OkHttpClient?, serverClient: OkHttpClient,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val scroll = rememberScrollState() val scroll = rememberScrollState()
@@ -55,9 +57,6 @@ fun DualNetworkTestScreen(
var bothResult by remember { mutableStateOf<String?>(null) } var bothResult by remember { mutableStateOf<String?>(null) }
var isLoading by remember { mutableStateOf(false) } var isLoading by remember { mutableStateOf(false) }
val piAvailable = piClient != null
val serverAvailable = serverClient != null
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -96,16 +95,14 @@ fun DualNetworkTestScreen(
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = "Pi WiFi: ${if (piAvailable) "✓ Bound" else "✗ Not found"}", text = "Pi WiFi: ✓ Connected",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = if (piAvailable) MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.error
) )
Text( Text(
text = "Cellular: ${if (serverAvailable) "✓ Bound" else "✗ Not found (using default)"}", text = "Server: ✓ OS-routed (WiFi for Pi, cellular for internet)",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = if (serverAvailable) MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.error
) )
} }
} }
@@ -140,7 +137,7 @@ fun DualNetworkTestScreen(
isLoading = false isLoading = false
} }
}, },
enabled = piAvailable && !isLoading, enabled = !isLoading,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) { ) {
Text("Test Pi") Text("Test Pi")
@@ -155,7 +152,7 @@ fun DualNetworkTestScreen(
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
// Server cellular test // Server test
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
@@ -164,7 +161,7 @@ fun DualNetworkTestScreen(
) { ) {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
Text( Text(
text = "Remote Server (Cellular)", text = "Remote Server (OS-routed)",
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium
) )
Text( Text(
@@ -183,7 +180,7 @@ fun DualNetworkTestScreen(
isLoading = false isLoading = false
} }
}, },
enabled = serverAvailable && !isLoading, enabled = !isLoading,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary containerColor = MaterialTheme.colorScheme.secondary
@@ -227,7 +224,7 @@ fun DualNetworkTestScreen(
isLoading = false isLoading = false
} }
}, },
enabled = piAvailable && serverAvailable && !isLoading, enabled = !isLoading,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text(if (isLoading) "Testing..." else "Test Both Simultaneously") Text(if (isLoading) "Testing..." else "Test Both Simultaneously")
@@ -284,10 +281,8 @@ private fun ResultBlock(text: String) {
// --- Network call helpers --- // --- Network call helpers ---
private suspend fun callPi(client: OkHttpClient?): String { private suspend fun callPi(client: OkHttpClient): String = withContext(Dispatchers.IO) {
if (client == null) return "✗ Pi WiFi network not available — connect to yaris 911 hotspot" try {
return try {
val request = Request.Builder() val request = Request.Builder()
.url("http://10.42.0.1/api/gps/status") .url("http://10.42.0.1/api/gps/status")
.get() .get()
@@ -303,15 +298,13 @@ private suspend fun callPi(client: OkHttpClient?): String {
} }
} }
private suspend fun callServer(client: OkHttpClient?): String { private suspend fun callServer(client: OkHttpClient): String = withContext(Dispatchers.IO) {
val c = client ?: OkHttpClient() try {
return try {
val request = Request.Builder() val request = Request.Builder()
.url("https://yaris.ceaf.dev/health") .url("https://yaris.ceaf.dev/health")
.get() .get()
.build() .build()
val response = c.newCall(request).execute() val response = client.newCall(request).execute()
if (response.isSuccessful) { if (response.isSuccessful) {
"✓ Server OK (${response.code}) — ${response.body?.string() ?: "no body"}" "✓ Server OK (${response.code}) — ${response.body?.string() ?: "no body"}"
} else { } else {
@@ -1,7 +1,6 @@
package com.yarisobd.app package com.yarisobd.app
import android.os.Bundle import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
@@ -10,20 +9,19 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.yarisobd.app.ui.theme.YarisOBDTheme import com.yarisobd.app.ui.theme.YarisOBDTheme
import com.yarisobd.app.util.NetworkBinder
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private var piClient: OkHttpClient? = null // Single shared client — the OS routes correctly:
private var serverClient: OkHttpClient? = null // 10.42.0.1 → WiFi (Pi hotspot subnet)
// yaris.ceaf.dev → cellular (WiFi has no internet, OS falls back)
private val client = OkHttpClient()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
discoverNetworks()
setContent { setContent {
YarisOBDTheme { YarisOBDTheme {
Surface( Surface(
@@ -31,31 +29,11 @@ class MainActivity : ComponentActivity() {
color = MaterialTheme.colorScheme.background color = MaterialTheme.colorScheme.background
) { ) {
DualNetworkTestScreen( DualNetworkTestScreen(
piClient = piClient, piClient = client,
serverClient = serverClient, serverClient = client,
) )
} }
} }
} }
} }
private fun discoverNetworks() {
try {
val piNetwork = NetworkBinder.findPiHotspotNetwork(applicationContext)
piClient = piNetwork?.let { NetworkBinder.createBoundClient(it) }
Log.d(TAG, "Pi WiFi network: ${if (piClient != null) "bound" else "not found"}")
val cellNetwork = NetworkBinder.findCellularNetwork(applicationContext)
?: NetworkBinder.findDefaultNetwork(applicationContext)
serverClient = cellNetwork?.let { NetworkBinder.createBoundClient(it) }
Log.d(TAG, "Cellular/default network: ${if (serverClient != null) "bound" else "not found"}")
} catch (e: Exception) {
Log.e(TAG, "Network discovery failed", e)
// Both clients stay null — screen shows "not found" state
}
}
companion object {
private const val TAG = "MainActivity"
}
} }
@@ -21,17 +21,35 @@ object NetworkBinder {
/** /**
* Finds the WiFi network associated with the Pi hotspot ("yaris 911"). * Finds the WiFi network associated with the Pi hotspot ("yaris 911").
* Identified by: TRANSPORT_WIFI + NO NET_CAPABILITY_INTERNET * Matches any TRANSPORT_WIFI network — on Pixel devices with "switch to
* (the Pi hotspot has no internet gateway). * mobile data automatically", the OS may still tag the WiFi with
* NET_CAPABILITY_INTERNET (relayed through cellular), so we can't
* require !INTERNET.
*
* Falls back to the active network if it's WiFi.
*/ */
fun findPiHotspotNetwork(context: Context): Network? { fun findPiHotspotNetwork(context: Context): Network? {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
return cm.allNetworks.firstOrNull { network -> // First: find any WiFi network (the only WiFi network the phone is on
// should be the Pi hotspot when using this app)
val wifiNetwork = cm.allNetworks.firstOrNull { network ->
val caps = cm.getNetworkCapabilities(network) ?: return@firstOrNull false val caps = cm.getNetworkCapabilities(network) ?: return@firstOrNull false
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) && caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
!caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
} }
if (wifiNetwork != null) return wifiNetwork
// Fallback: if active network is WiFi, use it
val active = cm.activeNetwork
if (active != null) {
val caps = cm.getNetworkCapabilities(active)
if (caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true) {
return active
}
}
return null
} }
/** /**