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.
This commit is contained in:
edde746
2026-08-06 03:45:09 +02:00
parent f93952ba6f
commit 7ce5a443fd
9 changed files with 942 additions and 18 deletions
+5
View File
@@ -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
@@ -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()
}
}
}
+3
View File
@@ -44,6 +44,9 @@
android:largeHeap="true"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="true">
<!-- Driver-distraction state on Android Automotive OS. Optional: the platform library is
absent on every other form factor, and CarRestrictionsMonitor falls back silently. -->
<uses-library android:name="android.car" android:required="false" />
<activity
android:name=".MainActivity"
android:exported="true"
@@ -22,6 +22,7 @@ import android.view.WindowManager
import android.view.inputmethod.InputMethodManager
import android.widget.FrameLayout
import androidx.annotation.RequiresApi
import com.edde746.plezy.car.CarRestrictionsMonitor
import com.edde746.plezy.exoplayer.ExoPlayerPlugin
import com.edde746.plezy.mpv.MpvAudioPlayerPlugin
import com.edde746.plezy.mpv.MpvPlayerPlugin
@@ -74,7 +75,10 @@ class MainActivity : FlutterActivity() {
private val DEVICE_ADJUSTMENT_CHANNEL = "com.plezy/device_adjustment"
private val TEXT_INPUT_CHANNEL = "com.plezy/text_input"
private val APP_EXIT_CHANNEL = "com.plezy/app_exit"
private val CAR_RESTRICTIONS_CHANNEL = "com.plezy/car_restrictions"
private var watchNextPlugin: WatchNextPlugin? = null
private var carRestrictions: CarRestrictionsMonitor? = null
private var carRestrictionsChannel: MethodChannel? = null
private var nativeTextInputFocused = false
private var originalWindowBrightness: Float? = null
private var flutterTextureView: FlutterTextureView? = null
@@ -460,6 +464,9 @@ class MainActivity : FlutterActivity() {
override fun onDestroy() {
externalPlayerChannel.dispose()
carRestrictions?.release()
carRestrictions = null
carRestrictionsChannel = null
activityStarted = false
flutterSurfaceReconnectPending = false
flutterTextureView = null
@@ -474,6 +481,29 @@ class MainActivity : FlutterActivity() {
}
}
// Connects the car UX-restriction monitor on first use, retrying while the platform signal is
// unavailable: a car service that was not ready during startup can still answer later, and on a
// phone every attempt fails cheaply on the FEATURE_AUTOMOTIVE check. The connect itself never
// blocks, so this is safe on the main thread; readiness arrives through the callback below.
private fun startCarRestrictionsIfNeeded() {
val existing = carRestrictions
if (existing?.supported == true) return
val monitor = existing ?: CarRestrictionsMonitor(applicationContext).also { carRestrictions = it }
monitor.start { restricted ->
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)
}
@@ -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"
}
}
@@ -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<CarUxRestrictionState> _state = ValueNotifier(CarUxRestrictionState.unknown);
bool _started = false;
bool _stalled = false;
Completer<void> _firstAnswer = Completer<void>();
Future<void>? _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<CarUxRestrictionState> 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<void> 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<void> _awaitVerdict(Duration remaining) {
final settled = Completer<void>();
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<void> _refresh() => _inFlight ??= _readState().whenComplete(() => _inFlight = null);
Future<void> _readState() async {
try {
final result = await channel.invokeMapMethod<String, dynamic>('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<dynamic> _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<String, dynamic>());
}
return null;
}
void _apply(Map<String, dynamic>? 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<void>();
_state.value = CarUxRestrictionState.unknown;
channel.setMethodCallHandler(null);
}
}
+34 -18
View File
@@ -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,
);
}
@@ -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<String> calls;
void answerWith(Map<String, Object?>? 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<void> pushChange(bool restricted) async {
await messenger.handlePlatformMessage(
CarUxRestrictionsService.channel.name,
CarUxRestrictionsService.channel.codec.encodeMethodCall(
MethodCall('onChanged', <String, Object?>{'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<void>().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', <String, Object?>{'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 = <CarUxRestrictionState>[];
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);
});
}
@@ -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');
});
});
}