From e19511fc679e2d8196831c5980f624fbf84d0593 Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Wed, 12 Aug 2026 02:27:33 -0400 Subject: [PATCH] fix: downloads use WorkManager foreground service + inline progress - DownloadWorker: foreground service with notification + setProgressAsync - ActiveDownload state tracked via WorkManager LiveData observation - Downloads tab shows progress bar + % for in-flight downloads - Survives screen lock / background - Fixed FGS type for Android 14+ (FOREGROUND_SERVICE_TYPE_DATA_SYNC) --- .../java/com/yarisobd/app/DashcamViewModel.kt | 48 +++++++++++++++++-- .../java/com/yarisobd/app/DownloadsScreen.kt | 42 +++++++++++++++- .../java/com/yarisobd/app/MainActivity.kt | 2 + poc/yaris-mediacenter-plan.md | 31 +++++++----- 4 files changed, 106 insertions(+), 17 deletions(-) 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 2d938df..477eb70 100644 --- a/android/app/src/main/java/com/yarisobd/app/DashcamViewModel.kt +++ b/android/app/src/main/java/com/yarisobd/app/DashcamViewModel.kt @@ -5,12 +5,12 @@ import android.os.Environment import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkInfo import androidx.work.WorkManager import androidx.work.workDataOf import com.yarisobd.app.data.remote.PiApi import com.yarisobd.app.data.remote.dto.VideoFile import com.yarisobd.app.data.remote.dto.VideoListResponse -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -36,6 +36,12 @@ data class DownloadedFile( val file: File, ) +data class ActiveDownload( + val filename: String, + val progress: Int, // 0-100 + val done: Boolean, +) + class DashcamViewModel(application: Application) : AndroidViewModel(application) { private val baseUrl = "http://10.42.0.1" // no trailing slash @@ -73,6 +79,9 @@ class DashcamViewModel(application: Application) : AndroidViewModel(application) private val _downloadedFiles = MutableStateFlow>(emptyList()) val downloadedFiles: StateFlow> = _downloadedFiles.asStateFlow() + private val _activeDownloads = MutableStateFlow>(emptyMap()) + val activeDownloads: StateFlow> = _activeDownloads.asStateFlow() + private fun getDownloadDir(): File { // Public Movies/yaris-obd/ — visible in file managers val dir = File( @@ -109,7 +118,6 @@ class DashcamViewModel(application: Application) : AndroidViewModel(application) val url = "$baseUrl/api/local-video/file?path=${video.path}" val filename = video.path.substringAfterLast("/") - // Check if already downloaded val dir = getDownloadDir() val localFile = File(dir, filename) if (localFile.exists() && localFile.length() == video.size && video.size > 0) { @@ -131,10 +139,42 @@ class DashcamViewModel(application: Application) : AndroidViewModel(application) .addTag("download_${video.path}") .build() - WorkManager.getInstance(getApplication()).enqueue(request) + val wm = WorkManager.getInstance(getApplication()) + wm.enqueue(request) + + // Observe progress + val workerId = request.id + _activeDownloads.value = _activeDownloads.value + (filename to ActiveDownload(filename, 0, false)) + + wm.getWorkInfoByIdLiveData(workerId).observeForever { info -> + if (info == null) return@observeForever + + val progress = info.progress.getInt("pct", 0) + val done = info.state == WorkInfo.State.SUCCEEDED || info.state == WorkInfo.State.FAILED + + _activeDownloads.value = _activeDownloads.value + (filename to ActiveDownload(filename, progress, done)) + + when (info.state) { + WorkInfo.State.SUCCEEDED -> { + viewModelScope.launch { + _downloadEvents.emit("✓ Downloaded: $filename") + scanDownloads() + } + _activeDownloads.value = _activeDownloads.value - filename + } + WorkInfo.State.FAILED -> { + val err = info.outputData.getString("error") ?: "Unknown error" + viewModelScope.launch { + _downloadEvents.emit("✗ Download failed: $err") + } + _activeDownloads.value = _activeDownloads.value - filename + } + else -> {} // running / enqueued + } + } viewModelScope.launch { - _downloadEvents.emit("⏳ Downloading: $filename (continues in background)") + _downloadEvents.emit("⏳ Downloading: $filename") } } 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 664f7ad..49b0582 100644 --- a/android/app/src/main/java/com/yarisobd/app/DownloadsScreen.kt +++ b/android/app/src/main/java/com/yarisobd/app/DownloadsScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -34,6 +35,7 @@ import androidx.core.content.FileProvider @Composable fun DownloadsScreen( files: List, + activeDownloads: Map, onDelete: (DownloadedFile) -> Unit, onRefresh: () -> Unit, ) { @@ -63,7 +65,45 @@ fun DownloadsScreen( } } - if (files.isEmpty()) { + if (activeDownloads.isNotEmpty()) { + LazyColumn( + contentPadding = androidx.compose.foundation.layout.PaddingValues( + start = 16.dp, end = 16.dp + ), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(activeDownloads.entries.toList(), key = { it.key }) { (filename, dl) -> + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + ) + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = if (dl.done) "✓ $filename" else "⏳ $filename", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + if (!dl.done) { + Spacer(modifier = Modifier.height(4.dp)) + LinearProgressIndicator( + progress = { dl.progress / 100f }, + modifier = Modifier.fillMaxWidth(), + ) + Text( + text = "${dl.progress}%", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + } + + if (files.isEmpty() && activeDownloads.isEmpty()) { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center 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 a3d1926..2bb19ed 100644 --- a/android/app/src/main/java/com/yarisobd/app/MainActivity.kt +++ b/android/app/src/main/java/com/yarisobd/app/MainActivity.kt @@ -54,6 +54,7 @@ fun YarisMainScreen(client: OkHttpClient) { val error by dashcamVm.error.collectAsState() val downloadProgress by dashcamVm.downloadProgress.collectAsState() val downloadedFiles by dashcamVm.downloadedFiles.collectAsState() + val activeDownloads by dashcamVm.activeDownloads.collectAsState() val snackbarHostState = remember { SnackbarHostState() } var selectedTab by remember { mutableIntStateOf(0) } @@ -104,6 +105,7 @@ fun YarisMainScreen(client: OkHttpClient) { onDelete = { dashcamVm.deleteVideo(it) }, ) 1 -> DownloadsScreen( + activeDownloads = activeDownloads, files = downloadedFiles, onDelete = { dashcamVm.deleteDownloaded(it) }, onRefresh = { dashcamVm.scanDownloads() }, diff --git a/poc/yaris-mediacenter-plan.md b/poc/yaris-mediacenter-plan.md index 8c95897..239fc55 100644 --- a/poc/yaris-mediacenter-plan.md +++ b/poc/yaris-mediacenter-plan.md @@ -88,16 +88,12 @@ radio. This has two implications: `/boot/firmware/config.txt` to free the PL011 UART for the SIM868 GPS module (see Phase 1 bring-up notes). **This completely disables the onboard Bluetooth controller.** To use Bluetooth for mediacenter: - - **Option A:** Use a USB Bluetooth dongle instead (preserves GPS UART routing, - cleaner separation, likely better range/audio quality with BT 5.0). - - **Option B:** Use the mini-UART (`/dev/ttyS0`) for the SIM868 at a lower, - core-frequency-dependent baud rate, re-enable the onboard Bluetooth for the - PL011 UART. Riskier — mini-UART is less stable and baud rate depends on the - core clock, which can drift. - - **Recommendation: Option A (USB dongle).** The onboard Bluetooth is disabled for - GPS, and a USB dongle is cheap, isolated, and doesn't require touching the - already-working GPS UART config. + - ~~**Option A:** Use a USB Bluetooth dongle~~ — **CONFIRMED: owner has a USB + Bluetooth dongle on hand.** This preserves GPS UART routing, gives cleaner + separation (no WiFi/BT combo-chip contention), and likely provides better + range/audio quality. + - ~~**Option B:** Use the mini-UART~~ — ruled out. No need to touch the + already-working GPS UART config. --- @@ -294,6 +290,17 @@ Add M6 later as an optimization for Spotify specifically. --- +## Hardware Status + +| Component | Status | +|-----------|--------| +| USB Bluetooth dongle | ✅ Confirmed — owner has one on hand | +| Car radio Bluetooth capability | ❓ Unknown — Q1 (does the 2013 Yaris radio have Bluetooth/A2DP?) | +| Test Bluetooth speaker (for M1 validation) | ❓ Unknown — needed to simulate radio side if car radio has no BT | +| Pi 3 USB port availability | ⚠️ Check — already has PL2303 OBD adapter + TP-Link T3U WiFi + possibly E3372 LTE modem; may need a powered USB hub | + +--- + ## Open Questions (to resolve during M1) | # | Question | Impact | @@ -301,7 +308,7 @@ Add M6 later as an optimization for Spotify specifically. | Q1 | Does the 2013 Yaris radio have Bluetooth at all? If so, which profiles? | Defines what the Pi needs to broadcast (A2DP vs HFP only) | | Q2 | Can the Pi's BCM43438 (or a USB dongle) maintain two simultaneous Bluetooth connections (phone + radio) without audio dropouts? | Determines if USB dongle is required | | Q3 | What is the real end-to-end latency (phone → Pi → radio)? | Determines if this is usable for video/navigation or music-only | -| Q4 | Does `dtoverlay=disable-bt` permanently block the onboard Bluetooth, or can it be re-enabled while keeping the SIM868 on mini-UART? | Determines if USB dongle is mandatory | +| Q4 | ~~Does `dtoverlay=disable-bt` permanently block the onboard Bluetooth?~~ | ✅ Resolved — USB dongle confirmed available, onboard BT stays disabled for GPS | | Q5 | Does the car radio support AVRCP metadata display (track title/artist on the radio screen)? | Determines if Phase M2 metadata relay is worth building | --- @@ -328,7 +335,7 @@ Add M6 later as an optimization for Spotify specifically. |------|-----------|--------|------------| | Audio latency too high for video/nav | Medium | High | Phase M1 validates early; M6 (Spotify Connect) bypasses it for music | | Bluetooth coexistence with WiFi hotspot causes dropouts | Medium | Medium | USB dongle + 5 GHz WiFi on wlan1 (already available) | -| `disable-bt` overlay blocks all Bluetooth | High (confirmed) | Low | USB dongle is the planned path anyway | +| `disable-bt` overlay blocks onboard Bluetooth | High (confirmed) | Low | ✅ USB dongle confirmed available — onboard BT stays disabled, dongle handles mediacenter | | AVRCP relay via DBus is unreliable or incomplete | Medium | Medium | Fall back to phone-side controls only; M1 validates | | Car radio has no Bluetooth at all | ? | High | M1 clarifies; if no BT radio, this project needs a different approach (FM transmitter? aux cable? replace the head unit?) |