Explorar o código

feat: add on-screen volume diagnostics

Show raw media volume, fixed-volume state, every audio stream level, and the last target/actual write when the Media volume label is long-pressed. Log the same values under CarDashVolume. Bump to 1.0.5+17.
Krasimir Kazakov hai 6 días
pai
achega
d9c8e697bd

+ 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, 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.
+- While CarDash is foreground, `MainActivity` consumes hardware volume keys and adjusts `STREAM_MUSIC` so they match the dashboard, unless `AudioManager.isVolumeFixed` is true; on fixed-volume car units the keys pass through to the vehicle amplifier and the on-screen slider is disabled with an explanation. `SystemBridge.setVolume` falls back to relative `adjustStreamVolume` steps when a unit ignores absolute `setStreamVolume`. Long-pressing **Media volume** toggles `SystemController.volumeDiagnostics`, which shows the raw `index/max`, fixed flag, all stream levels and the last write result; the native side logs the same data under `CarDashVolume`. Do not remove this path without providing another sync path; otherwise Android controls the ring stream when no media is playing.
 - `FLAG_KEEP_SCREEN_ON` is controlled by `DisplayController` from `display.keepScreenOn` (default on) through `SystemBridge`; do not hard-code it in `MainActivity` or move it to a wake lock.
 - `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.

+ 19 - 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.4.apk](releases/cardash-android-1.0.4.apk) — universal Android release
+- [cardash-android-1.0.5.apk](releases/cardash-android-1.0.5.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
@@ -213,6 +213,7 @@ first run shows a one-time tip pointing to this setting.
 | Mute pill | Toggle media mute |
 | Mute pill | Toggle media mute |
 | Slider or − / + | Set media volume |
 | Slider or − / + | Set media volume |
 | Hardware volume keys | Adjust the dashboard's media volume; the slider follows |
 | Hardware volume keys | Adjust the dashboard's media volume; the slider follows |
+| Long-press **Media volume** | Show/hide raw volume diagnostics while troubleshooting a car unit |
 
 
 The app hides the Android status and navigation bars while CarDash is in the
 The app hides the Android status and navigation bars while CarDash is in the
 foreground. Swipe from an edge to reveal them temporarily; they hide again when
 foreground. Swipe from an edge to reveal them temporarily; they hide again when
@@ -314,22 +315,23 @@ flutter test
 ./tool/check.sh
 ./tool/check.sh
 ```
 ```
 
 
-The 65 tests cover PIN hashing/persistence, settings persistence, theme/accent,
+The 66 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, 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
+metadata/progress, the marquee, the play/pause ring, fixed-volume handling,
+volume-write diagnostics, 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.5 universal APK is built from this tree and published; devices on 1.0.4
 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.
 
 
@@ -372,6 +374,10 @@ Android 15 AVD is left running headlessly after development; use
   CarDash disables the slider and explains why. Otherwise, if a unit ignores
   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
   absolute volume writes, CarDash falls back to the same relative steps as the
   hardware volume keys.
   hardware volume keys.
+- **Volume diagnostics** — long-press the **Media volume** label to show the raw
+  `index/max`, fixed-volume flag, every stream level, and the target/actual of
+  the last write. The same values are logged under the `CarDashVolume` logcat
+  tag.
 - **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.

+ 44 - 8
android/app/src/main/kotlin/com/wasp/cardash_launcher/SystemBridge.kt

@@ -7,6 +7,7 @@ import android.media.AudioManager
 import android.net.ConnectivityManager
 import android.net.ConnectivityManager
 import android.net.NetworkCapabilities
 import android.net.NetworkCapabilities
 import android.os.BatteryManager
 import android.os.BatteryManager
+import android.util.Log
 import android.view.KeyEvent
 import android.view.KeyEvent
 import android.view.WindowManager
 import android.view.WindowManager
 import io.flutter.plugin.common.BinaryMessenger
 import io.flutter.plugin.common.BinaryMessenger
@@ -18,9 +19,11 @@ class SystemBridge(private val activity: Activity) : MethodChannel.MethodCallHan
 
 
     companion object {
     companion object {
         const val CHANNEL = "com.wasp.cardash_launcher/system"
         const val CHANNEL = "com.wasp.cardash_launcher/system"
+        private const val VOLUME_TAG = "CarDashVolume"
     }
     }
 
 
     private var channel: MethodChannel? = null
     private var channel: MethodChannel? = null
+    private var lastVolumeLog: String? = null
 
 
     fun attach(messenger: BinaryMessenger) {
     fun attach(messenger: BinaryMessenger) {
         channel = MethodChannel(messenger, CHANNEL).also { it.setMethodCallHandler(this) }
         channel = MethodChannel(messenger, CHANNEL).also { it.setMethodCallHandler(this) }
@@ -98,11 +101,37 @@ class SystemBridge(private val activity: Activity) : MethodChannel.MethodCallHan
 
 
     private fun readVolume(): Map<String, Any?> {
     private fun readVolume(): Map<String, Any?> {
         val audio = audioManager()
         val audio = audioManager()
-        return mapOf(
-            "index" to audio.getStreamVolume(AudioManager.STREAM_MUSIC),
-            "max" to audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC),
-            "fixed" to audio.isVolumeFixed,
-        )
+        val snapshot =
+            mapOf(
+                "index" to streamVolume(audio, AudioManager.STREAM_MUSIC),
+                "max" to audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC),
+                "fixed" to audio.isVolumeFixed,
+                "streams" to
+                    mapOf(
+                        "music" to streamVolume(audio, AudioManager.STREAM_MUSIC),
+                        "voice" to streamVolume(audio, AudioManager.STREAM_VOICE_CALL),
+                        "ring" to streamVolume(audio, AudioManager.STREAM_RING),
+                        "alarm" to streamVolume(audio, AudioManager.STREAM_ALARM),
+                        "notif" to streamVolume(audio, AudioManager.STREAM_NOTIFICATION),
+                        "system" to streamVolume(audio, AudioManager.STREAM_SYSTEM),
+                        "dtmf" to streamVolume(audio, AudioManager.STREAM_DTMF),
+                        "access" to streamVolume(audio, AudioManager.STREAM_ACCESSIBILITY),
+                    ),
+            )
+        val log = snapshot.toString()
+        if (log != lastVolumeLog) {
+            lastVolumeLog = log
+            Log.i(VOLUME_TAG, "read $log")
+        }
+        return snapshot
+    }
+
+    private fun streamVolume(audio: AudioManager, stream: Int): Int {
+        return try {
+            audio.getStreamVolume(stream)
+        } catch (_: Throwable) {
+            -1
+        }
     }
     }
 
 
     private fun isMusicPlaying(): Boolean {
     private fun isMusicPlaying(): Boolean {
@@ -149,19 +178,26 @@ class SystemBridge(private val activity: Activity) : MethodChannel.MethodCallHan
             val audio = audioManager()
             val audio = audioManager()
             // A fixed-volume policy means the car amplifier owns the level;
             // A fixed-volume policy means the car amplifier owns the level;
             // software volume changes are intentionally ignored by Android.
             // software volume changes are intentionally ignored by Android.
-            if (audio.isVolumeFixed) return false
+            if (audio.isVolumeFixed) {
+                Log.i(VOLUME_TAG, "set $index rejected: fixed volume")
+                return false
+            }
 
 
             val max = audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
             val max = audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
             val target = index.coerceIn(0, max)
             val target = index.coerceIn(0, max)
             val before = audio.getStreamVolume(AudioManager.STREAM_MUSIC)
             val before = audio.getStreamVolume(AudioManager.STREAM_MUSIC)
             audio.setStreamVolume(AudioManager.STREAM_MUSIC, target, 0)
             audio.setStreamVolume(AudioManager.STREAM_MUSIC, target, 0)
             val after = audio.getStreamVolume(AudioManager.STREAM_MUSIC)
             val after = audio.getStreamVolume(AudioManager.STREAM_MUSIC)
+            Log.i(VOLUME_TAG, "set target=$target before=$before after=$after max=$max")
             if (after == target) return true
             if (after == target) return true
 
 
             // Some head units ignore absolute volume writes but accept the
             // Some head units ignore absolute volume writes but accept the
             // same relative steps as the hardware volume keys.
             // same relative steps as the hardware volume keys.
-            if (after == before) adjustTo(audio, target, after) else false
-        } catch (_: Throwable) {
+            val result = if (after == before) adjustTo(audio, target, after) else false
+            Log.i(VOLUME_TAG, "relative fallback result=$result now=${streamVolume(audio, AudioManager.STREAM_MUSIC)}")
+            result
+        } catch (error: Throwable) {
+            Log.i(VOLUME_TAG, "set $index failed: $error")
             false
             false
         }
         }
     }
     }

+ 9 - 3
docs/IMPLEMENTATION_PLAN.md

@@ -157,6 +157,7 @@ Android (Kotlin)
 | 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 |
 | M20 | Fixed-volume car detection; relative volume fallback; 1.0.4 | Done |
+| M21 | On-screen volume diagnostics and stream snapshots; 1.0.5 | Done |
 
 
 ## 5. Verification checklist
 ## 5. Verification checklist
 
 
@@ -171,6 +172,7 @@ Android (Kotlin)
 - [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] Fixed-volume car units disable the slider and pass hardware keys through; absolute-write failures fall back to relative stream steps.
+- [x] Volume diagnostics expose raw stream values and last-write results on screen and under the `CarDashVolume` logcat tag.
 - [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`).
