Parcourir la source

fix: control car volume through the FYT/Syu MCU

On FYT/Syu head units the media stream is not what the amplifier plays:
writes were accepted and ignored, and the card immediately trusted the
old read-back, so the slider snapped back to 100%. Bind the car's
com.syu.ms toolkit service, drive the sound module's master volume
(C_VOL, 0-36) and follow the level the MCU reports. Keep an unconfirmed
write for 1.6 s and mark it with `?` instead of reverting it. Add a
FLAG_SHOW_UI fallback for vendor panels and expose the control source,
minimum and vendor state in the long-press diagnostics.

Release 1.2.2+22, including the Jellyfin favorites, playing-album Home
shortcut and streaming-quality setting. 149 tests pass; analyze and the
debug/release builds succeed. The MCU volume path still needs the
real-car test.
Krasimir Kazakov il y a 5 jours
Parent
commit
85e15bb893

+ 5 - 3
AGENTS.md

@@ -95,7 +95,9 @@ 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 shortcut grid is bottom-anchored so a short row sits on the bottom edge; keep that behavior when changing layouts. 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`. 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.
+- 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`, then to a `FLAG_SHOW_UI` write that may reach the vendor's own volume panel. Long-pressing **Media volume** toggles `SystemController.volumeDiagnostics`, which shows the raw `index/max`/`min`, fixed flag, control source, vendor state, all stream levels and the last write result; the native side logs the same data under `CarDashVolume`. Do not remove this path without providing another sync path; otherwise Android controls the ring stream when no media is playing.
+- FYT/Syu head units (Joying, Junsun, Teyes, Atoto and similar Chinese firmware) own the amplifier in the MCU, so the Android media stream is not what the driver hears. `SyuVolumeBridge` binds `com.syu.ms` (`app.ToolkitService`, action `com.syu.ms.toolkit`), fetches module 4 (`getRemoteModule`, transaction 1) and sends `cmd(0, [level])` for the absolute master volume (command 0 = `FinalSound.C_VOL`, range 0–36); it also subscribes to update 2 (level) and 3 (mute), so knob changes and slider changes stay in sync. `SystemBridge.readVolume` reports `source = syu` while that bridge is connected. `MainActivity`'s hardware-key path is unchanged: the car's own knob and steering-wheel volume are handled by the MCU, not by injecting extra steps. No permission, root or manifest change is needed; if the package, service or module is missing the bridge stays disconnected and the `AudioManager` path is used. Never make the vendor bind a hard dependency, and never let a failed binder call crash the app.
+- A write that the car does not confirm must not snap the slider back to the old value: `SystemController` keeps the requested level for a 1.6 s settle window, then marks it unconfirmed (the card shows `25%?`) until the read changes. Do not restore the old immediate read-back revert; that is the "it always goes back" bug on units whose read does not follow writes.
 - `FLAG_KEEP_SCREEN_ON` is controlled by `DisplayController` from `display.keepScreenOn` (default on) through `SystemBridge`; do not hard-code it in `MainActivity` or move it to a wake lock.
 - The Music and Jellyfin equalizers are exclusive. The Jellyfin tile animates only while `JellyfinPlaybackController.playing`; the Music tile animates only when the system reports media playing, Jellyfin is not playing, and the active system session is not CarDash itself (`system.media.packageName != appPackageName`). The package guard prevents the Music tile flashing while a Jellyfin pause propagates through the 900 ms system poll. Jellyfin title/progress come from the native playback controller even without notification access; other apps use the notification-listener component and active MediaSession. Never read or store notification content.
 - 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.
@@ -104,8 +106,8 @@ notifyListeners();
 - Jellyfin is optional: Settings → Jellyfin owns pairing, the Home switch lives in General → Home buttons, and the Home tile opens the dashboard/player. If a Jellyfin album is active, the tile opens that album's page with the dashboard underneath; Back then lands on Albums. Quick Connect is the only sign-in path: the user enters the code in Jellyfin, and CarDash keeps the returned access token in secure storage and reuses it on the next start. A rejected token or server error disables the integration, clears the token and shows **Pair again**; an unreachable server keeps the token and retries with 5–60 s backoff, so never clear the token for a network error. `JellyfinController.configured` gates the Home switch/tile (saved URL + token, survives outages); `connected` is for network actions. The Home slot default is off, counts toward the 16-button cap, is cleared on disconnect, and has no app assignment or long-press. Do not add a username/password form.
 - Jellyfin 12.2 API contract: `/Items?userId=...` (there is no `/Users/{userId}/Items` path); `/Genres` requires `parentId` of a `music` view from `/UserViews`; `genres` is pipe-delimited and OR-ed; `/Items/Latest` returns a bare array; auth is the `Authorization: MediaBrowser Client=..., Device=..., DeviceId=..., Version=..., Token=...` header (`X-Emby-Token` and `api_key` were rejected on 12.2). Album `UserData` is not updated by track playback, so "recently played" is derived from played Audio tracks sorted by `DatePlayed`, deduped by `AlbumId`, and capped at 6. Stream URLs use `/Audio/{id}/universal`; the native data source adds the auth header and the token never goes in a URL or log. Favorites are `GET /Items?userId=...&filters=IsFavorite&includeItemTypes=MusicAlbum&recursive=true&enableUserData=true`; toggle with `POST`/`DELETE /UserFavoriteItems/{itemId}?userId=...` (not `/Users/{userId}/FavoriteItems`). The album's `UserData.IsFavorite` is parsed and re-checked on the album page; the Favorites tab refreshes on selection and after an album page closes.
 - Jellyfin playback is Media3 1.11.1 in `JellyfinPlaybackService` (foreground `mediaPlayback`). `JellyfinPlaybackController` sends the queue, polls `getState` every ~700 ms, and keeps the Home card alive; the service owns the `MediaSession`, audio focus and `JellyfinPlaybackReporter`. Focus loss pauses and keeps position, a transient loss resumes on gain, a permanent loss abandons focus and does not resume. The silent `jellyfin_playback` notification needs `POST_NOTIFICATIONS` on Android 13+; the Settings card's **Allow** row requests it, and playback still works when denied. The service keeps playing while CarDash is backgrounded.
