411 lines
22 KiB
Markdown
411 lines
22 KiB
Markdown
# 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
|
||
- [ ] 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 ⬜ NOT STARTED
|
||
**Depends on: 28.1–28.7 (whichever subset ships).**
|
||
|
||
#### 28.8.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.8.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 [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: 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<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.
|