@@ -209,14 +211,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; 65 tests pass.
+- [x] Yellow update dot appears on the Settings gear; 66 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] 65 tests pass, including the onboarding window and source preferences.
+- [x] 66 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.
@@ -319,10 +321,14 @@ 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; 65 tests pass.
+  1.0.3+15; 66 tests pass.
 - 2026-10-30: Added fixed-volume detection and a relative volume-step fallback
 - 2026-10-30: Added fixed-volume detection and a relative volume-step fallback
   for car units that ignore absolute `setStreamVolume`; fixed-volume sliders now
   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.
   explain that the vehicle amplifier owns the level. Bumped to 1.0.4+16.
+- 2026-10-30: Added a long-press volume diagnostics panel with all stream
+  indices and the last target/actual write, plus `CarDashVolume` logcat output,
+  to identify devices that ignore both absolute and relative volume writes.
+  Bumped to 1.0.5+17; 66 tests pass.
 - 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.4';
+const String appVersion = '1.0.5';

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

@@ -21,7 +21,12 @@ 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, this.fixed = false});
+  const VolumeInfo({
+    this.index = 0,
+    this.max = 15,
+    this.fixed = false,
+    this.streams = const {},
+  });
 
 
   final int index;
   final int index;
   final int max;
   final int max;
@@ -30,11 +35,24 @@ class VolumeInfo {
   /// on car head units). Apps cannot change that volume.
   /// on car head units). Apps cannot change that volume.
   final bool fixed;
   final bool fixed;
 
 
+  /// Raw indices for every stream, used by the on-screen diagnostics panel.
+  final Map<String, int> streams;
+
   factory VolumeInfo.fromMap(Map<dynamic, dynamic> map) {
   factory VolumeInfo.fromMap(Map<dynamic, dynamic> map) {
+    final rawStreams = map['streams'];
+    final streams = <String, int>{};
+    if (rawStreams is Map) {
+      rawStreams.forEach((key, value) {
+        if (key is String && value is num) {
+          streams[key] = value.toInt();
+        }
+      });
+    }
     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,
       fixed: map['fixed'] == true,
+      streams: streams,
     );
     );
   }
   }
 
 

