feat(android): upload all + per-file retry (Phase 28.3 complete)

This commit is contained in:
Mathieu Moran
2026-08-15 19:32:05 -04:00
parent 749feeb9d5
commit b6f8416b5c
5 changed files with 77 additions and 15 deletions
+7 -3
View File
@@ -2021,7 +2021,7 @@ tracked in FEATURES.md.
2. **FEAT-02** — QR / claim-code device pairing flow (medium) 2. **FEAT-02** — QR / claim-code device pairing flow (medium)
3. **FEAT-04** — Replace SIM868 2G GSM with 4G LTE modem (large) 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 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 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).** 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. - Bottom nav: Dashcam tab + Test tab.
- **Verified:** video list loads from real Pi over "yaris 911" hotspot on the owner's Pixel. - **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`). - `ServerApi.kt`: Retrofit chunked-upload endpoints (`/api/v1/dashcam/upload/status` + `.../chunk`).
- `UploadWorker.kt`: WorkManager foreground worker — SHA256 → `/status` check → 40MB chunks, - `UploadWorker.kt`: WorkManager foreground worker — SHA256 → `/status` check → 40MB chunks,
skipping already-received chunks. Byte-level progress via okio `ForwardingSink` (real bytes skipping already-received chunks. Byte-level progress via okio `ForwardingSink` (real bytes
sent, not chunk count), debug logging, robust chunk reads, re-upload guard. 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) #### Why native (not web, not PWA)
Revives the phone-relay goal (Phase 11/12) properly this time. The Phase 11 update proved Revives the phone-relay goal (Phase 11/12) properly this time. The Phase 11 update proved
@@ -101,6 +101,10 @@ class DashcamViewModel(application: Application) : AndroidViewModel(application)
private val _uploadedNames = MutableStateFlow<Set<String>>(emptySet()) private val _uploadedNames = MutableStateFlow<Set<String>>(emptySet())
val uploadedNames: StateFlow<Set<String>> = _uploadedNames.asStateFlow() val uploadedNames: StateFlow<Set<String>> = _uploadedNames.asStateFlow()
// Files whose last upload attempt failed (drives the per-file Retry button)
private val _failedUploads = MutableStateFlow<Set<String>>(emptySet())
val failedUploads: StateFlow<Set<String>> = _failedUploads.asStateFlow()
// USB dashcam state // USB dashcam state
private val _dashcamFiles = MutableStateFlow<List<DashcamFile>>(emptyList()) private val _dashcamFiles = MutableStateFlow<List<DashcamFile>>(emptyList())
val dashcamFiles: StateFlow<List<DashcamFile>> = _dashcamFiles.asStateFlow() val dashcamFiles: StateFlow<List<DashcamFile>> = _dashcamFiles.asStateFlow()
@@ -316,6 +320,9 @@ class DashcamViewModel(application: Application) : AndroidViewModel(application)
return return
} }
// Clear any previous failure marker — this is a fresh attempt
_failedUploads.value = _failedUploads.value - file.name
val serverUrl = CredentialStore.getServerUrl(ctx) val serverUrl = CredentialStore.getServerUrl(ctx)
val deviceName = CredentialStore.getDeviceName(ctx) val deviceName = CredentialStore.getDeviceName(ctx)
val apiKey = CredentialStore.getApiKey(ctx) val apiKey = CredentialStore.getApiKey(ctx)
@@ -350,11 +357,13 @@ class DashcamViewModel(application: Application) : AndroidViewModel(application)
WorkInfo.State.SUCCEEDED -> { WorkInfo.State.SUCCEEDED -> {
CredentialStore.markUploaded(getApplication(), file.name) CredentialStore.markUploaded(getApplication(), file.name)
_uploadedNames.value = CredentialStore.getUploaded(getApplication()) _uploadedNames.value = CredentialStore.getUploaded(getApplication())
_failedUploads.value = _failedUploads.value - file.name
viewModelScope.launch { _downloadEvents.emit("✓ Uploaded: ${file.name}") } viewModelScope.launch { _downloadEvents.emit("✓ Uploaded: ${file.name}") }
_uploadProgress.value = _uploadProgress.value - file.name _uploadProgress.value = _uploadProgress.value - file.name
} }
WorkInfo.State.FAILED -> { WorkInfo.State.FAILED -> {
val err = info.outputData.getString("error") ?: "Unknown error" val err = info.outputData.getString("error") ?: "Unknown error"
_failedUploads.value = _failedUploads.value + file.name
viewModelScope.launch { _downloadEvents.emit("✗ Upload failed: $err") } viewModelScope.launch { _downloadEvents.emit("✗ Upload failed: $err") }
_uploadProgress.value = _uploadProgress.value - file.name _uploadProgress.value = _uploadProgress.value - file.name
} }
@@ -368,6 +377,23 @@ class DashcamViewModel(application: Application) : AndroidViewModel(application)
viewModelScope.launch { _downloadEvents.emit("⏳ Uploading: ${file.name}") } viewModelScope.launch { _downloadEvents.emit("⏳ Uploading: ${file.name}") }
} }
fun uploadAll() {
val ctx = getApplication<YarisApp>().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) { fun cancelUpload(dl: ActiveDownload) {
try { try {
val uuid = UUID.fromString(dl.workerId) val uuid = UUID.fromString(dl.workerId)
@@ -63,6 +63,8 @@ fun DownloadsScreen(
deletingNames: Set<String>, deletingNames: Set<String>,
credentialsConfigured: Boolean, credentialsConfigured: Boolean,
uploadedNames: Set<String>, uploadedNames: Set<String>,
failedUploads: Set<String>,
onUploadAll: () -> Unit,
onDelete: (DownloadedFile) -> Unit, onDelete: (DownloadedFile) -> Unit,
onCancel: (ActiveDownload) -> Unit, onCancel: (ActiveDownload) -> Unit,
onUpload: (DownloadedFile) -> Unit, onUpload: (DownloadedFile) -> Unit,
@@ -85,13 +87,30 @@ fun DownloadsScreen(
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Text("${files.size} files · Movies/yaris-obd/", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) Text("${files.size} files · Movies/yaris-obd/", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(10.dp)) Spacer(Modifier.height(10.dp))
Button( val pendingCount = files.count { it.name !in uploadedNames && it.name !in activeUploads.keys }
onClick = onGenerateTestFile, Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
modifier = Modifier.height(34.dp), if (credentialsConfigured) {
shape = RoundedCornerShape(8.dp), Button(
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp) onClick = onUploadAll,
) { enabled = pendingCount > 0,
Text("Create 120 MB test file", style = MaterialTheme.typography.bodySmall) 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) { if (isDeleting) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.error) CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.error)
} else { } 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) }) { IconButton(onClick = { onUpload(file) }) {
Icon(Icons.Filled.CloudUpload, "Upload", tint = MaterialTheme.colorScheme.tertiary) Icon(Icons.Filled.CloudUpload, "Upload", tint = MaterialTheme.colorScheme.tertiary)
} }
@@ -63,6 +63,7 @@ fun YarisMainScreen(client: OkHttpClient) {
val deletingNames by dashcamVm.deletingNames.collectAsState() val deletingNames by dashcamVm.deletingNames.collectAsState()
val uploadProgress by dashcamVm.uploadProgress.collectAsState() val uploadProgress by dashcamVm.uploadProgress.collectAsState()
val uploadedNames by dashcamVm.uploadedNames.collectAsState() val uploadedNames by dashcamVm.uploadedNames.collectAsState()
val failedUploads by dashcamVm.failedUploads.collectAsState()
val dashcamFiles by dashcamVm.dashcamFiles.collectAsState() val dashcamFiles by dashcamVm.dashcamFiles.collectAsState()
val dashcamTotalSize by dashcamVm.dashcamTotalSize.collectAsState() val dashcamTotalSize by dashcamVm.dashcamTotalSize.collectAsState()
val dashcamStatus by dashcamVm.dashcamStatus.collectAsState() val dashcamStatus by dashcamVm.dashcamStatus.collectAsState()
@@ -131,6 +132,8 @@ fun YarisMainScreen(client: OkHttpClient) {
deletingNames = deletingNames, deletingNames = deletingNames,
credentialsConfigured = credentialsConfigured, credentialsConfigured = credentialsConfigured,
uploadedNames = uploadedNames, uploadedNames = uploadedNames,
failedUploads = failedUploads,
onUploadAll = { dashcamVm.uploadAll() },
onDelete = { dashcamVm.deleteDownloaded(it) }, onDelete = { dashcamVm.deleteDownloaded(it) },
onCancel = { dashcamVm.cancelDownload(it) }, onCancel = { dashcamVm.cancelDownload(it) },
onUpload = { dashcamVm.uploadFile(it) }, onUpload = { dashcamVm.uploadFile(it) },
+4 -4
View File
@@ -147,12 +147,12 @@ android/
- [x] Debug logging (`Log.d`/`Log.e`, tag `UploadWorker`) throughout - [x] Debug logging (`Log.d`/`Log.e`, tag `UploadWorker`) throughout
- [x] Re-upload guard: skips files already on server (`already_uploaded`) - [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] Downloads screen shows per-file upload status + inline progress bar
- [x] In-app test file generator for upload testing - [x] In-app test file generator for upload testing
- [ ] "Upload all" button → enqueue all pending files - [x] "Upload all" button → enqueue all pending files (header card, shows pending count)
- [ ] Retry button per failed file - [x] Retry button per failed file (tracks failed uploads via `failedUploads` StateFlow)
- [ ] Commit: `feat: upload queue screen` - [x] Commit: `feat: upload queue screen — upload all + per-file retry`
### 28.4 — Live dashboard ⬜ NOT STARTED ### 28.4 — Live dashboard ⬜ NOT STARTED
**Depends on: 28.1 (networking layer). Independent of 28.2/28.3.** **Depends on: 28.1 (networking layer). Independent of 28.2/28.3.**