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.
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).
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:
SettingsRepository; never add ad-hoc SharedPreferences keys in widgets.ChangeNotifiers consumed with provider; no business logic in widgets.LauncherPlatform so controllers stay testable.Persistence pattern:
_slots[slot] = app.toAssignment();
await _settings.setSlot(slot, app.toAssignment());
notifyListeners();
context.colors / AppTheme tokens, not hard-coded colors in widgets.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.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.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.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.README.md, and docs/IMPLEMENTATION_PLAN.md in the same commit as any behavior change.SYSTEM_ALERT_WINDOW, kiosk, root, or split-screen feature. The agreed product launches apps full-screen.FOREGROUND_SERVICE + FOREGROUND_SERVICE_MEDIA_PLAYBACK and POST_NOTIFICATIONS only for CarDash's own Jellyfin playback notification; no notification content, scanning or background location.android/local.properties, keystores, build outputs, APKs, or secrets.JAVA_HOME=/usr/lib/jvm/java-21-openjdk for builds./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.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.resumed lifecycle event; Android may restore system bars after dialogs or role prompts.cmd role add-role-holder; the Settings row uses RoleManager.LauncherController.homeRoleHintShown; do not show it again.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.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.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.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. 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.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.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.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.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.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./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.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.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.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.UpdateController.updateAvailable is true.bluetoothState, listBondedDevices, connectObd, disconnectObd) runs on TelemetryBridge's serialized cardash-bluetooth worker; never move it back to the MethodChannel/main thread.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 slider seeks on release through JellyfinPlaybackController.seek; a local drag value keeps the thumb stable until the next poll.tool/install.sh or use Settings > Home app.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.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:
version: in pubspec.yaml and appVersion in lib/core/app_version.dart identical. Increment versionCode../tool/check.sh.JAVA_HOME=/usr/lib/jvm/java-21-openjdk flutter build apk --release --target-platform android-arm,android-arm64,android-x64releases/cardash-android-<version>.apk, write releases/cardash-android-<version>.apk.sha256, update releases/README.md, then commit and push../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./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.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.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.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.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.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../tool/gogs_release.sh ... or the web UI, then verify the draft: false release through the API/page.