From 0cb89596b094fa7839bc41f25c1c1aabc04a41ee Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Sat, 15 Aug 2026 20:11:26 -0400 Subject: [PATCH] =?UTF-8?q?feat(android):=20remote=20portal=20access=20?= =?UTF-8?q?=E2=80=94=20login,=20stats,=20trips,=20dashcam=20(Phase=2028.6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PLAN.md | 13 +- .../java/com/yarisobd/app/MainActivity.kt | 31 ++ .../java/com/yarisobd/app/PortalScreen.kt | 251 ++++++++++++++++ .../java/com/yarisobd/app/PortalViewModel.kt | 282 ++++++++++++++++++ .../java/com/yarisobd/app/TripListScreen.kt | 2 +- .../main/java/com/yarisobd/app/TripMapView.kt | 21 +- .../java/com/yarisobd/app/TripReplayScreen.kt | 6 + .../com/yarisobd/app/data/CredentialStore.kt | 15 + .../com/yarisobd/app/data/remote/PortalApi.kt | 38 +++ .../yarisobd/app/data/remote/dto/Portal.kt | 61 ++++ android/plan.md | 37 +-- 11 files changed, 726 insertions(+), 31 deletions(-) create mode 100644 android/app/src/main/java/com/yarisobd/app/PortalScreen.kt create mode 100644 android/app/src/main/java/com/yarisobd/app/PortalViewModel.kt create mode 100644 android/app/src/main/java/com/yarisobd/app/data/remote/PortalApi.kt create mode 100644 android/app/src/main/java/com/yarisobd/app/data/remote/dto/Portal.kt diff --git a/PLAN.md b/PLAN.md index c844332..56332cb 100644 --- a/PLAN.md +++ b/PLAN.md @@ -2021,7 +2021,7 @@ tracked in FEATURES.md. 2. **FEAT-02** — QR / claim-code device pairing flow (medium) 3. **FEAT-04** — Replace SIM868 2G GSM with 4G LTE modem (large) -### Phase 28: Native Android companion app — 🚧 IN PROGRESS (28.1 ✅, 28.2 ✅, 28.3 ✅, 28.4 ✅, 28.5 ✅, 28.6–28.7 pending) +### Phase 28: Native Android companion app — 🚧 IN PROGRESS (28.1 ✅, 28.2 ✅, 28.3 ✅, 28.4 ✅, 28.5 ✅, 28.6 ✅, 28.7 pending) **Renumbered from former Phase 26 on 2026-08-10 to make room for the road-test bug-fix phase. Renumbered to Phase 28 on 2026-08-11 to make room for Phase 27 feature additions. Planning completed 2026-08-11. Detailed implementation plan in [android/plan.md](android/plan.md).** @@ -2212,10 +2212,13 @@ The app reuses the existing Pi REST API surface with no changes: with timestamp + speed, and play/pause animation. - Deferred: Room offline cache, start/end markers, geocode label under the scrubber. -**28.6 — Remote portal direct access (away from car)** -- Talk to `https://yaris.ceaf.dev/api/v1/portal/...` directly -- Browse trip history and dashcam archive from anywhere (not just when connected to Pi) -- Login with portal credentials (same as Phase 24 web portal login) +**28.6 — Remote portal direct access ✅ DONE (2026-08-15)** +- New "Server" tab: CSRF form login to `https://yaris.ceaf.dev` (Flask session cookie via an + in-memory `CookieJar`), auto-login on launch with stored credentials. +- Overview stats card, trip list, and dashcam archive from the portal API; taps open the same + `TripReplayScreen` as the Pi (reuses shared DTOs + `TripCard`). +- Deferred: dashcam video streaming/player, session-cookie persistence across launches (currently + re-logs-in each launch), and a server-side tile source for the replay map when off the hotspot. **28.7 — Polish + distribution** - App icon, splash screen, dark theme polish diff --git a/android/app/src/main/java/com/yarisobd/app/MainActivity.kt b/android/app/src/main/java/com/yarisobd/app/MainActivity.kt index 01b79be..cf92238 100644 --- a/android/app/src/main/java/com/yarisobd/app/MainActivity.kt +++ b/android/app/src/main/java/com/yarisobd/app/MainActivity.kt @@ -17,6 +17,7 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.Videocam import androidx.compose.material.icons.filled.Dashboard import androidx.compose.material.icons.filled.DirectionsCar @@ -70,6 +71,9 @@ fun YarisMainScreen(client: OkHttpClient) { val tripsError by tripVm.error.collectAsState() val route by tripVm.route.collectAsState() val routeLoading by tripVm.routeLoading.collectAsState() + val portalVm: PortalViewModel = viewModel() + val portalRoute by portalVm.route.collectAsState() + val portalRouteLoading by portalVm.routeLoading.collectAsState() val videos by dashcamVm.videos.collectAsState() val totalSize by dashcamVm.totalSize.collectAsState() val retentionDays by dashcamVm.retentionDays.collectAsState() @@ -92,6 +96,7 @@ fun YarisMainScreen(client: OkHttpClient) { val snackbarHostState = remember { SnackbarHostState() } var selectedTab by remember { mutableIntStateOf(0) } var selectedTrip by remember { mutableStateOf(null) } + var selectedPortalTrip by remember { mutableStateOf(null) } // Show download/delete events as snackbars LaunchedEffect(Unit) { @@ -110,6 +115,19 @@ fun YarisMainScreen(client: OkHttpClient) { tripVm.clearRoute() }, ) + } else if (selectedPortalTrip != null) { + TripReplayScreen( + trip = selectedPortalTrip!!, + route = portalRoute, + loading = portalRouteLoading, + onBack = { + selectedPortalTrip = null + portalVm.clearRoute() + }, + mapTileUrl = PORTAL_TILE_URL, + mapMinZoom = 0.0, + mapMaxZoom = 19.0, + ) } else { Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) }, @@ -145,6 +163,12 @@ fun YarisMainScreen(client: OkHttpClient) { icon = { Icon(Icons.Filled.Settings, contentDescription = "Settings") }, label = { Text("Settings") } ) + NavigationBarItem( + selected = selectedTab == 5, + onClick = { selectedTab = 5 }, + icon = { Icon(Icons.Filled.Cloud, contentDescription = "Server") }, + label = { Text("Server") } + ) } } ) { padding -> @@ -205,6 +229,13 @@ fun YarisMainScreen(client: OkHttpClient) { onRefresh = { tripVm.loadTrips() }, ) 4 -> SettingsScreen(client = client) + 5 -> PortalScreen( + viewModel = portalVm, + onTripClick = { t -> + selectedPortalTrip = t + portalVm.loadRoute(t.id) + }, + ) } } } diff --git a/android/app/src/main/java/com/yarisobd/app/PortalScreen.kt b/android/app/src/main/java/com/yarisobd/app/PortalScreen.kt new file mode 100644 index 0000000..2d80ae5 --- /dev/null +++ b/android/app/src/main/java/com/yarisobd/app/PortalScreen.kt @@ -0,0 +1,251 @@ +package com.yarisobd.app + +import androidx.compose.foundation.layout.Arrangement +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.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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 com.yarisobd.app.data.CredentialStore +import com.yarisobd.app.data.remote.dto.PortalDashcamFile +import com.yarisobd.app.data.remote.dto.PortalStats +import com.yarisobd.app.data.remote.dto.TripSummary +import java.util.Locale + +@Composable +fun PortalScreen( + viewModel: PortalViewModel, + onTripClick: (TripSummary) -> Unit, +) { + val loggedIn by viewModel.loggedIn.collectAsState() + val loggingIn by viewModel.loggingIn.collectAsState() + val error by viewModel.error.collectAsState() + val stats by viewModel.stats.collectAsState() + val trips by viewModel.trips.collectAsState() + val tripLabels by viewModel.tripLabels.collectAsState() + val dashcam by viewModel.dashcam.collectAsState() + val context = LocalContext.current + val serverUrl = CredentialStore.getServerUrl(context) + + var username by remember { mutableStateOf(CredentialStore.getPortalUsername(context)) } + var password by remember { mutableStateOf(CredentialStore.getPortalPassword(context)) } + + Column(modifier = Modifier.fillMaxSize()) { + com.yarisobd.app.ui.components.YarisHeader() + + Row( + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + if (loggedIn) "Connected to server" else "Remote server", + style = MaterialTheme.typography.bodyMedium, + color = if (loggedIn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + Text( + serverUrl.ifBlank { "not configured" }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1.5f) + ) + if (loggedIn) { + Button(onClick = { viewModel.logout() }) { Text("Logout") } + } + } + + if (!loggedIn) { + LoginCard( + username = username, + password = password, + loggingIn = loggingIn, + error = error, + onUsernameChange = { username = it }, + onPasswordChange = { password = it }, + onLogin = { viewModel.login(username, password) }, + ) + } else { + Column( + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + StatsCard(stats) + Text( + "Trips (${trips.size})", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary + ) + if (trips.isEmpty()) { + Text( + "No trips on server yet", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + trips.forEach { trip -> + TripCard(trip, tripLabels[trip.id], onClick = { onTripClick(trip) }) + } + Text( + "Dashcam archive (${dashcam.size})", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary + ) + dashcam.take(200).forEach { f -> DashcamRow(f) } + Spacer(Modifier.height(8.dp)) + } + } + } +} + +@Composable +private fun LoginCard( + username: String, + password: String, + loggingIn: Boolean, + error: String?, + onUsernameChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, + onLogin: () -> Unit, +) { + Card( + Modifier.fillMaxWidth().padding(16.dp), + shape = RoundedCornerShape(14.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh) + ) { + Column(Modifier.padding(16.dp)) { + Text("Portal sign in", style = MaterialTheme.typography.titleMedium) + Text( + "View trips and dashcam from anywhere (cellular).", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = username, onValueChange = onUsernameChange, + label = { Text("Username") }, singleLine = true, + modifier = Modifier.fillMaxWidth(), + textStyle = MaterialTheme.typography.bodySmall, + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = password, onValueChange = onPasswordChange, + label = { Text("Password") }, singleLine = true, + modifier = Modifier.fillMaxWidth(), + textStyle = MaterialTheme.typography.bodySmall, + visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(), + ) + if (error != null) { + Spacer(Modifier.height(8.dp)) + Text(error, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + } + Spacer(Modifier.height(12.dp)) + Button(onClick = onLogin, enabled = !loggingIn, modifier = Modifier.fillMaxWidth()) { + if (loggingIn) { + CircularProgressIndicator(Modifier.height(18.dp), strokeWidth = 2.dp) + } else { + Text("Sign in") + } + } + } + } +} + +@Composable +private fun StatsCard(stats: PortalStats?) { + Card( + Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh) + ) { + Column(Modifier.padding(14.dp)) { + Text("Overview", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.primary) + Spacer(Modifier.height(10.dp)) + Row(Modifier.fillMaxWidth()) { + Stat("Trips", "${stats?.trip_count ?: 0}", Modifier.weight(1f)) + Stat("Total km", oneDecimal(stats?.total_distance_km), Modifier.weight(1f)) + Stat("Top speed", "${intText(stats?.top_speed_kmh)} km/h", Modifier.weight(1f)) + } + Spacer(Modifier.height(10.dp)) + Row(Modifier.fillMaxWidth()) { + Stat("Drive time", durationText(stats?.total_drive_time_s ?: 0), Modifier.weight(1f)) + Stat("Videos", "${stats?.video_count ?: 0}", Modifier.weight(1f)) + Stat("Odometer", intText(stats?.odometer_km), Modifier.weight(1f)) + } + } + } +} + +@Composable +private fun Stat(label: String, value: String, modifier: Modifier = Modifier) { + Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { + Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(value, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold) + } +} + +@Composable +private fun DashcamRow(f: PortalDashcamFile) { + Card( + Modifier.fillMaxWidth(), + shape = RoundedCornerShape(10.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh) + ) { + Row(Modifier.fillMaxWidth().padding(10.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text( + f.relative_path.substringAfterLast("/"), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + formatBytes(f.size_bytes), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +private fun intText(v: Double?): String = v?.let { "%.0f".format(Locale.US, it) } ?: "—" +private fun oneDecimal(v: Double?): String = v?.let { "%.1f".format(Locale.US, it) } ?: "—" +private fun durationText(seconds: Int): String { + if (seconds < 60) return "${seconds}s" + val h = seconds / 3600 + val m = (seconds % 3600) / 60 + return if (h > 0) "${h}h ${m}m" else "${m}m" +} + +private fun formatBytes(bytes: Long): String = when { + bytes >= 1_000_000_000 -> "%.1f GB".format(Locale.US, bytes / 1_000_000_000.0) + bytes >= 1_000_000 -> "%.1f MB".format(Locale.US, bytes / 1_000_000.0) + bytes >= 1_000 -> "%.1f KB".format(Locale.US, bytes / 1_000.0) + else -> "$bytes B" +} diff --git a/android/app/src/main/java/com/yarisobd/app/PortalViewModel.kt b/android/app/src/main/java/com/yarisobd/app/PortalViewModel.kt new file mode 100644 index 0000000..b70a291 --- /dev/null +++ b/android/app/src/main/java/com/yarisobd/app/PortalViewModel.kt @@ -0,0 +1,282 @@ +package com.yarisobd.app + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.yarisobd.app.data.CredentialStore +import com.yarisobd.app.data.remote.PortalApi +import com.yarisobd.app.data.remote.dto.GeocodeBatchRequest +import com.yarisobd.app.data.remote.dto.GeocodeCoord +import com.yarisobd.app.data.remote.dto.LatLon +import com.yarisobd.app.data.remote.dto.PortalDashcamFile +import com.yarisobd.app.data.remote.dto.PortalStats +import com.yarisobd.app.data.remote.dto.TripRoute +import com.yarisobd.app.data.remote.dto.TripSummary +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.FormBody +import okhttp3.HttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import retrofit2.HttpException +import retrofit2.Retrofit +import retrofit2.converter.kotlinx.serialization.asConverterFactory +import java.util.Locale +import java.util.concurrent.TimeUnit + +/** In-memory cookie jar — keeps the Flask session cookie for the app session. */ +class SimpleCookieJar : CookieJar { + private val store = mutableMapOf>() + + override fun saveFromResponse(url: HttpUrl, cookies: List) { + val list = store.getOrPut(url.host) { mutableListOf() } + for (c in cookies) { + list.removeAll { it.name == c.name } + list.add(c) + } + } + + override fun loadForRequest(url: HttpUrl): List = + store[url.host] ?: emptyList() + + fun clear() = store.clear() +} + +class PortalViewModel(application: Application) : AndroidViewModel(application) { + + private val json = Json { ignoreUnknownKeys = true; coerceInputValues = true } + private val cookieJar = SimpleCookieJar() + private val okHttp = OkHttpClient.Builder() + .cookieJar(cookieJar) + .followRedirects(false) + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(20, TimeUnit.SECONDS) + .build() + + private var baseUrl: String = "" + private var api: PortalApi? = null + + private val _loggedIn = MutableStateFlow(false) + val loggedIn: StateFlow = _loggedIn.asStateFlow() + + private val _loggingIn = MutableStateFlow(false) + val loggingIn: StateFlow = _loggingIn.asStateFlow() + + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + + private val _stats = MutableStateFlow(null) + val stats: StateFlow = _stats.asStateFlow() + + private val _trips = MutableStateFlow>(emptyList()) + val trips: StateFlow> = _trips.asStateFlow() + + private val _tripLabels = MutableStateFlow>(emptyMap()) + val tripLabels: StateFlow> = _tripLabels.asStateFlow() + + private val _route = MutableStateFlow(null) + val route: StateFlow = _route.asStateFlow() + + private val _routeLoading = MutableStateFlow(false) + val routeLoading: StateFlow = _routeLoading.asStateFlow() + + private val _dashcam = MutableStateFlow>(emptyList()) + val dashcam: StateFlow> = _dashcam.asStateFlow() + + init { + val ctx = getApplication().applicationContext + val u = CredentialStore.getPortalUsername(ctx) + val p = CredentialStore.getPortalPassword(ctx) + if (u.isNotBlank() && p.isNotBlank()) { + login(u, p) + } + } + + fun login(username: String, password: String) { + viewModelScope.launch { + _loggingIn.value = true + _error.value = null + try { + val ok = doLogin(username, password) + if (ok) { + CredentialStore.savePortalCredentials(getApplication(), username, password) + _loggedIn.value = true + loadAll() + } else { + _error.value = "Login failed — check username/password" + } + } catch (e: Exception) { + _error.value = "Login failed: ${e.message}" + } finally { + _loggingIn.value = false + } + } + } + + fun logout() { + viewModelScope.launch(Dispatchers.IO) { + runCatching { + val url = baseUrl.ifBlank { CredentialStore.getServerUrl(getApplication()).trimEnd('/') } + okHttp.newCall(Request.Builder().url("$url/logout").get().build()).execute().close() + } + } + cookieJar.clear() + _loggedIn.value = false + _stats.value = null + _trips.value = emptyList() + _tripLabels.value = emptyMap() + _route.value = null + _dashcam.value = emptyList() + } + + fun loadStats() { + viewModelScope.launch { + val a = ensureApi() ?: return@launch + try { + _stats.value = a.stats() + } catch (e: Exception) { + handleError(e) + } + } + } + + fun loadTrips() { + viewModelScope.launch { + val a = ensureApi() ?: return@launch + try { + val mapped = a.trips().map { it.toTripSummary() } + _trips.value = mapped + geocode(mapped, a) + } catch (e: Exception) { + handleError(e) + } + } + } + + fun loadDashcam() { + viewModelScope.launch { + val a = ensureApi() ?: return@launch + try { + _dashcam.value = a.dashcam() + } catch (e: Exception) { + handleError(e) + } + } + } + + fun loadRoute(tripId: Int) { + viewModelScope.launch { + val a = ensureApi() ?: return@launch + _routeLoading.value = true + _error.value = null + try { + _route.value = a.tripRoute(tripId) + } catch (e: Exception) { + handleError(e) + } finally { + _routeLoading.value = false + } + } + } + + fun clearRoute() { + _route.value = null + } + + private suspend fun loadAll() { + loadStats() + loadTrips() + loadDashcam() + } + + /** GET /login for the CSRF token, then POST /login with credentials. */ + private suspend fun doLogin(username: String, password: String): Boolean = + withContext(Dispatchers.IO) { + val url = CredentialStore.getServerUrl(getApplication()).trimEnd('/') + if (url.isBlank()) return@withContext false + baseUrl = url + + val getResp = okHttp.newCall(Request.Builder().url("$url/login").get().build()).execute() + getResp.use { resp -> + if (resp.code == 302) return@withContext true // already logged in + val html = resp.body?.string() ?: return@withContext false + val token = Regex("name=\"csrf_token\" value=\"([^\"]+)\"") + .find(html)?.groupValues?.get(1) ?: return@withContext false + + val form = FormBody.Builder() + .add("csrf_token", token) + .add("username", username) + .add("password", password) + .build() + val postResp = okHttp.newCall(Request.Builder().url("$url/login").post(form).build()).execute() + postResp.use { pr -> + val loc = pr.header("Location") ?: "" + pr.code == 302 && !loc.contains("/login") + } + } + } + + /** Best-effort geocode of trip start/end via the server's batch endpoint. */ + private suspend fun geocode(trips: List, a: PortalApi) { + val coords = mutableListOf() + for (t in trips) { + t.start?.takeIf { it.latitude != null && it.longitude != null }?.let { + coords += GeocodeCoord(it.latitude!!, it.longitude!!) + } + t.end?.takeIf { it.latitude != null && it.longitude != null }?.let { + coords += GeocodeCoord(it.latitude!!, it.longitude!!) + } + } + if (coords.isEmpty()) return + try { + val result = a.geocodeBatch(GeocodeBatchRequest(coords)) + val labels = mutableMapOf() + for (t in trips) { + labels[t.id] = TripLabels(labelFor(result, t.start), labelFor(result, t.end)) + } + _tripLabels.value = labels + } catch (_: Exception) { + // Geocoding is best-effort. + } + } + + private fun labelFor(result: Map, ll: LatLon?): String { + val lat = ll?.latitude ?: return "—" + val lon = ll?.longitude ?: return "—" + val key = String.format(Locale.US, "%.3f,%.3f", lat, lon) + return result[key] ?: String.format(Locale.US, "%.4f, %.4f", lat, lon) + } + + private fun handleError(e: Exception) { + if (e is HttpException && e.code() == 401) { + _loggedIn.value = false + _error.value = "Session expired — please log in again" + } else { + _error.value = e.message + } + } + + private fun ensureApi(): PortalApi? { + val url = CredentialStore.getServerUrl(getApplication()).trimEnd('/') + if (url.isBlank()) return null + val existing = api + if (existing != null && baseUrl == url) return existing + baseUrl = url + val built = Retrofit.Builder() + .baseUrl("$url/") + .client(okHttp) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + .create(PortalApi::class.java) + api = built + return built + } +} diff --git a/android/app/src/main/java/com/yarisobd/app/TripListScreen.kt b/android/app/src/main/java/com/yarisobd/app/TripListScreen.kt index 6c75165..dc58bc9 100644 --- a/android/app/src/main/java/com/yarisobd/app/TripListScreen.kt +++ b/android/app/src/main/java/com/yarisobd/app/TripListScreen.kt @@ -105,7 +105,7 @@ fun TripListScreen( } @Composable -private fun TripCard(trip: TripSummary, label: TripLabels?, onClick: () -> Unit) { +fun TripCard(trip: TripSummary, label: TripLabels?, onClick: () -> Unit) { Card( modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), shape = RoundedCornerShape(14.dp), diff --git a/android/app/src/main/java/com/yarisobd/app/TripMapView.kt b/android/app/src/main/java/com/yarisobd/app/TripMapView.kt index cda1478..160d206 100644 --- a/android/app/src/main/java/com/yarisobd/app/TripMapView.kt +++ b/android/app/src/main/java/com/yarisobd/app/TripMapView.kt @@ -31,7 +31,9 @@ import org.maplibre.android.style.sources.RasterSource import org.maplibre.android.style.sources.TileSet /** Pi serves offline MapTiler raster tiles (zoom 8–15) at this path. */ -private const val TILE_URL = "http://10.42.0.1/static/vendor/tiles/{z}/{x}/{y}.png" +internal const val PI_TILE_URL = "http://10.42.0.1/static/vendor/tiles/{z}/{x}/{y}.png" +/** Online raster basemap for server trips (works away from the Pi hotspot). */ +internal const val PORTAL_TILE_URL = "https://a.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png" @Composable fun TripMapView( @@ -40,6 +42,9 @@ fun TripMapView( maxIndex: Int, modifier: Modifier = Modifier, onMaxClick: (() -> Unit)? = null, + tileUrl: String = PI_TILE_URL, + minZoom: Double = 8.0, + maxZoom: Double = 15.0, ) { val context = LocalContext.current val mapView = remember { MapView(context) } @@ -61,9 +66,9 @@ fun TripMapView( LaunchedEffect(Unit) { mapView.getMapAsync { map -> mapRef.value = map - map.setMinZoomPreference(8.0) - map.setMaxZoomPreference(15.0) - map.setStyle(buildTripStyle()) { _ -> + map.setMinZoomPreference(minZoom) + map.setMaxZoomPreference(maxZoom) + map.setStyle(buildTripStyle(tileUrl, minZoom, maxZoom)) { _ -> styleReady.value = true } // Tap the amber max-speed marker to jump the timeline to it. @@ -103,10 +108,10 @@ fun TripMapView( AndroidView(factory = { mapView }, modifier = modifier) } -private fun buildTripStyle(): Style.Builder { - val tileSet = TileSet("2.1.0", TILE_URL) - tileSet.minZoom = 8f - tileSet.maxZoom = 15f +private fun buildTripStyle(tileUrl: String, minZoom: Double, maxZoom: Double): Style.Builder { + val tileSet = TileSet("2.1.0", tileUrl) + tileSet.minZoom = minZoom.toFloat() + tileSet.maxZoom = maxZoom.toFloat() tileSet.attribution = "MapTiler" val raster = RasterSource("tiles", tileSet, 256) diff --git a/android/app/src/main/java/com/yarisobd/app/TripReplayScreen.kt b/android/app/src/main/java/com/yarisobd/app/TripReplayScreen.kt index 6bf55a2..e2b5044 100644 --- a/android/app/src/main/java/com/yarisobd/app/TripReplayScreen.kt +++ b/android/app/src/main/java/com/yarisobd/app/TripReplayScreen.kt @@ -61,6 +61,9 @@ fun TripReplayScreen( route: TripRoute?, loading: Boolean, onBack: () -> Unit, + mapTileUrl: String = PI_TILE_URL, + mapMinZoom: Double = 8.0, + mapMaxZoom: Double = 15.0, ) { val points = route?.points ?: emptyList() val maxIndex = remember(points) { @@ -132,6 +135,9 @@ fun TripReplayScreen( playing = false } }, + tileUrl = mapTileUrl, + minZoom = mapMinZoom, + maxZoom = mapMaxZoom, ) } } diff --git a/android/app/src/main/java/com/yarisobd/app/data/CredentialStore.kt b/android/app/src/main/java/com/yarisobd/app/data/CredentialStore.kt index dd25af5..2612fd2 100644 --- a/android/app/src/main/java/com/yarisobd/app/data/CredentialStore.kt +++ b/android/app/src/main/java/com/yarisobd/app/data/CredentialStore.kt @@ -8,6 +8,8 @@ object CredentialStore { private const val KEY_DEVICE_NAME = "device_name" private const val KEY_API_KEY = "api_key" private const val KEY_UPLOADED = "uploaded_files" + private const val KEY_PORTAL_USERNAME = "portal_username" + private const val KEY_PORTAL_PASSWORD = "portal_password" private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) @@ -35,4 +37,17 @@ object CredentialStore { fun getUploaded(context: Context): Set = prefs(context).getStringSet(KEY_UPLOADED, emptySet()) ?: emptySet() + + fun getPortalUsername(context: Context): String = + prefs(context).getString(KEY_PORTAL_USERNAME, "") ?: "" + + fun getPortalPassword(context: Context): String = + prefs(context).getString(KEY_PORTAL_PASSWORD, "") ?: "" + + fun savePortalCredentials(context: Context, username: String, password: String) { + prefs(context).edit() + .putString(KEY_PORTAL_USERNAME, username) + .putString(KEY_PORTAL_PASSWORD, password) + .apply() + } } diff --git a/android/app/src/main/java/com/yarisobd/app/data/remote/PortalApi.kt b/android/app/src/main/java/com/yarisobd/app/data/remote/PortalApi.kt new file mode 100644 index 0000000..d7a69eb --- /dev/null +++ b/android/app/src/main/java/com/yarisobd/app/data/remote/PortalApi.kt @@ -0,0 +1,38 @@ +package com.yarisobd.app.data.remote + +import com.yarisobd.app.data.remote.dto.GeocodeBatchRequest +import com.yarisobd.app.data.remote.dto.GeocodeResult +import com.yarisobd.app.data.remote.dto.PortalDashcamFile +import com.yarisobd.app.data.remote.dto.PortalStats +import com.yarisobd.app.data.remote.dto.PortalTripSummary +import com.yarisobd.app.data.remote.dto.TripRoute +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query + +/** + * Remote server portal API (https://yaris.ceaf.dev) — requires a logged-in + * browser session (Flask session cookie), established by PortalViewModel. + */ +interface PortalApi { + + @GET("/api/v1/portal/stats") + suspend fun stats(): PortalStats + + @GET("/api/v1/portal/trips") + suspend fun trips(@Query("limit") limit: Int = 200): List + + @GET("/api/v1/portal/trip/{id}") + suspend fun tripRoute(@Path("id") id: Int): TripRoute + + @GET("/api/v1/portal/dashcam") + suspend fun dashcam(@Query("limit") limit: Int = 500): List + + @GET("/api/geocode") + suspend fun geocode(@Query("lat") lat: Double, @Query("lon") lon: Double): GeocodeResult + + @POST("/api/geocode/batch") + suspend fun geocodeBatch(@Body body: GeocodeBatchRequest): Map +} diff --git a/android/app/src/main/java/com/yarisobd/app/data/remote/dto/Portal.kt b/android/app/src/main/java/com/yarisobd/app/data/remote/dto/Portal.kt new file mode 100644 index 0000000..c33485b --- /dev/null +++ b/android/app/src/main/java/com/yarisobd/app/data/remote/dto/Portal.kt @@ -0,0 +1,61 @@ +package com.yarisobd.app.data.remote.dto + +import kotlinx.serialization.Serializable + +/** + * One trip from the server's GET /api/v1/portal/trips (flat lat/lon, ISO dates). + */ +@Serializable +data class PortalTripSummary( + val id: Int = 0, + val start_datetime: String = "", + val end_datetime: String = "", + val distance_km: Double = 0.0, + val duration_seconds: Int = 0, + val average_speed_kmh: Double = 0.0, + val max_speed_kmh: Double = 0.0, + val start_lat: Double? = null, + val start_lon: Double? = null, + val end_lat: Double? = null, + val end_lon: Double? = null, + val point_count: Int = 0, + val odo_start_km: Double? = null, + val odo_end_km: Double? = null, +) { + /** Map to the shared TripSummary shape so portal trips reuse the Pi UI. */ + fun toTripSummary(): TripSummary = TripSummary( + id = id, + start_datetime = start_datetime, + end_datetime = end_datetime, + start = if (start_lat != null && start_lon != null) LatLon(start_lat, start_lon) else null, + end = if (end_lat != null && end_lon != null) LatLon(end_lat, end_lon) else null, + distance_km = distance_km, + duration_seconds = duration_seconds, + point_count = point_count, + average_speed_kmh = average_speed_kmh, + max_speed_kmh = max_speed_kmh, + odo_start_km = odo_start_km, + odo_end_km = odo_end_km, + ) +} + +/** Response from the server's GET /api/v1/portal/stats. */ +@Serializable +data class PortalStats( + val trip_count: Int = 0, + val total_distance_km: Double = 0.0, + val total_drive_time_s: Int = 0, + val top_speed_kmh: Double = 0.0, + val video_count: Int = 0, + val odometer_km: Double? = null, +) + +/** One file from the server's GET /api/v1/portal/dashcam. */ +@Serializable +data class PortalDashcamFile( + val id: Int = 0, + val relative_path: String = "", + val size_bytes: Long = 0, + val uploaded_at: String = "", + val storage_path: String = "", +) diff --git a/android/plan.md b/android/plan.md index 25541db..8caf2c5 100644 --- a/android/plan.md +++ b/android/plan.md @@ -203,26 +203,29 @@ android/ - [x] Trip stats header: distance, duration, avg speed, max speed - [ ] Start/end markers + geocode label below scrubber — deferred -### 28.6 — Remote portal direct access ⬜ NOT STARTED +### 28.6 — Remote portal direct access ✅ DONE (2026-08-15) **Depends on: 28.1 (credentials). Independent of 28.2–28.5. Stretch goal.** -#### 28.6.1 — Auth + data layer -- [ ] Retrofit interface for server portal: - - `POST /login` → session cookie (or token) - - `GET /api/v1/portal/stats` → overview stats - - `GET /api/v1/portal/trips` → trip list - - `GET /api/v1/portal/trip/{id}` → trip route - - `GET /api/v1/portal/dashcam` → dashcam file list - - `GET /api/v1/portal/dashcam/stream/{id}` → video stream -- [ ] Session persistence: store cookie/token in EncryptedSharedPreferences -- [ ] Commit: `feat: server portal data layer` +#### 28.6.1 — Auth + data layer ✅ +- [x] `PortalApi` (Retrofit) for the server portal: + - `GET /api/v1/portal/stats` → `PortalStats` + - `GET /api/v1/portal/trips` → `List` (mapped to shared `TripSummary`) + - `GET /api/v1/portal/trip/{id}` → `TripRoute` (reuses `RoutePoint`) + - `GET /api/v1/portal/dashcam` → `List` + - `GET/POST /api/geocode` + batch (server-side reverse geocoder) +- [x] `PortalViewModel` with CSRF form login (`GET /login` → parse token → `POST /login`) + + in-memory `CookieJar` for the Flask session cookie, 401 → auto "session expired" +- [x] Portal credentials (username/password) stored in `CredentialStore` (SharedPreferences); + auto-login on launch when present +- [ ] Dashcam video streaming/player — deferred (list only for now) -#### 28.6.2 — Portal screens -- [ ] Reuse trip list + route replay composables from 28.5 (same data shape) -- [ ] Reuse dashcam list + player composables (server serves same JSON shape as Pi) -- [ ] Login screen (if not already authenticated) -- [ ] "Connected to car" vs "Connected to server" mode indicator -- [ ] Commit: `feat: remote portal screens` +#### 28.6.2 — Portal screens ✅ +- [x] New "Server" tab: login form → overview stats + trip list + dashcam archive +- [x] Reuses `TripCard` + `TripReplayScreen` from 28.5 (server serves the same route shape) +- [x] "Connected to server" indicator + server URL + logout +- [x] Tapping a server trip opens the same replay screen with an online CartoDB basemap + (`PORTAL_TILE_URL`, zoom 0–19) so it works over cellular away from the hotspot +- [ ] Video playback (stream endpoint) — deferred ### 28.7 — Polish + distribution ⬜ NOT STARTED **Depends on: 28.1–28.6 (whichever subset ships).**