# 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 ⬜ NOT STARTED **Depends on: 28.1. Independent of 28.2–28.4.** #### 28.5.1 — Data layer - [ ] Retrofit interface additions: - `GET /api/trips` → `List` - `GET /api/trip/{id}` → `TripRoute` (list of route points with lat, lon, speed, timestamp) - `GET /api/geocode?lat={}&lon={}` → `GeocodeResult` (label) - [ ] Room cache: store trip list + last-viewed route for offline viewing - [ ] Commit: `feat: trip history data layer` #### 28.5.2 — Trip list UI - [ ] LazyColumn of trips: date, start time, distance (km), duration, start→end locations - [ ] Tap → navigates to route replay - [ ] Sort: by date (default: newest first) - [ ] Commit: `feat: trip list screen` #### 28.5.3 — Route replay UI - [ ] MapLibre MapView with route polyline + start/end markers - [ ] Timeline scrubber (slider) with timestamp + speed label - [ ] Play/pause button: animates marker along route - [ ] Geocoded location name below scrubber (debounced while scrubbing) - [ ] Trip stats header: date, distance, duration, avg speed, max speed - [ ] Commit: `feat: route replay screen` ### 28.6 — Remote portal direct access ⬜ NOT STARTED **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.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.7 — Polish + distribution ⬜ NOT STARTED **Depends on: 28.1–28.6 (whichever subset ships).** #### 28.7.1 — App polish - [ ] App icon (adaptive icon: foreground + background layers) - [ ] Splash screen (Android 12+ SplashScreen API) - [ ] Dark theme verified on all screens (default, car-friendly) - [ ] Error handling audit: every network call has a user-visible error state + retry - [ ] Accessibility: content descriptions on icons, reasonable touch targets (≥48dp) - [ ] Commit: `feat: app polish` #### 28.7.2 — Build + distribution - [ ] Release build signing key (keystore generated, not committed) - [ ] ProGuard/R8 rules verified (Retrofit, OkHttp, Coil, MapLibre keep rules) - [ ] APK output: `app/build/outputs/apk/release/app-release.apk` - [ ] Self-hosted distribution: - Copy APK to Pi: `scp app-release.apk yaris@yaris.local:~/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: release build + 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() // 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.