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"))
+ }
+}