fix(automotive): stop blocking parked playback when the car service has no verdict

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.
This commit is contained in:
edde746
2026-08-10 19:00:50 +02:00
parent 369c6279d6
commit 01279e5fbb
@@ -202,14 +202,31 @@ class CarRestrictionsMonitor(
Log.w(TAG, "Car service is ready but has no UX restrictions manager") Log.w(TAG, "Car service is ready but has no UX restrictions manager")
return return
} }
manager = uxManager
// Register before reading, per the platform's own guidance: registration subscribes to future // 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 // 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 // lands between the two binder calls, and publishing the stale "parked" afterwards would then
// override lifecycle gating and permit audio for the whole drive. // override lifecycle gating and permit audio for the whole drive.
uxManager.registerListener { restrictions: CarUxRestrictions -> publish(restrictions.isRequiresDistractionOptimization) } //
supported = true // Identity-guarded because a retry re-enters bind() with the same manager instance (Car caches
publish(uxManager.currentCarUxRestrictions?.isRequiresDistractionOptimization ?: true) // 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) { } catch (error: Throwable) {
// Registration is the failure the caller's retry is meant to survive, so keep the connection // 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. // 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 * 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. * reconnects, and disconnecting would end that for good.