diff --git a/README.md b/README.md index c1cc4edb5..d79f59ca9 100644 --- a/README.md +++ b/README.md @@ -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/)) diff --git a/android/README.md b/android/README.md index 5596f742d..757c60136 100644 --- a/android/README.md +++ b/android/README.md @@ -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] diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 9b20a00ba..6c552eddc 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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) diff --git a/android/app/src/main/cpp/bluetooth_socket.cpp b/android/app/src/main/cpp/bluetooth_socket.cpp index 865d4216b..484329ad8 100644 --- a/android/app/src/main/cpp/bluetooth_socket.cpp +++ b/android/app/src/main/cpp/bluetooth_socket.cpp @@ -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); @@ -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(); 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..481438c40 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/LibrePodsApplication.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/LibrePodsApplication.kt @@ -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) 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..504d2a637 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 @@ -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 @@ -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", 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", "Created socket via BluetoothDevice.$name($psm)") + return socket + } catch (e: Exception) { + Log.e("createSocket", "BluetoothDevice.$name failed: ${e.message}") + } + } + return null +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/DeviceInfoCard.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/DeviceInfoCard.kt index b5a996884..21a867204 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/DeviceInfoCard.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/DeviceInfoCard.kt @@ -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() { @@ -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, diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/NotSupportedPage.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/NotSupportedPage.kt index 66125ea6c..5c50a1670 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/NotSupportedPage.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/NotSupportedPage.kt @@ -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( @@ -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() diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/L2capStackSupport.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/L2capStackSupport.kt new file mode 100644 index 000000000..60ef03d83 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/L2capStackSupport.kt @@ -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 . +*/ + +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 +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/RootlessSupport.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/RootlessSupport.kt index 57741a81c..41c42cc4f 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/RootlessSupport.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/RootlessSupport.kt @@ -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 + } } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 3356edc4a..8acbd621d 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -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. + 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. + One UI version + Unknown (Name your own price) This device may not be supported due to platform limitations and requires an Xposed framework. Tick the checkbox below and type OK to continue. diff --git a/android/app/src/test/java/me/kavishdevar/librepods/utils/L2capStackSupportTest.kt b/android/app/src/test/java/me/kavishdevar/librepods/utils/L2capStackSupportTest.kt new file mode 100644 index 000000000..4b96a7169 --- /dev/null +++ b/android/app/src/test/java/me/kavishdevar/librepods/utils/L2capStackSupportTest.kt @@ -0,0 +1,114 @@ +package me.kavishdevar.librepods.utils + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class L2capStackSupportTest { + + @Test + fun samsungOneUi9OnSdk36IsAllowed() { + assertTrue( + hasFixedL2capStack( + sdkInt = 36, + manufacturer = "samsung", + androidRelease = "16", + buildId = "BP4A.251205.006", + oneUiVersion = 90_000, + semPlatformInt = 180_000 + ) + ) + } + + @Test + fun samsungOneUi85OnSdk36IsDeniedEvenIfSemLooksLike9() { + assertFalse( + hasFixedL2capStack( + sdkInt = 36, + manufacturer = "samsung", + androidRelease = "16", + buildId = "BP4A.251205.006", + oneUiVersion = 85_000, + semPlatformInt = 180_000 + ) + ) + } + + @Test + fun samsungUnknownOneUiUsesSem180000() { + assertTrue( + hasFixedL2capStack( + sdkInt = 36, + manufacturer = "samsung", + androidRelease = "16", + buildId = "BP4A.251205.006", + oneUiVersion = null, + semPlatformInt = 180_000 + ) + ) + } + + @Test + fun samsungUnknownOneUiLowSemIsDenied() { + assertFalse( + hasFixedL2capStack( + sdkInt = 36, + manufacturer = "samsung", + androidRelease = "16", + buildId = "BP4A.251205.006", + oneUiVersion = null, + semPlatformInt = 170_500 + ) + ) + } + + @Test + fun pixelQpr3IsAllowed() { + assertTrue( + hasFixedL2capStack( + sdkInt = 36, + manufacturer = "Google", + androidRelease = "16", + buildId = "CP1A.260305.018", + oneUiVersion = null, + semPlatformInt = null + ) + ) + } + + @Test + fun oppoOxygenOs16IsAllowed() { + assertTrue( + hasFixedL2capStack( + sdkInt = 36, + manufacturer = "OnePlus", + androidRelease = "16", + buildId = "CP1A.foo", + oneUiVersion = null, + semPlatformInt = null + ) + ) + } + + @Test + fun sdk37IsAlwaysAllowed() { + assertTrue( + hasFixedL2capStack( + sdkInt = 37, + manufacturer = "samsung", + androidRelease = "17", + buildId = "anything", + oneUiVersion = 85_000, + semPlatformInt = 170_000 + ) + ) + } + + @Test + fun oneUiPropertyWinsOverSem() { + assertFalse(isSamsungOneUi9OrNewer(oneUiVersion = 85_000, semPlatformInt = 180_000)) + assertTrue(isSamsungOneUi9OrNewer(oneUiVersion = 90_000, semPlatformInt = 170_000)) + assertTrue(isSamsungOneUi9OrNewer(oneUiVersion = null, semPlatformInt = 180_000)) + assertFalse(isSamsungOneUi9OrNewer(oneUiVersion = null, semPlatformInt = null)) + } +} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 0999d3957..40729f2ca 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -20,6 +20,7 @@ hilt = "2.59.2" xposed = "101.0.0" lifecycleProcess = "2.10.0" play = "2.0.2" +junit = "4.13.2" nav3Core = "1.1.2" lifecycleViewmodelNav3 = "2.11.0-rc01" navevent = "1.1.1" @@ -64,6 +65,7 @@ androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecy androidx-navigationevent = { module = "androidx.navigationevent:navigationevent", version.ref = "navevent"} androidx-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "foundationLayout" } androidx-compose-ui-text-google-fonts = { group = "androidx.compose.ui", name = "ui-text-google-fonts", version.ref = "uiTextGoogleFonts" } +junit = { group = "junit", name = "junit", version.ref = "junit" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }