From 0b7da6fdc9b83ab1d9fc4547bcc649e32827fdc9 Mon Sep 17 00:00:00 2001
From: code-new-bie <245316967+code-new-bie@users.noreply.github.com>
Date: Mon, 12 Oct 2026 12:20:02 +0800
Subject: [PATCH] feat(bluetooth): optionally drop the iPhone's Bluetooth links
on disableBluetooth
During wireless CarPlay the iPhone sends disableBluetooth to ask the
accessory to drop its classic Bluetooth links to it, so calls and media go
through CarPlay instead of the head unit's hands-free and Bluetooth audio.
The controller only used it to time the wireless handoff; the head unit kept
its HFP and A2DP links, so calls could ring on the head unit's own phone
screen and music could keep playing over Bluetooth.
Two new settings under Bluetooth, both off by default, turn off the iPhone's
hands-free (HFP client) and Bluetooth audio (A2DP sink) links when
disableBluetooth arrives:
- Each link's connection priority is saved and set to off, so the head unit
does not reconnect it, and the link is disconnected. A reconnect during the
session is disconnected again, at most three times a minute.
- The links are restored 15 s after the session ends unless CarPlay
reconnects first, at once when the app exits, and on the next start if the
process was killed before that (restored after 60 s unless CarPlay
connects first).
- Changing a setting applies at once, also during a session.
The profiles are hidden API and are called reflectively. On Android 10 and
earlier they need only BLUETOOTH_ADMIN; on later versions the head unit may
refuse, which is logged and leaves the links connected.
Verified in a BYD Song PLUS (Android 10): calls ring only in CarPlay and
music stays on CarPlay during the session, and both links come back after
it. BluetoothHandoffRestoreTest (Robolectric, so shared gains that test
dependency) covers holding, restoring, settings changes and the restore
after a killed process; BluetoothHandoffTest covers the reconnect limit and
address checks.
---
.../shilapi/xcertplay/CarPlayHostActivity.kt | 71 +++
shared/build.gradle | 1 +
.../xcertplay/network/BluetoothHandoff.kt | 446 ++++++++++++++++++
.../orchestration/CarPlayController.kt | 38 +-
.../network/BluetoothHandoffRestoreTest.kt | 283 +++++++++++
.../xcertplay/network/BluetoothHandoffTest.kt | 27 ++
6 files changed, 865 insertions(+), 1 deletion(-)
create mode 100644 shared/src/main/java/com/shilapi/xcertplay/network/BluetoothHandoff.kt
create mode 100644 shared/src/test/java/com/shilapi/xcertplay/network/BluetoothHandoffRestoreTest.kt
create mode 100644 shared/src/test/java/com/shilapi/xcertplay/network/BluetoothHandoffTest.kt
diff --git a/common/src/main/java/com/shilapi/xcertplay/CarPlayHostActivity.kt b/common/src/main/java/com/shilapi/xcertplay/CarPlayHostActivity.kt
index eaf49dc3..21dcea56 100644
--- a/common/src/main/java/com/shilapi/xcertplay/CarPlayHostActivity.kt
+++ b/common/src/main/java/com/shilapi/xcertplay/CarPlayHostActivity.kt
@@ -73,6 +73,8 @@ import com.shilapi.xcertplay.media.MainMediaAudioBuffer
import com.shilapi.xcertplay.media.MediaMetricsMonitor
import com.shilapi.xcertplay.media.MicrophoneGain
import com.shilapi.xcertplay.media.MicrophoneLevelMonitor
+import com.shilapi.xcertplay.network.BluetoothHandoffSettings
+import com.shilapi.xcertplay.network.BluetoothHandoff
import com.shilapi.xcertplay.network.CarPlayVpnService
import com.shilapi.xcertplay.orchestration.CarPlayController
import com.shilapi.xcertplay.orchestration.CarPlayRuntimeConfig
@@ -954,6 +956,21 @@ class CarPlayHostActivity : ComponentActivity() {
).apply { topMargin = dp(12) },
)
+ content.addView(
+ settingsCategoryHeader("Bluetooth"),
+ LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ).apply { topMargin = dp(36) },
+ )
+ content.addView(
+ buildBluetoothHandoffSection(),
+ LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ).apply { topMargin = dp(12) },
+ )
+
content.addView(
settingsCategoryHeader("Startup"),
LinearLayout.LayoutParams(
@@ -2616,6 +2633,53 @@ class CarPlayHostActivity : ComponentActivity() {
return section
}
+ /**
+ * What wireless CarPlay's disableBluetooth turns off on the head unit. Both are off by default;
+ * a change applies at once, also during a session.
+ */
+ private fun buildBluetoothHandoffSection(): View {
+ val section = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ }
+ section.addView(
+ settingsSwitchRow(
+ label = "Turn off iPhone Bluetooth calls during CarPlay",
+ checked = BluetoothHandoffSettings.callsEnabled(this),
+ description = "Calls go through CarPlay instead of the head unit's hands-free",
+ ) { checked -> BluetoothHandoffSettings.setCallsEnabled(this, checked) },
+ LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ),
+ )
+ section.addView(
+ settingsSwitchRow(
+ label = "Turn off iPhone Bluetooth audio during CarPlay",
+ checked = BluetoothHandoffSettings.audioEnabled(this),
+ description = "Music goes through CarPlay instead of Bluetooth audio",
+ ) { checked -> BluetoothHandoffSettings.setAudioEnabled(this, checked) },
+ LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ).apply { topMargin = dp(10) },
+ )
+ section.addView(
+ menuText(
+ "When the iPhone asks for it during wireless CarPlay (disableBluetooth), the head unit " +
+ "drops these Bluetooth links to the iPhone. They come back 15 s after CarPlay ends, " +
+ "when the app exits, or on its next start. Works on Android 10 and earlier; on " +
+ "later versions the head unit may refuse, which is logged.",
+ 14f,
+ MENU_SECONDARY,
+ ),
+ LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ).apply { topMargin = dp(6) },
+ )
+ return section
+ }
+
private fun buildFullscreenSection(): View {
val section = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
@@ -4006,6 +4070,12 @@ class CarPlayHostActivity : ComponentActivity() {
sessionLog.append("CarPlay stopped; clean=$clean")
sessionLog.close()
Log.i(TAG, "shutdown complete clean=$clean")
+ if (terminateProcess) {
+ // Give the iPhone its Bluetooth links back before the process goes away.
+ runCatching {
+ BluetoothHandoff.shared(applicationContext).releaseNowBlocking(BLUETOOTH_RESTORE_TIMEOUT_MILLIS)
+ }
+ }
teardownExecutor.shutdown()
if (terminateProcess) Process.killProcess(Process.myPid())
}
@@ -4378,6 +4448,7 @@ class CarPlayHostActivity : ComponentActivity() {
const val RECONNECT_DELAY_MILLIS = 2_000L
const val IAP_TUNNEL_RECONNECT_DELAY_MILLIS = 15_000L
const val CONTROLLER_CLOSE_TIMEOUT_MILLIS = 4_000L
+ const val BLUETOOTH_RESTORE_TIMEOUT_MILLIS = 5_000L
const val AUDIO_CAPTURE_DIRECTORY = "audio-captures"
const val THREE_FINGER_COUNT = 3
const val THREE_FINGER_SWIPE_DISTANCE_DP = 72
diff --git a/shared/build.gradle b/shared/build.gradle
index ea96ad70..c896c7ce 100644
--- a/shared/build.gradle
+++ b/shared/build.gradle
@@ -39,4 +39,5 @@ dependencies {
implementation libs.bouncycastle
implementation libs.concentus
testImplementation libs.junit
+ testImplementation 'org.robolectric:robolectric:4.17'
}
diff --git a/shared/src/main/java/com/shilapi/xcertplay/network/BluetoothHandoff.kt b/shared/src/main/java/com/shilapi/xcertplay/network/BluetoothHandoff.kt
new file mode 100644
index 00000000..65ba1440
--- /dev/null
+++ b/shared/src/main/java/com/shilapi/xcertplay/network/BluetoothHandoff.kt
@@ -0,0 +1,446 @@
+package com.shilapi.xcertplay.network
+
+import android.bluetooth.BluetoothDevice
+import android.bluetooth.BluetoothManager
+import android.bluetooth.BluetoothProfile
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.Handler
+import android.os.Looper
+import android.os.SystemClock
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+
+/** Which of the iPhone's classic Bluetooth links wireless CarPlay turns off; both off by default. */
+object BluetoothHandoffSettings {
+ private const val PREFS = "xcertplay_bluetooth_handoff"
+ private const val KEY_CALLS = "disconnect_calls"
+ private const val KEY_AUDIO = "disconnect_audio"
+
+ /** Hands-free (HFP): calls go through CarPlay and the stock phone shows no popup. */
+ fun callsEnabled(context: Context): Boolean = prefs(context).getBoolean(KEY_CALLS, false)
+
+ fun setCallsEnabled(context: Context, enabled: Boolean) {
+ prefs(context).edit().putBoolean(KEY_CALLS, enabled).apply()
+ BluetoothHandoff.shared(context).settingsChanged()
+ }
+
+ /** Bluetooth audio (A2DP): music goes through CarPlay instead of Bluetooth. */
+ fun audioEnabled(context: Context): Boolean = prefs(context).getBoolean(KEY_AUDIO, false)
+
+ fun setAudioEnabled(context: Context, enabled: Boolean) {
+ prefs(context).edit().putBoolean(KEY_AUDIO, enabled).apply()
+ BluetoothHandoff.shared(context).settingsChanged()
+ }
+
+ internal fun prefs(context: Context) =
+ context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+}
+
+/**
+ * Does what CarPlay's disableBluetooth asks: while wireless CarPlay runs, the head unit's
+ * hands-free (HFP client) and/or Bluetooth audio (A2DP sink) links to that iPhone are turned off,
+ * so calls and media go through CarPlay.
+ *
+ * Each link's connection priority is saved and set to off, which head-unit Bluetooth stacks
+ * check before reconnecting (a BYD DiLink stack was seen reconnecting otherwise), and the link is
+ * disconnected. One instance serves the whole process, so a reconnect (settings saved, reconnect
+ * button, brief drop) keeps the links off: [releaseLater] restores them only if no session holds
+ * them again within [RELEASE_DELAY_MS]. The saved priorities persist: if the process is killed
+ * before that release (some head units kill apps when the car is switched off), the next start
+ * takes the links over and restores them unless CarPlay connects within [STARTUP_GRACE_MS].
+ *
+ * The head unit's HFP client (profile 16) and A2DP sink (profile 11) are hidden API and are called
+ * reflectively. On Android 10 and earlier they need only BLUETOOTH_ADMIN; on later versions the
+ * calls may require privileged permissions and fail, which is logged and leaves the links
+ * connected. All state lives on the main thread.
+ */
+class BluetoothHandoff private constructor(context: Context) {
+ private enum class Link(val profile: Int, val label: String, val stateChanged: String) {
+ CALLS(16, "HFP", "android.bluetooth.headsetclient.profile.action.CONNECTION_STATE_CHANGED"),
+ AUDIO(11, "A2DP", "android.bluetooth.a2dp-sink.profile.action.CONNECTION_STATE_CHANGED"),
+ }
+
+ private val app = context.applicationContext
+ private val prefs = BluetoothHandoffSettings.prefs(app)
+ private val handler = Handler(Looper.getMainLooper())
+ private val adapter = runCatching {
+ app.getSystemService(BluetoothManager::class.java)?.adapter
+ }.getOrNull()
+ private val proxies = mutableMapOf()
+ private val requested = mutableSetOf()
+ private val pending = mutableMapOf Unit>>()
+ private val held = mutableSetOf()
+ private var target: String? = null
+ /** The session ended and [delayedRelease] is waiting: a setting may restore, never newly hold. */
+ private var releasePending = false
+ private var receiverRegistered = false
+ private val guards = Link.values().associateWith { HandoffReconnectGuard() }
+ private var releaseAt = 0L
+ private var releaseReason = ""
+ private val delayedRelease = Runnable { releaseNow(releaseReason) }
+
+ /** Where progress is written; the controller of the current connection sets it. */
+ @Volatile
+ var diagnostic: (String) -> Unit = {}
+
+ private val receiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ val link = Link.values().firstOrNull { it.stateChanged == intent.action } ?: return
+ if (link !in held) return
+ @Suppress("DEPRECATION")
+ val device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) ?: return
+ val address = target ?: return
+ if (!device.address.equals(address, ignoreCase = true)) return
+ if (intent.getIntExtra(BluetoothProfile.EXTRA_STATE, -1) != BluetoothProfile.STATE_CONNECTED) return
+ if (guards.getValue(link).allow(SystemClock.elapsedRealtime())) {
+ report("${link.label} to $address reconnected during CarPlay; disconnecting again")
+ withProxy(link) { disconnect(link, it, address) }
+ } else {
+ report("${link.label} to $address keeps reconnecting; leaving it connected")
+ }
+ }
+ }
+
+ init {
+ handler.post {
+ if (target == null) takeOverLeftover()
+ }
+ }
+
+ /**
+ * Links a killed process left off stay off a while longer: restoring at once would reconnect
+ * the iPhone only for the CarPlay session about to start to drop it again.
+ */
+ private fun takeOverLeftover() {
+ val address = prefs.getString(KEY_HELD_ADDRESS, null) ?: return
+ val links = Link.values().filter { prefs.contains(priorityKey(it)) }
+ if (links.isEmpty()) {
+ prefs.edit().remove(KEY_HELD_ADDRESS).apply()
+ return
+ }
+ target = address
+ held += links
+ links.forEach { guards.getValue(it).reset() }
+ registerReceiver()
+ scheduleRelease(STARTUP_GRACE_MS, "no CarPlay session within ${STARTUP_GRACE_MS / 1000}s of startup")
+ report("Bluetooth handoff: ${links.joinToString("+") { it.label }} to $address left off by an earlier run; " +
+ "restoring in ${STARTUP_GRACE_MS / 1000}s unless CarPlay connects")
+ }
+
+ /**
+ * The app is exiting, or the head unit powered on without CarPlay: restore the links now
+ * instead of after the delay, and wait up to [timeoutMillis] for it. Call from a background
+ * thread.
+ */
+ fun releaseNowBlocking(timeoutMillis: Long, reason: String = "app exit"): Boolean {
+ check(Looper.myLooper() != Looper.getMainLooper()) { "Bluetooth restore must not block the main thread" }
+ require(timeoutMillis >= 0) { "timeoutMillis must not be negative" }
+ val deadline = SystemClock.elapsedRealtime() + timeoutMillis
+ val releaseApplied = CountDownLatch(1)
+ handler.post {
+ try {
+ handler.removeCallbacks(delayedRelease)
+ if (target != null) {
+ releaseNow(reason)
+ } else {
+ prefs.getString(KEY_HELD_ADDRESS, null)?.let { address ->
+ for (link in Link.values()) restoreLink(link, address)
+ }
+ }
+ } finally {
+ releaseApplied.countDown()
+ }
+ }
+ return try {
+ if (!releaseApplied.await(timeoutMillis, TimeUnit.MILLISECONDS)) return false
+ var restored = Link.values().none { prefs.contains(priorityKey(it)) }
+ while (!restored) {
+ val remaining = deadline - SystemClock.elapsedRealtime()
+ if (remaining <= 0) break
+ Thread.sleep(minOf(50L, remaining))
+ restored = Link.values().none { prefs.contains(priorityKey(it)) }
+ }
+ restored
+ } catch (_: InterruptedException) {
+ Thread.currentThread().interrupt()
+ false
+ }
+ }
+
+ /**
+ * Turns off the iPhone's HFP when [calls] and A2DP when [audio] until released; a link no
+ * longer wanted is restored. Cancels a pending delayed release.
+ */
+ fun hold(address: String, calls: Boolean, audio: Boolean) {
+ handler.post {
+ handler.removeCallbacks(delayedRelease)
+ val normalized = address.trim().uppercase()
+ if (!BluetoothAdapterAddress.isValid(normalized)) {
+ report("Bluetooth handoff skipped: invalid iPhone Bluetooth address $address")
+ return@post
+ }
+ if (target != null && target != normalized) releaseNow("another iPhone took over")
+ target = normalized
+ releasePending = false
+ registerReceiver()
+ applyLinks(normalized, wanted(calls, audio), allowNewHolds = true)
+ }
+ }
+
+ /**
+ * A Bluetooth setting changed: it applies at once, as the Wi-Fi search setting does. A link
+ * switched off is restored now; a link switched on is turned off while a session runs. After
+ * the session ended only restoring applies, so the pending delayed release still happens.
+ */
+ fun settingsChanged() {
+ handler.post {
+ val address = target ?: return@post
+ val wanted = wanted(BluetoothHandoffSettings.callsEnabled(app), BluetoothHandoffSettings.audioEnabled(app))
+ report("Bluetooth handoff settings changed: calls=${Link.CALLS in wanted} audio=${Link.AUDIO in wanted}")
+ applyLinks(address, wanted, allowNewHolds = !releasePending)
+ }
+ }
+
+ private fun wanted(calls: Boolean, audio: Boolean): Set = buildSet {
+ if (calls) add(Link.CALLS)
+ if (audio) add(Link.AUDIO)
+ }
+
+ private fun applyLinks(address: String, wanted: Set, allowNewHolds: Boolean) {
+ for (link in Link.values()) {
+ when {
+ link in wanted && link in held -> report("${link.label} to $address still off for CarPlay")
+ link in wanted && allowNewHolds -> {
+ held += link
+ guards.getValue(link).reset()
+ withProxy(link) { profile -> if (link in held) turnOff(link, profile, address) }
+ }
+ link in wanted -> report("${link.label} to $address stays connected until the next CarPlay session")
+ link in held -> {
+ held -= link
+ restoreLink(link, address)
+ }
+ else -> report("${link.label} to $address left connected (setting off)")
+ }
+ }
+ }
+
+ /**
+ * Restores the links after [RELEASE_DELAY_MS] unless a session holds them again first. A longer
+ * wait already pending (after startup) is kept.
+ */
+ fun releaseLater() {
+ handler.post {
+ if (target == null) return@post
+ if (releasePending && releaseAt >= SystemClock.uptimeMillis() + RELEASE_DELAY_MS) return@post
+ scheduleRelease(RELEASE_DELAY_MS, "no CarPlay session for ${RELEASE_DELAY_MS / 1000}s")
+ report("Bluetooth handoff: restoring in ${RELEASE_DELAY_MS / 1000}s unless CarPlay reconnects")
+ }
+ }
+
+ private fun scheduleRelease(delayMillis: Long, reason: String) {
+ handler.removeCallbacks(delayedRelease)
+ releaseAt = SystemClock.uptimeMillis() + delayMillis
+ releaseReason = reason
+ handler.postAtTime(delayedRelease, releaseAt)
+ releasePending = true
+ }
+
+ private fun releaseNow(reason: String) {
+ val address = target ?: return
+ handler.removeCallbacks(delayedRelease)
+ report("Bluetooth handoff: restoring links ($reason)")
+ target = null
+ releasePending = false
+ held.clear()
+ unregisterReceiver()
+ for (link in Link.values()) restoreLink(link, address)
+ }
+
+ private fun turnOff(link: Link, profile: BluetoothProfile, address: String) {
+ val device = remoteDevice(address) ?: return
+ val savedKey = priorityKey(link)
+ if (!prefs.contains(savedKey)) {
+ // Off is what this class sets; reading it means an earlier run never restored, so it is
+ // not the user's choice and must not be saved as the original.
+ val read = call(profile, "getPriority", device) as? Int
+ val original = read?.takeIf { it > PRIORITY_OFF } ?: PRIORITY_ON
+ prefs.edit().putString(KEY_HELD_ADDRESS, address).putInt(savedKey, original).commit()
+ }
+ val off = describe(call(profile, "setPriority", device, PRIORITY_OFF))
+ val state = runCatching { profile.getConnectionState(device) }.getOrDefault(-1)
+ val disconnected = if (state == BluetoothProfile.STATE_CONNECTED || state == BluetoothProfile.STATE_CONNECTING) {
+ describe(call(profile, "disconnect", device))
+ } else {
+ "not connected"
+ }
+ report("${link.label} to $address off for CarPlay: priority off $off, disconnect $disconnected")
+ }
+
+ private fun disconnect(link: Link, profile: BluetoothProfile, address: String) {
+ val device = remoteDevice(address) ?: return
+ report("${link.label} to $address disconnect: ${describe(call(profile, "disconnect", device))}")
+ }
+
+ private fun restoreLink(link: Link, address: String) {
+ val key = priorityKey(link)
+ if (!prefs.contains(key)) return
+ val original = prefs.getInt(key, PRIORITY_ON).takeIf { it > PRIORITY_OFF } ?: PRIORITY_ON
+ withProxy(link) { profile ->
+ if (link in held || !prefs.contains(key) || prefs.getString(KEY_HELD_ADDRESS, null) != address) {
+ return@withProxy
+ }
+ val device = remoteDevice(address) ?: return@withProxy
+ val priority = call(profile, "setPriority", device, original)
+ val readBack = call(profile, "getPriority", device)
+ if (priority != true || readBack != original) {
+ report("${link.label} to $address restore pending: priority $original ${describe(priority)}, " +
+ "read back ${describe(readBack)}")
+ return@withProxy
+ }
+ val state = runCatching { profile.getConnectionState(device) }.getOrDefault(-1)
+ // The restriction is gone once the priority reads back; reconnecting is best effort; the
+ // head unit or the phone reconnects on its own (some A2DP sinks reject connects they did
+ // not start), so a rejected connect no longer keeps the restore pending.
+ val reconnect = if (state == BluetoothProfile.STATE_CONNECTED || state == BluetoothProfile.STATE_CONNECTING) {
+ true
+ } else {
+ call(profile, "connect", device)
+ }
+ val editor = prefs.edit().remove(key)
+ if (Link.values().none { other -> other != link && prefs.contains(priorityKey(other)) }) {
+ editor.remove(KEY_HELD_ADDRESS)
+ }
+ val saved = editor.commit()
+ report("${link.label} to $address restored: priority $original ok, " +
+ "reconnect ${describe(reconnect)}, connection state=$state, saved=$saved")
+ }
+ }
+
+ /** Calls a hidden profile method by reflection; returns its result or the failure. */
+ private fun call(profile: BluetoothProfile, method: String, device: BluetoothDevice, vararg extra: Int): Any? =
+ try {
+ val types = arrayOf>(BluetoothDevice::class.java) + extra.map { Int::class.javaPrimitiveType!! }
+ profile.javaClass.getMethod(method, *types).invoke(profile, device, *extra.toTypedArray())
+ } catch (error: Throwable) {
+ (error as? java.lang.reflect.InvocationTargetException)?.targetException ?: error
+ }
+
+ private fun describe(result: Any?): String = when (result) {
+ true -> "ok"
+ false -> "rejected"
+ is Throwable -> "failed ${result.javaClass.simpleName}: ${result.message.orEmpty().take(120)}"
+ else -> result.toString()
+ }
+
+ private fun withProxy(link: Link, action: (BluetoothProfile) -> Unit) {
+ proxies[link]?.let {
+ action(it)
+ return
+ }
+ pending.getOrPut(link) { mutableListOf() }.add(action)
+ if (!requested.add(link)) return
+ val bluetooth = adapter
+ if (bluetooth == null) {
+ report("Bluetooth handoff unavailable: no Bluetooth adapter")
+ return
+ }
+ val ok = runCatching {
+ bluetooth.getProfileProxy(app, object : BluetoothProfile.ServiceListener {
+ override fun onServiceConnected(profileId: Int, service: BluetoothProfile) {
+ handler.post {
+ proxies[link] = service
+ pending.remove(link)?.forEach { it(service) }
+ }
+ }
+
+ override fun onServiceDisconnected(profileId: Int) {
+ handler.post {
+ proxies.remove(link)
+ requested.remove(link)
+ }
+ }
+ }, link.profile)
+ }.getOrDefault(false)
+ if (!ok) {
+ requested.remove(link)
+ pending.remove(link)
+ report("Bluetooth handoff: the head unit has no ${link.label} profile")
+ }
+ }
+
+ private fun registerReceiver() {
+ if (receiverRegistered) return
+ val filter = IntentFilter().apply { Link.values().forEach { addAction(it.stateChanged) } }
+ receiverRegistered = runCatching {
+ @Suppress("DEPRECATION")
+ app.registerReceiver(receiver, filter, null, handler)
+ }.onFailure { report("Bluetooth reconnect watch unavailable: ${it.javaClass.simpleName}") }.isSuccess
+ }
+
+ private fun unregisterReceiver() {
+ if (!receiverRegistered) return
+ receiverRegistered = false
+ runCatching { app.unregisterReceiver(receiver) }
+ }
+
+ private fun remoteDevice(address: String): BluetoothDevice? =
+ runCatching { adapter?.getRemoteDevice(address) }.getOrNull()
+
+ private fun priorityKey(link: Link) = "$PRIORITY_KEY_PREFIX${link.name.lowercase()}"
+
+ private fun report(message: String) {
+ runCatching { diagnostic(message) }
+ }
+
+ companion object {
+ /** Long enough for a settings save or reconnect to bring the session back. */
+ const val RELEASE_DELAY_MS = 15_000L
+ /** Long enough for wireless CarPlay to connect after the head unit powers on. */
+ const val STARTUP_GRACE_MS = 60_000L
+ private const val PRIORITY_OFF = 0
+ private const val PRIORITY_ON = 100
+ private const val KEY_HELD_ADDRESS = "held_address"
+ private const val PRIORITY_KEY_PREFIX = "saved_priority_"
+
+ @Volatile
+ private var instance: BluetoothHandoff? = null
+
+ fun shared(context: Context): BluetoothHandoff =
+ instance ?: synchronized(this) {
+ instance ?: BluetoothHandoff(context).also { instance = it }
+ }
+
+ /** Whether an earlier run left an iPhone link off; reads only the saved state. */
+ fun hasLeftover(context: Context): Boolean {
+ val prefs = BluetoothHandoffSettings.prefs(context)
+ return prefs.all.keys.any { it.startsWith(PRIORITY_KEY_PREFIX) }
+ }
+ }
+}
+
+/** Disconnects at most [maxRetries] reconnects per [windowMillis], so a fight with the head unit stops. */
+internal class HandoffReconnectGuard(
+ private val maxRetries: Int = 3,
+ private val windowMillis: Long = 60_000L,
+) {
+ private val attempts = ArrayDeque()
+
+ fun allow(nowMillis: Long): Boolean {
+ while (attempts.isNotEmpty() && nowMillis - attempts.first() >= windowMillis) attempts.removeFirst()
+ if (attempts.size >= maxRetries) return false
+ attempts.addLast(nowMillis)
+ return true
+ }
+
+ fun reset() = attempts.clear()
+}
+
+internal object BluetoothAdapterAddress {
+ private val MAC = Regex("([0-9A-F]{2}:){5}[0-9A-F]{2}")
+
+ fun isValid(address: String): Boolean = MAC.matches(address) && address != "00:00:00:00:00:00"
+}
diff --git a/shared/src/main/java/com/shilapi/xcertplay/orchestration/CarPlayController.kt b/shared/src/main/java/com/shilapi/xcertplay/orchestration/CarPlayController.kt
index 4f990036..0e7c5521 100644
--- a/shared/src/main/java/com/shilapi/xcertplay/orchestration/CarPlayController.kt
+++ b/shared/src/main/java/com/shilapi/xcertplay/orchestration/CarPlayController.kt
@@ -40,6 +40,8 @@ import com.shilapi.xcertplay.mfi.LocalMfiAuthenticationClient
import com.shilapi.xcertplay.mfi.MfiAuthenticationClient
import com.shilapi.xcertplay.mfi.MfiAuthenticator
import com.shilapi.xcertplay.mfi.RemoteMfiAuthenticationClient
+import com.shilapi.xcertplay.network.BluetoothHandoffSettings
+import com.shilapi.xcertplay.network.BluetoothHandoff
import com.shilapi.xcertplay.network.CarPlayBonjour
import com.shilapi.xcertplay.network.CarPlayVpnService
import com.shilapi.xcertplay.network.LocalOnlyHotspotManager
@@ -207,6 +209,8 @@ class CarPlayController(
@Volatile private var vpnService: CarPlayVpnService? = null
@Volatile private var vpnBound = false
private val wirelessHandoffRequested = AtomicBoolean(false)
+ /** Turns the iPhone's classic Bluetooth links off when disableBluetooth asks for it (optional). */
+ private val bluetoothHandoff = BluetoothHandoff.shared(appContext).also { it.diagnostic = ::debugLog }
private val wirelessTunnelReady = AtomicBoolean(false)
private val wirelessActiveReported = AtomicBoolean(false)
private val wirelessGeneration = AtomicInteger(0)
@@ -241,7 +245,10 @@ class CarPlayController(
}
override fun onSessionEnded(session: AirPlaySession) {
- if (activeSession === session) activeSession = null
+ if (activeSession === session) {
+ activeSession = null
+ bluetoothHandoff.releaseLater()
+ }
debugLog("AirPlay session ended peer=${session.host}")
uiListener?.onSessionEnded(session)
}
@@ -281,6 +288,14 @@ class CarPlayController(
armWirelessHandoffWatchdog(wirelessGeneration.get())
maybeCompleteWirelessHandoff()
}
+ if (
+ config.transport == CarPlayTransport.WIRELESS &&
+ !closed &&
+ activeSession === session &&
+ isBluetoothHandoffCommand(type)
+ ) {
+ handOffFromBluetooth(params["deviceID"]?.toString())
+ }
uiListener?.onCommand(session, type, params)
}
@@ -434,6 +449,7 @@ class CarPlayController(
if (closed) return
closed = true
}
+ runCatching { bluetoothHandoff.releaseLater() }
closeReceivers()
stopFileTransferReceivers()
availabilityPollGeneration.incrementAndGet()
@@ -1480,6 +1496,26 @@ class CarPlayController(
return false
}
+ /**
+ * disableBluetooth asks the accessory to drop its classic Bluetooth links to the iPhone, so
+ * calls and media go through CarPlay instead of the head unit's hands-free and Bluetooth audio.
+ * Which links are dropped follows BluetoothHandoffSettings; with both off nothing changes.
+ */
+ private fun handOffFromBluetooth(iphoneAddress: String?) {
+ if (iphoneAddress.isNullOrBlank()) {
+ debugLog("disableBluetooth without an iPhone Bluetooth address; leaving Bluetooth connected")
+ return
+ }
+ synchronized(this) {
+ if (closed) return
+ bluetoothHandoff.hold(
+ iphoneAddress,
+ calls = BluetoothHandoffSettings.callsEnabled(appContext),
+ audio = BluetoothHandoffSettings.audioEnabled(appContext),
+ )
+ }
+ }
+
private fun isBluetoothHandoffCommand(type: String): Boolean =
type.equals("disableBluetooth", ignoreCase = true) ||
type.equals("disable-bluetooth", ignoreCase = true)
diff --git a/shared/src/test/java/com/shilapi/xcertplay/network/BluetoothHandoffRestoreTest.kt b/shared/src/test/java/com/shilapi/xcertplay/network/BluetoothHandoffRestoreTest.kt
new file mode 100644
index 00000000..20fdc7c3
--- /dev/null
+++ b/shared/src/test/java/com/shilapi/xcertplay/network/BluetoothHandoffRestoreTest.kt
@@ -0,0 +1,283 @@
+package com.shilapi.xcertplay.network
+
+import android.app.Application
+import android.bluetooth.BluetoothDevice
+import android.bluetooth.BluetoothManager
+import android.bluetooth.BluetoothProfile
+import android.content.Context
+import android.os.Looper
+import java.time.Duration
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.atomic.AtomicReference
+import kotlin.concurrent.thread
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+import org.robolectric.util.ReflectionHelpers
+import org.robolectric.util.ReflectionHelpers.ClassParameter
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [29], manifest = Config.NONE)
+class BluetoothHandoffRestoreTest {
+ private lateinit var app: Application
+ private lateinit var handoff: BluetoothHandoff
+ private lateinit var calls: TestProfile
+ private lateinit var audio: TestProfile
+
+ @Before
+ fun setUp() {
+ app = RuntimeEnvironment.getApplication()
+ BluetoothHandoffSettings.prefs(app).edit().clear().commit()
+ val adapter = app.getSystemService(BluetoothManager::class.java).adapter
+ calls = TestProfile()
+ audio = TestProfile()
+ shadowOf(adapter).setProfileProxy(16, calls)
+ shadowOf(adapter).setProfileProxy(11, audio)
+ handoff = ReflectionHelpers.callConstructor(
+ BluetoothHandoff::class.java, ClassParameter.from(Context::class.java, app),
+ )
+ shadowOf(Looper.getMainLooper()).idle()
+ }
+
+ @Test
+ fun queuedHoldIsReleasedBeforeExitReportsSuccess() {
+ handoff.hold(ADDRESS, calls = true, audio = false)
+ assertTrue(restore(checkWaitingForMain = true))
+ assertEquals(100, calls.priority)
+ assertFalse(prefs().contains("held_address"))
+ }
+
+ @Test
+ fun rejectedPriorityRestoreRemainsPending() {
+ hold()
+ calls.rejectRestore = true
+ assertFalse(restore())
+ assertEquals(0, calls.priority)
+ assertTrue(prefs().contains("saved_priority_calls"))
+ assertEquals(ADDRESS, prefs().getString("held_address", null))
+ }
+
+ @Test
+ fun acceptedSetterWithAnUnchangedPriorityRemainsPending() {
+ hold()
+ calls.ignoreRestore = true
+ assertFalse(restore())
+ assertTrue(prefs().contains("saved_priority_calls"))
+ assertEquals(0, calls.connects)
+ }
+
+ @Test
+ fun rejectedReconnectStillCountsAsRestoredOncePriorityReadsBack() {
+ // BYD's A2DP sink rejects connects it did not start; the restriction is already gone once
+ // the priority reads back, so exiting must not wait for a reconnect that never comes.
+ hold()
+ calls.acceptConnect = false
+ assertTrue(restore())
+ assertEquals(100, calls.priority)
+ assertEquals(1, calls.connects)
+ assertFalse(prefs().contains("saved_priority_calls"))
+ }
+
+ @Test
+ fun alreadyConnectedPhoneDoesNotNeedAnotherConnectRequest() {
+ hold()
+ calls.state = BluetoothProfile.STATE_CONNECTED
+ calls.acceptConnect = false
+ assertTrue(restore())
+ assertEquals(0, calls.connects)
+ }
+
+ @Test
+ fun switchingASettingOffDuringASessionRestoresThatLinkAtOnce() {
+ handoff.hold(ADDRESS, calls = true, audio = true)
+ shadowOf(Looper.getMainLooper()).idle()
+ assertEquals(0, calls.priority)
+ assertEquals(0, audio.priority)
+
+ setting(calls = false, audio = true)
+
+ assertEquals(100, calls.priority)
+ assertEquals(1, calls.connects)
+ assertFalse(prefs().contains("saved_priority_calls"))
+ assertEquals("Music stays off", 0, audio.priority)
+ assertEquals(ADDRESS, prefs().getString("held_address", null))
+ }
+
+ @Test
+ fun switchingASettingOnDuringASessionTurnsThatLinkOff() {
+ handoff.hold(ADDRESS, calls = false, audio = false)
+ shadowOf(Looper.getMainLooper()).idle()
+ assertEquals(100, calls.priority)
+
+ setting(calls = true, audio = false)
+
+ assertEquals(0, calls.priority)
+ assertTrue(prefs().contains("saved_priority_calls"))
+ }
+
+ @Test
+ fun afterTheSessionEndedASettingOnlyRestoresAndTheDelayedReleaseStillRuns() {
+ handoff.hold(ADDRESS, calls = true, audio = false)
+ shadowOf(Looper.getMainLooper()).idle()
+ handoff.releaseLater()
+ shadowOf(Looper.getMainLooper()).idle()
+
+ setting(calls = false, audio = true)
+ assertEquals("Switched off: restored now, not after 15 s", 100, calls.priority)
+ assertEquals("No new hold once the session ended", 100, audio.priority)
+
+ setting(calls = true, audio = true)
+ assertEquals(100, calls.priority)
+ shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(BluetoothHandoff.RELEASE_DELAY_MS))
+ assertFalse(prefs().contains("held_address"))
+ }
+
+ @Test
+ fun aSettingChangeWithoutASessionChangesNothing() {
+ setting(calls = false, audio = false)
+ assertEquals(100, calls.priority)
+ assertEquals(0, calls.connects)
+ }
+
+ @Test
+ fun linksLeftOffByAKilledRunWaitForCarPlayBeforeTheyAreRestored() {
+ leaveCallsOff()
+ newHandoff()
+ shadowOf(Looper.getMainLooper()).idle()
+ assertEquals("Still off right after startup", 0, calls.priority)
+
+ shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(BluetoothHandoff.STARTUP_GRACE_MS))
+ shadowOf(Looper.getMainLooper()).idle()
+ assertEquals(100, calls.priority)
+ assertEquals(1, calls.connects)
+ assertFalse(prefs().contains("held_address"))
+ }
+
+ @Test
+ fun carPlayConnectingDuringTheStartupGraceKeepsTheLeftoverLinkOff() {
+ leaveCallsOff()
+ val restarted = newHandoff()
+ restarted.hold(ADDRESS, calls = true, audio = false)
+ shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(2 * BluetoothHandoff.STARTUP_GRACE_MS))
+ assertEquals(0, calls.priority)
+ assertEquals(0, calls.connects)
+ assertTrue(prefs().contains("saved_priority_calls"))
+ }
+
+ @Test
+ fun releaseLaterDoesNotShortenTheStartupGrace() {
+ leaveCallsOff()
+ val restarted = newHandoff()
+ shadowOf(Looper.getMainLooper()).idle()
+ restarted.releaseLater()
+ shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(BluetoothHandoff.RELEASE_DELAY_MS + 1_000))
+ assertEquals(0, calls.priority)
+
+ shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(BluetoothHandoff.STARTUP_GRACE_MS))
+ shadowOf(Looper.getMainLooper()).idle()
+ assertEquals(100, calls.priority)
+ }
+
+ @Test
+ fun powerOnWithoutAutoStartRestoresTheLeftoverAtOnce() {
+ leaveCallsOff()
+ handoff = newHandoff()
+ assertTrue(restore())
+ assertEquals(100, calls.priority)
+ assertFalse(prefs().contains("held_address"))
+ }
+
+ @Test
+ fun aLeftoverIsFoundFromTheSavedStateAlone() {
+ assertFalse(BluetoothHandoff.hasLeftover(app))
+ leaveCallsOff()
+ assertTrue(BluetoothHandoff.hasLeftover(app))
+ }
+
+ /** What a run killed by the head unit's power-off leaves: the priority off and its original saved. */
+ private fun leaveCallsOff() {
+ prefs().edit().putString("held_address", ADDRESS).putInt("saved_priority_calls", 100).commit()
+ calls.priority = 0
+ }
+
+ private fun newHandoff(): BluetoothHandoff = ReflectionHelpers.callConstructor(
+ BluetoothHandoff::class.java, ClassParameter.from(Context::class.java, app),
+ )
+
+ private fun setting(calls: Boolean, audio: Boolean) {
+ prefs().edit().putBoolean("disconnect_calls", calls).putBoolean("disconnect_audio", audio).commit()
+ handoff.settingsChanged()
+ shadowOf(Looper.getMainLooper()).idle()
+ }
+
+ private fun hold() {
+ handoff.hold(ADDRESS, calls = true, audio = false)
+ shadowOf(Looper.getMainLooper()).idle()
+ assertEquals(0, calls.priority)
+ }
+
+ private fun prefs() = BluetoothHandoffSettings.prefs(app)
+
+ private fun restore(checkWaitingForMain: Boolean = false): Boolean {
+ val result = AtomicReference()
+ val failure = AtomicReference()
+ val started = CountDownLatch(1)
+ val worker = thread(isDaemon = true) {
+ started.countDown()
+ try {
+ result.set(handoff.releaseNowBlocking(500))
+ } catch (error: Throwable) {
+ failure.set(error)
+ }
+ }
+ started.await()
+ if (checkWaitingForMain) {
+ worker.join(30)
+ assertTrue("Exit must wait for its main-thread release request", worker.isAlive)
+ assertNull(result.get())
+ }
+ val deadline = System.nanoTime() + 2_000_000_000L
+ while (worker.isAlive && System.nanoTime() < deadline) {
+ shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(10))
+ Thread.sleep(2)
+ }
+ if (worker.isAlive) worker.interrupt()
+ worker.join(500)
+ failure.get()?.let { throw AssertionError(it) }
+ return requireNotNull(result.get()) { "Bluetooth restore did not finish" }
+ }
+
+ /** Public methods stand in for BYD's hidden Bluetooth profile API. */
+ class TestProfile : BluetoothProfile {
+ var priority = 100
+ var state = BluetoothProfile.STATE_DISCONNECTED
+ var connects = 0
+ var rejectRestore = false
+ var ignoreRestore = false
+ var acceptConnect = true
+
+ fun getPriority(device: BluetoothDevice): Int = priority
+ fun setPriority(device: BluetoothDevice, value: Int): Boolean {
+ if (value > 0 && rejectRestore) return false
+ if (value == 0 || !ignoreRestore) priority = value
+ return true
+ }
+ fun connect(device: BluetoothDevice): Boolean { connects++; return acceptConnect }
+ fun disconnect(device: BluetoothDevice): Boolean { state = BluetoothProfile.STATE_DISCONNECTED; return true }
+ override fun getConnectionState(device: BluetoothDevice): Int = state
+ override fun getConnectedDevices(): List = emptyList()
+ override fun getDevicesMatchingConnectionStates(states: IntArray): List = emptyList()
+ }
+
+ private companion object {
+ const val ADDRESS = "14:1A:97:73:B3:F6"
+ }
+}
diff --git a/shared/src/test/java/com/shilapi/xcertplay/network/BluetoothHandoffTest.kt b/shared/src/test/java/com/shilapi/xcertplay/network/BluetoothHandoffTest.kt
new file mode 100644
index 00000000..2578fb52
--- /dev/null
+++ b/shared/src/test/java/com/shilapi/xcertplay/network/BluetoothHandoffTest.kt
@@ -0,0 +1,27 @@
+package com.shilapi.xcertplay.network
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class BluetoothHandoffTest {
+ @Test
+ fun reconnectsAreDisconnectedAgainOnlyWithinTheLimit() {
+ val guard = HandoffReconnectGuard(maxRetries = 3, windowMillis = 60_000L)
+ assertTrue(guard.allow(0))
+ assertTrue(guard.allow(1_000))
+ assertTrue(guard.allow(2_000))
+ assertFalse(guard.allow(3_000))
+ // The oldest attempt leaves the window, so one more is allowed.
+ assertTrue(guard.allow(60_000))
+ guard.reset()
+ assertTrue(guard.allow(60_001))
+ }
+
+ @Test
+ fun onlyRealBluetoothAddressesAreHeld() {
+ assertTrue(BluetoothAdapterAddress.isValid("14:1A:97:73:B3:F6"))
+ assertFalse(BluetoothAdapterAddress.isValid("00:00:00:00:00:00"))
+ assertFalse(BluetoothAdapterAddress.isValid("not-an-address"))
+ }
+}