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.text.font.FontFamily
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
@@ -44,8 +46,8 @@ import okhttp3.Request
*/
@Composable
fun DualNetworkTestScreen(
piClient: OkHttpClient?,
serverClient: OkHttpClient?,
piClient: OkHttpClient,
serverClient: OkHttpClient,
) {
val scope = rememberCoroutineScope()
val scroll = rememberScrollState()
@@ -55,9 +57,6 @@ fun DualNetworkTestScreen(
var bothResult by remember { mutableStateOf<String?>(null) }
var isLoading by remember { mutableStateOf(false) }
val piAvailable = piClient != null
val serverAvailable = serverClient != null
Column(
modifier = Modifier
.fillMaxSize()
@@ -96,16 +95,14 @@ fun DualNetworkTestScreen(
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Pi WiFi: ${if (piAvailable) "✓ Bound" else "✗ Not found"}",
text = "Pi WiFi: ✓ Connected",
style = MaterialTheme.typography.bodySmall,
color = if (piAvailable) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.error
color = MaterialTheme.colorScheme.primary
)
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,
color = if (serverAvailable) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.error
color = MaterialTheme.colorScheme.primary
)
}
}
@@ -140,7 +137,7 @@ fun DualNetworkTestScreen(
isLoading = false
}
},
enabled = piAvailable && !isLoading,
enabled = !isLoading,
modifier = Modifier.weight(1f)
) {
Text("Test Pi")
@@ -155,7 +152,7 @@ fun DualNetworkTestScreen(
Spacer(modifier = Modifier.height(12.dp))
// Server cellular test
// Server test
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
@@ -164,7 +161,7 @@ fun DualNetworkTestScreen(
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Remote Server (Cellular)",
text = "Remote Server (OS-routed)",
style = MaterialTheme.typography.titleMedium
)
Text(
@@ -183,7 +180,7 @@ fun DualNetworkTestScreen(
isLoading = false
}
},
enabled = serverAvailable && !isLoading,
enabled = !isLoading,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary
@@ -227,7 +224,7 @@ fun DualNetworkTestScreen(
isLoading = false
}
},
enabled = piAvailable && serverAvailable && !isLoading,
enabled = !isLoading,
modifier = Modifier.fillMaxWidth()
) {
Text(if (isLoading) "Testing..." else "Test Both Simultaneously")
@@ -284,10 +281,8 @@ private fun ResultBlock(text: String) {
// --- Network call helpers ---
private suspend fun callPi(client: OkHttpClient?): String {
if (client == null) return "✗ Pi WiFi network not available — connect to yaris 911 hotspot"
return try {
private suspend fun callPi(client: OkHttpClient): String = withContext(Dispatchers.IO) {
try {
val request = Request.Builder()
.url("http://10.42.0.1/api/gps/status")
.get()
@@ -303,15 +298,13 @@ private suspend fun callPi(client: OkHttpClient?): String {
}
}
private suspend fun callServer(client: OkHttpClient?): String {
val c = client ?: OkHttpClient()
return try {
private suspend fun callServer(client: OkHttpClient): String = withContext(Dispatchers.IO) {
try {
val request = Request.Builder()
.url("https://yaris.ceaf.dev/health")
.get()
.build()
val response = c.newCall(request).execute()
val response = client.newCall(request).execute()
if (response.isSuccessful) {
"✓ Server OK (${response.code}) — ${response.body?.string() ?: "no body"}"
} else {
@@ -1,7 +1,6 @@
package com.yarisobd.app
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
@@ -10,20 +9,19 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.yarisobd.app.ui.theme.YarisOBDTheme
import com.yarisobd.app.util.NetworkBinder
import okhttp3.OkHttpClient
class MainActivity : ComponentActivity() {
private var piClient: OkHttpClient? = null
private var serverClient: OkHttpClient? = null
// Single shared client — the OS routes correctly:
// 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?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
discoverNetworks()
setContent {
YarisOBDTheme {
Surface(
@@ -31,31 +29,11 @@ class MainActivity : ComponentActivity() {
color = MaterialTheme.colorScheme.background
) {
DualNetworkTestScreen(
piClient = piClient,
serverClient = serverClient,
piClient = client,
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").
* Identified by: TRANSPORT_WIFI + NO NET_CAPABILITY_INTERNET
* (the Pi hotspot has no internet gateway).
* Matches any TRANSPORT_WIFI network — on Pixel devices with "switch to
* 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? {
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
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
!caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
}
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
}
/**