Skip to content
Open
Show file tree
Hide file tree
Changes from 12 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
47 changes: 32 additions & 15 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,10 @@ class MainActivity : ComponentActivity() {

override fun onDestroy() {
try {
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 +178,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 @@ -303,7 +303,11 @@ fun Main() {
canDrawOverlays = Settings.canDrawOverlays(context)
}

if (permissionState.allPermissionsGranted && (canDrawOverlays || overlaySkipped.value)) {
val bluetoothPermissionsGranted = permissionState.permissions.filter {
it.permission.contains("BLUETOOTH") || it.permission.contains("LOCATION")
}.all { it.status.isGranted }

if (bluetoothPermissionsGranted && (canDrawOverlays || overlaySkipped.value)) {
val context = LocalContext.current

val navController = rememberNavController()
Expand Down Expand Up @@ -356,6 +360,15 @@ fun Main() {
isConnected = isConnected.value,
isRemotelyConnected = isRemotelyConnected.value
)
} else {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
androidx.compose.material3.CircularProgressIndicator(
color = MaterialTheme.colorScheme.primary
)
}
}
}
composable("debug") {
Expand Down Expand Up @@ -457,7 +470,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)

if (airPodsService.value?.isConnectedLocally == true) {
isConnected.value = true
Expand Down Expand Up @@ -486,7 +501,9 @@ fun PermissionsScreen(

val scrollState = rememberScrollState()

val basicPermissionsGranted = permissionState.permissions.all { it.status.isGranted }
val basicPermissionsGranted = permissionState.permissions.filter {
it.permission.contains("BLUETOOTH") || it.permission.contains("LOCATION")
}.all { it.status.isGranted }

val infiniteTransition = rememberInfiniteTransition(label = "pulse")
val pulseScale by infiniteTransition.animateFloat(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
Expand Down Expand Up @@ -170,14 +171,21 @@ fun NoiseControlSettings(
}
}

val noiseControlIntentFilter = IntentFilter().apply {
addAction(AirPodsNotifications.ANC_DATA)
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter, Context.RECEIVER_EXPORTED)
} else {
context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter)
DisposableEffect(Unit) {
val noiseControlIntentFilter = IntentFilter().apply {
addAction(AirPodsNotifications.ANC_DATA)
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter, Context.RECEIVER_EXPORTED)
} else {
context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter)
}
Comment on lines +179 to +183
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.

onDispose {
try {
context.unregisterReceiver(noiseControlReceiver)
} catch (_: IllegalArgumentException) { }
}
}
Box(
modifier = Modifier
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1050,17 +1050,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
}
override fun onAudioSourceReceived(audioSource: ByteArray) {
Log.d("AirPodsParser", "Audio source changed mac: ${aacpManager.audioSource?.mac}, type: ${aacpManager.audioSource?.type?.name}")
if (aacpManager.audioSource?.type != AACPManager.Companion.AudioSourceType.NONE && aacpManager.audioSource?.mac != localMac) {
Log.d("AirPodsParser", "Audio source is another device, better to give up aacp control")
Log.d("AirPodsParser", "Audio source changed mac: ${aacpManager.audioSource?.mac}, type: ${aacpManager.audioSource?.type?.name}, localMac: $localMac")
if (aacpManager.audioSource?.type != AACPManager.Companion.AudioSourceType.NONE && localMac.isNotEmpty() && aacpManager.audioSource?.mac != localMac) {
Log.d("AirPodsParser", "Audio source is another device, giving up AACP control")
aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION.value,
byteArrayOf(0x00)
)
// this also means that the other device has start playing the audio, and if that's true, we can again start listening for audio config changes
// Log.d(TAG, "Another device started playing audio, listening for audio config changes again")
// MediaController.pausedForOtherDevice = false
// future me: what the heck is this? this just means it will not be taking over again if audio source doesn't change???
} else if (localMac.isNotEmpty() && aacpManager.audioSource?.mac == localMac) {
Log.d("AirPodsParser", "Audio source is local device, reclaiming AACP control")
aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION.value,
byteArrayOf(0x01)
)
}
Comment on lines 1051 to 1065
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

Potential null audioSource triggers false "give up" path.

If aacpManager.audioSource is null when this callback fires, the null-safe operators yield null != NONE → true and null != localMac → true, so the code will send OWNS_CONNECTION(0x00) (relinquish) even though there's no actual other device. Guard against null at the top:

Proposed fix
         override fun onAudioSourceReceived(audioSource: ByteArray) {
+            val source = aacpManager.audioSource ?: return
             Log.d("AirPodsParser", "Audio source changed mac: ${aacpManager.audioSource?.mac}, type: ${aacpManager.audioSource?.type?.name}, localMac: $localMac")
-            if (aacpManager.audioSource?.type != AACPManager.Companion.AudioSourceType.NONE && localMac.isNotEmpty() && aacpManager.audioSource?.mac != localMac) {
+            if (source.type != AACPManager.Companion.AudioSourceType.NONE && localMac.isNotEmpty() && source.mac != localMac) {
                 Log.d("AirPodsParser", "Audio source is another device, giving up AACP control")
                 aacpManager.sendControlCommand(
                     AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION.value,
                     byteArrayOf(0x00)
                 )
-            } else if (localMac.isNotEmpty() && aacpManager.audioSource?.mac == localMac) {
+            } else if (localMac.isNotEmpty() && source.mac == localMac) {
                 Log.d("AirPodsParser", "Audio source is local device, reclaiming AACP control")
                 aacpManager.sendControlCommand(
                     AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION.value,
                     byteArrayOf(0x01)
                 )
             }
         }
📝 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
override fun onAudioSourceReceived(audioSource: ByteArray) {
Log.d("AirPodsParser", "Audio source changed mac: ${aacpManager.audioSource?.mac}, type: ${aacpManager.audioSource?.type?.name}")
if (aacpManager.audioSource?.type != AACPManager.Companion.AudioSourceType.NONE && aacpManager.audioSource?.mac != localMac) {
Log.d("AirPodsParser", "Audio source is another device, better to give up aacp control")
Log.d("AirPodsParser", "Audio source changed mac: ${aacpManager.audioSource?.mac}, type: ${aacpManager.audioSource?.type?.name}, localMac: $localMac")
if (aacpManager.audioSource?.type != AACPManager.Companion.AudioSourceType.NONE && localMac.isNotEmpty() && aacpManager.audioSource?.mac != localMac) {
Log.d("AirPodsParser", "Audio source is another device, giving up AACP control")
aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION.value,
byteArrayOf(0x00)
)
// this also means that the other device has start playing the audio, and if that's true, we can again start listening for audio config changes
// Log.d(TAG, "Another device started playing audio, listening for audio config changes again")
// MediaController.pausedForOtherDevice = false
// future me: what the heck is this? this just means it will not be taking over again if audio source doesn't change???
} else if (localMac.isNotEmpty() && aacpManager.audioSource?.mac == localMac) {
Log.d("AirPodsParser", "Audio source is local device, reclaiming AACP control")
aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION.value,
byteArrayOf(0x01)
)
}
override fun onAudioSourceReceived(audioSource: ByteArray) {
val source = aacpManager.audioSource ?: return
Log.d("AirPodsParser", "Audio source changed mac: ${aacpManager.audioSource?.mac}, type: ${aacpManager.audioSource?.type?.name}, localMac: $localMac")
if (source.type != AACPManager.Companion.AudioSourceType.NONE && localMac.isNotEmpty() && source.mac != localMac) {
Log.d("AirPodsParser", "Audio source is another device, giving up AACP control")
aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION.value,
byteArrayOf(0x00)
)
} else if (localMac.isNotEmpty() && source.mac == localMac) {
Log.d("AirPodsParser", "Audio source is local device, reclaiming AACP control")
aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION.value,
byteArrayOf(0x01)
)
}
}
🤖 Prompt for AI Agents
In
`@android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt`
around lines 1051 - 1065, The callback onAudioSourceReceived can mis-evaluate
when aacpManager.audioSource is null; update onAudioSourceReceived to
early-return or explicitly treat null as AudioSourceType.NONE by first capturing
val source = aacpManager.audioSource and checking if source == null (or
source.type == AACPManager.Companion.AudioSourceType.NONE) before the existing
logic, then use that local source variable in the subsequent comparisons against
localMac and when calling aacpManager.sendControlCommand with
ControlCommandIdentifiers.OWNS_CONNECTION to avoid sending the relinquish (0x00)
command erroneously.

}

