Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.worktrees/
root-module/radare2-5.9.9-android-aarch64.tar.gz
wak.toml
log.txt
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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|<string name=\"self_mac_address\"></string>|<string name=\"self_mac_address\">XX:XX:XX:XX:XX:XX</string>|' 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!
Expand Down
47 changes: 32 additions & 15 deletions android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand All @@ -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")
}
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Comment on lines +179 to +183
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cd android && find . -name "NoiseControlSettings.kt" -type f

Repository: kavishdevar/librepods

Length of output: 145


🏁 Script executed:

cd android && git ls-files | grep -i noise

Repository: kavishdevar/librepods

Length of output: 541


🏁 Script executed:

find . -name "NoiseControlSettings.kt" -type f

Repository: kavishdevar/librepods

Length of output: 153


🏁 Script executed:

cat -n android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt | head -200 | tail -50

Repository: kavishdevar/librepods

Length of output: 2379


🏁 Script executed:

cat -n android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt | head -100

Repository: kavishdevar/librepods

Length of output: 5178


🏁 Script executed:

find . -name "AirPodsNotifications.kt" -type f

Repository: kavishdevar/librepods

Length of output: 47


🏁 Script executed:

rg "ANC_DATA|DISCONNECT_RECEIVERS" --type kt

Repository: kavishdevar/librepods

Length of output: 91


🏁 Script executed:

rg "sendBroadcast" --type kt -A 3 -B 3

Repository: kavishdevar/librepods

Length of output: 91


🏁 Script executed:

find . -name "*AirPodsNotifications*" -type f

Repository: kavishdevar/librepods

Length of output: 47


🏁 Script executed:

find . -name "*Notification*" -type f | grep -i airpods

Repository: kavishdevar/librepods

Length of output: 47


🏁 Script executed:

rg "ANC_DATA|DISCONNECT_RECEIVERS" -A 2 -B 2

Repository: kavishdevar/librepods

Length of output: 14838


🏁 Script executed:

rg "sendBroadcast" -A 3 -B 3

Repository: kavishdevar/librepods

Length of output: 20765


Use RECEIVER_NOT_EXPORTED for app-internal broadcasts.
These actions (me.kavishdevar.librepods.ANC_DATA and me.kavishdevar.librepods.DISCONNECT_RECEIVERS) are sent only from internal app code. Exporting lets other apps spoof ANC updates or force unregistration.

Suggested fix
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
-            context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter, Context.RECEIVER_EXPORTED)
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+            context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter, Context.RECEIVER_NOT_EXPORTED)
         } else {
             context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter)
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter, Context.RECEIVER_EXPORTED)
} else {
context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter, Context.RECEIVER_NOT_EXPORTED)
} else {
context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter)
}
🤖 Prompt for AI Agents
In
`@android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt`
around lines 179 - 183, The broadcast registration is currently using
Context.RECEIVER_EXPORTED for noiseControlReceiver which exposes internal
app-only actions; change the SDK >= TIRAMISU branch to use
Context.RECEIVER_NOT_EXPORTED instead (keep the else branch unchanged) so the
Intent actions handled by noiseControlIntentFilter
(me.kavishdevar.librepods.ANC_DATA and
me.kavishdevar.librepods.DISCONNECT_RECEIVERS) remain app-internal and cannot be
spoofed or triggered by other apps.

onDispose {
try {
context.unregisterReceiver(noiseControlReceiver)
} catch (_: IllegalArgumentException) { }
}
}
Box(
modifier = Modifier
Expand Down
Loading