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)
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
@@ -101,6 +101,10 @@ class DashcamViewModel(application: Application) : AndroidViewModel(application)
private val _uploadedNames = MutableStateFlow<Set<String>>(emptySet())
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
private val _dashcamFiles = MutableStateFlow<List<DashcamFile>>(emptyList())
val dashcamFiles: StateFlow<List<DashcamFile>> = _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<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) {
try {
val uuid = UUID.fromString(dl.workerId)
@@ -63,6 +63,8 @@ fun DownloadsScreen(
deletingNames: Set<String>,
credentialsConfigured: Boolean,
uploadedNames: Set<String>,
failedUploads: Set<String>,
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)
}
@@ -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) },
+4 -4
View File
@@ -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.**