-
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
fix(android): support non-rooted OnePlus 12 / OxygenOS 16 #453
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
fix(android): support non-rooted OnePlus 12 / OxygenOS 16 #453
Conversation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…ions 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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
📝 WalkthroughWalkthroughUpdated Android app: lifecycle and permission handling changes, service binding/start pattern, composable receiver lifecycle management, socket/connect concurrency guards, thread-safe collections, OxygenOS/ColorOS 16+ detection and README additions, plus a .gitignore entry for Changes
Possibly related issues
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
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 <noreply@anthropic.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In
`@android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt`:
- Around line 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.
🧹 Nitpick comments (3)
android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt (1)
174-175: KeyDisposableEffecttocontextto avoid stale registrations.
IfLocalContextchanges (e.g., configuration change), the receiver should be re-registered with the new context.♻️ Suggested change
- DisposableEffect(Unit) { + DisposableEffect(context) {android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt (1)
2178-2189: PreferBluetoothA2dp.STATE_PLAYINGover the magic number.
Improves readability and avoids future constant drift.♻️ Suggested change
+import android.bluetooth.BluetoothA2dp ... - if (state == 10) { // BluetoothA2dp.STATE_PLAYING + if (state == BluetoothA2dp.STATE_PLAYING) {android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt (1)
473-475: Move service start/bind into a side‑effect to avoid recomposition repeats.
Running these directly in composition can trigger multiple start/bind calls.♻️ Suggested change
- val serviceIntent = Intent(context, AirPodsService::class.java) - context.startForegroundService(serviceIntent) - context.bindService(serviceIntent, serviceConnection, Context.BIND_AUTO_CREATE) + LaunchedEffect(Unit) { + val serviceIntent = Intent(context, AirPodsService::class.java) + context.startForegroundService(serviceIntent) + context.bindService(serviceIntent, serviceConnection, Context.BIND_AUTO_CREATE) + }
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { | ||
| context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter, Context.RECEIVER_EXPORTED) | ||
| } else { | ||
| context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
cd android && find . -name "NoiseControlSettings.kt" -type fRepository: kavishdevar/librepods
Length of output: 145
🏁 Script executed:
cd android && git ls-files | grep -i noiseRepository: kavishdevar/librepods
Length of output: 541
🏁 Script executed:
find . -name "NoiseControlSettings.kt" -type fRepository: kavishdevar/librepods
Length of output: 153
🏁 Script executed:
cat -n android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt | head -200 | tail -50Repository: kavishdevar/librepods
Length of output: 2379
🏁 Script executed:
cat -n android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt | head -100Repository: kavishdevar/librepods
Length of output: 5178
🏁 Script executed:
find . -name "AirPodsNotifications.kt" -type fRepository: kavishdevar/librepods
Length of output: 47
🏁 Script executed:
rg "ANC_DATA|DISCONNECT_RECEIVERS" --type ktRepository: kavishdevar/librepods
Length of output: 91
🏁 Script executed:
rg "sendBroadcast" --type kt -A 3 -B 3Repository: kavishdevar/librepods
Length of output: 91
🏁 Script executed:
find . -name "*AirPodsNotifications*" -type fRepository: kavishdevar/librepods
Length of output: 47
🏁 Script executed:
find . -name "*Notification*" -type f | grep -i airpodsRepository: kavishdevar/librepods
Length of output: 47
🏁 Script executed:
rg "ANC_DATA|DISCONNECT_RECEIVERS" -A 2 -B 2Repository: kavishdevar/librepods
Length of output: 14838
🏁 Script executed:
rg "sendBroadcast" -A 3 -B 3Repository: 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.
| 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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@README.md`:
- Around line 76-93: The README currently suggests using adb shell run-as
me.kavishdevar.librepods ... but run-as only works for debuggable builds and
will fail on typical release installs; update the OxygenOS/ColorOS 16 setup
section to state the prerequisite that the app must be an adb-installed
debuggable build (e.g., built with DEBUG=true or installed via adb from Android
Studio) when using run-as, and provide alternatives: (1) instruct users to
enable a one-time in-app setting to paste their Bluetooth MAC into
shared_prefs/settings.xml (string name="self_mac_address"), (2) for rooted
devices show using su to edit shared_prefs/settings.xml, or (3) suggest using
Device File Explorer in Android Studio to edit
me.kavishdevar.librepods/shared_prefs/settings.xml; ensure the README mentions
the package name me.kavishdevar.librepods and the exact adb command as an option
only for debuggable builds.
| #### 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. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Clarify that run-as only works on debuggable builds (or provide an alternative).
On non-rooted devices, adb shell run-as me.kavishdevar.librepods ... fails for release builds because run-as is restricted to debuggable apps. Most users will be on a release install, so the current instructions likely won’t work. Please add a clear prerequisite (debug build installed via adb) and/or offer an alternative mechanism (e.g., in-app setting or a privileged adb broadcast).
✍️ Suggested doc tweak
-2. **Inject the MAC address via adb:**
+2. **Inject the MAC address via adb (debuggable builds only):**
+ > Note: `run-as` works only if the app is installed as a debuggable build (e.g., built locally or installed via adb in debug). It will fail for Play Store/FDroid release builds.
```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"
```🤖 Prompt for AI Agents
In `@README.md` around lines 76 - 93, The README currently suggests using adb
shell run-as me.kavishdevar.librepods ... but run-as only works for debuggable
builds and will fail on typical release installs; update the OxygenOS/ColorOS 16
setup section to state the prerequisite that the app must be an adb-installed
debuggable build (e.g., built with DEBUG=true or installed via adb from Android
Studio) when using run-as, and provide alternatives: (1) instruct users to
enable a one-time in-app setting to paste their Bluetooth MAC into
shared_prefs/settings.xml (string name="self_mac_address"), (2) for rooted
devices show using su to edit shared_prefs/settings.xml, or (3) suggest using
Device File Explorer in Android Studio to edit
me.kavishdevar.librepods/shared_prefs/settings.xml; ensure the README mentions
the package name me.kavishdevar.librepods and the exact adb command as an option
only for debuggable builds.
Summary
This PR enables LibrePods to work on non-rooted OnePlus 12 phones with OxygenOS 16, bringing full ANC, transparency, audio control, and other core features without requiring root or Xposed.
Changes
Testing
Compatibility
Device Tested
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes
Documentation