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'); + }); }); }