Răsfoiți Sursa

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 1 săptămână în urmă
părinte
comite
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.
 - 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, 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.
 - 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.

+ 19 - 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.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)
 
 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 |
 | Slider or − / + | Set media volume |
 | 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
 foreground. Swipe from an edge to reveal them temporarily; they hide again when
@@ -314,22 +315,23 @@ flutter test
 ./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
 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, 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
 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
   absolute volume writes, CarDash falls back to the same relative steps as the
   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
   (default on). It applies only while CarDash is the foreground window; with the
   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.NetworkCapabilities
 import android.os.BatteryManager
+import android.util.Log
 import android.view.KeyEvent
 import android.view.WindowManager
 import io.flutter.plugin.common.BinaryMessenger
@@ -18,9 +19,11 @@ class SystemBridge(private val activity: Activity) : MethodChannel.MethodCallHan
 
     companion object {
         const val CHANNEL = "com.wasp.cardash_launcher/system"
+        private const val VOLUME_TAG = "CarDashVolume"
     }
 
     private var channel: MethodChannel? = null
+    private var lastVolumeLog: String? = null
 
     fun attach(messenger: BinaryMessenger) {
         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?> {
         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 {
@@ -149,19 +178,26 @@ class SystemBridge(private val activity: Activity) : MethodChannel.MethodCallHan
             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
+            if (audio.isVolumeFixed) {
+                Log.i(VOLUME_TAG, "set $index rejected: fixed volume")
+                return false
+            }
 
             val max = audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
             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)
+            Log.i(VOLUME_TAG, "set target=$target before=$before after=$after max=$max")
             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) {
+            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
         }
     }

+ 9 - 3
docs/IMPLEMENTATION_PLAN.md

@@ -157,6 +157,7 @@ Android (Kotlin)
 | 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 |
+| M21 | On-screen volume diagnostics and stream snapshots; 1.0.5 | Done |
 
 ## 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] 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] 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] 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`).
@@ -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.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; 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] 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] 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] 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.
@@ -319,10 +321,14 @@ 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; 65 tests pass.
+  1.0.3+15; 66 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: 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
   `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.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.
 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 max;
@@ -30,11 +35,24 @@ class VolumeInfo {
   /// on car head units). Apps cannot change that volume.
   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) {
+    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(
       index: (map['index'] as num?)?.toInt() ?? 0,
       max: (map['max'] as num?)?.toInt() ?? 15,
       fixed: map['fixed'] == true,
+      streams: streams,
     );
   }
 

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

@@ -244,6 +244,9 @@ class _HomeScreenState extends State<HomeScreen> {
                                   onPlayPause: () =>
                                       unawaited(_toggleMusic(context)),
                                   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 _musicBusy = false;
   bool _musicPlaying = false;
+  bool _volumeSetting = false;
+  bool _showVolumeDiagnostics = false;
+  int _volumeWriteId = 0;
+  int? _lastVolumeTarget;
+  VolumeInfo? _lastVolumeRead;
   MediaPlayback _media = const MediaPlayback();
   bool _disposed = false;
   int _lastNonZeroVolume = 0;
@@ -36,6 +41,24 @@ class SystemController extends ChangeNotifier {
   bool get isVolumeFixed => _volume.fixed;
   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;
 
   bool get hasMediaAccess => _media.hasAccess;
@@ -67,13 +90,15 @@ class SystemController extends ChangeNotifier {
   }
 
   Future<void> _pollVolume() async {
-    if (_volumeBusy || !_active) return;
+    if (_volumeBusy || _volumeSetting || !_active) return;
     _volumeBusy = true;
     try {
       final next = await _platform.getVolume();
+      if (_lastVolumeTarget != null) _lastVolumeRead = next;
       if (next.index != _volume.index ||
           next.max != _volume.max ||
-          next.fixed != _volume.fixed) {
+          next.fixed != _volume.fixed ||
+          !mapEquals(next.streams, _volume.streams)) {
         _volume = next;
         if (next.index > 0) _lastNonZeroVolume = next.index;
         _notify();
@@ -118,13 +143,34 @@ 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);
+    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;
     _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);

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

@@ -22,6 +22,8 @@ class VolumeCard extends StatefulWidget {
     required this.onPlayPause,
     required this.media,
     this.compact = false,
+    this.diagnostics,
+    this.onToggleDiagnostics,
   });
 
   final VolumeInfo volume;
@@ -31,6 +33,8 @@ class VolumeCard extends StatefulWidget {
   final VoidCallback onPlayPause;
   final MediaPlayback media;
   final bool compact;
+  final String? diagnostics;
+  final VoidCallback? onToggleDiagnostics;
 
   @override
   State<VolumeCard> createState() => _VolumeCardState();
@@ -87,24 +91,30 @@ class _VolumeCardState extends State<VolumeCard> {
                   mainAxisSize: MainAxisSize.min,
                   crossAxisAlignment: CrossAxisAlignment.start,
                   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),
                     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
 description: "CarDash — a calm, configurable Android car dashboard launcher."
 publish_to: "none"
-version: 1.0.4+16
+version: 1.0.5+17
 
 environment:
   sdk: ^3.13.7

+ 13 - 0
releases/README.md

@@ -1,5 +1,18 @@
 # 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
 
 - **File:** `cardash-android-1.0.4.apk`

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);
     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();
+  });
 }