Expand Down Expand Up @@ -1460,6 +1462,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}

var isConnectedLocally = false
private val isConnecting = java.util.concurrent.atomic.AtomicBoolean(false)
var device: BluetoothDevice? = null

private lateinit var earReceiver: BroadcastReceiver
Expand Down Expand Up @@ -1587,6 +1590,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 @@ -1852,8 +1856,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList

val updatedNotification = updatedNotificationBuilder.build()

notificationManager.notify(2, updatedNotification)
notificationManager.cancel(1)
notificationManager.notify(1, updatedNotification)
} else if (!connected) {
updatedNotification = NotificationCompat.Builder(this, "background_service_status")
.setSmallIcon(R.drawable.airpods)
Expand All @@ -1866,7 +1869,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
.build()

notificationManager.notify(1, updatedNotification)
notificationManager.cancel(2)
} else if (!config.bleOnlyMode && !socket.isConnected && isConnectedLocally) {
showSocketConnectionFailureNotification("Socket created, but not connected. Is the Bluetooth process hooked?")
}
Expand Down Expand Up @@ -2173,6 +2175,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
context?.sendBroadcast(intent)
}
}
} else if (action == "android.bluetooth.a2dp.profile.action.PLAYING_STATE_CHANGED") {
val savedMac = context?.getSharedPreferences("settings", MODE_PRIVATE)
?.getString("mac_address", "")
if (!savedMac.isNullOrEmpty() && bluetoothDevice?.address == savedMac) {
val state = intent.getIntExtra("android.bluetooth.profile.extra.STATE", -1)
if (state == 10) { // BluetoothA2dp.STATE_PLAYING
Log.d(TAG, "A2DP playing on AirPods, re-triggering connection")
val connectionIntent = Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED)
connectionIntent.putExtra("name", name)
connectionIntent.putExtra("device", bluetoothDevice)
context?.sendBroadcast(connectionIntent)
}
}
}
}
}
Expand All @@ -2192,6 +2207,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)
if (bluetoothDevice != null) {
CoroutineScope(Dispatchers.IO).launch {
connectToSocket(bluetoothDevice)
}
}
}
}
Comment on lines +2210 to +2222
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 can throw on an invalid MAC string.