+ 3 - 0
lib/features/home/home_screen.dart

@@ -244,6 +244,9 @@ class _HomeScreenState extends State<HomeScreen> {
                                   onPlayPause: () =>
                                   onPlayPause: () =>
                                       unawaited(_toggleMusic(context)),
                                       unawaited(_toggleMusic(context)),
                                   media: system.media,
                                   media: system.media,
+                                  diagnostics: system.volumeDiagnostics,
+                                  onToggleDiagnostics:
+                                      system.toggleVolumeDiagnostics,
                                 ),
                                 ),
                               ],
                               ],
                             ),
                             ),

+ 51 - 5
lib/features/home/system_controller.dart

@@ -25,6 +25,11 @@ class SystemController extends ChangeNotifier {
   bool _statusBusy = false;
   bool _statusBusy = false;
   bool _musicBusy = false;
   bool _musicBusy = false;
   bool _musicPlaying = false;
   bool _musicPlaying = false;
+  bool _volumeSetting = false;
+  bool _showVolumeDiagnostics = false;
+  int _volumeWriteId = 0;
+  int? _lastVolumeTarget;
+  VolumeInfo? _lastVolumeRead;
   MediaPlayback _media = const MediaPlayback();
   MediaPlayback _media = const MediaPlayback();
   bool _disposed = false;
   bool _disposed = false;
   int _lastNonZeroVolume = 0;
   int _lastNonZeroVolume = 0;
@@ -36,6 +41,24 @@ class SystemController extends ChangeNotifier {
   bool get isVolumeFixed => _volume.fixed;
   bool get isVolumeFixed => _volume.fixed;
   bool get isMusicPlaying => _musicPlaying;
   bool get isMusicPlaying => _musicPlaying;
 
 
+  String? get volumeDiagnostics {
+    if (!_showVolumeDiagnostics) return null;
+    final write = _lastVolumeTarget == null
+        ? 'none'
+        : '${_lastVolumeTarget!} -> ${_lastVolumeRead?.index ?? '?'}';
+    final streams = _volume.streams.entries
+        .map((entry) => '${entry.key} ${entry.value}')
+        .join('  ');
+    return 'raw ${_volume.index}/${_volume.max}  fixed ${_volume.fixed}\n'
+        'write $write\n'
+        '${streams.isEmpty ? 'streams unavailable' : streams}';
+  }
+
+  void toggleVolumeDiagnostics() {
+    _showVolumeDiagnostics = !_showVolumeDiagnostics;
+    _notify();
+  }
+
   MediaPlayback get media => _media;
   MediaPlayback get media => _media;
 
 
   bool get hasMediaAccess => _media.hasAccess;
   bool get hasMediaAccess => _media.hasAccess;
@@ -67,13 +90,15 @@ class SystemController extends ChangeNotifier {
   }
   }
 
 
   Future<void> _pollVolume() async {
   Future<void> _pollVolume() async {
-    if (_volumeBusy || !_active) return;
+    if (_volumeBusy || _volumeSetting || !_active) return;
     _volumeBusy = true;
     _volumeBusy = true;
     try {
     try {
       final next = await _platform.getVolume();
       final next = await _platform.getVolume();
+      if (_lastVolumeTarget != null) _lastVolumeRead = next;
       if (next.index != _volume.index ||
       if (next.index != _volume.index ||
           next.max != _volume.max ||
           next.max != _volume.max ||
-          next.fixed != _volume.fixed) {
+          next.fixed != _volume.fixed ||
+          !mapEquals(next.streams, _volume.streams)) {
         _volume = next;
         _volume = next;
         if (next.index > 0) _lastNonZeroVolume = next.index;
         if (next.index > 0) _lastNonZeroVolume = next.index;
         _notify();
         _notify();
@@ -118,13 +143,34 @@ 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);
+    if (_volume.fixed) {
+      _lastVolumeTarget = clamped;
+      _lastVolumeRead = _volume;
+      return;
+    }
+    final writeId = ++_volumeWriteId;
+    _lastVolumeTarget = clamped;
+    _volumeSetting = true;
+    _volume = VolumeInfo(index: clamped, max: max, streams: _volume.streams);
     if (clamped > 0) _lastNonZeroVolume = clamped;
     if (clamped > 0) _lastNonZeroVolume = clamped;
     _notify();
     _notify();
-    await _platform.setVolume(clamped);
+    try {
+      await _platform.setVolume(clamped);
+      if (writeId != _volumeWriteId) return;
+      final actual = await _platform.getVolume();
+      if (writeId != _volumeWriteId) return;
+      _lastVolumeRead = actual;
+      _volume = actual;
+      if (actual.index > 0) _lastNonZeroVolume = actual.index;
+      if (!actual.fixed && actual.index != clamped) {
+        _showVolumeDiagnostics = true;
+      }
+      _notify();
+    } finally {
+      if (writeId == _volumeWriteId) _volumeSetting = false;
+    }
   }
   }
 
 
   Future<void> adjustVolume(int delta) => setVolume(_volume.index + delta);
   Future<void> adjustVolume(int delta) => setVolume(_volume.index + delta);

+ 46 - 16
lib/features/home/widgets/volume_card.dart

@@ -22,6 +22,8 @@ class VolumeCard extends StatefulWidget {
     required this.onPlayPause,
     required this.onPlayPause,
     required this.media,
     required this.media,
     this.compact = false,
     this.compact = false,
+    this.diagnostics,
+    this.onToggleDiagnostics,
   });
   });
 
 
   final VolumeInfo volume;
   final VolumeInfo volume;
@@ -31,6 +33,8 @@ class VolumeCard extends StatefulWidget {
   final VoidCallback onPlayPause;
   final VoidCallback onPlayPause;
   final MediaPlayback media;
   final MediaPlayback media;
   final bool compact;
   final bool compact;
+  final String? diagnostics;
+  final VoidCallback? onToggleDiagnostics;
 
 
   @override
   @override
   State<VolumeCard> createState() => _VolumeCardState();
   State<VolumeCard> createState() => _VolumeCardState();
@@ -87,24 +91,30 @@ class _VolumeCardState extends State<VolumeCard> {
                   mainAxisSize: MainAxisSize.min,
                   mainAxisSize: MainAxisSize.min,
                   crossAxisAlignment: CrossAxisAlignment.start,
                   crossAxisAlignment: CrossAxisAlignment.start,
                   children: [
                   children: [
-                    Row(
-                      children: [
-                        Text(
-                          'Media volume',
-                          style: theme.textTheme.labelLarge?.copyWith(
-                            color: colors.inkMuted,
-                            fontSize: compact ? 12 : 13,
+                    GestureDetector(
+                      behavior: HitTestBehavior.opaque,
+                      onLongPress: widget.onToggleDiagnostics,
+                      child: Row(
+                        children: [
+                          Text(
+                            'Media volume',
+                            style: theme.textTheme.labelLarge?.copyWith(
+                              color: colors.inkMuted,
+                              fontSize: compact ? 12 : 13,
+                            ),
                           ),
                           ),
-                        ),
-                        const Spacer(),
-                        Text(
-                          '$_percent%',
-                          style: theme.textTheme.headlineMedium?.copyWith(
-                            fontSize: compact ? 24 : 28,
-                            fontFeatures: const [FontFeature.tabularFigures()],
+                          const Spacer(),
+                          Text(
+                            '$_percent%',
+                            style: theme.textTheme.headlineMedium?.copyWith(
+                              fontSize: compact ? 24 : 28,
+                              fontFeatures: const [
+                                FontFeature.tabularFigures(),
+                              ],
+                            ),
                           ),
                           ),
-                        ),
-                      ],
+                        ],
+                      ),
                     ),
                     ),
                     const SizedBox(height: 2),
                     const SizedBox(height: 2),
                     SliderTheme(
                     SliderTheme(
@@ -213,6 +223,26 @@ class _VolumeCardState extends State<VolumeCard> {
                           ),
                           ),
                         ],
                         ],
                       ),
                       ),
+                    if (widget.diagnostics != null) ...[
+                      const SizedBox(height: 8),
+                      Container(
+                        width: double.infinity,
+                        padding: const EdgeInsets.all(8),
+                        decoration: BoxDecoration(
+                          color: colors.surfaceStrong,
+                          borderRadius: BorderRadius.circular(10),
+                          border: Border.all(color: colors.border),
+                        ),
+                        child: Text(
+                          widget.diagnostics!,
+                          style: theme.textTheme.bodySmall?.copyWith(
+                            color: colors.inkMuted,
+                            fontSize: 10,
+                            height: 1.35,
+                          ),
+                        ),
+                      ),
+                    ],
                   ],
                   ],
                 ),
                 ),
               ),
               ),

