feat: Phase 28.2 — dashcam video browser + WiFi download

- DashcamScreen: LazyColumn of imported Pi videos with thumbnails (Coil),
  file size, date, upload status, converting progress
- DashcamViewModel: Retrofit-backed video list + download to phone storage
  + delete local copy
- PiApi extended: listVideos, getVideoFile (streaming), getThumbnail, deleteVideo
- DTOs: VideoListResponse, VideoFile, ConvertProgress
- Bottom nav: Dashcam tab + Test tab
- Verified: video list loads from real Pi over yaris 911 hotspot
This commit is contained in:
Mathieu Moran
2026-08-12 01:14:20 -04:00
parent 348bfdc48c
commit 22bbee3b30
8 changed files with 584 additions and 45 deletions
+3 -3
View File
@@ -95,9 +95,9 @@ dependencies {
// Background work — will be used in 28.3 // Background work — will be used in 28.3
// implementation(libs.workmanager) // implementation(libs.workmanager)
// Images — will be used in 28.2 // Images
// implementation(libs.coil.compose) implementation(libs.coil.compose)
// implementation(libs.coil.okhttp) implementation(libs.coil.okhttp)
// Maps — will be used in 28.4 // Maps — will be used in 28.4
// implementation(libs.maplibre) // implementation(libs.maplibre)
@@ -0,0 +1,271 @@
package com.yarisobd.app
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.crossfade
import com.yarisobd.app.data.remote.dto.VideoFile
@Composable
fun DashcamScreen(
videos: List<VideoFile>,
totalSize: Long,
retentionDays: Int,
isLoading: Boolean,
error: String?,
onRefresh: () -> Unit,
onDownload: (VideoFile) -> Unit,
onDelete: (VideoFile) -> Unit,
) {
Column(modifier = Modifier.fillMaxSize()) {
// Header
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Dashcam Videos",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "${videos.size} files",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = formatBytes(totalSize),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (retentionDays > 0) {
Text(
text = "Auto-delete after ${retentionDays}d (if uploaded)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = onRefresh,
enabled = !isLoading,
modifier = Modifier.fillMaxWidth()
) {
Text(if (isLoading) "Loading..." else "Refresh")
}
}
}
// Content
when {
isLoading && videos.isEmpty() -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
}
error != null -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = error,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = onRefresh) { Text("Retry") }
}
}
}
videos.isEmpty() -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "No imported videos on the Pi.\nImport some from the dashcam first.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(
start = 16.dp, end = 16.dp, bottom = 16.dp
),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(videos, key = { it.path }) { video ->
VideoRow(
video = video,
onDownload = { onDownload(video) },
onDelete = { onDelete(video) },
)
}
}
}
}
}
}
@Composable
private fun VideoRow(
video: VideoFile,
onDownload: () -> Unit,
onDelete: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Thumbnail
AsyncImage(
model = ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current)
.data("http://10.42.0.1/api/local-video/thumbnail?path=${video.path}")
.crossfade(true)
.build(),
contentDescription = "Thumbnail",
modifier = Modifier
.size(64.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentScale = ContentScale.Crop,
)
Spacer(modifier = Modifier.width(12.dp))
// Info
Column(modifier = Modifier.weight(1f)) {
Text(
text = video.path.substringAfterLast("/"),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(2.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = video.formattedSize(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = video.formattedDate(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(2.dp))
if (video.converting) {
Text(
text = "Converting ${video.convert_progress?.pct ?: 0}%",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary
)
} else {
Text(
text = if (video.uploaded) "Uploaded" else "Pending",
style = MaterialTheme.typography.bodySmall,
color = if (video.uploaded) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.error
)
}
}
// Actions
Row {
Button(
onClick = onDownload,
enabled = !video.converting,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
),
modifier = Modifier.height(36.dp)
) {
Text("↓", style = MaterialTheme.typography.bodySmall)
}
Spacer(modifier = Modifier.width(4.dp))
Button(
onClick = onDelete,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
),
modifier = Modifier.height(36.dp)
) {
Text("✕", style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
private fun formatBytes(bytes: Long): String {
return when {
bytes >= 1_000_000_000 -> "%.1f GB".format(bytes / 1_000_000_000.0)
bytes >= 1_000_000 -> "%.1f MB".format(bytes / 1_000_000.0)
bytes >= 1_000 -> "%.1f KB".format(bytes / 1_000.0)
else -> "$bytes B"
}
}
@@ -0,0 +1,154 @@
package com.yarisobd.app
import android.app.Application
import android.content.Context
import android.os.Environment
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.yarisobd.app.data.remote.PiApi
import com.yarisobd.app.data.remote.dto.VideoFile
import com.yarisobd.app.data.remote.dto.VideoListResponse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okio.buffer
import okio.sink
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import okhttp3.MediaType.Companion.toMediaType
import kotlinx.serialization.json.Json
import java.io.File
data class DownloadProgress(
val path: String,
val bytesDownloaded: Long,
val totalBytes: Long,
)
class DashcamViewModel(application: Application) : AndroidViewModel(application) {
private val baseUrl = "http://10.42.0.1/"
private val okHttp = OkHttpClient.Builder().build()
private val json = Json { ignoreUnknownKeys = true; coerceInputValues = true }
private val api: PiApi = Retrofit.Builder()
.baseUrl(baseUrl)
.client(okHttp)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create(PiApi::class.java)
private val _videos = MutableStateFlow<List<VideoFile>>(emptyList())
val videos: StateFlow<List<VideoFile>> = _videos.asStateFlow()
private val _totalSize = MutableStateFlow(0L)
val totalSize: StateFlow<Long> = _totalSize.asStateFlow()
private val _retentionDays = MutableStateFlow(0)
val retentionDays: StateFlow<Int> = _retentionDays.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
private val _downloadProgress = MutableStateFlow<Map<String, DownloadProgress>>(emptyMap())
val downloadProgress: StateFlow<Map<String, DownloadProgress>> = _downloadProgress.asStateFlow()
private val _downloadEvents = MutableSharedFlow<String>()
val downloadEvents = _downloadEvents.asSharedFlow()
init {
loadVideos()
}
fun loadVideos() {
viewModelScope.launch {
_isLoading.value = true
_error.value = null
try {
val response: VideoListResponse = api.listVideos()
_videos.value = response.files
_totalSize.value = response.total_size
_retentionDays.value = response.retention_days
} catch (e: Exception) {
_error.value = "Failed to load: ${e.message}"
} finally {
_isLoading.value = false
}
}
}
fun downloadVideo(video: VideoFile) {
viewModelScope.launch {
val app = getApplication<YarisApp>()
val dir = app.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: return@launch
val localFile = File(dir, video.path.substringAfterLast("/"))
// Skip if already downloaded
if (localFile.exists() && localFile.length() == video.size) {
_downloadEvents.emit("Already downloaded: ${localFile.name}")
return@launch
}
try {
withContext(Dispatchers.IO) {
val request = Request.Builder()
.url("$baseUrl/api/local-video/file?path=${video.path}")
.get()
.build()
val response = okHttp.newCall(request).execute()
if (!response.isSuccessful) {
throw Exception("HTTP ${response.code}")
}
val body = response.body ?: throw Exception("Empty body")
val total = body.contentLength()
localFile.parentFile?.mkdirs()
var downloaded = 0L
body.source().use { source ->
localFile.sink().buffer().use { sink ->
val buffer = okio.Buffer()
while (true) {
val read = source.read(buffer, 8192)
if (read == -1L) break
sink.write(buffer, read)
downloaded += read
_downloadProgress.value = _downloadProgress.value + (
video.path to DownloadProgress(video.path, downloaded, total)
)
}
}
}
_downloadProgress.value = _downloadProgress.value - video.path
_downloadEvents.emit("Downloaded: ${localFile.name}")
}
} catch (e: Exception) {
_downloadProgress.value = _downloadProgress.value - video.path
_downloadEvents.emit("Download failed: ${e.message}")
}
}
}
fun deleteVideo(video: VideoFile) {
viewModelScope.launch {
try {
api.deleteVideo(mapOf("path" to video.path))
loadVideos()
} catch (e: Exception) {
_error.value = "Delete failed: ${e.message}"
}
}
}
}
@@ -4,18 +4,29 @@ 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.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.lifecycle.viewmodel.compose.viewModel
import com.yarisobd.app.ui.theme.YarisOBDTheme import com.yarisobd.app.ui.theme.YarisOBDTheme
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
// Single shared client — the OS routes correctly:
// 10.42.0.1 → WiFi (Pi hotspot subnet)
// yaris.ceaf.dev → cellular (WiFi has no internet, OS falls back)
private val client = OkHttpClient() private val client = OkHttpClient()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -24,15 +35,57 @@ class MainActivity : ComponentActivity() {
setContent { setContent {
YarisOBDTheme { YarisOBDTheme {
Surface( YarisMainScreen(client = client)
modifier = Modifier.fillMaxSize(), }
color = MaterialTheme.colorScheme.background }
) { }
DualNetworkTestScreen( }
piClient = client,
serverClient = client, @Composable
) fun YarisMainScreen(client: OkHttpClient) {
} val dashcamVm: DashcamViewModel = viewModel()
val videos by dashcamVm.videos.collectAsState()
val totalSize by dashcamVm.totalSize.collectAsState()
val retentionDays by dashcamVm.retentionDays.collectAsState()
val isLoading by dashcamVm.isLoading.collectAsState()
val error by dashcamVm.error.collectAsState()
var selectedTab by remember { mutableIntStateOf(0) }
Scaffold(
bottomBar = {
NavigationBar {
NavigationBarItem(
selected = selectedTab == 0,
onClick = { selectedTab = 0 },
icon = { Text("📹") },
label = { Text("Dashcam") }
)
NavigationBarItem(
selected = selectedTab == 1,
onClick = { selectedTab = 1 },
icon = { Text("🔌") },
label = { Text("Test") }
)
}
}
) { padding ->
Column(modifier = Modifier.padding(padding)) {
when (selectedTab) {
0 -> DashcamScreen(
videos = videos,
totalSize = totalSize,
retentionDays = retentionDays,
isLoading = isLoading,
error = error,
onRefresh = { dashcamVm.loadVideos() },
onDownload = { dashcamVm.downloadVideo(it) },
onDelete = { dashcamVm.deleteVideo(it) },
)
1 -> DualNetworkTestScreen(
piClient = client,
serverClient = client,
)
} }
} }
} }
@@ -0,0 +1,28 @@
package com.yarisobd.app
import com.yarisobd.app.data.remote.dto.VideoFile
import java.text.DecimalFormat
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
private val sizeFormat = DecimalFormat("#,##0.#")
private val dateFormat = SimpleDateFormat("dd MMM yyyy HH:mm", Locale.getDefault())
fun VideoFile.formattedSize(): String {
val bytes = size.toDouble()
return when {
bytes >= 1_000_000_000 -> "${sizeFormat.format(bytes / 1_000_000_000)} GB"
bytes >= 1_000_000 -> "${sizeFormat.format(bytes / 1_000_000)} MB"
bytes >= 1_000 -> "${sizeFormat.format(bytes / 1_000)} KB"
else -> "${size.toLong()} B"
}
}
fun VideoFile.formattedDate(): String {
return try {
dateFormat.format(Date((modified_at * 1000).toLong()))
} catch (_: Exception) {
""
}
}
@@ -1,27 +1,37 @@
package com.yarisobd.app.data.remote package com.yarisobd.app.data.remote
import com.yarisobd.app.data.remote.dto.GpsStatus import com.yarisobd.app.data.remote.dto.GpsStatus
import com.yarisobd.app.data.remote.dto.VideoListResponse
import okhttp3.ResponseBody
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Query
import retrofit2.http.Streaming
/** /**
* REST API for the Pi's local web server (10.42.0.1 over WiFi hotspot). * 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 { interface PiApi {
/** GPS fix status — Phase 25.5d */ /** GPS fix status */
@GET("/api/gps/status") @GET("/api/gps/status")
suspend fun getGpsStatus(): GpsStatus suspend fun getGpsStatus(): GpsStatus
// Future: add endpoints from android/plan.md as each sub-phase needs them: /** List imported dashcam videos (Phase 17) */
// @GET("/api/obd/status") — BUG-12 fix @GET("/api/local-videos")
// @GET("/api/stats") — Phase 23 suspend fun listVideos(): VideoListResponse
// @GET("/api/trips") — Phase 18
// @GET("/api/trip/{id}") — Phase 18 /** Stream/download a video file with Range support */
// @GET("/api/dtc") — BUG-03 fix @Streaming
// @GET("/api/local-video/list") — Phase 17 @GET("/api/local-video/file")
// @GET("/api/local-video/file") — Phase 17 (streaming, use @Streaming) suspend fun getVideoFile(@Query("path") path: String): ResponseBody
// @GET("/api/local-video/thumbnail") — Phase 17
// @DELETE("/api/local-video/file") — Phase 17 /** Get a cached thumbnail JPEG */
// @GET("/api/remote-config") — Phase 25.5c @GET("/api/local-video/thumbnail")
suspend fun getThumbnail(@Query("path") path: String): ResponseBody
/** Delete local copy only */
@DELETE("/api/local-video")
suspend fun deleteVideo(@Body body: Map<String, String>): Map<String, Any?>
} }
@@ -0,0 +1,29 @@
package com.yarisobd.app.data.remote.dto
import kotlinx.serialization.Serializable
@Serializable
data class VideoListResponse(
val status: String = "",
val files: List<VideoFile> = emptyList(),
val count: Int = 0,
val total_size: Long = 0,
val errors: List<String> = emptyList(),
val retention_days: Int = 0,
)
@Serializable
data class VideoFile(
val path: String = "",
val size: Long = 0,
val modified_at: Double = 0.0,
val uploaded: Boolean = false,
val converting: Boolean = false,
val convert_progress: ConvertProgress? = null,
)
@Serializable
data class ConvertProgress(
val elapsed_s: Int = 0,
val pct: Int = 0,
)
+10 -16
View File
@@ -69,35 +69,29 @@ android/
## Sub-phases ## Sub-phases
### 28.1 — Scaffold + networking proof-of-concept 🚧 IN PROGRESS ### 28.1 — Scaffold + networking proof-of-concept ✅ DONE (gate passed 2026-08-12)
**Gate: dual-network binding must work on the real Pixel before anything else is built.**
#### 28.1.1 — Project creation #### 28.1.1 — Project creation
- [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/`)
- [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) - [ ] Verify project builds and runs on emulator (needs Android Studio)
- [x] Commit: `f1c83a2` init: android project scaffold - [x] Commit: `f1c83a2` init: android project scaffold
#### 28.1.2 — Networking layer #### 28.1.2 — Networking layer
- [x] Add dependencies: OkHttp 4.12, Retrofit 2.11, Kotlinx Serialization 1.7.3 - [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 - [x] Create `NetworkBinder.kt` — discovers WiFi (Pi hotspot) + cellular networks (kept for future use)
via `ConnectivityManager`, creates socket-bound `OkHttpClient` per network
- [x] Create Retrofit service interfaces (`PiApi.kt`, `ServerApi.kt`) with placeholder endpoints - [x] Create Retrofit service interfaces (`PiApi.kt`, `ServerApi.kt`) with placeholder endpoints
- [x] Create DTOs: `GpsStatus`, `HealthResponse` - [x] Create DTOs: `GpsStatus`, `HealthResponse`
- [x] Wire `DualNetworkTestScreen.kt` with real OkHttp calls + simultaneous "Test Both" button - [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 - [x] Wire `MainActivity.kt` — simplified to single OkHttpClient, OS routes correctly
- [ ] **Real-device gate test:** - [x] **Real-device gate test — PASSED 2026-08-12 on Pixel:**
- Connect phone to Pi hotspot ("yaris 911") - "Test Pi" → `✓ Pi OK (200)` with live GPS fix data
- Verify cellular data is still active (browse any website while on hotspot) - "Test Server" → `✓ Server OK (200) — {"status":"ok"}`
- Press "Test Pi" → should return GPS status JSON - "Test Both Simultaneously" → `✓ BOTH NETWORKS WORK SIMULTANEOUSLY`
- Press "Test Server" → should return `{"status":"ok"}` - Key finding: per-socket `Network.bindSocket()` not needed — Android OS routes
- Press "Test Both" → both should succeed simultaneously `10.42.0.1` through WiFi and `yaris.ceaf.dev` through cellular automatically
- If either fails: investigate `bindProcessToNetwork()` behavior on this Pixel/Android version - [x] Commit: `348bfdc` feat: Phase 28.1 gate passed
- [ ] Commit: `feat: dual-network proof-of-concept`
#### 28.1.3 — Credential storage #### 28.1.3 — Credential storage
- [ ] Add EncryptedSharedPreferences for server URL, device name, API key - [ ] Add EncryptedSharedPreferences for server URL, device name, API key