From d647e9ebc335d75811b7120c0e24777b10094d9a Mon Sep 17 00:00:00 2001 From: sleepysec <103225769+sleepysec@users.noreply.github.com> Date: Sat, 26 Sep 2026 15:22:06 -0400 Subject: [PATCH 1/6] android: don't take over AirPods on service start Opening LibrePods (or the service restarting after boot) pulled AirPods away from another device even with every Auto-Connect preference off. On start the service calls fetchUuidsWithSdp() on bonded devices. The SDP query briefly brings up an ACL link and emits ACL_CONNECTED/UUID broadcasts, which were treated as "AirPods connected to this phone": the AACP socket was opened, and the first battery/ear-detection packet then called connectAudio(), moving audio to the phone. - Open the AACP socket from connection broadcasts only when A2DP or HFP is actually connected to these AirPods, and re-check when either profile connects so normal connections still attach automatically. - Startup check: require these AirPods to be in A2DP's connected list, not just any A2DP device. - Gate the BLE "Disconnected" reconnect on takeover_when_disconnected. - Battery and ear-detection paths only reconnect audio that LibrePods itself disconnected (case / not wearing) or claimed, never after another device took ownership. - Serialize connectToSocket() since several broadcasts can race. Fixes #784 Co-Authored-By: Claude Opus 5.5 --- .../librepods/services/AirPodsService.kt | 69 ++++++++++++++++--- 1 file changed, 61 insertions(+), 8 deletions(-) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt index 0cf08c11d..38f49632d 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt @@ -30,6 +30,7 @@ import android.app.NotificationManager import android.app.PendingIntent import android.app.Service import android.appwidget.AppWidgetManager +import android.bluetooth.BluetoothA2dp import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothHeadset @@ -160,6 +161,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList var cameraActive = false private var disconnectedBecauseReversed = false private var otherDeviceTookOver = false + // Background paths may only restore audio LibrePods itself released (case, not wearing) + // or claimed; otherwise they would pull audio back from whichever device is using it. + private var canAutoConnectAudio = false data class ServiceConfig( var deviceName: String = "AirPods", @@ -246,7 +250,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList override fun onDeviceStatusChanged( device: BLEManager.AirPodsStatus, previousStatus: BLEManager.AirPodsStatus? ) { - if (device.connectionState == "Disconnected" && BluetoothConnectionManager.aacpSocket?.isConnected != true) { // should never happen unless android messes up and sends us a stale broadcast + // Only claim idle AirPods when the user opted into Disconnected-state takeover. + if (config.takeoverWhenDisconnected && device.connectionState == "Disconnected" && + BluetoothConnectionManager.aacpSocket?.isConnected != true + ) { Log.d(TAG, "Seems no device has taken over, we will.") val bluetoothManager = getSystemService(BluetoothManager::class.java) val bluetoothAdapter = bluetoothManager.adapter @@ -255,6 +262,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList "mac_address", "" ) ?: "" ) + canAutoConnectAudio = true connectToSocket(bluetoothAdapter, bluetoothDevice) } Log.d(TAG, "Device status changed") @@ -678,10 +686,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList // Log.d("AirPodsCrossDevice", CrossDevice.isAvailable.toString()) // if (!CrossDevice.isAvailable) { Log.d(TAG, "${config.deviceName} connected") - CoroutineScope(Dispatchers.IO).launch { - val bluetoothManager = getSystemService(BluetoothManager::class.java) - connectToSocket(bluetoothManager.adapter, device!!) - } + // ACL/UUID broadcasts can result from service discovery alone. + connectToSocketIfAudioConnected(device!!) Log.d(TAG, "Setting metadata") setMetadatas(device!!) // isConnectedLocally = true @@ -694,6 +700,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } else if (intent?.action == AirPodsNotifications.AIRPODS_DISCONNECTED) { device = null // isConnectedLocally = false + canAutoConnectAudio = false popupShown = false updateNotificationContent(false) aacpManager.disconnected() @@ -763,7 +770,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { if (profile == BluetoothProfile.A2DP) { val connectedDevices = proxy.connectedDevices - if (connectedDevices.isNotEmpty()) { + if (device in connectedDevices) { // if (!CrossDevice.isAvailable) { CoroutineScope(Dispatchers.IO).launch { connectToSocket(bluetoothAdapter, device) @@ -882,7 +889,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList if (batteryNotification.getBattery()[0].status == BatteryStatus.CHARGING && batteryNotification.getBattery()[1].status == BatteryStatus.CHARGING) { disconnectAudio(this@AirPodsService, device) - } else { + canAutoConnectAudio = true + } else if (canAutoConnectAudio) { + canAutoConnectAudio = false connectAudio(this@AirPodsService, device) } } @@ -944,6 +953,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList MediaController.sendPause() MediaController.pausedForOtherDevice = true otherDeviceTookOver = true + canAutoConnectAudio = false disconnectAudio( this@AirPodsService, device ) @@ -965,6 +975,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList byteArrayOf(0x00) ) otherDeviceTookOver = true + canAutoConnectAudio = false disconnectAudio( this@AirPodsService, device ) @@ -1265,7 +1276,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } if (newInEarData.contains(true) && inEarData == listOf(false, false)) { - connectAudio(this@AirPodsService, device) + if (canAutoConnectAudio) { + canAutoConnectAudio = false + connectAudio(this@AirPodsService, device) + } justEnabledA2dp = true registerA2dpConnectionReceiver() if (MediaController.getMusicActive()) { @@ -1275,6 +1289,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList MediaController.sendPause(force = true) if (config.disconnectWhenNotWearing) { disconnectAudio(this@AirPodsService, device) + canAutoConnectAudio = true } } val wasNone = inEarData == listOf(false, false) @@ -2413,6 +2428,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } else { bluetoothDevice.fetchUuidsWithSdp() } + } else if ((BluetoothA2dp.ACTION_CONNECTION_STATE_CHANGED == action || + BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED == action) && + intent.getIntExtra(BluetoothProfile.EXTRA_STATE, BluetoothProfile.STATE_DISCONNECTED) == + BluetoothProfile.STATE_CONNECTED + ) { + val savedMac = context?.getSharedPreferences("settings", MODE_PRIVATE) + ?.getString("mac_address", "") ?: "" + if (bluetoothDevice.uuids?.contains(uuid) == true || bluetoothDevice.address == savedMac) { + context?.sendBroadcast(Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED).apply { + putExtra("name", name) + putExtra("device", bluetoothDevice) + }) + } } else if ("android.bluetooth.device.action.UUID" == action) { val savedMac = context?.getSharedPreferences("settings", MODE_PRIVATE) ?.getString("mac_address", "") ?: "" @@ -2633,6 +2661,31 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList // CrossDevice.isAvailable = false } + /** Attach the control socket only when these AirPods already have an audio profile. */ + @SuppressLint("MissingPermission") + private fun connectToSocketIfAudioConnected(device: BluetoothDevice) { + val adapter = getSystemService(BluetoothManager::class.java).adapter + // A call-only connection is valid too; do not rely solely on A2DP. + for (profile in listOf(BluetoothProfile.A2DP, BluetoothProfile.HEADSET)) { + adapter.getProfileProxy(this, object : BluetoothProfile.ServiceListener { + override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { + CoroutineScope(Dispatchers.IO).launch { + try { + if (proxy.getConnectionState(device) == BluetoothProfile.STATE_CONNECTED) { + connectToSocket(adapter, device) + } + } finally { + adapter.closeProfileProxy(profile, proxy) + } + } + } + + override fun onServiceDisconnected(profile: Int) {} + }, profile) + } + } + + @Synchronized @SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag") fun connectToSocket( adapter: BluetoothAdapter, device: BluetoothDevice, manual: Boolean = false From ecddeaa67820850455731785818aeb30668ead6f Mon Sep 17 00:00:00 2001 From: sleepysec <103225769+sleepysec@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:10:59 -0400 Subject: [PATCH 2/6] android: only restore audio after a release the platform accepted Follow-up to the previous commit, addressing review feedback: - Background restore was armed right after calling disconnectAudio(), even if nothing was released (no BLUETOOTH_PRIVILEGED, audio already on another device). Now it is armed only when A2DP was connected and setConnectionPolicy(FORBIDDEN) returned true, and a callback still in flight when permission is revoked (ownership loss, explicit connect) can't re-arm it. - The "play when A2DP connects" receiver is registered only when the ear-detection path actually restores audio, replaces any previous one, and expires after 10 s, so a later manual connection doesn't auto-play. - On privileged installs the forbidden policy outlives the AACP socket (e.g. buds in case, lid closed), leaving no audio profile to pass the new socket gate. Keep the release across socket drops and attach the socket for that device, so taking the buds out restores audio as before. Co-Authored-By: Claude Opus 5.5 --- .../librepods/services/AirPodsService.kt | 90 ++++++++++++++----- 1 file changed, 66 insertions(+), 24 deletions(-) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt index 38f49632d..38e533f96 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt @@ -161,9 +161,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList var cameraActive = false private var disconnectedBecauseReversed = false private var otherDeviceTookOver = false - // Background paths may only restore audio LibrePods itself released (case, not wearing) - // or claimed; otherwise they would pull audio back from whichever device is using it. - private var canAutoConnectAudio = false + // Background paths may only restore audio LibrePods itself released (case, not wearing) or + // claimed; otherwise they would pull audio back from whichever device is using it. + @Volatile private var claimedAudioAddress: String? = null + // Kept across socket drops: the connection policy LibrePods forbade blocks audio until restored. + @Volatile private var releasedAudioAddress: String? = null + // Bumped when restore permission is revoked, so a release callback already in flight can't re-grant it. + @Volatile private var audioRestoreEpoch = 0 + private var pendingA2dpPlayReceiver: BroadcastReceiver? = null data class ServiceConfig( var deviceName: String = "AirPods", @@ -262,7 +267,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList "mac_address", "" ) ?: "" ) - canAutoConnectAudio = true + claimedAudioAddress = bluetoothDevice.address connectToSocket(bluetoothAdapter, bluetoothDevice) } Log.d(TAG, "Device status changed") @@ -700,7 +705,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } else if (intent?.action == AirPodsNotifications.AIRPODS_DISCONNECTED) { device = null // isConnectedLocally = false - canAutoConnectAudio = false + claimedAudioAddress = null popupShown = false updateNotificationContent(false) aacpManager.disconnected() @@ -888,11 +893,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } if (batteryNotification.getBattery()[0].status == BatteryStatus.CHARGING && batteryNotification.getBattery()[1].status == BatteryStatus.CHARGING) { - disconnectAudio(this@AirPodsService, device) - canAutoConnectAudio = true - } else if (canAutoConnectAudio) { - canAutoConnectAudio = false - connectAudio(this@AirPodsService, device) + disconnectAudio(this@AirPodsService, device, restorable = true) + } else { + restoreAudio() } } @@ -953,7 +956,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList MediaController.sendPause() MediaController.pausedForOtherDevice = true otherDeviceTookOver = true - canAutoConnectAudio = false + forgetRestorableAudio() disconnectAudio( this@AirPodsService, device ) @@ -975,7 +978,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList byteArrayOf(0x00) ) otherDeviceTookOver = true - canAutoConnectAudio = false + forgetRestorableAudio() disconnectAudio( this@AirPodsService, device ) @@ -1276,20 +1279,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } if (newInEarData.contains(true) && inEarData == listOf(false, false)) { - if (canAutoConnectAudio) { - canAutoConnectAudio = false - connectAudio(this@AirPodsService, device) - } - justEnabledA2dp = true - registerA2dpConnectionReceiver() + justEnabledA2dp = restoreAudio(playWhenConnected = true) if (MediaController.getMusicActive()) { MediaController.userPlayedTheMedia = true } } else if (newInEarData == listOf(false, false)) { MediaController.sendPause(force = true) if (config.disconnectWhenNotWearing) { - disconnectAudio(this@AirPodsService, device) - canAutoConnectAudio = true + disconnectAudio(this@AirPodsService, device, restorable = true) } } val wasNone = inEarData == listOf(false, false) @@ -1329,7 +1326,34 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } } - private fun registerA2dpConnectionReceiver() { + private fun forgetRestorableAudio() { + audioRestoreEpoch++ + claimedAudioAddress = null + releasedAudioAddress = null + } + + /** Reconnect audio only if LibrePods released or claimed it; returns whether it did. */ + private fun restoreAudio(playWhenConnected: Boolean = false): Boolean { + val target = device ?: return false + if (target.address != releasedAudioAddress && target.address != claimedAudioAddress) return false + connectAudio(this, target) + if (playWhenConnected) registerA2dpConnectionReceiver() + return true + } + + private fun unregisterA2dpConnectionReceiver() { + pendingA2dpPlayReceiver?.let { + try { + unregisterReceiver(it) + } catch (e: IllegalArgumentException) { + Log.w(TAG, "A2DP play receiver was not registered", e) + } + } + pendingA2dpPlayReceiver = null + } + + private fun registerA2dpConnectionReceiver() = Handler(Looper.getMainLooper()).post { + unregisterA2dpConnectionReceiver() val a2dpConnectionStateReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { if (intent.action == "android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED") { @@ -1353,7 +1377,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList MediaController.sendPlay() MediaController.iPausedTheMedia = false - context.unregisterReceiver(this) + unregisterA2dpConnectionReceiver() } } } @@ -1366,6 +1390,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } else { registerReceiver(a2dpConnectionStateReceiver, a2dpIntentFilter) } + pendingA2dpPlayReceiver = a2dpConnectionStateReceiver + // If the restore never connects, a later manual connection must not auto-play. + Handler(Looper.getMainLooper()).postDelayed({ + if (pendingA2dpPlayReceiver === a2dpConnectionStateReceiver) unregisterA2dpConnectionReceiver() + }, 10_000) } private fun initializeConfig() { @@ -2665,6 +2694,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList @SuppressLint("MissingPermission") private fun connectToSocketIfAudioConnected(device: BluetoothDevice) { val adapter = getSystemService(BluetoothManager::class.java).adapter + if (device.address == releasedAudioAddress) { + // Audio is blocked by LibrePods' own release; attach so it can be restored. + CoroutineScope(Dispatchers.IO).launch { connectToSocket(adapter, device) } + return + } // A call-only connection is valid too; do not rely solely on A2DP. for (profile in listOf(BluetoothProfile.A2DP, BluetoothProfile.HEADSET)) { adapter.getProfileProxy(this, object : BluetoothProfile.ServiceListener { @@ -3010,7 +3044,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList return ancNotification.status } - fun disconnectAudio(context: Context, device: BluetoothDevice?) { + fun disconnectAudio(context: Context, device: BluetoothDevice?, restorable: Boolean = false) { + val restoreEpoch = audioRestoreEpoch val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter if (checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) { bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { @@ -3025,7 +3060,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList "setConnectionPolicy", BluetoothDevice::class.java, Int::class.java ) Log.d(TAG, "calling A2DP.setConnectionPolicy for ${device?.address} to 0") - method.invoke(proxy, device, 0) + val accepted = method.invoke(proxy, device, 0) == true + // Only a release the platform accepted may be undone in the background. + if (restorable && accepted && restoreEpoch == audioRestoreEpoch) { + releasedAudioAddress = device?.address + } } catch (e: Exception) { e.printStackTrace() } finally { @@ -3068,6 +3107,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } fun connectAudio(context: Context, device: BluetoothDevice?) { + // Connecting re-allows the connection policy, so any pending release is resolved. + forgetRestorableAudio() val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { @@ -3159,6 +3200,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList @SuppressLint("MissingPermission") override fun onDestroy() { + unregisterA2dpConnectionReceiver() clearPacketLogs() Log.d(TAG, "Service stopped is being destroyed for some reason!") From 2cff6c27acf8aa939ac6ae4a893b84101205ea7c Mon Sep 17 00:00:00 2001 From: sleepysec <103225769+sleepysec@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:24:39 -0400 Subject: [PATCH 3/6] android: close remaining audio-restore races - After ownership loss, block new restore grants until audio is back on this phone (explicit connect, a connected A2DP/HFP profile, or an opted-in Disconnected claim). Previously a charging/out-of-ear packet arriving while A2DP was still connected or disconnecting could grant a fresh release and later pull audio back from the new owner. A restorable release now also requires STATE_CONNECTED, and the epoch check and grant happen under a lock with revocation. - Background restores carry the epoch into connectAudio(), whose profile callbacks drop out if it was revoked before they ran. Explicit connects are unaffected. - The Play-on-connect receiver is registered in the A2DP callback right before a restore's connect, keyed to that device and epoch, and removed if the connect is rejected or throws, on revocation (ownership loss, explicit connect) and on socket closure. The 10 s timeout remains a fallback. Co-Authored-By: Claude Opus 5.5 --- .../librepods/services/AirPodsService.kt | 105 +++++++++++++----- 1 file changed, 77 insertions(+), 28 deletions(-) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt index 38e533f96..adedab9fa 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt @@ -166,8 +166,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList @Volatile private var claimedAudioAddress: String? = null // Kept across socket drops: the connection policy LibrePods forbade blocks audio until restored. @Volatile private var releasedAudioAddress: String? = null - // Bumped when restore permission is revoked, so a release callback already in flight can't re-grant it. + // Bumped when restore permission is revoked or consumed; callbacks carrying an older value must not act. @Volatile private var audioRestoreEpoch = 0 + // After another device takes over, no new release may be granted until audio is back on this phone. + @Volatile private var audioOwnershipLost = false + private val audioRestoreLock = Any() private var pendingA2dpPlayReceiver: BroadcastReceiver? = null data class ServiceConfig( @@ -267,7 +270,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList "mac_address", "" ) ?: "" ) - claimedAudioAddress = bluetoothDevice.address + synchronized(audioRestoreLock) { + claimedAudioAddress = bluetoothDevice.address + audioOwnershipLost = false + } connectToSocket(bluetoothAdapter, bluetoothDevice) } Log.d(TAG, "Device status changed") @@ -706,6 +712,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList device = null // isConnectedLocally = false claimedAudioAddress = null + unregisterA2dpConnectionReceiver() popupShown = false updateNotificationContent(false) aacpManager.disconnected() @@ -956,7 +963,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList MediaController.sendPause() MediaController.pausedForOtherDevice = true otherDeviceTookOver = true - forgetRestorableAudio() + forgetRestorableAudio(ownershipLost = true) disconnectAudio( this@AirPodsService, device ) @@ -978,7 +985,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList byteArrayOf(0x00) ) otherDeviceTookOver = true - forgetRestorableAudio() + forgetRestorableAudio(ownershipLost = true) disconnectAudio( this@AirPodsService, device ) @@ -1326,18 +1333,28 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } } - private fun forgetRestorableAudio() { - audioRestoreEpoch++ - claimedAudioAddress = null - releasedAudioAddress = null + private fun forgetRestorableAudio(ownershipLost: Boolean = false) { + synchronized(audioRestoreLock) { + audioRestoreEpoch++ + claimedAudioAddress = null + releasedAudioAddress = null + audioOwnershipLost = ownershipLost + } + Handler(Looper.getMainLooper()).post { unregisterA2dpConnectionReceiver() } } /** Reconnect audio only if LibrePods released or claimed it; returns whether it did. */ private fun restoreAudio(playWhenConnected: Boolean = false): Boolean { val target = device ?: return false - if (target.address != releasedAudioAddress && target.address != claimedAudioAddress) return false - connectAudio(this, target) - if (playWhenConnected) registerA2dpConnectionReceiver() + val restoreEpoch = synchronized(audioRestoreLock) { + if (audioOwnershipLost || + (target.address != releasedAudioAddress && target.address != claimedAudioAddress) + ) return false + claimedAudioAddress = null + releasedAudioAddress = null + ++audioRestoreEpoch + } + connectAudio(this, target, restoreEpoch, playWhenConnected) return true } @@ -1352,10 +1369,16 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList pendingA2dpPlayReceiver = null } - private fun registerA2dpConnectionReceiver() = Handler(Looper.getMainLooper()).post { + /** Main thread only: plays once the restore identified by [restoreEpoch] connects [target]. */ + private fun registerA2dpConnectionReceiver(target: BluetoothDevice, restoreEpoch: Int) { unregisterA2dpConnectionReceiver() + if (restoreEpoch != audioRestoreEpoch) return val a2dpConnectionStateReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { + if (restoreEpoch != audioRestoreEpoch) { + unregisterA2dpConnectionReceiver() + return + } if (intent.action == "android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED") { val state = intent.getIntExtra( BluetoothProfile.EXTRA_STATE, BluetoothProfile.STATE_DISCONNECTED @@ -1371,7 +1394,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList "A2DP state changed: $previousState -> $state for device: ${device?.address}" ) - if (state == BluetoothProfile.STATE_CONNECTED && previousState != BluetoothProfile.STATE_CONNECTED && device?.address == this@AirPodsService.device?.address) { + if (state == BluetoothProfile.STATE_CONNECTED && previousState != BluetoothProfile.STATE_CONNECTED && device?.address == target.address) { Log.d("MediaController", "A2DP connected, sending play command") MediaController.sendPlay() @@ -2706,6 +2729,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList CoroutineScope(Dispatchers.IO).launch { try { if (proxy.getConnectionState(device) == BluetoothProfile.STATE_CONNECTED) { + // Audio is on this phone again, so any other device's takeover has ended. + audioOwnershipLost = false connectToSocket(adapter, device) } } finally { @@ -3052,7 +3077,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { if (profile == BluetoothProfile.A2DP) { try { - if (proxy.getConnectionState(device) == BluetoothProfile.STATE_DISCONNECTED) { + val state = proxy.getConnectionState(device) + if (state == BluetoothProfile.STATE_DISCONNECTED) { Log.d(TAG, "Already disconnected from A2DP") return } @@ -3061,9 +3087,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList ) Log.d(TAG, "calling A2DP.setConnectionPolicy for ${device?.address} to 0") val accepted = method.invoke(proxy, device, 0) == true - // Only a release the platform accepted may be undone in the background. - if (restorable && accepted && restoreEpoch == audioRestoreEpoch) { - releasedAudioAddress = device?.address + // Only an accepted release of audio that was fully on this phone may be + // undone in the background, and never once another device took over. + if (restorable && accepted && state == BluetoothProfile.STATE_CONNECTED) { + synchronized(audioRestoreLock) { + if (restoreEpoch == audioRestoreEpoch && !audioOwnershipLost) { + releasedAudioAddress = device?.address + } + } } } catch (e: Exception) { e.printStackTrace() @@ -3107,12 +3138,33 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } fun connectAudio(context: Context, device: BluetoothDevice?) { - // Connecting re-allows the connection policy, so any pending release is resolved. + // An explicit connect re-allows the connection policy and supersedes any background restore. forgetRestorableAudio() + connectAudio(context, device, restoreEpoch = null, playWhenConnected = false) + } + + /** [restoreEpoch] is set for background restores, which must not act once it is outdated. */ + private fun connectAudio( + context: Context, device: BluetoothDevice?, restoreEpoch: Int?, playWhenConnected: Boolean + ) { val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter + fun restoreRevoked() = restoreEpoch != null && restoreEpoch != audioRestoreEpoch + fun connectA2dp(proxy: BluetoothProfile) { + val connectMethod = proxy.javaClass.getMethod("connect", BluetoothDevice::class.java) + val playAfterRestore = playWhenConnected && restoreEpoch != null && device != null + if (playAfterRestore) registerA2dpConnectionReceiver(device!!, restoreEpoch!!) + // A rejected connect never produces the broadcast the Play receiver waits for. + if (connectMethod.invoke(proxy, device) != true && playAfterRestore) { + unregisterA2dpConnectionReceiver() + } + } bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { + if (restoreRevoked()) { + bluetoothAdapter.closeProfileProxy(profile, proxy) + return + } if (profile == BluetoothProfile.A2DP) { if (context.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) { try { @@ -3124,12 +3176,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList Log.d(TAG, "calling A2DP.setConnectionPolicy for ${device?.address} to 100") policyMethod.invoke(proxy, device, 100) - val connectMethod = - proxy.javaClass.getMethod("connect", BluetoothDevice::class.java) - connectMethod.invoke( - proxy, device - ) + connectA2dp(proxy) } catch (e: Exception) { + unregisterA2dpConnectionReceiver() e.printStackTrace() } finally { bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy) @@ -3139,11 +3188,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } } else { - val connectMethod = - proxy.javaClass.getMethod("connect", BluetoothDevice::class.java) - connectMethod.invoke( - proxy, device - ) + connectA2dp(proxy) Log.d(TAG, "not setting connection policy for A2DP, no BLUETOOTH_PRIVILEGED permission. just called connect") } } @@ -3154,6 +3199,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { + if (restoreRevoked()) { + bluetoothAdapter.closeProfileProxy(profile, proxy) + return + } if (profile == BluetoothProfile.HEADSET) { if (checkSelfPermission("android.permission.MODIFY_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) { try { From 4c8c09cfae16e5217549d92ccda1d96a7ab4b912 Mon Sep 17 00:00:00 2001 From: sleepysec <103225769+sleepysec@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:44:29 -0400 Subject: [PATCH 4/6] android: tighten ownership-loss reset and restore teardown - Clear the ownership-loss barrier only on an A2DP/HFP transition to CONNECTED (plus explicit connect / opted-in claim), not from a profile state query, which could return a pre-loss connection that is still tearing down and run after the barrier was set. - Background restores also require the AACP socket that requested them, so a callback delivered after the socket closed does nothing. The release itself is still kept across socket drops for case recovery. - The app's explicit Disconnect revokes all background restore permission, and onDestroy invalidates in-flight restores. - Record accepted HFP releases too, so call-only connections released on case/not-wearing can be restored like A2DP ones. Co-Authored-By: Claude Opus 5.5 --- .../librepods/services/AirPodsService.kt | 47 ++++++++++++++----- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt index adedab9fa..d9ae1fdc2 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt @@ -136,6 +136,7 @@ import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.time.Duration.Companion.milliseconds private const val TAG = "AirPodsService" +private const val EXTRA_NEW_AUDIO_CONNECTION = "new_audio_connection" object ServiceManager { private var service: AirPodsService? = null @@ -688,6 +689,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } else { intent.getParcelableExtra("device") as BluetoothDevice? } + if (intent.getBooleanExtra(EXTRA_NEW_AUDIO_CONNECTION, false)) { + // Audio newly connected to this phone, so any other device's takeover has ended. + synchronized(audioRestoreLock) { audioOwnershipLost = false } + } if (config.deviceName == "AirPods" && device?.name != null) { config.deviceName = device?.name ?: "AirPods" @@ -2488,9 +2493,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList val savedMac = context?.getSharedPreferences("settings", MODE_PRIVATE) ?.getString("mac_address", "") ?: "" if (bluetoothDevice.uuids?.contains(uuid) == true || bluetoothDevice.address == savedMac) { + val previousState = intent.getIntExtra( + BluetoothProfile.EXTRA_PREVIOUS_STATE, BluetoothProfile.STATE_DISCONNECTED + ) context?.sendBroadcast(Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED).apply { putExtra("name", name) putExtra("device", bluetoothDevice) + // A transition, unlike a state query, can only mean a new local connection. + putExtra(EXTRA_NEW_AUDIO_CONNECTION, previousState != BluetoothProfile.STATE_CONNECTED) }) } } else if ("android.bluetooth.device.action.UUID" == action) { @@ -2729,8 +2739,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList CoroutineScope(Dispatchers.IO).launch { try { if (proxy.getConnectionState(device) == BluetoothProfile.STATE_CONNECTED) { - // Audio is on this phone again, so any other device's takeover has ended. - audioOwnershipLost = false connectToSocket(adapter, device) } } finally { @@ -2985,6 +2993,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } fun disconnectAirPods() { + // The user asked to disconnect, so nothing may reconnect audio in the background. + forgetRestorableAudio() if (BluetoothConnectionManager.aacpSocket == null) return try { BluetoothConnectionManager.aacpSocket?.close() @@ -3087,15 +3097,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList ) Log.d(TAG, "calling A2DP.setConnectionPolicy for ${device?.address} to 0") val accepted = method.invoke(proxy, device, 0) == true - // Only an accepted release of audio that was fully on this phone may be - // undone in the background, and never once another device took over. - if (restorable && accepted && state == BluetoothProfile.STATE_CONNECTED) { - synchronized(audioRestoreLock) { - if (restoreEpoch == audioRestoreEpoch && !audioOwnershipLost) { - releasedAudioAddress = device?.address - } - } - } + if (restorable) grantAudioRelease(device, state, accepted, restoreEpoch) } catch (e: Exception) { e.printStackTrace() } finally { @@ -3120,8 +3122,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList BluetoothDevice::class.java, Int::class.java ) + // Call-only connections are released too, so they must be restorable as well. + val state = proxy.getConnectionState(device) Log.d(TAG, "calling HEADSET.setConnectionPolicy for ${device?.address} to 0") - method.invoke(proxy, device, 0) + val accepted = method.invoke(proxy, device, 0) == true + if (restorable) grantAudioRelease(device, state, accepted, restoreEpoch) } catch (e: Exception) { e.printStackTrace() } finally { @@ -3137,6 +3142,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } } + /** + * Only an accepted release of a profile that was fully connected to this phone may be undone + * in the background, and never once another device took over. + */ + private fun grantAudioRelease(device: BluetoothDevice?, state: Int, accepted: Boolean, restoreEpoch: Int) { + if (!accepted || state != BluetoothProfile.STATE_CONNECTED) return + synchronized(audioRestoreLock) { + if (restoreEpoch == audioRestoreEpoch && !audioOwnershipLost) { + releasedAudioAddress = device?.address + } + } + } + fun connectAudio(context: Context, device: BluetoothDevice?) { // An explicit connect re-allows the connection policy and supersedes any background restore. forgetRestorableAudio() @@ -3148,7 +3166,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList context: Context, device: BluetoothDevice?, restoreEpoch: Int?, playWhenConnected: Boolean ) { val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter - fun restoreRevoked() = restoreEpoch != null && restoreEpoch != audioRestoreEpoch + // Restores also need the control link that requested them; it may have closed meanwhile. + fun restoreRevoked() = restoreEpoch != null && + (restoreEpoch != audioRestoreEpoch || BluetoothConnectionManager.aacpSocket?.isConnected != true) fun connectA2dp(proxy: BluetoothProfile) { val connectMethod = proxy.javaClass.getMethod("connect", BluetoothDevice::class.java) val playAfterRestore = playWhenConnected && restoreEpoch != null && device != null @@ -3249,6 +3269,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList @SuppressLint("MissingPermission") override fun onDestroy() { + synchronized(audioRestoreLock) { audioRestoreEpoch++ } unregisterA2dpConnectionReceiver() clearPacketLogs() Log.d(TAG, "Service stopped is being destroyed for some reason!") From aaaed1869c10ca6f22fed02154a34e8be38e5749 Mon Sep 17 00:00:00 2001 From: sleepysec <103225769+sleepysec@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:56:12 -0400 Subject: [PATCH 5/6] android: restore only the profiles LibrePods released - Track which profiles (A2DP / HFP) LibrePods forbade, and have a background restore re-allow only those. An HFP-only release no longer enables media audio the user had disabled (and vice versa). - Keep each release record until its policy is successfully re-allowed, instead of clearing it when the restore is queued. A restore cancelled before its callbacks run (e.g. the socket closed) no longer strands a FORBIDDEN policy with nothing left to reattach the socket for it. Ownership loss and the app's Disconnect still revoke everything. - Explicit connects keep connecting both profiles as before. Co-Authored-By: Claude Opus 5.5 --- .../librepods/services/AirPodsService.kt | 67 +++++++++++++------ 1 file changed, 48 insertions(+), 19 deletions(-) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt index d9ae1fdc2..52d2b16e4 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt @@ -167,6 +167,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList @Volatile private var claimedAudioAddress: String? = null // Kept across socket drops: the connection policy LibrePods forbade blocks audio until restored. @Volatile private var releasedAudioAddress: String? = null + // Profiles whose policy LibrePods forbade for releasedAudioAddress; guarded by audioRestoreLock. + private val releasedAudioProfiles = mutableSetOf() // Bumped when restore permission is revoked or consumed; callbacks carrying an older value must not act. @Volatile private var audioRestoreEpoch = 0 // After another device takes over, no new release may be granted until audio is back on this phone. @@ -1343,6 +1345,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList audioRestoreEpoch++ claimedAudioAddress = null releasedAudioAddress = null + releasedAudioProfiles.clear() audioOwnershipLost = ownershipLost } Handler(Looper.getMainLooper()).post { unregisterA2dpConnectionReceiver() } @@ -1351,18 +1354,33 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList /** Reconnect audio only if LibrePods released or claimed it; returns whether it did. */ private fun restoreAudio(playWhenConnected: Boolean = false): Boolean { val target = device ?: return false - val restoreEpoch = synchronized(audioRestoreLock) { - if (audioOwnershipLost || - (target.address != releasedAudioAddress && target.address != claimedAudioAddress) - ) return false + val (restoreEpoch, profiles) = synchronized(audioRestoreLock) { + if (audioOwnershipLost) return false + // Only the profiles LibrePods forbade; e.g. media audio the user disabled stays off. + val profiles = mutableSetOf() + if (target.address == releasedAudioAddress) profiles += releasedAudioProfiles + // A claim is a takeover, which connects audio the way takeOver() does. + if (target.address == claimedAudioAddress) { + profiles += listOf(BluetoothProfile.A2DP, BluetoothProfile.HEADSET) + } + if (profiles.isEmpty()) return false claimedAudioAddress = null - releasedAudioAddress = null - ++audioRestoreEpoch + // Release records stay until markAudioRestored(), so a cancelled restore can't strand + // a forbidden policy. + ++audioRestoreEpoch to profiles } - connectAudio(this, target, restoreEpoch, playWhenConnected) + connectAudio(this, target, restoreEpoch, profiles, playWhenConnected) return true } + private fun markAudioRestored(device: BluetoothDevice?, profile: Int) { + synchronized(audioRestoreLock) { + if (device?.address != releasedAudioAddress) return + releasedAudioProfiles -= profile + if (releasedAudioProfiles.isEmpty()) releasedAudioAddress = null + } + } + private fun unregisterA2dpConnectionReceiver() { pendingA2dpPlayReceiver?.let { try { @@ -3097,7 +3115,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList ) Log.d(TAG, "calling A2DP.setConnectionPolicy for ${device?.address} to 0") val accepted = method.invoke(proxy, device, 0) == true - if (restorable) grantAudioRelease(device, state, accepted, restoreEpoch) + if (restorable) grantAudioRelease(device, profile, state, accepted, restoreEpoch) } catch (e: Exception) { e.printStackTrace() } finally { @@ -3126,7 +3144,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList val state = proxy.getConnectionState(device) Log.d(TAG, "calling HEADSET.setConnectionPolicy for ${device?.address} to 0") val accepted = method.invoke(proxy, device, 0) == true - if (restorable) grantAudioRelease(device, state, accepted, restoreEpoch) + if (restorable) grantAudioRelease(device, profile, state, accepted, restoreEpoch) } catch (e: Exception) { e.printStackTrace() } finally { @@ -3146,11 +3164,15 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList * Only an accepted release of a profile that was fully connected to this phone may be undone * in the background, and never once another device took over. */ - private fun grantAudioRelease(device: BluetoothDevice?, state: Int, accepted: Boolean, restoreEpoch: Int) { - if (!accepted || state != BluetoothProfile.STATE_CONNECTED) return + private fun grantAudioRelease( + device: BluetoothDevice?, profile: Int, state: Int, accepted: Boolean, restoreEpoch: Int + ) { + if (device == null || !accepted || state != BluetoothProfile.STATE_CONNECTED) return synchronized(audioRestoreLock) { if (restoreEpoch == audioRestoreEpoch && !audioOwnershipLost) { - releasedAudioAddress = device?.address + if (releasedAudioAddress != device.address) releasedAudioProfiles.clear() + releasedAudioAddress = device.address + releasedAudioProfiles += profile } } } @@ -3158,12 +3180,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList fun connectAudio(context: Context, device: BluetoothDevice?) { // An explicit connect re-allows the connection policy and supersedes any background restore. forgetRestorableAudio() - connectAudio(context, device, restoreEpoch = null, playWhenConnected = false) + connectAudio( + context, device, restoreEpoch = null, + profiles = setOf(BluetoothProfile.A2DP, BluetoothProfile.HEADSET), playWhenConnected = false + ) } - /** [restoreEpoch] is set for background restores, which must not act once it is outdated. */ + /** + * Connects [profiles]. [restoreEpoch] is set for background restores, which must not act once + * it is outdated. + */ private fun connectAudio( - context: Context, device: BluetoothDevice?, restoreEpoch: Int?, playWhenConnected: Boolean + context: Context, device: BluetoothDevice?, restoreEpoch: Int?, profiles: Set, + playWhenConnected: Boolean ) { val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter // Restores also need the control link that requested them; it may have closed meanwhile. @@ -3181,7 +3210,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { - if (restoreRevoked()) { + if (restoreRevoked() || profile !in profiles) { bluetoothAdapter.closeProfileProxy(profile, proxy) return } @@ -3194,7 +3223,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList Int::class.java ) Log.d(TAG, "calling A2DP.setConnectionPolicy for ${device?.address} to 100") - policyMethod.invoke(proxy, device, 100) + if (policyMethod.invoke(proxy, device, 100) == true) markAudioRestored(device, profile) connectA2dp(proxy) } catch (e: Exception) { @@ -3219,7 +3248,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { - if (restoreRevoked()) { + if (restoreRevoked() || profile !in profiles) { bluetoothAdapter.closeProfileProxy(profile, proxy) return } @@ -3235,7 +3264,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList TAG, "calling HEADSET.setConnectionPolicy for ${device?.address} to 100" ) - policyMethod.invoke(proxy, device, 100) + if (policyMethod.invoke(proxy, device, 100) == true) markAudioRestored(device, profile) val connectMethod = proxy.javaClass.getMethod("connect", BluetoothDevice::class.java) connectMethod.invoke(proxy, device) From a6257630a1799cbde8e8683a5baf87a6f5302b0a Mon Sep 17 00:00:00 2001 From: sleepysec <103225769+sleepysec@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:09:44 -0400 Subject: [PATCH 6/6] android: don't apply superseded audio releases A restorable release's profile callbacks set the policy to FORBIDDEN first and only then checked whether the request was still current. If a restore ran between the A2DP and HFP callbacks, the late HFP callback still forbade HFP but was not recorded, so nothing ever re-allowed it and call audio stayed blocked until a manual reconnect. Revalidate the request, set FORBIDDEN and record the release together under the restore lock, so a restore or revocation that superseded the request (including ownership loss) prevents the policy change itself. Non-restorable disconnects are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../librepods/services/AirPodsService.kt | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt index 52d2b16e4..189734658 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt @@ -3114,8 +3114,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList "setConnectionPolicy", BluetoothDevice::class.java, Int::class.java ) Log.d(TAG, "calling A2DP.setConnectionPolicy for ${device?.address} to 0") - val accepted = method.invoke(proxy, device, 0) == true - if (restorable) grantAudioRelease(device, profile, state, accepted, restoreEpoch) + forbidProfile(proxy, method, device, profile, state, restorable, restoreEpoch) } catch (e: Exception) { e.printStackTrace() } finally { @@ -3143,8 +3142,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList // Call-only connections are released too, so they must be restorable as well. val state = proxy.getConnectionState(device) Log.d(TAG, "calling HEADSET.setConnectionPolicy for ${device?.address} to 0") - val accepted = method.invoke(proxy, device, 0) == true - if (restorable) grantAudioRelease(device, profile, state, accepted, restoreEpoch) + forbidProfile(proxy, method, device, profile, state, restorable, restoreEpoch) } catch (e: Exception) { e.printStackTrace() } finally { @@ -3161,19 +3159,29 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } /** - * Only an accepted release of a profile that was fully connected to this phone may be undone - * in the background, and never once another device took over. + * Sets [profile]'s policy to FORBIDDEN. A restorable release is revalidated and recorded under + * the restore lock, so a late callback can't block audio behind a restore or revocation that + * superseded it. Only an accepted release of a profile that was fully connected to this phone + * may be undone in the background, and never once another device took over. */ - private fun grantAudioRelease( - device: BluetoothDevice?, profile: Int, state: Int, accepted: Boolean, restoreEpoch: Int + private fun forbidProfile( + proxy: BluetoothProfile, setPolicy: java.lang.reflect.Method, device: BluetoothDevice?, + profile: Int, state: Int, restorable: Boolean, restoreEpoch: Int ) { - if (device == null || !accepted || state != BluetoothProfile.STATE_CONNECTED) return + if (!restorable) { + setPolicy.invoke(proxy, device, 0) + return + } synchronized(audioRestoreLock) { - if (restoreEpoch == audioRestoreEpoch && !audioOwnershipLost) { - if (releasedAudioAddress != device.address) releasedAudioProfiles.clear() - releasedAudioAddress = device.address - releasedAudioProfiles += profile + if (restoreEpoch != audioRestoreEpoch || audioOwnershipLost) { + Log.d(TAG, "Skipping superseded release of profile $profile") + return } + val accepted = setPolicy.invoke(proxy, device, 0) == true + if (device == null || !accepted || state != BluetoothProfile.STATE_CONNECTED) return + if (releasedAudioAddress != device.address) releasedAudioProfiles.clear() + releasedAudioAddress = device.address + releasedAudioProfiles += profile } }