BluetoothAdapter.getRemoteDevice(savedMac) throws IllegalArgumentException if savedMac is not a valid Bluetooth hardware address (e.g., corrupted preference value). Wrap in a try-catch or validate the format first.

Proposed fix
         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)
+            val bluetoothDevice = try {
+                bluetoothManager?.adapter?.getRemoteDevice(savedMac)
+            } catch (e: IllegalArgumentException) {
+                Log.e(TAG, "Invalid saved MAC address: $savedMac", e)
+                null
+            }
             if (bluetoothDevice != null) {
🤖 Prompt for AI Agents
In
`@android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt`
around lines 2210 - 2222, The call to BluetoothAdapter.getRemoteDevice(savedMac)
can throw IllegalArgumentException for an invalid MAC string; update the
AirPodsService.kt code around the savedMac handling to either validate the MAC
format first or wrap getRemoteDevice(savedMac) in a try-catch that catches
IllegalArgumentException, logs the invalid value via Log.w/Log.e (including TAG
and savedMac), and skips attempting connectToSocket if invalid; ensure you still
launch CoroutineScope(Dispatchers.IO).launch { connectToSocket(bluetoothDevice)
} only when a non-null, valid bluetoothDevice is obtained.


return START_STICKY
}

Expand Down Expand Up @@ -2391,15 +2420,27 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList

