Prechádzať zdrojové kódy

fix: handle fixed-volume car units and relative volume fallback

Detect AudioManager.isVolumeFixed, disable the misleading dashboard slider, and pass hardware volume keys through on fixed-volume car units. When a head unit ignores absolute setStreamVolume calls, retry with the same relative adjustStreamVolume steps used by the hardware keys. Bump to 1.0.4+16.
Krasimir Kazakov 6 dní pred
rodič
commit
9a1ad4b9ab

+ 1 - 1
AGENTS.md

@@ -93,7 +93,7 @@ notifyListeners();
 - 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.
 - 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 grid includes All apps; Settings sits in `StatusCluster` and does not consume a slot. Avoid unbounded `ListView`s inside `Column`s.
 - Screen-space is deliberately finite: test the 16-button grid at 1280×800, 1280×720 and 1024×600. 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.
 - `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. Do not remove that override without providing another sync path; otherwise Android controls the ring stream when no media is playing.
+- 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`. 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.
 - `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 equalizer follows `AudioManager.isMusicActive`. Now-playing title/progress use the notification-listener component and active MediaSession; never read or store notification content.
 - The Music equalizer follows `AudioManager.isMusicActive`. Now-playing title/progress 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.
 - 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.

+ 18 - 13
README.md

@@ -18,7 +18,7 @@ the normal Android launcher mechanism. No overlays, no root, no kiosk setup.
 ## Download
 ## Download
 
 
 - [Gogs release page](https://git.krasi.net/kkazakov/car-interface-android/releases) — published releases
 - [Gogs release page](https://git.krasi.net/kkazakov/car-interface-android/releases) — published releases
-- [cardash-android-1.0.3.apk](releases/cardash-android-1.0.3.apk) — universal Android release
+- [cardash-android-1.0.4.apk](releases/cardash-android-1.0.4.apk) — universal Android release
 - SHA-256 and build details: [releases/README.md](releases/README.md)
 - SHA-256 and build details: [releases/README.md](releases/README.md)
 
 
 Gogs limits release attachments to 32 MB, so the 54 MB universal APK is linked
 Gogs limits release attachments to 32 MB, so the 54 MB universal APK is linked
@@ -314,22 +314,22 @@ flutter test
 ./tool/check.sh
 ./tool/check.sh
 ```
 ```
 
 
-The 63 tests cover PIN hashing/persistence, settings persistence, theme/accent,
+The 65 tests cover PIN hashing/persistence, settings persistence, theme/accent,
 dark accent tinting, battery visibility, first-run state, the ten-minute
 dark accent tinting, battery visibility, first-run state, the ten-minute
 onboarding hint, slot visibility, pinned apps, custom labels/icons, the
 onboarding hint, slot visibility, pinned apps, custom labels/icons, the
 16-button cap at 1280×800, 1280×720 and 1024×600, the dense shortcut grid, the
 16-button cap at 1280×800, 1280×720 and 1024×600, the dense shortcut grid, the
 equalizer animation, OBD economy, the OBD card, media-key playback, media
 equalizer animation, OBD economy, the OBD card, media-key playback, media
-metadata/progress, the marquee, the play/pause ring, the keep-awake preference,
-the night overlay, GPS speed fallback, speed/temperature source persistence,
-weather fallback, the update version/availability logic and the Settings update
-dot. Native package enumeration, the HOME role, volume, app launching and PIN
-flows were verified on API 35 and API 29 emulators. Location permission, reverse
-geocoding and the Open-Meteo fallback were verified live on the API 35 emulator.
-Now-playing title, artist, progress and pause/resume were verified against a live
-Chrome MediaSession. The OBD hardware path compiles and is covered by the
-parser/economy design, but no ELM327 adapter is available on this machine;
-socket behavior still needs a car test. Release
-The 1.0.3 universal APK is built from this tree and published; devices on 1.0.2
+metadata/progress, the marquee, the play/pause ring, fixed-volume handling, the
+keep-awake preference, the night overlay, GPS speed fallback, speed/temperature
+source persistence, weather fallback, the update version/availability logic and
+the Settings update dot. Native package enumeration, the HOME role, volume, app
+launching and PIN flows were verified on API 35 and API 29 emulators. Location
+permission, reverse geocoding and the Open-Meteo fallback were verified live on
+the API 35 emulator. Now-playing title, artist, progress and pause/resume were
+verified against a live Chrome MediaSession. The OBD hardware path compiles and
+is covered by the parser/economy design, but no ELM327 adapter is available on
+this machine; socket behavior still needs a car test. Release
+The 1.0.4 universal APK is built from this tree and published; devices on 1.0.3
 will pick it up through Settings → Updates when the release is published. The
 will pick it up through Settings → Updates when the release is published. The
 emulator has verified the in-app update path.
 emulator has verified the in-app update path.
 
 
@@ -367,6 +367,11 @@ Android 15 AVD is left running headlessly after development; use
 - **Notification access is used only for media metadata.** CarDash does not read
 - **Notification access is used only for media metadata.** CarDash does not read
   notification text or store track history. Revoke it in Android settings at any
   notification text or store track history. Revoke it in Android settings at any
   time; the ring and title then fall back to an empty/generic state.
   time; the ring and title then fall back to an empty/generic state.
+- **Volume control** writes the media stream. If Android reports a fixed-volume
+  policy (common on car head units), the vehicle amplifier owns the level;
+  CarDash disables the slider and explains why. Otherwise, if a unit ignores
+  absolute volume writes, CarDash falls back to the same relative steps as the
+  hardware volume keys.
 - **Screen-on** is controlled by Settings → Prevent CarDash from sleeping
 - **Screen-on** is controlled by Settings → Prevent CarDash from sleeping
   (default on). It applies only while CarDash is the foreground window; with the
   (default on). It applies only while CarDash is the foreground window; with the
   switch off, the normal Android display timeout applies.
   switch off, the normal Android display timeout applies.

+ 15 - 2
android/app/src/main/kotlin/com/wasp/cardash_launcher/MainActivity.kt

@@ -45,7 +45,9 @@ class MainActivity : FlutterActivity() {
      * and the keys disagree.
      * and the keys disagree.
      */
      */
     override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
     override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
-        if (keyCode == KeyEvent.KEYCODE_VOLUME_UP || keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) {
+        if (controlsVolumeKeys() &&
+            (keyCode == KeyEvent.KEYCODE_VOLUME_UP || keyCode == KeyEvent.KEYCODE_VOLUME_DOWN)
+        ) {
             adjustMediaVolume(if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) 1 else -1)
             adjustMediaVolume(if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) 1 else -1)
             return true
             return true
         }
         }
@@ -53,12 +55,23 @@ class MainActivity : FlutterActivity() {
     }
     }
 
 
     override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
     override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
-        if (keyCode == KeyEvent.KEYCODE_VOLUME_UP || keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) {
+        if (controlsVolumeKeys() &&
+            (keyCode == KeyEvent.KEYCODE_VOLUME_UP || keyCode == KeyEvent.KEYCODE_VOLUME_DOWN)
+        ) {
             return true
             return true
         }
         }
         return super.onKeyUp(keyCode, event)
         return super.onKeyUp(keyCode, event)
     }
     }
 
 
+    /** Fixed-volume car units handle the keys in the vehicle amplifier. */
+    private fun controlsVolumeKeys(): Boolean {
+        return try {
+            getSystemService(AudioManager::class.java)?.isVolumeFixed != true
+        } catch (_: Throwable) {
+            true
+        }
+    }
+
     private fun adjustMediaVolume(direction: Int) {
     private fun adjustMediaVolume(direction: Int) {
         try {
         try {
             val audio = getSystemService(AudioManager::class.java)
             val audio = getSystemService(AudioManager::class.java)

+ 30 - 2
android/app/src/main/kotlin/com/wasp/cardash_launcher/SystemBridge.kt

@@ -101,6 +101,7 @@ class SystemBridge(private val activity: Activity) : MethodChannel.MethodCallHan
         return mapOf(
         return mapOf(
             "index" to audio.getStreamVolume(AudioManager.STREAM_MUSIC),
             "index" to audio.getStreamVolume(AudioManager.STREAM_MUSIC),
             "max" to audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC),
             "max" to audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC),
+            "fixed" to audio.isVolumeFixed,
         )
         )
     }
     }
 
 
@@ -146,11 +147,38 @@ class SystemBridge(private val activity: Activity) : MethodChannel.MethodCallHan
     private fun setVolume(index: Int): Boolean {
     private fun setVolume(index: Int): Boolean {
         return try {
         return try {
             val audio = audioManager()
             val audio = audioManager()
+            // A fixed-volume policy means the car amplifier owns the level;
+            // software volume changes are intentionally ignored by Android.
+            if (audio.isVolumeFixed) return false
+
             val max = audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
             val max = audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
-            audio.setStreamVolume(AudioManager.STREAM_MUSIC, index.coerceIn(0, max), 0)
-            true
+            val target = index.coerceIn(0, max)
+            val before = audio.getStreamVolume(AudioManager.STREAM_MUSIC)
+            audio.setStreamVolume(AudioManager.STREAM_MUSIC, target, 0)
+            val after = audio.getStreamVolume(AudioManager.STREAM_MUSIC)
+            if (after == target) return true
+
+            // Some head units ignore absolute volume writes but accept the
+            // same relative steps as the hardware volume keys.
+            if (after == before) adjustTo(audio, target, after) else false
         } catch (_: Throwable) {
         } catch (_: Throwable) {
             false
             false
         }
         }
     }
     }
+
+    private fun adjustTo(audio: AudioManager, target: Int, start: Int): Boolean {
+        var current = start
+        var remainingSteps = audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC) * 2 + 2
+        while (current != target && remainingSteps-- > 0) {
+            audio.adjustStreamVolume(
+                AudioManager.STREAM_MUSIC,
+                if (target > current) AudioManager.ADJUST_RAISE else AudioManager.ADJUST_LOWER,
+                0,
+            )
+            val next = audio.getStreamVolume(AudioManager.STREAM_MUSIC)
+            if (next == current) return false
+            current = next
+        }
+        return current == target
+    }
 }
 }

+ 9 - 3
docs/IMPLEMENTATION_PLAN.md

@@ -64,6 +64,7 @@ compatibility:
 | Home cap | 16 buttons total, including All apps; Settings is outside the grid | Keeps the single page legible at tablet resolutions; enforced in `LauncherController`. |
 | Home cap | 16 buttons total, including All apps; Settings is outside the grid | Keeps the single page legible at tablet resolutions; enforced in `LauncherController`. |
 | Settings placement | Gear next to Wi-Fi in `StatusCluster` | Keeps Settings reachable without spending a grid slot. |
 | Settings placement | Gear next to Wi-Fi in `StatusCluster` | Keeps Settings reachable without spending a grid slot. |
 | Playback control | Circled volume ring is play/pause; Mute pill remains | One prominent control for play/resume/pause without adding another card. |
 | Playback control | Circled volume ring is play/pause; Mute pill remains | One prominent control for play/resume/pause without adding another card. |
+| Volume control | Absolute `setStreamVolume`, relative `adjustStreamVolume` fallback, fixed-policy detection | Some car units ignore absolute writes; fixed-volume amplifiers cannot be controlled by apps. |
 | Icons | Only pinned apps show their own icon by default; fixed slots stay stylized | Keeps the dashboard consistent while making added apps recognizable. |
 | Icons | Only pinned apps show their own icon by default; fixed slots stay stylized | Keeps the dashboard consistent while making added apps recognizable. |
 | Accent | Six curated choices, persisted in Settings | Changes the whole theme without a color picker or arbitrary contrast failures. |
 | Accent | Six curated choices, persisted in Settings | Changes the whole theme without a color picker or arbitrary contrast failures. |
 | Battery visibility | One switch hides level and charging bolt | One control for both indicators, as requested. |
 | Battery visibility | One switch hides level and charging bolt | One control for both indicators, as requested. |
@@ -155,6 +156,7 @@ Android (Kotlin)
 | M17 | Non-blocking Gogs update check; 1.0.1 version-only update-test release | Done |
 | M17 | Non-blocking Gogs update check; 1.0.1 version-only update-test release | Done |
 | M18 | Bluetooth-off settings action; 1.0.2 release | Done |
 | M18 | Bluetooth-off settings action; 1.0.2 release | Done |
 | M19 | OBD-II/Speed/Temperature Settings cards; source switches; 10-minute first-install hint; 1.0.3 | Done |
 | M19 | OBD-II/Speed/Temperature Settings cards; source switches; 10-minute first-install hint; 1.0.3 | Done |
+| M20 | Fixed-volume car detection; relative volume fallback; 1.0.4 | Done |
 
 
 ## 5. Verification checklist
 ## 5. Verification checklist
 
 
@@ -168,6 +170,7 @@ Android (Kotlin)
 - [x] App picker assign / long-press change / clear, with persistence across restart.
 - [x] App picker assign / long-press change / clear, with persistence across restart.
 - [x] Launch Navigation/Music/Radio full-screen and return Home (verified with Maps and YouTube Music).
 - [x] Launch Navigation/Music/Radio full-screen and return Home (verified with Maps and YouTube Music).
 - [x] Volume slider, mute and hardware-key synchronization (keys are routed to `STREAM_MUSIC` while the dashboard is foreground).
 - [x] Volume slider, mute and hardware-key synchronization (keys are routed to `STREAM_MUSIC` while the dashboard is foreground).
+- [x] Fixed-volume car units disable the slider and pass hardware keys through; absolute-write failures fall back to relative stream steps.
 - [x] Light theme, dark theme and automatic switch.
 - [x] Light theme, dark theme and automatic switch.
 - [x] PIN set/change/remove; cold start shows lock; wrong PIN; no re-lock after returning from an app.
 - [x] PIN set/change/remove; cold start shows lock; wrong PIN; no re-lock after returning from an app.
 - [x] Verify 1024×600 compact layout (`adb shell wm size 1024x600`).
 - [x] Verify 1024×600 compact layout (`adb shell wm size 1024x600`).
@@ -206,14 +209,14 @@ Android (Kotlin)
 - [x] 1.0.0 updater build installed on emulator/tablet; it reported up to date before 1.0.1.
 - [x] 1.0.0 updater build installed on emulator/tablet; it reported up to date before 1.0.1.
 - [x] 1.0.1 universal APK built and added to `releases/`; release published for update testing.
 - [x] 1.0.1 universal APK built and added to `releases/`; release published for update testing.
 - [x] Emulator completed the full in-app 1.0.0 → 1.0.1 download/installer flow.
 - [x] Emulator completed the full in-app 1.0.0 → 1.0.1 download/installer flow.
-- [x] Yellow update dot appears on the Settings gear; 63 tests pass.
+- [x] Yellow update dot appears on the Settings gear; 65 tests pass.
 - [x] Bluetooth-off Settings card exposes **Open Bluetooth settings** and refreshes on return.
 - [x] Bluetooth-off Settings card exposes **Open Bluetooth settings** and refreshes on return.
 - [x] 1.0.2 universal APK built and published for in-app update.
 - [x] 1.0.2 universal APK built and published for in-app update.
 - [x] Settings split into separate OBD-II, Speed and Temperature cards; OBD-II holds adapter settings only.
 - [x] Settings split into separate OBD-II, Speed and Temperature cards; OBD-II holds adapter settings only.
 - [x] Speed and Temperature toggles hide/show their Home elements and persist.
 - [x] Speed and Temperature toggles hide/show their Home elements and persist.
 - [x] OBD/GPS source switches appear while an adapter is selected and persist.
 - [x] OBD/GPS source switches appear while an adapter is selected and persist.
 - [x] Onboarding hint is limited to ten minutes after a fresh install; upgraded installs never show it.
 - [x] Onboarding hint is limited to ten minutes after a fresh install; upgraded installs never show it.
-- [x] 63 tests pass, including the onboarding window and source preferences.
+- [x] 65 tests pass, including the onboarding window and source preferences.
 - [x] 1.0.3 universal APK built and published for in-app update.
 - [x] 1.0.3 universal APK built and published for in-app update.
 - [x] Tablet has the 1.0.0 installer build; unknown-app confirmation is user-controlled.
 - [x] Tablet has the 1.0.0 installer build; unknown-app confirmation is user-controlled.
 - [x] Screenshots in `docs/screenshots/`; README/AGENTS/plan/OBD doc updated.
 - [x] Screenshots in `docs/screenshots/`; README/AGENTS/plan/OBD doc updated.
@@ -316,7 +319,10 @@ Android (Kotlin)
 - 2026-10-30: Split Settings into OBD-II, Speed and Temperature cards, added
 - 2026-10-30: Split Settings into OBD-II, Speed and Temperature cards, added
   independent show/hide switches plus OBD/GPS and OBD/weather source switches,
   independent show/hide switches plus OBD/GPS and OBD/weather source switches,
   and limited the long-press hint to ten minutes after a fresh install. Bumped to
   and limited the long-press hint to ten minutes after a fresh install. Bumped to
-  1.0.3+15; 63 tests pass.
+  1.0.3+15; 65 tests pass.
+- 2026-10-30: Added fixed-volume detection and a relative volume-step fallback
+  for car units that ignore absolute `setStreamVolume`; fixed-volume sliders now
+  explain that the vehicle amplifier owns the level. Bumped to 1.0.4+16.
 - 2026-10-30: Versioned the universal Android release as 1.0.0+12 and added
 - 2026-10-30: Versioned the universal Android release as 1.0.0+12 and added
   `releases/cardash-android-1.0.0.apk` with checksum and release notes.
   `releases/cardash-android-1.0.0.apk` with checksum and release notes.
 - 2026-10-29: Centered the now-playing title across the whole media card instead of
 - 2026-10-29: Centered the now-playing title across the whole media card instead of

+ 1 - 1
lib/core/app_version.dart

@@ -1,2 +1,2 @@
 /// Keep this in sync with the `version` in pubspec.yaml.
 /// Keep this in sync with the `version` in pubspec.yaml.
-const String appVersion = '1.0.3';
+const String appVersion = '1.0.4';

+ 6 - 1
lib/data/models/system_status.dart

@@ -21,15 +21,20 @@ class SystemStatus {
 
 
 /// Current media-stream volume and its maximum.
 /// Current media-stream volume and its maximum.
 class VolumeInfo {
 class VolumeInfo {
-  const VolumeInfo({this.index = 0, this.max = 15});
+  const VolumeInfo({this.index = 0, this.max = 15, this.fixed = false});
 
 
   final int index;
   final int index;
   final int max;
   final int max;
 
 
+  /// True when Android delegates volume to a fixed hardware amplifier (common
+  /// on car head units). Apps cannot change that volume.
+  final bool fixed;
+
   factory VolumeInfo.fromMap(Map<dynamic, dynamic> map) {
   factory VolumeInfo.fromMap(Map<dynamic, dynamic> map) {
     return VolumeInfo(
     return VolumeInfo(
       index: (map['index'] as num?)?.toInt() ?? 0,
       index: (map['index'] as num?)?.toInt() ?? 0,
       max: (map['max'] as num?)?.toInt() ?? 15,
       max: (map['max'] as num?)?.toInt() ?? 15,
+      fixed: map['fixed'] == true,
     );
     );
   }
   }
 
 

+ 6 - 1
lib/features/home/system_controller.dart

@@ -33,6 +33,7 @@ class SystemController extends ChangeNotifier {
   VolumeInfo get volume => _volume;
   VolumeInfo get volume => _volume;
   int get volumePercent => _volume.percent;
   int get volumePercent => _volume.percent;
   bool get isMuted => _volume.index <= 0;
   bool get isMuted => _volume.index <= 0;
+  bool get isVolumeFixed => _volume.fixed;
   bool get isMusicPlaying => _musicPlaying;
   bool get isMusicPlaying => _musicPlaying;
 
 
   MediaPlayback get media => _media;
   MediaPlayback get media => _media;
@@ -70,7 +71,9 @@ class SystemController extends ChangeNotifier {
     _volumeBusy = true;
     _volumeBusy = true;
     try {
     try {
       final next = await _platform.getVolume();
       final next = await _platform.getVolume();
-      if (next.index != _volume.index || next.max != _volume.max) {
+      if (next.index != _volume.index ||
+          next.max != _volume.max ||
+          next.fixed != _volume.fixed) {
         _volume = next;
         _volume = next;
         if (next.index > 0) _lastNonZeroVolume = next.index;
         if (next.index > 0) _lastNonZeroVolume = next.index;
         _notify();
         _notify();
@@ -115,6 +118,7 @@ class SystemController extends ChangeNotifier {
   }
   }
 
 
   Future<void> setVolume(int index) async {
   Future<void> setVolume(int index) async {
+    if (_volume.fixed) return;
     final max = _volume.max <= 0 ? 15 : _volume.max;
     final max = _volume.max <= 0 ? 15 : _volume.max;
     final clamped = index.clamp(0, max);
     final clamped = index.clamp(0, max);
     _volume = VolumeInfo(index: clamped, max: max);
     _volume = VolumeInfo(index: clamped, max: max);
@@ -165,6 +169,7 @@ class SystemController extends ChangeNotifier {
   }
   }
 
 
   Future<void> toggleMute() async {
   Future<void> toggleMute() async {
+    if (_volume.fixed) return;
     if (_volume.index > 0) {
     if (_volume.index > 0) {
       _lastNonZeroVolume = _volume.index;
       _lastNonZeroVolume = _volume.index;
       await setVolume(0);
       await setVolume(0);

+ 83 - 61
lib/features/home/widgets/volume_card.dart

@@ -42,6 +42,7 @@ class _VolumeCardState extends State<VolumeCard> {
   int get _max => widget.volume.max <= 0 ? 15 : widget.volume.max;
   int get _max => widget.volume.max <= 0 ? 15 : widget.volume.max;
   int get _value => widget.volume.index.clamp(0, _max);
   int get _value => widget.volume.index.clamp(0, _max);
   bool get _muted => _value == 0;
   bool get _muted => _value == 0;
+  bool get _fixed => widget.volume.fixed;
   int get _percent => ((_value / _max) * 100).round();
   int get _percent => ((_value / _max) * 100).round();
 
 
   String get _nowPlayingLabel {
   String get _nowPlayingLabel {
@@ -118,79 +119,100 @@ class _VolumeCardState extends State<VolumeCard> {
                         min: 0,
                         min: 0,
                         max: _max.toDouble(),
                         max: _max.toDouble(),
                         divisions: _max <= 30 ? _max : null,
                         divisions: _max <= 30 ? _max : null,
-                        onChanged: (next) {
-                          setState(() => _dragValue = next);
-                          widget.onSetVolume(next.round());
-                        },
-                        onChangeEnd: (_) => setState(() => _dragValue = null),
+                        onChanged: _fixed
+                            ? null
+                            : (next) {
+                                setState(() => _dragValue = next);
+                                widget.onSetVolume(next.round());
+                              },
+                        onChangeEnd: _fixed
+                            ? null
+                            : (_) => setState(() => _dragValue = null),
                         semanticFormatterCallback: (value) =>
                         semanticFormatterCallback: (value) =>
                             '${(value / _max * 100).round()} percent',
                             '${(value / _max * 100).round()} percent',
                       ),
                       ),
                     ),
                     ),
                     SizedBox(height: compact ? 4 : 8),
                     SizedBox(height: compact ? 4 : 8),
-                    Row(
-                      children: [
-                        _StepButton(
-                          icon: Icons.remove_rounded,
-                          semanticLabel: 'Volume down',
-                          onTap: () => widget.onSetVolume(_value - 1),
-                          compact: compact,
+                    if (_fixed)
+                      Container(
+                        height: compact ? 36 : 40,
+                        alignment: Alignment.center,
+                        decoration: BoxDecoration(
+                          color: colors.surfaceStrong,
+                          borderRadius: BorderRadius.circular(999),
+                          border: Border.all(color: colors.border),
+                        ),
+                        child: Text(
+                          'Volume is controlled by the car',
+                          style: theme.textTheme.labelMedium?.copyWith(
+                            color: colors.inkMuted,
+                          ),
                         ),
                         ),
-                        SizedBox(width: compact ? 8 : 10),
-                        Expanded(
-                          child: Pressable(
-                            onTap: widget.onToggleMute,
-                            semanticLabel: _muted ? 'Unmute' : 'Mute',
-                            pressedScale: 0.97,
-                            child: Container(
-                              height: compact ? 36 : 40,
-                              decoration: BoxDecoration(
-                                color: _muted
-                                    ? colors.accentSoft
-                                    : colors.surfaceStrong,
-                                borderRadius: BorderRadius.circular(999),
-                                border: Border.all(
+                      )
+                    else
+                      Row(
+                        children: [
+                          _StepButton(
+                            icon: Icons.remove_rounded,
+                            semanticLabel: 'Volume down',
+                            onTap: () => widget.onSetVolume(_value - 1),
+                            compact: compact,
+                          ),
+                          SizedBox(width: compact ? 8 : 10),
+                          Expanded(
+                            child: Pressable(
+                              onTap: widget.onToggleMute,
+                              semanticLabel: _muted ? 'Unmute' : 'Mute',
+                              pressedScale: 0.97,
+                              child: Container(
+                                height: compact ? 36 : 40,
+                                decoration: BoxDecoration(
                                   color: _muted
                                   color: _muted
-                                      ? colors.accent.withValues(alpha: 0.25)
-                                      : colors.border,
-                                ),
-                              ),
-                              child: Row(
-                                mainAxisAlignment: MainAxisAlignment.center,
-                                children: [
-                                  AppIcon(
-                                    _muted
-                                        ? 'assets/icons/volume-x.svg'
-                                        : 'assets/icons/volume-2.svg',
-                                    size: compact ? 16 : 18,
+                                      ? colors.accentSoft
+                                      : colors.surfaceStrong,
+                                  borderRadius: BorderRadius.circular(999),
+                                  border: Border.all(
                                     color: _muted
                                     color: _muted
-                                        ? colors.accent
-                                        : colors.inkMuted,
-                                  ),
-                                  const SizedBox(width: 8),
-                                  Text(
-                                    _muted ? 'Unmute' : 'Mute',
-                                    style: theme.textTheme.labelMedium
-                                        ?.copyWith(
-                                          color: _muted
-                                              ? colors.accent
-                                              : colors.inkMuted,
-                                        ),
+                                        ? colors.accent.withValues(alpha: 0.25)
+                                        : colors.border,
                                   ),
                                   ),
-                                ],
+                                ),
+                                child: Row(
+                                  mainAxisAlignment: MainAxisAlignment.center,
+                                  children: [
+                                    AppIcon(
+                                      _muted
+                                          ? 'assets/icons/volume-x.svg'
+                                          : 'assets/icons/volume-2.svg',
+                                      size: compact ? 16 : 18,
+                                      color: _muted
+                                          ? colors.accent
+                                          : colors.inkMuted,
+                                    ),
+                                    const SizedBox(width: 8),
+                                    Text(
+                                      _muted ? 'Unmute' : 'Mute',
+                                      style: theme.textTheme.labelMedium
+                                          ?.copyWith(
+                                            color: _muted
+                                                ? colors.accent
+                                                : colors.inkMuted,
+                                          ),
+                                    ),
+                                  ],
+                                ),
                               ),
                               ),
                             ),
                             ),
                           ),
                           ),
-                        ),
-                        SizedBox(width: compact ? 8 : 10),
-                        _StepButton(
-                          icon: Icons.add_rounded,
-                          semanticLabel: 'Volume up',
-                          onTap: () => widget.onSetVolume(_value + 1),
-                          compact: compact,
-                        ),
-                      ],
-                    ),
+                          SizedBox(width: compact ? 8 : 10),
+                          _StepButton(
+                            icon: Icons.add_rounded,
+                            semanticLabel: 'Volume up',
+                            onTap: () => widget.onSetVolume(_value + 1),
+                            compact: compact,
+                          ),
+                        ],
+                      ),
                   ],
                   ],
                 ),
                 ),
               ),
               ),

+ 1 - 1
pubspec.yaml

@@ -1,7 +1,7 @@
 name: cardash_launcher
 name: cardash_launcher
 description: "CarDash — a calm, configurable Android car dashboard launcher."
 description: "CarDash — a calm, configurable Android car dashboard launcher."
 publish_to: "none"
 publish_to: "none"
-version: 1.0.3+15
+version: 1.0.4+16
 
 
 environment:
 environment:
   sdk: ^3.13.7
   sdk: ^3.13.7

+ 13 - 0
releases/README.md

@@ -1,5 +1,18 @@
 # CarDash releases
 # CarDash releases
 
 
+## 1.0.4 — Car volume fix
+
+- **File:** `cardash-android-1.0.4.apk`
+- **Version:** 1.0.4 (versionCode 16)
+- **ABIs:** arm64-v8a, armeabi-v7a, x86_64
+- **SHA-256:** `d26e5a3bd98c38e639edebea92e0e165da8245eccaca14973eb549569e6b6a5e`
+- **Signing:** project debug key (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`).
+- **Changes:** detects Android's fixed-volume car policy, disables the
+  misleading volume slider and explains that the vehicle amplifier owns the
+  level. On units that ignore absolute volume writes, CarDash now retries with
+  the same relative steps used by the hardware volume keys. Fixed-volume units
+  also pass the hardware keys through to the car instead of consuming them.
+
 ## 1.0.3 — Speed and temperature cards
 ## 1.0.3 — Speed and temperature cards
 
 
 - **File:** `cardash-android-1.0.3.apk`
 - **File:** `cardash-android-1.0.3.apk`

