Skip to content

Conversation

@teng-lin
Copy link

@teng-lin teng-lin commented Feb 10, 2026

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

  • Thread safety fixes: Convert AACP collections to CopyOnWriteArrayList/ConcurrentHashMap to prevent ConcurrentModificationException
  • Receiver lifecycle management: Wrap BroadcastReceivers in DisposableEffect with proper unregistration
  • Service lifecycle cleanup: Remove duplicate unbindService call in onDestroy
  • Optional notification permission: Make POST_NOTIFICATIONS and READ_PHONE_STATE optional - don't block main screen
  • Audio source switching: Automatically reclaim AACP control when audio source returns to local device
  • L2CAP reconnection: Trigger L2CAP reconnect when A2DP resumes playing after device switch
  • Stale socket detection: Use AACP connectedDevices as reliable liveness indicator instead of inputStream.available()

Testing

  • ✅ ANC/transparency switching works
  • ✅ Audio source switching between Mac and phone maintains control
  • ✅ Battery monitoring functional
  • ✅ All customizations available
  • ✅ No battery drain or thermal issues
  • ✅ Clean logs without spurious errors

Compatibility

  • Non-rooted OxygenOS/ColorOS 16 (SDK 36): Full support
  • All other Android systems: Unchanged - still require root + Xposed/btl2capfix
  • Rooted devices: Cleaner logs, no functional regression

Device Tested

  • OnePlus 12 (OxygenOS 16, SDK 36, non-rooted)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added loading indicator during service startup for clearer feedback.
  • Bug Fixes

    • Improved Bluetooth connection and reconnection reliability.
    • Fixed permission checks for Bluetooth/location flows.
    • Resolved lifecycle and receiver cleanup issues to improve app stability.
  • Documentation

    • Added non-root setup instructions for OxygenOS/ColorOS 16+ devices.

teng-lin and others added 12 commits February 6, 2026 19:16
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>
@coderabbitai
Copy link

coderabbitai bot commented Feb 10, 2026

📝 Walkthrough

Walkthrough

Updated 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 .worktrees/.

Changes

Cohort / File(s) Summary
Version Control
./.gitignore
Added ignore rule for .worktrees/.
Activity & Permissions
android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt
Replaced broad permission check with BLUETOOTH/LOCATION-focused check; show progress UI while service initializes; start foreground service before binding; guard unbind/unregister calls behind initialization checks.
Composable Receiver Lifecycle
android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt
Moved BroadcastReceiver registration into a DisposableEffect; create IntentFilter there and unregister in onDispose with IllegalArgumentException handling.
Service: Connection & Notifications
android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt
Added isConnecting atomic guard; added socketAlive checks and connection recovery, async L2CAP reconnection on start, takeover/reclaim AACP control handling, expanded A2DP playing-state handling, adjusted notification IDs and lifecycle/cleanup flows.
Concurrency: Collections
android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt
Replaced mutable collections with thread-safe types: controlCommandStatusListCopyOnWriteArrayList, controlCommandListenersConcurrentHashMap with CopyOnWriteArrayList for listener lists.
OS Detection & Docs
android/app/src/main/java/me/kavishdevar/librepods/utils/RadareOffsetFinder.kt, README.md
Added isOxygenOSOrColorOS16OrAbove() helper and short-circuit logic in offset checks; added non-root setup instructions for OxygenOS/ColorOS 16 in README.

Possibly related issues

  • kavishdevar/librepods issue 250 — Changes add OxygenOS/ColorOS 16+ detection and non-root setup documentation and include lifecycle/connection fixes that address reported disconnects and battery/case-related behavior on OxygenOS 16 devices.
🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(android): support non-rooted OnePlus 12 / OxygenOS 16' directly aligns with the main objective of enabling LibrePods on non-rooted OnePlus 12 phones running OxygenOS 16, which is the primary focus of all changes across multiple files.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

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>
Copy link

@coderabbitai coderabbitai bot left a 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: Key DisposableEffect to context to avoid stale registrations.
If LocalContext changes (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: Prefer BluetoothA2dp.STATE_PLAYING over 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)
+        }

Comment on lines +179 to +183
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter, Context.RECEIVER_EXPORTED)
} else {
context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter)
}
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.

Copy link

@coderabbitai coderabbitai bot left a 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.

Comment on lines +76 to +93
#### 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.
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

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant