From 89036d0f4cb87c044c8b1719bad38be6087c6e9e Mon Sep 17 00:00:00 2001 From: Brennan Shea Date: Wed, 2 Sep 2026 20:53:20 -0400 Subject: [PATCH 1/5] fix(android): resolve background NPEs, socket reflection, and add case sounds toggle - Fix KotlinNullPointerException on single-bud usage and uninitialized battery states in AirPodsService - Fix BluetoothSocket reflection constructor with valid fd (-1) and add dynamic fallback resolver - Add rootless Charging Case Sounds toggle for AirPods Pro 2 and AirPods 4 ANC via AACP - Remove per-packet notification updates in raw socket read loop - Fix default conversational awareness volume calculation in MediaController - Add NativeBridge and ProGuard hardening for JNI bindings --- android/app/proguard-rules.pro | 4 + .../librepods/LibrePodsApplication.kt | 21 ++++- .../bluetooth/BluetoothConnectionManager.kt | 61 ++++++++++++- .../me/kavishdevar/librepods/data/AirPods.kt | 15 ++-- .../components/ConnectionSettings.kt | 12 +++ .../screens/AirPodsSettingsScreen.kt | 14 ++- .../viewmodel/AirPodsViewModel.kt | 4 +- .../librepods/services/AirPodsService.kt | 90 +++++++++---------- .../librepods/utils/KotlinModule.kt | 8 ++ .../librepods/utils/MediaController.kt | 2 +- android/app/src/main/res/values/strings.xml | 4 +- 11 files changed, 176 insertions(+), 59 deletions(-) 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/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..f6a92f4ee 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 @@ -44,7 +44,8 @@ enum class Capability { ADAPTIVE_AUDIO, ADAPTIVE_VOLUME, SWIPE_FOR_VOLUME, - HRM + HRM, + CASE_SOUNDS } class AirPods: AirPodsBase( @@ -137,7 +138,8 @@ class AirPods4ANC: AirPodsBase( Capability.ADAPTIVE_AUDIO, Capability.SLEEP_DETECTION, Capability.ADAPTIVE_VOLUME, - Capability.STEM_CONFIG + Capability.STEM_CONFIG, + Capability.CASE_SOUNDS ) ) @@ -184,7 +186,8 @@ class AirPodsPro2Lightning: AirPodsBase( Capability.ADAPTIVE_AUDIO, Capability.ADAPTIVE_VOLUME, Capability.SWIPE_FOR_VOLUME, - Capability.HEAD_GESTURES + Capability.HEAD_GESTURES, + Capability.CASE_SOUNDS ) ) @@ -212,7 +215,8 @@ class AirPodsPro2USBC: AirPodsBase( Capability.ADAPTIVE_AUDIO, Capability.ADAPTIVE_VOLUME, Capability.SWIPE_FOR_VOLUME, - Capability.HEAD_GESTURES + Capability.HEAD_GESTURES, + Capability.CASE_SOUNDS ) ) @@ -242,7 +246,8 @@ class AirPodsPro3: AirPodsBase( Capability.ADAPTIVE_AUDIO, Capability.ADAPTIVE_VOLUME, Capability.SWIPE_FOR_VOLUME, - Capability.HRM + Capability.HRM, + Capability.CASE_SOUNDS ) ) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ConnectionSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ConnectionSettings.kt index 72f369525..ff84242be 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ConnectionSettings.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ConnectionSettings.kt @@ -31,6 +31,9 @@ fun ConnectionSettings( onAutomaticEarDetectionChanged: (Boolean) -> Unit, automaticConnectionEnabled: Boolean, onAutomaticConnectionChanged: (Boolean) -> Unit, + caseSoundsCapability: Boolean = false, + caseSoundsEnabled: Boolean = false, + onCaseSoundsChanged: ((Boolean) -> Unit)? = null, ) { StyledList { StyledToggle( @@ -45,5 +48,14 @@ fun ConnectionSettings( checked = automaticConnectionEnabled, onCheckedChange = onAutomaticConnectionChanged ) + + if (caseSoundsCapability && onCaseSoundsChanged != null) { + StyledToggle( + label = stringResource(R.string.case_sounds), + description = stringResource(R.string.case_sounds_description), + checked = caseSoundsEnabled, + onCheckedChange = onCaseSoundsChanged + ) + } } } 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..94cd88f59 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 @@ -491,11 +491,23 @@ fun AirPodsSettingsScreen( item(key = "spacer_connection") { Spacer(modifier = Modifier.height(16.dp)) } item(key = "connection") { + val model = state.instance?.model ?: AirPodsPro3() + val caseSoundsCapability = model.capabilities.contains(Capability.CASE_SOUNDS) + val caseSoundsChecked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.IN_CASE_TONE_CONFIG]?.getOrNull(0) != 0x02.toByte() + ConnectionSettings( automaticEarDetectionEnabled = state.automaticEarDetectionEnabled, onAutomaticEarDetectionChanged = onAutomaticEarDetectionChanged, automaticConnectionEnabled = state.automaticConnectionEnabled, - onAutomaticConnectionChanged = onAutomaticConnectionChanged + onAutomaticConnectionChanged = onAutomaticConnectionChanged, + caseSoundsCapability = caseSoundsCapability, + caseSoundsEnabled = caseSoundsChecked, + onCaseSoundsChanged = { checked -> + setControlCommandBoolean( + AACPManager.Companion.ControlCommandIdentifiers.IN_CASE_TONE_CONFIG, + checked + ) + } ) } 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..d9e45e327 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) 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..03140335a 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 @@ -291,4 +291,6 @@ Privacy Policy I agree Compatibility check bypassed. Are you sure your device is compatible? + Enable Charging Case Sounds + Play sound effects from the case when opening, charging, or pairing. From 96c4e2b4eacbd4c207fe07277f15c83ca41fdb25 Mon Sep 17 00:00:00 2001 From: Brennan Shea Date: Fri, 4 Sep 2026 09:26:44 -0400 Subject: [PATCH 2/5] fix(airpods): add AirPods Pro 3 case model A3122, fix AACP status caching, and persist case sounds --- .../librepods/bluetooth/AACPManager.kt | 8 +++---- .../me/kavishdevar/librepods/data/AirPods.kt | 2 +- .../screens/AirPodsSettingsScreen.kt | 11 ++++------ .../viewmodel/AirPodsViewModel.kt | 21 +++++++++++++++++++ .../librepods/services/AirPodsService.kt | 7 +++++++ 5 files changed, 36 insertions(+), 13 deletions(-) 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..957d7123f 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,8 @@ class AACPManager { 0x37 ), PPE_CAP_LEVEL_CONFIG(0x38), - DYNAMIC_END_OF_CHARGE(0x3B); + DYNAMIC_END_OF_CHARGE(0x3B), + IN_CASE_TONE_VOLUME(0x40); companion object { fun fromByte(byte: Byte): ControlCommandIdentifiers? = @@ -214,10 +215,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)) } 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 f6a92f4ee..8b21d9e16 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 @@ -221,7 +221,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 94cd88f59..baa1aa01b 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 @@ -172,6 +172,7 @@ fun AirPodsSettingsRoute( onAutomaticEarDetectionChanged = viewModel::setAutomaticEarDetectionEnabled, onAutomaticConnectionChanged = viewModel::setAutomaticConnectionEnabled, + onCaseSoundsChanged = viewModel::setCaseSoundsEnabled, setDynamicEndOfCharge = viewModel::setDynamicEndOfCharge, setOffListeningMode = viewModel::setOffListeningMode, disconnect = viewModel::disconnect, @@ -214,6 +215,7 @@ fun AirPodsSettingsScreen( onAutomaticEarDetectionChanged: (Boolean) -> Unit, onAutomaticConnectionChanged: (Boolean) -> Unit, + onCaseSoundsChanged: (Boolean) -> Unit = {}, setDynamicEndOfCharge: (Boolean) -> Unit, setOffListeningMode: (Boolean) -> Unit, disconnect: () -> Unit, @@ -493,7 +495,7 @@ fun AirPodsSettingsScreen( item(key = "connection") { val model = state.instance?.model ?: AirPodsPro3() val caseSoundsCapability = model.capabilities.contains(Capability.CASE_SOUNDS) - val caseSoundsChecked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.IN_CASE_TONE_CONFIG]?.getOrNull(0) != 0x02.toByte() + val caseSoundsChecked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.IN_CASE_TONE_CONFIG]?.getOrNull(0)?.let { it != 0x02.toByte() } ?: state.caseSoundsEnabled ConnectionSettings( automaticEarDetectionEnabled = state.automaticEarDetectionEnabled, @@ -502,12 +504,7 @@ fun AirPodsSettingsScreen( onAutomaticConnectionChanged = onAutomaticConnectionChanged, caseSoundsCapability = caseSoundsCapability, caseSoundsEnabled = caseSoundsChecked, - onCaseSoundsChanged = { checked -> - setControlCommandBoolean( - AACPManager.Companion.ControlCommandIdentifiers.IN_CASE_TONE_CONFIG, - checked - ) - } + onCaseSoundsChanged = onCaseSoundsChanged ) } 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 d9e45e327..f6710ee9b 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 @@ -97,6 +97,7 @@ data class AirPodsUiState( val vendorIdHook: Boolean = false, val dynamicEndOfCharge: Boolean = false, + val caseSoundsEnabled: Boolean = true, val connectionSuccessful: Boolean = false, val timeUntilFOSSPremiumExpiry: Long = 0L, @@ -131,6 +132,7 @@ val demoState = AirPodsUiState( ancMode = 3, offListeningMode = false, + caseSoundsEnabled = true, modelName = demoInstance.model.displayName, actualModel = demoInstance.actualModelNumber, @@ -410,6 +412,13 @@ class AirPodsViewModel( dynamicEndOfCharge = value[0] == 0x01.toByte(), controlStates = state.controlStates + (identifier to value) ) + } else if (identifier == ControlCommandIdentifiers.IN_CASE_TONE_CONFIG) { + val isEnabled = value.getOrNull(0) != 0x02.toByte() + sharedPreferences.edit { putBoolean("case_sounds", isEnabled) } + state.copy( + caseSoundsEnabled = isEnabled, + controlStates = state.controlStates + (identifier to value) + ) } else { state.copy( controlStates = state.controlStates + (identifier to value) @@ -495,6 +504,7 @@ class AirPodsViewModel( ) val vendorIdHook = xposedRemotePref.getBoolean("vendor_id_hook", false) val dynamicEndOfCharge = sharedPreferences.getBoolean("dynamic_end_of_charge", false) + val caseSoundsEnabled = sharedPreferences.getBoolean("case_sounds", true) val connectionSuccessful = sharedPreferences.getBoolean("connection_successful", false) @@ -508,6 +518,7 @@ class AirPodsViewModel( rightAction = rightAction, vendorIdHook = vendorIdHook, dynamicEndOfCharge = dynamicEndOfCharge, + caseSoundsEnabled = caseSoundsEnabled, connectionSuccessful = connectionSuccessful, ) } @@ -729,6 +740,16 @@ class AirPodsViewModel( } } + fun setCaseSoundsEnabled(enabled: Boolean) { + sharedPreferences.edit { putBoolean("case_sounds", enabled) } + setControlCommandBoolean(ControlCommandIdentifiers.IN_CASE_TONE_CONFIG, enabled) + _uiState.update { + it.copy( + caseSoundsEnabled = enabled + ) + } + } + fun activateDemoMode() { isDemoMode = true _uiState.update {demoState} 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 b187bb2f5..070972fbf 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 @@ -2781,6 +2781,13 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList }) setupStemActions() + if (sharedPreferences.contains("case_sounds")) { + val caseSoundsEnabled = sharedPreferences.getBoolean("case_sounds", true) + aacpManager.sendControlCommand( + AACPManager.Companion.ControlCommandIdentifiers.IN_CASE_TONE_CONFIG.value, + if (caseSoundsEnabled) 0x01.toByte() else 0x02.toByte() + ) + } while (socket.isConnected) { try { From c332e5cc04b45cd58737b68181777c7e6894ffdf Mon Sep 17 00:00:00 2001 From: Brennan Shea Date: Fri, 4 Sep 2026 12:42:48 -0400 Subject: [PATCH 3/5] Fix case sounds sync with extended init handshake and tone volume muting --- .../me/kavishdevar/librepods/bluetooth/AACPManager.kt | 10 ++++++++++ .../presentation/viewmodel/AirPodsViewModel.kt | 4 ++++ .../kavishdevar/librepods/services/AirPodsService.kt | 8 +++++++- 3 files changed, 21 insertions(+), 1 deletion(-) 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 957d7123f..aad070049 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 @@ -635,6 +635,16 @@ class AACPManager { return opcode + data } + fun sendInitExtPacket(): Boolean { + return sendDataPacket(createInitExtPacket()) + } + + fun createInitExtPacket(): ByteArray { + val opcode = byteArrayOf(Opcodes.SET_FEATURE_FLAGS, 0x00) + val data = byteArrayOf(0x0E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) + return opcode + data + } + fun createHandshakePacket(): ByteArray { return byteArrayOf( 0x00, 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 f6710ee9b..19e2478b7 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 @@ -743,6 +743,10 @@ class AirPodsViewModel( fun setCaseSoundsEnabled(enabled: Boolean) { sharedPreferences.edit { putBoolean("case_sounds", enabled) } setControlCommandBoolean(ControlCommandIdentifiers.IN_CASE_TONE_CONFIG, enabled) + setControlCommandByte( + ControlCommandIdentifiers.IN_CASE_TONE_VOLUME, + if (enabled) 0x50.toByte() else 0x00.toByte() + ) _uiState.update { it.copy( caseSoundsEnabled = enabled 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 070972fbf..bd9e99618 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 @@ -2751,14 +2751,15 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList BluetoothConnectionManager.aacpSocket?.let { aacpManager.sendPacket(aacpManager.createHandshakePacket()) aacpManager.sendSetFeatureFlagsPacket() + aacpManager.sendInitExtPacket() 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() + aacpManager.sendInitExtPacket() delay(200) aacpManager.sendNotificationRequest() delay(200) @@ -2769,6 +2770,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList Handler(Looper.getMainLooper()).postDelayed({ aacpManager.sendPacket(aacpManager.createHandshakePacket()) aacpManager.sendSetFeatureFlagsPacket() + aacpManager.sendInitExtPacket() aacpManager.sendNotificationRequest() aacpManager.sendRequestProximityKeys(AACPManager.Companion.ProximityKeyType.IRK.value) if (!handleIncomingCallOnceConnected) stopHeadTracking() @@ -2787,6 +2789,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList AACPManager.Companion.ControlCommandIdentifiers.IN_CASE_TONE_CONFIG.value, if (caseSoundsEnabled) 0x01.toByte() else 0x02.toByte() ) + aacpManager.sendControlCommand( + AACPManager.Companion.ControlCommandIdentifiers.IN_CASE_TONE_VOLUME.value, + if (caseSoundsEnabled) 0x50.toByte() else 0x00.toByte() + ) } while (socket.isConnected) { From b73fc39c6fa74a897fe65fab2a215b199965bb10 Mon Sep 17 00:00:00 2001 From: Brennan Shea Date: Fri, 4 Sep 2026 14:30:57 -0400 Subject: [PATCH 4/5] Sync all tone channels (0x31, 0x40, 0x1F) and enhance control command dispatch robustness --- .../librepods/bluetooth/AACPManager.kt | 46 +++++++++---------- .../viewmodel/AirPodsViewModel.kt | 4 ++ .../librepods/services/AirPodsService.kt | 4 ++ 3 files changed, 31 insertions(+), 23 deletions(-) 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 aad070049..7d67c7d78 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 @@ -113,7 +113,10 @@ class AACPManager { ), PPE_CAP_LEVEL_CONFIG(0x38), DYNAMIC_END_OF_CHARGE(0x3B), - IN_CASE_TONE_VOLUME(0x40); + 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? = @@ -306,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) } @@ -1161,10 +1162,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/presentation/viewmodel/AirPodsViewModel.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt index 19e2478b7..1a3a78a05 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 @@ -747,6 +747,10 @@ class AirPodsViewModel( ControlCommandIdentifiers.IN_CASE_TONE_VOLUME, if (enabled) 0x50.toByte() else 0x00.toByte() ) + setControlCommandValue( + ControlCommandIdentifiers.CHIME_VOLUME, + if (enabled) byteArrayOf(0x64, 0x50) else byteArrayOf(0x00, 0x00) + ) _uiState.update { it.copy( caseSoundsEnabled = enabled 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 bd9e99618..08bcdd0df 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 @@ -2793,6 +2793,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList AACPManager.Companion.ControlCommandIdentifiers.IN_CASE_TONE_VOLUME.value, if (caseSoundsEnabled) 0x50.toByte() else 0x00.toByte() ) + aacpManager.sendControlCommand( + AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME.value, + if (caseSoundsEnabled) byteArrayOf(0x64, 0x50) else byteArrayOf(0x00, 0x00) + ) } while (socket.isConnected) { From db14f2d987d38a74df269c24ddc2d1c65e256066 Mon Sep 17 00:00:00 2001 From: Brennan Shea Date: Fri, 4 Sep 2026 17:14:44 -0400 Subject: [PATCH 5/5] Revert experimental case sounds toggle and keep clean stability fixes --- .../librepods/bluetooth/AACPManager.kt | 10 ------- .../me/kavishdevar/librepods/data/AirPods.kt | 15 ++++------ .../components/ConnectionSettings.kt | 12 -------- .../screens/AirPodsSettingsScreen.kt | 9 ------ .../viewmodel/AirPodsViewModel.kt | 30 ------------------- .../librepods/services/AirPodsService.kt | 19 +----------- android/app/src/main/res/values/strings.xml | 2 -- 7 files changed, 6 insertions(+), 91 deletions(-) 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 7d67c7d78..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 @@ -636,16 +636,6 @@ class AACPManager { return opcode + data } - fun sendInitExtPacket(): Boolean { - return sendDataPacket(createInitExtPacket()) - } - - fun createInitExtPacket(): ByteArray { - val opcode = byteArrayOf(Opcodes.SET_FEATURE_FLAGS, 0x00) - val data = byteArrayOf(0x0E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00) - return opcode + data - } - fun createHandshakePacket(): ByteArray { return byteArrayOf( 0x00, 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 8b21d9e16..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 @@ -44,8 +44,7 @@ enum class Capability { ADAPTIVE_AUDIO, ADAPTIVE_VOLUME, SWIPE_FOR_VOLUME, - HRM, - CASE_SOUNDS + HRM } class AirPods: AirPodsBase( @@ -138,8 +137,7 @@ class AirPods4ANC: AirPodsBase( Capability.ADAPTIVE_AUDIO, Capability.SLEEP_DETECTION, Capability.ADAPTIVE_VOLUME, - Capability.STEM_CONFIG, - Capability.CASE_SOUNDS + Capability.STEM_CONFIG ) ) @@ -186,8 +184,7 @@ class AirPodsPro2Lightning: AirPodsBase( Capability.ADAPTIVE_AUDIO, Capability.ADAPTIVE_VOLUME, Capability.SWIPE_FOR_VOLUME, - Capability.HEAD_GESTURES, - Capability.CASE_SOUNDS + Capability.HEAD_GESTURES ) ) @@ -215,8 +212,7 @@ class AirPodsPro2USBC: AirPodsBase( Capability.ADAPTIVE_AUDIO, Capability.ADAPTIVE_VOLUME, Capability.SWIPE_FOR_VOLUME, - Capability.HEAD_GESTURES, - Capability.CASE_SOUNDS + Capability.HEAD_GESTURES ) ) @@ -246,8 +242,7 @@ class AirPodsPro3: AirPodsBase( Capability.ADAPTIVE_AUDIO, Capability.ADAPTIVE_VOLUME, Capability.SWIPE_FOR_VOLUME, - Capability.HRM, - Capability.CASE_SOUNDS + Capability.HRM ) ) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ConnectionSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ConnectionSettings.kt index ff84242be..72f369525 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ConnectionSettings.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ConnectionSettings.kt @@ -31,9 +31,6 @@ fun ConnectionSettings( onAutomaticEarDetectionChanged: (Boolean) -> Unit, automaticConnectionEnabled: Boolean, onAutomaticConnectionChanged: (Boolean) -> Unit, - caseSoundsCapability: Boolean = false, - caseSoundsEnabled: Boolean = false, - onCaseSoundsChanged: ((Boolean) -> Unit)? = null, ) { StyledList { StyledToggle( @@ -48,14 +45,5 @@ fun ConnectionSettings( checked = automaticConnectionEnabled, onCheckedChange = onAutomaticConnectionChanged ) - - if (caseSoundsCapability && onCaseSoundsChanged != null) { - StyledToggle( - label = stringResource(R.string.case_sounds), - description = stringResource(R.string.case_sounds_description), - checked = caseSoundsEnabled, - onCheckedChange = onCaseSoundsChanged - ) - } } } 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 baa1aa01b..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 @@ -172,7 +172,6 @@ fun AirPodsSettingsRoute( onAutomaticEarDetectionChanged = viewModel::setAutomaticEarDetectionEnabled, onAutomaticConnectionChanged = viewModel::setAutomaticConnectionEnabled, - onCaseSoundsChanged = viewModel::setCaseSoundsEnabled, setDynamicEndOfCharge = viewModel::setDynamicEndOfCharge, setOffListeningMode = viewModel::setOffListeningMode, disconnect = viewModel::disconnect, @@ -215,7 +214,6 @@ fun AirPodsSettingsScreen( onAutomaticEarDetectionChanged: (Boolean) -> Unit, onAutomaticConnectionChanged: (Boolean) -> Unit, - onCaseSoundsChanged: (Boolean) -> Unit = {}, setDynamicEndOfCharge: (Boolean) -> Unit, setOffListeningMode: (Boolean) -> Unit, disconnect: () -> Unit, @@ -493,18 +491,11 @@ fun AirPodsSettingsScreen( item(key = "spacer_connection") { Spacer(modifier = Modifier.height(16.dp)) } item(key = "connection") { - val model = state.instance?.model ?: AirPodsPro3() - val caseSoundsCapability = model.capabilities.contains(Capability.CASE_SOUNDS) - val caseSoundsChecked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.IN_CASE_TONE_CONFIG]?.getOrNull(0)?.let { it != 0x02.toByte() } ?: state.caseSoundsEnabled - ConnectionSettings( automaticEarDetectionEnabled = state.automaticEarDetectionEnabled, onAutomaticEarDetectionChanged = onAutomaticEarDetectionChanged, automaticConnectionEnabled = state.automaticConnectionEnabled, onAutomaticConnectionChanged = onAutomaticConnectionChanged, - caseSoundsCapability = caseSoundsCapability, - caseSoundsEnabled = caseSoundsChecked, - onCaseSoundsChanged = onCaseSoundsChanged ) } 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 1a3a78a05..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 @@ -97,7 +97,6 @@ data class AirPodsUiState( val vendorIdHook: Boolean = false, val dynamicEndOfCharge: Boolean = false, - val caseSoundsEnabled: Boolean = true, val connectionSuccessful: Boolean = false, val timeUntilFOSSPremiumExpiry: Long = 0L, @@ -132,7 +131,6 @@ val demoState = AirPodsUiState( ancMode = 3, offListeningMode = false, - caseSoundsEnabled = true, modelName = demoInstance.model.displayName, actualModel = demoInstance.actualModelNumber, @@ -412,13 +410,6 @@ class AirPodsViewModel( dynamicEndOfCharge = value[0] == 0x01.toByte(), controlStates = state.controlStates + (identifier to value) ) - } else if (identifier == ControlCommandIdentifiers.IN_CASE_TONE_CONFIG) { - val isEnabled = value.getOrNull(0) != 0x02.toByte() - sharedPreferences.edit { putBoolean("case_sounds", isEnabled) } - state.copy( - caseSoundsEnabled = isEnabled, - controlStates = state.controlStates + (identifier to value) - ) } else { state.copy( controlStates = state.controlStates + (identifier to value) @@ -504,8 +495,6 @@ class AirPodsViewModel( ) val vendorIdHook = xposedRemotePref.getBoolean("vendor_id_hook", false) val dynamicEndOfCharge = sharedPreferences.getBoolean("dynamic_end_of_charge", false) - val caseSoundsEnabled = sharedPreferences.getBoolean("case_sounds", true) - val connectionSuccessful = sharedPreferences.getBoolean("connection_successful", false) _uiState.update { @@ -518,7 +507,6 @@ class AirPodsViewModel( rightAction = rightAction, vendorIdHook = vendorIdHook, dynamicEndOfCharge = dynamicEndOfCharge, - caseSoundsEnabled = caseSoundsEnabled, connectionSuccessful = connectionSuccessful, ) } @@ -740,24 +728,6 @@ class AirPodsViewModel( } } - fun setCaseSoundsEnabled(enabled: Boolean) { - sharedPreferences.edit { putBoolean("case_sounds", enabled) } - setControlCommandBoolean(ControlCommandIdentifiers.IN_CASE_TONE_CONFIG, enabled) - setControlCommandByte( - ControlCommandIdentifiers.IN_CASE_TONE_VOLUME, - if (enabled) 0x50.toByte() else 0x00.toByte() - ) - setControlCommandValue( - ControlCommandIdentifiers.CHIME_VOLUME, - if (enabled) byteArrayOf(0x64, 0x50) else byteArrayOf(0x00, 0x00) - ) - _uiState.update { - it.copy( - caseSoundsEnabled = enabled - ) - } - } - fun activateDemoMode() { isDemoMode = true _uiState.update {demoState} 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 08bcdd0df..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 @@ -2751,15 +2751,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList BluetoothConnectionManager.aacpSocket?.let { aacpManager.sendPacket(aacpManager.createHandshakePacket()) aacpManager.sendSetFeatureFlagsPacket() - aacpManager.sendInitExtPacket() 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() - aacpManager.sendInitExtPacket() delay(200) aacpManager.sendNotificationRequest() delay(200) @@ -2770,7 +2769,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList Handler(Looper.getMainLooper()).postDelayed({ aacpManager.sendPacket(aacpManager.createHandshakePacket()) aacpManager.sendSetFeatureFlagsPacket() - aacpManager.sendInitExtPacket() aacpManager.sendNotificationRequest() aacpManager.sendRequestProximityKeys(AACPManager.Companion.ProximityKeyType.IRK.value) if (!handleIncomingCallOnceConnected) stopHeadTracking() @@ -2783,21 +2781,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList }) setupStemActions() - if (sharedPreferences.contains("case_sounds")) { - val caseSoundsEnabled = sharedPreferences.getBoolean("case_sounds", true) - aacpManager.sendControlCommand( - AACPManager.Companion.ControlCommandIdentifiers.IN_CASE_TONE_CONFIG.value, - if (caseSoundsEnabled) 0x01.toByte() else 0x02.toByte() - ) - aacpManager.sendControlCommand( - AACPManager.Companion.ControlCommandIdentifiers.IN_CASE_TONE_VOLUME.value, - if (caseSoundsEnabled) 0x50.toByte() else 0x00.toByte() - ) - aacpManager.sendControlCommand( - AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME.value, - if (caseSoundsEnabled) byteArrayOf(0x64, 0x50) else byteArrayOf(0x00, 0x00) - ) - } while (socket.isConnected) { try { diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 03140335a..a7a6b5df6 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -291,6 +291,4 @@ Privacy Policy I agree Compatibility check bypassed. Are you sure your device is compatible? - Enable Charging Case Sounds - Play sound effects from the case when opening, charging, or pairing.