+ 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.4+16
+version: 1.0.5+17
 
 
 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.5 — Volume diagnostics
+
+- **File:** `cardash-android-1.0.5.apk`
+- **Version:** 1.0.5 (versionCode 17)
+- **ABIs:** arm64-v8a, armeabi-v7a, x86_64
+- **SHA-256:** `d3fcd888d4b742a3c636d65126c9d0e4a9550a0d04db0eceef9cd6a18be0fcc2`
+- **Signing:** project debug key matching all releases.
+- **Changes:** adds a long-press **Media volume** diagnostics panel showing the
+  raw `index/max`, fixed-volume flag, every audio stream level, and the last
+  target/actual volume write. The same data is logged under `CarDashVolume`.
+  This release is for identifying car units that ignore both absolute and
+  relative volume writes.
+
 ## 1.0.4 — Car volume fix
 ## 1.0.4 — Car volume fix
 
 
 - **File:** `cardash-android-1.0.4.apk`
 - **File:** `cardash-android-1.0.4.apk`

BIN=BIN
releases/cardash-android-1.0.5.apk


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

@@ -0,0 +1 @@
+d3fcd888d4b742a3c636d65126c9d0e4a9550a0d04db0eceef9cd6a18be0fcc2  cardash-android-1.0.5.apk

+ 24 - 0
test/system_controller_test.dart

@@ -136,4 +136,28 @@ void main() {
     expect(controller.volume.index, 100);
     expect(controller.volume.index, 100);
     controller.stop();
     controller.stop();
   });
   });
+
+  test('a rejected volume write reverts and exposes diagnostics', () async {
+    final platform = _MediaPlatform()
+      ..volume = const VolumeInfo(
+        index: 100,
+        max: 100,
+        streams: {'music': 100, 'system': 8},
+      );
+    final controller = SystemController(platform);
+    await controller.start();
+    addTearDown(controller.dispose);
+
+    await controller.setVolume(25);
+
+    expect(platform.volumeWrites, contains(25));
+    expect(controller.volume.index, 100);
+    expect(controller.volumeDiagnostics, isNotNull);
+    expect(controller.volumeDiagnostics, contains('25 -> 100'));
+    expect(controller.volumeDiagnostics, contains('music 100'));
+
+    controller.toggleVolumeDiagnostics();
+    expect(controller.volumeDiagnostics, isNull);
+    controller.stop();
+  });
 }
 }