From fa02d4fd88ac99ae77dbb9b32b752d9b050ed2d5 Mon Sep 17 00:00:00 2001 From: Teng Lin Date: Fri, 6 Feb 2026 19:16:56 -0500 Subject: [PATCH 01/15] chore: add .worktrees/ to .gitignore Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index ed06b8946..6e670aadd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.worktrees/ root-module/radare2-5.9.9-android-aarch64.tar.gz wak.toml log.txt From 46d19c568cb974116ed9d561964f480ccd9772c0 Mon Sep 17 00:00:00 2001 From: Teng Lin Date: Fri, 6 Feb 2026 19:38:54 -0500 Subject: [PATCH 02/15] fix(android): support non-rooted OnePlus 12 / OxygenOS 16 OxygenOS 16's QTI Bluetooth stack handles L2CAP natively without root hooks. This commit: - Detect OxygenOS/ColorOS 16+ (OnePlus/OPPO/Realme on SDK 36) and skip root/radare2 setup in RadareOffsetFinder - Start service via startForegroundService() so it survives activity lifecycle (onStop unbind no longer kills the service) - Auto-reconnect L2CAP in onStartCommand() when service restarts via START_STICKY with a saved MAC address - Guard lateinit connectionStatusReceiver/serviceConnection with isInitialized checks to prevent UninitializedPropertyAccessException - Skip BLUETOOTH_PRIVILEGED setBatteryMetadata() calls on non-rooted devices to eliminate SecurityException log spam Co-Authored-By: Claude Opus 4.6 --- .../me/kavishdevar/librepods/MainActivity.kt | 28 +++++++++++++------ .../librepods/services/AirPodsService.kt | 15 ++++++++++ .../librepods/utils/RadareOffsetFinder.kt | 15 ++++++++++ 3 files changed, 49 insertions(+), 9 deletions(-) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt index 8de1b77d4..a88b24a7c 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt @@ -165,14 +165,18 @@ class MainActivity : ComponentActivity() { override fun onDestroy() { try { - unbindService(serviceConnection) - Log.d("MainActivity", "Unbound service") + if (::serviceConnection.isInitialized) { + unbindService(serviceConnection) + Log.d("MainActivity", "Unbound service") + } } catch (e: Exception) { Log.e("MainActivity", "Error while unbinding service: $e") } try { - unregisterReceiver(connectionStatusReceiver) - Log.d("MainActivity", "Unregistered receiver") + if (::connectionStatusReceiver.isInitialized) { + unregisterReceiver(connectionStatusReceiver) + Log.d("MainActivity", "Unregistered receiver") + } } catch (e: Exception) { Log.e("MainActivity", "Error while unregistering receiver: $e") } @@ -182,14 +186,18 @@ class MainActivity : ComponentActivity() { override fun onStop() { try { - unbindService(serviceConnection) - Log.d("MainActivity", "Unbound service") + if (::serviceConnection.isInitialized) { + unbindService(serviceConnection) + Log.d("MainActivity", "Unbound service") + } } catch (e: Exception) { Log.e("MainActivity", "Error while unbinding service: $e") } try { - unregisterReceiver(connectionStatusReceiver) - Log.d("MainActivity", "Unregistered receiver") + if (::connectionStatusReceiver.isInitialized) { + unregisterReceiver(connectionStatusReceiver) + Log.d("MainActivity", "Unregistered receiver") + } } catch (e: Exception) { Log.e("MainActivity", "Error while unregistering receiver: $e") } @@ -457,7 +465,9 @@ fun Main() { } } - context.bindService(Intent(context, AirPodsService::class.java), serviceConnection, Context.BIND_AUTO_CREATE) + val serviceIntent = Intent(context, AirPodsService::class.java) + context.startForegroundService(serviceIntent) + context.bindService(serviceIntent, serviceConnection, Context.BIND_AUTO_CREATE) if (airPodsService.value?.isConnectedLocally == true) { isConnected.value = true 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 d890e88f2..b956d4498 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 @@ -1587,6 +1587,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } fun setBatteryMetadata() { + if (::sharedPreferences.isInitialized && sharedPreferences.getBoolean("skip_setup", false)) return device?.let { it -> SystemApisUtils.setMetadata( it, @@ -2192,6 +2193,20 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList takeOver("music", manualTakeOverAfterReversed = true) } + if (!isConnectedLocally && ::sharedPreferences.isInitialized) { + val savedMac = sharedPreferences.getString("mac_address", "") + if (!savedMac.isNullOrEmpty()) { + Log.d(TAG, "Service restarted, attempting L2CAP reconnect to $savedMac") + val bluetoothManager = getSystemService(BluetoothManager::class.java) + val bluetoothDevice = bluetoothManager?.adapter?.getRemoteDevice(savedMac) + if (bluetoothDevice != null) { + CoroutineScope(Dispatchers.IO).launch { + connectToSocket(bluetoothDevice) + } + } + } + } + return START_STICKY } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/RadareOffsetFinder.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/RadareOffsetFinder.kt index e5a1e7bdc..ea25abcf5 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/RadareOffsetFinder.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/RadareOffsetFinder.kt @@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.withContext import me.kavishdevar.librepods.services.ServiceManager +import android.os.Build import java.io.BufferedReader import java.io.File import java.io.FileOutputStream @@ -60,6 +61,12 @@ class RadareOffsetFinder(context: Context) { "/system_ext/lib64/libbluetooth_qti.so" ) + fun isOxygenOSOrColorOS16OrAbove(): Boolean { + val manufacturer = Build.MANUFACTURER.lowercase() + if (manufacturer != "oneplus" && manufacturer != "oppo" && manufacturer != "realme") return false + return Build.VERSION.SDK_INT >= 36 + } + fun findBluetoothLibraryPath(): String? { for (path in LIBRARY_PATHS) { if (File(path).exists()) { @@ -115,6 +122,10 @@ class RadareOffsetFinder(context: Context) { } fun isSdpOffsetAvailable(): Boolean { + if (isOxygenOSOrColorOS16OrAbove()) { + Log.d(TAG, "OxygenOS/ColorOS 16+ detected, L2CAP works without SDP hook.") + return true + } val sharedPreferences = ServiceManager.getService()?.applicationContext?.getSharedPreferences("settings", Context.MODE_PRIVATE) // ik not good practice- too lazy if (sharedPreferences?.getBoolean("skip_setup", false) == true) { Log.d(TAG, "Setup skipped, returning true for SDP offset.") @@ -160,6 +171,10 @@ class RadareOffsetFinder(context: Context) { fun isHookOffsetAvailable(): Boolean { + if (isOxygenOSOrColorOS16OrAbove()) { + Log.d(TAG, "OxygenOS/ColorOS 16+ detected, L2CAP works without hook.") + return true + } Log.d(TAG, "Setup Skipped? " + ServiceManager.getService()?.applicationContext?.getSharedPreferences("settings", Context.MODE_PRIVATE)?.getBoolean("skip_setup", false).toString()) if (ServiceManager.getService()?.applicationContext?.getSharedPreferences("settings", Context.MODE_PRIVATE)?.getBoolean("skip_setup", false) == true) { Log.d(TAG, "Setup skipped, returning true.") From aff8014a97d105779f92a34071263dc4f0435724 Mon Sep 17 00:00:00 2001 From: Teng Lin Date: Mon, 9 Feb 2026 17:52:43 -0500 Subject: [PATCH 03/15] fix(android): show loading spinner when service is binding The settings screen rendered nothing when airPodsService was null, causing a black screen on startup until the service bind completed. Co-Authored-By: Claude Opus 4.6 --- .../main/java/me/kavishdevar/librepods/MainActivity.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt index a88b24a7c..408e7091f 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt @@ -364,6 +364,15 @@ fun Main() { isConnected = isConnected.value, isRemotelyConnected = isRemotelyConnected.value ) + } else { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + androidx.compose.material3.CircularProgressIndicator( + color = MaterialTheme.colorScheme.primary + ) + } } } composable("debug") { From 58b15fb551932c44df6ce10aa854c4b4c8da23ed Mon Sep 17 00:00:00 2001 From: Teng Lin Date: Mon, 9 Feb 2026 18:02:05 -0500 Subject: [PATCH 04/15] fix(android): prevent concurrent L2CAP connectToSocket calls Two callers (onStartCommand reconnect + BLE/A2DP callback) can race into connectToSocket simultaneously. The first wins the L2CAP channel; the second fails with "Message too long" and shows a spurious error notification. Add AtomicBoolean guard to serialize connection attempts. Co-Authored-By: Claude Opus 4.6 --- .../kavishdevar/librepods/services/AirPodsService.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) 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 b956d4498..8738ae230 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 @@ -1460,6 +1460,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } var isConnectedLocally = false + private val isConnecting = java.util.concurrent.atomic.AtomicBoolean(false) var device: BluetoothDevice? = null private lateinit var earReceiver: BroadcastReceiver @@ -2406,6 +2407,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList @SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag") fun connectToSocket(device: BluetoothDevice, manual: Boolean = false) { + if (!isConnecting.compareAndSet(false, true)) { + Log.d(TAG, "Already connecting to socket, skipping duplicate attempt") + return + } Log.d(TAG, " Connecting to socket") HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;") val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a") @@ -2415,6 +2420,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } catch (e: Exception) { Log.e(TAG, "Failed to create BluetoothSocket: ${e.message}") showSocketConnectionFailureNotification("Failed to create Bluetooth socket: ${e.localizedMessage}") + isConnecting.set(false) return } @@ -2466,6 +2472,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } else { showSocketConnectionFailureNotification("Couldn't connect to socket: ${e.localizedMessage}") } + isConnecting.set(false) return@withTimeout // throw e // lol how did i not catch this before... gonna comment this line instead of removing to preserve history } @@ -2480,8 +2487,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } else { showSocketConnectionFailureNotification("Couldn't connect to socket: Timeout") } + isConnecting.set(false) return } + isConnecting.set(false) this@AirPodsService.device = device socket.let { aacpManager.sendPacket(aacpManager.createHandshakePacket()) @@ -2562,10 +2571,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList Log.d(TAG, "Failed to connect to socket: ${e.message}") showSocketConnectionFailureNotification("Failed to establish connection: ${e.localizedMessage}") isConnectedLocally = false + isConnecting.set(false) this@AirPodsService.device = device updateNotificationContent(false) } } else { + isConnecting.set(false) Log.d(TAG, "Already connected locally, skipping socket connection (isConnectedLocally = $isConnectedLocally, socket.isConnected = ${this::socket.isInitialized && socket.isConnected})") } } From b162ee5ff355040e61c2f2f9ebd7c7ef6625bf1e Mon Sep 17 00:00:00 2001 From: Teng Lin Date: Mon, 9 Feb 2026 18:13:02 -0500 Subject: [PATCH 05/15] fix(android): use single notification ID to avoid duplicate notifications The foreground service notification (ID 1) cannot be cancelled via notificationManager.cancel(). Use ID 1 for both connected and disconnected states so the battery notification replaces the "Background Service Running" one instead of showing alongside it. Co-Authored-By: Claude Opus 4.6 --- .../java/me/kavishdevar/librepods/services/AirPodsService.kt | 4 +--- 1 file changed, 1 insertion(+), 3 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 8738ae230..a66a371af 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 @@ -1854,8 +1854,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList val updatedNotification = updatedNotificationBuilder.build() - notificationManager.notify(2, updatedNotification) - notificationManager.cancel(1) + notificationManager.notify(1, updatedNotification) } else if (!connected) { updatedNotification = NotificationCompat.Builder(this, "background_service_status") .setSmallIcon(R.drawable.airpods) @@ -1868,7 +1867,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList .build() notificationManager.notify(1, updatedNotification) - notificationManager.cancel(2) } else if (!config.bleOnlyMode && !socket.isConnected && isConnectedLocally) { showSocketConnectionFailureNotification("Socket created, but not connected. Is the Bluetooth process hooked?") } From 8c287b58bc7b0d8911849dfa9fdf94d8a6d5e6b8 Mon Sep 17 00:00:00 2001 From: Teng Lin Date: Mon, 9 Feb 2026 18:24:49 -0500 Subject: [PATCH 06/15] fix(android): thread-safety, receiver leak, and reconnect issues - Use CopyOnWriteArrayList/ConcurrentHashMap for AACP control command collections to prevent ConcurrentModificationException - Wrap NoiseControlSettings BroadcastReceiver in DisposableEffect to properly unregister on composable disposal (IntentReceiverLeaked) - Reset isConnectedLocally and isConnecting on bytesRead==-1 disconnect so auto-reconnect can trigger via onStartCommand Co-Authored-By: Claude Opus 4.6 --- .../composables/NoiseControlSettings.kt | 24 ++++++++++++------- .../librepods/services/AirPodsService.kt | 6 ++++- .../librepods/utils/AACPManager.kt | 6 ++--- 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt index d613d4bea..49ccec3c1 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt @@ -52,6 +52,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -170,14 +171,21 @@ fun NoiseControlSettings( } } - val noiseControlIntentFilter = IntentFilter().apply { - addAction(AirPodsNotifications.ANC_DATA) - addAction(AirPodsNotifications.DISCONNECT_RECEIVERS) - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter, Context.RECEIVER_EXPORTED) - } else { - context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter) + DisposableEffect(Unit) { + val noiseControlIntentFilter = IntentFilter().apply { + addAction(AirPodsNotifications.ANC_DATA) + addAction(AirPodsNotifications.DISCONNECT_RECEIVERS) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter, Context.RECEIVER_EXPORTED) + } else { + context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter) + } + onDispose { + try { + context.unregisterReceiver(noiseControlReceiver) + } catch (_: IllegalArgumentException) { } + } } Box( modifier = Modifier 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 a66a371af..1e7cb55c5 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 @@ -2550,8 +2550,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } else if (bytesRead == -1) { Log.d("AirPods Service", "Socket closed (bytesRead = -1)") - sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED)) + isConnectedLocally = false + isConnecting.set(false) + socket.close() aacpManager.disconnected() + updateNotificationContent(false) + sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED)) return@launch } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt index f3afe9f56..c1c126781 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt @@ -200,9 +200,9 @@ class AACPManager { } var controlCommandStatusList: MutableList = - mutableListOf() + java.util.concurrent.CopyOnWriteArrayList() var controlCommandListeners: MutableMap> = - mutableMapOf() + java.util.concurrent.ConcurrentHashMap>() var owns: Boolean = false private set @@ -290,7 +290,7 @@ class AACPManager { identifier: ControlCommandIdentifiers, callback: ControlCommandListener ) { - controlCommandListeners.getOrPut(identifier) { mutableListOf() }.add(callback) + controlCommandListeners.getOrPut(identifier) { java.util.concurrent.CopyOnWriteArrayList() }.add(callback) } fun unregisterControlCommandListener( From 7e52ae20aa7f91af4cb897bd75526c2494a1c6cb Mon Sep 17 00:00:00 2001 From: Teng Lin Date: Mon, 9 Feb 2026 18:28:10 -0500 Subject: [PATCH 07/15] fix(android): make notification and phone permissions optional Only Bluetooth and location permissions are required to proceed past the permission screen. Notification (POST_NOTIFICATIONS) and phone (READ_PHONE_STATE, ANSWER_PHONE_CALLS) permissions are still requested but no longer block the main settings screen. The foreground service notification is exempt from POST_NOTIFICATIONS on Android 13+. Co-Authored-By: Claude Opus 4.6 --- .../main/java/me/kavishdevar/librepods/MainActivity.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt index 408e7091f..699e97fd8 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt @@ -311,7 +311,11 @@ fun Main() { canDrawOverlays = Settings.canDrawOverlays(context) } - if (permissionState.allPermissionsGranted && (canDrawOverlays || overlaySkipped.value)) { + val bluetoothPermissionsGranted = permissionState.permissions.filter { + it.permission.contains("BLUETOOTH") || it.permission.contains("LOCATION") + }.all { it.status.isGranted } + + if (bluetoothPermissionsGranted && (canDrawOverlays || overlaySkipped.value)) { val context = LocalContext.current val navController = rememberNavController() @@ -505,7 +509,9 @@ fun PermissionsScreen( val scrollState = rememberScrollState() - val basicPermissionsGranted = permissionState.permissions.all { it.status.isGranted } + val basicPermissionsGranted = permissionState.permissions.filter { + it.permission.contains("BLUETOOTH") || it.permission.contains("LOCATION") + }.all { it.status.isGranted } val infiniteTransition = rememberInfiniteTransition(label = "pulse") val pulseScale by infiniteTransition.animateFloat( From dde12db00b44a7b3ef70f8e174de31fb93b28bae Mon Sep 17 00:00:00 2001 From: Teng Lin Date: Mon, 9 Feb 2026 18:36:29 -0500 Subject: [PATCH 08/15] fix(android): reclaim AACP control when audio source returns to phone When AirPods report audio source switched back to the local device, send OWNS_CONNECTION=0x01 to reclaim control. Previously the app only gave up control but never took it back, causing ANC/transparency switching to stop working after switching audio between devices. Also guard audio source checks with localMac.isNotEmpty(). Co-Authored-By: Claude Opus 4.6 --- .../librepods/services/AirPodsService.kt | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 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 1e7cb55c5..3ed0bdd46 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 @@ -1050,17 +1050,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } } override fun onAudioSourceReceived(audioSource: ByteArray) { - Log.d("AirPodsParser", "Audio source changed mac: ${aacpManager.audioSource?.mac}, type: ${aacpManager.audioSource?.type?.name}") - if (aacpManager.audioSource?.type != AACPManager.Companion.AudioSourceType.NONE && aacpManager.audioSource?.mac != localMac) { - Log.d("AirPodsParser", "Audio source is another device, better to give up aacp control") + Log.d("AirPodsParser", "Audio source changed mac: ${aacpManager.audioSource?.mac}, type: ${aacpManager.audioSource?.type?.name}, localMac: $localMac") + if (aacpManager.audioSource?.type != AACPManager.Companion.AudioSourceType.NONE && localMac.isNotEmpty() && aacpManager.audioSource?.mac != localMac) { + Log.d("AirPodsParser", "Audio source is another device, giving up AACP control") aacpManager.sendControlCommand( AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION.value, byteArrayOf(0x00) ) - // this also means that the other device has start playing the audio, and if that's true, we can again start listening for audio config changes -// Log.d(TAG, "Another device started playing audio, listening for audio config changes again") -// MediaController.pausedForOtherDevice = false -// future me: what the heck is this? this just means it will not be taking over again if audio source doesn't change??? + } else if (localMac.isNotEmpty() && aacpManager.audioSource?.mac == localMac) { + Log.d("AirPodsParser", "Audio source is local device, reclaiming AACP control") + aacpManager.sendControlCommand( + AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION.value, + byteArrayOf(0x01) + ) } } From 725cfb71ba736877657508cb533855ac8f6f5079 Mon Sep 17 00:00:00 2001 From: Teng Lin Date: Mon, 9 Feb 2026 18:47:35 -0500 Subject: [PATCH 09/15] fix(android): reconnect L2CAP when A2DP resumes playing When AirPods switch audio to another device (e.g. Mac), the L2CAP AACP socket gets dropped. When audio returns to the phone, the A2DP PLAYING_STATE_CHANGED broadcast fires but the bluetoothReceiver only handled ACL_CONNECTED. Now also handle PLAYING_STATE_CHANGED to re-trigger L2CAP connection when A2DP starts playing on the AirPods. Co-Authored-By: Claude Opus 4.6 --- .../librepods/services/AirPodsService.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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 3ed0bdd46..4d79b0247 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 @@ -2175,6 +2175,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList context?.sendBroadcast(intent) } } + } else if (action == "android.bluetooth.a2dp.profile.action.PLAYING_STATE_CHANGED") { + val savedMac = context?.getSharedPreferences("settings", MODE_PRIVATE) + ?.getString("mac_address", "") + if (!savedMac.isNullOrEmpty() && bluetoothDevice?.address == savedMac) { + val state = intent.getIntExtra("android.bluetooth.profile.extra.STATE", -1) + if (state == 10) { // BluetoothA2dp.STATE_PLAYING + Log.d(TAG, "A2DP playing on AirPods, re-triggering connection") + val connectionIntent = Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED) + connectionIntent.putExtra("name", name) + connectionIntent.putExtra("device", bluetoothDevice) + context?.sendBroadcast(connectionIntent) + } + } } } } From aadcaf1c6e587b8f780f11643a10e52e488c5e2a Mon Sep 17 00:00:00 2001 From: Teng Lin Date: Mon, 9 Feb 2026 18:59:53 -0500 Subject: [PATCH 10/15] fix(android): detect stale socket in connectToSocket guard isConnectedLocally can be stale after a remote disconnect because connectionReceiver sets it true on ACL_CONNECTED before connectToSocket runs. Now verify the socket is actually alive by probing inputStream before skipping reconnection. If the socket is dead, reset the flag and proceed with a fresh connection. Co-Authored-By: Claude Opus 4.6 --- .../me/kavishdevar/librepods/services/AirPodsService.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 4d79b0247..c7df4016c 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 @@ -2427,7 +2427,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList Log.d(TAG, " Connecting to socket") HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;") val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a") - if (!isConnectedLocally) { + val socketActuallyAlive = isConnectedLocally && this::socket.isInitialized && + try { socket.inputStream.available(); true } catch (_: Exception) { false } + if (!socketActuallyAlive) { + if (isConnectedLocally) { + Log.d(TAG, "isConnectedLocally was true but socket is dead, resetting") + isConnectedLocally = false + if (this::socket.isInitialized) try { socket.close() } catch (_: Exception) { } + } socket = try { createBluetoothSocket(device, uuid) } catch (e: Exception) { From b1a1fead21a6498eca76b28114c3eccf463c978d Mon Sep 17 00:00:00 2001 From: Teng Lin Date: Mon, 9 Feb 2026 19:08:29 -0500 Subject: [PATCH 11/15] fix(android): use AACP connected devices for socket liveness check inputStream.available() returns 0 on dead sockets instead of throwing, so it can't detect stale connections. Use aacpManager.connectedDevices which is cleared on disconnect and only populated after successful AACP handshake - a reliable indicator of actual socket health. Co-Authored-By: Claude Opus 4.6 --- .../java/me/kavishdevar/librepods/services/AirPodsService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c7df4016c..76785c0c3 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 @@ -2428,7 +2428,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;") val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a") val socketActuallyAlive = isConnectedLocally && this::socket.isInitialized && - try { socket.inputStream.available(); true } catch (_: Exception) { false } + socket.isConnected && aacpManager.connectedDevices.isNotEmpty() if (!socketActuallyAlive) { if (isConnectedLocally) { Log.d(TAG, "isConnectedLocally was true but socket is dead, resetting") From 5e6bcb62587a6ba169e08aa722faff6e54e5fd49 Mon Sep 17 00:00:00 2001 From: Teng Lin Date: Tue, 10 Feb 2026 13:00:16 -0500 Subject: [PATCH 12/15] fix(android): remove duplicate unbindService call in onDestroy The service is already unbound in onStop(), so calling unbindService() again in onDestroy() causes "Service not registered" error. Remove the duplicate unbind call since onStop() is called before onDestroy(). Co-Authored-By: Claude Haiku 4.5 --- .../main/java/me/kavishdevar/librepods/MainActivity.kt | 8 -------- 1 file changed, 8 deletions(-) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt index 699e97fd8..7c09b6582 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt @@ -164,14 +164,6 @@ class MainActivity : ComponentActivity() { } override fun onDestroy() { - try { - if (::serviceConnection.isInitialized) { - unbindService(serviceConnection) - Log.d("MainActivity", "Unbound service") - } - } catch (e: Exception) { - Log.e("MainActivity", "Error while unbinding service: $e") - } try { if (::connectionStatusReceiver.isInitialized) { unregisterReceiver(connectionStatusReceiver) From 6211c4c7145262b426750a27f4b1591d7f435f31 Mon Sep 17 00:00:00 2001 From: Teng Lin Date: Tue, 10 Feb 2026 13:27:04 -0500 Subject: [PATCH 13/15] docs: add MAC address injection guide for OxygenOS 16 non-rooted setup Add documentation for the MAC address injection workaround needed on non-rooted SDK 36 devices where the system's bluetooth_address is not accessible to user apps. Include step-by-step instructions using adb. Co-Authored-By: Claude Haiku 4.5 --- README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/README.md b/README.md index ee74c965a..021744363 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,25 @@ If you are using ColorOS/OxygenOS 16, you don't need root except for customizing Until then, you must xposed. I used to provide a non-xposed method too, where the module used overlayfs to replace the bluetooth library with a locally patched one, but that was broken due to how various devices handled overlayfs and a patched library. With xposed, you can also enable the DID hook enabling a few extra features. +#### Setup for OxygenOS/ColorOS 16 (Non-rooted) + +For multi-device audio switching to work properly on non-rooted OxygenOS 16, you need to inject your phone's Bluetooth MAC address into the app's settings. This is a one-time setup: + +1. **Get your phone's Bluetooth MAC address:** + - Go to Settings → About → Device Details → Bluetooth Address + - Or use: `adb shell settings get secure bluetooth_address` (requires running once with a recently-root device or use the Settings method) + +2. **Inject the MAC address via adb:** + ```bash + adb shell "run-as me.kavishdevar.librepods sed -i 's||XX:XX:XX:XX:XX:XX|' shared_prefs/settings.xml" + ``` + Replace `XX:XX:XX:XX:XX:XX` with your actual Bluetooth MAC address (e.g., `AC:C0:48:67:E6:EA`) + +3. **Restart the app** for the changes to take effect + +> [!NOTE] +> This is needed because non-rooted apps on SDK 36+ cannot access the system's `bluetooth_address` setting. Without this, audio source switching between devices won't work correctly, and the app will lose ANC/transparency control when you switch to another device. + ## Changing VendorID in the DID profile to that of Apple Turns out, if you change the VendorID in DID Profile to that of Apple, you get access to several special features! From bc5a12b7ee2f44a0b00c74767b9d18b481ec2a8c Mon Sep 17 00:00:00 2001 From: Teng Lin Date: Wed, 11 Feb 2026 06:49:55 -0500 Subject: [PATCH 14/15] fix(android): prevent L2CAP socket teardown during AACP handshake The socketActuallyAlive check required aacpManager.connectedDevices to be non-empty, but during the AACP handshake window (~seconds after socket.connect() succeeds) this list is still empty. A second connectToSocket call from the A2DP profile proxy callback would see the socket as "stale", tear it down, and fail to reconnect. - Add 10-second handshake grace period to socketActuallyAlive check - Remove premature isConnectedLocally=true from connectionReceiver and takeOver (connectToSocket sets it internally on success) - Wrap socket read loop in try/catch/finally to properly handle IOException from remote disconnect, preventing stale socket state --- .../librepods/services/AirPodsService.kt | 83 ++++++++++--------- 1 file changed, 42 insertions(+), 41 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 76785c0c3..68c5fba38 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 @@ -671,7 +671,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } Log.d(TAG, "Setting metadata") setMetadatas(device!!) - isConnectedLocally = true macAddress = device!!.address sharedPreferences.edit { putString("mac_address", macAddress) @@ -1463,6 +1462,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList var isConnectedLocally = false private val isConnecting = java.util.concurrent.atomic.AtomicBoolean(false) + @Volatile private var socketConnectedAt: Long = 0 var device: BluetoothDevice? = null private lateinit var earReceiver: BroadcastReceiver @@ -2372,7 +2372,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } else { connectToSocket(device!!) connectAudio(this, device) - isConnectedLocally = true } } showIsland(this, batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level!!.coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level!!), @@ -2427,8 +2426,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList Log.d(TAG, " Connecting to socket") HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;") val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a") + val inHandshakeWindow = System.currentTimeMillis() - socketConnectedAt < 10_000 val socketActuallyAlive = isConnectedLocally && this::socket.isInitialized && - socket.isConnected && aacpManager.connectedDevices.isNotEmpty() + socket.isConnected && (aacpManager.connectedDevices.isNotEmpty() || inHandshakeWindow) if (!socketActuallyAlive) { if (isConnectedLocally) { Log.d(TAG, "isConnectedLocally was true but socket is dead, resetting") @@ -2450,6 +2450,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList try { socket.connect() isConnectedLocally = true + socketConnectedAt = System.currentTimeMillis() this@AirPodsService.device = device BluetoothConnectionManager.setCurrentConnection(socket, device) @@ -2544,50 +2545,50 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList setupStemActions() - while (socket.isConnected) { - socket.let { it -> - val buffer = ByteArray(1024) - val bytesRead = it.inputStream.read(buffer) - var data: ByteArray - if (bytesRead > 0) { - data = buffer.copyOfRange(0, bytesRead) - sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DATA).apply { - putExtra("data", buffer.copyOfRange(0, bytesRead)) - }) - val bytes = buffer.copyOfRange(0, bytesRead) - val formattedHex = bytes.joinToString(" ") { "%02X".format(it) } -// CrossDevice.sendReceivedPacket(bytes) - updateNotificationContent( - true, - sharedPreferences.getString("name", device.name), - batteryNotification.getBattery() - ) + try { + while (socket.isConnected) { + socket.let { it -> + val buffer = ByteArray(1024) + val bytesRead = it.inputStream.read(buffer) + var data: ByteArray + if (bytesRead > 0) { + data = buffer.copyOfRange(0, bytesRead) + sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DATA).apply { + putExtra("data", buffer.copyOfRange(0, bytesRead)) + }) + val bytes = buffer.copyOfRange(0, bytesRead) + val formattedHex = bytes.joinToString(" ") { "%02X".format(it) } +// CrossDevice.sendReceivedPacket(bytes) + updateNotificationContent( + true, + sharedPreferences.getString("name", device.name), + batteryNotification.getBattery() + ) - aacpManager.receivePacket(data) + aacpManager.receivePacket(data) - if (!isHeadTrackingData(data)) { - Log.d("AirPodsData", "Data received: $formattedHex") - logPacket(data, "AirPods") - } + if (!isHeadTrackingData(data)) { + Log.d("AirPodsData", "Data received: $formattedHex") + logPacket(data, "AirPods") + } - } else if (bytesRead == -1) { - Log.d("AirPods Service", "Socket closed (bytesRead = -1)") - isConnectedLocally = false - isConnecting.set(false) - socket.close() - aacpManager.disconnected() - updateNotificationContent(false) - sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED)) - return@launch + } else if (bytesRead == -1) { + Log.d("AirPods Service", "Socket closed (bytesRead = -1)") + break + } } } + Log.d("AirPods Service", "Socket closed") + } catch (e: java.io.IOException) { + Log.d("AirPods Service", "Socket read error: ${e.message}") + } finally { + isConnectedLocally = false + isConnecting.set(false) + try { socket.close() } catch (_: Exception) {} + aacpManager.disconnected() + updateNotificationContent(false) + sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED)) } - Log.d("AirPods Service", "Socket closed") - isConnectedLocally = false - socket.close() - aacpManager.disconnected() - updateNotificationContent(false) - sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED)) } } } catch (e: Exception) { From aa3db920479dbfc4a24eccb3793e9e086df6d619 Mon Sep 17 00:00:00 2001 From: Teng Lin Date: Wed, 11 Feb 2026 06:52:30 -0500 Subject: [PATCH 15/15] docs: clarify run-as requires debug build for MAC injection Address PR review comment: run-as only works on debuggable builds. Add prerequisite note directing users to install the debug/nightly APK. Remove the alternative adb settings method which also requires elevated privileges. --- README.md | 6 ++- .../librepods/services/AirPodsService.kt | 48 +++++++++---------- 2 files changed, 26 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 021744363..ca1ab9548 100644 --- a/README.md +++ b/README.md @@ -75,11 +75,13 @@ Until then, you must xposed. I used to provide a non-xposed method too, where th #### Setup for OxygenOS/ColorOS 16 (Non-rooted) -For multi-device audio switching to work properly on non-rooted OxygenOS 16, you need to inject your phone's Bluetooth MAC address into the app's settings. This is a one-time setup: +For multi-device audio switching to work properly on non-rooted OxygenOS 16, you need to inject your phone's Bluetooth MAC address into the app's settings. This is a one-time setup. + +> [!IMPORTANT] +> The `run-as` command only works with **debug builds** (e.g., the nightly APK from CI). If you installed a release build, reinstall with the debug APK first. 1. **Get your phone's Bluetooth MAC address:** - Go to Settings → About → Device Details → Bluetooth Address - - Or use: `adb shell settings get secure bluetooth_address` (requires running once with a recently-root device or use the Settings method) 2. **Inject the MAC address via adb:** ```bash 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 68c5fba38..f268f959a 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 @@ -2547,35 +2547,31 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList try { while (socket.isConnected) { - socket.let { it -> - val buffer = ByteArray(1024) - val bytesRead = it.inputStream.read(buffer) - var data: ByteArray - if (bytesRead > 0) { - data = buffer.copyOfRange(0, bytesRead) - sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DATA).apply { - putExtra("data", buffer.copyOfRange(0, bytesRead)) - }) - val bytes = buffer.copyOfRange(0, bytesRead) - val formattedHex = bytes.joinToString(" ") { "%02X".format(it) } -// CrossDevice.sendReceivedPacket(bytes) - updateNotificationContent( - true, - sharedPreferences.getString("name", device.name), - batteryNotification.getBattery() - ) - - aacpManager.receivePacket(data) + val buffer = ByteArray(1024) + val bytesRead = socket.inputStream.read(buffer) + if (bytesRead > 0) { + val data = buffer.copyOfRange(0, bytesRead) + sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DATA).apply { + putExtra("data", buffer.copyOfRange(0, bytesRead)) + }) + val bytes = buffer.copyOfRange(0, bytesRead) + val formattedHex = bytes.joinToString(" ") { "%02X".format(it) } +// CrossDevice.sendReceivedPacket(bytes) + updateNotificationContent( + true, + sharedPreferences.getString("name", device.name), + batteryNotification.getBattery() + ) - if (!isHeadTrackingData(data)) { - Log.d("AirPodsData", "Data received: $formattedHex") - logPacket(data, "AirPods") - } + aacpManager.receivePacket(data) - } else if (bytesRead == -1) { - Log.d("AirPods Service", "Socket closed (bytesRead = -1)") - break + if (!isHeadTrackingData(data)) { + Log.d("AirPodsData", "Data received: $formattedHex") + logPacket(data, "AirPods") } + } else if (bytesRead == -1) { + Log.d("AirPods Service", "Socket closed (bytesRead = -1)") + break } } Log.d("AirPods Service", "Socket closed")