Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
fa02d4f
chore: add .worktrees/ to .gitignore
teng-lin Feb 7, 2026
46d19c5
fix(android): support non-rooted OnePlus 12 / OxygenOS 16
teng-lin Feb 7, 2026
aff8014
fix(android): show loading spinner when service is binding
teng-lin Feb 9, 2026
58b15fb
fix(android): prevent concurrent L2CAP connectToSocket calls
teng-lin Feb 9, 2026
b162ee5
fix(android): use single notification ID to avoid duplicate notificat…
teng-lin Feb 9, 2026
8c287b5
fix(android): thread-safety, receiver leak, and reconnect issues
teng-lin Feb 9, 2026
7e52ae2
fix(android): make notification and phone permissions optional
teng-lin Feb 9, 2026
dde12db
fix(android): reclaim AACP control when audio source returns to phone
teng-lin Feb 9, 2026
725cfb7
fix(android): reconnect L2CAP when A2DP resumes playing
teng-lin Feb 9, 2026
aadcaf1
fix(android): detect stale socket in connectToSocket guard
teng-lin Feb 9, 2026
b1a1fea
fix(android): use AACP connected devices for socket liveness check
teng-lin Feb 10, 2026
5e6bcb6
fix(android): remove duplicate unbindService call in onDestroy
teng-lin Feb 10, 2026
6211c4c
docs: add MAC address injection guide for OxygenOS 16 non-rooted setup
teng-lin Feb 10, 2026
bc5a12b
fix(android): prevent L2CAP socket teardown during AACP handshake
teng-lin Feb 11, 2026
aa3db92
docs: clarify run-as requires debug build for MAC injection
teng-lin Feb 11, 2026
b9736d7
Merge remote-tracking branch 'upstream/main' into fix/android-oneplus…
teng-lin May 4, 2026
856d3b6
ci(android): allow fork builds without release signing secrets
teng-lin May 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 43 additions & 10 deletions .github/workflows/ci-android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,22 +33,55 @@ jobs:
distribution: zulu
java-version: 21
- uses: gradle/actions/setup-gradle@v4
- name: Decode keystore
run: echo "${{ secrets.RELEASE_KEYSTORE_FILE }}" | base64 --decode > android/release.keystore
- name: Prepare signing config
env:
RELEASE_KEYSTORE_FILE: ${{ secrets.RELEASE_KEYSTORE_FILE }}
RELEASE_STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }}
RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
run: |
set -euo pipefail

store_password="${RELEASE_STORE_PASSWORD:-}"
key_alias="${RELEASE_KEY_ALIAS:-}"
key_password="${RELEASE_KEY_PASSWORD:-}"

if [ -n "${RELEASE_KEYSTORE_FILE:-}" ] &&
[ -n "$store_password" ] &&
[ -n "$key_alias" ] &&
[ -n "$key_password" ] &&
echo "$RELEASE_KEYSTORE_FILE" | base64 --decode > android/release.keystore &&
keytool -list -keystore android/release.keystore -storepass "$store_password" -alias "$key_alias" >/dev/null 2>&1; then
echo "Using release signing key from repository secrets"
else
echo "Release signing secrets are missing or invalid; generating disposable CI signing key"
rm -f android/release.keystore
store_password="ci-release-password"
key_alias="ci-release-key"
key_password="ci-release-password"
keytool -genkeypair \
-keystore android/release.keystore \
-storepass "$store_password" \
-alias "$key_alias" \
-keypass "$key_password" \
-keyalg RSA \
-keysize 2048 \
-validity 10000 \
-dname "CN=LibrePods CI, OU=CI, O=LibrePods, L=CI, S=CI, C=US"
fi

cat <<EOF > android/local.properties
RELEASE_STORE_FILE=../release.keystore
RELEASE_STORE_PASSWORD=$store_password
RELEASE_KEY_ALIAS=$key_alias
RELEASE_KEY_PASSWORD=$key_password
EOF
- name: Setup Android SDK
uses: android-actions/setup-android@v3
- name: Accept Licenses
run: yes | sdkmanager --licenses
- name: Install NDK
run: sdkmanager "ndk;30.0.14904198"
- name: Create local.properties
run: |
cat <<EOF > android/local.properties
RELEASE_STORE_FILE=../release.keystore
RELEASE_STORE_PASSWORD=${{ secrets.RELEASE_STORE_PASSWORD }}
RELEASE_KEY_ALIAS=${{ secrets.RELEASE_KEY_ALIAS }}
RELEASE_KEY_PASSWORD=${{ secrets.RELEASE_KEY_PASSWORD }}
EOF
- name: Build
run: ./gradlew packageReleaseArtifacts
working-directory: android
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.worktrees/
release
.vscode
.DS_Store
Expand Down
25 changes: 23 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<img src="https://img.shields.io/github/v/release/kavishdevar/librepods?style=for-the-badge&logoColor=white&label=Release" />
<img src="https://img.shields.io/github/downloads/kavishdevar/librepods/total?style=for-the-badge&label=Downloads" />
<img src="https://img.shields.io/github/issues/kavishdevar/librepods?style=for-the-badge" />

