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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user