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)
This commit is contained in:
Mathieu Moran
2026-08-12 02:27:33 -04:00
parent bc29a01337
commit e19511fc67
4 changed files with 106 additions and 17 deletions
@@ -5,12 +5,12 @@ import android.os.Environment
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.workDataOf import androidx.work.workDataOf
import com.yarisobd.app.data.remote.PiApi import com.yarisobd.app.data.remote.PiApi
import com.yarisobd.app.data.remote.dto.VideoFile import com.yarisobd.app.data.remote.dto.VideoFile
import com.yarisobd.app.data.remote.dto.VideoListResponse import com.yarisobd.app.data.remote.dto.VideoListResponse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -36,6 +36,12 @@ data class DownloadedFile(
val file: File, val file: File,
) )
data class ActiveDownload(
val filename: String,
val progress: Int, // 0-100
val done: Boolean,
)
class DashcamViewModel(application: Application) : AndroidViewModel(application) { class DashcamViewModel(application: Application) : AndroidViewModel(application) {
private val baseUrl = "http://10.42.0.1" // no trailing slash 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<List<DownloadedFile>>(emptyList()) private val _downloadedFiles = MutableStateFlow<List<DownloadedFile>>(emptyList())
val downloadedFiles: StateFlow<List<DownloadedFile>> = _downloadedFiles.asStateFlow() val downloadedFiles: StateFlow<List<DownloadedFile>> = _downloadedFiles.asStateFlow()
private val _activeDownloads = MutableStateFlow<Map<String, ActiveDownload>>(emptyMap())
val activeDownloads: StateFlow<Map<String, ActiveDownload>> = _activeDownloads.asStateFlow()
private fun getDownloadDir(): File { private fun getDownloadDir(): File {
// Public Movies/yaris-obd/ — visible in file managers // Public Movies/yaris-obd/ — visible in file managers
val dir = File( val dir = File(
@@ -109,7 +118,6 @@ class DashcamViewModel(application: Application) : AndroidViewModel(application)
val url = "$baseUrl/api/local-video/file?path=${video.path}" val url = "$baseUrl/api/local-video/file?path=${video.path}"
val filename = video.path.substringAfterLast("/") val filename = video.path.substringAfterLast("/")
// Check if already downloaded
val dir = getDownloadDir() val dir = getDownloadDir()
val localFile = File(dir, filename) val localFile = File(dir, filename)
if (localFile.exists() && localFile.length() == video.size && video.size > 0) { if (localFile.exists() && localFile.length() == video.size && video.size > 0) {
@@ -131,10 +139,42 @@ class DashcamViewModel(application: Application) : AndroidViewModel(application)
.addTag("download_${video.path}") .addTag("download_${video.path}")
.build() .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 { viewModelScope.launch {
_downloadEvents.emit("⏳ Downloading: $filename (continues in background)") _downloadEvents.emit("⏳ Downloading: $filename")
} }
} }
@@ -20,6 +20,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -34,6 +35,7 @@ import androidx.core.content.FileProvider
@Composable @Composable
fun DownloadsScreen( fun DownloadsScreen(
files: List<DownloadedFile>, files: List<DownloadedFile>,
activeDownloads: Map<String, ActiveDownload>,
onDelete: (DownloadedFile) -> Unit, onDelete: (DownloadedFile) -> Unit,
onRefresh: () -> 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( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
@@ -54,6 +54,7 @@ fun YarisMainScreen(client: OkHttpClient) {
val error by dashcamVm.error.collectAsState() val error by dashcamVm.error.collectAsState()
val downloadProgress by dashcamVm.downloadProgress.collectAsState() val downloadProgress by dashcamVm.downloadProgress.collectAsState()
val downloadedFiles by dashcamVm.downloadedFiles.collectAsState() val downloadedFiles by dashcamVm.downloadedFiles.collectAsState()
val activeDownloads by dashcamVm.activeDownloads.collectAsState()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
var selectedTab by remember { mutableIntStateOf(0) } var selectedTab by remember { mutableIntStateOf(0) }
@@ -104,6 +105,7 @@ fun YarisMainScreen(client: OkHttpClient) {
onDelete = { dashcamVm.deleteVideo(it) }, onDelete = { dashcamVm.deleteVideo(it) },
) )
1 -> DownloadsScreen( 1 -> DownloadsScreen(
activeDownloads = activeDownloads,
files = downloadedFiles, files = downloadedFiles,
onDelete = { dashcamVm.deleteDownloaded(it) }, onDelete = { dashcamVm.deleteDownloaded(it) },
onRefresh = { dashcamVm.scanDownloads() }, onRefresh = { dashcamVm.scanDownloads() },
+19 -12
View File
@@ -88,16 +88,12 @@ radio. This has two implications:
`/boot/firmware/config.txt` to free the PL011 UART for the SIM868 GPS module (see `/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 Phase 1 bring-up notes). **This completely disables the onboard Bluetooth
controller.** To use Bluetooth for mediacenter: controller.** To use Bluetooth for mediacenter:
- **Option A:** Use a USB Bluetooth dongle instead (preserves GPS UART routing, - ~~**Option A:** Use a USB Bluetooth dongle~~ — **CONFIRMED: owner has a USB
cleaner separation, likely better range/audio quality with BT 5.0). Bluetooth dongle on hand.** This preserves GPS UART routing, gives cleaner
- **Option B:** Use the mini-UART (`/dev/ttyS0`) for the SIM868 at a lower, separation (no WiFi/BT combo-chip contention), and likely provides better
core-frequency-dependent baud rate, re-enable the onboard Bluetooth for the range/audio quality.
PL011 UART. Riskier — mini-UART is less stable and baud rate depends on the - ~~**Option B:** Use the mini-UART~~ — ruled out. No need to touch the
core clock, which can drift. already-working GPS UART config.
**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.
--- ---
@@ -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) ## Open Questions (to resolve during M1)
| # | Question | Impact | | # | 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) | | 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 | | 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 | | 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 | | 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 | | 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) | | 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 | | 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?) | | 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?) |