From 7ce5a443fd70c8127cfec39687f1f99b3a89add1 Mon Sep 17 00:00:00 2001
From: edde746 <86283021+edde746@users.noreply.github.com>
Date: Thu, 6 Aug 2026 03:06:17 +0200
Subject: [PATCH] feat(automotive): read the vehicle's driver-distraction state
Android Automotive tells an app when the car requires distraction optimization,
and Plezy never asked. A monitor now watches CarUxRestrictions and publishes the
verdict over the existing platform channel, where a single Dart gate answers
whether playback may start.
The car service is reached through the lifecycle-listener overload rather than
Car.createCar(Context). That overload blocks its caller for up to five seconds
polling ServiceManager, and on car-service death it reaches killClient(), which
kills the hosting process for any context that is not an Activity or a Service -
a crash in a system component would take the app down with it. Head units on
Android 9 and 10 predate the listener, so a legacy ServiceConnection is used
there, with the same identity guard on reconnect.
A vehicle that has not answered yet counts as restricted, and one deadline is
spent resolving it rather than one per request, so a wedged car service delays
playback once instead of on every open.
---
android/app/build.gradle.kts | 5 +
.../plezy/car/CarRestrictionsMonitorTest.kt | 92 ++++++
android/app/src/main/AndroidManifest.xml | 3 +
.../kotlin/com/edde746/plezy/MainActivity.kt | 52 ++++
.../plezy/car/CarRestrictionsMonitor.kt | 263 ++++++++++++++++++
lib/services/car_ux_restrictions_service.dart | 200 +++++++++++++
lib/services/driver_distraction.dart | 52 ++--
.../car_ux_restrictions_service_test.dart | 231 +++++++++++++++
test/services/driver_distraction_test.dart | 62 +++++
9 files changed, 942 insertions(+), 18 deletions(-)
create mode 100644 android/app/src/androidTest/kotlin/com/edde746/plezy/car/CarRestrictionsMonitorTest.kt
create mode 100644 android/app/src/main/kotlin/com/edde746/plezy/car/CarRestrictionsMonitor.kt
create mode 100644 lib/services/car_ux_restrictions_service.dart
create mode 100644 test/services/car_ux_restrictions_service_test.dart
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 6dd132a0..4fe8b21e 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -324,6 +324,11 @@ android {
buildToolsVersion = "36.1.0"
ndkVersion = "29.0.14206865"
+ // Android Automotive OS driver-distraction state (CarUxRestrictionsManager). This is a platform
+ // stub, not a shipped dependency: the classes exist only on AAOS images, so every use is guarded
+ // by FEATURE_AUTOMOTIVE and the manifest declares `uses-library android.car required=false`.
+ useLibrary("android.car")
+
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
diff --git a/android/app/src/androidTest/kotlin/com/edde746/plezy/car/CarRestrictionsMonitorTest.kt b/android/app/src/androidTest/kotlin/com/edde746/plezy/car/CarRestrictionsMonitorTest.kt
new file mode 100644
index 00000000..0abaa4ff
--- /dev/null
+++ b/android/app/src/androidTest/kotlin/com/edde746/plezy/car/CarRestrictionsMonitorTest.kt
@@ -0,0 +1,92 @@
+package com.edde746.plezy.car
+
+import android.content.pm.PackageManager
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Verifies the platform half of the driver-distraction gate on a real device.
+ *
+ * Everything here is invisible to the Dart unit tests: `android.car` is a compile-time stub, so a
+ * missing `useLibrary`, a wrong `uses-library` declaration, a car service that refuses to connect,
+ * or an unresolvable default display all compile fine and merely make [CarRestrictionsMonitor.start]
+ * return false — at which point Plezy silently falls back to lifecycle gating and parked background
+ * audio never works, with no crash to notice.
+ *
+ * On a non-automotive device the monitor must stay inert instead of throwing, which is the other
+ * half of the contract.
+ */
+@RunWith(AndroidJUnit4::class)
+class CarRestrictionsMonitorTest {
+
+ private val context = InstrumentationRegistry.getInstrumentation().targetContext
+ private val isCar = context.packageManager.hasSystemFeature(PackageManager.FEATURE_AUTOMOTIVE)
+
+ @Test
+ fun reportsSupportOnlyOnACarAndNeverThrows() {
+ val monitor = CarRestrictionsMonitor(context)
+ val answered = CountDownLatch(1)
+ try {
+ val started = monitor.start { answered.countDown() }
+ if (isCar) {
+ assertTrue("android.car is present but the UX-restriction signal did not connect", started)
+ // The connect never blocks, so readiness arrives on the main thread while this test thread
+ // waits. A timeout here means the car service never handed over a verdict.
+ assertTrue("car service never reported its UX restrictions", answered.await(10, TimeUnit.SECONDS))
+ assertTrue("a verdict arrived without marking the monitor supported", monitor.supported)
+ } else {
+ assertFalse("a non-automotive device must not claim car restrictions", started)
+ assertFalse(monitor.supported)
+ }
+ } finally {
+ monitor.release()
+ }
+ assertFalse("release() must drop support so a stale verdict cannot leak", monitor.supported)
+ }
+
+ @Test
+ fun aParkedEmulatorReportsNoDistractionOptimization() {
+ if (!isCar) return
+ val monitor = CarRestrictionsMonitor(context)
+ val answered = CountDownLatch(1)
+ try {
+ assertTrue(monitor.start { answered.countDown() })
+ assertTrue("car service never reported its UX restrictions", answered.await(10, TimeUnit.SECONDS))
+ // The suite runs on a parked vehicle (no VHAL driving injection), which is the state that
+ // must permit background audio. A restricted verdict here means the gate would keep music
+ // tied to the foreground exactly as before.
+ assertFalse(
+ "parked car reported that distraction optimization is required",
+ monitor.requiresDistractionOptimization
+ )
+ } finally {
+ monitor.release()
+ }
+ }
+
+ @Test
+ fun theServiceConnectionRouteUsedBelowAndroidElevenAlsoReportsTheVehicle() {
+ if (!isCar) return
+ // No Android 9 or 10 Automotive image is published, so the only way to exercise the path those
+ // head units take is to force it here: the deprecated API is present on every version.
+ val monitor = CarRestrictionsMonitor(context, forceLegacyConnect = true)
+ val answered = CountDownLatch(1)
+ try {
+ assertTrue("the ServiceConnection route failed to start", monitor.start { answered.countDown() })
+ assertTrue("car service never reported through the legacy route", answered.await(20, TimeUnit.SECONDS))
+ assertTrue(monitor.supported)
+ assertFalse(
+ "parked car reported that distraction optimization is required",
+ monitor.requiresDistractionOptimization
+ )
+ } finally {
+ monitor.release()
+ }
+ }
+}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 6c56eb1d..06289af6 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -44,6 +44,9 @@
android:largeHeap="true"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="true">
+
+
+ runOnUiThread {
+ // `supported` rides along because it can go false again when the car service dies, and Dart
+ // must then fall back to lifecycle gating rather than read a stale verdict.
+ carRestrictionsChannel?.invokeMethod(
+ "onChanged",
+ mapOf(
+ "supported" to monitor.supported,
+ "requiresDistractionOptimization" to restricted
+ )
+ )
+ }
+ }
+ }
+
override fun getFlutterShellArgs(): FlutterShellArgs {
val args = super.getFlutterShellArgs()
selectedFlutterRenderer = selectFlutterRenderer()
@@ -602,6 +632,28 @@ class MainActivity : FlutterActivity() {
}
}
+ val carChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CAR_RESTRICTIONS_CHANNEL)
+ carRestrictionsChannel = carChannel
+ carChannel.setMethodCallHandler { call, result ->
+ when (call.method) {
+ "getState" -> {
+ startCarRestrictionsIfNeeded()
+ val monitor = carRestrictions
+ val supported = monitor?.supported == true
+ result.success(
+ mapOf(
+ "supported" to supported,
+ // Tells Dart the difference between "this device has no car service" and "the verdict
+ // is coming": only the latter is worth waiting for.
+ "pending" to (monitor?.pending == true),
+ "requiresDistractionOptimization" to (supported && monitor.requiresDistractionOptimization)
+ )
+ )
+ }
+ else -> result.notImplemented()
+ }
+ }
+
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, DEVICE_ADJUSTMENT_CHANNEL).setMethodCallHandler { call, result ->
handleDeviceAdjustmentCall(call.method, call.arguments, result)
}
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
new file mode 100644
index 00000000..81d0038c
--- /dev/null
+++ b/android/app/src/main/kotlin/com/edde746/plezy/car/CarRestrictionsMonitor.kt
@@ -0,0 +1,263 @@
+package com.edde746.plezy.car
+
+import android.car.Car
+import android.car.drivingstate.CarUxRestrictions
+import android.car.drivingstate.CarUxRestrictionsManager
+import android.content.ComponentName
+import android.content.Context
+import android.content.ServiceConnection
+import android.content.pm.PackageManager
+import android.hardware.display.DisplayManager
+import android.os.Build
+import android.os.IBinder
+import android.util.Log
+import android.view.Display
+
+/**
+ * Reads Android Automotive OS driver-distraction state straight from the platform.
+ *
+ * Plezy previously derived "may audio play?" from the Flutter activity lifecycle, which conflates
+ * two different things: a car that is driving, and a car that is parked with Plezy simply not in
+ * the foreground. Only the first must silence playback (car app quality `DD-2`/`DD-3`); the second
+ * is ordinary background audio. [CarUxRestrictionsManager] is the signal the platform documents for
+ * apps where lifecycle reaction is not sufficient.
+ *
+ * Bound explicitly to [Display.DEFAULT_DISPLAY] — the driver's screen — by creating a display
+ * context for it. An application context would NOT do: it has no associated display, so
+ * `CarUxRestrictionsManager` falls back to the current user's assigned main display, which under
+ * multi-user Android Automotive OS can be a passenger screen. UX restrictions legitimately differ
+ * per display, but audio is not per-display, and `DD-2` requires audio to stop when the user starts
+ * driving unless the device declares `com.android.car.background_audio_while_driving`. Reading the
+ * driver display therefore keeps a passenger-display session stricter than it may need to be, which
+ * is the safe direction; do not rebind this without pairing it with that feature check. If the
+ * default display cannot be resolved, [start] fails closed and the caller keeps lifecycle gating.
+ *
+ * Connection uses the [Car.CarServiceLifecycleListener] overload with
+ * [Car.CAR_WAIT_TIMEOUT_DO_NOT_WAIT], which is not a preference:
+ *
+ * - `Car.createCar(Context)` blocks the calling thread polling for the car service (AOSP polls
+ * every 50 ms up to 100 times, so up to five seconds) and is reached from the main thread here.
+ * - Worse, a client created without a lifecycle listener is terminated when the car service dies:
+ * the library calls `finishClient()`, which for anything that is not literally an `Activity` or
+ * `Service` — a display context included — runs `Process.killProcess(myPid())`. A routine car
+ * service update would take the whole app down.
+ *
+ * With the listener, `createCar` never blocks and a service restart arrives as `ready = false`
+ * instead. Every `ready = true` re-fetches the manager and re-registers the listener, because the
+ * old manager holds a dead binder: its calls then return null and are swallowed, which would look
+ * like a permanently parked car rather than a failure.
+ *
+ * `android.car` is a platform library that only exists on Android Automotive OS images, hence the
+ * `useLibrary` compile-time stub, the `uses-library required=false` manifest entry, and the
+ * defensive [Throwable] catches: on a phone the classes are simply absent.
+ */
+class CarRestrictionsMonitor(
+ private val context: Context,
+ /**
+ * Forces the pre-Android-11 [ServiceConnection] route on a device that also has the newer one, so
+ * instrumentation can exercise it: no Android 9 or 10 Automotive system image is published.
+ */
+ private val forceLegacyConnect: Boolean = false
+) {
+
+ private var car: Car? = null
+ private var manager: CarUxRestrictionsManager? = null
+ private var onChanged: ((Boolean) -> Unit)? = null
+ private var connecting = false
+ private var legacyConnection = false
+
+ /**
+ * True while a live car connection can answer; until then callers must not trust
+ * [requiresDistractionOptimization]. Goes back to false if the car service dies.
+ */
+ var supported: Boolean = false
+ private set
+
+ /**
+ * Latest platform verdict. Defaults to restricted so a half-connected car can never be read as
+ * "free to play"; [supported] is what gates whether this value is used at all.
+ */
+ var requiresDistractionOptimization: Boolean = true
+ private set
+
+ /**
+ * A connection exists but has not produced a verdict yet — the car service is starting, or died
+ * and is expected back. Callers that gate behaviour on the verdict should wait rather than treat
+ * this like a phone, where no answer is ever coming.
+ */
+ val pending: Boolean
+ get() = connecting && !supported
+
+ /**
+ * Begins connecting to the car service. Returns whether this device can have the signal at all,
+ * NOT whether it is ready: readiness arrives through [onChanged], and [supported] reports it.
+ * A false return leaves the caller on its previous (lifecycle-derived) behaviour for good.
+ */
+ fun start(onChanged: (Boolean) -> Unit): Boolean {
+ if (supported) return true
+ val existing = car
+ if (existing != null) {
+ // A connection exists but is not observing the vehicle: the manager lookup or the listener
+ // registration failed. Retry it here, because the car service is already ready and will not
+ // announce itself again — without this the signal would stay dark for the whole process.
+ this.onChanged = onChanged
+ if (existing.isConnected) bind(existing) else reconnectLegacyIfNeeded()
+ return true
+ }
+ if (connecting) return true
+ if (!context.packageManager.hasSystemFeature(PackageManager.FEATURE_AUTOMOTIVE)) return false
+
+ return try {
+ // Fail closed when the driver display cannot be resolved: an unbound context would silently
+ // report some other display's restrictions (see the class doc).
+ val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager ?: return false
+ val driverDisplay = displayManager.getDisplay(Display.DEFAULT_DISPLAY) ?: return false
+ val displayContext = context.createDisplayContext(driverDisplay)
+ this.onChanged = onChanged
+ connecting = true
+ if (!forceLegacyConnect && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ connectWithLifecycleListener(displayContext)
+ } else {
+ connectWithServiceConnection(displayContext)
+ }
+ } catch (error: Throwable) {
+ Log.w(TAG, "Car UX restrictions unavailable; falling back to lifecycle gating", error)
+ release()
+ false
+ }
+ }
+
+ /** Android 11 and newer: the car service announces readiness and death through one callback. */
+ private fun connectWithLifecycleListener(displayContext: Context): Boolean {
+ val created = Car.createCar(
+ displayContext,
+ null,
+ Car.CAR_WAIT_TIMEOUT_DO_NOT_WAIT
+ ) { connectedCar: Car, ready: Boolean ->
+ // Use the Car handed to the listener: on the synchronous path — the car service is already
+ // up and this runs on the main thread — it fires inside createCar, before it returns.
+ if (ready) bind(connectedCar) else unbind()
+ }
+ // Cold service: the listener has not run, so this is the only reference to the pending
+ // connection and release() would otherwise leave it bound for the process's lifetime.
+ if (car == null) car = created
+ return true
+ }
+
+ /**
+ * Android 9 and 10 head units, which have no [Car.CarServiceLifecycleListener].
+ *
+ * A [ServiceConnection] is the safe alternative, not merely an older one: the process kill in
+ * `finishClient()` is reached only by a client that supplies neither callback, and binding here is
+ * asynchronous, so nothing blocks. Kept behind a version check because naming the newer overload
+ * on such a device raises `NoSuchMethodError`.
+ */
+ @Suppress("DEPRECATION")
+ private fun connectWithServiceConnection(displayContext: Context): Boolean {
+ legacyConnection = true
+ val connection = object : ServiceConnection {
+ override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
+ car?.let { bind(it) }
+ }
+
+ override fun onServiceDisconnected(name: ComponentName?) {
+ unbind()
+ // Android 10 unbinds inside its own disconnect handling, so the binding that would have
+ // reconnected is gone; newer platforms keep it and race us harmlessly.
+ reconnectLegacyIfNeeded()
+ }
+ }
+ val created = Car.createCar(displayContext, connection)
+ if (created == null) {
+ // The platform returns null when the car service loader is unavailable. Clearing the
+ // connection state matters: leaving it set would report a pending verdict for the rest of the
+ // process, and every later start would short-circuit instead of trying again.
+ Log.w(TAG, "Car service loader unavailable; falling back to lifecycle gating for now")
+ release()
+ return false
+ }
+ car = created
+ created.connect()
+ return true
+ }
+
+ private fun reconnectLegacyIfNeeded() {
+ if (!legacyConnection) return
+ val existing = car ?: return
+ if (existing.isConnected || existing.isConnecting) return
+ try {
+ existing.connect()
+ } catch (error: IllegalStateException) {
+ // The platform's own reconnect won the race, which is the outcome we wanted anyway.
+ Log.w(TAG, "Car service reconnect already in progress", error)
+ }
+ }
+
+ /** Adopts a ready car service: fresh manager, fresh listener, fresh verdict. */
+ private fun bind(connectedCar: Car) {
+ car = connectedCar
+ try {
+ val uxManager = connectedCar.getCarManager(Car.CAR_UX_RESTRICTION_SERVICE) as? CarUxRestrictionsManager
+ if (uxManager == null) {
+ 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)
+ } 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.
+ Log.w(TAG, "Failed to observe car UX restrictions", error)
+ manager = null
+ supported = false
+ }
+ }
+
+ /**
+ * 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.
+ */
+ private fun unbind() {
+ Log.w(TAG, "Car service went away; falling back to lifecycle gating until it returns")
+ manager = null
+ supported = false
+ requiresDistractionOptimization = true
+ onChanged?.invoke(true)
+ }
+
+ private fun publish(restricted: Boolean) {
+ requiresDistractionOptimization = restricted
+ onChanged?.invoke(restricted)
+ }
+
+ /** Unregisters and disconnects. Safe to call when never started. */
+ fun release() {
+ try {
+ manager?.unregisterListener()
+ } catch (error: Throwable) {
+ Log.w(TAG, "Failed to unregister car UX restrictions listener", error)
+ }
+ try {
+ car?.disconnect()
+ } catch (error: Throwable) {
+ Log.w(TAG, "Failed to disconnect from the car service", error)
+ }
+ manager = null
+ car = null
+ onChanged = null
+ connecting = false
+ legacyConnection = false
+ supported = false
+ requiresDistractionOptimization = true
+ }
+
+ private companion object {
+ const val TAG = "CarRestrictions"
+ }
+}
diff --git a/lib/services/car_ux_restrictions_service.dart b/lib/services/car_ux_restrictions_service.dart
new file mode 100644
index 00000000..1cfbf8d6
--- /dev/null
+++ b/lib/services/car_ux_restrictions_service.dart
@@ -0,0 +1,200 @@
+import 'dart:async';
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
+
+import '../utils/app_logger.dart';
+import '../utils/platform_detector.dart';
+
+/// What the vehicle says about driver distraction right now.
+enum CarUxRestrictionState {
+ /// No platform answer: either not a car, or `android.car` was unavailable.
+ /// Callers fall back to their previous, lifecycle-derived behaviour.
+ unknown,
+
+ /// The vehicle is parked (or this display is unrestricted): ordinary rules.
+ unrestricted,
+
+ /// Distraction optimization is required — playback must stop and stay stopped.
+ restricted,
+}
+
+/// Live `CarUxRestrictionsManager` state, mirrored from the Android side.
+///
+/// Plezy used to derive its car playback authority from [AppLifecycleState], which cannot tell a
+/// driving car apart from a parked car whose driver simply opened navigation. That made background
+/// audio impossible on a head unit even while parked. This service supplies the signal the platform
+/// documents for exactly that case; [CarUxRestrictionState.unknown] preserves the old behaviour
+/// wherever the vehicle cannot answer.
+class CarUxRestrictionsService {
+ CarUxRestrictionsService._();
+
+ static final CarUxRestrictionsService instance = CarUxRestrictionsService._();
+
+ @visibleForTesting
+ static const MethodChannel channel = MethodChannel('com.plezy/car_restrictions');
+
+ static CarUxRestrictionState? _debugOverride;
+
+ final ValueNotifier _state = ValueNotifier(CarUxRestrictionState.unknown);
+ bool _started = false;
+ bool _stalled = false;
+ Completer _firstAnswer = Completer();
+ Future? _inFlight;
+
+ /// Whether the platform says a verdict is on its way — see [_apply].
+ bool _pendingVerdict = false;
+
+ /// Current verdict. Reading it on a car starts the platform subscription, so callers never have
+ /// to sequence an explicit initialization; off-car it is a constant.
+ ///
+ /// Synchronous, so it reads [CarUxRestrictionState.unknown] until the first platform answer
+ /// lands. Anything that configures a session from this value must first await [ensureResolved].
+ CarUxRestrictionState get state {
+ if (_debugOverride != null) return _debugOverride!;
+ if (!PlatformDetector.isAutomotive()) return CarUxRestrictionState.unknown;
+ ensureStarted();
+ return _state.value;
+ }
+
+ /// Notifies on every transition. Listeners are only meaningful on a car.
+ ValueListenable get listenable => _state;
+
+ /// Begins observing the vehicle. Idempotent, and a no-op off Android Automotive OS.
+ void ensureStarted() {
+ if (_started || !PlatformDetector.isAutomotive()) return;
+ _started = true;
+ channel.setMethodCallHandler(_handlePlatformCall);
+ unawaited(_refresh());
+ }
+
+ /// Waits for a definitive answer, and retries once if the vehicle answered without giving one.
+ ///
+ /// Callers that latch behaviour on the verdict — enabling the foreground service, asking for the
+ /// notification permission — must await this, or a cold start races the platform and configures
+ /// the session as if the car were mute. [timeout] is the whole budget, not per attempt: a car
+ /// service that never answers must not hold up playback, and the caller simply keeps the
+ /// lifecycle fallback until [listenable] reports the late answer.
+ Future ensureResolved({Duration timeout = const Duration(seconds: 2)}) async {
+ if (_debugOverride != null || !PlatformDetector.isAutomotive()) return;
+ ensureStarted();
+ if (_state.value != CarUxRestrictionState.unknown) return;
+ // A deadline already blew on this platform — a call still in flight, or a promised push that
+ // never came. Waiting again would spend the budget on every open for as long as the car service
+ // stays wedged, and neither a platform call nor a push can be cancelled. Still ask, without
+ // waiting: when the previous call has landed this issues a fresh `getState`, which is how the
+ // platform retries a connection that came up without observing the vehicle. While one is still
+ // in flight the de-duplication below makes it a no-op, and its answer reconfigures the session
+ // when it arrives.
+ if (_stalled) {
+ unawaited(_refresh());
+ return;
+ }
+
+ final budget = Stopwatch()..start();
+ if (!_firstAnswer.isCompleted) {
+ await _firstAnswer.future.timeout(timeout, onTimeout: () {});
+ if (!_firstAnswer.isCompleted) {
+ _stalled = true;
+ return;
+ }
+ if (_state.value != CarUxRestrictionState.unknown) return;
+ }
+
+ // The answer was "no verdict"; a car service that was not ready at startup can still connect
+ // later, so try once more inside what is left of the budget rather than latching mute forever.
+ var remaining = timeout - budget.elapsed;
+ if (remaining <= Duration.zero) return;
+ try {
+ await _refresh().timeout(remaining);
+ } on TimeoutException {
+ _stalled = true;
+ return;
+ }
+ if (_state.value != CarUxRestrictionState.unknown) return;
+
+ // The platform is connected to the car service but has not been handed a verdict yet, so one is
+ // genuinely coming — over a push, not a return value. Waiting for it is the whole point of this
+ // method: the alternative is configuring the session as if this were a phone.
+ if (!_pendingVerdict) return;
+ remaining = timeout - budget.elapsed;
+ if (remaining <= Duration.zero) return;
+ await _awaitVerdict(remaining);
+ // Still nothing: stop holding every later open for a push that is not coming on any schedule.
+ if (_state.value == CarUxRestrictionState.unknown) _stalled = true;
+ }
+
+ Future _awaitVerdict(Duration remaining) {
+ final settled = Completer();
+ void check() {
+ if (_state.value != CarUxRestrictionState.unknown && !settled.isCompleted) settled.complete();
+ }
+
+ _state.addListener(check);
+ return settled.future.timeout(remaining, onTimeout: () {}).whenComplete(() => _state.removeListener(check));
+ }
+
+ Future _refresh() => _inFlight ??= _readState().whenComplete(() => _inFlight = null);
+
+ Future _readState() async {
+ try {
+ final result = await channel.invokeMapMethod('getState');
+ _apply(result);
+ } on MissingPluginException {
+ // Non-Android host or an engine without the channel: stay unknown.
+ } catch (e, stackTrace) {
+ appLogger.w('Failed to read car UX restrictions', error: e, stackTrace: stackTrace);
+ } finally {
+ if (!_firstAnswer.isCompleted) _firstAnswer.complete();
+ }
+ }
+
+ Future _handlePlatformCall(MethodCall call) async {
+ if (call.method != 'onChanged') return null;
+ final args = call.arguments;
+ if (args is Map) {
+ // A push carrying `supported: false` means the car service died. Going back to unknown puts
+ // callers on lifecycle gating instead of a verdict nothing is maintaining any more.
+ _apply(args.cast());
+ }
+ return null;
+ }
+
+ void _apply(Map? result) {
+ if (result == null || result['supported'] != true) {
+ // `pending` means the platform holds a live car connection that has not been handed a verdict
+ // yet, so one is still coming; without it there is nothing to wait for on this device.
+ _pendingVerdict = result != null && result['pending'] == true;
+ // Losing a verdict we had means the platform is alive and talking, so waiting out the
+ // reconnect is worth one budget again.
+ if (_state.value != CarUxRestrictionState.unknown) _stalled = false;
+ _state.value = CarUxRestrictionState.unknown;
+ return;
+ }
+ _pendingVerdict = false;
+ _stalled = false;
+ _setRestricted(result['requiresDistractionOptimization'] == true);
+ }
+
+ void _setRestricted(bool restricted) {
+ final next = restricted ? CarUxRestrictionState.restricted : CarUxRestrictionState.unrestricted;
+ if (_state.value == next) return;
+ appLogger.d('Car UX restrictions: ${next.name}');
+ _state.value = next;
+ }
+
+ @visibleForTesting
+ static void debugSetOverride(CarUxRestrictionState? value) => _debugOverride = value;
+
+ @visibleForTesting
+ void debugReset() {
+ _debugOverride = null;
+ _started = false;
+ _stalled = false;
+ _pendingVerdict = false;
+ _inFlight = null;
+ _firstAnswer = Completer();
+ _state.value = CarUxRestrictionState.unknown;
+ channel.setMethodCallHandler(null);
+ }
+}
diff --git a/lib/services/driver_distraction.dart b/lib/services/driver_distraction.dart
index e65bc526..92680437 100644
--- a/lib/services/driver_distraction.dart
+++ b/lib/services/driver_distraction.dart
@@ -1,40 +1,56 @@
import 'package:flutter/widgets.dart';
import '../utils/platform_detector.dart';
+import 'car_ux_restrictions_service.dart';
/// Android Automotive OS driver-distraction gating for Plezy's `video` app
/// category (car app quality `DD-2` / `DD-3`).
///
-/// While a vehicle's user-experience restrictions are active the system hides
-/// the app's activity. That delivers `onPause` — Flutter
-/// [AppLifecycleState.inactive] — at minimum; only devices carrying the
-/// Automotive compatibility mode go on to deliver `onStop`
-/// ([AppLifecycleState.hidden] then [AppLifecycleState.paused]). Reacting to
-/// lifecycle callbacks is the mechanism the platform documents as sufficient,
-/// so playback authority is derived from lifecycle state alone and no
-/// `android.car` dependency is required.
-///
/// Two obligations follow from `DD-2`, and this single predicate serves both:
/// audio must stop when driving starts, and it must not be resumable while
/// driving. The second obligation covers every path that can start audio, not
/// just OS media-session commands — a gapless track transition or queue
-/// auto-advance landing just after the lifecycle pause must fail closed too.
+/// auto-advance landing just after driving starts must fail closed too.
///
-/// The gate itself fails closed: an unknown (null) lifecycle state denies
-/// playback so a command arriving before the first lifecycle message cannot
-/// slip through; nothing is playing that early, so the strictness costs nothing.
-bool automotivePlaybackAllowed({required bool isAutomotive, required AppLifecycleState? state}) {
+/// Authority is the vehicle's own user-experience restrictions on the driver
+/// display ([CarUxRestrictionsService]), which is the mechanism the platform
+/// documents for apps that must separate "driving" from "not in the
+/// foreground". Lifecycle state cannot make that distinction: the app is
+/// equally not-resumed when the system covers it for driving and when a parked
+/// driver opens navigation, so deriving authority from lifecycle alone silenced
+/// parked background audio — something `DD-2` never asked for.
+///
+/// Where the vehicle cannot answer ([CarUxRestrictionState.unknown] — an older
+/// head unit, a car service that failed to connect, or a driver display that
+/// could not be resolved) the previous lifecycle rule still applies, including
+/// its fail-closed treatment of a null state: a command arriving before the
+/// first lifecycle message is denied, and nothing is playing that early, so the
+/// strictness costs nothing.
+bool automotivePlaybackAllowed({
+ required bool isAutomotive,
+ required AppLifecycleState? state,
+ CarUxRestrictionState restrictions = CarUxRestrictionState.unknown,
+}) {
if (!isAutomotive) return true;
- return state == AppLifecycleState.resumed;
+ return switch (restrictions) {
+ CarUxRestrictionState.restricted => false,
+ CarUxRestrictionState.unrestricted => true,
+ CarUxRestrictionState.unknown => state == AppLifecycleState.resumed,
+ };
}
-/// [automotivePlaybackAllowed] against the ambient form factor and lifecycle,
-/// for owners that hold no injected lifecycle state of their own.
+/// [automotivePlaybackAllowed] against the ambient form factor, vehicle state
+/// and lifecycle, for owners that hold no injected state of their own.
///
/// Short-circuits before reading [WidgetsBinding.instance] so this stays usable
/// from plain `test()` suites, where the binding is not initialized and the
/// `instance` getter throws.
bool automotivePlaybackAllowedNow() {
if (!PlatformDetector.isAutomotive()) return true;
- return automotivePlaybackAllowed(isAutomotive: true, state: WidgetsBinding.instance.lifecycleState);
+ final restrictions = CarUxRestrictionsService.instance.state;
+ return automotivePlaybackAllowed(
+ isAutomotive: true,
+ state: restrictions == CarUxRestrictionState.unknown ? WidgetsBinding.instance.lifecycleState : null,
+ restrictions: restrictions,
+ );
}
diff --git a/test/services/car_ux_restrictions_service_test.dart b/test/services/car_ux_restrictions_service_test.dart
new file mode 100644
index 00000000..414f469c
--- /dev/null
+++ b/test/services/car_ux_restrictions_service_test.dart
@@ -0,0 +1,231 @@
+import 'dart:async';
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:plezy/services/car_ux_restrictions_service.dart';
+import 'package:plezy/utils/platform_detector.dart';
+
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+
+ final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
+ late List calls;
+
+ void answerWith(Map? state) {
+ messenger.setMockMethodCallHandler(CarUxRestrictionsService.channel, (call) async {
+ calls.add(call.method);
+ if (call.method == 'getState') return state;
+ return null;
+ });
+ }
+
+ /// Delivers what the platform pushes on a restriction change.
+ Future pushChange(bool restricted) async {
+ await messenger.handlePlatformMessage(
+ CarUxRestrictionsService.channel.name,
+ CarUxRestrictionsService.channel.codec.encodeMethodCall(
+ MethodCall('onChanged', {'supported': true, 'requiresDistractionOptimization': restricted}),
+ ),
+ (_) {},
+ );
+ }
+
+ setUp(() {
+ calls = [];
+ TvDetectionService.debugReset();
+ CarUxRestrictionsService.instance.debugReset();
+ });
+
+ tearDown(() {
+ messenger.setMockMethodCallHandler(CarUxRestrictionsService.channel, null);
+ TvDetectionService.debugReset();
+ CarUxRestrictionsService.instance.debugReset();
+ });
+
+ test('stays unknown off a car and never touches the platform', () async {
+ answerWith({'supported': true, 'requiresDistractionOptimization': true});
+ TvDetectionService.debugSetAutomotiveOverride(false);
+
+ expect(CarUxRestrictionsService.instance.state, CarUxRestrictionState.unknown);
+ await pumpEventQueue();
+ expect(calls, isEmpty, reason: 'a phone must not query the car service');
+ });
+
+ test('reads the vehicle state on first use', () async {
+ answerWith({'supported': true, 'requiresDistractionOptimization': false});
+ TvDetectionService.debugSetAutomotiveOverride(true);
+
+ expect(CarUxRestrictionsService.instance.state, CarUxRestrictionState.unknown, reason: 'async until answered');
+ await pumpEventQueue();
+
+ expect(calls, ['getState']);
+ expect(CarUxRestrictionsService.instance.state, CarUxRestrictionState.unrestricted);
+ });
+
+ test('a car that cannot answer stays unknown, so callers keep lifecycle gating', () async {
+ answerWith({'supported': false, 'requiresDistractionOptimization': false});
+ TvDetectionService.debugSetAutomotiveOverride(true);
+
+ CarUxRestrictionsService.instance.state;
+ await pumpEventQueue();
+
+ expect(CarUxRestrictionsService.instance.state, CarUxRestrictionState.unknown);
+ });
+
+ test('a wedged car service costs one budget, not one per caller', () async {
+ // Never answers: the call stays in flight forever, which is what a car service that hangs
+ // during startup looks like.
+ messenger.setMockMethodCallHandler(CarUxRestrictionsService.channel, (call) async {
+ calls.add(call.method);
+ await Completer().future;
+ return null;
+ });
+ TvDetectionService.debugSetAutomotiveOverride(true);
+ const budget = Duration(milliseconds: 300);
+
+ final first = Stopwatch()..start();
+ await CarUxRestrictionsService.instance.ensureResolved(timeout: budget);
+ first.stop();
+
+ final second = Stopwatch()..start();
+ await CarUxRestrictionsService.instance.ensureResolved(timeout: budget);
+ second.stop();
+
+ // Wide margins on both sides of the real boundary: one budget spent (~300 ms) against the two
+ // a per-attempt timeout would cost (~600 ms), and a second call that should not wait at all.
+ expect(calls, ['getState'], reason: 're-awaiting an in-flight call is not a retry');
+ expect(second.elapsed, lessThan(budget ~/ 2), reason: 'a call already known to be stuck is not waited on again');
+ expect(first.elapsed, lessThan(budget + budget ~/ 2), reason: 'the timeout is the whole budget, not per attempt');
+ expect(CarUxRestrictionsService.instance.state, CarUxRestrictionState.unknown);
+ });
+
+ test('a vehicle that answered without a verdict is asked again', () async {
+ answerWith({'supported': false, 'requiresDistractionOptimization': false});
+ TvDetectionService.debugSetAutomotiveOverride(true);
+
+ await CarUxRestrictionsService.instance.ensureResolved(timeout: const Duration(milliseconds: 300));
+ expect(calls, ['getState', 'getState'], reason: 'a car service that was not up yet can connect later');
+
+ // And the retry is what lands the verdict once it can.
+ answerWith({'supported': true, 'requiresDistractionOptimization': true});
+ await CarUxRestrictionsService.instance.ensureResolved(timeout: const Duration(milliseconds: 300));
+ expect(CarUxRestrictionsService.instance.state, CarUxRestrictionState.restricted);
+ });
+
+ test('a car service that dies drops the verdict instead of freezing it', () async {
+ answerWith({'supported': true, 'requiresDistractionOptimization': false});
+ TvDetectionService.debugSetAutomotiveOverride(true);
+ await CarUxRestrictionsService.instance.ensureResolved(timeout: const Duration(milliseconds: 300));
+ expect(CarUxRestrictionsService.instance.state, CarUxRestrictionState.unrestricted);
+
+ // What MainActivity pushes when the car service goes away: nothing maintains
+ // the verdict any more, so callers must go back to lifecycle gating rather
+ // than keep reading "parked" forever.
+ await messenger.handlePlatformMessage(
+ CarUxRestrictionsService.channel.name,
+ CarUxRestrictionsService.channel.codec.encodeMethodCall(
+ const MethodCall('onChanged', {'supported': false, 'requiresDistractionOptimization': true}),
+ ),
+ (_) {},
+ );
+
+ expect(CarUxRestrictionsService.instance.state, CarUxRestrictionState.unknown);
+ });
+
+ test('a car service that is still connecting is waited for, not read as absent', () async {
+ // What the platform answers while `Car` connects: no verdict yet, but one is coming over a push.
+ answerWith({'supported': false, 'pending': true, 'requiresDistractionOptimization': true});
+ TvDetectionService.debugSetAutomotiveOverride(true);
+
+ var resolved = false;
+ final waiting = CarUxRestrictionsService.instance
+ .ensureResolved(timeout: const Duration(seconds: 2))
+ .then((_) => resolved = true);
+ await pumpEventQueue();
+ expect(resolved, isFalse, reason: 'the verdict is still on its way');
+
+ answerWith({'supported': true, 'requiresDistractionOptimization': false});
+ await pushChange(false);
+ await waiting;
+
+ expect(resolved, isTrue);
+ expect(CarUxRestrictionsService.instance.state, CarUxRestrictionState.unrestricted);
+ });
+
+ test('a device with no car service is not waited for at all', () async {
+ answerWith({'supported': false, 'pending': false, 'requiresDistractionOptimization': true});
+ TvDetectionService.debugSetAutomotiveOverride(true);
+
+ final budget = Stopwatch()..start();
+ await CarUxRestrictionsService.instance.ensureResolved(timeout: const Duration(seconds: 2));
+ budget.stop();
+
+ expect(budget.elapsed, lessThan(const Duration(seconds: 1)), reason: 'nothing is coming; do not hold playback');
+ expect(CarUxRestrictionsService.instance.state, CarUxRestrictionState.unknown);
+ });
+
+ test('a promised verdict that never arrives is waited for once, not per caller', () async {
+ answerWith({'supported': false, 'pending': true, 'requiresDistractionOptimization': true});
+ TvDetectionService.debugSetAutomotiveOverride(true);
+ const budget = Duration(milliseconds: 300);
+
+ final first = Stopwatch()..start();
+ await CarUxRestrictionsService.instance.ensureResolved(timeout: budget);
+ first.stop();
+
+ final second = Stopwatch()..start();
+ await CarUxRestrictionsService.instance.ensureResolved(timeout: budget);
+ second.stop();
+
+ expect(second.elapsed, lessThan(budget ~/ 2), reason: 'a push that never came must not delay every open');
+ expect(first.elapsed, lessThan(budget * 2));
+ expect(CarUxRestrictionsService.instance.state, CarUxRestrictionState.unknown);
+
+ // And it still takes the answer if the car service eventually speaks.
+ await pushChange(false);
+ expect(CarUxRestrictionsService.instance.state, CarUxRestrictionState.unrestricted);
+ });
+
+ test('a stalled platform is still asked, so it can retry its own connection', () async {
+ answerWith({'supported': false, 'pending': true, 'requiresDistractionOptimization': true});
+ TvDetectionService.debugSetAutomotiveOverride(true);
+ const budget = Duration(milliseconds: 200);
+
+ await CarUxRestrictionsService.instance.ensureResolved(timeout: budget);
+ final asksAfterFirst = calls.length;
+
+ await CarUxRestrictionsService.instance.ensureResolved(timeout: budget);
+ await pumpEventQueue();
+
+ // Not waited for a second time, but still asked: `getState` is what makes the platform retry a
+ // connection that came up without observing the vehicle.
+ expect(calls.length, greaterThan(asksAfterFirst));
+ });
+
+ test('platform pushes flip the state and notify listeners', () async {
+ answerWith({'supported': true, 'requiresDistractionOptimization': false});
+ TvDetectionService.debugSetAutomotiveOverride(true);
+ CarUxRestrictionsService.instance.state;
+ await pumpEventQueue();
+
+ final seen = [];
+ void listener() => seen.add(CarUxRestrictionsService.instance.listenable.value);
+ CarUxRestrictionsService.instance.listenable.addListener(listener);
+ addTearDown(() => CarUxRestrictionsService.instance.listenable.removeListener(listener));
+
+ await pushChange(true);
+ await pushChange(true);
+ await pushChange(false);
+
+ expect(seen, [CarUxRestrictionState.restricted, CarUxRestrictionState.unrestricted]);
+ expect(CarUxRestrictionsService.instance.state, CarUxRestrictionState.unrestricted);
+ });
+
+ test('a missing platform channel degrades to unknown instead of throwing', () async {
+ messenger.setMockMethodCallHandler(CarUxRestrictionsService.channel, null);
+ TvDetectionService.debugSetAutomotiveOverride(true);
+
+ expect(CarUxRestrictionsService.instance.state, CarUxRestrictionState.unknown);
+ await pumpEventQueue();
+ expect(CarUxRestrictionsService.instance.state, CarUxRestrictionState.unknown);
+ });
+}
diff --git a/test/services/driver_distraction_test.dart b/test/services/driver_distraction_test.dart
index 1bb3572b..2d11b0d7 100644
--- a/test/services/driver_distraction_test.dart
+++ b/test/services/driver_distraction_test.dart
@@ -1,5 +1,6 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
+import 'package:plezy/services/car_ux_restrictions_service.dart';
import 'package:plezy/services/driver_distraction.dart';
import 'package:plezy/utils/platform_detector.dart';
@@ -35,12 +36,60 @@ void main() {
test('fails closed on an unknown lifecycle state', () {
expect(automotivePlaybackAllowed(isAutomotive: true, state: null), isFalse);
});
+
+ test('a vehicle reporting restrictions overrides lifecycle in both directions', () {
+ // The point of the platform signal: parked-but-backgrounded keeps playing,
+ // and driving stops audio even while the app still looks resumed.
+ for (final state in [...AppLifecycleState.values, null]) {
+ expect(
+ automotivePlaybackAllowed(isAutomotive: true, state: state, restrictions: CarUxRestrictionState.unrestricted),
+ isTrue,
+ reason: 'a parked car must allow playback regardless of lifecycle ($state)',
+ );
+ expect(
+ automotivePlaybackAllowed(isAutomotive: true, state: state, restrictions: CarUxRestrictionState.restricted),
+ isFalse,
+ reason: 'a driving car must deny playback regardless of lifecycle ($state)',
+ );
+ }
+ });
+
+ test('an unknown vehicle keeps the lifecycle rule', () {
+ expect(
+ automotivePlaybackAllowed(
+ isAutomotive: true,
+ state: AppLifecycleState.resumed,
+ restrictions: CarUxRestrictionState.unknown,
+ ),
+ isTrue,
+ );
+ expect(
+ automotivePlaybackAllowed(
+ isAutomotive: true,
+ state: AppLifecycleState.inactive,
+ restrictions: CarUxRestrictionState.unknown,
+ ),
+ isFalse,
+ );
+ });
+
+ test('a restricted vehicle never unlocks playback off a car', () {
+ expect(
+ automotivePlaybackAllowed(
+ isAutomotive: false,
+ state: AppLifecycleState.resumed,
+ restrictions: CarUxRestrictionState.restricted,
+ ),
+ isTrue,
+ );
+ });
});
group('automotivePlaybackAllowedNow', () {
setUp(() {
TvDetectionService.debugReset();
addTearDown(TvDetectionService.debugReset);
+ addTearDown(() => CarUxRestrictionsService.debugSetOverride(null));
});
testWidgets('reads the ambient form factor and lifecycle', (tester) async {
@@ -56,5 +105,18 @@ void main() {
TvDetectionService.debugSetAutomotiveOverride(false);
expect(automotivePlaybackAllowedNow(), isTrue);
});
+
+ testWidgets('a parked vehicle keeps playback alive while the app is backgrounded', (tester) async {
+ TvDetectionService.debugSetAutomotiveOverride(true);
+ tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
+ expect(automotivePlaybackAllowedNow(), isFalse, reason: 'no vehicle signal yet: lifecycle still rules');
+
+ CarUxRestrictionsService.debugSetOverride(CarUxRestrictionState.unrestricted);
+ expect(automotivePlaybackAllowedNow(), isTrue);
+
+ CarUxRestrictionsService.debugSetOverride(CarUxRestrictionState.restricted);
+ tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
+ expect(automotivePlaybackAllowedNow(), isFalse, reason: 'driving denies playback even while resumed');
+ });
});
}