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:
@@ -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
|
||||
|
||||
+92
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user