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
@@ -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) },