diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index e996ec5ba..68b9f6c07 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -21,3 +21,7 @@ #-renamesourcefileattribute SourceFile -keep class me.kavishdevar.librepods.utils.KotlinModule { *; } +-keep class me.kavishdevar.librepods.utils.NativeBridge { *; } +-keepclasseswithmembernames class * { + native ; +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/LibrePodsApplication.kt b/android/app/src/main/java/me/kavishdevar/librepods/LibrePodsApplication.kt index d6968038c..3ad129d31 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/LibrePodsApplication.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/LibrePodsApplication.kt @@ -14,6 +14,11 @@ import me.kavishdevar.librepods.utils.XposedState class LibrePodsApplication: Application(), XposedServiceHelper.OnServiceListener, DefaultLifecycleObserver { override fun onCreate() { + try { + System.loadLibrary("bluetooth_socket") + } catch (e: Throwable) { + android.util.Log.e("LibrePodsApp", "Failed to load bluetooth_socket: ${e.message}") + } XposedServiceHelper.registerListener(this) BillingManager.provider = BillingProviderFactory.create(this) ProcessLifecycleOwner.get().lifecycle.addObserver(this) @@ -22,16 +27,28 @@ class LibrePodsApplication: Application(), XposedServiceHelper.OnServiceListener } + private fun isBluetoothScopeEnabled(): Boolean { + val scope = XposedServiceHolder.service?.scope ?: return false + return scope.any { + it in listOf( + "com.google.android.bluetooth", + "com.android.bluetooth", + "com.google.android.btservices", + "com.android.btservices" + ) + } + } + override fun onResume(owner: LifecycleOwner) { BillingManager.provider.queryPurchases() XposedState.isAvailable = XposedServiceHolder.service != null - XposedState.bluetoothScopeEnabled = XposedServiceHolder.service?.scope?.contains("com.google.android.bluetooth") == true || XposedServiceHolder.service?.scope?.contains("com.android.bluetooth") == true + XposedState.bluetoothScopeEnabled = isBluetoothScopeEnabled() } override fun onServiceBind(service: XposedService) { XposedServiceHolder.service = service XposedState.isAvailable = true - XposedState.bluetoothScopeEnabled = XposedServiceHolder.service?.scope?.contains("com.google.android.bluetooth") == true || XposedServiceHolder.service?.scope?.contains("com.android.bluetooth") == true + XposedState.bluetoothScopeEnabled = isBluetoothScopeEnabled() } override fun onServiceDied(p0: XposedService) { 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 ac6d356b7..143e8f32c 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 @@ -112,7 +112,11 @@ class AACPManager { 0x37 ), PPE_CAP_LEVEL_CONFIG(0x38), - DYNAMIC_END_OF_CHARGE(0x3B); + DYNAMIC_END_OF_CHARGE(0x3B), + UPLINK_EQ_BUD_CONFIG(0x3E), + UPLINK_EQ_SOURCE_CONFIG(0x3F), + IN_CASE_TONE_VOLUME(0x40), + DISABLE_BUTTON_INPUT(0x41); companion object { fun fromByte(byte: Byte): ControlCommandIdentifiers? = @@ -214,10 +218,7 @@ class AACPManager { private fun setControlCommandStatusValue( identifier: ControlCommandIdentifiers, value: ByteArray ) { - val existingStatus = getControlCommandStatus(identifier) - if (existingStatus?.value.contentEquals(value)) { - controlCommandStatusList.remove(existingStatus) - } + controlCommandStatusList.removeAll { it.identifier == identifier } controlCommandListeners[identifier]?.forEach { listener -> listener.onControlCommandReceived(ControlCommand(identifier.value, value)) } @@ -308,38 +309,36 @@ class AACPManager { fun sendControlCommand(identifier: Byte, value: ByteArray): Boolean { val controlPacket = createControlCommandPacket(identifier, value) - setControlCommandStatusValue( - ControlCommandIdentifiers.fromByte(identifier) ?: return false, value - ) + ControlCommandIdentifiers.fromByte(identifier)?.let { + setControlCommandStatusValue(it, value) + } return sendDataPacket(controlPacket) } @OptIn(ExperimentalStdlibApi::class) fun sendControlCommand(identifier: Byte, value: Byte): Boolean { val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value)) - setControlCommandStatusValue( - ControlCommandIdentifiers.fromByte(identifier) ?: return false, byteArrayOf(value) - ) + ControlCommandIdentifiers.fromByte(identifier)?.let { + setControlCommandStatusValue(it, byteArrayOf(value)) + } return sendDataPacket(controlPacket) } fun sendControlCommand(identifier: Byte, value: Boolean): Boolean { - val controlPacket = createControlCommandPacket( - identifier, if (value) byteArrayOf(0x01) else byteArrayOf(0x02) - ) - setControlCommandStatusValue( - ControlCommandIdentifiers.fromByte(identifier) ?: return false, - if (value) byteArrayOf(0x01) else byteArrayOf(0x02) - ) + val bytes = if (value) byteArrayOf(0x01) else byteArrayOf(0x02) + val controlPacket = createControlCommandPacket(identifier, bytes) + ControlCommandIdentifiers.fromByte(identifier)?.let { + setControlCommandStatusValue(it, bytes) + } return sendDataPacket(controlPacket) } fun sendControlCommand(identifier: Byte, value: Int): Boolean { - val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value.toByte())) - setControlCommandStatusValue( - ControlCommandIdentifiers.fromByte(identifier) ?: return false, - byteArrayOf(value.toByte()) - ) + val bytes = byteArrayOf(value.toByte()) + val controlPacket = createControlCommandPacket(identifier, bytes) + ControlCommandIdentifiers.fromByte(identifier)?.let { + setControlCommandStatusValue(it, bytes) + } return sendDataPacket(controlPacket) } @@ -1153,10 +1152,9 @@ class AACPManager { TAG, "Control command: ${controlCommand.identifier.toHexString()} - ${ controlCommand.value.joinToString(" ") { "%02X".format(it) } }") - setControlCommandStatusValue( - ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return false, - controlCommand.value - ) + ControlCommandIdentifiers.fromByte(controlCommand.identifier)?.let { + setControlCommandStatusValue(it, controlCommand.value) + } } val socket = BluetoothConnectionManager.aacpSocket ?: return false diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BluetoothConnectionManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BluetoothConnectionManager.kt index 95d5d655f..7181c0ea0 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BluetoothConnectionManager.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BluetoothConnectionManager.kt @@ -36,9 +36,10 @@ fun createBluetoothSocket( val constructorSpecs = listOf( arrayOf(adapter, device, type, true, true, psm, uuid), // A16QPR3 arrayOf(device, type, true, true, psm, uuid), - arrayOf(device, type, 1, true, true, psm, uuid), - arrayOf(type, 1, true, true, device, psm, uuid), - arrayOf(type, true, true, device, psm, uuid) + arrayOf(device, type, -1, true, true, psm, uuid), + arrayOf(type, -1, true, true, device, psm, uuid), + arrayOf(type, true, true, device, psm, uuid), + arrayOf(type, device, true, true, psm, uuid) ) val constructors = BluetoothSocket::class.java.declaredConstructors @@ -69,6 +70,60 @@ fun createBluetoothSocket( } } + // Dynamic constructor resolution fallback + for (constructor in constructors) { + try { + val paramTypes = constructor.parameterTypes + val args = arrayOfNulls(paramTypes.size) + var matched = true + + for (i in paramTypes.indices) { + val pt = paramTypes[i] + when { + pt == BluetoothAdapter::class.java -> args[i] = adapter + pt == BluetoothDevice::class.java -> args[i] = device + pt == ParcelUuid::class.java -> args[i] = uuid + pt == java.lang.Boolean.TYPE || pt == java.lang.Boolean::class.java -> args[i] = true + pt == java.lang.Integer.TYPE || pt == java.lang.Integer::class.java -> { + // Will populate in second pass based on total integer count + } + else -> { + matched = false + break + } + } + } + + if (matched) { + val totalInts = paramTypes.count { it == java.lang.Integer.TYPE || it == java.lang.Integer::class.java } + var currentIntIndex = 0 + for (i in paramTypes.indices) { + val pt = paramTypes[i] + if (pt == java.lang.Integer.TYPE || pt == java.lang.Integer::class.java) { + currentIntIndex++ + args[i] = when (totalInts) { + 1 -> psm + 2 -> if (currentIntIndex == 1) type else psm + 3 -> when (currentIntIndex) { + 1 -> type + 2 -> -1 + else -> psm + } + else -> if (currentIntIndex == totalInts) psm else type + } + } + } + + constructor.isAccessible = true + Log.d("createSocket", "Successfully created socket using dynamic fallback constructor (${paramTypes.joinToString { it.simpleName }})") + return constructor.newInstance(*args) as BluetoothSocket + } + } catch (e: Exception) { + Log.w("createSocket", "Dynamic constructor invocation failed: ${e.message}") + lastException = e + } + } + val errorMessage = "Failed to create BluetoothSocket after trying $attemptedConstructors constructor signatures" Log.e("createSocket", errorMessage) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/AirPods.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/AirPods.kt index 9d83f05f5..bf52140f8 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/data/AirPods.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/data/AirPods.kt @@ -217,7 +217,7 @@ class AirPodsPro2USBC: AirPodsBase( ) class AirPodsPro3: AirPodsBase( - modelNumber = listOf("A3063", "A3064", "A3065"), + modelNumber = listOf("A3063", "A3064", "A3065", "A3122"), name = "AirPods Pro 3", displayName = "AirPods Pro", // budCaseRes = R.drawable.airpods_pro_3 diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt index b607817c3..051b55909 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt @@ -495,7 +495,7 @@ fun AirPodsSettingsScreen( automaticEarDetectionEnabled = state.automaticEarDetectionEnabled, onAutomaticEarDetectionChanged = onAutomaticEarDetectionChanged, automaticConnectionEnabled = state.automaticConnectionEnabled, - onAutomaticConnectionChanged = onAutomaticConnectionChanged + onAutomaticConnectionChanged = onAutomaticConnectionChanged, ) } 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 0b43370e0..6f54fad33 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 @@ -182,6 +182,7 @@ val demoState = AirPodsUiState( ControlCommandIdentifiers.PPE_TOGGLE_CONFIG to byteArrayOf(0x01), ControlCommandIdentifiers.PPE_CAP_LEVEL_CONFIG to byteArrayOf(0x52), ControlCommandIdentifiers.DYNAMIC_END_OF_CHARGE to byteArrayOf(0x01), + ControlCommandIdentifiers.IN_CASE_TONE_CONFIG to byteArrayOf(0x01), ControlCommandIdentifiers.LISTENING_MODE to byteArrayOf(0x04) ) ) @@ -448,7 +449,8 @@ class AirPodsViewModel( ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG, ControlCommandIdentifiers.OWNS_CONNECTION, ControlCommandIdentifiers.PPE_TOGGLE_CONFIG, - ControlCommandIdentifiers.DYNAMIC_END_OF_CHARGE + ControlCommandIdentifiers.DYNAMIC_END_OF_CHARGE, + ControlCommandIdentifiers.IN_CASE_TONE_CONFIG ) for (identifier in identifiersList) { observeControl(identifier) @@ -493,7 +495,6 @@ class AirPodsViewModel( ) val vendorIdHook = xposedRemotePref.getBoolean("vendor_id_hook", false) val dynamicEndOfCharge = sharedPreferences.getBoolean("dynamic_end_of_charge", false) - val connectionSuccessful = sharedPreferences.getBoolean("connection_successful", false) _uiState.update { 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 0cf08c11d..b187bb2f5 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 @@ -241,6 +241,18 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } } + private fun getDisplayBatteryLevel(): Int { + val batteries = batteryNotification.getBattery() + val left = batteries.find { it.component == BatteryComponent.LEFT }?.level + val right = batteries.find { it.component == BatteryComponent.RIGHT }?.level + return when { + left != null && right != null -> left.coerceAtMost(right) + left != null -> left + right != null -> right + else -> 0 + } + } + private val bleStatusListener = object : BLEManager.AirPodsStatusListener { @SuppressLint("NewApi") override fun onDeviceStatusChanged( @@ -664,14 +676,21 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList connectionReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (intent?.action == AirPodsNotifications.AIRPODS_CONNECTION_DETECTED) { - device = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - intent.getParcelableExtra("device", BluetoothDevice::class.java)!! + val btDevice = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra("device", BluetoothDevice::class.java) } else { - intent.getParcelableExtra("device") as BluetoothDevice? + @Suppress("DEPRECATION") + intent.getParcelableExtra("device") as? BluetoothDevice + } + + if (btDevice == null) { + Log.w(TAG, "AIRPODS_CONNECTION_DETECTED received without valid device extra") + return } + device = btDevice - if (config.deviceName == "AirPods" && device?.name != null) { - config.deviceName = device?.name ?: "AirPods" + if (config.deviceName == "AirPods" && btDevice.name != null) { + config.deviceName = btDevice.name ?: "AirPods" sharedPreferences.edit { putString("name", config.deviceName) } } @@ -680,12 +699,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList Log.d(TAG, "${config.deviceName} connected") CoroutineScope(Dispatchers.IO).launch { val bluetoothManager = getSystemService(BluetoothManager::class.java) - connectToSocket(bluetoothManager.adapter, device!!) + connectToSocket(bluetoothManager.adapter, btDevice) } Log.d(TAG, "Setting metadata") - setMetadatas(device!!) + setMetadatas(btDevice) // isConnectedLocally = true - macAddress = device!!.address + macAddress = btDevice.address sharedPreferences.edit { putString("mac_address", macAddress) } @@ -702,26 +721,23 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } } } + val showIslandReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - if (intent?.action == "me.kavishdevar.librepods.cross_device_island") { - showIsland( - this@AirPodsService, - batteryNotification.getBattery() - .find { it.component == BatteryComponent.LEFT }?.level!!.coerceAtMost( - batteryNotification.getBattery() - .find { it.component == BatteryComponent.RIGHT }?.level!! - ) - ) - } else if (intent?.action == AirPodsNotifications.DISCONNECT_RECEIVERS) { - try { - context?.unregisterReceiver(this) - } catch (e: Exception) { - e.printStackTrace() - } + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == "me.kavishdevar.librepods.cross_device_island") { + showIsland( + this@AirPodsService, + getDisplayBatteryLevel() + ) + } else if (intent?.action == AirPodsNotifications.DISCONNECT_RECEIVERS) { + try { + context?.unregisterReceiver(this) + } catch (e: Exception) { + e.printStackTrace() } } } + } val showIslandIntentFilter = IntentFilter().apply { addAction("me.kavishdevar.librepods.cross_device_island") @@ -880,7 +896,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList ) } - if (batteryNotification.getBattery()[0].status == BatteryStatus.CHARGING && batteryNotification.getBattery()[1].status == BatteryStatus.CHARGING) { + val batteries = batteryNotification.getBattery() + if (batteries.size >= 2 && batteries[0].status == BatteryStatus.CHARGING && batteries[1].status == BatteryStatus.CHARGING) { disconnectAudio(this@AirPodsService, device) } else { connectAudio(this@AirPodsService, device) @@ -2511,11 +2528,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList connectAudio(this, device) showIsland( this, - batteryNotification.getBattery() - .find { it.component == BatteryComponent.LEFT }?.level!!.coerceAtMost( - batteryNotification.getBattery() - .find { it.component == BatteryComponent.RIGHT }?.level!! - ), + getDisplayBatteryLevel(), IslandType.CONNECTED ) @@ -2622,11 +2635,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } showIsland( this, - batteryNotification.getBattery() - .find { it.component == BatteryComponent.LEFT }?.level!!.coerceAtMost( - batteryNotification.getBattery() - .find { it.component == BatteryComponent.RIGHT }?.level!! - ), + getDisplayBatteryLevel(), IslandType.TAKING_OVER ) @@ -2787,11 +2796,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList 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() - ) aacpManager.receivePacket(data) @@ -2847,11 +2851,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList Log.d(TAG, "Disconnected from AirPods, showing island.") showIsland( this, - batteryNotification.getBattery() - .find { it.component == BatteryComponent.LEFT }?.level!!.coerceAtMost( - batteryNotification.getBattery() - .find { it.component == BatteryComponent.RIGHT }?.level!! - ), + getDisplayBatteryLevel(), IslandType.MOVED_TO_REMOTE ) val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/KotlinModule.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/KotlinModule.kt index b32892686..dd3fad254 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/KotlinModule.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/KotlinModule.kt @@ -151,5 +151,13 @@ class KotlinModule: XposedModule() { object NativeBridge { + init { + try { + System.loadLibrary("l2c_fcr_hook") + } catch (e: Throwable) { + Log.d("NativeBridge", "l2c_fcr_hook library not available or not loaded: ${e.message}") + } + } + external fun setSdpHook(enabled: Boolean) } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/MediaController.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/MediaController.kt index 400e0ff72..6a1f55478 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/MediaController.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/MediaController.kt @@ -74,7 +74,7 @@ object MediaController { this.sharedPreferences = sharedPreferences Log.d("MediaController", "Initializing MediaController") relativeVolume = sharedPreferences.getBoolean("relative_conversational_awareness_volume", false) - conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", (audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) / 0.4).toInt()) + conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", (audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) * 0.4).toInt()) conversationalAwarenessPauseMusic = sharedPreferences.getBoolean("conversational_awareness_pause_music", false) preferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index c2c3f7a38..a7a6b5df6 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -248,7 +248,7 @@ Many devices are not supported due to limitations in the Android Bluetooth stack. \nOn these devices, root access with an Xposed framework is required for full functionality. \n\nThis limitation has been addressed in newer Android versions. The following device configurations can run the app natively: - \n• Google Pixel® running Android 16 March update and later with the lateset Play system update + \n• Google Pixel® running Android 16 March update and later with the latest Play system update \n• Google Pixel® running 17 Beta 3 and above \n• OnePlus devices running OxygenOS 16 or later \n• Oppo devices running ColorOS 16 or later