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
1 change: 1 addition & 0 deletions android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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 **HyperOS 3 (Xiaomi/POCO/Redmi on Android 16)** the compatibility check is skipped as well, but rootless support there is not confirmed yet: some devices still fail to open the private L2CAP channel ([#393](https://github.com/librepods-org/librepods/issues/393)). If that happens, you still need root and Xposed.
- 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 @@ -158,6 +158,7 @@ dependencies {
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
implementation(libs.androidx.navigationevent)
testImplementation(libs.junit)
}

aboutLibraries {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,23 +22,64 @@ import android.content.SharedPreferences
import android.os.Build
import androidx.core.content.edit

internal const val ANDROID_16_SDK = 36
internal const val ANDROID_17_SDK = 37

private const val BYPASS_DEVICE_CHECK_KEY = "bypass_device_check.v2"
internal const val GOOGLE_MANUFACTURER = "google"
internal const val ONEPLUS_MANUFACTURER = "oneplus"
internal const val OPPO_MANUFACTURER = "oppo"
internal const val REALME_MANUFACTURER = "realme"
internal const val XIAOMI_MANUFACTURER = "xiaomi"
internal const val POCO_MANUFACTURER = "poco"
internal const val REDMI_MANUFACTURER = "redmi"
private const val PIXEL_ANDROID_16_SUPPORTED_BUILD_PREFIX = "CP1A"

private val OPPO_FAMILY_MANUFACTURERS = setOf(
ONEPLUS_MANUFACTURER,
OPPO_MANUFACTURER,
REALME_MANUFACTURER,
)
private val XIAOMI_FAMILY_MANUFACTURERS = setOf(
XIAOMI_MANUFACTURER,
POCO_MANUFACTURER,
REDMI_MANUFACTURER,
)

fun isSupported(sharedPreferences: SharedPreferences): Boolean {
if (Build.VERSION.SDK_INT >= 37) return true
val isBypassFlagActive = sharedPreferences.getBoolean(BYPASS_DEVICE_CHECK_KEY, false)

val isBypassFlagActive = sharedPreferences.getBoolean("bypass_device_check.v2", false)
return isRootlessSupported(
sdkInt = Build.VERSION.SDK_INT,
manufacturer = Build.MANUFACTURER,
buildId = Build.ID,
isBypassFlagActive = isBypassFlagActive,
)
}

fun isRootlessSupported(
sdkInt: Int,
manufacturer: String,
buildId: String,
isBypassFlagActive: Boolean,
): Boolean {
if (sdkInt >= ANDROID_17_SDK) return true
if (isBypassFlagActive) return true

val isPixel = Build.MANUFACTURER.lowercase() == "google"
val isOppoFamily = Build.MANUFACTURER.lowercase() in listOf("oneplus", "oppo", "realme")
val normalizedManufacturer = manufacturer.lowercase()
val isPixel = normalizedManufacturer == GOOGLE_MANUFACTURER
val isOppoFamily = normalizedManufacturer in OPPO_FAMILY_MANUFACTURERS
val isXiaomiFamily = normalizedManufacturer in XIAOMI_FAMILY_MANUFACTURERS

if (isPixel && Build.VERSION.SDK_INT == 36) {
return Build.ID.startsWith("CP1A")
} else if (isOppoFamily) {
return Build.VERSION.SDK_INT >= 36
if (isPixel && sdkInt == ANDROID_16_SDK) {
return buildId.startsWith(PIXEL_ANDROID_16_SUPPORTED_BUILD_PREFIX)
} else if (isOppoFamily || isXiaomiFamily) {
return sdkInt >= ANDROID_16_SDK
}

return false
}

fun bypassDeviceCheck(sharedPreferences: SharedPreferences) {
sharedPreferences.edit{ putBoolean("bypass_device_check.v2", true) }
sharedPreferences.edit{ putBoolean(BYPASS_DEVICE_CHECK_KEY, true) }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
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

import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test

class RootlessSupportTest {
@Test
fun android16XiaomiFamilyDevicesAreSupported() {
assertTrue(isRootlessSupported(ANDROID_16_SDK, XIAOMI_MANUFACTURER, GENERIC_ANDROID_16_BUILD_ID, false))
assertTrue(isRootlessSupported(ANDROID_16_SDK, POCO_MANUFACTURER, GENERIC_ANDROID_16_BUILD_ID, false))
assertTrue(isRootlessSupported(ANDROID_16_SDK, REDMI_MANUFACTURER, GENERIC_ANDROID_16_BUILD_ID, false))
}

@Test
fun android16OppoFamilyDevicesRemainSupported() {
assertTrue(isRootlessSupported(ANDROID_16_SDK, ONEPLUS_MANUFACTURER, GENERIC_ANDROID_16_BUILD_ID, false))
assertTrue(isRootlessSupported(ANDROID_16_SDK, OPPO_MANUFACTURER, GENERIC_ANDROID_16_BUILD_ID, false))
assertTrue(isRootlessSupported(ANDROID_16_SDK, REALME_MANUFACTURER, GENERIC_ANDROID_16_BUILD_ID, false))
}

@Test
fun pixelAndroid16RequiresMarchUpdateBuildPrefix() {
assertTrue(isRootlessSupported(ANDROID_16_SDK, GOOGLE_MANUFACTURER, SUPPORTED_PIXEL_ANDROID_16_BUILD_ID, false))
assertFalse(isRootlessSupported(ANDROID_16_SDK, GOOGLE_MANUFACTURER, GENERIC_ANDROID_16_BUILD_ID, false))
}

@Test
fun bypassFlagSupportsOtherwiseUnsupportedDevices() {
assertTrue(isRootlessSupported(ANDROID_16_SDK, UNSUPPORTED_MANUFACTURER, GENERIC_ANDROID_16_BUILD_ID, true))
}

@Test
fun android17AndLaterAreSupported() {
assertTrue(isRootlessSupported(ANDROID_17_SDK, UNSUPPORTED_MANUFACTURER, GENERIC_ANDROID_16_BUILD_ID, false))
}

private companion object {
private const val GENERIC_ANDROID_16_BUILD_ID = "BP2A.250605.031"
private const val SUPPORTED_PIXEL_ANDROID_16_BUILD_ID = "CP1A.250305.019"
private const val UNSUPPORTED_MANUFACTURER = "samsung"
}
}
2 changes: 2 additions & 0 deletions android/gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -58,6 +59,7 @@ libxposed-service = { group = "io.github.libxposed", name = "service", version.r
androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleProcess" }
play-review = { group = "com.google.android.play", name="review", version.ref = "play" }
play-review-ktx = { group = "com.google.android.play", name="review-ktx", version.ref = "play" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" }
androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" }
Expand Down