From 01279e5fbbfa659e14bb4ad322fd02e4bab3b524 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:00:50 +0200 Subject: [PATCH] fix(automotive): stop blocking parked playback when the car service has no verdict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CarRestrictionsMonitor.bind() treated a null getCurrentCarUxRestrictions() as a restricted verdict with supported = true. Dart then latched the restricted state, every play path refused to start, and on a car that stays parked no restriction transition ever arrives to correct it — video never played for the whole session. This is the failure mode behind the Play Automotive rejection of version code 128 ("unable to play video content"): a review bench whose car service tracks no restrictions for the resolved display gets exactly that null. A missing verdict now stays pending instead: Dart keeps lifecycle gating (parked, foregrounded video plays; while driving the platform blocks the activity, so DD-2/DD-3 still hold), the registered listener adopts the first real verdict, and every later getState retries the read. Listener registration is identity-guarded because retries re-enter bind() with the same cached manager instance. Verified on an API 34 Automotive emulator: CarRestrictionsMonitorTest passes on both connect routes, parked playback starts, driving pauses it behind the OS blocking screen, and parking again leaves it paused until the user resumes. --- .../plezy/car/CarRestrictionsMonitor.kt | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/kotlin/com/edde746/plezy/car/CarRestrictionsMonitor.kt b/android/app/src/main/kotlin/com/edde746/plezy/car/CarRestrictionsMonitor.kt index 81d0038c..1d9f03b3 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/car/CarRestrictionsMonitor.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/car/CarRestrictionsMonitor.kt @@ -202,14 +202,31 @@ class CarRestrictionsMonitor( Log.w(TAG, "Car service is ready but has no UX restrictions manager") return } - manager = uxManager // Register before reading, per the platform's own guidance: registration subscribes to future // changes only, it does not replay the current one. Reading first would drop a transition that // lands between the two binder calls, and publishing the stale "parked" afterwards would then // override lifecycle gating and permit audio for the whole drive. - uxManager.registerListener { restrictions: CarUxRestrictions -> publish(restrictions.isRequiresDistractionOptimization) } - supported = true - publish(uxManager.currentCarUxRestrictions?.isRequiresDistractionOptimization ?: true) + // + // Identity-guarded because a retry re-enters bind() with the same manager instance (Car caches + // managers until the service dies): a second registration is at best redundant and at worst a + // platform refusal. `manager` is assigned after the listener sticks, so it only ever names a + // manager that is actually observing the vehicle. + if (manager !== uxManager) { + uxManager.registerListener { restrictions: CarUxRestrictions -> adoptVerdict(restrictions.isRequiresDistractionOptimization) } + manager = uxManager + } + val current = uxManager.currentCarUxRestrictions + if (current == null) { + // The service holds no restrictions for this display (yet). Claiming a verdict here would + // publish the restricted default with `supported = true`, and on a car that then stays + // parked no transition ever arrives to correct it — playback would refuse to start for the + // whole session. Stay pending instead: Dart keeps lifecycle gating (parked, foregrounded + // video plays; while driving the platform blocks the activity), the listener above adopts + // the first real verdict, and every later getState retries this read. + Log.w(TAG, "Car service has no UX restrictions for this display; keeping lifecycle gating until it answers") + return + } + adoptVerdict(current.isRequiresDistractionOptimization) } catch (error: Throwable) { // Registration is the failure the caller's retry is meant to survive, so keep the connection // owned (it is in `car`) and report nothing rather than half-observing the vehicle. @@ -219,6 +236,12 @@ class CarRestrictionsMonitor( } } + /** A real verdict from the vehicle: only now may callers trust [requiresDistractionOptimization]. */ + private fun adoptVerdict(restricted: Boolean) { + supported = true + publish(restricted) + } + /** * Drops a dead car service. The [Car] itself is kept: its `BIND_AUTO_CREATE` binding is what * reconnects, and disconnecting would end that for good.