feat: downloads tab + public Movies/yaris-obd/ folder

- Downloads now go to Movies/yaris-obd/ (visible in file managers)
- New Downloads tab: list downloaded files, tap to play (system player),
  delete button
- FileProvider added for video playback intent
- Downloaded files auto-refresh after each download
This commit is contained in:
Mathieu Moran
2026-08-12 01:56:18 -04:00
parent e30b85d218
commit ed2b3aacfc
5 changed files with 219 additions and 2 deletions
+10
View File
@@ -36,6 +36,16 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application> </application>
</manifest> </manifest>
@@ -3,6 +3,7 @@ package com.yarisobd.app
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import android.os.Environment import android.os.Environment
import android.webkit.MimeTypeMap
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.yarisobd.app.data.remote.PiApi import com.yarisobd.app.data.remote.PiApi
@@ -32,6 +33,12 @@ data class DownloadProgress(
val totalBytes: Long, val totalBytes: Long,
) )
data class DownloadedFile(
val name: String,
val size: Long,
val file: File,
)
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
@@ -66,8 +73,22 @@ class DashcamViewModel(application: Application) : AndroidViewModel(application)
private val _downloadEvents = MutableSharedFlow<String>() private val _downloadEvents = MutableSharedFlow<String>()
val downloadEvents = _downloadEvents.asSharedFlow() val downloadEvents = _downloadEvents.asSharedFlow()
private val _downloadedFiles = MutableStateFlow<List<DownloadedFile>>(emptyList())
val downloadedFiles: StateFlow<List<DownloadedFile>> = _downloadedFiles.asStateFlow()
private fun getDownloadDir(): File {
// Public Movies/yaris-obd/ — visible in file managers
val dir = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES),
"yaris-obd"
)
dir.mkdirs()
return dir
}
init { init {
loadVideos() loadVideos()
scanDownloads()
} }
fun loadVideos() { fun loadVideos() {
@@ -134,6 +155,7 @@ class DashcamViewModel(application: Application) : AndroidViewModel(application)
"✓ Downloaded: ${localFile.name} (${downloaded} bytes)" "✓ Downloaded: ${localFile.name} (${downloaded} bytes)"
} }
_downloadEvents.emit(result) _downloadEvents.emit(result)
scanDownloads()
} catch (e: Exception) { } catch (e: Exception) {
_downloadProgress.value = _downloadProgress.value - video.path _downloadProgress.value = _downloadProgress.value - video.path
_downloadEvents.emit("✗ Download failed: ${e.message}") _downloadEvents.emit("✗ Download failed: ${e.message}")
@@ -141,6 +163,25 @@ class DashcamViewModel(application: Application) : AndroidViewModel(application)
} }
} }
fun scanDownloads() {
val dir = getDownloadDir()
val files = dir.listFiles()?.filter { it.isFile }?.map { f ->
DownloadedFile(name = f.name, size = f.length(), file = f)
}?.sortedByDescending { it.file.lastModified() } ?: emptyList()
_downloadedFiles.value = files
}
fun deleteDownloaded(file: DownloadedFile) {
viewModelScope.launch {
if (file.file.delete()) {
_downloadEvents.emit("✓ Deleted: ${file.name}")
scanDownloads()
} else {
_downloadEvents.emit("✗ Could not delete: ${file.name}")
}
}
}
fun deleteVideo(video: VideoFile) { fun deleteVideo(video: VideoFile) {
viewModelScope.launch { viewModelScope.launch {
try { try {
@@ -0,0 +1,150 @@
package com.yarisobd.app
import android.content.Intent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
@Composable
fun DownloadsScreen(
files: List<DownloadedFile>,
onDelete: (DownloadedFile) -> Unit,
onRefresh: () -> Unit,
) {
val context = LocalContext.current
Column(modifier = Modifier.fillMaxSize()) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Downloads",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "${files.size} files in Movies/yaris-obd/",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (files.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "No downloaded files yet.\nDownload a video from the Dashcam tab.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else {
LazyColumn(
contentPadding = androidx.compose.foundation.layout.PaddingValues(
start = 16.dp, end = 16.dp, bottom = 16.dp
),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(files, key = { it.name }) { file ->
Card(
modifier = Modifier
.fillMaxWidth()
.clickable {
// Open with system video player
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file.file
)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "video/*")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(intent)
},
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = file.name,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = formatBytes(file.size),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Button(
onClick = { onDelete(file) },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
),
modifier = Modifier.height(36.dp)
) {
Text("✕", style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
}
}
}
private fun formatBytes(bytes: Long): String {
return when {
bytes >= 1_000_000_000 -> "%.1f GB".format(bytes / 1_000_000_000.0)
bytes >= 1_000_000 -> "%.1f MB".format(bytes / 1_000_000.0)
bytes >= 1_000 -> "%.1f KB".format(bytes / 1_000.0)
else -> "$bytes B"
}
}
@@ -53,6 +53,7 @@ fun YarisMainScreen(client: OkHttpClient) {
val isLoading by dashcamVm.isLoading.collectAsState() val isLoading by dashcamVm.isLoading.collectAsState()
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 snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
var selectedTab by remember { mutableIntStateOf(0) } var selectedTab by remember { mutableIntStateOf(0) }
@@ -77,7 +78,13 @@ fun YarisMainScreen(client: OkHttpClient) {
NavigationBarItem( NavigationBarItem(
selected = selectedTab == 1, selected = selectedTab == 1,
onClick = { selectedTab = 1 }, onClick = { selectedTab = 1 },
icon = { Text("🔌") }, icon = { Text("�") },
label = { Text("Downloads") }
)
NavigationBarItem(
selected = selectedTab == 2,
onClick = { selectedTab = 2 },
icon = { Text("�🔌") },
label = { Text("Test") } label = { Text("Test") }
) )
} }
@@ -96,7 +103,12 @@ fun YarisMainScreen(client: OkHttpClient) {
onDownload = { dashcamVm.downloadVideo(it) }, onDownload = { dashcamVm.downloadVideo(it) },
onDelete = { dashcamVm.deleteVideo(it) }, onDelete = { dashcamVm.deleteVideo(it) },
) )
1 -> DualNetworkTestScreen( 1 -> DownloadsScreen(
files = downloadedFiles,
onDelete = { dashcamVm.deleteDownloaded(it) },
onRefresh = { dashcamVm.scanDownloads() },
)
2 -> DualNetworkTestScreen(
piClient = client, piClient = client,
serverClient = client, serverClient = client,
) )
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-path name="movies" path="Movies/yaris-obd/" />
</paths>