feat: Phase 28.2 — dashcam video browser + WiFi download
- DashcamScreen: LazyColumn of imported Pi videos with thumbnails (Coil), file size, date, upload status, converting progress - DashcamViewModel: Retrofit-backed video list + download to phone storage + delete local copy - PiApi extended: listVideos, getVideoFile (streaming), getThumbnail, deleteVideo - DTOs: VideoListResponse, VideoFile, ConvertProgress - Bottom nav: Dashcam tab + Test tab - Verified: video list loads from real Pi over yaris 911 hotspot
This commit is contained in:
@@ -95,9 +95,9 @@ dependencies {
|
||||
// Background work — will be used in 28.3
|
||||
// implementation(libs.workmanager)
|
||||
|
||||
// Images — will be used in 28.2
|
||||
// implementation(libs.coil.compose)
|
||||
// implementation(libs.coil.okhttp)
|
||||
// Images
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.okhttp)
|
||||
|
||||
// Maps — will be used in 28.4
|
||||
// implementation(libs.maplibre)
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
package com.yarisobd.app
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
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.CircularProgressIndicator
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.request.ImageRequest
|
||||
import coil3.request.crossfade
|
||||
import com.yarisobd.app.data.remote.dto.VideoFile
|
||||
|
||||
@Composable
|
||||
fun DashcamScreen(
|
||||
videos: List<VideoFile>,
|
||||
totalSize: Long,
|
||||
retentionDays: Int,
|
||||
isLoading: Boolean,
|
||||
error: String?,
|
||||
onRefresh: () -> Unit,
|
||||
onDownload: (VideoFile) -> Unit,
|
||||
onDelete: (VideoFile) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Header
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
)
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = "Dashcam Videos",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "${videos.size} files",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = formatBytes(totalSize),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
if (retentionDays > 0) {
|
||||
Text(
|
||||
text = "Auto-delete after ${retentionDays}d (if uploaded)",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Button(
|
||||
onClick = onRefresh,
|
||||
enabled = !isLoading,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(if (isLoading) "Loading..." else "Refresh")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Content
|
||||
when {
|
||||
isLoading && videos.isEmpty() -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
error != null -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = error,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Button(onClick = onRefresh) { Text("Retry") }
|
||||
}
|
||||
}
|
||||
}
|
||||
videos.isEmpty() -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "No imported videos on the Pi.\nImport some from the dashcam first.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(
|
||||
start = 16.dp, end = 16.dp, bottom = 16.dp
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(videos, key = { it.path }) { video ->
|
||||
VideoRow(
|
||||
video = video,
|
||||
onDownload = { onDownload(video) },
|
||||
onDelete = { onDelete(video) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VideoRow(
|
||||
video: VideoFile,
|
||||
onDownload: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// Thumbnail
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(androidx.compose.ui.platform.LocalContext.current)
|
||||
.data("http://10.42.0.1/api/local-video/thumbnail?path=${video.path}")
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = "Thumbnail",
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
// Info
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = video.path.substringAfterLast("/"),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = video.formattedSize(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = video.formattedDate(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
if (video.converting) {
|
||||
Text(
|
||||
text = "Converting ${video.convert_progress?.pct ?: 0}%",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = if (video.uploaded) "Uploaded" else "Pending",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (video.uploaded) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Actions
|
||||
Row {
|
||||
Button(
|
||||
onClick = onDownload,
|
||||
enabled = !video.converting,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
),
|
||||
modifier = Modifier.height(36.dp)
|
||||
) {
|
||||
Text("↓", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Button(
|
||||
onClick = onDelete,
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.yarisobd.app
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.yarisobd.app.data.remote.PiApi
|
||||
import com.yarisobd.app.data.remote.dto.VideoFile
|
||||
import com.yarisobd.app.data.remote.dto.VideoListResponse
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
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 kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
|
||||
data class DownloadProgress(
|
||||
val path: String,
|
||||
val bytesDownloaded: Long,
|
||||
val totalBytes: Long,
|
||||
)
|
||||
|
||||
class DashcamViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private val baseUrl = "http://10.42.0.1/"
|
||||
private val okHttp = OkHttpClient.Builder().build()
|
||||
private val json = Json { ignoreUnknownKeys = true; coerceInputValues = true }
|
||||
|
||||
private val api: PiApi = Retrofit.Builder()
|
||||
.baseUrl(baseUrl)
|
||||
.client(okHttp)
|
||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||
.build()
|
||||
.create(PiApi::class.java)
|
||||
|
||||
private val _videos = MutableStateFlow<List<VideoFile>>(emptyList())
|
||||
val videos: StateFlow<List<VideoFile>> = _videos.asStateFlow()
|
||||
|
||||
private val _totalSize = MutableStateFlow(0L)
|
||||
val totalSize: StateFlow<Long> = _totalSize.asStateFlow()
|
||||
|
||||
private val _retentionDays = MutableStateFlow(0)
|
||||
val retentionDays: StateFlow<Int> = _retentionDays.asStateFlow()
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||
|
||||
private val _error = MutableStateFlow<String?>(null)
|
||||
val error: StateFlow<String?> = _error.asStateFlow()
|
||||
|
||||
private val _downloadProgress = MutableStateFlow<Map<String, DownloadProgress>>(emptyMap())
|
||||
val downloadProgress: StateFlow<Map<String, DownloadProgress>> = _downloadProgress.asStateFlow()
|
||||
|
||||
private val _downloadEvents = MutableSharedFlow<String>()
|
||||
val downloadEvents = _downloadEvents.asSharedFlow()
|
||||
|
||||
init {
|
||||
loadVideos()
|
||||
}
|
||||
|
||||
fun loadVideos() {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
_error.value = null
|
||||
try {
|
||||
val response: VideoListResponse = api.listVideos()
|
||||
_videos.value = response.files
|
||||
_totalSize.value = response.total_size
|
||||
_retentionDays.value = response.retention_days
|
||||
} catch (e: Exception) {
|
||||
_error.value = "Failed to load: ${e.message}"
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadVideo(video: VideoFile) {
|
||||
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
|
||||
if (localFile.exists() && localFile.length() == video.size) {
|
||||
_downloadEvents.emit("Already downloaded: ${localFile.name}")
|
||||
return@launch
|
||||
}
|
||||
|
||||
try {
|
||||
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) {
|
||||
throw Exception("HTTP ${response.code}")
|
||||
}
|
||||
|
||||
val body = response.body ?: throw Exception("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
|
||||
_downloadProgress.value = _downloadProgress.value + (
|
||||
video.path to DownloadProgress(video.path, downloaded, total)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_downloadProgress.value = _downloadProgress.value - video.path
|
||||
_downloadEvents.emit("Downloaded: ${localFile.name}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_downloadProgress.value = _downloadProgress.value - video.path
|
||||
_downloadEvents.emit("Download failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteVideo(video: VideoFile) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
api.deleteVideo(mapOf("path" to video.path))
|
||||
loadVideos()
|
||||
} catch (e: Exception) {
|
||||
_error.value = "Delete failed: ${e.message}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,18 +4,29 @@ import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.yarisobd.app.ui.theme.YarisOBDTheme
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
// Single shared client — the OS routes correctly:
|
||||
// 10.42.0.1 → WiFi (Pi hotspot subnet)
|
||||
// yaris.ceaf.dev → cellular (WiFi has no internet, OS falls back)
|
||||
private val client = OkHttpClient()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -24,16 +35,58 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
setContent {
|
||||
YarisOBDTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
DualNetworkTestScreen(
|
||||
YarisMainScreen(client = client)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun YarisMainScreen(client: OkHttpClient) {
|
||||
val dashcamVm: DashcamViewModel = viewModel()
|
||||
val videos by dashcamVm.videos.collectAsState()
|
||||
val totalSize by dashcamVm.totalSize.collectAsState()
|
||||
val retentionDays by dashcamVm.retentionDays.collectAsState()
|
||||
val isLoading by dashcamVm.isLoading.collectAsState()
|
||||
val error by dashcamVm.error.collectAsState()
|
||||
|
||||
var selectedTab by remember { mutableIntStateOf(0) }
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
NavigationBarItem(
|
||||
selected = selectedTab == 0,
|
||||
onClick = { selectedTab = 0 },
|
||||
icon = { Text("📹") },
|
||||
label = { Text("Dashcam") }
|
||||
)
|
||||
NavigationBarItem(
|
||||
selected = selectedTab == 1,
|
||||
onClick = { selectedTab = 1 },
|
||||
icon = { Text("🔌") },
|
||||
label = { Text("Test") }
|
||||
)
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
Column(modifier = Modifier.padding(padding)) {
|
||||
when (selectedTab) {
|
||||
0 -> DashcamScreen(
|
||||
videos = videos,
|
||||
totalSize = totalSize,
|
||||
retentionDays = retentionDays,
|
||||
isLoading = isLoading,
|
||||
error = error,
|
||||
onRefresh = { dashcamVm.loadVideos() },
|
||||
onDownload = { dashcamVm.downloadVideo(it) },
|
||||
onDelete = { dashcamVm.deleteVideo(it) },
|
||||
)
|
||||
1 -> DualNetworkTestScreen(
|
||||
piClient = client,
|
||||
serverClient = client,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.yarisobd.app
|
||||
|
||||
import com.yarisobd.app.data.remote.dto.VideoFile
|
||||
import java.text.DecimalFormat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
private val sizeFormat = DecimalFormat("#,##0.#")
|
||||
private val dateFormat = SimpleDateFormat("dd MMM yyyy HH:mm", Locale.getDefault())
|
||||
|
||||
fun VideoFile.formattedSize(): String {
|
||||
val bytes = size.toDouble()
|
||||
return when {
|
||||
bytes >= 1_000_000_000 -> "${sizeFormat.format(bytes / 1_000_000_000)} GB"
|
||||
bytes >= 1_000_000 -> "${sizeFormat.format(bytes / 1_000_000)} MB"
|
||||
bytes >= 1_000 -> "${sizeFormat.format(bytes / 1_000)} KB"
|
||||
else -> "${size.toLong()} B"
|
||||
}
|
||||
}
|
||||
|
||||
fun VideoFile.formattedDate(): String {
|
||||
return try {
|
||||
dateFormat.format(Date((modified_at * 1000).toLong()))
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,37 @@
|
||||
package com.yarisobd.app.data.remote
|
||||
|
||||
import com.yarisobd.app.data.remote.dto.GpsStatus
|
||||
import com.yarisobd.app.data.remote.dto.VideoListResponse
|
||||
import okhttp3.ResponseBody
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Query
|
||||
import retrofit2.http.Streaming
|
||||
|
||||
/**
|
||||
* REST API for the Pi's local web server (10.42.0.1 over WiFi hotspot).
|
||||
* Only the endpoints the Android app needs are declared here.
|
||||
*/
|
||||
interface PiApi {
|
||||
|
||||
/** GPS fix status — Phase 25.5d */
|
||||
/** GPS fix status */
|
||||
@GET("/api/gps/status")
|
||||
suspend fun getGpsStatus(): GpsStatus
|
||||
|
||||
// Future: add endpoints from android/plan.md as each sub-phase needs them:
|
||||
// @GET("/api/obd/status") — BUG-12 fix
|
||||
// @GET("/api/stats") — Phase 23
|
||||
// @GET("/api/trips") — Phase 18
|
||||
// @GET("/api/trip/{id}") — Phase 18
|
||||
// @GET("/api/dtc") — BUG-03 fix
|
||||
// @GET("/api/local-video/list") — Phase 17
|
||||
// @GET("/api/local-video/file") — Phase 17 (streaming, use @Streaming)
|
||||
// @GET("/api/local-video/thumbnail") — Phase 17
|
||||
// @DELETE("/api/local-video/file") — Phase 17
|
||||
// @GET("/api/remote-config") — Phase 25.5c
|
||||
/** List imported dashcam videos (Phase 17) */
|
||||
@GET("/api/local-videos")
|
||||
suspend fun listVideos(): VideoListResponse
|
||||
|
||||
/** Stream/download a video file with Range support */
|
||||
@Streaming
|
||||
@GET("/api/local-video/file")
|
||||
suspend fun getVideoFile(@Query("path") path: String): ResponseBody
|
||||
|
||||
/** Get a cached thumbnail JPEG */
|
||||
@GET("/api/local-video/thumbnail")
|
||||
suspend fun getThumbnail(@Query("path") path: String): ResponseBody
|
||||
|
||||
/** Delete local copy only */
|
||||
@DELETE("/api/local-video")
|
||||
suspend fun deleteVideo(@Body body: Map<String, String>): Map<String, Any?>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.yarisobd.app.data.remote.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class VideoListResponse(
|
||||
val status: String = "",
|
||||
val files: List<VideoFile> = emptyList(),
|
||||
val count: Int = 0,
|
||||
val total_size: Long = 0,
|
||||
val errors: List<String> = emptyList(),
|
||||
val retention_days: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VideoFile(
|
||||
val path: String = "",
|
||||
val size: Long = 0,
|
||||
val modified_at: Double = 0.0,
|
||||
val uploaded: Boolean = false,
|
||||
val converting: Boolean = false,
|
||||
val convert_progress: ConvertProgress? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ConvertProgress(
|
||||
val elapsed_s: Int = 0,
|
||||
val pct: Int = 0,
|
||||
)
|
||||
+10
-16
@@ -69,35 +69,29 @@ android/
|
||||
|
||||
## Sub-phases
|
||||
|
||||
### 28.1 — Scaffold + networking proof-of-concept 🚧 IN PROGRESS
|
||||
**Gate: dual-network binding must work on the real Pixel before anything else is built.**
|
||||
### 28.1 — Scaffold + networking proof-of-concept ✅ DONE (gate passed 2026-08-12)
|
||||
|
||||
#### 28.1.1 — Project creation
|
||||
- [x] Create Android project in `android/` via Android Studio (Empty Activity + Compose)
|
||||
- [x] Kotlin DSL build files with version catalog
|
||||
- [x] `.gitignore` for Android (`*.iml`, `.gradle/`, `build/`, `local.properties`, `.idea/`)
|
||||
- [x] Create Android project in `android/` via Android Studio (Empty Activity + Compose)
|
||||
- [x] Kotlin DSL build files with version catalog
|
||||
- [x] `.gitignore` for Android (`*.iml`, `.gradle/`, `build/`, `local.properties`, `.idea/`)
|
||||
- [ ] Verify project builds and runs on emulator (needs Android Studio)
|
||||
- [x] Commit: `f1c83a2` init: android project scaffold
|
||||
|
||||
#### 28.1.2 — Networking layer
|
||||
- [x] Add dependencies: OkHttp 4.12, Retrofit 2.11, Kotlinx Serialization 1.7.3
|
||||
- [x] Create `NetworkBinder.kt` — discovers WiFi (Pi hotspot, no-internet) + cellular networks
|
||||
via `ConnectivityManager`, creates socket-bound `OkHttpClient` per network
|
||||
- [x] Create `NetworkBinder.kt` — discovers WiFi (Pi hotspot) + cellular networks (kept for future use)
|
||||
- [x] Create Retrofit service interfaces (`PiApi.kt`, `ServerApi.kt`) with placeholder endpoints
|
||||
- [x] Create DTOs: `GpsStatus`, `HealthResponse`
|
||||
- [x] Wire `DualNetworkTestScreen.kt` with real OkHttp calls + simultaneous "Test Both" button
|
||||
- [x] Wire `MainActivity.kt` to discover networks at startup and pass bound clients to screen
|
||||
- [ ] **Real-device gate test:**
|
||||
- Connect phone to Pi hotspot ("yaris 911")
|
||||
- Verify cellular data is still active (browse any website while on hotspot)
|
||||
- Press "Test Pi" → should return GPS status JSON
|
||||
- Press "Test Server" → should return `{"status":"ok"}`
|
||||
- Press "Test Both" → both should succeed simultaneously
|
||||
- If either fails: investigate `bindProcessToNetwork()` behavior on this Pixel/Android version
|
||||
- [ ] Commit: `feat: dual-network proof-of-concept`
|
||||
- [x] Wire `MainActivity.kt` — simplified to single OkHttpClient, OS routes correctly
|
||||
- [x] **Real-device gate test — PASSED 2026-08-12 on Pixel:**
|
||||
- "Test Pi" → `✓ Pi OK (200)` with live GPS fix data
|
||||
- "Test Server" → `✓ Server OK (200) — {"status":"ok"}`
|
||||
- "Test Both Simultaneously" → `✓ BOTH NETWORKS WORK SIMULTANEOUSLY`
|
||||
- Key finding: per-socket `Network.bindSocket()` not needed — Android OS routes
|
||||
`10.42.0.1` through WiFi and `yaris.ceaf.dev` through cellular automatically
|
||||
- [x] Commit: `348bfdc` feat: Phase 28.1 gate passed
|
||||
|
||||
#### 28.1.3 — Credential storage
|
||||
- [ ] Add EncryptedSharedPreferences for server URL, device name, API key
|
||||
|
||||
Reference in New Issue
Block a user