-- Jellyfin streaming quality is persisted as `jellyfin.audioQuality` (`JellyfinAudioQuality`). Direct (default) omits `MaxStreamingBitrate` and advertises the source codecs; 320/256/192/128 kbps set `Container=mp3`, `AudioCodec=mp3`, `TranscodingContainer=mp3` and `MaxStreamingBitrate`, asking Jellyfin to transcode. `JellyfinLibraryService` reads `audioQualityProvider` when a queue is built, so a change applies to the next album. The dropdown is staged for the next build; do not claim it is in 1.2.1.
-- Dashboard layout: the controller asks for 6 albums per row; the screen shows 6 when each of six cards is at least 160 px wide, otherwise 5. Album names/artists share the album art's left edge, the album page cover is top-aligned with the track list/controls, and selecting a Random album re-rolls the Random row after playback starts. The **Favorites** tab sits between Albums and Artists (Artists stays disabled), uses the same 5/6 columns in a scrollable multi-row grid, and shows `No favorites` centered when empty. The album-page heart toggles the server favorite; the Favorites tab reloads after the album page closes. These favorites/Home-shortcut changes are staged for the next build, not in 1.2.1. The Home equalizer is exclusive: Jellyfin playing → Jellyfin tile only; another app playing while Jellyfin is paused → Music tile only. The Home `VolumeCard` prefers the Jellyfin state while it is playing, or while it has a queue and no other app is playing; otherwise it uses the system session/media keys.
+- Jellyfin streaming quality is persisted as `jellyfin.audioQuality` (`JellyfinAudioQuality`). Direct (default) omits `MaxStreamingBitrate` and advertises the source codecs; 320/256/192/128 kbps set `Container=mp3`, `AudioCodec=mp3`, `TranscodingContainer=mp3` and `MaxStreamingBitrate`, asking Jellyfin to transcode. `JellyfinLibraryService` reads `audioQualityProvider` when a queue is built, so a change applies to the next album. The dropdown ships in 1.2.2.
+- Dashboard layout: the controller asks for 6 albums per row; the screen shows 6 when each of six cards is at least 160 px wide, otherwise 5. Album names/artists share the album art's left edge, the album page cover is top-aligned with the track list/controls, and selecting a Random album re-rolls the Random row after playback starts. The **Favorites** tab sits between Albums and Artists (Artists stays disabled), uses the same 5/6 columns in a scrollable multi-row grid, and shows `No favorites` centered when empty. The album-page heart toggles the server favorite; the Favorites tab reloads after the album page closes. These favorites/Home-shortcut changes ship in 1.2.2. The Home equalizer is exclusive: Jellyfin playing → Jellyfin tile only; another app playing while Jellyfin is paused → Music tile only. The Home `VolumeCard` prefers the Jellyfin state while it is playing, or while it has a queue and no other app is playing; otherwise it uses the system session/media keys.
 - Updates use a non-blocking public Gogs release check. The Update action downloads the APK to cache and opens Android’s package installer; never install silently. REQUEST_INSTALL_PACKAGES + one-time unknown-apps permission is required. Show the yellow dot on the Settings gear whenever `UpdateController.updateAvailable` is true.
 - ADB installs: always try at most twice per connected device. If both attempts fail, stop silently rather than looping or switching install methods.
 - OBD is Bluetooth Classic SPP, bonded-only and read-only, and still needs a real-car test; weather uses Open-Meteo and one-shot location and stores no coordinates.

+ 20 - 13
README.md

@@ -19,7 +19,8 @@ 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.2.1.apk](releases/cardash-android-1.2.1.apk) — universal Android release
+- [cardash-android-1.2.2.apk](releases/cardash-android-1.2.2.apk) — universal Android release
+- [cardash-android-1.2.1.apk](releases/cardash-android-1.2.1.apk) — previous release
 - SHA-256 and build details: [releases/README.md](releases/README.md)
 
 Gogs limits release attachments to 32 MB, so the 59 MB universal APK is linked
@@ -433,10 +434,12 @@ real 12.2 server (Quick Connect, grouped genres, 6/5-card dashboard, album page,
 background playback, notification, media keys, exclusive equalizers, audio-focus
 handover, Random re-shuffle) and against a local 12.2 container. 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. The 1.2.1
-universal APK is the current published release; the streaming-quality setting,
-Favorites and the playing-album Home shortcut are staged for the next build. The
-emulator has verified the in-app update path.
+is available on this machine; socket behavior still needs a car test. The 1.2.2
+universal APK is the current published release. It adds the FYT/Syu MCU volume
+bridge and the no-snap-back volume handling, and includes the streaming-quality
+setting, Favorites and the playing-album Home shortcut. The emulator has
+verified the in-app update path; the MCU volume path still needs the real-car
+test.
 
 ## Troubleshooting the emulator
 
@@ -472,15 +475,19 @@ 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.
+- **Volume control** prefers the car's own MCU module on FYT/Syu head units
+  (Joying, Junsun, Teyes, Atoto and similar Chinese firmware). When the
+  `com.syu.ms` sound service is present, CarDash sends it the absolute master
+  level (0–36) and follows the level the MCU reports, including changes made
+  with the car's knob. On other units it writes the media stream. A fixed-volume
+  policy disables the slider with an explanation; ignored absolute writes fall
+  back to relative steps and then to a write that asks Android to show its
+  volume panel. If a unit still reports the old level after a write, the card
+  keeps the requested level and marks it with `?` instead of snapping back.
 - **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.
+  `index/max`, minimum, fixed-volume flag, control source (`system` or `syu`),
+  the vendor bridge state, 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.

+ 60 - 4
android/app/src/main/kotlin/com/wasp/cardash_launcher/SystemBridge.kt

