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.runtime.compose)
implementation(libs.lifecycle.viewmodel.compose) implementation(libs.lifecycle.viewmodel.compose)
// Network — will be used in 28.1.2 // Network
// implementation(libs.okhttp) implementation(libs.okhttp)
// implementation(libs.okhttp.logging) implementation(libs.okhttp.logging)
// implementation(libs.retrofit) implementation(libs.retrofit)
// implementation(libs.retrofit.kotlinx.serialization) implementation(libs.retrofit.kotlinx.serialization)
// implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
// Coroutines // Coroutines
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
@@ -2,11 +2,14 @@ package com.yarisobd.app
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
@@ -14,29 +17,54 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp 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 * Phase 28.1 gate screen — proves dual-network binding works on the real device
* before building anything else. * before building anything else.
* *
* Two buttons: * Two networks, two OkHttpClients (each socket-bound to its respective network):
* "Test Pi" → calls 10.42.0.1/api/gps/status over WiFi * - Pi client → WiFi → 10.42.0.1/api/gps/status
* "Test Server" → calls yaris.ceaf.dev/health over cellular * - Server client → Cellular → yaris.ceaf.dev/health
* *
* Both must succeed simultaneously while the phone is associated with the * Both must succeed simultaneously while the phone is associated with the
* Pi's no-internet hotspot ("yaris 911"). * Pi's no-internet hotspot ("yaris 911").
*/ */
@Composable @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( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(scroll)
.padding(24.dp), .padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Top
) { ) {
Text( Text(
text = "yaris-obd", text = "yaris-obd",
@@ -44,7 +72,7 @@ fun DualNetworkTestScreen() {
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = "Phase 28.1 — Network Binding Proof", text = "Phase 28.1 — Network Binding Proof",
@@ -52,7 +80,37 @@ fun DualNetworkTestScreen() {
color = MaterialTheme.colorScheme.onSurfaceVariant 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 // Pi WiFi test
Card( Card(
@@ -61,29 +119,41 @@ fun DualNetworkTestScreen() {
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
) )
) { ) {
Column( Column(modifier = Modifier.padding(16.dp)) {
modifier = Modifier.padding(16.dp)
) {
Text( Text(
text = "Pi Hotspot (WiFi)", text = "Pi Hotspot (WiFi)",
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium
) )
Text( Text(
text = "Target: 10.42.0.1/api/gps/status", text = "GET 10.42.0.1/api/gps/status",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(8.dp))
Row {
Button( Button(
onClick = { /* TODO: 28.1.2 — WiFi-bound OkHttp call */ }, onClick = {
modifier = Modifier.fillMaxWidth() isLoading = true
piResult = null
scope.launch {
piResult = callPi(piClient)
isLoading = false
}
},
enabled = piAvailable && !isLoading,
modifier = Modifier.weight(1f)
) { ) {
Text("Test Pi") 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 // Server cellular test
Card( Card(
@@ -92,22 +162,29 @@ fun DualNetworkTestScreen() {
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
) )
) { ) {
Column( Column(modifier = Modifier.padding(16.dp)) {
modifier = Modifier.padding(16.dp)
) {
Text( Text(
text = "Remote Server (Cellular)", text = "Remote Server (Cellular)",
style = MaterialTheme.typography.titleMedium style = MaterialTheme.typography.titleMedium
) )
Text( Text(
text = "Target: yaris.ceaf.dev/health", text = "GET yaris.ceaf.dev/health",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(8.dp))
Row {
Button( Button(
onClick = { /* TODO: 28.1.2 — Cellular-bound OkHttp call */ }, onClick = {
modifier = Modifier.fillMaxWidth(), isLoading = true
serverResult = null
scope.launch {
serverResult = callServer(serverClient)
isLoading = false
}
},
enabled = serverAvailable && !isLoading,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary containerColor = MaterialTheme.colorScheme.secondary
) )
@@ -115,14 +192,132 @@ fun DualNetworkTestScreen() {
Text("Test Server") 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(
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, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant 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.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface 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 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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
class MainActivity : ComponentActivity() { 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
// Discover networks synchronously at startup (fast — just reads
// ConnectivityManager state, no network I/O)
discoverNetworks()
setContent { setContent {
YarisOBDTheme { YarisOBDTheme {
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background 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] Create Android project in `android/` via Android Studio (Empty Activity + Compose)
- [x] Kotlin DSL build files with version catalog - [x] Kotlin DSL build files with version catalog
- [x] `.gitignore` for Android (`*.iml`, `.gradle/`, `build/`, `local.properties`, `.idea/`) - [x] `.gitignore` for Android (`*.iml`, `.gradle/`, `build/`, `local.properties`, `.idea/`)
- [ ] Verify project builds and runs on emulator (blank screen) - [x] Create Android project in `android/` via Android Studio (Empty Activity + Compose)
- [ ] Commit: `init: android project scaffold` - [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 #### 28.1.2 — Networking layer
- [ ] Add dependencies: OkHttp 4.x, Retrofit 2.x, Kotlinx Serialization (or Moshi) - [x] Add dependencies: OkHttp 4.12, Retrofit 2.11, Kotlinx Serialization 1.7.3
- [ ] Create `NetworkBinder.kt` — utility class that: - [x] Create `NetworkBinder.kt` — discovers WiFi (Pi hotspot, no-internet) + cellular networks
- On startup, discovers available `Network` objects via `ConnectivityManager` via `ConnectivityManager`, creates socket-bound `OkHttpClient` per network
- Identifies the WiFi network matching `10.42.0.0/24` (Pi hotspot subnet) - [x] Create Retrofit service interfaces (`PiApi.kt`, `ServerApi.kt`) with placeholder endpoints
- Identifies the cellular network (`TRANSPORT_CELLULAR`) - [x] Create DTOs: `GpsStatus`, `HealthResponse`
- Exposes `bindToWifi(): OkHttpClient` and `bindToCellular(): OkHttpClient` factories - [x] Wire `DualNetworkTestScreen.kt` with real OkHttp calls + simultaneous "Test Both" button
- Falls back gracefully if one network is unavailable - [x] Wire `MainActivity.kt` to discover networks at startup and pass bound clients to screen
- [ ] 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
- [ ] **Real-device gate test:** - [ ] **Real-device gate test:**
- Connect phone to Pi hotspot ("yaris 911") - Connect phone to Pi hotspot ("yaris 911")
- Verify cellular data is still active (browse any website while on hotspot) - Verify cellular data is still active (browse any website while on hotspot)
- Press "Test Pi" → should return GPS status JSON - Press "Test Pi" → should return GPS status JSON
- Press "Test Server" → should return `{"status":"ok"}` - 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 - If either fails: investigate `bindProcessToNetwork()` behavior on this Pixel/Android version
- [ ] Commit: `feat: dual-network proof-of-concept` - [ ] Commit: `feat: dual-network proof-of-concept`