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:
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user