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
// 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)
@@ -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.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,11 +35,54 @@ class MainActivity : ComponentActivity() {
setContent {
YarisOBDTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
DualNetworkTestScreen(
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,
)
@@ -36,4 +90,3 @@ class MainActivity : ComponentActivity() {
}
}
}
}
@@ -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
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<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
### 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