# CarDash — Agent Guide ## 1. Project Overview CarDash is a Flutter/Dart Android home-screen launcher for a car tablet. It has one configurable home page (clock, battery/Wi-Fi, media volume, up to 16 home buttons), launches other apps full-screen, keeps the screen awake by default (configurable), offers a long-press night screen, light/dark/automatic themes, and an optional four-digit PIN requested only on cold start. Stack: Flutter 3.47.8 / Dart 3.13.7, Android minSdk 29 (Android 10), compile/targetSdk 36, Kotlin bridges, AGP 9.1.0, Gradle 9.3.1, JDK 21. Package `com.wasp.cardash_launcher`. Landscape-only, tablet-first (1280×800). You are the implementation agent: keep the app buildable, tested, documented, committed, and pushed. Always call this project **CarDash**. ## 2. Commands Run from the repository root. ```bash flutter pub get flutter analyze flutter test flutter test test/pin_service_test.dart export JAVA_HOME=/usr/lib/jvm/java-21-openjdk flutter build apk --debug flutter build apk --release ./tool/check.sh # analyze + tests ./tool/emulator.sh 35 # boot the Android 15 tablet (headless) ./tool/emulator.sh 29 # boot the Android 10 tablet ./tool/install.sh debug # build with JDK 21, install, set HOME role ./tool/install.sh release ``` ADB lives at `/opt/android-sdk/platform-tools/adb`; the SDK is at `/opt/android-sdk`. AVDs: `cardash_tablet_api35`, `cardash_tablet_api29` (1280×800, landscape). ## 3. Architecture - `lib/core/` — theme tokens (`AppColors`, `AccentChoice`), `AppTheme`, `ThemeController`, `DisplayController` (keep-awake), reusable widgets. - `lib/data/models/` — `AppEntry`, `SlotType`, `SlotAssignment`, `SystemStatus`, `VolumeInfo`, `MediaPlayback`, Jellyfin library/connection/playback models, OBD/weather models. - `lib/data/services/` — `NativeBridge`, `TelemetryBridge`, `WeatherService`, `UpdateService`, `PinService`, Jellyfin Quick Connect + library gateway, `JellyfinGenreCatalog`, `JellyfinLibraryService`, `JellyfinPlaybackPlatform`/`NativeJellyfinPlayback`, secure-store adapter. - `lib/data/repositories/` — `SettingsRepository`, the SSOT for theme, night window, slot assignments, visibility flags, pinned apps, onboarding time, speed/temperature choices, the Jellyfin server URL/enable flag and the selected genre families. - `lib/features/home/` — home screen, `LauncherController`, `SystemController`, `ObdController`, `WeatherController`, `GpsSpeedController`, `TelemetrySettingsController` (speed/temperature visibility and sources), equalizer, night overlay, Jellyfin Home tile and source-aware `VolumeCard`. - `lib/features/jellyfin/` — dashboard controller/screen, album screen, app-level `JellyfinPlaybackController`, and the Jellyfin-specific widgets. - `lib/features/apps/` — All Apps screen and app-picker sheet. - `lib/features/settings/` — General, Media, Jellyfin, OBD-II, Speed, Temperature, Security and Updates cards; `JellyfinController` owns the Quick Connect session and its active/inactive state; the genre dialog and playback-notification row live with the Jellyfin card. - `lib/features/lock/` — PIN pad, lock screen, cold-start `LockController`. - `android/app/src/main/kotlin/com/wasp/cardash_launcher/` — `MainActivity`, `LauncherBridge`, `SystemBridge`, `TelemetryBridge`, `ObdService` (Classic SPP ELM327), `MediaBridge`, `CardashNotificationListener`, `JellyfinPlaybackService` (Media3), `JellyfinPlaybackBridge`, `JellyfinPlaybackReporter`; `SystemBridge` also dispatches media keys and applies the keep-awake flag. - `tool/` — emulator/install/check scripts. `docs/` — implementation plan and design notes. Rules: - Persisted configuration goes through `SettingsRepository`; never add ad-hoc SharedPreferences keys in widgets. - Controllers are `ChangeNotifier`s consumed with `provider`; no business logic in widgets. - Android behavior lives behind `LauncherPlatform` so controllers stay testable. - External apps are launched with an Intent; they are **not** embedded in the launcher. - The PIN is app-local and checked only when the process starts. Persistence pattern: ```dart _slots[slot] = app.toAssignment(); await _settings.setSlot(slot, app.toAssignment()); notifyListeners(); ``` ## 4. Conventions - Use `context.colors` / `AppTheme` tokens, not hard-coded colors in widgets. - All UI copy is sentence case (except the CarDash name). Keep copy short and functional. - New home buttons: extend `SlotType` for fixed slots or use `LauncherController` pinned-app APIs; fixed slots stay stylized, pinned apps default to their own icon, and icon edits use `style_icon_choices.dart`. `SlotType.jellyfin` is a fixed system slot with no app assignment; it opens the dashboard and is gated by `LauncherController.jellyfinConfigured`. - New native methods: add them to `LauncherPlatform`, `NativeBridge`, the relevant Kotlin bridge, and the test fake if used. The Media3 playback commands are the exception: they live behind `JellyfinPlaybackPlatform`/`NativeJellyfinPlayback` and `JellyfinPlaybackBridge`, so package/volume fakes do not grow playback stubs. - Tests: fake `LauncherPlatform`/`SecureStore`; `SharedPreferences.setMockInitialValues({})`; widget tests wrap in `MaterialApp(theme: AppTheme.light())`. Jellyfin tests use `test/jellyfin_fakes.dart` (`FakeJellyfinLibraryGateway`, `FakeJellyfinPlaybackPlatform`, `configuredJellyfinController`). Test files live in `test/` and end with `_test.dart`. - Run `dart format lib test` before committing. Conventional Commits (`feat:`, `fix:`, `docs:`, `chore:`). After every commit, push to `origin` (`git push origin main`); never leave commits unpushed and never force-push. - Update this file, `README.md`, and `docs/IMPLEMENTATION_PLAN.md` in the same commit as any behavior change. - Keep the UI fast: no large blur layers, no continuously running decorative animation, prefer implicit animations under ~250 ms. ## 5. Boundaries - NEVER embed a third-party app's UI, add an overlay/`SYSTEM_ALERT_WINDOW`, kiosk, root, or split-screen feature. The agreed product launches apps full-screen. - NEVER add a terms-and-conditions screen, blocking first-run modal, or mandatory onboarding. - NEVER change the application ID, HOME intent filter, landscape lock, or minSdk without human confirmation. - NEVER re-lock on resume, screen-off, or route changes; no "Lock now" action. PIN is requested only on cold start. - NEVER store, log, or display a plaintext PIN. - INTERNET, location and Bluetooth permissions are approved only for OBD, Open-Meteo weather, the GPS speed fallback, the Jellyfin server the user configures, and the public release check; notification access only for MediaSession title/progress; `FOREGROUND_SERVICE` + `FOREGROUND_SERVICE_MEDIA_PLAYBACK` and `POST_NOTIFICATIONS` only for CarDash's own Jellyfin playback notification; no notification content, scanning or background location. - NEVER commit `android/local.properties`, keystores, build outputs, APKs, or secrets. - REQUIRE human confirmation before changing signing configuration, package visibility permissions, or the repository remote. ## 6. Gotchas - The machine default Java is 27; Gradle/AGP for this project is validated with **JDK 21**. Always export `JAVA_HOME=/usr/lib/jvm/java-21-openjdk` for builds. - Only x86_64 system images run here; the pre-installed arm64 image cannot boot. The download script/images are already in `/opt/android-sdk`. - `QUERY_ALL_PACKAGES` is required to list every app. It is acceptable for a launcher but is not a Play-Store-distributable choice as-is; document it. - The Flutter template uses AGP 9.1 built-in Kotlin support. Do not add the Kotlin Gradle plugin unless the build explicitly requires it. - Impeller stays disabled through `io.flutter.embedding.android.EnableImpeller=false` in the **``** block. Flutter silently ignores this key under ``; putting it there enabled Impeller and crashed the SwiftShader emulator when Settings opened. Do not move it back. - Immersive mode is reapplied on every `resumed` lifecycle event; Android may restore system bars after dialogs or role prompts. - The HOME role must be granted for the dashboard to replace the tablet home screen. On the emulator use `cmd role add-role-holder`; the Settings row uses `RoleManager`. - There is no Home-role banner. The one-time first-run tip uses `LauncherController.homeRoleHintShown`; do not show it again. - The long-press hint uses `onboarding.firstLaunchAt` and `HomeHint`: it is visible only for the first ten minutes after a fresh installation. Upgraded installs are migrated to an expired window and never see it again. - Screen-space is deliberately finite: test the 16-button grid at 1280×800, 1280×720 and 1024×600. The shortcut grid is bottom-anchored so a short row sits on the bottom edge; keep that behavior when changing layouts. The grid includes All apps; Settings sits in `StatusCluster` and does not consume a slot. Avoid unbounded `ListView`s inside `Column`s. - `tool/emulator.sh` starts a headless AVD with the **host GPU**; SwiftShader segfaulted on this machine's API 35 image. Use `CARDASH_GPU=swiftshader_indirect` only as a fallback. Use `adb exec-out screencap -p` for screenshots; there may be multiple `adb` binaries on PATH, so use the absolute one. - While CarDash is foreground, `MainActivity` consumes hardware volume keys and adjusts `STREAM_MUSIC` so they match the dashboard, unless `AudioManager.isVolumeFixed` is true; on fixed-volume car units the keys pass through to the vehicle amplifier and the on-screen slider is disabled with an explanation. `SystemBridge.setVolume` falls back to relative `adjustStreamVolume` steps when a unit ignores absolute `setStreamVolume`. Long-pressing **Media volume** toggles `SystemController.volumeDiagnostics`, which shows the raw `index/max`, fixed flag, all stream levels and the last write result; the native side logs the same data under `CarDashVolume`. Do not remove this path without providing another sync path; otherwise Android controls the ring stream when no media is playing. - `FLAG_KEEP_SCREEN_ON` is controlled by `DisplayController` from `display.keepScreenOn` (default on) through `SystemBridge`; do not hard-code it in `MainActivity` or move it to a wake lock. - The Music and Jellyfin equalizers are exclusive. The Jellyfin tile animates only while `JellyfinPlaybackController.playing`; the Music tile animates only when the system reports media playing, Jellyfin is not playing, and the active system session is not CarDash itself (`system.media.packageName != appPackageName`). The package guard prevents the Music tile flashing while a Jellyfin pause propagates through the 900 ms system poll. Jellyfin title/progress come from the native playback controller even without notification access; other apps use the notification-listener component and active MediaSession. Never read or store notification content. - Night mode is a full-screen black overlay with a dim clock. Long-press only on empty background; tap anywhere to dismiss. It does not change brightness or add a wake lock. - Speed is shown only when `TelemetrySettingsController.speedEnabled` is on. OBD-II is preferred when connected and fresh; the GPS fallback is used when OBD speed is off or unavailable. Enabling speed with GPS as the source (no adapter selected or OBD speed off) asks for location permission; the normal resume path never prompts. GPS starts only with fine location and GPS, and clamps stationary noise below 2 km/h to 0. - Temperature is shown only when `TelemetrySettingsController.temperatureEnabled` is on. OBD ambient is preferred when connected and fresh; Open-Meteo/location weather is the fallback, and the source switch only appears while an OBD-II adapter is selected. - Jellyfin is optional: Settings → Jellyfin owns pairing, the Home switch lives in General → Home buttons, and the Home tile opens the dashboard/player. Quick Connect is the only sign-in path: the user enters the code in Jellyfin, and CarDash keeps the returned access token in secure storage and reuses it on the next start. A rejected token or server error disables the integration, clears the token and shows **Pair again**; an unreachable server keeps the token and retries with 5–60 s backoff, so never clear the token for a network error. `JellyfinController.configured` gates the Home switch/tile (saved URL + token, survives outages); `connected` is for network actions. The Home slot default is off, counts toward the 16-button cap, is cleared on disconnect, and has no app assignment or long-press. Do not add a username/password form. - Jellyfin 12.2 API contract: `/Items?userId=...` (there is no `/Users/{userId}/Items` path); `/Genres` requires `parentId` of a `music` view from `/UserViews`; `genres` is pipe-delimited and OR-ed; `/Items/Latest` returns a bare array; auth is the `Authorization: MediaBrowser Client=..., Device=..., DeviceId=..., Version=..., Token=...` header (`X-Emby-Token` and `api_key` were rejected on 12.2). Album `UserData` is not updated by track playback, so "recently played" is derived from played Audio tracks sorted by `DatePlayed`, deduped by `AlbumId`, and capped at 6. Stream URLs use `/Audio/{id}/universal`; the native data source adds the auth header and the token never goes in a URL or log. - Jellyfin playback is Media3 1.11.1 in `JellyfinPlaybackService` (foreground `mediaPlayback`). `JellyfinPlaybackController` sends the queue, polls `getState` every ~700 ms, and keeps the Home card alive; the service owns the `MediaSession`, audio focus and `JellyfinPlaybackReporter`. Focus loss pauses and keeps position, a transient loss resumes on gain, a permanent loss abandons focus and does not resume. The silent `jellyfin_playback` notification needs `POST_NOTIFICATIONS` on Android 13+; the Settings card's **Allow** row requests it, and playback still works when denied. The service keeps playing while CarDash is backgrounded. - Dashboard layout: the controller asks for 6 albums per row; the screen shows 6 when each of six cards is at least 160 px wide, otherwise 5. Album names/artists share the album art's left edge, the album page cover is top-aligned with the track list/controls, and selecting a Random album re-rolls the Random row after playback starts. The Home equalizer is exclusive: Jellyfin playing → Jellyfin tile only; another app playing while Jellyfin is paused → Music tile only. The Home `VolumeCard` prefers the Jellyfin state while it is playing, or while it has a queue and no other app is playing; otherwise it uses the system session/media keys. - Updates use a non-blocking public Gogs release check. The Update action downloads the APK to cache and opens Android’s package installer; never install silently. REQUEST_INSTALL_PACKAGES + one-time unknown-apps permission is required. Show the yellow dot on the Settings gear whenever `UpdateController.updateAvailable` is true. - ADB installs: always try at most twice per connected device. If both attempts fail, stop silently rather than looping or switching install methods. - OBD is Bluetooth Classic SPP, bonded-only and read-only, and still needs a real-car test; weather uses Open-Meteo and one-shot location and stores no coordinates. - All Bluetooth binder work (`bluetoothState`, `listBondedDevices`, `connectObd`, `disconnectObd`) runs on `TelemetryBridge`'s serialized `cardash-bluetooth` worker; never move it back to the MethodChannel/main thread. - The OBD-II picker lists likely OBD-II adapters first; **Show all paired devices** is the explicit escape hatch for unusual adapters. Phones and headsets stay out of the default list. - Connection attempts are bounded (three failures, 12 s connect watchdog). After they are exhausted the card shows **Retry connection**; do not restore an infinite reconnect loop. - When Bluetooth is off, the OBD-II card shows **Open Bluetooth settings**. On resume the controller checks adapter power state and reuses the cached bonded list; it only lists devices when the list is unknown, after **Find**, or when the card opens. - Pairing happens in Android settings, never in CarDash. - The circular ring is song progress; its centre is play/pause and the Mute pill is separate. Jellyfin playback title/progress comes from `JellyfinPlaybackController`, so it works without notification access; other apps still need the user-granted notification access, otherwise the card falls back to Playing / Nothing playing. The album page reuses `VolumeCard` with previous/next and a `large` ring; the Home card keeps the normal size. - Reinstalling the app can clear its HOME role; re-run `tool/install.sh` or use Settings > Home app. ## 7. Releasing and deploying updates Remote: `origin` = `https://git.krasi.net/kkazakov/car-interface-android.git`, branch `main`. Push with `git push origin main`; never force-push. The host runs **Gogs** (not Gitea); the public Releases page is `https://git.krasi.net/kkazakov/car-interface-android/releases`. Release checklist: 1. Keep `version:` in `pubspec.yaml` and `appVersion` in `lib/core/app_version.dart` identical. Increment versionCode. 2. Run `./tool/check.sh`. 3. Build the universal APK: `JAVA_HOME=/usr/lib/jvm/java-21-openjdk flutter build apk --release --target-platform android-arm,android-arm64,android-x64` 4. Copy to `releases/cardash-android-.apk`, write `releases/cardash-android-.apk.sha256`, update `releases/README.md`, then commit and push. 5. Create and publish a Gogs release: - Preferred: `./tool/gogs_release.sh v "CarDash " releases/.md`. It uses the web form (the API cannot create releases), reads credentials locally, and never prints them. - Or use `/releases/new` in the web UI: tag `v`, target `main`, title/body, link the raw APK: `https://git.krasi.net/kkazakov/car-interface-android/raw/main/releases/cardash-android-.apk`. Gogs attachments cap at 32 MB, so the ~59 MB universal APK is linked from the release body instead of attached. 6. Publishing the Gogs release is the deployment for in-app updates. The app queries `https://git.krasi.net/api/v1/repos/kkazakov/car-interface-android/releases`, selects the highest published semver tag, and downloads `/raw/main/releases/cardash-android-.apk`. No app-side release configuration is needed. 7. The current release build uses the project debug signing config. Reuse the existing debug keystore (certificate SHA-256 `C2:8E:5B:0B:E0:F8:27:EE:12:22:B0:62:9F:EE:1C:F2:7E:14:3F:78:95:86:1E:3F:34:15:A6:E5:3A:06:BE:ED`). It is stored locally at `android/keystore/debug.keystore`; that path is gitignored and must never be committed. The Gradle config uses it for debug and release when present, falling back to the default debug key otherwise. Never let Gradle generate a fresh key for a release, or in-place updates will fail. Android still requires the user to confirm installation and allow unknown-app installs once; never attempt silent installation. 8. After the release APK is built, check whether the remote ADB server at `100.101.102.21:5037` is reachable (`adb -H 100.101.102.21 -P 5037 devices`). If `emulator-5554` is listed as `device`, install the release APK with `adb -H 100.101.102.21 -P 5037 -s emulator-5554 install -r releases/cardash-android-.apk`, following the two-attempt ADB rule. If it is unreachable or offline, skip it silently; this step is optional and must never fail the release. Note that `100.101.102.21:5037` is the ADB *server* port; `adb connect` expects the device port (normally `5555`), so use `-H`/`-P` as above. ## 8. Gogs credentials - Never commit, print, or paste the Gogs token or web password into chat, commit messages, logs, release bodies, or shell history. - **API token:** read from `GOGS_TOKEN`, else `$HOME/.config/cardash/gogs_token`, else `android/keystore/gogs_token` (all mode `600`; the repo-local file is gitignored). It belongs to `kkazakov`. Authenticate with `Authorization: token $GOGS_TOKEN`; it can list releases and read repository data. - **Web credentials:** `tool/gogs_release.sh` logs into the web form to publish releases, because this Gogs build only exposes `GET /repos/:owner/:repo/releases` and returns 404 for creating releases over the API. It reads `username=` / `password=` from `GOGS_CREDENTIALS`, else `$HOME/.config/cardash/gogs_credentials`, else `android/keystore/gogs_credentials` (mode `600`, gitignored). The repo-local file is the durable path across Paseo rebuilds. - Do not block a release, or claim it is published, just because the API cannot create one. Publish with `./tool/gogs_release.sh ...` or the web UI, then verify the `draft: false` release through the API/page. - Use the token only for API operations this Gogs version actually supports.