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
diff --git a/README.md b/README.md
index ee74c965a..ca1ab9548 100644
--- a/README.md
+++ b/README.md
@@ -73,6 +73,27 @@ 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.
+
+> [!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
+
+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!
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..7c09b6582 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,10 @@ class MainActivity : ComponentActivity() {
override fun onDestroy() {
try {
- 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 +178,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")
}
@@ -303,7 +303,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()
@@ -356,6 +360,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") {
@@ -457,7 +470,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
@@ -486,7 +501,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(
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 d890e88f2..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
@@ -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)
@@ -1050,17 +1049,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)
+ )
}
}
@@ -1460,6 +1461,8 @@ 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
@@ -1587,6 +1590,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
fun setBatteryMetadata() {
+ if (::sharedPreferences.isInitialized && sharedPreferences.getBoolean("skip_setup", false)) return
device?.let { it ->
SystemApisUtils.setMetadata(
it,
@@ -1852,8 +1856,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)
@@ -1866,7 +1869,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?")
}
@@ -2173,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)
+ }
+ }
}
}
}
@@ -2192,6 +2207,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
}
@@ -2343,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!!),
@@ -2391,15 +2419,28 @@ 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")
- if (!isConnectedLocally) {
+ val inHandshakeWindow = System.currentTimeMillis() - socketConnectedAt < 10_000
+ val socketActuallyAlive = isConnectedLocally && this::socket.isInitialized &&
+ socket.isConnected && (aacpManager.connectedDevices.isNotEmpty() || inHandshakeWindow)
+ 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) {
Log.e(TAG, "Failed to create BluetoothSocket: ${e.message}")
showSocketConnectionFailureNotification("Failed to create Bluetooth socket: ${e.localizedMessage}")
+ isConnecting.set(false)
return
}
@@ -2409,6 +2450,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
try {
socket.connect()
isConnectedLocally = true
+ socketConnectedAt = System.currentTimeMillis()
this@AirPodsService.device = device
BluetoothConnectionManager.setCurrentConnection(socket, device)
@@ -2451,6 +2493,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
}
@@ -2465,8 +2508,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())
@@ -2500,13 +2545,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
setupStemActions()
- while (socket.isConnected) {
- socket.let { it ->
+ try {
+ while (socket.isConnected) {
val buffer = ByteArray(1024)
- val bytesRead = it.inputStream.read(buffer)
- var data: ByteArray
+ val bytesRead = socket.inputStream.read(buffer)
if (bytesRead > 0) {
- data = buffer.copyOfRange(0, bytesRead)
+ val data = buffer.copyOfRange(0, bytesRead)
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DATA).apply {
putExtra("data", buffer.copyOfRange(0, bytesRead))
})
@@ -2525,21 +2569,22 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
Log.d("AirPodsData", "Data received: $formattedHex")
logPacket(data, "AirPods")
}
-
} else if (bytesRead == -1) {
Log.d("AirPods Service", "Socket closed (bytesRead = -1)")
- sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED))
- aacpManager.disconnected()
- return@launch
+ 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) {
@@ -2547,10 +2592,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})")
}
}
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(
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.")