@@ -24,12 +24,15 @@ class SystemBridge(private val activity: Activity) : MethodChannel.MethodCallHan
 
     private var channel: MethodChannel? = null
     private var lastVolumeLog: String? = null
+    private val syu = SyuVolumeBridge(activity.applicationContext)
 
     fun attach(messenger: BinaryMessenger) {
+        syu.start()
         channel = MethodChannel(messenger, CHANNEL).also { it.setMethodCallHandler(this) }
     }
 
     fun dispose() {
+        syu.dispose()
         channel?.setMethodCallHandler(null)
         channel = null
     }
@@ -101,11 +104,33 @@ class SystemBridge(private val activity: Activity) : MethodChannel.MethodCallHan
 
     private fun readVolume(): Map<String, Any?> {
         val audio = audioManager()
+        // Until the MCU reports its current level we keep showing Android's
+        // stream value; an absolute write based on the wrong scale could jump
+        // the amplifier to full volume.
+        val syuActive = syu.connected && syu.level != null
+        val max = if (syuActive) {
+            SyuVolumeBridge.MAX_LEVEL
+        } else {
+            audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
+        }
+        val min = if (syuActive) 0 else minStreamVolume(audio, AudioManager.STREAM_MUSIC)
+        val index = if (syuActive) {
+            syu.level ?: 0
+        } else {
+            streamVolume(audio, AudioManager.STREAM_MUSIC)
+        }
         val snapshot =
             mapOf(
-                "index" to streamVolume(audio, AudioManager.STREAM_MUSIC),
-                "max" to audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC),
-                "fixed" to audio.isVolumeFixed,
+                "index" to index,
+                "max" to max,
+                "min" to min,
+                "fixed" to (if (syuActive) false else audio.isVolumeFixed),
+                "source" to when {
+                    syuActive -> "syu"
+                    syu.connected -> "syu-wait"
+                    else -> "system"
+                },
+                "vendor" to syu.status,
                 "streams" to
                     mapOf(
                         "music" to streamVolume(audio, AudioManager.STREAM_MUSIC),
@@ -134,6 +159,14 @@ class SystemBridge(private val activity: Activity) : MethodChannel.MethodCallHan
         }
     }
 