@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
fun connectToSocket(device: BluetoothDevice, manual: Boolean = false) {
if (!isConnecting.compareAndSet(false, true)) {
Log.d(TAG, "Already connecting to socket, skipping duplicate attempt")
return
}
Log.d(TAG, "<LogCollector:Start> Connecting to socket")
HiddenApiBypass.addHiddenApiExemptions("Landroid/bluetooth/BluetoothSocket;")
val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
if (!isConnectedLocally) {
val socketActuallyAlive = isConnectedLocally && this::socket.isInitialized &&
socket.isConnected && aacpManager.connectedDevices.isNotEmpty()
if (!socketActuallyAlive) {
if (isConnectedLocally) {
Log.d(TAG, "isConnectedLocally was true but socket is dead, resetting")
isConnectedLocally = false
if (this::socket.isInitialized) try { socket.close() } catch (_: Exception) { }
}
Comment on lines +2429 to +2437
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

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "*.kt" | xargs grep -l "class AACPManager" | head -5

Repository: kavishdevar/librepods

Length of output: 138


🏁 Script executed:

rg -n "connectedDevices" --type=kt -C3 | grep -E "(AACPManager|connectedDevices.*=|val connectedDevices|var connectedDevices)" | head -20

Repository: kavishdevar/librepods

Length of output: 91


🏁 Script executed:

# Search for the AACPManager class definition and find connectedDevices
rg -n "class AACPManager" --type=kt -A 50

Repository: kavishdevar/librepods

Length of output: 91


🏁 Script executed:

cat ./android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt | head -100

Repository: kavishdevar/librepods

Length of output: 3820


🏁 Script executed:

rg -n "connectedDevices" ./android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt -A 2 -B 2

Repository: kavishdevar/librepods

Length of output: 4014


Thread-safe access to connectedDevices is required.

The socketActuallyAlive check at lines 2430–2431 reads aacpManager.connectedDevices.isNotEmpty() without synchronization. However, connectedDevices in AACPManager is declared as a regular List<ConnectedDevice> (line 213) and is mutated in multiple places (e.g., line 535 where it's reassigned, and lines 551–561 where elements are modified). This creates a data race when AirPodsService reads it concurrently with mutations in AACPManager. Convert connectedDevices to a thread-safe collection such as CopyOnWriteArrayList or use synchronization to guard concurrent access.

🤖 Prompt for AI Agents
In
`@android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt`
around lines 2429 - 2437, The access to aacpManager.connectedDevices from
AirPodsService (in the socketActuallyAlive check) is not thread-safe because
AACPManager mutates connectedDevices; update AACPManager to use a thread-safe
collection (e.g., declare connectedDevices as a
CopyOnWriteArrayList<ConnectedDevice>) or serialize access by adding a private
lock and guarding all reads/writes (including places that reassign or modify
connectedDevices such as the methods that add/remove or update entries). Ensure
all code that references connectedDevices (reads in AirPodsService and mutations
in AACPManager methods) uses the chosen thread-safe collection or the same
synchronization primitive so concurrent reads like
aacpManager.connectedDevices.isNotEmpty() are safe.

socket = try {
createBluetoothSocket(device, uuid)
} catch (e: Exception) {
Log.e(TAG, "Failed to create BluetoothSocket: ${e.message}")
showSocketConnectionFailureNotification("Failed to create Bluetooth socket: ${e.localizedMessage}")
isConnecting.set(false)
return
}

Expand Down Expand Up @@ -2451,6 +2492,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} else {
showSocketConnectionFailureNotification("Couldn't connect to socket: ${e.localizedMessage}")
}
isConnecting.set(false)
return@withTimeout
// throw e // lol how did i not catch this before... gonna comment this line instead of removing to preserve history
}
Expand All @@ -2465,8 +2507,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} else {
showSocketConnectionFailureNotification("Couldn't connect to socket: Timeout")
}
isConnecting.set(false)
return
}
isConnecting.set(false)
this@AirPodsService.device = device
socket.let {
aacpManager.sendPacket(aacpManager.createHandshakePacket())
Expand Down Expand Up @@ -2528,8 +2572,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList

} else if (bytesRead == -1) {
Log.d("AirPods Service", "Socket closed (bytesRead = -1)")
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED))
isConnectedLocally = false
isConnecting.set(false)
socket.close()
aacpManager.disconnected()
updateNotificationContent(false)
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED))
return@launch
}
}
Expand All @@ -2547,10 +2595,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
Log.d(TAG, "Failed to connect to socket: ${e.message}")
showSocketConnectionFailureNotification("Failed to establish connection: ${e.localizedMessage}")
isConnectedLocally = false
isConnecting.set(false)
this@AirPodsService.device = device
updateNotificationContent(false)
}
} else {
isConnecting.set(false)
Log.d(TAG, "Already connected locally, skipping socket connection (isConnectedLocally = $isConnectedLocally, socket.isConnected = ${this::socket.isInitialized && socket.isConnected})")
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,9 +200,9 @@ class AACPManager {
}

var controlCommandStatusList: MutableList<ControlCommandStatus> =
mutableListOf<ControlCommandStatus>()
java.util.concurrent.CopyOnWriteArrayList<ControlCommandStatus>()
var controlCommandListeners: MutableMap<ControlCommandIdentifiers, MutableList<ControlCommandListener>> =
mutableMapOf()
java.util.concurrent.ConcurrentHashMap<ControlCommandIdentifiers, MutableList<ControlCommandListener>>()

var owns: Boolean = false
private set
Expand Down Expand Up @@ -290,7 +290,7 @@ class AACPManager {
identifier: ControlCommandIdentifiers,
callback: ControlCommandListener
) {
controlCommandListeners.getOrPut(identifier) { mutableListOf() }.add(callback)
controlCommandListeners.getOrPut(identifier) { java.util.concurrent.CopyOnWriteArrayList() }.add(callback)
}

fun unregisterControlCommandListener(
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