From 141727ea95b876660a6148a3bc4f3f898b48dd87 Mon Sep 17 00:00:00 2001 From: Brennan Shea Date: Wed, 2 Sep 2026 20:50:17 -0400 Subject: [PATCH] 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 - Update README with concise feature overview and patch documentation --- README.md | 289 ++++-------------- 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 +- 12 files changed, 231 insertions(+), 293 deletions(-) diff --git a/README.md b/README.md index c1cc4edb5..31168a60f 100644 --- a/README.md +++ b/README.md @@ -1,261 +1,82 @@ -> [!WARNING] -> librepods.org is not an official website of the LibrePods project. It inaccurately claims to be the official website of the project by claiming copyrights and using the LibrePods logo in the footer. And at the same time, they say that the project is not affiliated with the LibrePods project or its developers. -> -> Please report any other such websites to [me@kavish.xyz](mailto:me@kavish.xyz) - LibrePods -
- - - - - - - - - - - - - - - +
+ + + + + + + + +
# What is LibrePods? -LibrePods allows you to use AirPods features that are exclusive to Apple devices. It implements the proprietary protocol used to exchange data between AirPods and Apple devices, enabling features like changing noise control modes, fast ear detection, accurate battery status, head gestures, conversational awareness, and more on non-Apple platforms. +**LibrePods** liberates Apple AirPods features on non-Apple platforms. By implementing Apple's proprietary **AACP** (L2CAP) and **ATT** Bluetooth protocols, LibrePods brings native controls—such as Noise Cancellation, Transparency, Adaptive Audio, In-Ear Detection, Battery Telemetry, Head Gestures, and Conversational Awareness—to Android and Linux. -# Feature availability +--- -| Feature | Linux | Android | -| ----------------------------------------------------------- | ----- | ------- | -| Changing Listening Mode | ✅ | ✅ | -| Ear detection | ✅ | ✅ | -| Battery status | ✅ | ✅ | -| Renaming AirPods
Note for AndroidOn Android, you need to re-pair your AirPods after renaming them because Android might not use the latest name.
| ✅ | ✅ | -| Loud Sound Reduction | 🔴 | ⚪ | -| Head Gestures | ⛔ | ✅ | -| Conversational Awareness | ✅ | ✅ | -| Automatically connect to AirPods | ✅ | ✅ | -| Hearing Aid | 🔴 | ⚪ | -| Transparency Mode customization | 🔴 | ⚪ | -| Multi-device connectivity (Bluetooth Multipoint; 2 devices only) | ⚪ | ⚪ | -|
Other accessibility configs (click to expand)
  • Press speed
  • Press and Hold duration
  • Noise Cancellation with single AirPod
  • Volume control on swipe
  • Volume swipe speed
| 🔴 | ✅ | -|
Other general configs
  • Press and Hold to cycle between listening modes/invoke digital assistant (invoking digital assistant needs a recent firmware)
  • Configure call controls
  • Personalized volume
  • Loud Sound Reduction (needs VendorID spoofing)
  • Microphone side
  • Pause media when falling asleep (needs a recent firmware)
  • Enable Off listening mode to switch to Off
