From 8d64b1e8b0635fe20793fd434084d704eae2c4bb Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Wed, 12 Aug 2026 00:34:41 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=2028.1.2=20=E2=80=94=20dual-netwo?= =?UTF-8?q?rk=20binding=20+=20OkHttp=20clients?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- android/app/build.gradle.kts | 12 +- .../com/yarisobd/app/DualNetworkTestScreen.kt | 261 +++++++++++++++--- .../java/com/yarisobd/app/MainActivity.kt | 41 ++- .../com/yarisobd/app/data/remote/PiApi.kt | 27 ++ .../com/yarisobd/app/data/remote/ServerApi.kt | 23 ++ .../yarisobd/app/data/remote/dto/GpsStatus.kt | 15 + .../app/data/remote/dto/HealthResponse.kt | 11 + .../com/yarisobd/app/util/NetworkBinder.kt | 120 ++++++++ android/plan.md | 29 +- 9 files changed, 482 insertions(+), 57 deletions(-) create mode 100644 android/app/src/main/java/com/yarisobd/app/data/remote/PiApi.kt create mode 100644 android/app/src/main/java/com/yarisobd/app/data/remote/ServerApi.kt create mode 100644 android/app/src/main/java/com/yarisobd/app/data/remote/dto/GpsStatus.kt create mode 100644 android/app/src/main/java/com/yarisobd/app/data/remote/dto/HealthResponse.kt create mode 100644 android/app/src/main/java/com/yarisobd/app/util/NetworkBinder.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index fc71d9d..ea50f9b 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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) diff --git a/android/app/src/main/java/com/yarisobd/app/DualNetworkTestScreen.kt b/android/app/src/main/java/com/yarisobd/app/DualNetworkTestScreen.kt index e3a5fe1..fb8190d 100644 --- a/android/app/src/main/java/com/yarisobd/app/DualNetworkTestScreen.kt +++ b/android/app/src/main/java/com/yarisobd/app/DualNetworkTestScreen.kt @@ -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(null) } + var serverResult by remember { mutableStateOf(null) } + var bothResult by remember { mutableStateOf(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}" + } +} diff --git a/android/app/src/main/java/com/yarisobd/app/MainActivity.kt b/android/app/src/main/java/com/yarisobd/app/MainActivity.kt index e5b7f2c..c3d7534 100644 --- a/android/app/src/main/java/com/yarisobd/app/MainActivity.kt +++ b/android/app/src/main/java/com/yarisobd/app/MainActivity.kt @@ -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) } + } + } } diff --git a/android/app/src/main/java/com/yarisobd/app/data/remote/PiApi.kt b/android/app/src/main/java/com/yarisobd/app/data/remote/PiApi.kt new file mode 100644 index 0000000..0e2f039 --- /dev/null +++ b/android/app/src/main/java/com/yarisobd/app/data/remote/PiApi.kt @@ -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 +} diff --git a/android/app/src/main/java/com/yarisobd/app/data/remote/ServerApi.kt b/android/app/src/main/java/com/yarisobd/app/data/remote/ServerApi.kt new file mode 100644 index 0000000..37fad21 --- /dev/null +++ b/android/app/src/main/java/com/yarisobd/app/data/remote/ServerApi.kt @@ -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 +} diff --git a/android/app/src/main/java/com/yarisobd/app/data/remote/dto/GpsStatus.kt b/android/app/src/main/java/com/yarisobd/app/data/remote/dto/GpsStatus.kt new file mode 100644 index 0000000..2ce708b --- /dev/null +++ b/android/app/src/main/java/com/yarisobd/app/data/remote/dto/GpsStatus.kt @@ -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, +) diff --git a/android/app/src/main/java/com/yarisobd/app/data/remote/dto/HealthResponse.kt b/android/app/src/main/java/com/yarisobd/app/data/remote/dto/HealthResponse.kt new file mode 100644 index 0000000..dbae4a1 --- /dev/null +++ b/android/app/src/main/java/com/yarisobd/app/data/remote/dto/HealthResponse.kt @@ -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 = "", +) diff --git a/android/app/src/main/java/com/yarisobd/app/util/NetworkBinder.kt b/android/app/src/main/java/com/yarisobd/app/util/NetworkBinder.kt new file mode 100644 index 0000000..c9ed2d6 --- /dev/null +++ b/android/app/src/main/java/com/yarisobd/app/util/NetworkBinder.kt @@ -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 { + 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) } + } +} diff --git a/android/plan.md b/android/plan.md index ac82a74..066136a 100644 --- a/android/plan.md +++ b/android/plan.md @@ -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`