AGENTS.md 25 KB

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.

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, the selected genre families and the streaming quality.
  • 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 ChangeNotifiers 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:

_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 <application> block. Flutter silently ignores this key under <activity>; 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 ListViews inside Columns.
  • 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, then to a FLAG_SHOW_UI write that may reach the vendor's own volume panel. Long-pressing Media volume toggles SystemController.volumeDiagnostics, which shows the raw index/max/min, fixed flag, control source, vendor state, 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.
  • FYT/Syu head units (Joying, Junsun, Teyes, Atoto and similar Chinese firmware) own the amplifier in the MCU, so the Android media stream is not what the driver hears. SyuVolumeBridge binds com.syu.ms (app.ToolkitService, action com.syu.ms.toolkit), fetches module 4 (getRemoteModule, transaction 1) and sends cmd(0, [level]) for the absolute master volume (command 0 = FinalSound.C_VOL, range 0–36); it also subscribes to update 2 (level) and 3 (mute), so knob changes and slider changes stay in sync. SystemBridge.readVolume reports source = syu while that bridge is connected. This path is verified on a real Android 13 FYT/Syu unit: the on-screen slider and the car's own volume control agree. MainActivity's hardware-key path is unchanged: the car's own knob and steering-wheel volume are handled by the MCU, not by injecting extra steps. No permission, root or manifest change is needed; if the package, service or module is missing the bridge stays disconnected and the AudioManager path is used. Never make the vendor bind a hard dependency, and never let a failed binder call crash the app.
  • A write that the car does not confirm must not snap the slider back to the old value: SystemController keeps the requested level for a 1.6 s settle window, then marks it unconfirmed (the card shows 25%?) until the read changes. Do not restore the old immediate read-back revert; that is the "it always goes back" bug on units whose read does not follow writes.
  • 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. If a Jellyfin album is active, the tile opens that album's page with the dashboard underneath; Back then lands on Albums. 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. Favorites are GET /Items?userId=...&filters=IsFavorite&includeItemTypes=MusicAlbum&recursive=true&enableUserData=true; toggle with POST/DELETE /UserFavoriteItems/{itemId}?userId=... (not /Users/{userId}/FavoriteItems). The album's UserData.IsFavorite is parsed and re-checked on the album page; the Favorites tab refreshes on selection and after an album page closes.
  • 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. Progressive/transcoded streams often report no Media3 duration; the bridge falls back to the queued MediaItem duration and JellyfinPlaybackController.mediaPlayback falls back to the current queue item so progress and seeking have a denominator.
  • Jellyfin streaming quality is persisted as jellyfin.audioQuality (JellyfinAudioQuality). Direct (default) omits MaxStreamingBitrate and advertises the source codecs; 320/256/192/128 kbps set Container=mp3, AudioCodec=mp3, TranscodingContainer=mp3 and MaxStreamingBitrate, asking Jellyfin to transcode. JellyfinLibraryService reads audioQualityProvider when a queue is built, so a change applies to the next album. The dropdown ships in 1.2.2.
  • 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 Favorites tab sits between Albums and Artists (Artists stays disabled), uses the same 5/6 columns in a scrollable multi-row grid, and shows No favorites centered when empty. The album-page heart toggles the server favorite; the Favorites tab reloads after the album page closes. These favorites/Home-shortcut changes ship in 1.2.2. 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.
  • On Home, 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 no longer uses VolumeCard: it has one seekable progress slider with elapsed/total time, a centered previous / play-pause / next row, and a compact volume card (−, Mute/Unmute, +) beside the now-playing block. The Home card keeps the ring. The progress/transport card is bottom-aligned with the album cover in both the wide and narrow layouts; the transport buttons are 80/62 px and the compact volume steps are 40 px. The album header appends the Jellyfin ProductionYear in parentheses to the name when present, e.g. Ascension (2025), and does not duplicate a year already in the name. The album slider seeks on release through JellyfinPlaybackController.seek; a local drag value keeps the thumb stable until the next poll.
  • Reinstalling the app can clear its HOME role; re-run tool/install.sh or use Settings > Home app.
  • CarDash can also run as a normal app without the HOME role. The launcher activity intentionally has no excludeFromRecents, so it appears in recents and can be switched back to. resumeWhilePausing and alwaysRetainTaskState keep the Home transition and task state sane. Android may still kill any background app: there is deliberately no keep-alive foreground service, persistent flag or battery-exemption request. On vendor firmware, tell the user to set CarDash to no battery restrictions / allow autostart. When CarDash holds the HOME role, Android may still filter the home task out of recents; use Home to return. Do not re-add excludeFromRecents without asking, and never add a launcher keep-alive FGS.

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-<version>.apk, write releases/cardash-android-<version>.apk.sha256, update releases/README.md, then commit and push.
  5. Create and publish a Gogs release:
    • Preferred: ./tool/gogs_release.sh v<version> "CarDash <version>" releases/<version>.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<version>, target main, title/body, link the raw APK: https://git.krasi.net/kkazakov/car-interface-android/raw/main/releases/cardash-android-<version>.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-<version>.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-<version>.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.