docs: FEAT-05 DTC sync shipped — multi-device selector, portal DTC page, Pi snapshot upload
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user