BIN
releases/cardash-android-1.0.4.apk


+ 1 - 0
releases/cardash-android-1.0.4.apk.sha256

@@ -0,0 +1 @@
+d26e5a3bd98c38e639edebea92e0e165da8245eccaca14973eb549569e6b6a5e  cardash-android-1.0.4.apk

+ 22 - 2
test/system_controller_test.dart

@@ -9,7 +9,9 @@ import 'package:flutter_test/flutter_test.dart';
 class _MediaPlatform implements LauncherPlatform {
 class _MediaPlatform implements LauncherPlatform {
   bool playing = false;
   bool playing = false;
   MediaPlayback media = const MediaPlayback();
   MediaPlayback media = const MediaPlayback();
+  VolumeInfo volume = const VolumeInfo(index: 8, max: 15);
   final List<String> keys = [];
   final List<String> keys = [];
+  final List<int> volumeWrites = [];
 
 
   @override
   @override
   Future<List<AppEntry>> listApps() async => const [];
   Future<List<AppEntry>> listApps() async => const [];
@@ -30,10 +32,12 @@ class _MediaPlatform implements LauncherPlatform {
   Future<SystemStatus> getStatus() async => const SystemStatus();
   Future<SystemStatus> getStatus() async => const SystemStatus();
 
 
   @override
   @override
-  Future<VolumeInfo> getVolume() async => const VolumeInfo(index: 8, max: 15);
+  Future<VolumeInfo> getVolume() async => volume;
 
 
   @override
   @override
-  Future<void> setVolume(int index) async {}
+  Future<void> setVolume(int index) async {
+    volumeWrites.add(index);
+  }
 
 
   @override
   @override
   Future<bool> isMusicPlaying() async => playing;
   Future<bool> isMusicPlaying() async => playing;
@@ -116,4 +120,20 @@ void main() {
     expect(controller.isMusicPlaying, isTrue);
     expect(controller.isMusicPlaying, isTrue);
     controller.stop();
     controller.stop();
   });
   });
