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:
@@ -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))
|
||||||
Button(
|
Row {
|
||||||
onClick = { /* TODO: 28.1.2 — WiFi-bound OkHttp call */ },
|
Button(
|
||||||
modifier = Modifier.fillMaxWidth()
|
onClick = {
|
||||||
) {
|
isLoading = true
|
||||||
Text("Test Pi")
|
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
|
// Server cellular test
|
||||||
Card(
|
Card(
|
||||||
@@ -92,37 +162,162 @@ 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))
|
||||||
Button(
|
Row {
|
||||||
onClick = { /* TODO: 28.1.2 — Cellular-bound OkHttp call */ },
|
Button(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
onClick = {
|
||||||
colors = ButtonDefaults.buttonColors(
|
isLoading = true
|
||||||
containerColor = MaterialTheme.colorScheme.secondary
|
serverResult = null
|
||||||
)
|
scope.launch {
|
||||||
) {
|
serverResult = callServer(serverClient)
|
||||||
Text("Test Server")
|
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(
|
||||||
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
@@ -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`
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user