+    private fun minStreamVolume(audio: AudioManager, stream: Int): Int {
+        return try {
+            audio.getStreamMinVolume(stream)
+        } catch (_: Throwable) {
+            0
+        }
+    }
+
     private fun isMusicPlaying(): Boolean {
         return try {
             audioManager().isMusicActive
@@ -174,6 +207,15 @@ class SystemBridge(private val activity: Activity) : MethodChannel.MethodCallHan
     }
 
     private fun setVolume(index: Int): Boolean {
+        // On FYT/Syu units the amplifier ignores Android's stream volume, so
+        // hand the change to the MCU app first.
+        if (syu.connected && syu.level != null) {
+            val target = index.coerceIn(0, SyuVolumeBridge.MAX_LEVEL)
+            if (syu.setLevel(target)) {
+                Log.i(VOLUME_TAG, "set syu target=$target")
+                return true
+            }
+        }
         return try {
             val audio = audioManager()
             // A fixed-volume policy means the car amplifier owns the level;
@@ -187,10 +229,24 @@ class SystemBridge(private val activity: Activity) : MethodChannel.MethodCallHan
             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)
+            var 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 == before) {
+                // Some head units only route a volume change through their own
+                // panel; asking Android to show it uses a different code path
+                // than a silent write.
+                audio.setStreamVolume(
+                    AudioManager.STREAM_MUSIC,
+                    target,
+                    AudioManager.FLAG_SHOW_UI,
+                )
+                after = audio.getStreamVolume(AudioManager.STREAM_MUSIC)
+                Log.i(VOLUME_TAG, "set with ui target=$target after=$after")
+                if (after == target) return true
+            }
+
             // Some head units ignore absolute volume writes but accept the
             // same relative steps as the hardware volume keys.
             val result = if (after == before) adjustTo(audio, target, after) else false

+ 266 - 0
android/app/src/main/kotlin/com/wasp/cardash_launcher/SyuVolumeBridge.kt

@@ -0,0 +1,266 @@
+package com.wasp.cardash_launcher
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.ServiceConnection
+import android.os.Binder
+import android.os.Handler
+import android.os.IBinder
+import android.os.Looper
+import android.os.Parcel
+import android.util.Log
+
+/**
+ * Master volume for FYT/Syu head units (Joying, Junsun, Teyes, Atoto and other
+ * Chinese firmware built on the Syu MCU).
+ *
+ * On these units the media stream is not what the amplifier uses: Android's
+ * `AudioManager` writes are accepted by the framework but the actual gain is
+ * owned by the MCU, so apps cannot change the audible volume that way. The
+ * stock `com.syu.ms` system app exposes the sound module over its binder
+ * `com.syu.ms.toolkit` service and applies command 1 as the absolute master
+ * volume (0..[MAX_LEVEL]). It is exported without a permission on these
+ * builds, which is how other third-party launchers control the volume too.
+ *
+ * Everything here is best-effort: when the package or the module is missing
+ * the bridge stays disconnected and [SystemBridge] falls back to the regular
+ * `AudioManager` path. No root, no extra permission, no manifest change.
+ */
+class SyuVolumeBridge(private val context: Context) {
+
+    companion object {
+        private const val TAG = "CarDashVolume"
+        private const val PACKAGE = "com.syu.ms"
+        private const val SERVICE = "app.ToolkitService"
+        private const val ACTION = "com.syu.ms.toolkit"
+        private const val MODULE_SOUND = 4
+        private const val CMD_VOLUME = 0
+        private const val UPDATE_VOLUME = 2
+        private const val UPDATE_MUTE = 3
+        private const val TOOLKIT_DESCRIPTOR = "com.syu.ipc.IRemoteToolkit"
+        private const val MODULE_DESCRIPTOR = "com.syu.ipc.IRemoteModule"
+        private const val CALLBACK_DESCRIPTOR = "com.syu.ipc.IModuleCallback"
+        private const val RETRY_MS = 5_000L
+        private const val CONNECT_TIMEOUT_MS = 4_000L
+
+        /** FYT's default master volume range; the MCU clamps lower on some cars. */
+        const val MAX_LEVEL = 36
+    }
+
+    @Volatile
+    var level: Int? = null
+        private set
+
+    @Volatile
+    var muted: Boolean = false
+        private set
+
+    @Volatile
+    var connected: Boolean = false
+        private set
+
+    /** Short state label surfaced in the on-screen diagnostics. */
+    @Volatile
+    var status: String = "off"
+        private set
+
+    private val handler = Handler(Looper.getMainLooper())
+    private var module: IBinder? = null
+    private var bound = false
+    private var disposed = false
+
+    private val callback = object : Binder() {
+        init {
+            attachInterface(null, CALLBACK_DESCRIPTOR)
+        }
+
+        override fun onTransact(
+            code: Int,
+            data: Parcel,
+            reply: Parcel?,
+            flags: Int,
+        ): Boolean {
+            if (code == 1) {
+                try {
+                    data.enforceInterface(CALLBACK_DESCRIPTOR)
+                    val update = data.readInt()
+                    val ints = data.createIntArray()
+                    data.createFloatArray()
+                    data.createStringArray()
+                    when (update) {
+                        UPDATE_VOLUME -> {
+                            val next = ints?.firstOrNull()
+                            if (next != null) {
+                                level = next.coerceIn(0, MAX_LEVEL)
+                                muted = false
+                            }
+                        }
+                        UPDATE_MUTE -> muted = ints?.firstOrNull() == 1
+                    }
+                } catch (_: Throwable) {
+                    // A malformed vendor callback must never take down the app.
+                }
+                reply?.writeNoException()
+                return true
+            }
+            return super.onTransact(code, data, reply, flags)
+        }
+    }
+
+    private val connection = object : ServiceConnection {
+        override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
+            handler.removeCallbacks(timeout)
+            if (service == null) {
+                status = "no-service"
+                return
+            }
+            val sound = try {
+                remoteModule(service)
+            } catch (error: Throwable) {
+                Log.i(TAG, "syu module lookup failed: $error")
+                null
+            }
+            if (sound == null) {
+                status = "no-module"
+                return
+            }
+            module = sound
+            connected = true
+            status = "ready"
+            try {
+                register(sound, UPDATE_VOLUME)
+                register(sound, UPDATE_MUTE)
+            } catch (error: Throwable) {
+                Log.i(TAG, "syu volume subscribe failed: $error")
+            }
+            Log.i(TAG, "syu sound module ready, level=$level")
+        }
+
+        override fun onServiceDisconnected(name: ComponentName?) {
+            // The binding is kept; Android reconnects when the system app is back.
+            module = null
+            connected = false
+            status = "disconnected"
+            Log.i(TAG, "syu sound module disconnected")
+        }
+
+        override fun onBindingDied(name: ComponentName?) {
+            module = null
+            connected = false
+            status = "died"
+            if (bound) {
+                try {
+                    context.unbindService(this)
+                } catch (_: Throwable) {
+                    // Already detached.
+                }
+            }
+            bound = false
+            handler.postDelayed({ if (!disposed) start() }, RETRY_MS)
+        }
+
+        override fun onNullBinding(name: ComponentName?) {
+            module = null
+            connected = false
+            status = "null-binding"
+        }
+    }
+
+    private val timeout = Runnable {
+        if (!connected) status = "timeout"
+    }
+
+    /** Starts the binding once; safe to call from every attach. */
+    fun start() {
+        if (disposed || bound) return
+        status = "binding"
+        val intent = Intent(ACTION).setComponent(ComponentName(PACKAGE, SERVICE))
+        bound = try {
+            context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
+        } catch (error: Throwable) {
+            Log.i(TAG, "syu bind failed: $error")
+            false
+        }
+        if (!bound) {
+            status = "unavailable"
+            return
+        }
+        handler.postDelayed(timeout, CONNECT_TIMEOUT_MS)
+    }
+
+    /** Absolute master volume, 0..[MAX_LEVEL]. False when the bridge is down. */
+    fun setLevel(value: Int): Boolean {
+        val binder = module ?: return false
+        val target = value.coerceIn(0, MAX_LEVEL)
+        val data = Parcel.obtain()
+        val reply = Parcel.obtain()
+        return try {
+            data.writeInterfaceToken(MODULE_DESCRIPTOR)
+            data.writeInt(CMD_VOLUME)
+            data.writeIntArray(intArrayOf(target))
+            data.writeFloatArray(null)
+            data.writeStringArray(null)
+            binder.transact(1, data, reply, 0)
+            reply.readException()
+            // Do not optimistically record the value: the update callback is
+            // the only proof that the MCU actually applied this level.
+            true
+        } catch (error: Throwable) {
+            Log.i(TAG, "syu set $target failed: $error")
+            false
+        } finally {
+            data.recycle()
+            reply.recycle()
+        }
+    }
+
+    fun dispose() {
+        disposed = true
+        handler.removeCallbacksAndMessages(null)
+        if (bound) {
+            try {
+                context.unbindService(connection)
+            } catch (_: Throwable) {
+                // Already detached.
+            }
+        }
+        bound = false
+        module = null
+        connected = false
+        status = "off"
+    }
+
+    /** Calls `IRemoteToolkit.getRemoteModule(int)` (transaction 1). */
+    private fun remoteModule(toolkit: IBinder): IBinder? {
+        val data = Parcel.obtain()
+        val reply = Parcel.obtain()
+        return try {
+            data.writeInterfaceToken(TOOLKIT_DESCRIPTOR)
+            data.writeInt(MODULE_SOUND)
+            if (!toolkit.transact(1, data, reply, 0)) return null
+            reply.readException()
+            reply.readStrongBinder()
+        } finally {
+            reply.recycle()
+            data.recycle()
+        }
+    }
+
+    /** Calls `IRemoteModule.register(IModuleCallback, int, int)` (transaction 3). */
+    private fun register(binder: IBinder, update: Int) {
+        val data = Parcel.obtain()
+        val reply = Parcel.obtain()
+        try {
+            data.writeInterfaceToken(MODULE_DESCRIPTOR)
+            data.writeStrongBinder(callback)
+            data.writeInt(update)
+            data.writeInt(1) // ask for the current value immediately
+            binder.transact(3, data, reply, 0)
+            reply.readException()
+        } finally {
+            data.recycle()
+            reply.recycle()
+        }
+    }
+}