+
+  test('fixed car volume ignores software volume changes', () async {
+    final platform = _MediaPlatform()
+      ..volume = const VolumeInfo(index: 100, max: 100, fixed: true);
+    final controller = SystemController(platform);
+    await controller.start();
+    addTearDown(controller.dispose);
+
+    expect(controller.isVolumeFixed, isTrue);
+    await controller.setVolume(25);
+    await controller.toggleMute();
+
+    expect(platform.volumeWrites, isEmpty);
+    expect(controller.volume.index, 100);
+    controller.stop();
+  });
 }
 }

+ 25 - 0
test/volume_card_test.dart

@@ -57,6 +57,31 @@ void main() {
     expect(find.text('Nothing playing'), findsOneWidget);
     expect(find.text('Nothing playing'), findsOneWidget);
   });
   });
 
 
+  testWidgets('fixed car volume disables the slider and explains why', (
+    tester,
+  ) async {
+    await tester.pumpWidget(
+      MaterialApp(
+        theme: AppTheme.dark(),
+        home: const Scaffold(
+          body: VolumeCard(
+            volume: VolumeInfo(index: 100, max: 100, fixed: true),
+            onSetVolume: _ignoreVolume,
+            onToggleMute: _ignore,
+            isMusicPlaying: false,
+            onPlayPause: _ignore,
+            media: MediaPlayback(),
+          ),
+        ),
+      ),
+    );
+
+    expect(find.text('100%'), findsOneWidget);
+    expect(find.text('Volume is controlled by the car'), findsOneWidget);
+    expect(tester.widget<Slider>(find.byType(Slider)).onChanged, isNull);
+    expect(find.text('Mute'), findsNothing);
+  });
+
   testWidgets('the ring switches between play and pause', (tester) async {
   testWidgets('the ring switches between play and pause', (tester) async {
     var playing = false;
     var playing = false;
     var taps = 0;
     var taps = 0;