Forráskód Böngészése

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 napja
szülő
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.
 - 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.
-- 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.
 - 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.

+ 18 - 13
README.md

@@ -18,7 +18,7 @@ the normal Android launcher mechanism. No overlays, no root, no kiosk setup.
 ## Download
 
 - [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)
 
 Gogs limits release attachments to 32 MB, so the 54 MB universal APK is linked
@@ -314,22 +314,22 @@ flutter test
 ./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
 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
 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
 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 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.
+- **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
   (default on). It applies only while CarDash is the foreground window; with the
   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.
      */
     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)
             return true
         }
@@ -53,12 +55,23 @@ class MainActivity : FlutterActivity() {
     }
 
     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 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) {
         try {
             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(
             "index" to audio.getStreamVolume(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 {
         return try {
             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)
-            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) {
             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`. |
 | 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. |
+| 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. |
 | 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. |
@@ -155,6 +156,7 @@ Android (Kotlin)
 | 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 |
 | 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
 
@@ -168,6 +170,7 @@ Android (Kotlin)
 - [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] 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] 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`).
@@ -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.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] 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] 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] 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] 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] 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.
@@ -316,7 +319,10 @@ Android (Kotlin)
 - 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,
   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
   `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

+ 1 - 1
lib/core/app_version.dart

@@ -1,2 +1,2 @@
 /// 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.
 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 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) {
     return VolumeInfo(
       index: (map['index'] as num?)?.toInt() ?? 0,
       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;
   int get volumePercent => _volume.percent;
   bool get isMuted => _volume.index <= 0;
+  bool get isVolumeFixed => _volume.fixed;
   bool get isMusicPlaying => _musicPlaying;
 
   MediaPlayback get media => _media;
@@ -70,7 +71,9 @@ class SystemController extends ChangeNotifier {
     _volumeBusy = true;
     try {
       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;
         if (next.index > 0) _lastNonZeroVolume = next.index;
         _notify();
@@ -115,6 +118,7 @@ class SystemController extends ChangeNotifier {
   }
 
   Future<void> setVolume(int index) async {
+    if (_volume.fixed) return;
     final max = _volume.max <= 0 ? 15 : _volume.max;
     final clamped = index.clamp(0, max);
     _volume = VolumeInfo(index: clamped, max: max);
@@ -165,6 +169,7 @@ class SystemController extends ChangeNotifier {
   }
 
   Future<void> toggleMute() async {
+    if (_volume.fixed) return;
     if (_volume.index > 0) {
       _lastNonZeroVolume = _volume.index;
       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 _value => widget.volume.index.clamp(0, _max);
   bool get _muted => _value == 0;
+  bool get _fixed => widget.volume.fixed;
   int get _percent => ((_value / _max) * 100).round();
 
   String get _nowPlayingLabel {
@@ -118,79 +119,100 @@ class _VolumeCardState extends State<VolumeCard> {
                         min: 0,
                         max: _max.toDouble(),
                         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) =>
                             '${(value / _max * 100).round()} percent',
                       ),
                     ),
                     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
-                                      ? 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
-                                        ? 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
 description: "CarDash — a calm, configurable Android car dashboard launcher."
 publish_to: "none"
-version: 1.0.3+15
+version: 1.0.4+16
 
 environment:
   sdk: ^3.13.7

+ 13 - 0
releases/README.md

@@ -1,5 +1,18 @@
 # 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
 
 - **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 {
   bool playing = false;
   MediaPlayback media = const MediaPlayback();
+  VolumeInfo volume = const VolumeInfo(index: 8, max: 15);
   final List<String> keys = [];
+  final List<int> volumeWrites = [];
 
   @override
   Future<List<AppEntry>> listApps() async => const [];
@@ -30,10 +32,12 @@ class _MediaPlatform implements LauncherPlatform {
   Future<SystemStatus> getStatus() async => const SystemStatus();
 
   @override
-  Future<VolumeInfo> getVolume() async => const VolumeInfo(index: 8, max: 15);
+  Future<VolumeInfo> getVolume() async => volume;
 
   @override
-  Future<void> setVolume(int index) async {}
+  Future<void> setVolume(int index) async {
+    volumeWrites.add(index);
+  }
 
   @override
   Future<bool> isMusicPlaying() async => playing;
@@ -116,4 +120,20 @@ void main() {
     expect(controller.isMusicPlaying, isTrue);
     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);
   });
 
+  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 {
     var playing = false;
     var taps = 0;