Skip to content

fix(android): support non-rooted OnePlus 12 / OxygenOS 16#453

Open
teng-lin wants to merge 17 commits intokavishdevar:mainfrom
teng-lin:fix/android-oneplus12-oxygenos16
Open

fix(android): support non-rooted OnePlus 12 / OxygenOS 16#453
teng-lin wants to merge 17 commits intokavishdevar:mainfrom
teng-lin:fix/android-oneplus12-oxygenos16

Conversation

@teng-lin
Copy link
Copy Markdown

@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

    • Loading indicator shown while background service starts.
  • Bug Fixes / Improvements

    • More reliable Bluetooth connection, reconnection, and takeover flows with stronger guards to avoid duplicate operations.
    • Improved permission checks, receiver lifecycle handling and cleanup to reduce crashes/leaks.
    • Safer background service start/bind behavior, notification consistency, and battery/metadata short-circuiting.
    • Thread-safe collections used for command handling.
  • Documentation

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

teng-lin and others added 13 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>
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>
coderabbitai[bot]

This comment was marked as outdated.

coderabbitai[bot]

This comment was marked as outdated.

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
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.
@teng-lin teng-lin force-pushed the fix/android-oneplus12-oxygenos16 branch from d090728 to aa3db92 Compare February 11, 2026 11:53
coderabbitai[bot]

This comment was marked as outdated.

coderabbitai[bot]

This comment was marked as outdated.

@kavishdevar kavishdevar force-pushed the main branch 6 times, most recently from 4bbaa29 to cb246d1 Compare April 26, 2026 12:06
@teng-lin teng-lin marked this pull request as draft May 4, 2026 01:58
@teng-lin teng-lin marked this pull request as ready for review May 4, 2026 01:58
teng-lin added 2 commits May 3, 2026 22:05
…12-oxygenos16

# Conflicts:
#	.gitignore
#	README.md
#	android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt
#	android/app/src/main/java/me/kavishdevar/librepods/presentation/components/NoiseControlSettings.kt
#	android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt
Repository owner deleted a comment from coderabbitai Bot May 4, 2026
Repository owner deleted a comment from coderabbitai Bot May 4, 2026
Repository owner deleted a comment from coderabbitai Bot May 4, 2026
Repository owner deleted a comment from coderabbitai Bot May 4, 2026
Repository owner deleted a comment from coderabbitai Bot May 4, 2026
Repository owner deleted a comment from coderabbitai Bot May 4, 2026
Repository owner deleted a comment from coderabbitai Bot May 4, 2026
@kavishdevar
Copy link
Copy Markdown
Owner

Could you please another PR for the CI fix? Thanks!

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.

2 participants