From 22bbee3b30ed11913d85b33b3f76bb0d55f3157a Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Wed, 12 Aug 2026 01:14:20 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=2028.2=20=E2=80=94=20dashcam=20vi?= =?UTF-8?q?deo=20browser=20+=20WiFi=20download?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- android/app/build.gradle.kts | 6 +- .../java/com/yarisobd/app/DashcamScreen.kt | 271 ++++++++++++++++++ .../java/com/yarisobd/app/DashcamViewModel.kt | 154 ++++++++++ .../java/com/yarisobd/app/MainActivity.kt | 79 ++++- .../java/com/yarisobd/app/VideoFileExt.kt | 28 ++ .../com/yarisobd/app/data/remote/PiApi.kt | 36 ++- .../app/data/remote/dto/VideoListResponse.kt | 29 ++ android/plan.md | 26 +- 8 files changed, 584 insertions(+), 45 deletions(-) create mode 100644 android/app/src/main/java/com/yarisobd/app/DashcamScreen.kt create mode 100644 android/app/src/main/java/com/yarisobd/app/DashcamViewModel.kt create mode 100644 android/app/src/main/java/com/yarisobd/app/VideoFileExt.kt create mode 100644 android/app/src/main/java/com/yarisobd/app/data/remote/dto/VideoListResponse.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index ea50f9b..e9b3f86 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -95,9 +95,9 @@ dependencies { // Background work — will be used in 28.3 // implementation(libs.workmanager) - // Images — will be used in 28.2 - // implementation(libs.coil.compose) - // implementation(libs.coil.okhttp) + // Images + implementation(libs.coil.compose) + implementation(libs.coil.okhttp) // Maps — will be used in 28.4 // implementation(libs.maplibre) diff --git a/android/app/src/main/java/com/yarisobd/app/DashcamScreen.kt b/android/app/src/main/java/com/yarisobd/app/DashcamScreen.kt new file mode 100644 index 0000000..5353461 --- /dev/null +++ b/android/app/src/main/java/com/yarisobd/app/DashcamScreen.kt @@ -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, + 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" + } +} diff --git a/android/app/src/main/java/com/yarisobd/app/DashcamViewModel.kt b/android/app/src/main/java/com/yarisobd/app/DashcamViewModel.kt new file mode 100644 index 0000000..4722cbb --- /dev/null +++ b/android/app/src/main/java/com/yarisobd/app/DashcamViewModel.kt @@ -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>(emptyList()) + val videos: StateFlow> = _videos.asStateFlow() + + private val _totalSize = MutableStateFlow(0L) + val totalSize: StateFlow = _totalSize.asStateFlow() + + private val _retentionDays = MutableStateFlow(0) + val retentionDays: StateFlow = _retentionDays.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + + private val _downloadProgress = MutableStateFlow>(emptyMap()) + val downloadProgress: StateFlow> = _downloadProgress.asStateFlow() + + private val _downloadEvents = MutableSharedFlow() + 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() + 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}" + } + } + } +} 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 7a2f0d7..14867a1 100644 --- a/android/app/src/main/java/com/yarisobd/app/MainActivity.kt +++ b/android/app/src/main/java/com/yarisobd/app/MainActivity.kt @@ -4,18 +4,29 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Column 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.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.res.painterResource +import androidx.lifecycle.viewmodel.compose.viewModel import com.yarisobd.app.ui.theme.YarisOBDTheme import okhttp3.OkHttpClient 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() override fun onCreate(savedInstanceState: Bundle?) { @@ -24,15 +35,57 @@ class MainActivity : ComponentActivity() { setContent { YarisOBDTheme { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background - ) { - DualNetworkTestScreen( - piClient = client, - serverClient = client, - ) - } + YarisMainScreen(client = 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, + ) } } } diff --git a/android/app/src/main/java/com/yarisobd/app/VideoFileExt.kt b/android/app/src/main/java/com/yarisobd/app/VideoFileExt.kt new file mode 100644 index 0000000..3fc97fa --- /dev/null +++ b/android/app/src/main/java/com/yarisobd/app/VideoFileExt.kt @@ -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) { + "" + } +} 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 index 0e2f039..3f4749d 100644 --- 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 @@ -1,27 +1,37 @@ package com.yarisobd.app.data.remote 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.Query +import retrofit2.http.Streaming /** * 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 */ + /** GPS fix status */ @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 + /** List imported dashcam videos (Phase 17) */ + @GET("/api/local-videos") + suspend fun listVideos(): VideoListResponse + + /** Stream/download a video file with Range support */ + @Streaming + @GET("/api/local-video/file") + suspend fun getVideoFile(@Query("path") path: String): ResponseBody + + /** Get a cached thumbnail JPEG */ + @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): Map } diff --git a/android/app/src/main/java/com/yarisobd/app/data/remote/dto/VideoListResponse.kt b/android/app/src/main/java/com/yarisobd/app/data/remote/dto/VideoListResponse.kt new file mode 100644 index 0000000..2150205 --- /dev/null +++ b/android/app/src/main/java/com/yarisobd/app/data/remote/dto/VideoListResponse.kt @@ -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 = emptyList(), + val count: Int = 0, + val total_size: Long = 0, + val errors: List = 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, +) diff --git a/android/plan.md b/android/plan.md index 066136a..07a9625 100644 --- a/android/plan.md +++ b/android/plan.md @@ -69,35 +69,29 @@ android/ ## Sub-phases -### 28.1 — Scaffold + networking proof-of-concept 🚧 IN PROGRESS -**Gate: dual-network binding must work on the real Pixel before anything else is built.** +### 28.1 — Scaffold + networking proof-of-concept ✅ DONE (gate passed 2026-08-12) #### 28.1.1 — Project creation - [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/`) -- [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 - [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 `NetworkBinder.kt` — discovers WiFi (Pi hotspot) + cellular networks (kept for future use) - [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 "Test Both" → both should succeed simultaneously - - If either fails: investigate `bindProcessToNetwork()` behavior on this Pixel/Android version -- [ ] Commit: `feat: dual-network proof-of-concept` +- [x] Wire `MainActivity.kt` — simplified to single OkHttpClient, OS routes correctly +- [x] **Real-device gate test — PASSED 2026-08-12 on Pixel:** + - "Test Pi" → `✓ Pi OK (200)` with live GPS fix data + - "Test Server" → `✓ Server OK (200) — {"status":"ok"}` + - "Test Both Simultaneously" → `✓ BOTH NETWORKS WORK SIMULTANEOUSLY` + - Key finding: per-socket `Network.bindSocket()` not needed — Android OS routes + `10.42.0.1` through WiFi and `yaris.ceaf.dev` through cellular automatically +- [x] Commit: `348bfdc` feat: Phase 28.1 gate passed #### 28.1.3 — Credential storage - [ ] Add EncryptedSharedPreferences for server URL, device name, API key