<a href="https://discord.gg/HhG4ycVum4">
<img src="https://img.shields.io/discord/1441416992027574375?style=for-the-badge&logoColor=white&color=5865F2&label=Discord" />
</a>
Expand Down Expand Up @@ -85,6 +85,27 @@ LibrePods **may** require root depending on your device/OS and what features you
> [!IMPORTANT]
> This workaround with Xposed is not guaranteed to work on all devices.

### Setup for OxygenOS/ColorOS 16 (Non-rooted)

For multi-device audio switching to work properly on non-rooted OxygenOS 16, you need to inject your phone's Bluetooth MAC address into the app's settings. This is a one-time setup.

> [!IMPORTANT]
> The `run-as` command only works with **debug builds** (e.g., the nightly APK from CI). If you installed a release build, reinstall with the debug APK first.

1. **Get your phone's Bluetooth MAC address:**
- Go to Settings -> About -> Device Details -> Bluetooth Address

2. **Inject the MAC address via adb:**
```bash
adb shell "run-as me.kavishdevar.librepods sed -i 's|<string name=\"self_mac_address\"></string>|<string name=\"self_mac_address\">XX:XX:XX:XX:XX:XX</string>|' shared_prefs/settings.xml"
```
Replace `XX:XX:XX:XX:XX:XX` with your actual Bluetooth MAC address (e.g., `AC:C0:48:67:E6:EA`)

3. **Restart the app** for the changes to take effect

> [!NOTE]
> This is needed because non-rooted apps on SDK 36+ cannot access the system's `bluetooth_address` setting. Without this, audio source switching between devices won't work correctly, and the app will lose ANC/transparency control when you switch to another device.

### Troubleshooting steps for common errors
- Ensure the correct scope is set in LSPosed/Vector.
- Ensure there is no root-hiding module preventing the hook from loading on the Bluetooth app.
Expand Down Expand Up @@ -114,7 +135,7 @@ Upto two devices can be simultaneously connected to AirPods, for audio and contr

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.
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.

# Supporters

Expand Down
46 changes: 31 additions & 15 deletions android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
Expand Down Expand Up @@ -185,14 +186,10 @@ class MainActivity : ComponentActivity() {

override fun onDestroy() {
try {
unbindService(serviceConnection)
Log.d("MainActivity", "Unbound service")
} catch (e: Exception) {
Log.e("MainActivity", "Error while unbinding service: $e")
}
try {
unregisterReceiver(connectionStatusReceiver)
Log.d("MainActivity", "Unregistered receiver")
if (::connectionStatusReceiver.isInitialized) {
unregisterReceiver(connectionStatusReceiver)
Log.d("MainActivity", "Unregistered receiver")
}
} catch (e: Exception) {
Log.e("MainActivity", "Error while unregistering receiver: $e")
}
Expand All @@ -202,14 +199,18 @@ class MainActivity : ComponentActivity() {

override fun onStop() {
try {
unbindService(serviceConnection)
Log.d("MainActivity", "Unbound service")
if (::serviceConnection.isInitialized) {
unbindService(serviceConnection)
Log.d("MainActivity", "Unbound service")
}
} catch (e: Exception) {
Log.e("MainActivity", "Error while unbinding service: $e")
}
try {
unregisterReceiver(connectionStatusReceiver)
Log.d("MainActivity", "Unregistered receiver")
if (::connectionStatusReceiver.isInitialized) {
unregisterReceiver(connectionStatusReceiver)
Log.d("MainActivity", "Unregistered receiver")
}
} catch (e: Exception) {
Log.e("MainActivity", "Error while unregistering receiver: $e")
}
Expand Down Expand Up @@ -511,7 +512,11 @@ fun Main() {
canDrawOverlays = Settings.canDrawOverlays(context)
}

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

if (bluetoothPermissionsGranted && (canDrawOverlays || overlaySkipped.value)) {

val navController = rememberNavController()

Expand Down Expand Up @@ -550,7 +555,16 @@ fun Main() {
)
}) {
composable("settings") {
if (airPodsViewModel != null) AirPodsSettingsScreen(airPodsViewModel, navController)
if (airPodsViewModel != null) {
AirPodsSettingsScreen(airPodsViewModel, navController)
} else {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}
composable("debug") {
DebugScreen(navController = navController)
Expand Down Expand Up @@ -692,7 +706,9 @@ fun PermissionsScreen(

val scrollState = rememberScrollState()

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

val infiniteTransition = rememberInfiniteTransition(label = "pulse")
val pulseScale by infiniteTransition.animateFloat(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,9 +174,9 @@ class AACPManager {
}

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

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

fun unregisterControlCommandListener(
Expand Down
Loading