From b6f8416b5cc237414617ce6228d97207ef718fa3 Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Sat, 15 Aug 2026 19:32:05 -0400 Subject: [PATCH] feat(android): upload all + per-file retry (Phase 28.3 complete) --- PLAN.md | 10 +++-- .../java/com/yarisobd/app/DashcamViewModel.kt | 26 +++++++++++ .../java/com/yarisobd/app/DownloadsScreen.kt | 45 +++++++++++++++---- .../java/com/yarisobd/app/MainActivity.kt | 3 ++ android/plan.md | 8 ++-- 5 files changed, 77 insertions(+), 15 deletions(-) diff --git a/PLAN.md b/PLAN.md index b69fefa..3e6bb50 100644 --- a/PLAN.md +++ b/PLAN.md @@ -2021,7 +2021,7 @@ tracked in FEATURES.md. 2. **FEAT-02** โ€” QR / claim-code device pairing flow (medium) 3. **FEAT-04** โ€” Replace SIM868 2G GSM with 4G LTE modem (large) -### Phase 28: Native Android companion app โ€” ๐Ÿšง IN PROGRESS (28.1 โœ…, 28.2 โœ…, 28.3 ๐Ÿšง, 28.4โ€“28.7 pending) +### Phase 28: Native Android companion app โ€” ๐Ÿšง IN PROGRESS (28.1 โœ…, 28.2 โœ…, 28.3 โœ…, 28.4โ€“28.7 pending) **Renumbered from former Phase 26 on 2026-08-10 to make room for the road-test bug-fix phase. Renumbered to Phase 28 on 2026-08-11 to make room for Phase 27 feature additions. Planning completed 2026-08-11. Detailed implementation plan in [android/plan.md](android/plan.md).** @@ -2046,12 +2046,16 @@ completed 2026-08-11. Detailed implementation plan in [android/plan.md](android/ - Bottom nav: Dashcam tab + Test tab. - **Verified:** video list loads from real Pi over "yaris 911" hotspot on the owner's Pixel. -**28.3 โ€” Cellular upload ๐Ÿšง IN PROGRESS (2026-08-12)** +**28.3 โ€” Cellular upload โœ… DONE (2026-08-15)** - `ServerApi.kt`: Retrofit chunked-upload endpoints (`/api/v1/dashcam/upload/status` + `.../chunk`). - `UploadWorker.kt`: WorkManager foreground worker โ€” SHA256 โ†’ `/status` check โ†’ 40MB chunks, skipping already-received chunks. Byte-level progress via okio `ForwardingSink` (real bytes sent, not chunk count), debug logging, robust chunk reads, re-upload guard. -- `DownloadsScreen`: per-file upload status + inline progress bar; in-app test file generator. +- `DownloadsScreen`: per-file upload status + inline progress bar; in-app test file generator; + "Upload all" button (queues all pending files); per-file "Retry" button (tracks failed + uploads via `failedUploads` StateFlow). +- **Pending:** real-device verification of the upload path end-to-end (chunk 0 through the Pi + NAT + Cloudflare still needs confirming on the owner's Pixel). #### Why native (not web, not PWA) Revives the phone-relay goal (Phase 11/12) properly this time. The Phase 11 update proved 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 e5381dd..a0555bb 100644 --- a/android/app/src/main/java/com/yarisobd/app/DashcamViewModel.kt +++ b/android/app/src/main/java/com/yarisobd/app/DashcamViewModel.kt @@ -101,6 +101,10 @@ class DashcamViewModel(application: Application) : AndroidViewModel(application) private val _uploadedNames = MutableStateFlow>(emptySet()) val uploadedNames: StateFlow> = _uploadedNames.asStateFlow() + // Files whose last upload attempt failed (drives the per-file Retry button) + private val _failedUploads = MutableStateFlow>(emptySet()) + val failedUploads: StateFlow> = _failedUploads.asStateFlow() + // USB dashcam state private val _dashcamFiles = MutableStateFlow>(emptyList()) val dashcamFiles: StateFlow> = _dashcamFiles.asStateFlow() @@ -316,6 +320,9 @@ class DashcamViewModel(application: Application) : AndroidViewModel(application) return } + // Clear any previous failure marker โ€” this is a fresh attempt + _failedUploads.value = _failedUploads.value - file.name + val serverUrl = CredentialStore.getServerUrl(ctx) val deviceName = CredentialStore.getDeviceName(ctx) val apiKey = CredentialStore.getApiKey(ctx) @@ -350,11 +357,13 @@ class DashcamViewModel(application: Application) : AndroidViewModel(application) WorkInfo.State.SUCCEEDED -> { CredentialStore.markUploaded(getApplication(), file.name) _uploadedNames.value = CredentialStore.getUploaded(getApplication()) + _failedUploads.value = _failedUploads.value - file.name viewModelScope.launch { _downloadEvents.emit("โœ“ Uploaded: ${file.name}") } _uploadProgress.value = _uploadProgress.value - file.name } WorkInfo.State.FAILED -> { val err = info.outputData.getString("error") ?: "Unknown error" + _failedUploads.value = _failedUploads.value + file.name viewModelScope.launch { _downloadEvents.emit("โœ— Upload failed: $err") } _uploadProgress.value = _uploadProgress.value - file.name } @@ -368,6 +377,23 @@ class DashcamViewModel(application: Application) : AndroidViewModel(application) viewModelScope.launch { _downloadEvents.emit("โณ Uploading: ${file.name}") } } + fun uploadAll() { + val ctx = getApplication().applicationContext + if (!CredentialStore.isConfigured(ctx)) { + viewModelScope.launch { _downloadEvents.emit("โœ— Server credentials not configured") } + return + } + val pending = _downloadedFiles.value.filter { f -> + f.name !in _uploadedNames.value && f.name !in _uploadProgress.value + } + if (pending.isEmpty()) { + viewModelScope.launch { _downloadEvents.emit("โœ“ Nothing to upload โ€” all files already on server") } + return + } + pending.forEach { uploadFile(it) } + viewModelScope.launch { _downloadEvents.emit("โณ Queued ${pending.size} file(s) for upload") } + } + fun cancelUpload(dl: ActiveDownload) { try { val uuid = UUID.fromString(dl.workerId) diff --git a/android/app/src/main/java/com/yarisobd/app/DownloadsScreen.kt b/android/app/src/main/java/com/yarisobd/app/DownloadsScreen.kt index 119478c..21163f9 100644 --- a/android/app/src/main/java/com/yarisobd/app/DownloadsScreen.kt +++ b/android/app/src/main/java/com/yarisobd/app/DownloadsScreen.kt @@ -63,6 +63,8 @@ fun DownloadsScreen( deletingNames: Set, credentialsConfigured: Boolean, uploadedNames: Set, + failedUploads: Set, + onUploadAll: () -> Unit, onDelete: (DownloadedFile) -> Unit, onCancel: (ActiveDownload) -> Unit, onUpload: (DownloadedFile) -> Unit, @@ -85,13 +87,30 @@ fun DownloadsScreen( Spacer(Modifier.height(4.dp)) Text("${files.size} files ยท Movies/yaris-obd/", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) Spacer(Modifier.height(10.dp)) - Button( - onClick = onGenerateTestFile, - modifier = Modifier.height(34.dp), - shape = RoundedCornerShape(8.dp), - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp) - ) { - Text("Create 120 MB test file", style = MaterialTheme.typography.bodySmall) + val pendingCount = files.count { it.name !in uploadedNames && it.name !in activeUploads.keys } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (credentialsConfigured) { + Button( + onClick = onUploadAll, + enabled = pendingCount > 0, + modifier = Modifier.height(34.dp), + shape = RoundedCornerShape(8.dp), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp) + ) { + Text( + if (pendingCount > 0) "Upload all ($pendingCount)" else "Upload all", + style = MaterialTheme.typography.bodySmall + ) + } + } + Button( + onClick = onGenerateTestFile, + modifier = Modifier.height(34.dp), + shape = RoundedCornerShape(8.dp), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp) + ) { + Text("Create 120 MB test file", style = MaterialTheme.typography.bodySmall) + } } } } @@ -247,7 +266,17 @@ fun DownloadsScreen( if (isDeleting) { CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.error) } else { - if (credentialsConfigured && file.name !in uploadedNames) { + val isUploading = file.name in activeUploads.keys + if (file.name in failedUploads) { + Button( + onClick = { onUpload(file) }, + enabled = !isUploading, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), + modifier = Modifier.height(30.dp), + shape = RoundedCornerShape(8.dp), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp) + ) { Text("Retry", style = MaterialTheme.typography.bodySmall) } + } else if (credentialsConfigured && file.name !in uploadedNames && !isUploading) { IconButton(onClick = { onUpload(file) }) { Icon(Icons.Filled.CloudUpload, "Upload", tint = MaterialTheme.colorScheme.tertiary) } 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 7212571..d70ad58 100644 --- a/android/app/src/main/java/com/yarisobd/app/MainActivity.kt +++ b/android/app/src/main/java/com/yarisobd/app/MainActivity.kt @@ -63,6 +63,7 @@ fun YarisMainScreen(client: OkHttpClient) { val deletingNames by dashcamVm.deletingNames.collectAsState() val uploadProgress by dashcamVm.uploadProgress.collectAsState() val uploadedNames by dashcamVm.uploadedNames.collectAsState() + val failedUploads by dashcamVm.failedUploads.collectAsState() val dashcamFiles by dashcamVm.dashcamFiles.collectAsState() val dashcamTotalSize by dashcamVm.dashcamTotalSize.collectAsState() val dashcamStatus by dashcamVm.dashcamStatus.collectAsState() @@ -131,6 +132,8 @@ fun YarisMainScreen(client: OkHttpClient) { deletingNames = deletingNames, credentialsConfigured = credentialsConfigured, uploadedNames = uploadedNames, + failedUploads = failedUploads, + onUploadAll = { dashcamVm.uploadAll() }, onDelete = { dashcamVm.deleteDownloaded(it) }, onCancel = { dashcamVm.cancelDownload(it) }, onUpload = { dashcamVm.uploadFile(it) }, diff --git a/android/plan.md b/android/plan.md index 9494921..082a91b 100644 --- a/android/plan.md +++ b/android/plan.md @@ -147,12 +147,12 @@ android/ - [x] Debug logging (`Log.d`/`Log.e`, tag `UploadWorker`) throughout - [x] Re-upload guard: skips files already on server (`already_uploaded`) -#### 28.3.3 โ€” Upload queue UI ๐Ÿšง IN PROGRESS +#### 28.3.3 โ€” Upload queue UI โœ… DONE (2026-08-15) - [x] Downloads screen shows per-file upload status + inline progress bar - [x] In-app test file generator for upload testing -- [ ] "Upload all" button โ†’ enqueue all pending files -- [ ] Retry button per failed file -- [ ] Commit: `feat: upload queue screen` +- [x] "Upload all" button โ†’ enqueue all pending files (header card, shows pending count) +- [x] Retry button per failed file (tracks failed uploads via `failedUploads` StateFlow) +- [x] Commit: `feat: upload queue screen โ€” upload all + per-file retry` ### 28.4 โ€” Live dashboard โฌœ NOT STARTED **Depends on: 28.1 (networking layer). Independent of 28.2/28.3.**