feat: Phase 28.1.2 — dual-network binding + OkHttp clients

- NetworkBinder: discovers Pi hotspot WiFi (no-internet) and cellular networks
  via ConnectivityManager, creates per-network socket-bound OkHttpClients
- DualNetworkTestScreen: real OkHttp calls to 10.42.0.1/api/gps/status and
  yaris.ceaf.dev/health, simultaneous "Test Both" gate button
- MainActivity: discovers networks at startup, passes bound clients to screen
- Retrofit interfaces (PiApi, ServerApi) + DTOs (GpsStatus, HealthResponse)
- Uncommented OkHttp/Retrofit/kotlinx-serialization deps in build.gradle.kts
This commit is contained in:
Mathieu Moran
2026-08-12 00:34:41 -04:00
parent f1c83a2ee7
commit 8d64b1e8b0
9 changed files with 482 additions and 57 deletions
+6 -6
View File
@@ -77,12 +77,12 @@ dependencies {
implementation(libs.lifecycle.runtime.compose)
implementation(libs.lifecycle.viewmodel.compose)
// Network — will be used in 28.1.2
// implementation(libs.okhttp)
// implementation(libs.okhttp.logging)
// implementation(libs.retrofit)
// implementation(libs.retrofit.kotlinx.serialization)
// implementation(libs.kotlinx.serialization.json)
// Network
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
implementation(libs.retrofit)
implementation(libs.retrofit.kotlinx.serialization)
implementation(libs.kotlinx.serialization.json)
// Coroutines
implementation(libs.kotlinx.coroutines.android)
@@ -2,11 +2,14 @@ package com.yarisobd.app
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
@@ -14,29 +17,54 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
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.async
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import okhttp3.Request
/**
* Phase 28.1 gate screen — proves dual-network binding works on the real device
* before building anything else.
*
* Two buttons:
* "Test Pi" → calls 10.42.0.1/api/gps/status over WiFi
* "Test Server" → calls yaris.ceaf.dev/health over cellular
* Two networks, two OkHttpClients (each socket-bound to its respective network):
* - Pi client → WiFi → 10.42.0.1/api/gps/status
* - Server client → Cellular → yaris.ceaf.dev/health
*
* Both must succeed simultaneously while the phone is associated with the
* Pi's no-internet hotspot ("yaris 911").
*/
@Composable
fun DualNetworkTestScreen() {
fun DualNetworkTestScreen(
piClient: OkHttpClient?,
serverClient: OkHttpClient?,
) {
val scope = rememberCoroutineScope()
val scroll = rememberScrollState()
var piResult by remember { mutableStateOf<String?>(null) }
var serverResult by remember { mutableStateOf<String?>(null) }
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()
.verticalScroll(scroll)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
verticalArrangement = Arrangement.Top
) {
Text(
text = "yaris-obd",
@@ -44,7 +72,7 @@ fun DualNetworkTestScreen() {
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Phase 28.1 — Network Binding Proof",
@@ -52,7 +80,37 @@ fun DualNetworkTestScreen() {
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(32.dp))
Spacer(modifier = Modifier.height(8.dp))
// Network status
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
)
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = "Network Status",
style = MaterialTheme.typography.titleSmall
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Pi WiFi: ${if (piAvailable) "✓ Bound" else "✗ Not found"}",
style = MaterialTheme.typography.bodySmall,
color = if (piAvailable) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.error
)
Text(
text = "Cellular: ${if (serverAvailable) "✓ Bound" else "✗ Not found (using default)"}",
style = MaterialTheme.typography.bodySmall,
color = if (serverAvailable) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.error
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Pi WiFi test
Card(
@@ -61,29 +119,41 @@ fun DualNetworkTestScreen() {
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Pi Hotspot (WiFi)",
style = MaterialTheme.typography.titleMedium
)
Text(
text = "Target: 10.42.0.1/api/gps/status",
text = "GET 10.42.0.1/api/gps/status",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = { /* TODO: 28.1.2 — WiFi-bound OkHttp call */ },
modifier = Modifier.fillMaxWidth()
) {
Text("Test Pi")
Spacer(modifier = Modifier.height(8.dp))
Row {
Button(
onClick = {
isLoading = true
piResult = null
scope.launch {
piResult = callPi(piClient)
isLoading = false
}
},
enabled = piAvailable && !isLoading,
modifier = Modifier.weight(1f)
) {
Text("Test Pi")
}
}
piResult?.let { result ->
Spacer(modifier = Modifier.height(8.dp))
ResultBlock(result)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.height(12.dp))
// Server cellular test
Card(
@@ -92,37 +162,162 @@ fun DualNetworkTestScreen() {
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Remote Server (Cellular)",
style = MaterialTheme.typography.titleMedium
)
Text(
text = "Target: yaris.ceaf.dev/health",
text = "GET yaris.ceaf.dev/health",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = { /* TODO: 28.1.2 — Cellular-bound OkHttp call */ },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary
)
) {
Text("Test Server")
Spacer(modifier = Modifier.height(8.dp))
Row {
Button(
onClick = {
isLoading = true
serverResult = null
scope.launch {
serverResult = callServer(serverClient)
isLoading = false
}
},
enabled = serverAvailable && !isLoading,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary
)
) {
Text("Test Server")
}
}
serverResult?.let { result ->
Spacer(modifier = Modifier.height(8.dp))
ResultBlock(result)
}
}
}
Spacer(modifier = Modifier.height(24.dp))
Spacer(modifier = Modifier.height(12.dp))
// Simultaneous test
Button(
onClick = {
isLoading = true
piResult = null
serverResult = null
bothResult = null
scope.launch {
val piDeferred = async { callPi(piClient) }
val serverDeferred = async { callServer(serverClient) }
val pi = piDeferred.await()
val server = serverDeferred.await()
piResult = pi
serverResult = server
val piOk = pi?.startsWith("✓") == true
val serverOk = server?.startsWith("✓") == true
bothResult = when {
piOk && serverOk -> "✓ BOTH NETWORKS WORK SIMULTANEOUSLY — gate passed!"
piOk -> "⚠ Pi OK but server failed — check cellular data is on"
serverOk -> "⚠ Server OK but Pi failed — are you on the yaris 911 hotspot?"
else -> "✗ Both failed"
}
isLoading = false
}
},
enabled = piAvailable && serverAvailable && !isLoading,
modifier = Modifier.fillMaxWidth()
) {
Text(if (isLoading) "Testing..." else "Test Both Simultaneously")
}
bothResult?.let { result ->
Spacer(modifier = Modifier.height(12.dp))
val isSuccess = result.startsWith("✓")
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = if (isSuccess)
MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.errorContainer
)
) {
Text(
text = result,
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyLarge
)
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Connect phone to \"yaris 911\" hotspot first.",
text = "Connect phone to \"yaris 911\" hotspot, ensure cellular data is ON, then test.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
private fun ResultBlock(text: String) {
val isSuccess = text.startsWith("✓")
Card(
colors = CardDefaults.cardColors(
containerColor = if (isSuccess)
MaterialTheme.colorScheme.surfaceContainerLow
else MaterialTheme.colorScheme.errorContainer
)
) {
Text(
text = text,
modifier = Modifier.padding(8.dp),
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = if (isSuccess) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onErrorContainer
)
}
}
// --- 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 {
val request = Request.Builder()
.url("http://10.42.0.1/api/gps/status")
.get()
.build()
val response = client.newCall(request).execute()
if (response.isSuccessful) {
"✓ Pi OK (${response.code}) — ${response.body?.string()?.take(200) ?: "no body"}"
} else {
"✗ Pi HTTP ${response.code} — ${response.message}"
}
} catch (e: Exception) {
"✗ Pi error: ${e.javaClass.simpleName}: ${e.message}"
}
}
private suspend fun callServer(client: OkHttpClient?): String {
val c = client ?: OkHttpClient()
return try {
val request = Request.Builder()
.url("https://yaris.ceaf.dev/health")
.get()
.build()
val response = c.newCall(request).execute()
if (response.isSuccessful) {
"✓ Server OK (${response.code}) — ${response.body?.string() ?: "no body"}"
} else {
"✗ Server HTTP ${response.code} — ${response.message}"
}
} catch (e: Exception) {
"✗ Server error: ${e.javaClass.simpleName}: ${e.message}"
}
}
@@ -4,28 +4,65 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.yarisobd.app.ui.theme.YarisOBDTheme
import com.yarisobd.app.util.NetworkBinder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
class MainActivity : ComponentActivity() {
// Discovered at startup — null if the network isn't available
private var piClient: OkHttpClient? = null
private var serverClient: OkHttpClient? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
// Discover networks synchronously at startup (fast — just reads
// ConnectivityManager state, no network I/O)
discoverNetworks()
setContent {
YarisOBDTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
// Placeholder — replaced by nav host in 28.1.2
DualNetworkTestScreen()
DualNetworkTestScreen(
piClient = piClient,
serverClient = serverClient,
)
}
}
}
}
private fun discoverNetworks() {
val context = applicationContext
// Blocking is fine here — NetworkBinder discovery just reads
// ConnectivityManager state, no network calls.
runBlocking(Dispatchers.Main) {
val piNetwork = NetworkBinder.findPiHotspotNetwork(context)
piClient = piNetwork?.let { NetworkBinder.createBoundClient(it) }
val cellNetwork = NetworkBinder.findCellularNetwork(context)
?: NetworkBinder.findDefaultNetwork(context)
serverClient = cellNetwork?.let { NetworkBinder.createBoundClient(it) }
}
}
}
@@ -0,0 +1,27 @@
package com.yarisobd.app.data.remote
import com.yarisobd.app.data.remote.dto.GpsStatus
import retrofit2.http.GET
/**
* REST API for the Pi's local web server (10.42.0.1 over WiFi hotspot).
* Only the endpoints the Android app needs are declared here.
*/
interface PiApi {
/** GPS fix status — Phase 25.5d */
@GET("/api/gps/status")
suspend fun getGpsStatus(): GpsStatus
// Future: add endpoints from android/plan.md as each sub-phase needs them:
// @GET("/api/obd/status") — BUG-12 fix
// @GET("/api/stats") — Phase 23
// @GET("/api/trips") — Phase 18
// @GET("/api/trip/{id}") — Phase 18
// @GET("/api/dtc") — BUG-03 fix
// @GET("/api/local-video/list") — Phase 17
// @GET("/api/local-video/file") — Phase 17 (streaming, use @Streaming)
// @GET("/api/local-video/thumbnail") — Phase 17
// @DELETE("/api/local-video/file") — Phase 17
// @GET("/api/remote-config") — Phase 25.5c
}
@@ -0,0 +1,23 @@
package com.yarisobd.app.data.remote
import com.yarisobd.app.data.remote.dto.HealthResponse
import retrofit2.http.GET
/**
* REST API for the remote server (https://yaris.ceaf.dev over cellular/internet).
* Only the endpoints the Android app needs are declared here.
*/
interface ServerApi {
/** Health check — Phase 9 (unauthenticated) */
@GET("/health")
suspend fun health(): HealthResponse
// Future: add endpoints from android/plan.md as each sub-phase needs them:
// @GET("/api/v1/dashcam/upload/status") — Phase 10 chunked upload status
// @POST("/api/v1/dashcam/upload/chunk") — Phase 10 multipart chunk upload
// @GET("/api/v1/portal/trips") — Phase 24 portal trips (needs auth cookie)
// @GET("/api/v1/portal/trip/{id}") — Phase 24
// @GET("/api/v1/portal/dashcam") — Phase 24
// @GET("/api/v1/portal/dashcam/stream/{id}") — Phase 24 streaming
}
@@ -0,0 +1,15 @@
package com.yarisobd.app.data.remote.dto
import kotlinx.serialization.Serializable
/**
* Response from Pi's GET /api/gps/status (Phase 25.5d).
*/
@Serializable
data class GpsStatus(
val thread_alive: Boolean = false,
val has_fix: Boolean = false,
val latitude: Double? = null,
val longitude: Double? = null,
val fix_age_seconds: Double? = null,
)
@@ -0,0 +1,11 @@
package com.yarisobd.app.data.remote.dto
import kotlinx.serialization.Serializable
/**
* Response from server's GET /health (Phase 9).
*/
@Serializable
data class HealthResponse(
val status: String = "",
)
@@ -0,0 +1,120 @@
package com.yarisobd.app.util
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.Dns
import okhttp3.OkHttpClient
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Proxy
import java.net.Socket
import javax.net.SocketFactory
import kotlin.coroutines.resume
/**
* Discovers and binds OkHttp clients to specific Android [Network] objects
* for simultaneous WiFi + cellular connectivity.
*
* This is the core unlock for Phase 28 — the web UI (Phase 11/12) could
* never do this because browsers have no access to per-socket network binding.
* A native app using [Network.bindSocket] / [Network.socketFactory] can.
*/
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).
*/
suspend fun findPiHotspotNetwork(context: Context): Network? {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
return cm.allNetworks.firstOrNull { network ->
val caps = cm.getNetworkCapabilities(network) ?: return@firstOrNull false
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
!caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
}
/**
* Finds the cellular network.
* Identified by: TRANSPORT_CELLULAR + NET_CAPABILITY_INTERNET
*/
suspend fun findCellularNetwork(context: Context): Network? {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
return cm.allNetworks.firstOrNull { network ->
val caps = cm.getNetworkCapabilities(network) ?: return@firstOrNull false
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
}
/**
* Returns the current default network (whatever Android picked).
* Useful as a fallback when cellular isn't available — the default
* network may still route internet traffic.
*/
fun findDefaultNetwork(context: Context): Network? {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
return cm.activeNetwork
}
/**
* Creates an OkHttpClient whose sockets are bound to [network].
*
* Uses [Network.socketFactory] so ALL sockets (including redirects,
* connection pooling, DNS) use the specified network interface.
* Also overrides DNS resolution through the same network to avoid
* leaking DNS queries to the wrong interface.
*/
fun createBoundClient(network: Network): OkHttpClient {
return OkHttpClient.Builder()
.socketFactory(network.socketFactory)
.dns(NetworkDns(network))
.proxy(Proxy.NO_PROXY) // Don't use system proxy — may route to wrong network
.build()
}
/**
* Creates an OkHttpClient for the default (system-chosen) network.
*/
fun createDefaultClient(): OkHttpClient {
return OkHttpClient.Builder().build()
}
/**
* DNS resolver that uses a specific [Network]'s name resolution.
* OkHttp's default Dns uses java.net.InetAddress which goes through
* the system default network — this pins DNS to the bound network.
*/
private class NetworkDns(private val network: Network) : Dns {
override fun lookup(hostname: String): List<InetAddress> {
return network.getAllByName(hostname).toList()
}
}
}
/**
* Waits for a network with the given [transport] to become available.
* Used at app startup to wait for the Pi hotspot to connect before
* making API calls.
*/
suspend fun ConnectivityManager.awaitNetwork(transport: Int): Network {
return suspendCancellableCoroutine { continuation ->
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
continuation.resume(network)
}
}
val request = NetworkRequest.Builder()
.addTransportType(transport)
.build()
registerNetworkCallback(request, callback)
continuation.invokeOnCancellation { unregisterNetworkCallback(callback) }
}
}
+13 -16
View File
@@ -76,29 +76,26 @@ android/
- [x] Create Android project in `android/` via Android Studio (Empty Activity + Compose)
- [x] Kotlin DSL build files with version catalog
- [x] `.gitignore` for Android (`*.iml`, `.gradle/`, `build/`, `local.properties`, `.idea/`)
- [ ] Verify project builds and runs on emulator (blank screen)
- [ ] Commit: `init: android project scaffold`
- [x] Create Android project in `android/` via Android Studio (Empty Activity + Compose)
- [x] Kotlin DSL build files with version catalog
- [x] `.gitignore` for Android (`*.iml`, `.gradle/`, `build/`, `local.properties`, `.idea/`)
- [ ] Verify project builds and runs on emulator (needs Android Studio)
- [x] Commit: `f1c83a2` init: android project scaffold
#### 28.1.2 — Networking layer
- [ ] Add dependencies: OkHttp 4.x, Retrofit 2.x, Kotlinx Serialization (or Moshi)
- [ ] Create `NetworkBinder.kt` — utility class that:
- On startup, discovers available `Network` objects via `ConnectivityManager`
- Identifies the WiFi network matching `10.42.0.0/24` (Pi hotspot subnet)
- Identifies the cellular network (`TRANSPORT_CELLULAR`)
- Exposes `bindToWifi(): OkHttpClient` and `bindToCellular(): OkHttpClient` factories
- Falls back gracefully if one network is unavailable
- [ ] Create Retrofit service interfaces:
- `PiApi.kt` — endpoints on `http://10.42.0.1` (or dynamically resolved)
- `ServerApi.kt` — endpoints on `https://yaris.ceaf.dev`
- [ ] Wire up a manual test screen with two buttons + status text:
- "Test Pi" → calls `/api/gps/status` on WiFi-bound client, shows response
- "Test Server" → calls `/health` on cellular-bound client, shows response
- [x] Add dependencies: OkHttp 4.12, Retrofit 2.11, Kotlinx Serialization 1.7.3
- [x] Create `NetworkBinder.kt` — discovers WiFi (Pi hotspot, no-internet) + cellular networks
via `ConnectivityManager`, creates socket-bound `OkHttpClient` per network
- [x] Create Retrofit service interfaces (`PiApi.kt`, `ServerApi.kt`) with placeholder endpoints
- [x] Create DTOs: `GpsStatus`, `HealthResponse`
- [x] Wire `DualNetworkTestScreen.kt` with real OkHttp calls + simultaneous "Test Both" button
- [x] Wire `MainActivity.kt` to discover networks at startup and pass bound clients to screen
- [ ] **Real-device gate test:**
- Connect phone to Pi hotspot ("yaris 911")
- Verify cellular data is still active (browse any website while on hotspot)
- Press "Test Pi" → should return GPS status JSON
- Press "Test Server" → should return `{"status":"ok"}`
- Press both rapidly → both should succeed
- Press "Test Both" → both should succeed simultaneously
- If either fails: investigate `bindProcessToNetwork()` behavior on this Pixel/Android version
- [ ] Commit: `feat: dual-network proof-of-concept`