From bc1516257a0eeaffa7815a402aa7af0642ab98ce Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Wed, 12 Aug 2026 05:06:39 -0400 Subject: [PATCH] feat: USB dashcam file browser on Dashcam tab - PiApi extended: listDashcamFiles, deleteDashcamFile, importDashcamFiles - DTO: DashcamFilesResponse, DashcamDevice, DashcamFile - DashcamScreen: USB Dashcam card with status, file count, Import All + Refresh - ViewModel: loadDashcamFiles, deleteDashcamFile, importDashcamFiles --- .../java/com/yarisobd/app/DashcamScreen.kt | 35 +++++++++++++ .../java/com/yarisobd/app/DashcamViewModel.kt | 52 +++++++++++++++++++ .../java/com/yarisobd/app/MainActivity.kt | 12 ++++- .../com/yarisobd/app/data/remote/PiApi.kt | 16 ++++++ .../data/remote/dto/DashcamFilesResponse.kt | 30 +++++++++++ 5 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 android/app/src/main/java/com/yarisobd/app/data/remote/dto/DashcamFilesResponse.kt diff --git a/android/app/src/main/java/com/yarisobd/app/DashcamScreen.kt b/android/app/src/main/java/com/yarisobd/app/DashcamScreen.kt index 922e054..c317e9c 100644 --- a/android/app/src/main/java/com/yarisobd/app/DashcamScreen.kt +++ b/android/app/src/main/java/com/yarisobd/app/DashcamScreen.kt @@ -12,6 +12,7 @@ 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.PaddingValues import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -40,6 +41,7 @@ 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.DashcamFile import com.yarisobd.app.data.remote.dto.VideoFile @Composable @@ -50,13 +52,46 @@ fun DashcamScreen( isLoading: Boolean, error: String?, downloadProgress: Map, + dashcamFiles: List, + dashcamTotalSize: Long, + dashcamStatus: String, + dashcamLoading: Boolean, onRefresh: () -> Unit, onDownload: (VideoFile) -> Unit, onDelete: (VideoFile) -> Unit, + onDeleteDashcam: (DashcamFile) -> Unit, + onImport: (List?) -> Unit, ) { Column(modifier = Modifier.fillMaxSize()) { com.yarisobd.app.ui.components.YarisHeader() + // USB Dashcam section + Card( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh) + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text("USB Dashcam", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary) + when (dashcamStatus) { + "ok" -> { + Text("${dashcamFiles.size} files · ${formatBytes(dashcamTotalSize)}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + if (dashcamFiles.isNotEmpty()) { + Spacer(Modifier.height(4.dp)) + Row { + Button(onClick = { onImport(null) }, modifier = Modifier.weight(1f), contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp)) { Text("Import All", style = MaterialTheme.typography.bodySmall) } + Spacer(Modifier.width(4.dp)) + Button(onClick = onRefresh, enabled = !dashcamLoading, modifier = Modifier.weight(1f), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary), contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp)) { Text(if (dashcamLoading) "..." else "Refresh", style = MaterialTheme.typography.bodySmall) } + } + } + } + "no_device" -> Text("No USB dashcam connected", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + "error" -> Text("Failed to read dashcam", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + "" -> Text("Tap Refresh to scan", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + else -> Text(dashcamStatus, style = MaterialTheme.typography.bodySmall) + } + } + } + // Header card Card( modifier = Modifier diff --git a/android/app/src/main/java/com/yarisobd/app/DashcamViewModel.kt b/android/app/src/main/java/com/yarisobd/app/DashcamViewModel.kt index 2c39662..5f3da75 100644 --- a/android/app/src/main/java/com/yarisobd/app/DashcamViewModel.kt +++ b/android/app/src/main/java/com/yarisobd/app/DashcamViewModel.kt @@ -10,6 +10,8 @@ import androidx.work.WorkManager import androidx.work.workDataOf import com.yarisobd.app.data.CredentialStore import com.yarisobd.app.data.remote.PiApi +import com.yarisobd.app.data.remote.dto.DashcamFile +import com.yarisobd.app.data.remote.dto.DashcamFilesResponse import com.yarisobd.app.data.remote.dto.VideoFile import com.yarisobd.app.data.remote.dto.VideoListResponse import kotlinx.coroutines.flow.MutableSharedFlow @@ -92,6 +94,16 @@ class DashcamViewModel(application: Application) : AndroidViewModel(application) private val _uploadProgress = MutableStateFlow>(emptyMap()) val uploadProgress: StateFlow> = _uploadProgress.asStateFlow() + // USB dashcam state + private val _dashcamFiles = MutableStateFlow>(emptyList()) + val dashcamFiles: StateFlow> = _dashcamFiles.asStateFlow() + private val _dashcamTotalSize = MutableStateFlow(0L) + val dashcamTotalSize: StateFlow = _dashcamTotalSize.asStateFlow() + private val _dashcamStatus = MutableStateFlow("") + val dashcamStatus: StateFlow = _dashcamStatus.asStateFlow() + private val _dashcamLoading = MutableStateFlow(false) + val dashcamLoading: StateFlow = _dashcamLoading.asStateFlow() + private fun getDownloadDir(): File { // Public Movies/yaris-obd/ — visible in file managers val dir = File( @@ -124,6 +136,46 @@ class DashcamViewModel(application: Application) : AndroidViewModel(application) } } + fun loadDashcamFiles() { + viewModelScope.launch { + _dashcamLoading.value = true + try { + val resp: DashcamFilesResponse = api.listDashcamFiles() + _dashcamStatus.value = resp.status + _dashcamFiles.value = resp.files + _dashcamTotalSize.value = resp.total_size + } catch (e: Exception) { + _dashcamStatus.value = "error" + _downloadEvents.emit("Dashcam: ${e.message}") + } finally { + _dashcamLoading.value = false + } + } + } + + fun deleteDashcamFile(file: DashcamFile) { + viewModelScope.launch { + try { + api.deleteDashcamFile(mapOf("path" to file.path)) + _downloadEvents.emit("✓ Deleted from dashcam: ${file.path}") + loadDashcamFiles() + } catch (e: Exception) { + _downloadEvents.emit("✗ Delete failed: ${e.message}") + } + } + } + + fun importDashcamFiles(paths: List?) { + viewModelScope.launch { + try { + api.importDashcamFiles(mapOf("paths" to (paths ?: emptyList()))) + _downloadEvents.emit("⏳ Import started") + } catch (e: Exception) { + _downloadEvents.emit("✗ Import failed: ${e.message}") + } + } + } + fun downloadVideo(video: VideoFile) { val url = "$baseUrl/api/local-video/file?path=${video.path}" val filename = video.path.substringAfterLast("/") 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 9fc4635..bce4a9c 100644 --- a/android/app/src/main/java/com/yarisobd/app/MainActivity.kt +++ b/android/app/src/main/java/com/yarisobd/app/MainActivity.kt @@ -62,6 +62,10 @@ fun YarisMainScreen(client: OkHttpClient) { val activeDownloads by dashcamVm.activeDownloads.collectAsState() val deletingNames by dashcamVm.deletingNames.collectAsState() val uploadProgress by dashcamVm.uploadProgress.collectAsState() + val dashcamFiles by dashcamVm.dashcamFiles.collectAsState() + val dashcamTotalSize by dashcamVm.dashcamTotalSize.collectAsState() + val dashcamStatus by dashcamVm.dashcamStatus.collectAsState() + val dashcamLoading by dashcamVm.dashcamLoading.collectAsState() val context = androidx.compose.ui.platform.LocalContext.current val credentialsConfigured = com.yarisobd.app.data.CredentialStore.isConfigured(context) @@ -109,9 +113,15 @@ fun YarisMainScreen(client: OkHttpClient) { isLoading = isLoading, error = error, downloadProgress = downloadProgress, - onRefresh = { dashcamVm.loadVideos() }, + dashcamFiles = dashcamFiles, + dashcamTotalSize = dashcamTotalSize, + dashcamStatus = dashcamStatus, + dashcamLoading = dashcamLoading, + onRefresh = { dashcamVm.loadVideos(); dashcamVm.loadDashcamFiles() }, onDownload = { dashcamVm.downloadVideo(it) }, onDelete = { dashcamVm.deleteVideo(it) }, + onDeleteDashcam = { dashcamVm.deleteDashcamFile(it) }, + onImport = { dashcamVm.importDashcamFiles(it) }, ) 1 -> DownloadsScreen( files = downloadedFiles, 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 3b043a9..ece5ab6 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,5 +1,6 @@ package com.yarisobd.app.data.remote +import com.yarisobd.app.data.remote.dto.DashcamFilesResponse import com.yarisobd.app.data.remote.dto.GpsStatus import com.yarisobd.app.data.remote.dto.VideoListResponse import kotlinx.serialization.json.JsonObject @@ -7,6 +8,7 @@ import okhttp3.ResponseBody import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.HTTP +import retrofit2.http.POST import retrofit2.http.Query import retrofit2.http.Streaming @@ -33,6 +35,20 @@ interface PiApi { suspend fun getThumbnail(@Query("path") path: String): ResponseBody /** Delete local copy only */ + + // --- USB dashcam endpoints --- + + /** List files on USB dashcam (Phase 16) */ + @GET("/api/dash/files") + suspend fun listDashcamFiles(): DashcamFilesResponse + + /** Delete a single file from USB dashcam */ + @HTTP(method = "DELETE", path = "/api/dash/file", hasBody = true) + suspend fun deleteDashcamFile(@Body body: Map): JsonObject + + /** Import files from USB dashcam (selected or all) */ + @POST("/api/dash/import") + suspend fun importDashcamFiles(@Body body: Map>): JsonObject @HTTP(method = "DELETE", path = "/api/local-video", hasBody = true) suspend fun deleteVideo(@Body body: Map): JsonObject } diff --git a/android/app/src/main/java/com/yarisobd/app/data/remote/dto/DashcamFilesResponse.kt b/android/app/src/main/java/com/yarisobd/app/data/remote/dto/DashcamFilesResponse.kt new file mode 100644 index 0000000..818408a --- /dev/null +++ b/android/app/src/main/java/com/yarisobd/app/data/remote/dto/DashcamFilesResponse.kt @@ -0,0 +1,30 @@ +package com.yarisobd.app.data.remote.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class DashcamFilesResponse( + val status: String = "", + val message: String? = null, + val device: DashcamDevice? = null, + val files: List = emptyList(), + val count: Int = 0, + val total_size: Long = 0, + val errors: List = emptyList(), +) + +@Serializable +data class DashcamDevice( + val path: String = "", + val fstype: String = "", + val label: String? = null, + val size: String? = null, + val mountpoint: String? = null, +) + +@Serializable +data class DashcamFile( + val path: String = "", + val size: Long = 0, + val modified: Double = 0.0, +)