+ 18 - 4
docs/IMPLEMENTATION_PLAN.md

@@ -67,7 +67,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. |
+| Volume control | FYT/Syu MCU sound module, then absolute `setStreamVolume`, UI-panel write, relative `adjustStreamVolume`, fixed-policy detection | Car amplifiers ignore the Android stream on many Chinese units; the MCU module is the only app-reachable master volume there. |
 | 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. |
@@ -331,7 +331,7 @@ Android (Kotlin)
 
 ## 8. Change log
 
-- 2026-10-31 (unreleased): adds a Jellyfin **Favorites** tab between Albums and
+- 2026-10-31: adds a Jellyfin **Favorites** tab between Albums and
   Artists, backed by the Jellyfin favorites API; a scrollable 5/6-column grid and
   a centered `No favorites` empty state. The album page gains a heart that sets
   or unsets the server favorite, and the Favorites tab refreshes when you return.
@@ -339,8 +339,7 @@ Android (Kotlin)
   underneath, so Back returns to Albums instead of Home. Also adds a **Streaming
   quality** dropdown: Direct (default) or 320/256/192/128 kbps MP3 transcoding.
   The quality is persisted in `SettingsRepository`, read when the next album
-  queue is built, and shown in the Jellyfin settings card. Staged for the next
-  version; no APK or version bump has been made. 149 tests pass.
+  queue is built, and shown in the Jellyfin settings card. Included in 1.2.2.
 - 2026-10-31: CarDash 1.2.1+21 fixes the brief Music-tile equalizer flash after
   pausing Jellyfin from the Home card. The Music equalizer now ignores
   CarDash's own stale system MediaSession while a Jellyfin pause propagates, and
@@ -443,3 +442,18 @@ Android (Kotlin)
   selected or OBD speed off) now requests location permission and retries the
   GPS fallback; a denied request leaves the switch on and shows an explanation.
   The normal resume path still never prompts. 89 tests pass.
+- 2026-11-01: Volume against a real FYT/Syu head unit. CarDash now binds the
+  car's `com.syu.ms` toolkit service and drives the sound module's absolute
+  master volume (command 0, module 4, 0–36), so the on-screen slider changes the
+  amplifier instead of the unused Android media stream. The car's own knob and
+  steering-wheel controls stay independent; the Android key path is unchanged.
+  On other units the existing absolute/relative/UI-panel fallbacks remain.
+  Reads that never follow a write no longer snap the slider back: the requested
+  level is kept and shown with a `?`, and the diagnostics panel gained the
+  minimum, control source and vendor state. No new permission, no root and no
+  manifest change.
+- 2026-11-01: CarDash 1.2.2+22 releases the car-volume work together with the
+  Jellyfin favorites/active-album shortcut and streaming-quality setting. The
+  volume card now prefers the FYT/Syu MCU master level, keeps an unconfirmed
+  write instead of snapping back, and exposes the control source in diagnostics.
+  149 tests pass; release `releases/cardash-android-1.2.2.apk`.

+ 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.2.1';
+const String appVersion = '1.2.2';

+ 41 - 0
lib/data/models/system_status.dart

