docs: FEAT-05 DTC sync shipped — multi-device selector, portal DTC page, Pi snapshot upload

This commit is contained in:
Mathieu Moran
2026-08-12 02:20:48 -04:00
parent ed2b3aacfc
commit bc29a01337
7 changed files with 617 additions and 59 deletions
+2 -2
View File
@@ -92,8 +92,8 @@ dependencies {
// implementation(libs.room.ktx)
// ksp(libs.room.compiler)
// Background work — will be used in 28.3
// implementation(libs.workmanager)
// Background work
implementation(libs.workmanager)
// Images
implementation(libs.coil.compose)
+5
View File
@@ -46,6 +46,11 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
android:exported="false" />
</application>
</manifest>
@@ -1,11 +1,12 @@
package com.yarisobd.app
import android.app.Application
import android.content.Context
import android.os.Environment
import android.webkit.MimeTypeMap
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import androidx.work.OneTimeWorkRequestBuilder
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
@@ -16,14 +17,10 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okio.buffer
import okio.sink
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import kotlinx.serialization.json.Json
import java.io.File
@@ -109,57 +106,35 @@ class DashcamViewModel(application: Application) : AndroidViewModel(application)
}
fun downloadVideo(video: VideoFile) {
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) {
viewModelScope.launch {
_downloadEvents.emit("✓ Already downloaded: $filename")
}
return
}
val request = OneTimeWorkRequestBuilder<DownloadWorker>()
.setInputData(
workDataOf(
DownloadWorker.KEY_PATH to video.path,
DownloadWorker.KEY_URL to url,
DownloadWorker.KEY_FILENAME to filename,
DownloadWorker.KEY_FILE_SIZE to video.size,
)
)
.addTag("download_${video.path}")
.build()
WorkManager.getInstance(getApplication()).enqueue(request)
viewModelScope.launch {
val app = getApplication<YarisApp>()
val dir = app.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: return@launch
val localFile = File(dir, video.path.substringAfterLast("/"))
// Skip if already downloaded and size matches
if (localFile.exists() && localFile.length() == video.size) {
_downloadEvents.emit("✓ Already downloaded: ${localFile.name}")
return@launch
}
try {
val result = withContext(Dispatchers.IO) {
val request = Request.Builder()
.url("$baseUrl/api/local-video/file?path=${video.path}")
.get()
.build()
val response = okHttp.newCall(request).execute()
if (!response.isSuccessful) {
return@withContext "Download failed: HTTP ${response.code}"
}
val body = response.body ?: return@withContext "Download failed: Empty body"
val total = body.contentLength()
localFile.parentFile?.mkdirs()
var downloaded = 0L
body.source().use { source ->
localFile.sink().buffer().use { sink ->
val buffer = okio.Buffer()
while (true) {
val read = source.read(buffer, 8192)
if (read == -1L) break
sink.write(buffer, read)
downloaded += read
val progress = DownloadProgress(video.path, downloaded, total)
_downloadProgress.value = _downloadProgress.value + (video.path to progress)
}
}
}
_downloadProgress.value = _downloadProgress.value - video.path
"✓ Downloaded: ${localFile.name} (${downloaded} bytes)"
}
_downloadEvents.emit(result)
scanDownloads()
} catch (e: Exception) {
_downloadProgress.value = _downloadProgress.value - video.path
_downloadEvents.emit("✗ Download failed: ${e.message}")
}
_downloadEvents.emit("⏳ Downloading: $filename (continues in background)")
}
}
@@ -0,0 +1,188 @@
package com.yarisobd.app
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Environment
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okio.buffer
import okio.sink
import java.io.File
class DownloadWorker(
context: Context,
params: WorkerParameters,
) : CoroutineWorker(context, params) {
private val okHttp = OkHttpClient.Builder().build()
private val notificationManager = NotificationManagerCompat.from(context)
companion object {
const val KEY_PATH = "path"
const val KEY_URL = "url"
const val KEY_FILENAME = "filename"
const val KEY_FILE_SIZE = "file_size"
const val CHANNEL_ID = "yaris_downloads"
const val NOTIFICATION_ID_BASE = 2000
fun createChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"Downloads",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Dashcam video downloads"
}
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.createNotificationChannel(channel)
}
}
}
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
val path = inputData.getString(KEY_PATH) ?: return@withContext Result.failure()
val url = inputData.getString(KEY_URL) ?: return@withContext Result.failure()
val filename = inputData.getString(KEY_FILENAME) ?: path.substringAfterLast("/")
val totalSize = inputData.getLong(KEY_FILE_SIZE, 0)
val dir = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES),
"yaris-obd"
)
dir.mkdirs()
val localFile = File(dir, filename)
// Skip if already downloaded
if (localFile.exists() && localFile.length() == totalSize && totalSize > 0) {
return@withContext Result.success(
workDataOf("filename" to filename, "bytes" to totalSize)
)
}
setForeground(createForegroundInfo(filename))
try {
val request = Request.Builder().url(url).get().build()
val response = okHttp.newCall(request).execute()
if (!response.isSuccessful) {
return@withContext Result.failure(
workDataOf("error" to "HTTP ${response.code}")
)
}
val body = response.body ?: return@withContext Result.failure(
workDataOf("error" to "Empty body")
)
val total = body.contentLength()
var downloaded = 0L
body.source().use { source ->
localFile.sink().buffer().use { sink ->
val buffer = okio.Buffer()
while (true) {
val read = source.read(buffer, 8192)
if (read == -1L) break
sink.write(buffer, read)
downloaded += read
// Update notification progress
if (total > 0) {
val pct = ((downloaded * 100) / total).toInt()
setForeground(createForegroundInfo(filename, pct, downloaded, total))
}
}
}
}
// Final notification
showCompleteNotification(filename)
return@withContext Result.success(
workDataOf("filename" to filename, "bytes" to downloaded)
)
} catch (e: Exception) {
return@withContext Result.failure(
workDataOf("error" to (e.message ?: "Unknown error"))
)
}
}
private fun createForegroundInfo(
filename: String,
progress: Int = 0,
downloaded: Long = 0,
total: Long = 0,
): ForegroundInfo {
val intent = Intent(applicationContext, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
}
val pendingIntent = PendingIntent.getActivity(
applicationContext, 0, intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val builder = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setContentTitle("Downloading $filename")
.setSmallIcon(android.R.drawable.stat_sys_download)
.setOngoing(true)
.setContentIntent(pendingIntent)
if (total > 0) {
builder.setContentText("$progress% — ${downloaded / 1_000_000} / ${total / 1_000_000} MB")
builder.setProgress(100, progress, false)
} else {
builder.setContentText("Downloading...")
builder.setProgress(0, 0, true)
}
return ForegroundInfo(
NOTIFICATION_ID_BASE + id.hashCode(),
builder.build(),
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
)
}
private fun showCompleteNotification(filename: String) {
val intent = Intent(applicationContext, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
}
val pendingIntent = PendingIntent.getActivity(
applicationContext, 0, intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
val builder = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setContentTitle("Download complete")
.setContentText(filename)
.setSmallIcon(android.R.drawable.stat_sys_download_done)
.setAutoCancel(true)
.setContentIntent(pendingIntent)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(applicationContext, Manifest.permission.POST_NOTIFICATIONS)
== PackageManager.PERMISSION_GRANTED
) {
notificationManager.notify(NOTIFICATION_ID_BASE + filename.hashCode(), builder.build())
}
} else {
notificationManager.notify(NOTIFICATION_ID_BASE + filename.hashCode(), builder.build())
}
}
}
@@ -7,6 +7,7 @@ class YarisApp : Application() {
override fun onCreate() {
super.onCreate()
instance = this
DownloadWorker.createChannel(this)
}
companion object {