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"
|
buildToolsVersion = "36.1.0"
|
||||||
ndkVersion = "29.0.14206865"
|
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 {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = 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:largeHeap="true"
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:usesCleartextTraffic="true">
|
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
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import android.view.WindowManager
|
|||||||
import android.view.inputmethod.InputMethodManager
|
import android.view.inputmethod.InputMethodManager
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
|
import com.edde746.plezy.car.CarRestrictionsMonitor
|
||||||
import com.edde746.plezy.exoplayer.ExoPlayerPlugin
|
import com.edde746.plezy.exoplayer.ExoPlayerPlugin
|
||||||
import com.edde746.plezy.mpv.MpvAudioPlayerPlugin
|
import com.edde746.plezy.mpv.MpvAudioPlayerPlugin
|
||||||
import com.edde746.plezy.mpv.MpvPlayerPlugin
|
import com.edde746.plezy.mpv.MpvPlayerPlugin
|
||||||
@@ -74,7 +75,10 @@ class MainActivity : FlutterActivity() {
|
|||||||
private val DEVICE_ADJUSTMENT_CHANNEL = "com.plezy/device_adjustment"
|
private val DEVICE_ADJUSTMENT_CHANNEL = "com.plezy/device_adjustment"
|
||||||
private val TEXT_INPUT_CHANNEL = "com.plezy/text_input"
|
private val TEXT_INPUT_CHANNEL = "com.plezy/text_input"
|
||||||
private val APP_EXIT_CHANNEL = "com.plezy/app_exit"
|
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 watchNextPlugin: WatchNextPlugin? = null
|
||||||
|
private var carRestrictions: CarRestrictionsMonitor? = null
|
||||||
|
private var carRestrictionsChannel: MethodChannel? = null
|
||||||
private var nativeTextInputFocused = false
|
private var nativeTextInputFocused = false
|
||||||
private var originalWindowBrightness: Float? = null
|
private var originalWindowBrightness: Float? = null
|
||||||
private var flutterTextureView: FlutterTextureView? = null
|
private var flutterTextureView: FlutterTextureView? = null
|
||||||
@@ -460,6 +464,9 @@ class MainActivity : FlutterActivity() {
|
|||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
externalPlayerChannel.dispose()
|
externalPlayerChannel.dispose()
|
||||||
|
carRestrictions?.release()
|
||||||
|
carRestrictions = null
|
||||||
|
carRestrictionsChannel = null
|
||||||
activityStarted = false
|
activityStarted = false
|
||||||
flutterSurfaceReconnectPending = false
|
flutterSurfaceReconnectPending = false
|
||||||
flutterTextureView = null
|
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 {
|
override fun getFlutterShellArgs(): FlutterShellArgs {
|
||||||
val args = super.getFlutterShellArgs()
|
val args = super.getFlutterShellArgs()
|
||||||
selectedFlutterRenderer = selectFlutterRenderer()
|
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 ->
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, DEVICE_ADJUSTMENT_CHANNEL).setMethodCallHandler { call, result ->
|
||||||
handleDeviceAdjustmentCall(call.method, call.arguments, 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 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
import '../utils/platform_detector.dart';
|
import '../utils/platform_detector.dart';
|
||||||
|
import 'car_ux_restrictions_service.dart';
|
||||||
|
|
||||||
/// Android Automotive OS driver-distraction gating for Plezy's `video` app
|
/// Android Automotive OS driver-distraction gating for Plezy's `video` app
|
||||||
/// category (car app quality `DD-2` / `DD-3`).
|
/// 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:
|
/// 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
|
/// 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
|
/// driving. The second obligation covers every path that can start audio, not
|
||||||
/// just OS media-session commands — a gapless track transition or queue
|
/// 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
|
/// Authority is the vehicle's own user-experience restrictions on the driver
|
||||||
/// playback so a command arriving before the first lifecycle message cannot
|
/// display ([CarUxRestrictionsService]), which is the mechanism the platform
|
||||||
/// slip through; nothing is playing that early, so the strictness costs nothing.
|
/// documents for apps that must separate "driving" from "not in the
|
||||||
bool automotivePlaybackAllowed({required bool isAutomotive, required AppLifecycleState? state}) {
|
/// 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;
|
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,
|
/// [automotivePlaybackAllowed] against the ambient form factor, vehicle state
|
||||||
/// for owners that hold no injected lifecycle state of their own.
|
/// and lifecycle, for owners that hold no injected state of their own.
|
||||||
///
|
///
|
||||||
/// Short-circuits before reading [WidgetsBinding.instance] so this stays usable
|
/// Short-circuits before reading [WidgetsBinding.instance] so this stays usable
|
||||||
/// from plain `test()` suites, where the binding is not initialized and the
|
/// from plain `test()` suites, where the binding is not initialized and the
|
||||||
/// `instance` getter throws.
|
/// `instance` getter throws.
|
||||||
bool automotivePlaybackAllowedNow() {
|
bool automotivePlaybackAllowedNow() {
|
||||||
if (!PlatformDetector.isAutomotive()) return true;
|
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/widgets.dart';
|
||||||
import 'package:flutter_test/flutter_test.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/services/driver_distraction.dart';
|
||||||
import 'package:plezy/utils/platform_detector.dart';
|
import 'package:plezy/utils/platform_detector.dart';
|
||||||
|
|
||||||
@@ -35,12 +36,60 @@ void main() {
|
|||||||
test('fails closed on an unknown lifecycle state', () {
|
test('fails closed on an unknown lifecycle state', () {
|
||||||
expect(automotivePlaybackAllowed(isAutomotive: true, state: null), isFalse);
|
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', () {
|
group('automotivePlaybackAllowedNow', () {
|
||||||
setUp(() {
|
setUp(() {
|
||||||
TvDetectionService.debugReset();
|
TvDetectionService.debugReset();
|
||||||
addTearDown(TvDetectionService.debugReset);
|
addTearDown(TvDetectionService.debugReset);
|
||||||
|
addTearDown(() => CarUxRestrictionsService.debugSetOverride(null));
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('reads the ambient form factor and lifecycle', (tester) async {
|
testWidgets('reads the ambient form factor and lifecycle', (tester) async {
|
||||||
@@ -56,5 +105,18 @@ void main() {
|
|||||||
TvDetectionService.debugSetAutomotiveOverride(false);
|
TvDetectionService.debugSetAutomotiveOverride(false);
|
||||||
expect(automotivePlaybackAllowedNow(), isTrue);
|
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