feat(android): remote portal access — login, stats, trips, dashcam (Phase 28.6)

This commit is contained in:
Mathieu Moran
2026-08-15 20:11:26 -04:00
parent 9fa5e2e2da
commit 0cb89596b0
11 changed files with 726 additions and 31 deletions
+8 -5
View File
@@ -2021,7 +2021,7 @@ tracked in FEATURES.md.
2. **FEAT-02** — QR / claim-code device pairing flow (medium) 2. **FEAT-02** — QR / claim-code device pairing flow (medium)
3. **FEAT-04** — Replace SIM868 2G GSM with 4G LTE modem (large) 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 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 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).** 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. with timestamp + speed, and play/pause animation.
- Deferred: Room offline cache, start/end markers, geocode label under the scrubber. - Deferred: Room offline cache, start/end markers, geocode label under the scrubber.
**28.6 — Remote portal direct access (away from car)** **28.6 — Remote portal direct access ✅ DONE (2026-08-15)**
- Talk to `https://yaris.ceaf.dev/api/v1/portal/...` directly - New "Server" tab: CSRF form login to `https://yaris.ceaf.dev` (Flask session cookie via an
- Browse trip history and dashcam archive from anywhere (not just when connected to Pi) in-memory `CookieJar`), auto-login on launch with stored credentials.
- Login with portal credentials (same as Phase 24 web portal login) - 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** **28.7 — Polish + distribution**
- App icon, splash screen, dark theme polish - App icon, splash screen, dark theme polish
@@ -17,6 +17,7 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material.icons.Icons 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.Videocam
import androidx.compose.material.icons.filled.Dashboard import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.DirectionsCar import androidx.compose.material.icons.filled.DirectionsCar
@@ -70,6 +71,9 @@ fun YarisMainScreen(client: OkHttpClient) {
val tripsError by tripVm.error.collectAsState() val tripsError by tripVm.error.collectAsState()
val route by tripVm.route.collectAsState() val route by tripVm.route.collectAsState()
val routeLoading by tripVm.routeLoading.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 videos by dashcamVm.videos.collectAsState()
val totalSize by dashcamVm.totalSize.collectAsState() val totalSize by dashcamVm.totalSize.collectAsState()
val retentionDays by dashcamVm.retentionDays.collectAsState() val retentionDays by dashcamVm.retentionDays.collectAsState()
@@ -92,6 +96,7 @@ fun YarisMainScreen(client: OkHttpClient) {
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
var selectedTab by remember { mutableIntStateOf(0) } var selectedTab by remember { mutableIntStateOf(0) }
var selectedTrip by remember { mutableStateOf<TripSummary?>(null) } var selectedTrip by remember { mutableStateOf<TripSummary?>(null) }
var selectedPortalTrip by remember { mutableStateOf<TripSummary?>(null) }
// Show download/delete events as snackbars // Show download/delete events as snackbars
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -110,6 +115,19 @@ fun YarisMainScreen(client: OkHttpClient) {
tripVm.clearRoute() 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 { } else {
Scaffold( Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
@@ -145,6 +163,12 @@ fun YarisMainScreen(client: OkHttpClient) {
icon = { Icon(Icons.Filled.Settings, contentDescription = "Settings") }, icon = { Icon(Icons.Filled.Settings, contentDescription = "Settings") },
label = { Text("Settings") } label = { Text("Settings") }
) )
NavigationBarItem(
selected = selectedTab == 5,
onClick = { selectedTab = 5 },
icon = { Icon(Icons.Filled.Cloud, contentDescription = "Server") },
label = { Text("Server") }
)
} }
} }
) { padding -> ) { padding ->
@@ -205,6 +229,13 @@ fun YarisMainScreen(client: OkHttpClient) {
onRefresh = { tripVm.loadTrips() }, onRefresh = { tripVm.loadTrips() },
) )
4 -> SettingsScreen(client = client) 4 -> SettingsScreen(client = client)
5 -> PortalScreen(
viewModel = portalVm,
onTripClick = { t ->
selectedPortalTrip = t
portalVm.loadRoute(t.id)
},
)
} }
} }
} }
@@ -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"
}
@@ -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<String, MutableList<Cookie>>()
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
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<Cookie> =
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<Boolean> = _loggedIn.asStateFlow()
private val _loggingIn = MutableStateFlow(false)
val loggingIn: StateFlow<Boolean> = _loggingIn.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
private val _stats = MutableStateFlow<PortalStats?>(null)
val stats: StateFlow<PortalStats?> = _stats.asStateFlow()
private val _trips = MutableStateFlow<List<TripSummary>>(emptyList())
val trips: StateFlow<List<TripSummary>> = _trips.asStateFlow()
private val _tripLabels = MutableStateFlow<Map<Int, TripLabels>>(emptyMap())
val tripLabels: StateFlow<Map<Int, TripLabels>> = _tripLabels.asStateFlow()
private val _route = MutableStateFlow<TripRoute?>(null)
val route: StateFlow<TripRoute?> = _route.asStateFlow()
private val _routeLoading = MutableStateFlow(false)
val routeLoading: StateFlow<Boolean> = _routeLoading.asStateFlow()
private val _dashcam = MutableStateFlow<List<PortalDashcamFile>>(emptyList())
val dashcam: StateFlow<List<PortalDashcamFile>> = _dashcam.asStateFlow()
init {
val ctx = getApplication<Application>().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<TripSummary>, a: PortalApi) {
val coords = mutableListOf<GeocodeCoord>()
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<Int, TripLabels>()
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<String, String>, 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
}
}
@@ -105,7 +105,7 @@ fun TripListScreen(
} }
@Composable @Composable
private fun TripCard(trip: TripSummary, label: TripLabels?, onClick: () -> Unit) { fun TripCard(trip: TripSummary, label: TripLabels?, onClick: () -> Unit) {
Card( Card(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
shape = RoundedCornerShape(14.dp), shape = RoundedCornerShape(14.dp),
@@ -31,7 +31,9 @@ import org.maplibre.android.style.sources.RasterSource
import org.maplibre.android.style.sources.TileSet import org.maplibre.android.style.sources.TileSet
/** Pi serves offline MapTiler raster tiles (zoom 8–15) at this path. */ /** 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 @Composable
fun TripMapView( fun TripMapView(
@@ -40,6 +42,9 @@ fun TripMapView(
maxIndex: Int, maxIndex: Int,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onMaxClick: (() -> Unit)? = null, onMaxClick: (() -> Unit)? = null,
tileUrl: String = PI_TILE_URL,
minZoom: Double = 8.0,
maxZoom: Double = 15.0,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val mapView = remember { MapView(context) } val mapView = remember { MapView(context) }
@@ -61,9 +66,9 @@ fun TripMapView(
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
mapView.getMapAsync { map -> mapView.getMapAsync { map ->
mapRef.value = map mapRef.value = map
map.setMinZoomPreference(8.0) map.setMinZoomPreference(minZoom)
map.setMaxZoomPreference(15.0) map.setMaxZoomPreference(maxZoom)
map.setStyle(buildTripStyle()) { _ -> map.setStyle(buildTripStyle(tileUrl, minZoom, maxZoom)) { _ ->
styleReady.value = true styleReady.value = true
} }
// Tap the amber max-speed marker to jump the timeline to it. // Tap the amber max-speed marker to jump the timeline to it.
@@ -103,10 +108,10 @@ fun TripMapView(
AndroidView(factory = { mapView }, modifier = modifier) AndroidView(factory = { mapView }, modifier = modifier)
} }
private fun buildTripStyle(): Style.Builder { private fun buildTripStyle(tileUrl: String, minZoom: Double, maxZoom: Double): Style.Builder {
val tileSet = TileSet("2.1.0", TILE_URL) val tileSet = TileSet("2.1.0", tileUrl)
tileSet.minZoom = 8f tileSet.minZoom = minZoom.toFloat()
tileSet.maxZoom = 15f tileSet.maxZoom = maxZoom.toFloat()
tileSet.attribution = "MapTiler" tileSet.attribution = "MapTiler"
val raster = RasterSource("tiles", tileSet, 256) val raster = RasterSource("tiles", tileSet, 256)
@@ -61,6 +61,9 @@ fun TripReplayScreen(
route: TripRoute?, route: TripRoute?,
loading: Boolean, loading: Boolean,
onBack: () -> Unit, onBack: () -> Unit,
mapTileUrl: String = PI_TILE_URL,
mapMinZoom: Double = 8.0,
mapMaxZoom: Double = 15.0,
) { ) {
val points = route?.points ?: emptyList() val points = route?.points ?: emptyList()
val maxIndex = remember(points) { val maxIndex = remember(points) {
@@ -132,6 +135,9 @@ fun TripReplayScreen(
playing = false playing = false
} }
}, },
tileUrl = mapTileUrl,
minZoom = mapMinZoom,
maxZoom = mapMaxZoom,
) )
} }
} }
@@ -8,6 +8,8 @@ object CredentialStore {
private const val KEY_DEVICE_NAME = "device_name" private const val KEY_DEVICE_NAME = "device_name"
private const val KEY_API_KEY = "api_key" private const val KEY_API_KEY = "api_key"
private const val KEY_UPLOADED = "uploaded_files" 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) = private fun prefs(context: Context) =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@@ -35,4 +37,17 @@ object CredentialStore {
fun getUploaded(context: Context): Set<String> = fun getUploaded(context: Context): Set<String> =
prefs(context).getStringSet(KEY_UPLOADED, emptySet()) ?: emptySet() 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()
}
} }
@@ -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<PortalTripSummary>
@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<PortalDashcamFile>
@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<String, String>
}
@@ -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 = "",
)
+20 -17
View File
@@ -203,26 +203,29 @@ android/
- [x] Trip stats header: distance, duration, avg speed, max speed - [x] Trip stats header: distance, duration, avg speed, max speed
- [ ] Start/end markers + geocode label below scrubber — deferred - [ ] 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.** **Depends on: 28.1 (credentials). Independent of 28.2–28.5. Stretch goal.**
#### 28.6.1 — Auth + data layer #### 28.6.1 — Auth + data layer ✅
- [ ] Retrofit interface for server portal: - [x] `PortalApi` (Retrofit) for the server portal:
- `POST /login` → session cookie (or token) - `GET /api/v1/portal/stats` → `PortalStats`
- `GET /api/v1/portal/stats` → overview stats - `GET /api/v1/portal/trips` → `List<PortalTripSummary>` (mapped to shared `TripSummary`)
- `GET /api/v1/portal/trips` → trip list - `GET /api/v1/portal/trip/{id}` → `TripRoute` (reuses `RoutePoint`)
- `GET /api/v1/portal/trip/{id}` → trip route - `GET /api/v1/portal/dashcam` → `List<PortalDashcamFile>`
- `GET /api/v1/portal/dashcam` → dashcam file list - `GET/POST /api/geocode` + batch (server-side reverse geocoder)
- `GET /api/v1/portal/dashcam/stream/{id}` → video stream - [x] `PortalViewModel` with CSRF form login (`GET /login` → parse token → `POST /login`)
- [ ] Session persistence: store cookie/token in EncryptedSharedPreferences + in-memory `CookieJar` for the Flask session cookie, 401 → auto "session expired"
- [ ] Commit: `feat: server portal data layer` - [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 #### 28.6.2 — Portal screens ✅
- [ ] Reuse trip list + route replay composables from 28.5 (same data shape) - [x] New "Server" tab: login form → overview stats + trip list + dashcam archive
- [ ] Reuse dashcam list + player composables (server serves same JSON shape as Pi) - [x] Reuses `TripCard` + `TripReplayScreen` from 28.5 (server serves the same route shape)
- [ ] Login screen (if not already authenticated) - [x] "Connected to server" indicator + server URL + logout
- [ ] "Connected to car" vs "Connected to server" mode indicator - [x] Tapping a server trip opens the same replay screen with an online CartoDB basemap
- [ ] Commit: `feat: remote portal screens` (`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 ### 28.7 — Polish + distribution ⬜ NOT STARTED
**Depends on: 28.1–28.6 (whichever subset ships).** **Depends on: 28.1–28.6 (whichever subset ships).**