feat(android): upload all + per-file retry (Phase 28.3 complete)
This commit is contained in:
@@ -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,6 +87,22 @@ 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))
|
||||||
|
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(
|
Button(
|
||||||
onClick = onGenerateTestFile,
|
onClick = onGenerateTestFile,
|
||||||
modifier = Modifier.height(34.dp),
|
modifier = Modifier.height(34.dp),
|
||||||
@@ -95,6 +113,7 @@ fun DownloadsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Active uploads
|
// Active uploads
|
||||||
AnimatedVisibility(visible = activeUploads.isNotEmpty(), enter = fadeIn(), exit = fadeOut()) {
|
AnimatedVisibility(visible = activeUploads.isNotEmpty(), enter = fadeIn(), exit = fadeOut()) {
|
||||||
@@ -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
@@ -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.**
|
||||||
|
|||||||
Reference in New Issue
Block a user