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:
@@ -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() },
|
||||||
|
|||||||
@@ -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?) |
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user