@@ -24,13 +24,20 @@ class VolumeInfo {
   const VolumeInfo({
     this.index = 0,
     this.max = 15,
+    this.min = 0,
     this.fixed = false,
     this.streams = const {},
+    this.source = 'system',
+    this.vendor = '',
   });
 
   final int index;
   final int max;
 
+  /// Lowest index Android accepts for the stream. When it equals [max] the
+  /// unit exposes no usable range at all.
+  final int min;
+
   /// True when Android delegates volume to a fixed hardware amplifier (common
   /// on car head units). Apps cannot change that volume.
   final bool fixed;
@@ -38,6 +45,13 @@ class VolumeInfo {
   /// Raw indices for every stream, used by the on-screen diagnostics panel.
   final Map<String, int> streams;
 
+  /// Which backend produced [index]: `system` for `AudioManager`, `syu` when
+  /// the car's MCU volume module is in control.
+  final String source;
+
+  /// Native vendor state (binding/ready/unavailable), diagnostics only.
+  final String vendor;
+
   factory VolumeInfo.fromMap(Map<dynamic, dynamic> map) {
     final rawStreams = map['streams'];
     final streams = <String, int>{};
@@ -51,8 +65,35 @@ class VolumeInfo {
     return VolumeInfo(
       index: (map['index'] as num?)?.toInt() ?? 0,
       max: (map['max'] as num?)?.toInt() ?? 15,
+      min: (map['min'] as num?)?.toInt() ?? 0,
       fixed: map['fixed'] == true,
       streams: streams,
+      source: map['source'] is String ? map['source'] as String : 'system',
+      vendor: map['vendor'] is String ? map['vendor'] as String : '',
+    );
+  }
+
+  /// True when there is no range to move through, either because the firmware
+  /// says so or because the stream reports a single step.
+  bool get isControllable => !fixed && max > 0 && min < max;
+
+  VolumeInfo copyWith({
+    int? index,
+    int? max,
+    int? min,
+    bool? fixed,
+    Map<String, int>? streams,
+    String? source,
+    String? vendor,
+  }) {
+    return VolumeInfo(
+      index: index ?? this.index,
+      max: max ?? this.max,
+      min: min ?? this.min,
+      fixed: fixed ?? this.fixed,
+      streams: streams ?? this.streams,
+      source: source ?? this.source,
+      vendor: vendor ?? this.vendor,
     );
   }
 

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

@@ -290,6 +290,7 @@ class _HomeScreenState extends State<HomeScreen> {
                                   diagnostics: system.volumeDiagnostics,
                                   onToggleDiagnostics:
                                       system.toggleVolumeDiagnostics,
+                                  unconfirmed: system.volumeUnconfirmed,
                                 ),
                               ],
                             ),

+ 146 - 18
lib/features/home/system_controller.dart

@@ -34,13 +34,28 @@ class SystemController extends ChangeNotifier {
   bool _disposed = false;
   int _lastNonZeroVolume = 0;
 
+  /// How long a fresh write may take before a poll decides the read-back is
+  /// not tracking it. Car units often answer a write a few hundred ms later.
+  static const Duration _settleWindow = Duration(milliseconds: 1600);
+
+  int? _pendingIndex;
+  int? _pendingBaseline;
+  DateTime? _pendingSince;
+  int _pendingMisses = 0;
+  bool _readBackStale = false;
+  int? _staleIndex;
+
   SystemStatus get status => _status;
   VolumeInfo get volume => _volume;
   int get volumePercent => _volume.percent;
   bool get isMuted => _volume.index <= 0;
-  bool get isVolumeFixed => _volume.fixed;
+  bool get isVolumeFixed => !_volume.isControllable;
   bool get isMusicPlaying => _musicPlaying;
 
+  /// True when the car reports the same level no matter what was written.
+  /// The card still shows the requested level, marked with a question mark.
+  bool get volumeUnconfirmed => _readBackStale;
+
   String? get volumeDiagnostics {
     if (!_showVolumeDiagnostics) return null;
     final write = _lastVolumeTarget == null
@@ -49,7 +64,15 @@ class SystemController extends ChangeNotifier {
     final streams = _volume.streams.entries
         .map((entry) => '${entry.key} ${entry.value}')
         .join('  ');
-    return 'raw ${_volume.index}/${_volume.max}  fixed ${_volume.fixed}\n'
+    final state = [
+      'raw ${_volume.index}/${_volume.max}',
+      'min ${_volume.min}',
+      'fixed ${_volume.fixed}',
+      'source ${_volume.source}',
+      if (_volume.vendor.isNotEmpty) 'vendor ${_volume.vendor}',
+      if (_readBackStale) 'unconfirmed',
+    ].join('  ');
+    return '$state\n'
         'write $write\n'
         '${streams.isEmpty ? 'streams unavailable' : streams}';
   }
@@ -94,20 +117,114 @@ class SystemController extends ChangeNotifier {
     _volumeBusy = true;
     try {
       final next = await _platform.getVolume();
+      if (!_active) return;
       if (_lastVolumeTarget != null) _lastVolumeRead = next;
-      if (next.index != _volume.index ||
-          next.max != _volume.max ||
-          next.fixed != _volume.fixed ||
-          !mapEquals(next.streams, _volume.streams)) {
-        _volume = next;
-        if (next.index > 0) _lastNonZeroVolume = next.index;
-        _notify();
+
+      final pending = _pendingIndex;
+      if (pending != null) {
+        if (next.index == pending) {
+          _confirmVolume(next);
+          return;
+        }
+        final baseline = _pendingBaseline;
+        final since = _pendingSince;
+        final elapsed = since == null
+            ? _settleWindow
+            : DateTime.now().difference(since);
+        if (baseline != null && next.index == baseline) {
+          if (elapsed >= _settleWindow) {
+            _pendingMisses++;
+            if (_pendingMisses >= 2) {
+              _readBackStale = true;
+              _staleIndex = baseline;
+            }
+          }
+          _mergeMetadata(next);
+          return;
+        }
+        // The car may still be applying an earlier step of the same gesture.
+        if (elapsed < _settleWindow) {
+          _mergeMetadata(next);
+          return;
+        }
+        // Something else moved the volume (hardware keys, another app).
+        _adoptVolume(next);
+        return;
+      }
+
+      if (_readBackStale) {
+        if (next.index == _staleIndex) {
+          _mergeMetadata(next);
+          return;
+        }
+        _readBackStale = false;
+        _staleIndex = null;
       }
+      _adoptVolume(next);
     } finally {
       _volumeBusy = false;
     }
   }
 
+  bool _sameVolume(VolumeInfo a, VolumeInfo b) {
+    return a.index == b.index &&
+        a.max == b.max &&
+        a.min == b.min &&
+        a.fixed == b.fixed &&
+        a.source == b.source &&
+        a.vendor == b.vendor &&
+        mapEquals(a.streams, b.streams);
+  }
+
+  void _clearPending() {
+    _pendingIndex = null;
+    _pendingBaseline = null;
+    _pendingSince = null;
+    _pendingMisses = 0;
+  }
+
+  void _confirmVolume(VolumeInfo next) {
+    _clearPending();
+    _readBackStale = false;
+    _staleIndex = null;
+    _applyVolume(next);
+  }
+
+  void _adoptVolume(VolumeInfo next) {
+    _clearPending();
+    _readBackStale = false;
+    _staleIndex = null;
+    _applyVolume(next);
+  }
+
+  void _applyVolume(VolumeInfo next) {
+    if (next.index > 0) _lastNonZeroVolume = next.index;
+    if (!_sameVolume(next, _volume)) {
+      _volume = next;
+      _notify();
+    }
+  }
+
+  /// Keeps the level the user chose while refreshing the metadata around it.
+  void _mergeMetadata(VolumeInfo next) {
+    final max = next.max <= 0 ? _volume.max : next.max;
+    final min = next.min.clamp(0, max);
+    final index = _volume.index.clamp(min, max);
+    final merged = VolumeInfo(
+      index: index,
+      max: max,
+      min: min,
+      fixed: next.fixed,
+      streams: next.streams,
+      source: next.source,
+      vendor: next.vendor,
+    );
+    if (!_sameVolume(merged, _volume)) {
+      _volume = merged;
+      _notify();
+    }
+  }
+
   Future<void> _pollMusic() async {
     if (_musicBusy || !_active) return;
     _musicBusy = true;
@@ -144,16 +261,25 @@ class SystemController extends ChangeNotifier {
 
   Future<void> setVolume(int index) async {
     final max = _volume.max <= 0 ? 15 : _volume.max;
-    final clamped = index.clamp(0, max);
-    if (_volume.fixed) {
+    final min = _volume.min.clamp(0, max);
+    final clamped = index.clamp(min, max);
+    if (isVolumeFixed) {
       _lastVolumeTarget = clamped;
       _lastVolumeRead = _volume;
       return;
     }
+    final before = _volume.index;
+    if (clamped == before && _pendingIndex == null) return;
     final writeId = ++_volumeWriteId;
     _lastVolumeTarget = clamped;
+    if (_pendingIndex == null) {
+      _pendingBaseline = _lastVolumeRead?.index ?? before;
+    }
+    _pendingIndex = clamped;
+    _pendingSince = DateTime.now();
+    _pendingMisses = 0;
     _volumeSetting = true;
-    _volume = VolumeInfo(index: clamped, max: max, streams: _volume.streams);
+    _volume = _volume.copyWith(index: clamped);
     if (clamped > 0) _lastNonZeroVolume = clamped;
     _notify();
     try {
@@ -162,12 +288,14 @@ class SystemController extends ChangeNotifier {
       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;
+      if (actual.index == clamped) {
+        _confirmVolume(actual);
+      } else {
+        // The car has not reported the new level yet. Keep what the user set;
+        // the next polls either confirm it or mark it unconfirmed instead of
+        // snapping the slider back to the old value.
+        _mergeMetadata(actual);
       }
-      _notify();
     } finally {
       if (writeId == _volumeWriteId) _volumeSetting = false;
     }
@@ -215,7 +343,7 @@ class SystemController extends ChangeNotifier {
   }
 
   Future<void> toggleMute() async {
-    if (_volume.fixed) return;
+    if (isVolumeFixed) return;
     if (_volume.index > 0) {
       _lastNonZeroVolume = _volume.index;
       await setVolume(0);

+ 6 - 2
lib/features/home/widgets/volume_card.dart

@@ -29,6 +29,7 @@ class VolumeCard extends StatefulWidget {
     this.canNext = false,
     this.diagnostics,
     this.onToggleDiagnostics,
+    this.unconfirmed = false,
   });
 
   final VolumeInfo volume;
@@ -46,6 +47,9 @@ class VolumeCard extends StatefulWidget {
   final String? diagnostics;
   final VoidCallback? onToggleDiagnostics;
 
+  /// The car kept reporting a different level than the one written.
+  final bool unconfirmed;
+
   @override
   State<VolumeCard> createState() => _VolumeCardState();
 }
@@ -56,7 +60,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;
+  bool get _fixed => !widget.volume.isControllable;
   int get _percent => ((_value / _max) * 100).round();
 
   String get _nowPlayingLabel {
@@ -144,7 +148,7 @@ class _VolumeCardState extends State<VolumeCard> {
                           ),
                           const SizedBox(width: 6),
                           Text(
-                            '$_percent%',
+                            '$_percent%${widget.unconfirmed ? '?' : ''}',
                             style: theme.textTheme.headlineMedium?.copyWith(
                               fontSize: compact ? 24 : 28,
                               fontFeatures: const [

+ 1 - 0
lib/features/jellyfin/jellyfin_album_screen.dart

@@ -216,6 +216,7 @@ class _JellyfinAlbumScreenState extends State<JellyfinAlbumScreen> {
             onNext: active ? () => unawaited(playback.next()) : null,
             canPrevious: active && playback.canGoPrevious,
             canNext: active && playback.canGoNext,
+            unconfirmed: system.volumeUnconfirmed,
           ),
         ],
       );

+ 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.2.1+21
+version: 1.2.2+22
 
 environment:
   sdk: ^3.13.7

+ 49 - 0
releases/1.2.2.md

@@ -0,0 +1,49 @@
+CarDash 1.2.2 fixes the car volume on FYT/Syu head units and stops the volume card from jumping back to 100%.
+
+- **Package:** `com.wasp.cardash_launcher`
+- **Version:** 1.2.2 (versionCode 22)
+- **ABIs:** arm64-v8a, armeabi-v7a, x86_64 (universal APK)
+- **Signing:** project debug key matching all prior releases
+- **SHA-256:** `fed6d1818cfd28b19c0e568652ad14f44ce4bf15291a635f73fd97efe1ef5a35`
+
+**The bug**
+On FYT/Syu-based head units (Joying, Junsun, Teyes, Atoto and similar Chinese
+firmware) the amplifier belongs to the MCU. Android's media-stream volume is not
+what the driver hears, so `setStreamVolume` was accepted and ignored. The card
+then trusted the old read-back and snapped straight back to 100%, which made the
+car's real steps (10, 20, …) impossible to reach from the dashboard.
+
+**The fix**
+- CarDash binds the car's own `com.syu.ms` toolkit/sound module and sets the same
+  master volume the car's OSD uses (0–36, MCU command `C_VOL`). It subscribes to
+  the MCU level, so the dashboard and the car's knob now follow each other.
+- If the car reports an old level after a write, the card keeps the requested
+  value and marks it with `?` instead of snapping back.
+- Units without the Syu service keep the Android path, now with an extra
+  `FLAG_SHOW_UI` write that can reach vendor volume panels.
+- Volume diagnostics show the raw level/max, minimum, fixed flag, control source
+  (`syu`, `syu-wait`, `system`) and vendor state. Long-press **Media volume**.
+- No new permission, root or manifest change. The car's own knob and
+  steering-wheel handling is untouched.
+
+**Also in 1.2.2**
+- Jellyfin **Favorites** tab, album-page heart and the playing-album Home
+  shortcut.
+- Jellyfin **Streaming quality** setting: Direct or 320/256/192/128 kbps MP3.
+
+**Verified**
+- 149 tests pass; `flutter analyze` clean; debug and release APKs build.
+- **The MCU volume path still needs the real-car test.** After installing,
+  long-press **Media volume**: `source syu` + `vendor ready` means the bridge is
+  active. `source system` + `vendor unavailable` means this is not a Syu unit.
+
+**[Download cardash-android-1.2.2.apk](https://git.krasi.net/kkazakov/car-interface-android/raw/main/releases/cardash-android-1.2.2.apk)**
+
+Devices with the in-app updater can use Settings → Updates to download this APK
+and open Android's package installer. Android requires install confirmation and a
+one-time unknown-apps allowance; no silent install is attempted. A yellow dot
+appears on the Settings gear when this release is available.
+
+This build is debug-signed for sideloading/testing, not Play Store distribution.
+Gogs release attachments are limited to 32 MB, so the 59 MB universal APK is
+linked directly from the repository.

+ 16 - 0
releases/README.md

@@ -1,5 +1,21 @@
 # CarDash releases
 
+## 1.2.2 — car volume on FYT/Syu units
+
+- **File:** `cardash-android-1.2.2.apk`
+- **Version:** 1.2.2 (versionCode 22)
+- **ABIs:** arm64-v8a, armeabi-v7a, x86_64
+- **SHA-256:** `fed6d1818cfd28b19c0e568652ad14f44ce4bf15291a635f73fd97efe1ef5a35`
+- **Signing:** project debug key matching all releases.
+- **Changes:** the volume card now drives the FYT/Syu MCU master volume through
+  `com.syu.ms` (sound module, `C_VOL` 0–36), follows the level the car reports,
+  and keeps an unconfirmed write instead of snapping back to 100%. Non-Syu units
+  keep the Android path with an extra UI-panel write. Diagnostics show the
+  control source and vendor state. Includes the Jellyfin Favorites tab, the
+  playing-album Home shortcut and the streaming-quality setting.
+- **Verified:** 149 tests, `flutter analyze`, formatting, debug and release
+  builds. The MCU volume path still needs the real-car test.
+
 ## 1.2.1 — Jellyfin pause equalizer fix
 
 - **File:** `cardash-android-1.2.1.apk`

BIN
releases/cardash-android-1.2.2.apk


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

@@ -0,0 +1 @@
+fed6d1818cfd28b19c0e568652ad14f44ce4bf15291a635f73fd97efe1ef5a35  releases/cardash-android-1.2.2.apk

+ 25 - 3
test/system_controller_test.dart

@@ -8,6 +8,7 @@ import 'package:flutter_test/flutter_test.dart';
 
 class _MediaPlatform implements LauncherPlatform {
   bool playing = false;
+  bool applyWrites = false;
   MediaPlayback media = const MediaPlayback();
   VolumeInfo volume = const VolumeInfo(index: 8, max: 15);
   final List<String> keys = [];
@@ -37,6 +38,9 @@ class _MediaPlatform implements LauncherPlatform {
   @override
   Future<void> setVolume(int index) async {
     volumeWrites.add(index);
+    if (applyWrites) {
+      volume = volume.copyWith(index: index);
+    }
   }
 
   @override
@@ -137,7 +141,7 @@ void main() {
     controller.stop();
   });
 
-  test('a rejected volume write reverts and exposes diagnostics', () async {
+  test('a rejected volume write keeps the requested level', () async {
     final platform = _MediaPlatform()
       ..volume = const VolumeInfo(
         index: 100,
@@ -151,8 +155,10 @@ void main() {
     await controller.setVolume(25);
 
     expect(platform.volumeWrites, contains(25));
-    expect(controller.volume.index, 100);
-    expect(controller.volumeDiagnostics, isNotNull);
+    expect(controller.volume.index, 25);
+    expect(controller.volumeUnconfirmed, isFalse);
+
+    controller.toggleVolumeDiagnostics();
     expect(controller.volumeDiagnostics, contains('25 -> 100'));
     expect(controller.volumeDiagnostics, contains('music 100'));
 
@@ -160,4 +166,20 @@ void main() {
     expect(controller.volumeDiagnostics, isNull);
     controller.stop();
   });
+
+  test('a confirmed volume write adopts the car level', () async {
+    final platform = _MediaPlatform()
+      ..applyWrites = true
+      ..volume = const VolumeInfo(index: 8, max: 15);
+    final controller = SystemController(platform);
+    await controller.start();
+    addTearDown(controller.dispose);
+
+    await controller.setVolume(3);
+
+    expect(platform.volumeWrites, contains(3));
+    expect(controller.volume.index, 3);
+    expect(controller.volumeUnconfirmed, isFalse);
+    controller.stop();
+  });
 }

+ 23 - 0
test/volume_card_test.dart

@@ -82,6 +82,29 @@ void main() {
     expect(find.text('Mute'), findsNothing);
   });
 
+  testWidgets('an unconfirmed level is marked with a question mark', (
+    tester,
+  ) async {
+    await tester.pumpWidget(
+      MaterialApp(
+        theme: AppTheme.dark(),
+        home: const Scaffold(
+          body: VolumeCard(
+            volume: VolumeInfo(index: 5, max: 15),
+            onSetVolume: _ignoreVolume,
+            onToggleMute: _ignore,
+            isMusicPlaying: false,
+            onPlayPause: _ignore,
+            media: MediaPlayback(),
+            unconfirmed: true,
+          ),
+        ),
+      ),
+    );
+
+    expect(find.text('33%?'), findsOneWidget);
+  });
+
   testWidgets('the ring switches between play and pause', (tester) async {
     var playing = false;
     var taps = 0;