Skip to content

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

Closed
teng-lin wants to merge 2 commits intokavishdevar:mainfrom
teng-lin:fix/android-oneplus12-oxygenos16
Closed

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

Conversation

@teng-lin
Copy link

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

Summary

Fixes LibrePods to launch and run on non-rooted OnePlus 12 (OxygenOS 16 / Android 16, SDK 36) where L2CAP works natively on the QTI Bluetooth stack without root hooks.

Device: OnePlus CPH2583, Android 16, OxygenOS 16.0.0.202, non-rooted

Changes

  • OxygenOS/ColorOS 16 detection: isOxygenOSOrColorOS16OrAbove() detects OnePlus/OPPO/Realme on SDK 36+ and returns true from isHookOffsetAvailable() / isSdpOffsetAvailable(), bypassing root/radare2 setup
  • Service persistence: Call startForegroundService() before bindService() (wrapped in LaunchedEffect) so the service survives activity backgrounding
  • Early foreground notification: Move startForegroundNotification() to first call after super.onCreate() to prevent ForegroundServiceDidNotStartInTimeException
  • L2CAP auto-reconnect: attemptL2capReconnect() in onStartCommand() with BT adapter check, MAC validation, and service-scoped coroutine (SupervisorJob)
  • Guard empty selfMacAddress: Don't give up AACP control when localMac is empty (can't determine if audio source is local or remote)
  • Fix lateinit crash: Guard connectionStatusReceiver with ::isInitialized before unregistering
  • Skip privileged APIs on non-root: Skip setBatteryMetadata() on OxygenOS 16+ and when skip_setup is set

Known limitation: self MAC address

On SDK 36 with targetSdkVersion=36, the local Bluetooth MAC address cannot be retrieved programmatically without root:

  • Settings.Secure.getString("bluetooth_address") → blocked for targetSdk > 31
  • settings get secure bluetooth_address shell command → requires INTERACT_ACROSS_USERS
  • su → not available on non-rooted devices

Workaround: Inject via ADB after install:

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"

Without the MAC, the app adds a localMac.isNotEmpty() guard to avoid surrendering AACP control, so ANC switching still works. Multi-device handoff (TiPi packets) will be degraded.

Pre-existing issues found during testing (not addressed in this PR)

  • ConcurrentModificationException in AACPManager.receivePacket() (AACPManager.kt:463) — ArrayList iterated while modified from another thread, causes crash
  • IntentReceiverLeaked in NoiseControlSettings.kt:178 — BroadcastReceiver registered but never unregistered
  • L2CAP socket can silently disconnect with no reconnection
  • name pref shows generic "AirPods" instead of using airpods_name ("Teng's AirPods Pro 2")
  • Socket failure notification fires transiently during connection transitions

Test plan

  • App launches without crash on non-rooted OxygenOS 16
  • OxygenOS 16 detected, onboarding skipped
  • startForegroundNotification() completes before timeout
  • Service survives activity backgrounding (10s+)
  • L2CAP connects, AACP data flows (ear detection, battery)
  • ANC mode switching works (Transparency/Adaptive/Noise Cancellation confirmed by AirPods)
  • No UninitializedPropertyAccessException
  • No BLUETOOTH_PRIVILEGED spam

🤖 Generated with Claude Code

teng-lin and others added 2 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>
@coderabbitai
Copy link

coderabbitai bot commented Feb 7, 2026

Caution

Review failed

Failed to post review comments

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Starts the foreground service before binding in the UI, adds guarded unbind/unregister checks, implements coroutine-based L2CAP reconnect on service restart using saved MAC, short-circuits radare offset checks for OxygenOS/ColorOS 16+, and adds .worktrees to .gitignore.

Changes

Cohort / File(s) Summary
Git ignore
/.gitignore
Added an ignore rule for the .worktrees directory.
MainActivity — binding & lifecycle
android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt
Start foreground service before binding in the composable entry; guard unbind/unregister calls by initialization checks in onStop/onDestroy (preserve logs); adjust lifecycle binding workflow.
AirPodsService — reconnect & guards
android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt
On service restart (onStartCommand/RECONNECT_AFTER_REVERSE) attempt L2CAP reconnect by reading saved mac_address, resolving device via BluetoothManager, and launching coroutine to connect; add guard in setBatteryMetadata() to skip when skip_setup is true; lifecycle restart/reconnect logic added.
RadareOffsetFinder — OS detection
android/app/src/main/java/me/kavishdevar/librepods/utils/RadareOffsetFinder.kt
Added isOxygenOSOrColorOS16OrAbove() in companion object and short-circuited isSdpOffsetAvailable() / isHookOffsetAvailable() to return early for OxygenOS/ColorOS 16+ devices.

