Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
28 changes: 19 additions & 9 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,18 @@ class MainActivity : ComponentActivity() {

override fun onDestroy() {
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 All @@ -182,14 +186,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 @@ -457,7 +465,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)
Comment on lines +468 to +470
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.


if (airPodsService.value?.isConnectedLocally == true) {
isConnected.value = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1587,6 +1587,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}

fun setBatteryMetadata() {
if (::sharedPreferences.isInitialized && sharedPreferences.getBoolean("skip_setup", false)) return
device?.let { it ->
SystemApisUtils.setMetadata(
it,
Expand Down Expand Up @@ -2192,6 +2193,20 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
takeOver("music", manualTakeOverAfterReversed = true)
}

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

if (bluetoothDevice != null) {
CoroutineScope(Dispatchers.IO).launch {
connectToSocket(bluetoothDevice)
}
}
}
}
Comment on lines +2196 to +2208
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.


return START_STICKY
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext
import me.kavishdevar.librepods.services.ServiceManager
import android.os.Build
import java.io.BufferedReader
import java.io.File
import java.io.FileOutputStream
Expand Down Expand Up @@ -60,6 +61,12 @@ class RadareOffsetFinder(context: Context) {
"/system_ext/lib64/libbluetooth_qti.so"
)

fun isOxygenOSOrColorOS16OrAbove(): Boolean {
val manufacturer = Build.MANUFACTURER.lowercase()
if (manufacturer != "oneplus" && manufacturer != "oppo" && manufacturer != "realme") return false
return Build.VERSION.SDK_INT >= 36
}

fun findBluetoothLibraryPath(): String? {
for (path in LIBRARY_PATHS) {
if (File(path).exists()) {
Expand Down Expand Up @@ -115,6 +122,10 @@ class RadareOffsetFinder(context: Context) {
}

fun isSdpOffsetAvailable(): Boolean {
if (isOxygenOSOrColorOS16OrAbove()) {
Log.d(TAG, "OxygenOS/ColorOS 16+ detected, L2CAP works without SDP hook.")
return true
}
val sharedPreferences = ServiceManager.getService()?.applicationContext?.getSharedPreferences("settings", Context.MODE_PRIVATE) // ik not good practice- too lazy
if (sharedPreferences?.getBoolean("skip_setup", false) == true) {
Log.d(TAG, "Setup skipped, returning true for SDP offset.")
Expand Down Expand Up @@ -160,6 +171,10 @@ class RadareOffsetFinder(context: Context) {


fun isHookOffsetAvailable(): Boolean {
if (isOxygenOSOrColorOS16OrAbove()) {
Log.d(TAG, "OxygenOS/ColorOS 16+ detected, L2CAP works without hook.")
return true
}
Log.d(TAG, "Setup Skipped? " + ServiceManager.getService()?.applicationContext?.getSharedPreferences("settings", Context.MODE_PRIVATE)?.getBoolean("skip_setup", false).toString())
if (ServiceManager.getService()?.applicationContext?.getSharedPreferences("settings", Context.MODE_PRIVATE)?.getBoolean("skip_setup", false) == true) {
Log.d(TAG, "Setup skipped, returning true.")
Expand Down