From 57d692c4aec6ee0878c0332e289cc63955df7149 Mon Sep 17 00:00:00 2001 From: Kavish Devar Date: Mon, 1 Jun 2026 14:53:33 +0530 Subject: [PATCH] android: refactor AACP socket handling --- .../me/kavishdevar/librepods/MainActivity.kt | 3 +- .../librepods/bluetooth/AACPManager.kt | 3 +- .../viewmodel/AirPodsViewModel.kt | 7 +- .../librepods/services/AirPodsQSService.kt | 5 +- .../librepods/services/AirPodsService.kt | 122 +++++++++--------- 5 files changed, 69 insertions(+), 71 deletions(-) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt index 065781cc..efe1ee2e 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt @@ -118,6 +118,7 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.rememberHazeState +import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager import me.kavishdevar.librepods.data.AirPodsNotifications import me.kavishdevar.librepods.data.ControlCommandRepository import me.kavishdevar.librepods.presentation.components.AppInfoCard @@ -541,7 +542,7 @@ fun Main() { Context.BIND_AUTO_CREATE ) - if (airPodsService.value?.isConnected() == true) { + if (BluetoothConnectionManager.getAACPSocket()?.isConnected == true) { isConnected.value = true } } else { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt index e8273e6a..edaec253 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt @@ -31,9 +31,8 @@ import kotlin.io.encoding.ExperimentalEncodingApi * constructing and parsing packets for communication with AirPods. */ class AACPManager { + private val TAG = "AACPManager[${System.identityHashCode(this)}]" companion object { - private const val TAG = "AACPManager" - @Suppress("unused") object Opcodes { const val SET_FEATURE_FLAGS: Byte = 0x4D diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt index 9584f043..1114859f 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt @@ -24,6 +24,7 @@ import android.content.Intent import android.content.IntentFilter import android.content.SharedPreferences import android.content.pm.PackageManager +import android.util.Log import android.widget.Toast import androidx.core.content.edit import androidx.lifecycle.ViewModel @@ -40,6 +41,7 @@ import me.kavishdevar.librepods.bluetooth.AACPManager import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers import me.kavishdevar.librepods.bluetooth.ATTCCCDHandles import me.kavishdevar.librepods.bluetooth.ATTHandles +import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager import me.kavishdevar.librepods.data.AirPodsInstance import me.kavishdevar.librepods.data.AirPodsModels import me.kavishdevar.librepods.data.AirPodsNotifications @@ -352,7 +354,7 @@ class AirPodsViewModel( service.let { service -> _uiState.update { it.copy( - isLocallyConnected = service.isConnected(), battery = service.getBattery() + isLocallyConnected = BluetoothConnectionManager.getAACPSocket()?.isConnected == true, battery = service.getBattery() ) } } @@ -382,7 +384,6 @@ class AirPodsViewModel( val connectionSuccessful = sharedPreferences.getBoolean("connection_successful", false) - val fossUpgraded = sharedPreferences.getBoolean("foss_upgraded", false) _uiState.update { it.copy( offListeningMode = offListeningModeEnabled, @@ -398,8 +399,8 @@ class AirPodsViewModel( } // faulty update on Play caused PLAY_BUILD to be false and resulted in use of FOSS billing in Play. since FOSS is not verified, we need to give 2 weeks to verify the purchase - if (BuildConfig.PLAY_BUILD) { + val fossUpgraded = sharedPreferences.getBoolean("foss_upgraded", false) val expiryTime = sharedPreferences.getLong("premium_expiry_time", 0L) val now = System.currentTimeMillis() diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsQSService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsQSService.kt index 8514659c..eea7c6fc 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsQSService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsQSService.kt @@ -35,9 +35,10 @@ import android.util.Log import androidx.annotation.RequiresApi import me.kavishdevar.librepods.QuickSettingsDialogActivity import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.bluetooth.AACPManager +import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager import me.kavishdevar.librepods.data.AirPodsNotifications import me.kavishdevar.librepods.data.NoiseControlMode -import me.kavishdevar.librepods.bluetooth.AACPManager import kotlin.io.encoding.ExperimentalEncodingApi @RequiresApi(Build.VERSION_CODES.Q) @@ -98,7 +99,7 @@ class AirPodsQSService : TileService() { Log.d("AirPodsQSService", "onStartListening") val service = ServiceManager.getService() - isAirPodsConnected = service?.isConnected() == true + isAirPodsConnected = BluetoothConnectionManager.getAACPSocket()?.isConnected == true currentAncMode = service?.getANC() ?: (NoiseControlMode.OFF.ordinal + 1) if (currentAncMode == NoiseControlMode.OFF.ordinal + 1 && !isOffModeEnabled()) { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt index 881b68be..0495cd44 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt @@ -233,8 +233,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList lateinit var bleManager: BLEManager - private lateinit var socket: BluetoothSocket - companion object { init { System.loadLibrary("bluetooth_socket") @@ -246,7 +244,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList override fun onDeviceStatusChanged( device: BLEManager.AirPodsStatus, previousStatus: BLEManager.AirPodsStatus? ) { - if (device.connectionState == "Disconnected" && !isConnected()) { // should never happen unless android messes up and sends us a stale broadcast + if (device.connectionState == "Disconnected" && BluetoothConnectionManager.getAACPSocket()?.isConnected != true) { // should never happen unless android messes up and sends us a stale broadcast Log.d(TAG, "Seems no device has taken over, we will.") val bluetoothManager = getSystemService(BluetoothManager::class.java) val bluetoothAdapter = bluetoothManager.adapter @@ -258,7 +256,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList connectToSocket(bluetoothAdapter, bluetoothDevice) } Log.d(TAG, "Device status changed") - if (socket.isConnected) return + if (BluetoothConnectionManager.getAACPSocket()?.isConnected == true) return val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0 val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0 val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0 @@ -291,7 +289,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList getSharedPreferences("settings", MODE_PRIVATE).getString("name", "AirPods Pro") ?: "AirPods" ) - if (socket.isConnected) return + if (BluetoothConnectionManager.getAACPSocket()?.isConnected == true) return val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0 val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0 val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0 @@ -325,7 +323,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } override fun onBatteryChanged(device: BLEManager.AirPodsStatus) { - if (socket.isConnected) return + if (BluetoothConnectionManager.getAACPSocket()?.isConnected == true) return val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0 val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0 val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0 @@ -1739,7 +1737,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList val socketFailureChannel = NotificationChannel( "socket_connection_failure", - "AirPods Socket Connection Issues", + "AirPods BluetoothConnectionManager.getAACPSocket()? Connection Issues", NotificationManager.IMPORTANCE_HIGH ).apply { description = "Notifications about problems connecting to AirPods protocol" @@ -1785,7 +1783,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList if (BuildConfig.FLAVOR != "xposed") { Log.w( TAG, - "Not showing socket error notification to user, the service shouldn't be running if it isn't supported." + "Not showing BluetoothConnectionManager.getAACPSocket()? error notification to user, the service shouldn't be running if it isn't supported." ) return } @@ -2040,10 +2038,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) - if (!::socket.isInitialized) { + if (BluetoothConnectionManager.getAACPSocket() == null) { return } - if (connected && (config.bleOnlyMode || socket.isConnected)) { + if (BluetoothConnectionManager.getAACPSocket()?.isConnected == true) { val updatedNotificationBuilder = NotificationCompat.Builder(this, "airpods_connection_status") .setSmallIcon(R.drawable.airpods) @@ -2091,8 +2089,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList notificationManager.cancel(1) } else if (!connected) { notificationManager.cancel(2) - } else if (!config.bleOnlyMode && !socket.isConnected) { - showSocketConnectionFailureNotification("Socket created, but not connected. Check logs") + } else if (!config.bleOnlyMode && BluetoothConnectionManager.getAACPSocket()?.isConnected != true) { + showSocketConnectionFailureNotification("BluetoothConnectionManager.getAACPSocket()? created, but not connected. Check logs") } } @@ -2467,8 +2465,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList Log.d( TAG, "owns connection: $ownsConnection" ) - if (!::socket.isInitialized) return - if (socket.isConnected) { + if (BluetoothConnectionManager.getAACPSocket()?.isConnected == true) { if (!XposedRemotePrefProvider.create().getBoolean("vendor_id_hook", false) || ownsConnection == 0) { Log.d(TAG, "not taking over, vendorid is probably not set to apple") return @@ -2677,10 +2674,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList fun connectToSocket( adapter: BluetoothAdapter, device: BluetoothDevice, manual: Boolean = false ) { + if (BluetoothConnectionManager.getAACPSocket() != null && BluetoothConnectionManager.getAACPSocket()?.isConnected == true) return Log.d(TAG, " Connecting to socket") val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a") // if (!isConnectedLocally) { - socket = try { + val socket = try { createBluetoothSocket(adapter, device, uuid, 4097) } catch (e: Exception) { Log.e(TAG, "Failed to create BluetoothSocket: ${e.message}") @@ -2768,7 +2766,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } } if (!socket.isConnected) { - Log.d(TAG, " Socket not connected") + Log.d(TAG, " socket not connected") if (manual) { sendToast( "Couldn't connect to socket: timeout." @@ -2779,13 +2777,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList return } this@AirPodsService.device = device - socket.let { + BluetoothConnectionManager.getAACPSocket()?.let { aacpManager.sendPacket(aacpManager.createHandshakePacket()) aacpManager.sendSetFeatureFlagsPacket() aacpManager.sendNotificationRequest() Log.d(TAG, "Requesting proximity keys") aacpManager.sendRequestProximityKeys((AACPManager.Companion.ProximityKeyType.IRK.value + AACPManager.Companion.ProximityKeyType.ENC_KEY.value).toByte()) CoroutineScope(Dispatchers.IO).launch { + delay(200) aacpManager.sendPacket(aacpManager.createHandshakePacket()) delay(200) aacpManager.sendSetFeatureFlagsPacket() @@ -2813,55 +2812,53 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList setupStemActions() while (socket.isConnected) { - socket.let { it -> - try { - val buffer = ByteArray(1024) - val bytesRead = it.inputStream.read(buffer) - var data: ByteArray - if (bytesRead > 0) { - data = buffer.copyOfRange(0, bytesRead) - sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DATA).apply { - putExtra("data", buffer.copyOfRange(0, bytesRead)) - setPackage(packageName) - }) - val bytes = buffer.copyOfRange(0, bytesRead) - val formattedHex = bytes.joinToString(" ") { "%02X".format(it) } + try { + val buffer = ByteArray(1024) + val bytesRead = it.inputStream.read(buffer) + var data: ByteArray + if (bytesRead > 0) { + data = buffer.copyOfRange(0, bytesRead) + sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DATA).apply { + putExtra("data", buffer.copyOfRange(0, bytesRead)) + setPackage(packageName) + }) + val bytes = buffer.copyOfRange(0, bytesRead) + val formattedHex = bytes.joinToString(" ") { "%02X".format(it) } // CrossDevice.sendReceivedPacket(bytes) - updateNotificationContent( - true, - sharedPreferences.getString("name", device.name), - batteryNotification.getBattery() - ) + updateNotificationContent( + true, + sharedPreferences.getString("name", device.name), + batteryNotification.getBattery() + ) - aacpManager.receivePacket(data) + aacpManager.receivePacket(data) - if (!isHeadTrackingData(data)) { - Log.d("AirPodsData", "Data received: $formattedHex") - logPacket(data, "AirPods") - } - - } else if (bytesRead == -1) { - Log.d("AirPods Service", "Socket closed (bytesRead = -1)") - sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply { - setPackage(packageName) - }) - aacpManager.disconnected() - return@launch + if (!isHeadTrackingData(data)) { + Log.d("AirPodsData", "Data received: $formattedHex") + logPacket(data, "AirPods") } - } catch (e: Exception) { - Log.w(TAG, "Error reading data, we have probably disconnected.") - e.printStackTrace() + + } else if (bytesRead == -1) { + Log.d("AirPods Service", "BluetoothConnectionManager.getAACPSocket()? closed (bytesRead = -1)") sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply { setPackage(packageName) }) aacpManager.disconnected() return@launch } + } catch (e: Exception) { + Log.w(TAG, "Error reading data, we have probably disconnected.") + e.printStackTrace() + sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply { + setPackage(packageName) + }) + aacpManager.disconnected() + return@launch } + } - Log.d("AirPods Service", "Socket closed") + Log.d("AirPods Service", "socket closed") // isConnectedLocally = false - socket.close() aacpManager.disconnected() updateNotificationContent(false) sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply { @@ -2871,20 +2868,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } } catch (e: Exception) { e.printStackTrace() - Log.d(TAG, "Failed to connect to socket: ${e.message}") + Log.d(TAG, "Failed to connect to BluetoothConnectionManager.getAACPSocket()?: ${e.message}") showSocketConnectionFailureNotification("Failed to establish connection: ${e.localizedMessage}") // isConnectedLocally = false this@AirPodsService.device = device updateNotificationContent(false) } // } else { -// Log.d(TAG, "Already connected locally, skipping socket connection (isConnectedLocally = $isConnectedLocally, socket.isConnected = ${this::socket.isInitialized && socket.isConnected})") +// Log.d(TAG, "Already connected locally, skipping BluetoothConnectionManager.getAACPSocket()? connection (isConnectedLocally = $isConnectedLocally, BluetoothConnectionManager.getAACPSocket()?.isConnected = ${this::BluetoothConnectionManager.getAACPSocket()?.isInitialized && BluetoothConnectionManager.getAACPSocket()?.isConnected})") // } } fun disconnectForCD() { - if (!this::socket.isInitialized) return - socket.close() + BluetoothConnectionManager.getAACPSocket()?.close() MediaController.pausedWhileTakingOver = false Log.d(TAG, "Disconnected from AirPods, showing island.") showIsland( @@ -2915,8 +2911,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } fun disconnectAirPods() { - if (!this::socket.isInitialized) return - socket.close() + if (BluetoothConnectionManager.getAACPSocket() == null) return + try { + BluetoothConnectionManager.getAACPSocket()?.close() + } catch(e: Exception) { + Log.e(TAG, "error closing aacp socket ${e.message}") + } // isConnectedLocally = false aacpManager.disconnected() attManager.disconnected() @@ -3228,10 +3228,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } } } - - fun isConnected(): Boolean { - return if (::socket.isInitialized) socket.isConnected else false - } } private fun Int.dpToPx(): Int {