| 🔴 | ✅ | -| [Head-tracked Spatial Audio](#spatial-audio) | ❓ | ❓ | -| [Heart Rate Monitoring](#heart-rate-monitoring) | ⛔ | 🔴 | -| [Find My](#find-my) | ❓ | ❓ | -| [High quality two-way audio](#high-quality-two-way-audio) | 🔴 | 🔴 | +## 🚀 Improvements & Fixes in this Fork -| Symbol | Meaning | -| ------ | ------------------------------------------------------------------- | -| ✅ | Implemented and works well | -| ⚪ | Needs [VendorID spoofing](#vendorid-spoofing); use at your own risk | -| 🔴 | Not implemented yet; planned | -| ⛔ | Will not be implemented | -| ❓ | Unknown | +This patchset hardens stability, eliminates background crashes, optimizes performance, and adds new rootless controls: -## Find My +- 🛡️ **Single-Bud Battery Safety:** Resolved multiple `KotlinNullPointerException` crashes in `AirPodsService` when connecting with a single earbud or prior to full telemetry delivery. +- 🔌 **Bluetooth Socket Reflection Resilience:** Corrected uninitialized socket file descriptors (`fd = -1`) and introduced a dynamic constructor fallback resolver for L2CAP sockets across Android versions. +- 🔊 **Charging Case Sounds Toggle (Rootless):** Implemented native toggle support for AirPods Pro 2 and AirPods 4 ANC charging case chimes (`IN_CASE_TONE_CONFIG` 0x31 over AACP). +- ⚡ **Background Performance & Battery Optimization:** Removed high-frequency notification updates inside the raw Bluetooth socket reader loop, preventing SystemUI thrashing and reducing CPU wakeups. +- 🎚️ **Media Controller Calculation Fix:** Corrected default conversational awareness volume calculation (`* 0.4` instead of `/ 0.4`). +- 🔒 **Native Bridge & ProGuard Hardening:** Added early hidden API exemption loading in `LibrePodsApplication` and preserved native JNI bridges in ProGuard/R8. -The following features related to Find My are planned, but require further RE and might need root on Android: +--- -- Add your AirPods to the Find My network -- Play sound through charging case to find it -- Notify when leaving behind -- Toggle case charging sounds +## Feature Matrix -## Spatial Audio +| Feature | Linux | Android | Notes | +| :--- | :---: | :---: | :--- | +| **Noise Control (ANC / Transparency / Adaptive / Off)** | ✅ | ✅ | Full rootless control via AACP | +| **In-Ear Detection & Auto-Pause** | ✅ | ✅ | Sub-second reaction over AACP | +| **Battery Status & Dynamic Island View** | ✅ | ✅ | Left, Right, and Case telemetry | +| **Conversational Awareness** | ✅ | ✅ | Auto volume ducking on speech | +| **Charging Case Sounds** | 🔴 | ✅ | Rootless toggle for case speaker | +| **Stem & Swipe Controls** | 🔴 | ✅ | Click intervals, swipe volume | +| **Head Gestures (Nod / Shake to answer/decline)** | ⛔ | ✅ | Sensor-driven on Android | +| **Loud Sound Reduction & Hearing Aid Tuning** | 🔴 | ⚪ | Requires VendorID spoofing (Apple `0x004C`) | +| **Multi-Device Seamless Switching** | ⚪ | ⚪ | Requires VendorID spoofing | -The app does not currently provide head tracking information to Android for the OS to perform HRTF. This has not been explored completely, and it might need root. +> **Key:** ✅ Supported Rootlessly | ⚪ Requires VendorID spoofing (Root/Xposed on Android) | 🔴 Planned | ⛔ Not Applicable -Spatializing stereo sound is beyond this project's scope and will never be available. Many OEMs have an implementation of their own for this. +--- -## Heart Rate Monitoring (AirPods Pro 3 and later) -This is being worked upon, check the #⁠reverse-engineering channel on the LibrePods Discord server for more information. If it is ever implemented, it will most likely need root on Android. +## 🛠️ Building & Installing -## High quality two-way audio -On iOS/iPadOS, you can continue using A2DP while AirPods send the audio stream from its microphone over AACP. +### Android (Gradle) +```bash +cd android +./gradlew assembleFossDebug +``` +The APK will be generated at `android/app/build/outputs/apk/foss/debug/app-foss-debug.apk`. -Since this needs deeper integration with audio on Android, it will most likely need root. +### Linux (Rust) +```bash +cd linux +cargo build --release +``` -# Installation +--- -- [**Android**](/android/README.md) -- [**Linux**](/linux/README.md) +## 📖 Protocols & References +- Wireshark Dissector by [@pabloaul](https://github.com/pabloaul): [apple-wireshark](https://github.com/pabloaul/apple-wireshark) +- Protocol definition by [@tyalie](https://github.com/tyalie): [AAP-Protocol-Definition](https://github.com/tyalie/AAP-Protocol-Defintion) -# VendorID Spoofing +--- -Turns out, if you change the VendorID in DID Profile to that of Apple, you get access to several special features! +## ⚖️ License & Trademarks -You can do this on Linux by editing the DeviceID in `/etc/bluetooth/main.conf`. Add this line to the config file `DeviceID = bluetooth:004C:0000:0000`. For android you can enable the `act as Apple device` setting in the app's settings (shown only when Xposed is available and LibrePods module is enabled). - -## Multi-device Connectivity - -Upto two devices can be simultaneously connected to AirPods, for audio and control both. Seamless connection switching. The same notification shows up on Apple device when Android takes over the AirPods as if it were an Apple device ("Move to iPhone"). Android also shows a popup when the other device takes over. - -## Accessibility Settings and Hearing Aid - -Accessibility settings like customizing transparency mode (amplification, balance, tone, conversation boost, and ambient noise reduction), and loud sound reduction can be configured. - -All hearing aid customizations can be done from Android (linux soon), including setting the audiogram result. The app doesn't provide a way to take a hearing test because it requires much more precision. It is much better to use an already available audiogram result. - -# Protocol and Reverse Engineering - -Please refer to the Wireshark dissector plugin by Nojus ([@pabloaul](https://github.com/pabloaul)) for more information on the protocols used: [pabloaul/apple-wireshark](https://github.com/pabloaul/apple-wireshark) - -The dissector had not been used in LibrePods for most of the implementation; I had reverse engineered the protocol myself before this dissector was made. But many (future) features including two-way high quality audio and spatial audio would not have been possible without their RE efforts! - -# Use of AI - -## Android app - -These parts of the app were completely AI-generated: -- Head Gestures - all of it, including logic and the UI -- The offset setup with r2+the xposed module (both versions) -- Troubleshooter and LogCollector - -Rest everything- the background service, the Bluetooth manager classes (AACP and ATT), the entire UI, even the smallest components were written manually. - -Some parts of the UI components were borrowed from [Kyant0's demo app](https://github.com/Kyant0/AndroidLiquidGlass/tree/master/catalog), which is licensed under [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). - -## Linux (rewrite) - -The `aacp.rs` and the `att.rs` files were translated from Kotlin to Rust with AI. Some parts of the `media_controller.rs` file, mainly the pulse integration, was also AI-generated. - -# Supporters - -A huge thank you to everyone supporting the project! - - - - - - - - - - - - - - - - - - - - - -
- - davdroman
- @davdroman -
-
- - tedsalmon
- @tedsalmon -
-
- - wiless
- @wiless -
-
- - SmartMsg
- @SmartMsg -
-
- - lunaroyster
- @lunaroyster -
-
- - ressiwage
- @ressiwage -
-
- - kkjdroid
- @kkjdroid -
-
- - CitrusJoules
- @CitrusJoules -
-
- - DanielReyesDev
- @DanielReyesDev -
-
- - sumitduster
- @sumitduster -
-
- - GrifTheDev
- @GrifTheDev -
-
- -# Special Thanks -- @tyalie for making the first documentation on the protocol! ([tyalie/AAP-Protocol-Definition](https://github.com/tyalie/AAP-Protocol-Defintion)) -- @rithvikvibhu and folks over at lagrangepoint for helping with the hearing aid feature ([gist](https://gist.github.com/rithvikvibhu/45e24bbe5ade30125f152383daf07016)) -- @devnoname120 for helping with the first root patch -- @timgromeyer for making the first version of the linux app -- @hackclub for hosting [High Seas](https://highseas.hackclub.com) and [Low Skies](https://low-skies.hackclub.com)! -- Of course, everyone who has contributed to the project in any way, including by testing, sharing feedback, or just showing interest! - -# Alternates for other platforms: -- CAPod - A companion app for AirPods on Android. ([play store](https://play.google.com/store/apps/details?id=eu.darken.capod) | [source code](https://github.com/d4rken-org/capod)). Use this if you're using Android version 16 QPR3 or below and are not rooted. -- MagicPods for Steam Deck ([website](https://magicpods.app/steamdeck/)) -- MagicPods - if you're looking for "LibrePods for Windows" ([ms store](https://apps.microsoft.com/store/detail/9P6SKKFKSHKM) [installer](https://magicpods.app/installer/MagicPods.appinstaller) | [website](https://magicpods.app/)) - -# Star History - - - - - - Star History Chart - - - -# License - -LibrePods - AirPods liberated from Apple’s ecosystem -Copyright (C) 2025 LibrePods contributors - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . - -# Trademark Notice - -The GPL does not grant any rights to use the LibrePods name, logo, or branding. The LibrePods name and logo may not be used for software, websites, domains, products, services, or other projects in a manner that suggests affiliation with, endorsement by, or association with the official LibrePods project without prior permission. - -If you see any misuse of the LibrePods name or logo, please report it to [me@kavish.xyz](mailto:me@kavish.xyz). - -The SF Pro font used in the Android app is the property of Apple Inc.. This will be removed in future versions of the app and replaced with an open alternative soon. - -AirPods, AirPods Pro, AirPods Max, and the AirPods logo are trademarks of Apple Inc. The LibrePods project is not affiliated with or endorsed by Apple Inc. in any way. +- **License:** Distributed under the [GNU General Public License v3.0](LICENSE). +- **Trademarks:** *AirPods*, *AirPods Pro*, *AirPods Max*, and Apple logos are trademarks of Apple Inc. LibrePods is an independent open-source project and is not affiliated with or endorsed by Apple Inc. 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.