Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ A huge thank you to everyone supporting the project!
- 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.
- 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, or a Samsung Galaxy on One UI 8 / 8.5. Galaxy S26 on One UI 9 should use LibrePods (GitHub APK).
- 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/))

Expand Down
2 changes: 2 additions & 0 deletions android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ LibrePods *may* require root depending on your device/OS and what features you w

- Features requiring the VendorID hook ([the features marked with an asterisk here](https://github.com/kavishdevar/librepods#key-features)) will always require root regardless of your device/OS.
- On **ColorOS/OxygenOS 16 and realme UI 7.0** and **Pixel devices on Android 16 QPR3** (with the latest Google Play system update), LibrePods does not need root for most features.
- On **Samsung Galaxy devices running One UI 9 (Android 17)** — including **Galaxy S26 / S26+ / S26 Ultra** — LibrePods does not need root. The maintainer confirmed this on One UI 9 beta. Install the [GitHub release APK](https://github.com/librepods-org/librepods/releases/latest); Play Store still excludes Samsung until stable One UI 9 is widely rolled out.
- On Samsung One UI 8 / 8.5 (Android 16), the Bluetooth stack does not include the L2CAP fix. There is no no-root workaround. Update to One UI 9, or use [CAPod](https://github.com/d4rken-org/capod) for battery/proximity only.
- On other devices, LibrePods needs root because of a bug in the Android Bluetooth stack Fluoride/non-compliance of Apple with Bluetooth standards. You must have Xposed installed for the app to workaround this bug and connect to AirPods. [This issue is being tracked here](https://issuetracker.google.com/issues/371713238). **Please do not comment on the issue thread.** The issue has already been resolved and should be available in **Android 17** for all devices.

> [!IMPORTANT]
Expand Down
1 change: 1 addition & 0 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ dependencies {
// implementation(libs.hilt.compiler)
compileOnly(libs.libxposed.api)
implementation(libs.libxposed.service)
testImplementation(libs.junit)
implementation(libs.play.review)
implementation(libs.play.review.ktx)
implementation(libs.androidx.navigation3.ui)
Expand Down
4 changes: 3 additions & 1 deletion android/app/src/main/cpp/bluetooth_socket.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) {
constexpr auto c6 = ENC("java/lang/String");
constexpr auto c7 = ENC("Landroid/bluetooth/BluetoothSocket;");
constexpr auto c8 = ENC("Landroid/bluetooth/BluetoothDevice;");
constexpr auto c9 = ENC("Landroid/os/SystemProperties;");

JNIEnv* env;
getVm()->AttachCurrentThread(&env, nullptr);
Expand All @@ -52,9 +53,10 @@ JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) {

jobject runtime = env->CallStaticObjectMethod(vmRuntime, getRuntime);
jobjectArray prefixes = env->NewObjectArray(
2, env->FindClass(DEC(c6)), nullptr);
3, env->FindClass(DEC(c6)), nullptr);
env->SetObjectArrayElement(prefixes, 0, env->NewStringUTF(DEC(c7)));
env->SetObjectArrayElement(prefixes, 1, env->NewStringUTF(DEC(c8)));
env->SetObjectArrayElement(prefixes, 2, env->NewStringUTF(DEC(c9)));

env->CallVoidMethod(runtime, setExemptions, prefixes);
getVm()->DetachCurrentThread();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,19 @@ import me.kavishdevar.librepods.utils.XposedState

class LibrePodsApplication: Application(), XposedServiceHelper.OnServiceListener, DefaultLifecycleObserver {

companion object {
init {
// Hidden-API exemption for SystemProperties / BluetoothSocket must be in
// place before onboarding calls isSupported(). AirPodsService also loads
// this library; a second loadLibrary is a no-op.
try {
System.loadLibrary("bluetooth_socket")
} catch (_: UnsatisfiedLinkError) {
// JVM unit tests and hosts without the JNI lib.
}
}
}

override fun onCreate() {
XposedServiceHelper.registerListener(this)
BillingManager.provider = BillingProviderFactory.create(this)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,17 @@ fun createBluetoothSocket(
adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid, psm: Int
): BluetoothSocket {
val type = 3 // L2CAP
// fd must be -1 ("no existing fd") for a new client socket. Passing 1 matches
// the AOSP constructor but wraps stdout and fails on Samsung's BluetoothSocket.
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, -1, true, true, device, psm, uuid, false, false),
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, 1, true, true, device, psm, uuid)
)

val constructors = BluetoothSocket::class.java.declaredConstructors
Expand Down Expand Up @@ -69,8 +74,29 @@ fun createBluetoothSocket(
}
}

createL2capSocketViaDevice(device, psm)?.let { return it }

val errorMessage =
"Failed to create BluetoothSocket after trying $attemptedConstructors constructor signatures"
Log.e("createSocket<psm>", errorMessage)
throw lastException ?: IllegalStateException(errorMessage)
}

private fun createL2capSocketViaDevice(device: BluetoothDevice, psm: Int): BluetoothSocket? {
val methodNames = listOf("createL2capSocket", "createInsecureL2capSocket")
for (name in methodNames) {
try {
val method = BluetoothDevice::class.java.getDeclaredMethod(
name,
Int::class.javaPrimitiveType
)
method.isAccessible = true
val socket = method.invoke(device, psm) as BluetoothSocket
Log.d("createSocket<psm>", "Created socket via BluetoothDevice.$name($psm)")
return socket
} catch (e: Exception) {
Log.e("createSocket<psm>", "BluetoothDevice.$name failed: ${e.message}")
}
}
return null
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.utils.XposedState
import me.kavishdevar.librepods.utils.isSamsungDevice
import me.kavishdevar.librepods.utils.oneUiVersionLabel

@Composable
fun DeviceInfoCard() {
Expand All @@ -21,6 +23,14 @@ fun DeviceInfoCard() {
enabled = false
)

if (isSamsungDevice()) {
StyledListItem(
name = stringResource(R.string.one_ui_version),
description = oneUiVersionLabel() ?: stringResource(R.string.unknown),
enabled = false
)
}

StyledListItem(
name = stringResource(R.string.build_id),
description = Build.DISPLAY,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.AppInfoCard
import me.kavishdevar.librepods.presentation.components.DeviceInfoCard
import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.utils.isSamsungDevice

@Composable
fun NotSupportedPage(
Expand All @@ -38,13 +39,21 @@ fun NotSupportedPage(
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = stringResource(R.string.check_the_repository_for_more_info),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringResource(R.string.enable_app_in_xposed_or_update_device),
text = stringResource(
if (isSamsungDevice()) {
R.string.samsung_one_ui9_required
} else {
R.string.check_the_repository_for_more_info
}
),
style = MaterialTheme.typography.bodyMedium,
)
if (!isSamsungDevice()) {
Text(
text = stringResource(R.string.enable_app_in_xposed_or_update_device),
style = MaterialTheme.typography.bodyMedium,
)
}
DeviceInfoCard()
AppInfoCard()

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
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 <https://www.gnu.org/licenses/>.
*/

package me.kavishdevar.librepods.utils

/** One UI encodes 9.0 as 90000, 8.5 as 85000, 8.0 as 80000. */
internal const val ONE_UI_9 = 90_000

/** Samsung SEM_PLATFORM_INT for One UI 9.0 (One UI 8.0 is 170000). */
internal const val SEM_PLATFORM_ONE_UI_9 = 180_000

fun isSamsungManufacturer(manufacturer: String, brand: String = ""): Boolean {
return manufacturer.equals("samsung", ignoreCase = true) ||
brand.equals("samsung", ignoreCase = true)
}

/**
* Pure allowlist for the AOSP/OEM L2CAP fix. Android 17 (SDK 37) has it in AOSP.
* Samsung ships it as One UI 9. One UI 8 / 8.5 does not, even if SEM looks high.
*
* When [oneUiVersion] is present it wins: a known 8.5 (85000) must not be rescued
* by [semPlatformInt].
*/
internal fun hasFixedL2capStack(
sdkInt: Int,
manufacturer: String,
androidRelease: String,
buildId: String,
oneUiVersion: Int?,
semPlatformInt: Int?
): Boolean {
if (sdkInt >= 37) return true
if (androidRelease.startsWith("17")) return true

val mfr = manufacturer.lowercase()
if (mfr == "google" && sdkInt == 36) {
return buildId.startsWith("CP1A")
}
if (mfr in listOf("oneplus", "oppo", "realme") && sdkInt >= 36) return true
return isSamsungManufacturer(manufacturer) &&
isSamsungOneUi9OrNewer(oneUiVersion, semPlatformInt)
}

internal fun isSamsungOneUi9OrNewer(
oneUiVersion: Int?,
semPlatformInt: Int?
): Boolean {
if (oneUiVersion != null) return oneUiVersion >= ONE_UI_9
if (semPlatformInt != null) return semPlatformInt >= SEM_PLATFORM_ONE_UI_9
return false
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,25 +20,83 @@ package me.kavishdevar.librepods.utils

import android.content.SharedPreferences
import android.os.Build
import android.util.Log
import androidx.core.content.edit

private const val TAG = "RootlessSupport"

fun isSupported(sharedPreferences: SharedPreferences): Boolean {
if (Build.VERSION.SDK_INT >= 37) return true
if (deviceHasFixedL2capStack()) return true

val isBypassFlagActive = sharedPreferences.getBoolean("bypass_device_check.v2", false)
if (isBypassFlagActive) return true
return isBypassFlagActive
}

fun bypassDeviceCheck(sharedPreferences: SharedPreferences) {
sharedPreferences.edit { putBoolean("bypass_device_check.v2", true) }
}

val isPixel = Build.MANUFACTURER.lowercase() == "google"
val isOppoFamily = Build.MANUFACTURER.lowercase() in listOf("oneplus", "oppo", "realme")
fun isSamsungDevice(
manufacturer: String = Build.MANUFACTURER,
brand: String = Build.BRAND
): Boolean = isSamsungManufacturer(manufacturer, brand)

if (isPixel && Build.VERSION.SDK_INT == 36) {
return Build.ID.startsWith("CP1A")
} else if (isOppoFamily) {
return Build.VERSION.SDK_INT >= 36
internal fun deviceHasFixedL2capStack(): Boolean {
val oneUiVersion = readOneUiVersion()
val semPlatformInt = readSemPlatformInt()
val supported = hasFixedL2capStack(
sdkInt = Build.VERSION.SDK_INT,
manufacturer = Build.MANUFACTURER,
androidRelease = Build.VERSION.RELEASE ?: "",
buildId = Build.ID ?: "",
oneUiVersion = oneUiVersion,
semPlatformInt = semPlatformInt
)
if (supported && isSamsungDevice()) {
Log.i(
TAG,
"Samsung One UI 9+ detected (oneui=$oneUiVersion sem=$semPlatformInt sdk=${Build.VERSION.SDK_INT})"
)
}
return false
return supported
}

fun bypassDeviceCheck(sharedPreferences: SharedPreferences) {
sharedPreferences.edit{ putBoolean("bypass_device_check.v2", true) }
fun readOneUiVersion(): Int? {
val raw = readSystemProperty("ro.build.version.oneui") ?: return null
return raw.filter { it.isDigit() }.toIntOrNull()
}

fun readSemPlatformInt(): Int? {
return try {
Build.VERSION::class.java.getField("SEM_PLATFORM_INT").getInt(null)
} catch (_: Throwable) {
readSystemProperty("ro.build.version.sep")?.toIntOrNull()
}
}

fun oneUiVersionLabel(): String? {
val oneUi = readOneUiVersion()
if (oneUi != null) {
val major = oneUi / 10000
val minor = (oneUi / 100) % 100
return if (minor == 0) "$major.0" else "$major.$minor"
}
val sem = readSemPlatformInt() ?: return null
val major = sem / 10000 - 9
val minor = (sem / 100) % 100
return if (major >= 1) {
if (minor == 0) "$major.0" else "$major.$minor"
} else {
sem.toString()
}
}

private fun readSystemProperty(key: String): String? {
return try {
val clazz = Class.forName("android.os.SystemProperties")
val get = clazz.getMethod("get", String::class.java)
(get.invoke(null, key) as? String)?.takeIf { it.isNotBlank() }
} catch (_: Throwable) {
null
}
}
4 changes: 4 additions & 0 deletions android/app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -252,8 +252,12 @@
\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
\n• Samsung Galaxy devices running One UI 9 (Android 17), including Galaxy S26 / S26+ / S26 Ultra. Use the GitHub APK until Play Store listing is enabled for Samsung.
\n\nFor details, see the project documentation.
</string>
<string name="samsung_one_ui9_required">Galaxy phones do not need root. They need One UI 9 (Android 17), which includes the Bluetooth L2CAP fix required to control AirPods.\n\nGalaxy S26, S26+, and S26 Ultra work without root on One UI 9. Install the GitHub release APK — Play Store still hides Samsung until the stable One UI 9 rollout.\n\nOne UI 8 and 8.5 cannot open the AirPods control channel. That is a Samsung Bluetooth stack limitation, not something this app can patch without root.</string>
<string name="one_ui_version">One UI version</string>
<string name="unknown">Unknown</string>
<string name="name_your_own_price">(Name your own price)</string>
<string name="compatibility_play_dialog_confirmation">
This device may not be supported due to platform limitations and requires an Xposed framework. Tick the checkbox below and type OK to continue.
Expand Down
Loading