From ef38ebff05c11b286cc679b564ac617103a7b54c Mon Sep 17 00:00:00 2001 From: Andrii Antoniuk Date: Mon, 24 Aug 2026 06:46:43 +0200 Subject: [PATCH 1/2] android: allow Galaxy S26 on One UI 9 without root Samsung's L2CAP fix ships in One UI 9, but betas can still report SDK 36 so the allowlist rejected S26. Detect One UI 9 via Samsung properties, create sockets with fd=-1, and tell One UI 8.5 users there is no no-root workaround. Co-authored-by: Cursor --- README.md | 2 +- android/README.md | 2 + android/app/src/main/cpp/bluetooth_socket.cpp | 4 +- .../bluetooth/BluetoothConnectionManager.kt | 30 ++++- .../presentation/components/DeviceInfoCard.kt | 10 ++ .../screens/onboarding/NotSupportedPage.kt | 19 ++- .../librepods/utils/RootlessSupport.kt | 113 ++++++++++++++++-- android/app/src/main/res/values/strings.xml | 4 + 8 files changed, 165 insertions(+), 19 deletions(-) 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/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/bluetooth/BluetoothConnectionManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BluetoothConnectionManager.kt index 95d5d655f..e5d09e7a3 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("createInsecureL2capSocket", "createL2capSocket") + 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/RootlessSupport.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/RootlessSupport.kt index 57741a81c..b2a80a1b5 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,118 @@ 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" + +/** 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 isSupported(sharedPreferences: SharedPreferences): Boolean { - if (Build.VERSION.SDK_INT >= 37) return true + if (hasFixedL2capStack()) return true val isBypassFlagActive = sharedPreferences.getBoolean("bypass_device_check.v2", false) - if (isBypassFlagActive) return true + return isBypassFlagActive +} - val isPixel = Build.MANUFACTURER.lowercase() == "google" - val isOppoFamily = Build.MANUFACTURER.lowercase() in listOf("oneplus", "oppo", "realme") +fun bypassDeviceCheck(sharedPreferences: SharedPreferences) { + sharedPreferences.edit { putBoolean("bypass_device_check.v2", true) } +} - if (isPixel && Build.VERSION.SDK_INT == 36) { - return Build.ID.startsWith("CP1A") - } else if (isOppoFamily) { - return Build.VERSION.SDK_INT >= 36 +fun isSamsungDevice( + manufacturer: String = Build.MANUFACTURER, + brand: String = Build.BRAND +): Boolean { + return manufacturer.equals("samsung", ignoreCase = true) || + brand.equals("samsung", ignoreCase = true) +} + +/** + * True when this OS build includes Google's AirPods L2CAP stack fix, so LibrePods + * can talk to AirPods without root or Xposed. + * + * Android 17 (SDK 37) has the fix in AOSP. Samsung ships it as One UI 9; Galaxy S26 + * on One UI 8 / 8.5 does not. One UI 9 betas sometimes still report SDK 36, so we + * also read Samsung's One UI version properties. + */ +internal fun hasFixedL2capStack( + sdkInt: Int = Build.VERSION.SDK_INT, + manufacturer: String = Build.MANUFACTURER, + androidRelease: String = Build.VERSION.RELEASE ?: "", + buildId: String = Build.ID ?: "", + oneUiVersion: Int? = readOneUiVersion(), + semPlatformInt: Int? = readSemPlatformInt() +): Boolean { + if (sdkInt >= 37) return true + if (androidRelease.startsWith("17")) return true + + val mfr = manufacturer.lowercase() + val isPixel = mfr == "google" + val isOppoFamily = mfr in listOf("oneplus", "oppo", "realme") + + if (isPixel && sdkInt == 36) { + return buildId.startsWith("CP1A") + } + if (isOppoFamily && sdkInt >= 36) return true + if (isSamsungDevice(manufacturer) && isSamsungOneUi9OrNewer(oneUiVersion, semPlatformInt)) { + Log.i( + TAG, + "Samsung One UI 9+ detected (oneui=$oneUiVersion sem=$semPlatformInt sdk=$sdkInt)" + ) + return true } return false } -fun bypassDeviceCheck(sharedPreferences: SharedPreferences) { - sharedPreferences.edit{ putBoolean("bypass_device_check.v2", true) } +internal fun isSamsungOneUi9OrNewer( + oneUiVersion: Int? = readOneUiVersion(), + semPlatformInt: Int? = readSemPlatformInt() +): Boolean { + if (oneUiVersion != null && oneUiVersion >= ONE_UI_9) return true + if (semPlatformInt != null && semPlatformInt >= SEM_PLATFORM_ONE_UI_9) return true + return false +} + +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. From d045ad44b88840081922711f82742a6ea98ef76d Mon Sep 17 00:00:00 2001 From: Andrii Antoniuk Date: Mon, 24 Aug 2026 07:13:07 +0200 Subject: [PATCH 2/2] android: load L2CAP JNI before onboarding and lock One UI 8.5 out Detect One UI 9 during first launch by loading bluetooth_socket in Application, treat a known One UI 8.x property as unsupported even if SEM looks like 9, prefer the secure L2CAP factory, and cover the allowlist. Co-authored-by: Cursor --- android/app/build.gradle.kts | 1 + .../librepods/LibrePodsApplication.kt | 13 ++ .../bluetooth/BluetoothConnectionManager.kt | 2 +- .../librepods/utils/L2capStackSupport.kt | 66 ++++++++++ .../librepods/utils/RootlessSupport.kt | 69 +++-------- .../librepods/utils/L2capStackSupportTest.kt | 114 ++++++++++++++++++ android/gradle/libs.versions.toml | 2 + 7 files changed, 214 insertions(+), 53 deletions(-) create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/utils/L2capStackSupport.kt create mode 100644 android/app/src/test/java/me/kavishdevar/librepods/utils/L2capStackSupportTest.kt 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/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 e5d09e7a3..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 @@ -83,7 +83,7 @@ fun createBluetoothSocket( } private fun createL2capSocketViaDevice(device: BluetoothDevice, psm: Int): BluetoothSocket? { - val methodNames = listOf("createInsecureL2capSocket", "createL2capSocket") + val methodNames = listOf("createL2capSocket", "createInsecureL2capSocket") for (name in methodNames) { try { val method = BluetoothDevice::class.java.getDeclaredMethod( 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 b2a80a1b5..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 @@ -25,14 +25,8 @@ import androidx.core.content.edit private const val TAG = "RootlessSupport" -/** 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 isSupported(sharedPreferences: SharedPreferences): Boolean { - if (hasFixedL2capStack()) return true + if (deviceHasFixedL2capStack()) return true val isBypassFlagActive = sharedPreferences.getBoolean("bypass_device_check.v2", false) return isBypassFlagActive @@ -45,55 +39,26 @@ fun bypassDeviceCheck(sharedPreferences: SharedPreferences) { fun isSamsungDevice( manufacturer: String = Build.MANUFACTURER, brand: String = Build.BRAND -): Boolean { - return manufacturer.equals("samsung", ignoreCase = true) || - brand.equals("samsung", ignoreCase = true) -} - -/** - * True when this OS build includes Google's AirPods L2CAP stack fix, so LibrePods - * can talk to AirPods without root or Xposed. - * - * Android 17 (SDK 37) has the fix in AOSP. Samsung ships it as One UI 9; Galaxy S26 - * on One UI 8 / 8.5 does not. One UI 9 betas sometimes still report SDK 36, so we - * also read Samsung's One UI version properties. - */ -internal fun hasFixedL2capStack( - sdkInt: Int = Build.VERSION.SDK_INT, - manufacturer: String = Build.MANUFACTURER, - androidRelease: String = Build.VERSION.RELEASE ?: "", - buildId: String = Build.ID ?: "", - oneUiVersion: Int? = readOneUiVersion(), - semPlatformInt: Int? = readSemPlatformInt() -): Boolean { - if (sdkInt >= 37) return true - if (androidRelease.startsWith("17")) return true - - val mfr = manufacturer.lowercase() - val isPixel = mfr == "google" - val isOppoFamily = mfr in listOf("oneplus", "oppo", "realme") - - if (isPixel && sdkInt == 36) { - return buildId.startsWith("CP1A") - } - if (isOppoFamily && sdkInt >= 36) return true - if (isSamsungDevice(manufacturer) && isSamsungOneUi9OrNewer(oneUiVersion, semPlatformInt)) { +): Boolean = isSamsungManufacturer(manufacturer, brand) + +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=$sdkInt)" + "Samsung One UI 9+ detected (oneui=$oneUiVersion sem=$semPlatformInt sdk=${Build.VERSION.SDK_INT})" ) - return true } - return false -} - -internal fun isSamsungOneUi9OrNewer( - oneUiVersion: Int? = readOneUiVersion(), - semPlatformInt: Int? = readSemPlatformInt() -): Boolean { - if (oneUiVersion != null && oneUiVersion >= ONE_UI_9) return true - if (semPlatformInt != null && semPlatformInt >= SEM_PLATFORM_ONE_UI_9) return true - return false + return supported } fun readOneUiVersion(): Int? { 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" }