Possibly related issues

  • kavishdevar/librepods issue 250 — Adds OxygenOS/ColorOS 16+ detection and early-return logic in RadareOffsetFinder, directly addressing behavior noted for OxygenOS 16 in the issue.
🚥 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 PR title accurately summarizes the main objective: adding support for non-rooted OnePlus 12 / OxygenOS 16. The changes across all files (gitignore, MainActivity, AirPodsService, and RadareOffsetFinder) collectively achieve this goal through OS detection, service lifecycle improvements, and reconnection logic.

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

✨ Finishing touches
  • 📝 Generate docstrings
🧪 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.

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/MainActivity.kt`:
- Around line 452-456: The service currently does significant setup before
posting its foreground notification, risking
ForegroundServiceDidNotStartInTimeException; modify AirPodsService.onCreate() to
call startForegroundNotification() as the very first operation (or immediately
after minimal safety initialization) so the notification/startForeground is
guaranteed before BLE setup, broadcast registration, or other heavy work in
AirPodsService; then move BLE initialization and registerReceiver calls into a
separate method or coroutine launched after startForegroundNotification() so
heavy work happens post-foreground, and ensure startForegroundNotification() and
any minimal initialization are safe to run early.
🧹 Nitpick comments (3)
android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt (1)

2200-2231: attemptL2capReconnect() uses an unstructured CoroutineScope — consider tying it to the service lifecycle.

CoroutineScope(Dispatchers.IO) at line 2228 creates an orphaned scope. While the Job reference is saved and cancelled in onDestroy, if cancel() is called, only the job is cancelled — the scope is never cleaned up. This is a minor concern since there's no leak beyond the job itself.

More importantly, connectToSocket (line 2229) internally calls runBlocking with withTimeout(5000L) (around line 2442–2493). Since attemptL2capReconnect is launched on Dispatchers.IO, the runBlocking will block an IO thread — which is acceptable but worth noting. The reconnect will also trigger the full socket read loop inside connectToSocket, which runs indefinitely until the socket closes.

One edge case: if onStartCommand is called rapidly (e.g., service restart + intent delivery), the previous reconnectJob is cancelled, but connectToSocket may have already set isConnectedLocally = true and started the read loop in a nested coroutine that isn't tracked by reconnectJob. This nested coroutine (line 2512) is launched from a different CoroutineScope(Dispatchers.IO) inside connectToSocket and won't be cancelled.

Consider using a service-scoped CoroutineScope
+    private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+
     `@SuppressLint`("MissingPermission")
     private fun attemptL2capReconnect() {
         // ... guards ...
 
         reconnectJob?.cancel()
-        reconnectJob = CoroutineScope(Dispatchers.IO).launch {
+        reconnectJob = serviceScope.launch {
             connectToSocket(bluetoothDevice)
         }
     }
 
     override fun onDestroy() {
-        reconnectJob?.cancel()
+        serviceScope.cancel()
         // ...
     }
android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt (1)

142-143: Top-level lateinit var for serviceConnection and connectionStatusReceiver is fragile.

These are module-level mutable properties shared between MainActivity and the Main() composable. This coupling makes lifecycle management error-prone — any recomposition or configuration change could interact unexpectedly. The isInitialized guards added in this PR are a good mitigation, but ideally these should be scoped to the Activity or a ViewModel.

Not blocking since this is pre-existing design, but worth noting for future cleanup.

android/app/src/main/java/me/kavishdevar/librepods/utils/RadareOffsetFinder.kt (1)

64-69: OxygenOS/ColorOS detection uses manufacturer heuristic — edge cases are possible but low-impact.

The detection logic correctly lowercases Build.MANUFACTURER and checks for SDK 36+. However, the approach makes an assumption that may not hold in all cases:

  1. Custom ROMs: Devices running aftermarket ROMs on OnePlus/OPPO/Realme hardware would match the manufacturer check but may lack native L2CAP support, causing battery metadata setup to be skipped when it shouldn't be.
  2. Future device variants: Not all devices from these OEMs on SDK 36+ will necessarily have the QTI Bluetooth stack.

The codebase already has a findBluetoothLibraryPath() function that checks for QTI library existence. If edge cases are a concern, consider adding a runtime capability check (e.g., probing for library availability) before assuming L2CAP is available. This would handle custom ROMs and variant devices more robustly.

For mainstream use, the current heuristic is reasonable.

@teng-lin teng-lin force-pushed the fix/android-oneplus12-oxygenos16 branch from 55e477a to dec6b7a Compare February 7, 2026 01:57
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: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt (1)

2193-2234: ⚠️ Potential issue | 🟠 Major

Potential double-connect on RECONNECT_AFTER_REVERSE intent.

When the intent action is RECONNECT_AFTER_REVERSE, takeOver() is called at line 2200 (which internally calls connectToSocket), and then attemptL2capReconnect() is called unconditionally at line 2203, which also launches connectToSocket in a coroutine. This can result in two concurrent connection attempts to the same device.

Consider returning early or skipping the reconnect attempt when the reverse-reconnect path was taken:

Suggested fix
     if (intent?.action == "me.kavishdevar.librepods.RECONNECT_AFTER_REVERSE") {
         Log.d(TAG, "reconnect after reversed received, taking over")
         disconnectedBecauseReversed = false
         otherDeviceTookOver = false
         takeOver("music", manualTakeOverAfterReversed = true)
+        return START_STICKY
     }
 
     attemptL2capReconnect()
🧹 Nitpick comments (1)
android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt (1)

142-143: Top-level lateinit var declarations are global mutable state.

serviceConnection and connectionStatusReceiver are top-level mutable properties, making them accessible and modifiable from anywhere. This is a pre-existing pattern, but consider encapsulating these within MainActivity as private members in a future refactor to avoid accidental misuse.

@teng-lin teng-lin force-pushed the fix/android-oneplus12-oxygenos16 branch 2 times, most recently from 28990fc to 66c6a25 Compare February 7, 2026 02:47
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: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt (1)

2221-2234: ⚠️ Potential issue | 🟠 Major

Potential duplicate connection attempt on RECONNECT_AFTER_REVERSE intent.

When the action is RECONNECT_AFTER_REVERSE, takeOver() (Line 2228) may initiate its own connectToSocket() call, and then attemptL2capReconnect() (Line 2231) runs immediately after. Since takeOver() launches coroutines internally, isConnectedLocally may not yet be true when attemptL2capReconnect() checks it, leading to a race where two connection attempts proceed concurrently.

Consider guarding with an early return:

Proposed fix
     if (intent?.action == "me.kavishdevar.librepods.RECONNECT_AFTER_REVERSE") {
         Log.d(TAG, "reconnect after reversed received, taking over")
         disconnectedBecauseReversed = false
         otherDeviceTookOver = false
         takeOver("music", manualTakeOverAfterReversed = true)
+        return START_STICKY
     }

     attemptL2capReconnect()
🤖 Fix all issues with AI agents
In
`@android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt`:
- Around line 380-423: The su fallback can hang because
Runtime.getRuntime().exec(...).waitFor() may block; update the su branch that
assigns localMac to use Process.waitFor(timeout, TimeUnit.MILLISECONDS/SECONDS)
(API 26+) on the Process returned by Runtime.getRuntime().exec in the
AirPodsService code (the block that constructs arrayOf("su", "-c", "settings get
secure bluetooth_address")), and if waitFor times out call process.destroy() (or
destroyForcibly()) and log a timeout error before returning an empty string;
ensure you still read inputStream only when exitCode == 0 and handle exceptions
as before.

In
`@android/app/src/main/java/me/kavishdevar/librepods/utils/RadareOffsetFinder.kt`:
- Around line 64-69: The current isOxygenOSOrColorOS16OrAbove() uses only
Build.MANUFACTURER (COLOROS_MANUFACTURERS) and SDK to detect stock
OxygenOS/ColorOS, which can false-positive on custom ROMs; update that function
to also verify the ROM identifier by checking Build.DISPLAY and/or the system
property ro.build.display.id (via android.os.SystemProperties or reflection) for
substrings like "OxygenOS", "ColorOS", "RealmeUI" before returning true, so both
manufacturer and ROM/display id checks must pass; keep the existing
COLOROS_MANUFACTURERS constant and the SDK >= 36 check and add the additional
display/property check inside isOxygenOSOrColorOS16OrAbove().
🧹 Nitpick comments (1)
android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt (1)

142-143: Top-level lateinit var for serviceConnection and connectionStatusReceiver is fragile.

These module-level mutable variables are shared between the composable (Main()) and the Activity lifecycle (onDestroy). If multiple MainActivity instances exist (e.g., configuration change with singleTop not set), or if the composable is recomposed, the old reference could be lost. Consider moving these to Activity-level properties or using a remember-backed pattern that feeds into a callback for cleanup.

This is a pre-existing pattern and not introduced by this PR, so flagging as optional.

Comment on lines 380 to 423
// Try Settings.Secure content provider first
localMac = try {
val process = Runtime.getRuntime().exec(
arrayOf("su", "-c", "settings get secure bluetooth_address")
)

val exitCode = process.waitFor()

if (exitCode == 0) {
process.inputStream.bufferedReader().use { it.readLine()?.trim().orEmpty() }
} else {
""
}
Settings.Secure.getString(contentResolver, "bluetooth_address")?.trim().orEmpty()
} catch (e: Exception) {
Log.e(TAG, "Error retrieving local MAC address: ${e.message}. We probably aren't rooted.")
Log.d(TAG, "Could not read bluetooth_address from Settings.Secure: ${e.message}")
""
}
// Try shell command without root (works on OxygenOS 16+)
if (localMac.isEmpty()) {
localMac = try {
val process = Runtime.getRuntime().exec(
arrayOf("settings", "get", "secure", "bluetooth_address")
)
val exitCode = process.waitFor()
if (exitCode == 0) {
process.inputStream.bufferedReader().use { it.readLine()?.trim().orEmpty() }
} else {
""
}
} catch (e: Exception) {
Log.d(TAG, "Could not read bluetooth_address via settings command: ${e.message}")
""
}
}
// Last resort: try with su (rooted devices)
if (localMac.isEmpty()) {
localMac = try {
val process = Runtime.getRuntime().exec(
arrayOf("su", "-c", "settings get secure bluetooth_address")
)
val exitCode = process.waitFor()
if (exitCode == 0) {
process.inputStream.bufferedReader().use { it.readLine()?.trim().orEmpty() }
} else {
""
}
} catch (e: Exception) {
Log.e(TAG, "Error retrieving local MAC address: ${e.message}. We probably aren't rooted.")
""
}
}
if (localMac.isNotEmpty()) {
Log.d(TAG, "Local Bluetooth MAC: ...${localMac.takeLast(5)}")
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Multi-step MAC retrieval is a solid fallback chain, but the su step can hang.

Runtime.exec("su …").waitFor() (Line 410) blocks indefinitely if the su binary exists but prompts for user confirmation or stalls. Consider adding a timeout (e.g., via Process.waitFor(timeout, unit) available since API 26) to prevent onCreate from blocking.

Proposed fix: add timeout to su process
                     val process = Runtime.getRuntime().exec(
                         arrayOf("su", "-c", "settings get secure bluetooth_address")
                     )
-                    val exitCode = process.waitFor()
+                    val finished = process.waitFor(3, java.util.concurrent.TimeUnit.SECONDS)
+                    val exitCode = if (finished) process.exitValue() else -1
+                    if (!finished) process.destroyForcibly()
                     if (exitCode == 0) {
🤖 Prompt for AI Agents
In
`@android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt`
around lines 380 - 423, The su fallback can hang because
Runtime.getRuntime().exec(...).waitFor() may block; update the su branch that
assigns localMac to use Process.waitFor(timeout, TimeUnit.MILLISECONDS/SECONDS)
(API 26+) on the Process returned by Runtime.getRuntime().exec in the
AirPodsService code (the block that constructs arrayOf("su", "-c", "settings get
secure bluetooth_address")), and if waitFor times out call process.destroy() (or
destroyForcibly()) and log a timeout error before returning an empty string;
ensure you still read inputStream only when exitCode == 0 and handle exceptions
as before.

Comment on lines 64 to 69
private val COLOROS_MANUFACTURERS = setOf("oneplus", "oppo", "realme")

fun isOxygenOSOrColorOS16OrAbove(): Boolean {
return Build.MANUFACTURER.lowercase() in COLOROS_MANUFACTURERS
&& Build.VERSION.SDK_INT >= 36
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the actual implementation of isOxygenOSOrColorOS16OrAbove
cat -n android/app/src/main/java/me/kavishdevar/librepods/utils/RadareOffsetFinder.kt | head -80

Repository: kavishdevar/librepods

Length of output: 4086


🏁 Script executed:

# Check where this function is used in the codebase
rg -n "isOxygenOSOrColorOS16OrAbove" --type=kt

Repository: kavishdevar/librepods

Length of output: 91


🏁 Script executed:

# Check for other OS-fingerprinting patterns in the repo to see if there are better examples
rg -n "Build\.MANUFACTURER|Build\.DISPLAY|ro\.build" --type=kt -B2 -A2

Repository: kavishdevar/librepods

Length of output: 91


🏁 Script executed:

# Check if there are system property checks elsewhere
rg -n "getprop|ro\." --type=kt | head -20

Repository: kavishdevar/librepods

Length of output: 91


🏁 Script executed:

# Search for usage of the function without type filter
rg -n "isOxygenOSOrColorOS16OrAbove" --type-not=binary

Repository: kavishdevar/librepods

Length of output: 95


🏁 Script executed:

# Search for OS detection patterns without type filter
rg -n "Build\.MANUFACTURER|Build\.DISPLAY" --type-not=binary -B2 -A2

Repository: kavishdevar/librepods

Length of output: 95


🏁 Script executed:

# Verify Android SDK INT mapping - check if SDK 36 is Android 16
rg -n "SDK_INT\|Build\.VERSION" --type-not=binary | grep -E "(36|35|16|15)" | head -10

Repository: kavishdevar/librepods

Length of output: 95


🏁 Script executed:

# Look at how the function is used in context
rg -B5 -A5 "isOxygenOSOrColorOS16OrAbove" --type-not=binary

Repository: kavishdevar/librepods

Length of output: 95


🏁 Script executed:

# Search for function usage without type filter
rg -n "isOxygenOSOrColorOS16OrAbove"

Repository: kavishdevar/librepods

Length of output: 615


🏁 Script executed:

# Search for Build.MANUFACTURER or Build.DISPLAY usage
rg -n "Build\.MANUFACTURER|Build\.DISPLAY"

Repository: kavishdevar/librepods

Length of output: 221


🏁 Script executed:

# Check Android API level to version mapping (general knowledge verification)
# Look for any comments or documentation about SDK levels
rg -n "SDK_INT|Android [0-9]+" | head -20

Repository: kavishdevar/librepods

Length of output: 3142


🏁 Script executed:

# Look for the uses of the isOxygenOSOrColorOS16OrAbove in context (lines around usage)
rg -B10 -A10 "isOxygenOSOrColorOS16OrAbove"

Repository: kavishdevar/librepods

Length of output: 10510


🌐 Web query:

Android SDK_INT level 36 API version

💡 Result:

Build.VERSION.SDK_INT == 36 means the device is running Android 16, whose API level 36 version code/codename is BAKLAVA (“Baklava”). [1][2]

Sources: Android Developers Build.VERSION_CODES (shows BAKLAVA = 36) and Android SDK Platform release notes (lists “Android 16 (API level 36)”). [1][2]


Manufacturer-only check may false-positive on custom ROMs.

The detection relies solely on Build.MANUFACTURER + SDK level, but a OnePlus/OPPO/Realme device running a custom ROM (e.g., LineageOS) won't have the QTI Bluetooth stack. Consider additionally checking Build.DISPLAY or a system property (e.g., ro.build.display.id containing "OxygenOS" / "ColorOS") to confirm the stock ROM is present.

This won't affect stock ROM users but could cause silent misbehavior for custom-ROM users on matching hardware.

🤖 Prompt for AI Agents
In
`@android/app/src/main/java/me/kavishdevar/librepods/utils/RadareOffsetFinder.kt`
around lines 64 - 69, The current isOxygenOSOrColorOS16OrAbove() uses only
Build.MANUFACTURER (COLOROS_MANUFACTURERS) and SDK to detect stock
OxygenOS/ColorOS, which can false-positive on custom ROMs; update that function
to also verify the ROM identifier by checking Build.DISPLAY and/or the system
property ro.build.display.id (via android.os.SystemProperties or reflection) for
substrings like "OxygenOS", "ColorOS", "RealmeUI" before returning true, so both
manufacturer and ROM/display id checks must pass; keep the existing
COLOROS_MANUFACTURERS constant and the SDK >= 36 check and add the additional
display/property check inside isOxygenOSOrColorOS16OrAbove().

@teng-lin teng-lin force-pushed the fix/android-oneplus12-oxygenos16 branch 2 times, most recently from fbb268a to 8bdee2d Compare February 9, 2026 18:51
@teng-lin teng-lin closed this Feb 9, 2026
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: 3

🤖 Fix all issues with AI agents
In `@android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt`:
- Around line 468-470: The startForegroundService and bindService calls are
being executed directly in the composable body and will run on every
recomposition; wrap these side-effects so they run once and are cleaned up: move
the Intent creation and context.startForegroundService(serviceIntent) and
context.bindService(serviceIntent, serviceConnection, Context.BIND_AUTO_CREATE)
into a LaunchedEffect(Unit) or DisposableEffect(Unit) block (use
DisposableEffect if you need to unbind in onDispose), referencing the existing
serviceConnection and AirPodsService class, and in DisposableEffect's onDispose
call context.unbindService(serviceConnection) (and stop the service if needed)
to avoid multiple bindings and leaks.

In
`@android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt`:
- Around line 2198-2201: savedMac is only checked for null/empty but may be
malformed and cause BluetoothAdapter.getRemoteDevice(savedMac) to throw
IllegalArgumentException; update AirPodsService to validate the MAC string
before calling getRemoteDevice (e.g., check against a MAC regex for six hex
pairs separated by ':' or '-') or wrap the getRemoteDevice(...) call in a
try/catch for IllegalArgumentException, log the invalid savedMac and skip the
reconnect attempt; reference the savedMac variable and the call to
bluetoothManager?.adapter?.getRemoteDevice(savedMac) in your change.
- Around line 2196-2208: The reconnect coroutine launched in onStartCommand is
unstructured and can run multiple times concurrently; create a service-scoped
CoroutineScope and a cancellable reconnect Job to serialize and manage reconnect
attempts: add class-level fields like serviceScope =
CoroutineScope(SupervisorJob() + Dispatchers.IO) and var reconnectJob: Job?,
replace CoroutineScope(Dispatchers.IO).launch { connectToSocket(bluetoothDevice)
} with reconnectJob?.cancel() followed by reconnectJob = serviceScope.launch {
if (!isConnectedLocally) connectToSocket(bluetoothDevice) }, ensure
connectToSocket and the isConnectedLocally check are used inside that scope to
avoid race conditions, and cancel serviceScope (or call serviceScope.cancel())
in onDestroy to avoid leaked coroutines.

Comment on lines +468 to +470
val serviceIntent = Intent(context, AirPodsService::class.java)
context.startForegroundService(serviceIntent)
context.bindService(serviceIntent, serviceConnection, Context.BIND_AUTO_CREATE)
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

startForegroundService() and bindService() are called on every recomposition — must be wrapped in a side-effect.

These calls sit directly in the composable body without any side-effect wrapper. Composables can recompose many times (on any state change in the parent tree), so startForegroundService and especially bindService will fire repeatedly, leading to multiple bindings without corresponding unbinds.

Wrap in LaunchedEffect(Unit) (or DisposableEffect) so they execute only once:

Proposed fix
-    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)
+    }
📝 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
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)
}
🤖 Prompt for AI Agents
In `@android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt` around
lines 468 - 470, The startForegroundService and bindService calls are being
executed directly in the composable body and will run on every recomposition;
wrap these side-effects so they run once and are cleaned up: move the Intent
creation and context.startForegroundService(serviceIntent) and
context.bindService(serviceIntent, serviceConnection, Context.BIND_AUTO_CREATE)
into a LaunchedEffect(Unit) or DisposableEffect(Unit) block (use
DisposableEffect if you need to unbind in onDispose), referencing the existing
serviceConnection and AirPodsService class, and in DisposableEffect's onDispose
call context.unbindService(serviceConnection) (and stop the service if needed)
to avoid multiple bindings and leaks.

Comment on lines +2196 to +2208
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)
}
}
}
}
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

Unstructured coroutine scope risks duplicate reconnect attempts and leaked coroutines.

onStartCommand can be called multiple times (START_STICKY restarts, duplicate intents). Each invocation launches a new CoroutineScope(Dispatchers.IO) coroutine with no tracking or cancellation. This creates two problems:

  1. Race condition: Multiple coroutines can pass the !isConnectedLocally check concurrently before any sets isConnectedLocally = true inside connectToSocket, leading to parallel socket connection attempts.
  2. Leaked coroutine: The scope is unstructured and never cancelled in onDestroy(), so the reconnect work can outlive the service.

Consider using a service-scoped CoroutineScope with SupervisorJob (as noted in the PR description) and cancelling previous reconnect jobs.

Proposed fix (sketch)

Add a service-level scope and reconnect job:

// Class-level fields
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var reconnectJob: Job? = null

In onStartCommand:

     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 {
+                reconnectJob?.cancel()
+                reconnectJob = serviceScope.launch {
                     connectToSocket(bluetoothDevice)
                 }
             }
         }
     }

In onDestroy:

+    serviceScope.cancel()
     super.onDestroy()
🤖 Prompt for AI Agents
In
`@android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt`
around lines 2196 - 2208, The reconnect coroutine launched in onStartCommand is
unstructured and can run multiple times concurrently; create a service-scoped
CoroutineScope and a cancellable reconnect Job to serialize and manage reconnect
attempts: add class-level fields like serviceScope =
CoroutineScope(SupervisorJob() + Dispatchers.IO) and var reconnectJob: Job?,
replace CoroutineScope(Dispatchers.IO).launch { connectToSocket(bluetoothDevice)
} with reconnectJob?.cancel() followed by reconnectJob = serviceScope.launch {
if (!isConnectedLocally) connectToSocket(bluetoothDevice) }, ensure
connectToSocket and the isConnectedLocally check are used inside that scope to
avoid race conditions, and cancel serviceScope (or call serviceScope.cancel())
in onDestroy to avoid leaked coroutines.

Comment on lines +2198 to +2201
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)
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

getRemoteDevice() throws IllegalArgumentException on malformed MAC addresses.

The savedMac value is only checked for null/empty, but if the stored preference contains a malformed MAC string, getRemoteDevice(savedMac) will throw. Consider validating the MAC format before calling it.

Proposed fix
         val savedMac = sharedPreferences.getString("mac_address", "")
-        if (!savedMac.isNullOrEmpty()) {
+        if (!savedMac.isNullOrEmpty() && BluetoothAdapter.checkBluetoothAddress(savedMac)) {
📝 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 (!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 (!savedMac.isNullOrEmpty() && BluetoothAdapter.checkBluetoothAddress(savedMac)) {
Log.d(TAG, "Service restarted, attempting L2CAP reconnect to $savedMac")
val bluetoothManager = getSystemService(BluetoothManager::class.java)
val bluetoothDevice = bluetoothManager?.adapter?.getRemoteDevice(savedMac)
🤖 Prompt for AI Agents
In
`@android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt`
around lines 2198 - 2201, savedMac is only checked for null/empty but may be
malformed and cause BluetoothAdapter.getRemoteDevice(savedMac) to throw
IllegalArgumentException; update AirPodsService to validate the MAC string
before calling getRemoteDevice (e.g., check against a MAC regex for six hex
pairs separated by ':' or '-') or wrap the getRemoteDevice(...) call in a
try/catch for IllegalArgumentException, log the invalid savedMac and skip the
reconnect attempt; reference the savedMac variable and the call to
bluetoothManager?.adapter?.getRemoteDevice(savedMac) in your change.

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