Files
yaris-obd/android/plan.md
T

412 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# yaris-obd Android Companion App — Implementation Plan
Native Android (Kotlin + Jetpack Compose) companion app for the yaris-obd Pi system.
Primary goal: dashcam file relay (download from Pi over hotspot WiFi → upload to remote
server over cellular, simultaneously) — the one thing the web UI cannot do (see main
PLAN.md Phase 11/12 for the platform limitation backstory).
This file is the ground-level implementation plan. The high-level architecture, API
surface, and scope decisions live in the main `PLAN.md` (Phase 28). This file tracks
day-to-day progress, per-sub-phase implementation details, and working notes specific
to the Android codebase.
## Dev environment
- **IDE:** Android Studio (Ladybug or newer) on Fedora dev machine
- **Language:** Kotlin 2.x, JDK 17 (Android Studio bundled)
- **Build:** Gradle with Kotlin DSL, version catalog (`gradle/libs.versions.toml`)
- **Emulator:** API 35 AVD, 4GB RAM, 2GB storage — UI/flow testing only (single virtual
NIC, can't test dual-network binding)
- **Real device:** owner's Pixel, USB debugging enabled — required for dual-network tests
- **Repo:** `android/` at project root, part of the same `yaris-obd` git repo
## Project scaffold
```
android/
├── plan.md # this file
├── .gitignore # Android-specific ignores
├── build.gradle.kts # root build file
├── settings.gradle.kts # project settings + version catalog
├── gradle/
│ └── libs.versions.toml # dependency version catalog
├── gradle.properties # JVM args, AndroidX, Compose compiler
├── gradlew / gradlew.bat # Gradle wrapper
├── app/
│ ├── build.gradle.kts # app module build
│ ├── proguard-rules.pro # R8/ProGuard rules
│ └── src/
│ ├── main/
│ │ ├── AndroidManifest.xml
│ │ ├── java/com/yarisobd/app/
│ │ │ ├── YarisApp.kt # Application class
│ │ │ ├── MainActivity.kt # single-activity host
│ │ │ ├── di/ # manual DI or Hilt modules
│ │ │ ├── data/
│ │ │ │ ├── local/ # Room DB, DAOs, entities
│ │ │ │ ├── remote/ # Retrofit services, DTOs
│ │ │ │ └── repository/ # repository layer
│ │ │ ├── domain/
│ │ │ │ └── model/ # domain models
│ │ │ ├── ui/
│ │ │ │ ├── theme/ # Material 3 theme, colors, typography
│ │ │ │ ├── navigation/ # NavHost, route definitions
│ │ │ │ ├── dashboard/ # live car dashboard screen
│ │ │ │ ├── dashcam/ # file browser + download screen
│ │ │ │ ├── upload/ # upload queue + progress screen
│ │ │ │ ├── trips/ # trip list + route replay screen
│ │ │ │ ├── settings/ # credentials + preferences screen
│ │ │ │ └── components/ # shared composables
│ │ │ └── util/
│ │ │ └── NetworkBinder.kt # dual-network socket binding
│ │ └── res/
│ │ ├── values/ # strings, colors, themes
│ │ ├── drawable/ # icons, splash
│ │ └── mipmap/ # app launcher icons
│ └── test/ # unit tests
│ └── androidTest/ # instrumented tests
```
## Sub-phases
### 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/`)
- [ ] 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) + 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` — 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
- [ ] First-launch screen: text fields for server URL, device name, API key
- [ ] "Test connection" button on credentials screen (calls `/health` on server)
- [ ] Persist credentials after successful test
- [ ] Commit: `feat: encrypted credential storage + first-launch flow`
### 28.2 — Dashcam browser + download ✅ DONE (2026-08-12)
**Depends on: 28.1 (networking layer + credentials).**
#### What was built
- **Pi API extension** (`PiApi.kt`): `listVideos()` → `VideoListResponse`, `getVideoFile()`
(streaming `ResponseBody`), `getThumbnail()` (JPEG bytes), `deleteVideo()`
- **DTOs:** `VideoListResponse`, `VideoFile` (relative_path, size, mtime, sha256, uploaded,
thumbnail_url), `ConvertProgress`
- **DashcamScreen:** `LazyColumn` of imported Pi videos with thumbnails via Coil async image
loader, file size, date, upload status badge (Uploaded/Pending)
- **DashcamViewModel:** manages video list state, download to phone storage, delete local copy,
conversion progress polling
- **Downloads via WorkManager:** `DownloadWorker` foreground service with notification +
`setProgressAsync()` for inline progress bar. `ActiveDownload` state tracked via WorkManager
`LiveData` observation. Survives screen lock / background. FGS type declared as
`FOREGROUND_SERVICE_TYPE_DATA_SYNC` for Android 14+.
- **Downloads tab:** progress bar + % for in-flight downloads, downloaded files list
- **Bottom navigation:** Dashcam tab + Test tab
- **Verified:** video list loads from real Pi over "yaris 911" hotspot on the owner's Pixel
#### Commits
- Phase 28.2 — dashcam video browser + WiFi download
- fix: downloads use WorkManager foreground service + inline progress
### 28.3 — Cellular upload 🚧 IN PROGRESS (2026-08-12)
**Depends on: 28.2 (files on phone local storage).**
#### 28.3.1 — Server upload data layer ✅ DONE
- [x] Retrofit interface for server chunked upload (`data/remote/ServerApi.kt`):
- `GET /api/v1/dashcam/upload/status?sha256={hash}` → `UploadStatus`
- `POST /api/v1/dashcam/upload/chunk` → multipart (file chunk, sha256, chunk_index, total_chunks)
- [x] Data classes: `UploadStatus` (already_uploaded, received_chunk_indices, total_chunks)
#### 28.3.2 — Upload worker ✅ DONE
- [x] WorkManager `CoroutineWorker` (`UploadWorker.kt`):
- Input: filename, server URL, device name, API key
- Step 1: compute SHA256, check `/status` for already-received chunks
- Step 2: read file in 40MB chunks (matching `DASHCAM_CHUNK_SIZE`)
- Step 3: POST each missing chunk (skips already-received chunks)
- Step 4: verify server reports `already_uploaded` after last chunk
- [x] Foreground service notification with live % ("Uploading VID_00234.MOV — 62%")
- [x] Byte-level upload progress via `ProgressRequestBody` (okio `ForwardingSink`) — progress
reflects actual bytes sent including already-received chunks, not just chunk count
- [x] Robust chunk reads (loop `skip()`/`read()` instead of single calls)
- [x] Debug logging (`Log.d`/`Log.e`, tag `UploadWorker`) throughout
- [x] Re-upload guard: skips files already on server (`already_uploaded`)
#### 28.3.3 — Upload queue UI ✅ DONE (2026-08-15)
- [x] Downloads screen shows per-file upload status + inline progress bar
- [x] In-app test file generator for upload testing
- [x] "Upload all" button → enqueue all pending files (header card, shows pending count)
- [x] Retry button per failed file (tracks failed uploads via `failedUploads` StateFlow)
- [x] Commit: `feat: upload queue screen — upload all + per-file retry`
### 28.4 — Live dashboard ✅ DONE (2026-08-15) — MapLibre map deferred
**Depends on: 28.1 (networking layer). Independent of 28.2/28.3.**
#### 28.4.1 — Data layer ✅
- [x] Retrofit interface additions to `PiApi`:
- `GET /api/gps/status` → `GpsStatus` (has_fix, lat, lon, fix_age_seconds, thread_alive)
- `GET /api/obd/status` → `ObdStatus` (adapter_present, port, car_connected, speed, rpm,
coolant_temp, fuel_level, throttle_position, mil_on)
- `GET /api/stats` → `Stats` (trip_count, total_km, active_dtc, video_count, odometer_km)
- [x] Polling scheduler: `DashboardViewModel` with `startPolling()`/`stopPolling()` — fetches
immediately then every 2s; stopped via `DisposableEffect` when the tab loses focus.
- [x] Server `/health` check (cellular) alongside Pi polling for the connection bar.
#### 28.4.2 — Dashboard UI ✅ (map deferred)
- [x] Speed card: large number (km/h), "OBD speed"/"OBD offline" source label
- [x] RPM card: number with color scale (green < 3000, amber < 5000, red ≥ 5000)
- [x] Engine stats row: coolant temp (°C), fuel level (%), throttle (%)
- [x] MIL/DTC card: check-engine warning (amber = MIL on, green = no MIL) + stored DTC count
- [x] GPS card: fix status, lat/lon, fix age, thread alive (text — MapLibre map deferred)
- [x] Overview card: trips, total km, all-time max speed, videos, DTCs, odometer
- [x] Connection status bar: Pi WiFi dot + Server dot (green = online), last-update timestamp
- [x] Auto-refresh every 2s; polling stops when navigating away from the tab
- [ ] GPS map (MapLibre) — deferred: needs the MapLibre dep + basemap over hotspot (no internet).
### 28.5 — Trip history ✅ DONE (2026-08-15)
**Depends on: 28.1. Independent of 28.2–28.4.**
#### 28.5.1 — Data layer ✅
- [x] Retrofit interface additions to `PiApi`:
- `GET /api/trips` → `TripsResponse` (list of `TripSummary`)
- `GET /api/trip/{id}` → `TripRoute` (metadata + route points with lat/lon/speed/source)
- `GET /api/geocode?lat&lon` → `GeocodeResult`
- `POST /api/geocode/batch` → `Map<String,String>` (one call for all start/end labels)
- [x] `TripViewModel`: `loadTrips()` (with batch geocode of start/end), `loadRoute(id)`, `clearRoute()`
- [ ] Room cache (trip list + last route for offline) — deferred; Pi is the live source
#### 28.5.2 — Trip list UI ✅
- [x] `LazyColumn` of trips: date, start→end time, distance, duration, avg/max speed, start→end labels
- [x] Tap → opens `TripReplayScreen`
- [x] Newest first (Pi returns newest-first)
#### 28.5.3 — Route replay UI ✅
- [x] MapLibre `MapView` with the Pi's offline raster tiles (`/static/vendor/tiles/{z}/{x}/{y}.png`, zoom 8–15) — works with no internet
- [x] Route polyline (GeoJsonSource + LineLayer) + cursor circle marker, camera fit to bounds
- [x] Timeline scrubber (Slider) with timestamp + speed label
- [x] Play/pause button animating the cursor along the route (~5 fps)
- [x] Trip stats header: distance, duration, avg speed, max speed
- [x] System back button returns from replay to the list (BackHandler)
- [ ] Start/end markers + geocode label below scrubber — deferred
### 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 ✅
- [x] `PortalApi` (Retrofit) for the server portal:
- `GET /api/v1/portal/stats` → `PortalStats`
- `GET /api/v1/portal/trips` → `List<PortalTripSummary>` (mapped to shared `TripSummary`)
- `GET /api/v1/portal/trip/{id}` → `TripRoute` (reuses `RoutePoint`)
- `GET /api/v1/portal/dashcam` → `List<PortalDashcamFile>`
- `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 ✅
- [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 — UI redesign: match the mobile web UI ✅ DONE (2026-08-15)
**Depends on: 28.1–28.6. Renumbers the old 28.7 (polish) to 28.8.**
#### 28.7.1 — Theme
- [x] Light theme matching `static/yaris.css`: content bg `#f4f6fb`, white cards,
primary `#4e73df`, accent/error red `#e63946`, text-muted `#6c757d`
- [x] App defaults to light (matches the web; the web has no dark mode)
#### 28.7.2 — Chrome (top bar + bottom nav)
- [x] Global dark top bar (`YarisHeader`): `#1c1c1e`, white italic "YARIS-OBD"
wordmark + red underline, status-bar aware
- [x] Dark bottom `NavigationBar` (`#1c1c1e`, white active / 55% white inactive)
mirroring the web's `.mobile-bottom-nav`
- [x] Removed the per-screen `YarisHeader` in favour of the one global top bar
- [x] Trip replay screen gets the same dark top bar
#### 28.7.3 — Component polish (follow-up)
- [ ] Card shadows (`shadow-sm`) + white card surfaces
- [ ] Blue primary buttons (`btn-primary` #4e73df)
- [ ] FontAwesome-style icons where they map to the web nav
- [ ] Page titles (`h3 fw-bold`) at the top of each screen
### 28.8 — Polish + distribution 🚧 IN PROGRESS
**Depends on: 28.1–28.7 (whichever subset ships).**
#### 28.8.1 — App polish ✅ (2026-08-15)
- [x] App icon: adaptive icon (red `#e63946` bg + white gauge foreground)
- [x] Splash screen: native API 31+ (`values-v31/themes.xml`, dark bg + gauge icon)
- [x] Dark theme follows the OS setting (done in 28.7)
- [x] App label: "Yaris OBD"
- [ ] Error handling audit — most screens have error states; dashboard still shows "—" when offline
- [ ] Accessibility audit — content descriptions present on most icons; touch targets ≥48dp mostly
#### 28.8.2 — Build + distribution 🚧 (release APK signed; hosting pending)
- [x] Release signing key: `android/keystore/release.keystore` (gitignored) + `keystore.properties`
- [x] ProGuard/R8 rules verified — release APK builds AND launches on the Pixel
- [x] APK output: `app/build/outputs/apk/release/app-release.apk` (~43 MB, signed)
- [ ] Self-hosted distribution:
- Copy APK to Pi: `scp app-release.apk [email protected]:~/app/yaris-obd/static/app/`
- Add a lightweight Pi route: `GET /app` → serves `static/app/index.html` with
download link + install instructions
- Phone flow: connect to Pi hotspot → open `http://yaris.local/app` → download APK →
install (user must enable "Install unknown apps" once)
- [ ] Commit: `feat: self-hosted distribution`
## Working notes
### Dual-network binding reference
The key mechanism — from the Android docs and the Phase 11 investigation:
```kotlin
val connectivityManager = context.getSystemService<ConnectivityManager>()
// Find the WiFi network (connected to Pi hotspot)
val wifiNetwork: Network? = connectivityManager.allNetworks
.firstOrNull { network ->
val caps = connectivityManager.getNetworkCapabilities(network)
caps?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true &&
caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == false
// Pi hotspot has no internet — we WANT this network for local API calls
}
// Find the cellular network
val cellNetwork: Network? = connectivityManager.allNetworks
.firstOrNull { network ->
val caps = connectivityManager.getNetworkCapabilities(network)
caps?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true
}
// Create OkHttp clients bound to specific networks
fun OkHttpClient.bindToNetwork(network: Network?): OkHttpClient {
if (network == null) return this
return newBuilder()
.socketFactory(network.socketFactory)
.dns { network.getAllByName(it) }
.build()
}
```
The two clients then share nothing — different socket factories, different DNS resolvers —
fully independent network paths.
### Pi hotspot info
- SSID: "yaris 911" (WPA2-PSK)
- Subnet: `10.42.0.0/24`
- Pi IP: `10.42.0.1`
- mDNS: `yaris.local` (may not resolve on phone hotspot — documented limitation)
- App should use IP `10.42.0.1` directly, not rely on mDNS
### Chunked upload protocol (reused from Phase 10)
- Chunk size: 40MB (well under Cloudflare's 100MB free-tier limit)
- `/status?sha256=...` returns `{already_uploaded: true}` or `{received_chunk_indices: [0,1,3], total_chunks: 8}`
- `/chunk` POST: multipart with `file` (binary chunk), `sha256`, `chunk_index` (0-based), `total_chunks`
- Server reassembles when all chunks received, verifies SHA256, records in Postgres
- Entirely deduplicated by SHA256 — safe to re-upload chunks, server ignores duplicates
### Dependencies (tentative)
```toml
[versions]
kotlin = "2.1.0"
compose-bom = "2025.01.00"
okhttp = "4.12.0"
retrofit = "2.11.0"
kotlinx-serialization = "1.7.3"
room = "2.6.1"
workmanager = "2.10.0"
coil = "3.0.4"
maplibre = "11.5.0"
hilt = "2.51.1"
[libraries]
# Compose
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
compose-navigation = { group = "androidx.navigation", name = "navigation-compose" }
# Network
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" }
# Local storage
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
# Background work
workmanager = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workmanager" }
# Images
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
# Maps
maplibre = { group = "org.maplibre.gl", name = "android-sdk", version.ref = "maplibre" }
# Security
security-crypto = { group = "androidx.security", name = "security-crypto", version = "1.1.0-alpha06" }
# DI (optional — manual DI is fine for this scope; add Hilt only if boilerplate gets heavy)
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
```
### Real-device testing workflow
1. Build debug APK: `./gradlew assembleDebug`
2. Install on Pixel: `adb install app/build/outputs/apk/debug/app-debug.apk`
3. Connect phone to Pi hotspot
4. Confirm cellular data toggle is ON (Android should show both WiFi and LTE icons)
5. Run the app → check dual-network test screen
6. For upload tests: temporarily pause the Pi's own background uploader
(Settings → "Upload dashcam videos" toggle OFF) so the phone's upload isn't racing
the Pi's own uploader for the same files
### Environment gotchas
- **Emulator networking**: the Android emulator has its own virtual router. Accessing
`10.42.0.1` from the emulator goes through the host machine's network stack — it won't
reach the Pi unless the host is also on the Pi's WiFi hotspot. Prefer real-device testing
for any network-dependent code.
- **Pixel "switch to mobile data automatically"**: some Android versions have a setting
that auto-disables WiFi if it detects no internet. On the Pi hotspot (which has no
internet), this could silently drop the WiFi connection. The Phase 11 testing already
confirmed this is a hard platform limitation for the web UI; the native app's
`bindProcessToNetwork()` might bypass it. If the OS still kills the WiFi association,
the fallback is asking the user to enable "Keep WiFi on when no internet" in Developer
Options.
- **WorkManager + doze**: on Android 6+, WorkManager jobs may be deferred when the device
is in Doze mode. For uploads that the user explicitly triggered, consider using a
foreground service instead of (or in addition to) WorkManager so uploads continue
immediately.
- **40MB chunk memory**: the OkHttp `MultipartBody` will buffer each 40MB chunk in memory
before sending. On modern phones with 6GB+ RAM this is fine, but if it becomes an issue,
switch to a streaming multipart approach (OkHttp's `RequestBody` as `okio` pipe).
- **SHA256 of large files**: computing SHA256 of a 300MB file on a phone takes a few
seconds. Do it once when the download completes, store the result in Room, and reuse
for upload. Don't recompute on every upload attempt.