Compare commits

...

25 Commits

Author SHA1 Message Date
Kavish Devar
8b24ac49e2 android: add scroll on compatibility check 2026-04-24 21:35:22 +05:30
Kavish Devar
d2dd722bc7 android: change device bypass sharedPref key 2026-04-24 20:29:37 +05:30
Kavish Devar
67fc93bde5 android: add packaging task 2026-04-24 19:50:35 +05:30
Kavish Devar
0b578d62cf android: add more compatibility information, fix FOSS billing, hide upgrade button before first AACP connect
closes #538
2026-04-24 19:50:35 +05:30
Kavish Devar
072b9b4dac android: remove radare root module 2026-04-24 19:50:35 +05:30
Kavish Devar
0af60cd8a9 android: fix ATT on A16QPR3+ 2026-04-24 19:50:35 +05:30
Kavish Devar
be29a46dab android: check for A16 on OP/Oppo devices 2026-04-24 19:50:35 +05:30
Kavish Devar
7461f7dfb7 android: remove debugging logs 2026-04-24 19:50:35 +05:30
Kavish Devar
904c00afce android: fix xposed module in release builds 2026-04-24 19:50:35 +05:30
abc0922001
6272357d84 android: update zh-rTW translation (#536) 2026-04-23 20:47:12 +05:30
Kavish Devar
6ac6700be6 android: format l2c_fcr_hook.cpp 2026-04-23 18:45:00 +05:30
Kavish Devar
113ee0a966 android: fallback to .dynsym when .gnu_debugdata fails 2026-04-23 18:45:00 +05:30
Kavish Devar
d82e4e2427 android: bump version 2026-04-23 18:45:00 +05:30
Kavish Devar
481d5f13cf android: fix automatically pausing when media changes without vendorid hook enabled 2026-04-23 18:45:00 +05:30
Kavish Devar
ef221af505 android: bump version 2026-04-23 18:45:00 +05:30
Kavish Devar
c19190f031 android: fix convo detect not restoring volume when in Transparency mode 2026-04-23 18:45:00 +05:30
Kavish Devar
d0b8574c68 android: hide disconnect when not wearing config on play builds 2026-04-23 18:45:00 +05:30
Kavish Devar
294d733e71 android: add 'required xposed' text to vendorid config toggle 2026-04-23 18:45:00 +05:30
Kavish Devar
f6d7e97796 android: show price in buy button 2026-04-23 18:45:00 +05:30
Kavish Devar
ae174bc9ea android: add confirmation step for unsupported devices 2026-04-23 18:45:00 +05:30
Kavish Devar
1804e80cba docs: fix typo 2026-04-23 05:43:43 +05:30
Kavish Devar
0b8bd5a5b8 docs: fix issuetracker link in README 2026-04-23 05:42:58 +05:30
Kavish Devar
d1d48562d7 docs: clarify root requirements 2026-04-23 05:41:56 +05:30
Kavish Devar
c84e64e656 android: remove unsupported device message 2026-04-23 01:27:55 +05:30
Kavish Devar
51739514fa android: fix normal builds 2026-04-23 01:22:49 +05:30
50 changed files with 1840 additions and 1327 deletions

7
.gitignore vendored
View File

@@ -1,10 +1,5 @@
root-module/radare2-5.9.9-android-aarch64.tar.gz release
wak.toml
log.txt
btl2capfix.zip
root-module-manual
.vscode .vscode
testing.py
.DS_Store .DS_Store
CMakeLists.txt.user* CMakeLists.txt.user*

View File

@@ -76,10 +76,23 @@ https://github.com/user-attachments/assets/43911243-0576-4093-8c55-89c1db5ea533
### Root Requirement ### Root Requirement
If you are using ColorOS/OxygenOS 16, Android 16 QPR3, Android 17 Beta 3 or higher, you don't need root except for customizing transparency mode, setting up hearing aid, and use Bluetooth Multipoint. Changing ANC, conversational awareness, ear detection, and other customizations will work without root. The app 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.
For everyone else: [https://issuetracker.google.com/issues/371713238](https://issuetracker.google.com/issues/371713238)
**You must have a rooted device with Xposed to use LibrePods on Android.**
Please do not comment in the thread. The issue has already been resolved and should be available in Android 17 for all devices.
However, if you are using ColorOS/OxygenOS 16, Android 16 QPR3 on Pixel (ensure you're on the latest Play system update), you don't need root for most features.
> [!IMPORTANT]
> This workaround with Xposed is not guaranteed to work on all devices.
Features requiring the VendorID hook will still require root. These features include customizing transparency mode, setting up hearing aid, and use Bluetooth Multipoint.
### 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.
- Restart your phone after confirming the scope.
### A few notes ### A few notes
@@ -140,7 +153,7 @@ Want to try the latest features before they're officially released? You can grab
4. Extract the zip and install the `.apk` on your device 4. Extract the zip and install the `.apk` on your device
> [!NOTE] > [!NOTE]
> You need to be signed in to GitHub to download artifacts. Nightly builds are debug-signed and may not auto-update — you may need to uninstall the stable version first. > You need to be signed in to GitHub to download artifacts. Nightly builds are debug-signed and may not auto-update. You may need to uninstall the stable version first.
### Linux (Rust) ### Linux (Rust)
1. Go to the [Actions tab](https://github.com/kavishdevar/librepods/actions/workflows/ci-linux-rust.yml) 1. Go to the [Actions tab](https://github.com/kavishdevar/librepods/actions/workflows/ci-linux-rust.yml)

View File

@@ -1,5 +1,7 @@
import java.util.Properties import java.util.Properties
val versionName = "0.2.3"
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
@@ -28,16 +30,15 @@ android {
applicationId = "me.kavishdevar.librepods" applicationId = "me.kavishdevar.librepods"
minSdk = 33 minSdk = 33
targetSdk = 37 targetSdk = 37
versionCode = 28 versionCode = 38
versionName = "0.2.0" versionName = versionName
} }
buildTypes { buildTypes {
release { release {
isMinifyEnabled = true isMinifyEnabled = true
isShrinkResources = true isShrinkResources = true
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
"proguard-rules.pro"
) )
externalNativeBuild { externalNativeBuild {
cmake { cmake {
@@ -50,14 +51,17 @@ android {
debug { debug {
buildConfigField("Boolean", "PLAY_BUILD", "false") buildConfigField("Boolean", "PLAY_BUILD", "false")
signingConfig = signingConfigs.getByName("release") signingConfig = signingConfigs.getByName("release")
versionNameSuffix = "-debug"
} }
create("playRelease") { create("playRelease") {
initWith(getByName("release")) initWith(getByName("release"))
buildConfigField("Boolean", "PLAY_BUILD", "true") buildConfigField("Boolean", "PLAY_BUILD", "true")
versionNameSuffix = "-play"
} }
create("playDebug") { create("playDebug") {
initWith(getByName("debug")) initWith(getByName("debug"))
buildConfigField("Boolean", "PLAY_BUILD", "true") buildConfigField("Boolean", "PLAY_BUILD", "true")
versionNameSuffix = "-youshouldnothavethis"
} }
} }
compileOptions { compileOptions {
@@ -104,7 +108,6 @@ android {
arguments += "-DIS_XPOSED=ON" arguments += "-DIS_XPOSED=ON"
} }
} }
versionNameSuffix = "-xposed"
} }
} }
} }
@@ -113,6 +116,7 @@ dependencies {
implementation(platform(libs.androidx.compose.bom)) implementation(platform(libs.androidx.compose.bom))
implementation(libs.accompanist.permissions) implementation(libs.accompanist.permissions)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.ui) implementation(libs.androidx.ui)
@@ -147,3 +151,77 @@ aboutLibraries {
outputFile = file("src/main/res/raw/aboutlibraries.json") outputFile = file("src/main/res/raw/aboutlibraries.json")
} }
} }
val rootModuleDir = rootProject.file("../root-module-manual")
val releaseDir = rootProject.file("../release")
fun cap(s: String) = s.replaceFirstChar { it.uppercase() }
fun registerRootModuleZipTask(
name: String,
flavor: String,
buildType: String
) = tasks.register<Zip>(name) {
val variantTask = "assemble${cap(flavor)}${cap(buildType)}"
dependsOn(variantTask)
val apkPath = "outputs/apk/$flavor/$buildType/app-$flavor-$buildType.apk"
from(rootModuleDir)
duplicatesStrategy = DuplicatesStrategy.WARN
from(layout.buildDirectory.file(apkPath)) {
into("system/priv-app/LibrePods")
rename { "LibrePods.apk" }
}
archiveFileName.set("LibrePods-FOSS-v$versionName-$buildType.zip")
destinationDirectory.set(layout.buildDirectory.dir("outputs/rootModuleZips"))
}
val zipRelease = registerRootModuleZipTask(
"zipXposedReleaseModule",
"xposed",
"release"
)
val zipDebug = registerRootModuleZipTask(
"zipXposedDebugModule",
"xposed",
"debug"
)
val collect = tasks.register<Copy>("collectReleaseArtifacts") {
dependsOn(
zipRelease,
zipDebug,
"bundleXposedPlayRelease"
)
into(releaseDir)
from(layout.buildDirectory.dir("outputs/apk/xposed/release")) {
include("*.apk")
rename(".*", "LibrePods-FOSS-v$versionName-release.apk")
}
from(layout.buildDirectory.dir("outputs/apk/xposed/debug")) {
include("*.apk")
rename(".*", "LibrePods-FOSS-v$versionName-debug.apk")
}
from(layout.buildDirectory.dir("outputs/bundle/xposedPlayRelease")) {
include("*.aab")
}
from(layout.buildDirectory.dir("outputs/rootModuleZips")) {
include("*.zip")
}
}
tasks.register("packageReleaseArtifacts") {
dependsOn(collect)
}

View File

@@ -20,5 +20,4 @@
# hide the original source file name. # hide the original source file name.
#-renamesourcefileattribute SourceFile #-renamesourcefileattribute SourceFile
-keep class androidx.compose.** { *; } -keep class me.kavishdevar.librepods.utils.KotlinModule { *; }
-dontwarn androidx.compose.**

View File

@@ -14,9 +14,18 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" /> <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<!-- <uses-permission--> <uses-permission
<!-- android:name="android.permission.BLUETOOTH_PRIVILEGED"--> android:name="android.permission.BLUETOOTH_PRIVILEGED"
<!-- tools:ignore="ProtectedPermissions" />--> tools:ignore="ProtectedPermissions" />
<uses-permission
android:name="android.permission.MODIFY_PHONE_STATE"
tools:ignore="ProtectedPermissions" />
<uses-permission
android:name="android.permission.LOCAL_MAC_ADDRESS"
tools:ignore="ProtectedPermissions" />
<uses-permission
android:name="android.permission.INTERACT_ACROSS_USERS"
tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.BLUETOOTH" /> <uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission <uses-permission
android:name="android.permission.BLUETOOTH_SCAN" android:name="android.permission.BLUETOOTH_SCAN"
@@ -27,8 +36,6 @@
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" /> <uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- <uses-permission android:name="android.permission.INTERNET" />--> <!-- <uses-permission android:name="android.permission.INTERNET" />-->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<!-- <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"--> <!-- <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"-->
<!-- android:maxSdkVersion="30" />--> <!-- android:maxSdkVersion="30" />-->
<!-- <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"--> <!-- <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"-->

View File

@@ -52,7 +52,6 @@ import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -64,7 +63,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@@ -81,8 +79,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -94,8 +90,6 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -122,12 +116,12 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.rememberHazeState import dev.chrisbanes.haze.rememberHazeState
import kotlinx.coroutines.delay
import me.kavishdevar.librepods.billing.BillingManager
import me.kavishdevar.librepods.billing.BillingProviderFactory
import me.kavishdevar.librepods.data.AirPodsNotifications import me.kavishdevar.librepods.data.AirPodsNotifications
import me.kavishdevar.librepods.data.ControlCommandRepository import me.kavishdevar.librepods.data.ControlCommandRepository
import me.kavishdevar.librepods.presentation.components.ConfirmationDialog import me.kavishdevar.librepods.presentation.components.ConfirmationDialog
import me.kavishdevar.librepods.presentation.components.DeviceInfoCard
import me.kavishdevar.librepods.presentation.components.PlayBypassSheet
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledIconButton import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.presentation.screens.AccessibilitySettingsScreen import me.kavishdevar.librepods.presentation.screens.AccessibilitySettingsScreen
import me.kavishdevar.librepods.presentation.screens.AdaptiveStrengthScreen import me.kavishdevar.librepods.presentation.screens.AdaptiveStrengthScreen
@@ -147,6 +141,7 @@ import me.kavishdevar.librepods.presentation.screens.TransparencySettingsScreen
import me.kavishdevar.librepods.presentation.screens.TroubleshootingScreen import me.kavishdevar.librepods.presentation.screens.TroubleshootingScreen
import me.kavishdevar.librepods.presentation.screens.UpdateHearingTestScreen import me.kavishdevar.librepods.presentation.screens.UpdateHearingTestScreen
import me.kavishdevar.librepods.presentation.screens.VersionScreen import me.kavishdevar.librepods.presentation.screens.VersionScreen
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
@@ -174,7 +169,7 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
_root_ide_package_.me.kavishdevar.librepods.presentation.theme.LibrePodsTheme { LibrePodsTheme {
Main() Main()
} }
} }
@@ -223,134 +218,109 @@ fun Main() {
val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE) val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE)
if (!isSupported(sharedPreferences)) { if (!isSupported(sharedPreferences)) {
val showDialog = remember { mutableStateOf(false) } val showDialog = remember { mutableStateOf(false) }
val blockTouches = remember { mutableStateOf(false) } val showPlayBypassVisible = remember { mutableStateOf(false) }
val tapCount = remember { mutableIntStateOf(0) }
val lastTapTime = remember { mutableLongStateOf(0L) }
val hazeState = rememberHazeState() val hazeState = rememberHazeState()
val backdrop = rememberLayerBackdrop()
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val scrollState = rememberScrollState()
LaunchedEffect(blockTouches) {
if (blockTouches.value) {
delay(500)
blockTouches.value = false
}
}
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.hazeSource(hazeState) .hazeSource(hazeState)
.background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)), .layerBackdrop(backdrop)
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Box (
modifier = Modifier
.fillMaxSize()
.then(
if (blockTouches.value)
{
Modifier.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
event.changes.forEach { it.consume() }
}
}
}
}
else Modifier
)
)
Column ( Column (
verticalArrangement = Arrangement.spacedBy(8.dp) modifier = Modifier
.padding(horizontal = 16.dp)
.verticalScroll(scrollState),
verticalArrangement = Arrangement
.spacedBy(16.dp)
) {
val innerBackdrop = rememberLayerBackdrop()
Spacer(modifier = Modifier.height(48.dp))
Column(
modifier = Modifier.layerBackdrop(innerBackdrop),
verticalArrangement = Arrangement
.spacedBy(16.dp)
) { ) {
Text( Text(
text = "Not supported", text = stringResource(R.string.not_supported),
style = TextStyle( style = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)), fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = if (isSystemInDarkTheme()) Color.White else Color.Black, color = textColor,
fontSize = 20.sp fontSize = 20.sp,
textAlign = TextAlign.Center
), ),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
Row (
modifier = Modifier.fillMaxWidth().pointerInput(Unit) {
detectTapGestures(
onTap = {
val now = System.currentTimeMillis()
if (now - lastTapTime.longValue > 400) { DeviceInfoCard()
tapCount.intValue = 0
}
tapCount.intValue++ Box(
lastTapTime.longValue = now modifier = Modifier
.fillMaxWidth()
if (tapCount.intValue >= 7) { .background(backgroundColor, RoundedCornerShape(28.dp))
showDialog.value = true .clip(RoundedCornerShape(28.dp))
blockTouches.value = true
}
}
)
},
horizontalArrangement = Arrangement.Center
) { ) {
Text( Text(
text = "Device Info:", text = stringResource(R.string.check_the_repository_for_more_info),
style = TextStyle( style = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)), fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = if (isSystemInDarkTheme()) Color.White else Color.Black, color = if (isSystemInDarkTheme()) Color.White else Color.Black,
fontSize = 16.sp fontSize = 16.sp
), ),
textAlign = TextAlign.End, modifier = Modifier
) .fillMaxWidth()
Spacer(modifier = Modifier.width(4.dp)) .padding(horizontal = 12.dp, vertical = 16.dp)
Text(
text =
"MANUFACTURER=${Build.MANUFACTURER}\n" +
"MODEL=${Build.MODEL}\n" +
"BUILD_ID=${Build.ID}\n" +
"SDK_INT_FULL= ${Build.VERSION.SDK_INT_FULL}\n",
style = TextStyle(
fontFamily = FontFamily(Font(R.font.hack)),
fontWeight = FontWeight.Medium,
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
fontSize = 16.sp
),
textAlign = TextAlign.Start,
) )
} }
}
StyledButton(
onClick = { showDialog.value = true },
backdrop = innerBackdrop,
modifier = Modifier
.fillMaxWidth()
) {
Text( Text(
text = "Check the repository for more info.", text = stringResource(R.string.bypass_compatibility_check),
style = TextStyle( style = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)), fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = if (isSystemInDarkTheme()) Color.White else Color.Black, color = if (isSystemInDarkTheme()) Color.White else Color.Black,
fontSize = 18.sp fontSize = 16.sp
), ),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
) )
} }
Spacer(modifier = Modifier.height(48.dp))
}
} }
ConfirmationDialog( ConfirmationDialog(
showDialog = showDialog, showDialog = showDialog,
title = "Confirm device check bypass?", title = stringResource(R.string.bypass_compatibility_check),
message = "Are you sure your device is supported with LibrePods?", message = stringResource(R.string.bypass_compatiblity_check_confirmation),
confirmText = "Yes", confirmText = stringResource(R.string.yes),
dismissText = "No", dismissText = stringResource(R.string.no),
onConfirm = { onConfirm = {
showDialog.value = false showDialog.value = false
if (BuildConfig.PLAY_BUILD) {
showPlayBypassVisible.value = true
} else {
sharedPreferences.edit { sharedPreferences.edit {
tapCount.intValue = 0 putBoolean("bypass_device_check.v2", true)
putBoolean("bypass_device_check", true)
val intent = Intent(context, MainActivity::class.java) val intent = Intent(context, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
context.startActivity(intent) context.startActivity(intent)
} }
}
}, },
onDismiss = { onDismiss = {
showDialog.value = false showDialog.value = false
@@ -358,6 +328,26 @@ fun Main() {
hazeState = hazeState hazeState = hazeState
) )
if (BuildConfig.PLAY_BUILD) {
PlayBypassSheet(
visible = showPlayBypassVisible.value,
onDismiss = {
showPlayBypassVisible.value = false
showDialog.value = true
},
onConfirm = {
showPlayBypassVisible.value = false
sharedPreferences.edit {
putBoolean("bypass_device_check.v2", true)
val intent = Intent(context, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
context.startActivity(intent)
}
},
backdrop = backdrop
)
}
return return
} }
@@ -371,8 +361,6 @@ fun Main() {
) )
} }
BillingManager.provider = BillingProviderFactory.create(context)
val bluetoothPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val bluetoothPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
listOf( listOf(
"android.permission.BLUETOOTH_CONNECT", "android.permission.BLUETOOTH_CONNECT",
@@ -508,7 +496,7 @@ fun Main() {
if (airPodsViewModel != null) VersionScreen(airPodsViewModel) if (airPodsViewModel != null) VersionScreen(airPodsViewModel)
} }
composable("hearing_protection") { composable("hearing_protection") {
if (airPodsViewModel != null) HearingProtectionScreen(airPodsViewModel) if (airPodsViewModel != null) HearingProtectionScreen(airPodsViewModel, navController)
} }
composable("purchase_screen") { composable("purchase_screen") {
val purchaseViewModel: PurchaseViewModel = viewModel() val purchaseViewModel: PurchaseViewModel = viewModel()
@@ -523,10 +511,6 @@ fun Main() {
navController.addOnDestinationChangedListener { _, destination, _ -> navController.addOnDestinationChangedListener { _, destination, _ ->
showBackButton.value = showBackButton.value =
destination.route != "settings" // && destination.route != "onboarding" destination.route != "settings" // && destination.route != "onboarding"
Log.d(
"MainActivity",
"Navigated to ${destination.route}, showBackButton: ${showBackButton.value}"
)
} }
} }

View File

@@ -26,4 +26,5 @@ interface BillingProvider {
val price: StateFlow<String> val price: StateFlow<String>
fun purchase(activity: Activity) fun purchase(activity: Activity)
fun queryPurchases() fun queryPurchases()
fun restorePurchases()
} }

View File

@@ -31,13 +31,13 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import me.kavishdevar.librepods.R
class FOSSBillingProvider(context: Context): BillingProvider { class FOSSBillingProvider(context: Context): BillingProvider {
private val _isPremium = MutableStateFlow(false) private val _isPremium = MutableStateFlow(false)
override val isPremium: StateFlow<Boolean> = _isPremium override val isPremium: StateFlow<Boolean> = _isPremium
private val _price = MutableStateFlow("Any") private val _price = MutableStateFlow(context.getString(R.string.name_your_own_price))
override val price: StateFlow<String> = _price override val price: StateFlow<String> = _price
private val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE) private val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
@@ -57,13 +57,11 @@ class FOSSBillingProvider(context: Context): BillingProvider {
purchaseJob?.cancel() purchaseJob?.cancel()
purchaseJob = scope.launch { purchaseJob = scope.launch {
delay(2_000) delay(5_000)
withContext(Dispatchers.Main) {
_isPremium.value = true _isPremium.value = true
sharedPreferences.edit { putBoolean("foss_upgraded", true) } sharedPreferences.edit { putBoolean("foss_upgraded", true) }
} }
} }
}
override fun queryPurchases() { override fun queryPurchases() {
val stored = sharedPreferences.getBoolean("foss_upgraded", false) val stored = sharedPreferences.getBoolean("foss_upgraded", false)
@@ -71,4 +69,9 @@ class FOSSBillingProvider(context: Context): BillingProvider {
_isPremium.value = stored _isPremium.value = stored
} }
} }
override fun restorePurchases() {
_isPremium.value = true
sharedPreferences.edit { putBoolean("foss_upgraded", true) }
}
} }

View File

@@ -162,21 +162,19 @@ class PlayBillingProvider(
it.purchaseState == Purchase.PurchaseState.PURCHASED it.purchaseState == Purchase.PurchaseState.PURCHASED
} }
// val purchase = purchases.find {
// val navigateToPurchase = purchases.find {
// it.products.contains(PREMIUM_PRODUCT_ID) && it.purchaseState == Purchase.PurchaseState.PURCHASED // it.products.contains(PREMIUM_PRODUCT_ID) && it.purchaseState == Purchase.PurchaseState.PURCHASED
// } // }
// //
// if (navigateToPurchase != null) { // if (purchase != null) {
// val consumeParams = ConsumeParams.newBuilder() // val consumeParams = ConsumeParams.newBuilder()
// .setPurchaseToken(navigateToPurchase.purchaseToken) // .setPurchaseToken(purchase.purchaseToken)
// .build() // .build()
// scope.launch { // scope.launch {
// billingClient.consumeAsync(consumeParams) { _, _ ->} // billingClient.consumeAsync(consumeParams) { _, _ ->}
// } // }
// } // }
_isPremium.value = hasPremium _isPremium.value = hasPremium
scope.launch { scope.launch {
@@ -201,4 +199,8 @@ class PlayBillingProvider(
queryExistingPurchases() queryExistingPurchases()
} }
} }
override fun restorePurchases() {
queryPurchases()
}
} }

View File

@@ -363,7 +363,13 @@ class AACPManager {
} }
val key = ByteArray(keyLength) val key = ByteArray(keyLength)
System.arraycopy(data, offset, key, 0, keyLength) System.arraycopy(data, offset, key, 0, keyLength)
try {
keys[ProximityKeyType.fromByte(keyType)] = key keys[ProximityKeyType.fromByte(keyType)] = key
} catch (e: Exception) {
Log.e(
TAG, "incorrect key type received: $keyType, ${key.toHexString()}"
)
}
offset += keyLength offset += keyLength
Log.d( Log.d(
TAG, "Parsed Proximity Key: Type: ${keyType}, Length: $keyLength, Key: ${ TAG, "Parsed Proximity Key: Type: ${keyType}, Length: $keyLength, Key: ${
@@ -908,7 +914,7 @@ class AACPManager {
) )
buffer.put(byteArrayOf(0x01, 0xE5.toByte(), 0x4A)) // unknown, constant buffer.put(byteArrayOf(0x01, 0xE5.toByte(), 0x4A)) // unknown, constant
buffer.put("PlayingApp".toByteArray()) buffer.put("PlayingApp".toByteArray())
buffer.put(byteArrayOf(0x56)) // 'V', seems like a identifier or a separator buffer.put(byteArrayOf(0x56)) // 'V', seems like an identifier or a separator
buffer.put("com.google.ios.youtube".toByteArray()) // package name, hardcoding for now, aforementioned reason buffer.put("com.google.ios.youtube".toByteArray()) // package name, hardcoding for now, aforementioned reason
buffer.put(byteArrayOf(0x52)) // 'R' buffer.put(byteArrayOf(0x52)) // 'R'
buffer.put("HostStreamingState".toByteArray()) buffer.put("HostStreamingState".toByteArray())

View File

@@ -72,7 +72,11 @@ class ATTManager(private val adapter: BluetoothAdapter, private val device: Blue
fun connect() { fun connect() {
val uuid = ParcelUuid.fromString("00000000-0000-0000-0000-000000000000") val uuid = ParcelUuid.fromString("00000000-0000-0000-0000-000000000000")
try {
socket = createBluetoothSocket(adapter, device, uuid) socket = createBluetoothSocket(adapter, device, uuid)
} catch (e: Exception) {
Log.w(TAG, "Failed to create socket")
}
try { try {
socket!!.connect() socket!!.connect()
} catch (e: Exception) { } catch (e: Exception) {
@@ -203,7 +207,7 @@ class ATTManager(private val adapter: BluetoothAdapter, private val device: Blue
private fun createBluetoothSocket(adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid): BluetoothSocket { private fun createBluetoothSocket(adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid): BluetoothSocket {
val type = 3 // L2CAP val type = 3 // L2CAP
val constructorSpecs = listOf( val constructorSpecs = listOf(
arrayOf(adapter, device, type, true, 31, uuid), arrayOf(adapter, device, type, true, true, 31, uuid),
arrayOf(device, type, true, true, 31, uuid), arrayOf(device, type, true, true, 31, uuid),
arrayOf(device, type, 1, true, true, 31, uuid), arrayOf(device, type, 1, true, true, 31, uuid),
arrayOf(type, 1, true, true, device, 31, uuid), arrayOf(type, 1, true, true, device, 31, uuid),

View File

@@ -72,6 +72,7 @@ enum class NoiseControlMode {
class AirPodsNotifications { class AirPodsNotifications {
companion object { companion object {
const val AIRPODS_CONNECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTED" const val AIRPODS_CONNECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTED"
const val AIRPODS_L2CAP_CONNECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTED"
const val AIRPODS_DATA = "me.kavishdevar.librepods.AIRPODS_DATA" const val AIRPODS_DATA = "me.kavishdevar.librepods.AIRPODS_DATA"
const val EAR_DETECTION_DATA = "me.kavishdevar.librepods.EAR_DETECTION_DATA" const val EAR_DETECTION_DATA = "me.kavishdevar.librepods.EAR_DETECTION_DATA"
const val ANC_DATA = "me.kavishdevar.librepods.ANC_DATA" const val ANC_DATA = "me.kavishdevar.librepods.ANC_DATA"

View File

@@ -0,0 +1,171 @@
package me.kavishdevar.librepods.presentation.components
import android.os.Build
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R
@Composable
fun DeviceInfoCard() {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val rowHeight = remember { mutableStateOf(0.dp) }
val density = LocalDensity.current
Column (
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = stringResource(R.string.device_info), style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
), modifier = Modifier.padding(start = 16.dp)
)
Column(
modifier = Modifier
.clip(RoundedCornerShape(28.dp))
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.onGloballyPositioned { coordinates ->
rowHeight.value = with(density) { coordinates.size.height.toDp() }
},
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.manufacturer), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = Build.MANUFACTURER, style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
alpha = 0.8f
),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.model_number), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = Build.MODEL, style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
alpha = 0.8f
),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.build_id), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = Build.DISPLAY, style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
alpha = 0.8f
),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.version), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = Build.ID + " (${Build.VERSION.SDK_INT_FULL})",
style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
alpha = 0.8f
),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
}
}

View File

@@ -0,0 +1,254 @@
package me.kavishdevar.librepods.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.kyant.backdrop.backdrops.LayerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur
import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.effects.vibrancy
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PlayBypassSheet(
visible: Boolean,
onDismiss: () -> Unit,
onConfirm: () -> Unit,
backdrop: LayerBackdrop
) {
if (!visible) return
val dark = isSystemInDarkTheme()
val contentColor = if (dark) Color.White else Color.Black
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var acknowledged by remember { mutableStateOf(false) }
val inputState = rememberTextFieldState("")
val isValid = acknowledged && inputState.text.trim() == "OK"
val sfPro = FontFamily(Font(R.font.sf_pro))
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = Color.Transparent,
dragHandle = { },
shape = RoundedCornerShape(48.dp),
scrimColor = Color.Transparent,
modifier = Modifier.padding(16.dp)
) {
val innerBackdrop = rememberLayerBackdrop()
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(48.dp))
.drawBackdrop(
backdrop = backdrop,
exportedBackdrop = innerBackdrop,
shape = { RoundedCornerShape(48.dp) },
effects = {
vibrancy()
blur(6f.dp.toPx())
lens(12f.dp.toPx(), 48f.dp.toPx(), true)
},
onDrawSurface = {
drawRect(
if (dark) Color.DarkGray.copy(alpha = 0.3f) else Color.White.copy(alpha = 0.6f)
)
}
)
.padding(24.dp)
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
text = stringResource(R.string.bypass_compatibility_check),
style = TextStyle(
fontFamily = sfPro,
fontWeight = FontWeight.SemiBold,
fontSize = 18.sp,
color = contentColor
),
modifier = Modifier.padding(horizontal = 12.dp)
)
Text(
text = stringResource(R.string.compatibility_play_dialog_confirmation),
style = TextStyle(
fontFamily = sfPro,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
color = contentColor
),
modifier = Modifier.padding(horizontal = 12.dp)
)
StyledSelectList(
items = listOf(
SelectItem(
name = stringResource(R.string.read_compatibility_requirements),
selected = acknowledged,
onClick = { acknowledged = !acknowledged }
)
)
)
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
LaunchedEffect(Unit) {
focusRequester.requestFocus()
keyboardController?.show()
}
val backgroundColor = if (dark) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (dark) Color.White else Color.Black
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.height(58.dp)
.background(
backgroundColor,
RoundedCornerShape(28.dp)
)
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
BasicTextField(
state = inputState,
textStyle = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
cursorBrush = SolidColor(textColor),
decorator = { innerTextField ->
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = Modifier
.weight(1f)
) {
Box {
if (inputState.text == "") {
Text(
text = stringResource(R.string.type_ok_to_continue, "OK"),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
fontFamily = sfPro,
color = textColor.copy(alpha = 0.8f)
)
)
}
innerTextField()
}
}
IconButton(
onClick = {
inputState.clearText()
}
) {
Text(
text = "􀁡",
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = if (dark) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f)
),
)
}
}
},
modifier = Modifier
.fillMaxWidth()
.padding(start = 8.dp)
.focusRequester(focusRequester)
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(24.dp)
) {
StyledButton(
onClick = onDismiss,
backdrop = innerBackdrop,
modifier = Modifier.weight(1f),
) {
Text(
text = stringResource(R.string.no),
style = TextStyle(
fontFamily = sfPro,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
color = contentColor
)
)
}
StyledButton(
onClick = onConfirm,
backdrop = innerBackdrop,
isInteractive = isValid,
modifier = Modifier.weight(1f),
enabled = isValid,
surfaceColor = if (dark) Color(0xFF0091FF) else Color(0xFF0088FF)
) {
Text(
text = stringResource(R.string.proceed),
style = TextStyle(
fontFamily = sfPro,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
color = if (isValid) contentColor else contentColor.copy(alpha = 0.4f)
)
)
}
}
}
}
}
}

View File

@@ -77,8 +77,10 @@ fun StyledButton(
tint: Color = Color.Unspecified, tint: Color = Color.Unspecified,
surfaceColor: Color = Color.Unspecified, surfaceColor: Color = Color.Unspecified,
maxScale: Float = 0.1f, maxScale: Float = 0.1f,
enabled: Boolean = true,
content: @Composable RowScope.() -> Unit, content: @Composable RowScope.() -> Unit,
) { ) {
val isInteractive = enabled && isInteractive
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val haptics = LocalHapticFeedback.current val haptics = LocalHapticFeedback.current
val progressAnimation = remember { Animatable(0f) } val progressAnimation = remember { Animatable(0f) }
@@ -125,8 +127,8 @@ half4 main(float2 coord) {
} else { } else {
drawRect(Color.White.copy(0.1f)) drawRect(Color.White.copy(0.1f))
} }
if (surfaceColor.isSpecified) { if (surfaceColor.isSpecified && enabled) {
val color = if (!isInteractive && isPressed) { val color = if (isPressed) {
Color( Color(
red = surfaceColor.red * 0.5f, red = surfaceColor.red * 0.5f,
green = surfaceColor.green * 0.5f, green = surfaceColor.green * 0.5f,
@@ -137,6 +139,11 @@ half4 main(float2 coord) {
surfaceColor surfaceColor
} }
drawRect(color) drawRect(color)
} else {
if (isPressed) {
drawRect(Color.Black.copy(alpha = 0.4f))
drawRect(Color.White.copy(alpha = 0.2f))
}
} }
}, },
onDrawFront = null, onDrawFront = null,
@@ -245,9 +252,11 @@ half4 main(float2 coord) {
indication = null, indication = null,
role = Role.Button, role = Role.Button,
onClick = { onClick = {
if (enabled) {
haptics.performHapticFeedback(HapticFeedbackType.ContextClick) haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
onClick() onClick()
} }
}
) )
.then( .then(
if (isInteractive) { if (isInteractive) {
@@ -302,9 +311,11 @@ half4 main(float2 coord) {
isPressed = false isPressed = false
}, },
onTap = { onTap = {
if (enabled) {
haptics.performHapticFeedback(HapticFeedbackType.ContextClick) haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
onClick() onClick()
} }
}
) )
} }
} }

View File

@@ -39,7 +39,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -74,7 +73,6 @@ fun StyledSelectList(
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val scope = rememberCoroutineScope()
val haptics = LocalHapticFeedback.current val haptics = LocalHapticFeedback.current
Column( Column(

View File

@@ -20,6 +20,7 @@ package me.kavishdevar.librepods.presentation.screens
// import me.kavishdevar.librepods.utils.RadareOffsetFinder // import me.kavishdevar.librepods.utils.RadareOffsetFinder
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.util.Log
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
@@ -39,6 +40,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
@@ -71,6 +73,10 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager import me.kavishdevar.librepods.bluetooth.AACPManager
@@ -85,8 +91,8 @@ import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
//private var phoneMediaDebounceJob: Job? = null private var phoneMediaDebounceJob: Job? = null
//private var toneVolumeDebounceJob: Job? = null private var toneVolumeDebounceJob: Job? = null
//private const val TAG = "AccessibilitySettings" //private const val TAG = "AccessibilitySettings"
@SuppressLint("DefaultLocale") @SuppressLint("DefaultLocale")
@@ -99,7 +105,13 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val hearingAidEnabled = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(1)?.toInt() == 1 && state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(0)?.toInt() == 1 val hearingAidEnabled =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(
1
)
?.toInt() == 1 && state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(
0
)?.toInt() == 1
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
@@ -125,7 +137,7 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
backdrop = rememberLayerBackdrop(), backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f, maxScale = 0.05f,
tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) { ) {
Text( Text(
stringResource(R.string.unlock_advanced_features), stringResource(R.string.unlock_advanced_features),
@@ -149,7 +161,10 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
2.toByte() to stringResource(R.string.slowest) 2.toByte() to stringResource(R.string.slowest)
) )
val selectedPressSpeedValue = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL]?.getOrNull(0) val selectedPressSpeedValue =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL]?.getOrNull(
0
)
var selectedPressSpeed by remember { var selectedPressSpeed by remember {
mutableStateOf( mutableStateOf(
pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0] pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0]
@@ -162,7 +177,10 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
2.toByte() to stringResource(R.string.slowest) 2.toByte() to stringResource(R.string.slowest)
) )
val selectedPressAndHoldDurationValue = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL]?.getOrNull(0) val selectedPressAndHoldDurationValue =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL]?.getOrNull(
0
)
var selectedPressAndHoldDuration by remember { var selectedPressAndHoldDuration by remember {
mutableStateOf( mutableStateOf(
pressAndHoldDurationOptions[selectedPressAndHoldDurationValue] pressAndHoldDurationOptions[selectedPressAndHoldDurationValue]
@@ -175,7 +193,10 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
2.toByte() to stringResource(R.string.longer), 2.toByte() to stringResource(R.string.longer),
3.toByte() to stringResource(R.string.longest) 3.toByte() to stringResource(R.string.longest)
) )
val selectedVolumeSwipeSpeedValue = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL]?.getOrNull(0) val selectedVolumeSwipeSpeedValue =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL]?.getOrNull(
0
)
var selectedVolumeSwipeSpeed by remember { var selectedVolumeSwipeSpeed by remember {
mutableStateOf( mutableStateOf(
volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue] volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue]
@@ -183,33 +204,34 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
) )
} }
// LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) { val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
// phoneMediaDebounceJob?.cancel() val phoneEQEnabled = remember { mutableStateOf(false) }
// phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch { val mediaEQEnabled = remember { mutableStateOf(false) }
// delay(150)
// val manager = ServiceManager.getService()?.aacpManager LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) {
// if (manager == null) { phoneMediaDebounceJob?.cancel()
// Log.w(TAG, "Cannot write EQ: AACPManager not available") phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch {
// return@launch delay(150)
// } try {
// try { val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte()
// val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte() val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte()
// val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte() Log.d(
// Log.d( "AccessibilitySettingsScreen",
// TAG, "Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})"
// "Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})" )
// ) viewModel.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte)
// manager.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte) } catch (e: Exception) {
// } catch (e: Exception) { Log.w(
// Log.w(TAG, "Error sending phone/media EQ: ${e.message}") "AccessibilitySettingsScreen",
// } "Error sending phone/media EQ: ${e.message}"
// } )
// } }
}
}
Box( Box(
modifier = Modifier.then( modifier = Modifier.then(
if (!state.isPremium) { if (!state.isPremium) {
Modifier Modifier.pointerInput(Unit) {
.pointerInput(Unit) {
awaitPointerEventScope { awaitPointerEventScope {
while (true) { while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial) val event = awaitPointerEvent(PointerEventPass.Initial)
@@ -217,9 +239,7 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
} }
} }
} }
} else Modifier } else Modifier)) {
)
) {
DropdownMenuComponent( DropdownMenuComponent(
label = stringResource(R.string.press_speed), label = stringResource(R.string.press_speed),
description = stringResource(R.string.press_speed_description), description = stringResource(R.string.press_speed_description),
@@ -242,8 +262,7 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
Box( Box(
modifier = Modifier.then( modifier = Modifier.then(
if (!state.isPremium) { if (!state.isPremium) {
Modifier Modifier.pointerInput(Unit) {
.pointerInput(Unit) {
awaitPointerEventScope { awaitPointerEventScope {
while (true) { while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial) val event = awaitPointerEvent(PointerEventPass.Initial)
@@ -251,9 +270,7 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
} }
} }
} }
} else Modifier } else Modifier)) {
)
) {
DropdownMenuComponent( DropdownMenuComponent(
label = stringResource(R.string.press_and_hold_duration), label = stringResource(R.string.press_and_hold_duration),
description = stringResource(R.string.press_and_hold_duration_description), description = stringResource(R.string.press_and_hold_duration_description),
@@ -278,8 +295,14 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
label = stringResource(R.string.noise_cancellation_single_airpod), label = stringResource(R.string.noise_cancellation_single_airpod),
description = stringResource(R.string.noise_cancellation_single_airpod_description), description = stringResource(R.string.noise_cancellation_single_airpod_description),
independent = true, independent = true,
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE]?.getOrNull(0) == 0x01.toByte(), checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE]?.getOrNull(
onCheckedChange = { viewModel.setControlCommandBoolean(AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE, it) }, 0
) == 0x01.toByte(),
onCheckedChange = {
viewModel.setControlCommandBoolean(
AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE, it
)
},
enabled = state.isPremium enabled = state.isPremium
) )
@@ -288,7 +311,12 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
label = stringResource(R.string.loud_sound_reduction), label = stringResource(R.string.loud_sound_reduction),
description = stringResource(R.string.loud_sound_reduction_description), description = stringResource(R.string.loud_sound_reduction_description),
checked = state.loudSoundReductionEnabled, checked = state.loudSoundReductionEnabled,
onCheckedChange = { viewModel.setATTCharacteristicValue(ATTHandles.LOUD_SOUND_REDUCTION, if (it) byteArrayOf(0x01) else byteArrayOf(0x00)) }, onCheckedChange = {
viewModel.setATTCharacteristicValue(
ATTHandles.LOUD_SOUND_REDUCTION,
if (it) byteArrayOf(0x01) else byteArrayOf(0x00)
)
},
enabled = state.isPremium enabled = state.isPremium
) )
} }
@@ -302,13 +330,19 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
) )
} }
val toneVolumeValue = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME]?.getOrNull(0)?.toFloat() ?: 75f val toneVolumeValue =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME]?.getOrNull(
0
)?.toFloat() ?: 75f
StyledSlider( StyledSlider(
label = stringResource(R.string.tone_volume), label = stringResource(R.string.tone_volume),
description = stringResource(R.string.tone_volume_description), description = stringResource(R.string.tone_volume_description),
value = toneVolumeValue, value = toneVolumeValue,
onValueChange = { onValueChange = {
viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME, byteArrayOf(it.toInt().toByte(), 0x50)) viewModel.setControlCommandValue(
AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME,
byteArrayOf(it.toInt().toByte(), 0x50)
)
}, },
valueRange = 0f..100f, valueRange = 0f..100f,
snapPoints = listOf(75f), snapPoints = listOf(75f),
@@ -319,20 +353,26 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
) )
if (state.capabilities.contains(Capability.SWIPE_FOR_VOLUME)) { if (state.capabilities.contains(Capability.SWIPE_FOR_VOLUME)) {
val volumeSwipeEnabled = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE]?.getOrNull(0)?.toInt() == 0x01 val volumeSwipeEnabled =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE]?.getOrNull(
0
)?.toInt() == 0x01
StyledToggle( StyledToggle(
label = stringResource(R.string.volume_control), label = stringResource(R.string.volume_control),
description = stringResource(R.string.volume_control_description), description = stringResource(R.string.volume_control_description),
checked = volumeSwipeEnabled, checked = volumeSwipeEnabled,
onCheckedChange = { viewModel.setControlCommandBoolean(AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE, it) }, onCheckedChange = {
viewModel.setControlCommandBoolean(
AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE, it
)
},
enabled = state.isPremium enabled = state.isPremium
) )
Box( Box(
modifier = Modifier.then( modifier = Modifier.then(
if (!state.isPremium) { if (!state.isPremium) {
Modifier Modifier.pointerInput(Unit) {
.pointerInput(Unit) {
awaitPointerEventScope { awaitPointerEventScope {
while (true) { while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial) val event = awaitPointerEvent(PointerEventPass.Initial)
@@ -340,9 +380,7 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
} }
} }
} }
} else Modifier } else Modifier)) {
)
) {
DropdownMenuComponent( DropdownMenuComponent(
label = stringResource(R.string.volume_swipe_speed), label = stringResource(R.string.volume_swipe_speed),
description = stringResource(R.string.volume_swipe_speed_description), description = stringResource(R.string.volume_swipe_speed_description),
@@ -364,21 +402,22 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
} }
} }
// if (!hearingAidEnabled.value&& BuildConfig.FLAVOR == "xposed") { // if (!hearingAidEnabled && BuildConfig.FLAVOR == "xposed") {
// Text( // Text(
// text = stringResource(R.string.apply_eq_to), // text = stringResource(R.string.apply_eq_to), style = TextStyle(
// style = TextStyle(
// fontSize = 14.sp, // fontSize = 14.sp,
// fontWeight = FontWeight.Bold, // fontWeight = FontWeight.Bold,
// color = textColor.copy(alpha = 0.6f), // color = textColor.copy(alpha = 0.6f),
// fontFamily = FontFamily(Font(R.font.sf_pro)) // fontFamily = FontFamily(Font(R.font.sf_pro))
// ), // ), modifier = Modifier.padding(8.dp, bottom = 0.dp)
// modifier = Modifier.padding(8.dp, bottom = 0.dp)
// ) // )
// Column( // Column(
// modifier = Modifier // modifier = Modifier
// .fillMaxWidth() // .fillMaxWidth()
// .background(backgroundColor, RoundedCornerShape(28.dp)) // .background(
// if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF),
// RoundedCornerShape(28.dp)
// )
// .padding(vertical = 0.dp) // .padding(vertical = 0.dp)
// ) { // ) {
// val darkModeLocal = isSystemInDarkTheme() // val darkModeLocal = isSystemInDarkTheme()
@@ -405,17 +444,19 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
// detectTapGestures( // detectTapGestures(
// onPress = { // onPress = {
// phoneBackgroundColor = // phoneBackgroundColor =
// if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9) // if (darkModeLocal) Color(0x40888888) else Color(
// 0x40D9D9D9
// )
// tryAwaitRelease() // tryAwaitRelease()
// phoneBackgroundColor = // phoneBackgroundColor =
// if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) // if (darkModeLocal) Color(0xFF1C1C1E) else Color(
// phoneEQEnabled.value = !phoneEQEnabled.value // 0xFFFFFFFF
// }
// ) // )
// phoneEQEnabled.value = !phoneEQEnabled.value
// })
// } // }
// .padding(horizontal = 16.dp), // .padding(horizontal = 16.dp),
// verticalAlignment = Alignment.CenterVertically // verticalAlignment = Alignment.CenterVertically) {
// ) {
// Text( // Text(
// stringResource(R.string.phone), // stringResource(R.string.phone),
// fontSize = 16.sp, // fontSize = 16.sp,
@@ -441,8 +482,7 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
// } // }
// //
// HorizontalDivider( // HorizontalDivider(
// thickness = 1.dp, // thickness = 1.dp, color = Color(0x40888888)
// color = Color(0x40888888)
// ) // )
// //
// val mediaShape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp) // val mediaShape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)
@@ -467,17 +507,19 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
// detectTapGestures( // detectTapGestures(
// onPress = { // onPress = {
// mediaBackgroundColor = // mediaBackgroundColor =
// if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9) // if (darkModeLocal) Color(0x40888888) else Color(
// 0x40D9D9D9
// )
// tryAwaitRelease() // tryAwaitRelease()
// mediaBackgroundColor = // mediaBackgroundColor =
// if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) // if (darkModeLocal) Color(0xFF1C1C1E) else Color(
// mediaEQEnabled.value = !mediaEQEnabled.value // 0xFFFFFFFF
// }
// ) // )
// mediaEQEnabled.value = !mediaEQEnabled.value
// })
// } // }
// .padding(horizontal = 16.dp), // .padding(horizontal = 16.dp),
// verticalAlignment = Alignment.CenterVertically // verticalAlignment = Alignment.CenterVertically) {
// ) {
// Text( // Text(
// stringResource(R.string.media), // stringResource(R.string.media),
// fontSize = 16.sp, // fontSize = 16.sp,
@@ -502,15 +544,21 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
// ) // )
// } // }
// } // }
//
// EQ Settings. Don't seem to have an effect? //// EQ Settings. Don't seem to have an effect?
// Column( // Column(
// modifier = Modifier // modifier = Modifier
// .fillMaxWidth() // .fillMaxWidth()
// .background(backgroundColor, RoundedCornerShape(28.dp)) // .background(
// .padding(12.dp), // if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF),
// horizontalAlignment = Alignment.CenterHorizontally // RoundedCornerShape(28.dp)
// )
// .padding(12.dp), horizontalAlignment = Alignment.CenterHorizontally
// ) { // ) {
// val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
// val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
// val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
//
// for (i in 0 until 8) { // for (i in 0 until 8) {
// val eqPhoneValue = // val eqPhoneValue =
// remember(phoneMediaEQ.value[i]) { mutableFloatStateOf(phoneMediaEQ.value[i]) } // remember(phoneMediaEQ.value[i]) { mutableFloatStateOf(phoneMediaEQ.value[i]) }
@@ -527,7 +575,7 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
// color = textColor, // color = textColor,
// modifier = Modifier.padding(bottom = 4.dp) // modifier = Modifier.padding(bottom = 4.dp)
// ) // )
//
// Slider( // Slider(
// value = eqPhoneValue.floatValue, // value = eqPhoneValue.floatValue,
// onValueChange = { newVal -> // onValueChange = { newVal ->
@@ -559,8 +607,7 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
// .fillMaxWidth() // .fillMaxWidth()
// .height(12.dp), // .height(12.dp),
// contentAlignment = Alignment.CenterStart // contentAlignment = Alignment.CenterStart
// ) // ) {
// {
// Box( // Box(
// modifier = Modifier // modifier = Modifier
// .fillMaxWidth() // .fillMaxWidth()
@@ -571,12 +618,13 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
// modifier = Modifier // modifier = Modifier
// .fillMaxWidth(eqPhoneValue.floatValue / 100f) // .fillMaxWidth(eqPhoneValue.floatValue / 100f)
// .height(4.dp) // .height(4.dp)
// .background(activeTrackColor, RoundedCornerShape(4.dp)) // .background(
// activeTrackColor, RoundedCornerShape(4.dp)
// )
// ) // )
// } // }
// } // })
// ) //
// Text( // Text(
// text = stringResource(R.string.band_label, i + 1), // text = stringResource(R.string.band_label, i + 1),
// fontSize = 12.sp, // fontSize = 12.sp,
@@ -585,6 +633,7 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
// ) // )
// } // }
// } // }
// }
// } // }
Spacer(modifier = Modifier.height(bottomPadding)) Spacer(modifier = Modifier.height(bottomPadding))
} }
@@ -630,13 +679,13 @@ private fun DropdownMenuComponent(
} else Modifier } else Modifier
) )
.background( .background(
if (independent) (if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) else Color.Transparent, if (independent) (if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(
0xFFFFFFFF
)) else Color.Transparent,
if (independent) RoundedCornerShape(28.dp) else RoundedCornerShape(0.dp)
) then (if (independent) Modifier.padding(horizontal = 4.dp) else Modifier).clip(
if (independent) RoundedCornerShape(28.dp) else RoundedCornerShape(0.dp) if (independent) RoundedCornerShape(28.dp) else RoundedCornerShape(0.dp)
) )
then(
if (independent) Modifier.padding(horizontal = 4.dp) else Modifier
)
.clip(if (independent) RoundedCornerShape(28.dp) else RoundedCornerShape(0.dp))
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -658,8 +707,7 @@ private fun DropdownMenuComponent(
} }
} }
.pointerInput(Unit) { .pointerInput(Unit) {
detectDragGesturesAfterLongPress( detectDragGesturesAfterLongPress(onDragStart = { offset ->
onDragStart = { offset ->
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
touchOffset = offset touchOffset = offset
if (!expanded && now - lastDismissTime > 250L) { if (!expanded && now - lastDismissTime > 250L) {
@@ -668,19 +716,21 @@ private fun DropdownMenuComponent(
lastDismissTime = now lastDismissTime = now
parentDragActive = true parentDragActive = true
parentHoveredIndex = 0 parentHoveredIndex = 0
}, }, onDrag = { change, _ ->
onDrag = { change, _ ->
val current = change.position val current = change.position
val touch = touchOffset ?: current val touch = touchOffset ?: current
val posInPopupY = current.y - touch.y val posInPopupY = current.y - touch.y
val idx = (posInPopupY / itemHeightPx).toInt() val idx = (posInPopupY / itemHeightPx).toInt()
if (idx != previousIdx) { if (idx != previousIdx) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) } scope.launch {
haptics.performHapticFeedback(
HapticFeedbackType.SegmentTick
)
}
} }
parentHoveredIndex = idx parentHoveredIndex = idx
previousIdx = idx previousIdx = idx
}, }, onDragEnd = {
onDragEnd = {
parentDragActive = false parentDragActive = false
parentHoveredIndex?.let { idx -> parentHoveredIndex?.let { idx ->
if (idx in options.indices) { if (idx in options.indices) {
@@ -690,15 +740,17 @@ private fun DropdownMenuComponent(
} }
} }
if (parentHoveredIndex != null && parentHoveredIndex in options.indices) { if (parentHoveredIndex != null && parentHoveredIndex in options.indices) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) } scope.launch {
haptics.performHapticFeedback(
HapticFeedbackType.GestureEnd
)
}
} }
parentHoveredIndex = null parentHoveredIndex = null
}, }, onDragCancel = {
onDragCancel = {
parentDragActive = false parentDragActive = false
parentHoveredIndex = null parentHoveredIndex = null
} })
)
}, },
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
@@ -714,42 +766,35 @@ private fun DropdownMenuComponent(
) )
if (!independent && description != null) { if (!independent && description != null) {
Text( Text(
text = description, text = description, style = TextStyle(
style = TextStyle(
fontSize = 12.sp, fontSize = 12.sp,
fontWeight = FontWeight.Light, fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f), color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)) fontFamily = FontFamily(Font(R.font.sf_pro))
), ), modifier = Modifier.padding(16.dp, top = 0.dp, bottom = 2.dp)
modifier = Modifier.padding(16.dp, top = 0.dp, bottom = 2.dp)
) )
} }
} }
Box( Box(
modifier = Modifier.onGloballyPositioned { coordinates -> modifier = Modifier.onGloballyPositioned { coordinates ->
boxPosition = coordinates.positionInParent() boxPosition = coordinates.positionInParent()
} }) {
) {
Row( Row(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Text( Text(
text = selectedOption, text = selectedOption, style = TextStyle(
style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
color = textColor.copy(alpha = 0.8f), color = textColor.copy(alpha = 0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro)) fontFamily = FontFamily(Font(R.font.sf_pro))
) )
) )
Text( Text(
text = "􀆏", text = "􀆏", style = TextStyle(
style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
color = textColor.copy(alpha = 0.6f), color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)) fontFamily = FontFamily(Font(R.font.sf_pro))
), ), modifier = Modifier.padding(start = 6.dp)
modifier = Modifier
.padding(start = 6.dp)
) )
} }
@@ -779,14 +824,17 @@ private fun DropdownMenuComponent(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.background(if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7)) .background(
if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7)
)
) { ) {
Text( Text(
text = description, text = description, style = TextStyle(
style = TextStyle(
fontSize = 12.sp, fontSize = 12.sp,
fontWeight = FontWeight.Light, fontWeight = FontWeight.Light,
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f), color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(
alpha = 0.6f
),
fontFamily = FontFamily(Font(R.font.sf_pro)) fontFamily = FontFamily(Font(R.font.sf_pro))
) )
) )

View File

@@ -81,7 +81,7 @@ fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel, navController: NavContro
backdrop = rememberLayerBackdrop(), backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f, maxScale = 0.05f,
tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) { ) {
Text( Text(
stringResource(R.string.unlock_advanced_features), stringResource(R.string.unlock_advanced_features),

View File

@@ -239,13 +239,11 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl
item(key = "spacer_call") { Spacer(modifier = Modifier.height(16.dp)) } item(key = "spacer_call") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "call_control") { item(key = "call_control") {
val flipped = val bytes = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG]?.take(2)?.toByteArray() ?: byteArrayOf(0x00, 0x00)
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG]?.take( val flipped = bytes[1] == 0x02.toByte()
2
)?.equals(byteArrayOf(0x00.toByte(), 0x02.toByte()))
CallControlSettings( CallControlSettings(
hazeState = hazeState, hazeState = hazeState,
flipped = flipped == true, flipped = flipped,
onCallControlValueChanged = { onCallControlValueChanged = {
viewModel.setControlCommandValue( viewModel.setControlCommandValue(
AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG, AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG,
@@ -277,7 +275,7 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl
backdrop = rememberLayerBackdrop(), backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f, maxScale = 0.05f,
tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color( surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(
0xFFE59900 0xFFE59900
) )
) { ) {

View File

@@ -19,6 +19,7 @@
package me.kavishdevar.librepods.presentation.screens package me.kavishdevar.librepods.presentation.screens
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -71,13 +72,13 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import me.kavishdevar.librepods.BuildConfig import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.DeviceInfoCard
import me.kavishdevar.librepods.presentation.components.NavigationButton import me.kavishdevar.librepods.presentation.components.NavigationButton
import me.kavishdevar.librepods.presentation.components.StyledButton import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledSlider import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
import java.util.Locale.getDefault
@Composable @Composable
fun AppSettingsScreen( fun AppSettingsScreen(
@@ -106,7 +107,7 @@ fun AppSettingsScreen(
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
if (!state.isPremium) { if (!state.isPremium && state.connectionSuccessful) {
StyledButton( StyledButton(
onClick = { onClick = {
navController.navigate("purchase_screen") navController.navigate("purchase_screen")
@@ -114,7 +115,7 @@ fun AppSettingsScreen(
backdrop = rememberLayerBackdrop(), backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f, maxScale = 0.05f,
tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) { ) {
Text( Text(
stringResource(R.string.unlock_advanced_features), stringResource(R.string.unlock_advanced_features),
@@ -128,6 +129,7 @@ fun AppSettingsScreen(
} }
} }
if (state.connectionSuccessful) {
StyledToggle( StyledToggle(
title = stringResource(R.string.widget), title = stringResource(R.string.widget),
label = stringResource(R.string.show_phone_battery_in_widget), label = stringResource(R.string.show_phone_battery_in_widget),
@@ -195,29 +197,33 @@ fun AppSettingsScreen(
snapPoints = listOf(44f), snapPoints = listOf(44f),
startLabel = "10%", startLabel = "10%",
endLabel = "85%", endLabel = "85%",
onValueChange = { newValue -> viewModel.setConversationalAwarenessVolume(newValue) }, onValueChange = { newValue ->
viewModel.setConversationalAwarenessVolume(
newValue
)
},
independent = true, independent = true,
enabled = state.isPremium enabled = state.isPremium
) )
if (!BuildConfig.PLAY_BUILD) { // if (!BuildConfig.PLAY_BUILD) {
Spacer(modifier = Modifier.height(16.dp)) // Spacer(modifier = Modifier.height(16.dp))
//
NavigationButton( // NavigationButton(
to = "", // to = "",
title = stringResource(R.string.camera_control), // title = stringResource(R.string.camera_control),
name = stringResource(R.string.set_custom_camera_package), // name = stringResource(R.string.set_custom_camera_package),
navController = navController, // navController = navController,
onClick = { // onClick = {
if (state.isPremium) viewModel.setShowCameraDialog(true) // if (state.isPremium) viewModel.setShowCameraDialog(true)
}, // },
independent = true, // independent = true,
description = stringResource(R.string.camera_control_app_description) // description = stringResource(R.string.camera_control_app_description)
) // )
} // }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
if (BuildConfig.FLAVOR == "xposed") { if (context.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) {
StyledToggle( StyledToggle(
title = stringResource(R.string.ear_detection), title = stringResource(R.string.ear_detection),
label = stringResource(R.string.disconnect_when_not_wearing), label = stringResource(R.string.disconnect_when_not_wearing),
@@ -361,16 +367,33 @@ fun AppSettingsScreen(
independent = true, independent = true,
enabled = state.isPremium enabled = state.isPremium
) )
} else {
Text(
text = stringResource(R.string.customizations_unavailable),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor.copy(alpha = 0.6f),
),
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(top = 16.dp)
)
}
if (BuildConfig.FLAVOR == "xposed") { if (BuildConfig.FLAVOR == "xposed") {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
val restartBluetoothText = stringResource(R.string.found_offset_restart_bluetooth) val restartBluetoothText =
stringResource(R.string.found_offset_restart_bluetooth)
StyledToggle( StyledToggle(
label = stringResource(R.string.act_as_an_apple_device), label = stringResource(R.string.act_as_an_apple_device) + " (${
description = stringResource(R.string.act_as_an_apple_device_description) + "\n" + stringResource( stringResource(
R.string.requires_xposed R.string.requires_xposed
).replaceFirstChar { if (it.isLowerCase()) it.titlecase(getDefault()) else it.toString() }, )
})",
description = stringResource(R.string.act_as_an_apple_device_description),
checked = state.vendorIdHook, checked = state.vendorIdHook,
onCheckedChange = { enabled -> onCheckedChange = { enabled ->
Toast.makeText(context, restartBluetoothText, Toast.LENGTH_SHORT).show() Toast.makeText(context, restartBluetoothText, Toast.LENGTH_SHORT).show()
@@ -381,8 +404,8 @@ fun AppSettingsScreen(
) )
} }
if (!BuildConfig.PLAY_BUILD) { if (!BuildConfig.PLAY_BUILD) {
Spacer(modifier = Modifier.height(16.dp))
NavigationButton( NavigationButton(
to = "troubleshooting", to = "troubleshooting",
name = stringResource(R.string.troubleshooting), name = stringResource(R.string.troubleshooting),
@@ -420,15 +443,16 @@ fun AppSettingsScreen(
val intent = Intent(Intent.ACTION_SENDTO).apply { val intent = Intent(Intent.ACTION_SENDTO).apply {
data = "mailto:".toUri() data = "mailto:".toUri()
putExtra(Intent.EXTRA_EMAIL, arrayOf("contact@kavish.xyz")) putExtra(Intent.EXTRA_EMAIL, arrayOf("contact@kavish.xyz"))
putExtra(Intent.EXTRA_SUBJECT, "LibrePods: ") putExtra(Intent.EXTRA_SUBJECT, "LibrePods: <SUBJECT>")
putExtra( putExtra(
Intent.EXTRA_TEXT, Intent.EXTRA_TEXT,
"\n\n\n----------" + "Describe your issue here:" +
"\n\n\n\n----------" +
"\nPhone details:" + "\nPhone details:" +
"\nDEVICE: ${Build.DEVICE}" + "\nMANUFACTURER: ${Build.MANUFACTURER}" +
"\nMANUFACTURER: ${Build.MANUFACTURER} (${Build.BRAND})" +
"\nMODEL: ${Build.MODEL} (${Build.PRODUCT})" + "\nMODEL: ${Build.MODEL} (${Build.PRODUCT})" +
"\nVERSION: ${Build.DISPLAY} (${Build.VERSION.SDK_INT_FULL})" + "\nDISPLAY_VERSION: ${Build.DISPLAY} (${Build.PRODUCT})" +
"\nID: ${Build.ID} (SDK ${Build.VERSION.SDK_INT_FULL})" +
"\n\nApp details:" + "\n\nApp details:" +
"\nVERSION: ${BuildConfig.VERSION_NAME}" + "\nVERSION: ${BuildConfig.VERSION_NAME}" +
"\nVERSION_CODE: ${BuildConfig.VERSION_CODE}" + "\nVERSION_CODE: ${BuildConfig.VERSION_CODE}" +
@@ -480,7 +504,8 @@ fun AppSettingsScreen(
) )
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(20.dp))
DeviceInfoCard()
Text( Text(
text = stringResource(R.string.about), style = TextStyle( text = stringResource(R.string.about), style = TextStyle(
@@ -488,7 +513,7 @@ fun AppSettingsScreen(
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f), color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)) fontFamily = FontFamily(Font(R.font.sf_pro))
), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp) ), modifier = Modifier.padding(start = 16.dp, bottom = 2.dp, top = 24.dp)
) )
val rowHeight = remember { mutableStateOf(0.dp) } val rowHeight = remember { mutableStateOf(0.dp) }

View File

@@ -175,7 +175,7 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController
backdrop = rememberLayerBackdrop(), backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f, maxScale = 0.05f,
tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) { ) {
Text( Text(
stringResource(R.string.unlock_advanced_features), stringResource(R.string.unlock_advanced_features),

View File

@@ -18,29 +18,39 @@
package me.kavishdevar.librepods.presentation.screens package me.kavishdevar.librepods.presentation.screens
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.bluetooth.AACPManager import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.ATTHandles import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@Composable @Composable
fun HearingProtectionScreen(viewModel: AirPodsViewModel) { fun HearingProtectionScreen(viewModel: AirPodsViewModel, navController: NavController) {
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
StyledScaffold( StyledScaffold(
@@ -53,7 +63,27 @@ fun HearingProtectionScreen(viewModel: AirPodsViewModel) {
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
) { ) {
Spacer(modifier = Modifier.height(spacerHeight)) Spacer(modifier = Modifier.height(spacerHeight))
if (!state.isPremium) {
StyledButton(
onClick = {
navController.navigate("purchase_screen")
},
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) {
Text(
stringResource(R.string.unlock_advanced_features),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color.White
),
)
}
}
if (state.vendorIdHook) { if (state.vendorIdHook) {
StyledToggle( StyledToggle(
title = stringResource(R.string.environmental_noise), title = stringResource(R.string.environmental_noise),

View File

@@ -130,7 +130,7 @@ fun LongPress(viewModel: AirPodsViewModel, name: String, navController: NavContr
backdrop = rememberLayerBackdrop(), backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f, maxScale = 0.05f,
tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) { ) {
Text( Text(
stringResource(R.string.unlock_advanced_features), stringResource(R.string.unlock_advanced_features),

View File

@@ -99,7 +99,7 @@ fun PurchaseScreen(
.padding(horizontal = 16.dp, vertical = 4.dp) .padding(horizontal = 16.dp, vertical = 4.dp)
) { ) {
Text( Text(
text = "Free features", text = stringResource(R.string.free_features),
style = TextStyle( style = TextStyle(
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
@@ -242,7 +242,7 @@ fun PurchaseScreen(
.padding(horizontal = 16.dp, vertical = 4.dp) .padding(horizontal = 16.dp, vertical = 4.dp)
) { ) {
Text( Text(
text = "Advanced features", text = stringResource(R.string.advanced_features),
style = TextStyle( style = TextStyle(
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
@@ -288,6 +288,36 @@ fun PurchaseScreen(
modifier = Modifier modifier = Modifier
.padding(horizontal = 12.dp) .padding(horizontal = 12.dp)
) )
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = stringResource(R.string.digital_assistant_on_long_press),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
)
)
Text(
text = stringResource(R.string.digital_assistant_on_long_press_description),
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -456,10 +486,11 @@ fun PurchaseScreen(
backdrop = rememberLayerBackdrop(), backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f, maxScale = 0.05f,
tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) surfaceColor = if (isSystemInDarkTheme()) Color(0xFF0091FF)
else Color(0xFF0088FF) // if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) { ) {
Text( Text(
stringResource(R.string.buy), stringResource(R.string.buy_price, state.price),
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
@@ -478,6 +509,7 @@ fun PurchaseScreen(
backdrop = rememberLayerBackdrop(), backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f, maxScale = 0.05f,
isInteractive = false
) { ) {
Text( Text(
stringResource(R.string.restore_purchases), stringResource(R.string.restore_purchases),

View File

@@ -56,8 +56,6 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.edit import androidx.core.content.edit
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledScaffold import me.kavishdevar.librepods.presentation.components.StyledScaffold
@@ -79,15 +77,12 @@ fun RenameScreen(viewModel: AirPodsViewModel) {
name.value = name.value.copy(selection = TextRange(name.value.text.length)) name.value = name.value.copy(selection = TextRange(name.value.text.length))
} }
val backdrop = rememberLayerBackdrop()
StyledScaffold( StyledScaffold(
title = stringResource(R.string.name), title = stringResource(R.string.name),
) { spacerHeight -> ) { spacerHeight ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.layerBackdrop(backdrop)
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
) { ) {
Spacer(modifier = Modifier.height(spacerHeight)) Spacer(modifier = Modifier.height(spacerHeight))

View File

@@ -23,7 +23,6 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.SharedPreferences import android.content.SharedPreferences
import android.util.Log
import androidx.core.content.edit import androidx.core.content.edit
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@@ -34,7 +33,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.billing.BillingManager import me.kavishdevar.librepods.billing.BillingManager
import me.kavishdevar.librepods.bluetooth.AACPManager import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers
@@ -111,8 +109,6 @@ class AirPodsViewModel(
private var isDemoMode = false private var isDemoMode = false
val demoActivated = MutableSharedFlow<Unit>() val demoActivated = MutableSharedFlow<Unit>()
private var billingFirstCollectDone = false
private val listeners = private val listeners =
mutableMapOf<ControlCommandIdentifiers, AACPManager.ControlCommandListener>() mutableMapOf<ControlCommandIdentifiers, AACPManager.ControlCommandListener>()
@@ -164,21 +160,18 @@ class AirPodsViewModel(
private fun observeBilling() { private fun observeBilling() {
if (isDemoMode) return if (isDemoMode) return
viewModelScope.launch { viewModelScope.launch {
if (!BuildConfig.PLAY_BUILD) billingFirstCollectDone = true // FOSS doesn't send multiple events // if (!BuildConfig.PLAY_BUILD) billingFirstCollectDone = true // FOSS doesn't send multiple events
BillingManager.provider.isPremium.collect { premium -> BillingManager.provider.isPremium.collect { premium ->
if (!billingFirstCollectDone) { // if (!billingFirstCollectDone) {
billingFirstCollectDone = true // billingFirstCollectDone = true
return@collect // return@collect
} // }
if (!premium) { if (!premium) {
Log.d("AirPodsViewModel", "we are not premium")
setControlCommandBoolean( setControlCommandBoolean(
ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG, ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
false false
) )
setHeadGesturesEnabled(false) setHeadGesturesEnabled(false)
} else {
Log.d("AirPodsViewModel", "we are premium")
} }
_uiState.update { it.copy(isPremium = premium) } _uiState.update { it.copy(isPremium = premium) }
} }
@@ -188,8 +181,9 @@ class AirPodsViewModel(
private fun observeBroadcasts() { private fun observeBroadcasts() {
broadcastReceiver = object : BroadcastReceiver() { broadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
if (!isDemoMode) when (intent?.action) { val action = intent?.action ?: return
AirPodsNotifications.AIRPODS_CONNECTED -> { if (!isDemoMode) when (action) {
AirPodsNotifications.AIRPODS_L2CAP_CONNECTED -> {
_uiState.update { _uiState.update {
it.copy(isLocallyConnected = true) it.copy(isLocallyConnected = true)
} }
@@ -202,10 +196,8 @@ class AirPodsViewModel(
} }
AirPodsNotifications.BATTERY_DATA -> { AirPodsNotifications.BATTERY_DATA -> {
val data = intent.getParcelableArrayListExtra("data", Battery::class.java)
?.toList() ?: emptyList()
_uiState.update { _uiState.update {
it.copy(battery = data) it.copy(battery = service.getBattery())
} }
} }
@@ -280,7 +272,7 @@ class AirPodsViewModel(
} }
} }
listeners[identifier] = listener as AACPManager.ControlCommandListener listeners[identifier] = listener
} }
// I'm lazy, sorry. // I'm lazy, sorry.
@@ -365,7 +357,6 @@ class AirPodsViewModel(
fun setOffListeningMode(enabled: Boolean) { fun setOffListeningMode(enabled: Boolean) {
sharedPreferences.edit { putBoolean("off_listening_mode", enabled) } sharedPreferences.edit { putBoolean("off_listening_mode", enabled) }
setControlCommandBoolean(ControlCommandIdentifiers.ALLOW_OFF_OPTION, enabled) setControlCommandBoolean(ControlCommandIdentifiers.ALLOW_OFF_OPTION, enabled)
Log.d("AirPodsViewModel", "Hello???? $enabled")
_uiState.update { _uiState.update {
it.copy(offListeningMode = enabled) it.copy(offListeningMode = enabled)
} }
@@ -440,7 +431,15 @@ class AirPodsViewModel(
_uiState.update { it.copy(loudSoundReductionEnabled = value[0].toInt() == 0x01) } _uiState.update { it.copy(loudSoundReductionEnabled = value[0].toInt() == 0x01) }
} }
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try {
service.attManager?.connect()
while (service.attManager?.socket?.isConnected != true) {
delay(250)
}
service.attManager?.write(handle, value) service.attManager?.write(handle, value)
} catch (e: Exception) {
e.printStackTrace()
}
} }
} }
@@ -463,13 +462,16 @@ class AirPodsViewModel(
fun observeATT() { fun observeATT() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
service.attManager?.connect() service.attManager?.connect()
while (service.attManager?.socket?.isConnected != true) {
delay(1000)
}
service.attManager?.enableNotifications(ATTHandles.LOUD_SOUND_REDUCTION) service.attManager?.enableNotifications(ATTHandles.LOUD_SOUND_REDUCTION)
service.attManager?.enableNotifications(ATTHandles.TRANSPARENCY) service.attManager?.enableNotifications(ATTHandles.TRANSPARENCY)
service.attManager?.enableNotifications(ATTHandles.HEARING_AID) service.attManager?.enableNotifications(ATTHandles.HEARING_AID)
while (true) { while (true) {
refreshATT() refreshATT()
delay(10000) delay(15000)
} }
} }
} }
@@ -494,10 +496,6 @@ class AirPodsViewModel(
} }
} }
// fun purchase(context: Context) {
// BillingManager.provider.purchase(context as Activity)
// }
fun activateDemoMode() { fun activateDemoMode() {
isDemoMode = true isDemoMode = true
viewModelScope.launch { viewModelScope.launch {
@@ -530,8 +528,17 @@ class AirPodsViewModel(
modelName = fakeInstance.model.displayName, modelName = fakeInstance.model.displayName,
actualModel = fakeInstance.actualModelNumber, actualModel = fakeInstance.actualModelNumber,
serialNumbers = listOf("DEMO", "DEMO", "DEMO"), serialNumbers = listOf("DEMO", "DEMO", "DEMO"),
version3 = "Demo Firmware" version3 = "Demo Firmware",
// isPremium = true
) )
} }
} }
fun sendPhoneMediaEQ(eq: FloatArray, phoneByte: Byte, mediaByte: Byte) {
service.aacpManager.sendPhoneMediaEQ(eq, phoneByte, mediaByte)
}
fun disconnect() {
service.disconnectAirPods()
}
} }

View File

@@ -2,6 +2,7 @@ package me.kavishdevar.librepods.presentation.viewmodel
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit import androidx.core.content.edit
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@@ -32,7 +33,8 @@ data class AppSettingsUiState(
val cameraPackageValue: String = "", val cameraPackageValue: String = "",
val cameraPackageError: String? = null, val cameraPackageError: String? = null,
val vendorIdHook: Boolean = false, val vendorIdHook: Boolean = false,
val isPremium: Boolean = false val isPremium: Boolean = false,
val connectionSuccessful: Boolean = false
) )
class AppSettingsViewModel(application: Application) : AndroidViewModel(application) { class AppSettingsViewModel(application: Application) : AndroidViewModel(application) {
@@ -43,9 +45,22 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat
private val xposedRemotePref = XposedRemotePrefProvider.create() private val xposedRemotePref = XposedRemotePrefProvider.create()
val sharedPrefListener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPref, key ->
if (key == "connection_successful") {
_uiState.update { it.copy(connectionSuccessful = sharedPref.getBoolean(key, false)) }
}
}
init { init {
loadSettings() loadSettings()
observeBilling() observeBilling()
sharedPreferences.registerOnSharedPreferenceChangeListener(sharedPrefListener)
}
override fun onCleared() {
sharedPreferences.unregisterOnSharedPreferenceChangeListener(sharedPrefListener)
super.onCleared()
} }
private fun observeBilling() { private fun observeBilling() {
@@ -72,7 +87,8 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat
useAlternateHeadTrackingPackets = sharedPreferences.getBoolean("use_alternate_head_tracking_packets", true), useAlternateHeadTrackingPackets = sharedPreferences.getBoolean("use_alternate_head_tracking_packets", true),
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 43).toFloat(), conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 43).toFloat(),
cameraPackageValue = sharedPreferences.getString("custom_camera_package", "") ?: "", cameraPackageValue = sharedPreferences.getString("custom_camera_package", "") ?: "",
vendorIdHook = xposedRemotePref.getBoolean("vendor_id_hook", false) vendorIdHook = xposedRemotePref.getBoolean("vendor_id_hook", false),
connectionSuccessful = sharedPreferences.getBoolean("connection_successful", false)
) )
} }
if (BuildConfig.FLAVOR == "xposed") { if (BuildConfig.FLAVOR == "xposed") {

View File

@@ -42,6 +42,6 @@ class PurchaseViewModel(application: Application) : AndroidViewModel(application
} }
fun restorePurchases() { fun restorePurchases() {
BillingManager.provider.queryPurchases() BillingManager.provider.restorePurchases()
} }
} }

View File

@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) @file:Suppress("DEPRECATION") @file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.services package me.kavishdevar.librepods.services
@@ -58,7 +58,7 @@ import android.os.ParcelUuid
import android.os.UserHandle import android.os.UserHandle
import android.provider.Settings import android.provider.Settings
import android.telecom.TelecomManager import android.telecom.TelecomManager
import android.telephony.PhoneStateListener import android.telephony.TelephonyCallback
import android.telephony.TelephonyManager import android.telephony.TelephonyManager
import android.util.Log import android.util.Log
import android.util.TypedValue import android.util.TypedValue
@@ -222,7 +222,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
val packetLogsFlow: StateFlow<Set<String>> get() = _packetLogsFlow val packetLogsFlow: StateFlow<Set<String>> get() = _packetLogsFlow
private lateinit var telephonyManager: TelephonyManager private lateinit var telephonyManager: TelephonyManager
private lateinit var phoneStateListener: PhoneStateListener private lateinit var phoneStateListener: TelephonyCallback
private val maxLogEntries = 1000 private val maxLogEntries = 1000
private val inMemoryLogs = mutableSetOf<String>() private val inMemoryLogs = mutableSetOf<String>()
@@ -361,7 +361,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} }
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag") @SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag", "HardwareIds")
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
Log.i(TAG, "lib exempt worked: ${isBluetoothSocketExempted()}") Log.i(TAG, "lib exempt worked: ${isBluetoothSocketExempted()}")
@@ -383,7 +383,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
localMac = config.selfMacAddress localMac = config.selfMacAddress
if (localMac.isEmpty()) { if (localMac.isEmpty()) {
if (BuildConfig.FLAVOR == "xposed") { if (checkSelfPermission("android.permission.LOCAL_MAC_ADDRESS") == PackageManager.PERMISSION_GRANTED) {
val bluetoothManager = getSystemService(BluetoothManager::class.java)
val bluetoothAdapter = bluetoothManager.adapter
localMac = bluetoothAdapter.address
} else {
localMac = try { localMac = try {
val process = Runtime.getRuntime().exec( val process = Runtime.getRuntime().exec(
arrayOf("su", "-c", "settings get secure bluetooth_address") arrayOf("su", "-c", "settings get secure bluetooth_address")
@@ -594,10 +598,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
macAddress = sharedPreferences.getString("mac_address", "") ?: "" macAddress = sharedPreferences.getString("mac_address", "") ?: ""
telephonyManager = getSystemService(TELEPHONY_SERVICE) as TelephonyManager telephonyManager = getSystemService(TELEPHONY_SERVICE) as TelephonyManager
phoneStateListener = object : PhoneStateListener() { phoneStateListener = object: TelephonyCallback(), TelephonyCallback.CallStateListener {
@Deprecated("Deprecated in Java") override fun onCallStateChanged(state: Int) {
override fun onCallStateChanged(state: Int, phoneNumber: String?) {
super.onCallStateChanged(state, phoneNumber)
when (state) { when (state) {
TelephonyManager.CALL_STATE_RINGING -> { TelephonyManager.CALL_STATE_RINGING -> {
val leAvailableForAudio = val leAvailableForAudio =
@@ -607,7 +609,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
takeOver("call") takeOver("call")
} }
if (config.headGestures) { if (config.headGestures) {
callNumber = phoneNumber
handleIncomingCall() handleIncomingCall()
} }
} }
@@ -626,13 +627,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
TelephonyManager.CALL_STATE_IDLE -> { TelephonyManager.CALL_STATE_IDLE -> {
isInCall = false isInCall = false
callNumber = null
gestureDetector?.stopDetection() gestureDetector?.stopDetection()
} }
} }
} }
} }
telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_CALL_STATE) if (checkSelfPermission("android.permission.READ_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) {
telephonyManager.registerTelephonyCallback(mainExecutor, phoneStateListener)
}
if (config.showPhoneBatteryInWidget) { if (config.showPhoneBatteryInWidget) {
widgetMobileBatteryEnabled = true widgetMobileBatteryEnabled = true
@@ -842,7 +844,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
) || (cameraActive && config.cameraAction == StemPressType.LONG_PRESS) ) || (cameraActive && config.cameraAction == StemPressType.LONG_PRESS)
Log.d( Log.d(
TAG, TAG,
"Setting up stem actions: " + "Single Press Customized: $singlePressCustomized, " + "Double Press Customized: $doublePressCustomized, " + "Triple Press Customized: $triplePressCustomized, " + "Long Press Customized: $longPressCustomized" "Setting up stem actions: Single Press Customized: $singlePressCustomized, Double Press Customized: $doublePressCustomized, Triple Press Customized: $triplePressCustomized, Long Press Customized: $longPressCustomized"
) )
aacpManager.sendStemConfigPacket( aacpManager.sendStemConfigPacket(
singlePressCustomized, singlePressCustomized,
@@ -913,7 +915,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
if (conversationAwarenessNotification.status == 1.toByte() || conversationAwarenessNotification.status == 2.toByte()) { if (conversationAwarenessNotification.status == 1.toByte() || conversationAwarenessNotification.status == 2.toByte()) {
MediaController.startSpeaking() MediaController.startSpeaking()
} else if (conversationAwarenessNotification.status == 8.toByte() || conversationAwarenessNotification.status == 9.toByte()) { } else if (conversationAwarenessNotification.status == 6.toByte() ||conversationAwarenessNotification.status == 8.toByte() || conversationAwarenessNotification.status == 9.toByte()) {
MediaController.stopSpeaking() MediaController.stopSpeaking()
} }
@@ -1062,6 +1064,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
version2 = config.airpodsVersion2, version2 = config.airpodsVersion2,
version3 = config.airpodsVersion3, version3 = config.airpodsVersion3,
) )
if (device != null) setMetadatas(device!!)
} }
sendBroadcast( sendBroadcast(
Intent(AirPodsNotifications.AIRPODS_INFORMATION_UPDATED).setPackage( Intent(AirPodsNotifications.AIRPODS_INFORMATION_UPDATED).setPackage(
@@ -1714,7 +1717,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
val disconnectedNotificationChannel = NotificationChannel( val disconnectedNotificationChannel = NotificationChannel(
"background_service_status", "background_service_status",
"Background Service Status", "Background Service Status",
NotificationManager.IMPORTANCE_LOW NotificationManager.IMPORTANCE_NONE
) )
val connectedNotificationChannel = NotificationChannel( val connectedNotificationChannel = NotificationChannel(
@@ -1805,6 +1808,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} }
fun sendBatteryBroadcast() { fun sendBatteryBroadcast() {
broadcastBatteryInformation()
sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply { sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply {
putParcelableArrayListExtra("data", ArrayList(batteryNotification.getBattery())) putParcelableArrayListExtra("data", ArrayList(batteryNotification.getBattery()))
setPackage(packageName) setPackage(packageName)
@@ -1821,13 +1825,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} }
fun setBatteryMetadata() { fun setBatteryMetadata() {
if (BuildConfig.FLAVOR != "xposed") return if (checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") != PackageManager.PERMISSION_GRANTED) {
device?.let { it -> device?.let { it ->
SystemApisUtils.setMetadata( SystemApisUtils.setMetadata(
it, it,
it.METADATA_UNTETHERED_CASE_BATTERY, it.METADATA_UNTETHERED_CASE_BATTERY,
batteryNotification.getBattery() batteryNotification.getBattery()
.find { it.component == BatteryComponent.CASE }?.level.toString().toByteArray() .find { it.component == BatteryComponent.CASE }?.level.toString()
.toByteArray()
) )
SystemApisUtils.setMetadata( SystemApisUtils.setMetadata(
it, it,
@@ -1840,7 +1845,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
it, it,
it.METADATA_UNTETHERED_LEFT_BATTERY, it.METADATA_UNTETHERED_LEFT_BATTERY,
batteryNotification.getBattery() batteryNotification.getBattery()
.find { it.component == BatteryComponent.LEFT }?.level.toString().toByteArray() .find { it.component == BatteryComponent.LEFT }?.level.toString()
.toByteArray()
) )
SystemApisUtils.setMetadata( SystemApisUtils.setMetadata(
it, it,
@@ -1853,7 +1859,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
it, it,
it.METADATA_UNTETHERED_RIGHT_BATTERY, it.METADATA_UNTETHERED_RIGHT_BATTERY,
batteryNotification.getBattery() batteryNotification.getBattery()
.find { it.component == BatteryComponent.RIGHT }?.level.toString().toByteArray() .find { it.component == BatteryComponent.RIGHT }?.level.toString()
.toByteArray()
) )
SystemApisUtils.setMetadata( SystemApisUtils.setMetadata(
it, it,
@@ -1864,6 +1871,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
) )
} }
} }
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
fun updateBatteryWidget() { fun updateBatteryWidget() {
@@ -2012,7 +2020,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
connected: Boolean, airpodsName: String? = null, batteryList: List<Battery>? = null connected: Boolean, airpodsName: String? = null, batteryList: List<Battery>? = null
) { ) {
val notificationManager = getSystemService(NotificationManager::class.java) val notificationManager = getSystemService(NotificationManager::class.java)
var updatedNotification: Notification?
val notificationIntent = Intent(this, MainActivity::class.java) val notificationIntent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity( val pendingIntent = PendingIntent.getActivity(
@@ -2072,13 +2079,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
notificationManager.notify(2, updatedNotification) notificationManager.notify(2, updatedNotification)
notificationManager.cancel(1) notificationManager.cancel(1)
} else if (!connected) { } else if (!connected) {
updatedNotification = NotificationCompat.Builder(this, "background_service_status")
.setSmallIcon(R.drawable.airpods).setContentTitle("AirPods not connected")
.setContentText("Tap to open app").setContentIntent(pendingIntent)
.setCategory(Notification.CATEGORY_SERVICE)
.setPriority(NotificationCompat.PRIORITY_LOW).setOngoing(true).build()
notificationManager.notify(1, updatedNotification)
notificationManager.cancel(2) notificationManager.cancel(2)
} else if (!config.bleOnlyMode && !socket.isConnected) { } else if (!config.bleOnlyMode && !socket.isConnected) {
showSocketConnectionFailureNotification("Socket created, but not connected. Check logs") showSocketConnectionFailureNotification("Socket created, but not connected. Check logs")
@@ -2108,7 +2108,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
return suspendCancellableCoroutine { continuation -> return suspendCancellableCoroutine { continuation ->
gestureDetector?.startDetection(doNotStop = true) { accepted -> gestureDetector?.startDetection(doNotStop = true) { accepted ->
if (continuation.isActive) { if (continuation.isActive) {
continuation.resume(accepted) { continuation.resume(accepted) { _, _, _ ->
gestureDetector?.stopDetection() gestureDetector?.stopDetection()
} }
} }
@@ -2121,7 +2121,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val telecomManager = getSystemService(TELECOM_SERVICE) as TelecomManager val telecomManager = getSystemService(TELECOM_SERVICE) as TelecomManager
if (checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) == PackageManager.PERMISSION_GRANTED) { if (checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) == PackageManager.PERMISSION_GRANTED) {
telecomManager.acceptRingingCall() telecomManager.acceptRingingCall() // TODO: Switch to InCallService (needs CDM association)
} }
} else { } else {
val telephonyService = getSystemService(TELEPHONY_SERVICE) as TelephonyManager val telephonyService = getSystemService(TELEPHONY_SERVICE) as TelephonyManager
@@ -2148,7 +2148,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val telecomManager = getSystemService(TELECOM_SERVICE) as TelecomManager val telecomManager = getSystemService(TELECOM_SERVICE) as TelecomManager
if (checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) == PackageManager.PERMISSION_GRANTED) { if (checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) == PackageManager.PERMISSION_GRANTED) {
telecomManager.endCall() telecomManager.endCall() // TODO: Switch to InCallService (needs CDM association)
} }
} else { } else {
val telephonyService = getSystemService(TELEPHONY_SERVICE) as TelephonyManager val telephonyService = getSystemService(TELEPHONY_SERVICE) as TelephonyManager
@@ -2221,9 +2221,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
@Suppress("PrivatePropertyName") @Suppress("PrivatePropertyName")
private val ACTION_ASI_UPDATE_BLUETOOTH_DATA = "batterywidget.impl.action.update_bluetooth_data" private val ACTION_ASI_UPDATE_BLUETOOTH_DATA = "batterywidget.impl.action.update_bluetooth_data"
@Suppress("MissingPermission", "unused") @SuppressLint("MissingPermission")
fun broadcastBatteryInformation() { fun broadcastBatteryInformation() {
if (device == null) return if (device == null || checkSelfPermission("android.permission.INTERACT_ACROSS_USERS") != PackageManager.PERMISSION_GRANTED) return
val batteryList = batteryNotification.getBattery() val batteryList = batteryNotification.getBattery()
val leftBattery = batteryList.find { it.component == BatteryComponent.LEFT } val leftBattery = batteryList.find { it.component == BatteryComponent.LEFT }
@@ -2307,7 +2307,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} }
private fun setMetadatas(d: BluetoothDevice) { private fun setMetadatas(d: BluetoothDevice) {
if (BuildConfig.FLAVOR != "xposed") return if (checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") != PackageManager.PERMISSION_GRANTED) {
Log.d(TAG, "no permission BLUETOOTH_PRIVILEGED, returning")
return
}
Log.d(TAG, "has permission BLUETOOTH_PRIVILEGED, proceeding")
d.let { device -> d.let { device ->
val instance = airpodsInstance val instance = airpodsInstance
if (instance != null) { if (instance != null) {
@@ -2377,7 +2381,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
val context = context?.applicationContext val context = context?.applicationContext
val name = context?.getSharedPreferences("settings", MODE_PRIVATE) val name = context?.getSharedPreferences("settings", MODE_PRIVATE)
?.getString("name", bluetoothDevice?.name) ?.getString("name", bluetoothDevice?.name)
if (bluetoothDevice != null && action != null && !action.isEmpty()) { if (bluetoothDevice != null && !action.isNullOrEmpty()) {
Log.d(TAG, "Received bluetooth connection broadcast: action=$action") Log.d(TAG, "Received bluetooth connection broadcast: action=$action")
if (BluetoothDevice.ACTION_ACL_CONNECTED == action) { if (BluetoothDevice.ACTION_ACL_CONNECTED == action) {
val uuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a") val uuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
@@ -2434,16 +2438,13 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
) )
otherDeviceTookOver = false otherDeviceTookOver = false
} }
val ownsConnection = aacpManager.getControlCommandStatus(AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION)?.value?.get(0)?.toInt()
Log.d( Log.d(
TAG, "owns connection: ${ TAG, "owns connection: $ownsConnection"
aacpManager.getControlCommandStatus(AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION)?.value?.get(
0
)?.toInt()
}"
) )
if (!::socket.isInitialized) return if (!::socket.isInitialized) return
if (socket.isConnected) { if (socket.isConnected) {
if (BuildConfig.FLAVOR != "xposed") { if (!XposedRemotePrefProvider.create().getBoolean("vendor_id_hook", false) || ownsConnection == 0) {
Log.d(TAG, "not taking over, vendorid is probably not set to apple") Log.d(TAG, "not taking over, vendorid is probably not set to apple")
return return
} }
@@ -2693,6 +2694,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
version2 = config.airpodsVersion2, version2 = config.airpodsVersion2,
version3 = config.airpodsVersion3, version3 = config.airpodsVersion3,
) )
setMetadatas(device)
} }
} }
@@ -2700,7 +2702,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
true, config.deviceName, batteryNotification.getBattery() true, config.deviceName, batteryNotification.getBattery()
) )
Log.d(TAG, "<LogCollector:Complete:Success> Socket connected") Log.d(TAG, "<LogCollector:Complete:Success> Socket connected")
sharedPreferences.edit { putBoolean("connection_successful", true) }
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_L2CAP_CONNECTED))
} catch (e: Exception) { } catch (e: Exception) {
// sharedPreferences.edit { putBoolean("connection_successful", false) }
Log.d( Log.d(
TAG, "<LogCollector:Complete:Failed> Socket not connected, ${e.message}" TAG, "<LogCollector:Complete:Failed> Socket not connected, ${e.message}"
) )
@@ -2865,6 +2870,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}) })
val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter
if (checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED){
bluetoothAdapter.getProfileProxy(this, object : BluetoothProfile.ServiceListener { bluetoothAdapter.getProfileProxy(this, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
if (profile == BluetoothProfile.A2DP) { if (profile == BluetoothProfile.A2DP) {
@@ -2878,6 +2884,22 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
override fun onServiceDisconnected(profile: Int) {} override fun onServiceDisconnected(profile: Int) {}
}, BluetoothProfile.A2DP) }, BluetoothProfile.A2DP)
}
if (checkSelfPermission("android.permission.MODIFY_PHONE_STATE") == PackageManager.PERMISSION_GRANTED){
bluetoothAdapter.getProfileProxy(this, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
if (profile == BluetoothProfile.HEADSET) {
val connectedDevices = proxy.connectedDevices
if (connectedDevices.isNotEmpty()) {
MediaController.sendPause()
}
}
bluetoothAdapter.closeProfileProxy(profile, proxy)
}
override fun onServiceDisconnected(profile: Int) {}
}, BluetoothProfile.HEADSET)
}
Log.d(TAG, "Disconnected AirPods upon user request") Log.d(TAG, "Disconnected AirPods upon user request")
} }
@@ -2912,6 +2934,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
fun disconnectAudio(context: Context, device: BluetoothDevice?) { fun disconnectAudio(context: Context, device: BluetoothDevice?) {
val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
if (checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) {
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
if (profile == BluetoothProfile.A2DP) { if (profile == BluetoothProfile.A2DP) {
@@ -2923,9 +2946,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
val method = proxy.javaClass.getMethod( val method = proxy.javaClass.getMethod(
"setConnectionPolicy", BluetoothDevice::class.java, Int::class.java "setConnectionPolicy", BluetoothDevice::class.java, Int::class.java
) )
Log.d(TAG, "calling A2DP.setConnectionPolicy for ${device?.address} to 0")
method.invoke(proxy, device, 0) method.invoke(proxy, device, 0)
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "we probably do not have BLUETOOTH_PRIVILEGED") e.printStackTrace()
} finally { } finally {
bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy) bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy)
} }
@@ -2934,29 +2958,40 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
override fun onServiceDisconnected(profile: Int) {} override fun onServiceDisconnected(profile: Int) {}
}, BluetoothProfile.A2DP) }, BluetoothProfile.A2DP)
// requires protected permission (MODIFY_PHONE_STATE) } else {
// bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { Log.d(TAG, "not disconnecting A2DP, no BLUETOOTH_PRIVILEGED permission")
// override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { }
// if (profile == BluetoothProfile.HEADSET) { if (checkSelfPermission("android.permission.MODIFY_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) {
// try { bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
// val method = override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
// proxy.javaClass.getMethod("setConnectionPolicy", BluetoothDevice::class.java, Int::class.java) if (profile == BluetoothProfile.HEADSET) {
// method.invoke(proxy, device, 0) try {
// } catch (e: Exception) { val method =
// e.printStackTrace() proxy.javaClass.getMethod(
// } finally { "setConnectionPolicy",
// bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy) BluetoothDevice::class.java,
// } Int::class.java
// } )
// } Log.d(TAG, "calling HEADSET.setConnectionPolicy for ${device?.address} to 0")
// method.invoke(proxy, device, 0)
// override fun onServiceDisconnected(profile: Int) {} } catch (e: Exception) {
// }, BluetoothProfile.HEADSET) e.printStackTrace()
} finally {
bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy)
}
}
}
override fun onServiceDisconnected(profile: Int) {}
}, BluetoothProfile.HEADSET)
} else {
Log.d(TAG, "not disconnecting HEADSET, no MODIFIY_PHONE_STATE permission")
}
} }
fun connectAudio(context: Context, device: BluetoothDevice?) { fun connectAudio(context: Context, device: BluetoothDevice?) {
val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
if (context.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) {
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
@@ -2965,14 +3000,15 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
val policyMethod = proxy.javaClass.getMethod( val policyMethod = proxy.javaClass.getMethod(
"setConnectionPolicy", BluetoothDevice::class.java, Int::class.java "setConnectionPolicy", BluetoothDevice::class.java, Int::class.java
) )
Log.d(TAG, "calling A2DP.setConnectionPolicy for ${device?.address} to 100")
policyMethod.invoke(proxy, device, 100) policyMethod.invoke(proxy, device, 100)
val connectMethod = val connectMethod =
proxy.javaClass.getMethod("connect", BluetoothDevice::class.java) proxy.javaClass.getMethod("connect", BluetoothDevice::class.java)
connectMethod.invoke( connectMethod.invoke(
proxy, device proxy, device
) // reduces the slight delay between allowing and actually connecting )
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "we probably do not have BLUETOOTH_PRIVILEGED") e.printStackTrace()
} finally { } finally {
bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy) bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy)
if (MediaController.pausedWhileTakingOver) { if (MediaController.pausedWhileTakingOver) {
@@ -2984,26 +3020,37 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
override fun onServiceDisconnected(profile: Int) {} override fun onServiceDisconnected(profile: Int) {}
}, BluetoothProfile.A2DP) }, BluetoothProfile.A2DP)
// requires protected permission (MODIFY_PHONE_STATE) } else {
// bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { Log.d(TAG, "not connecting A2DP, no BLUETOOTH_PRIVILEGED permission")
// override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { }
// if (profile == BluetoothProfile.HEADSET) { if (checkSelfPermission("android.permission.MODIFY_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) {
// try { bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
// val policyMethod = proxy.javaClass.getMethod("setConnectionPolicy", BluetoothDevice::class.java, Int::class.java) override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
// policyMethod.invoke(proxy, device, 100) if (profile == BluetoothProfile.HEADSET) {
// val connectMethod = try {
// proxy.javaClass.getMethod("connect", BluetoothDevice::class.java) val policyMethod = proxy.javaClass.getMethod(
// connectMethod.invoke(proxy, device) "setConnectionPolicy",
// } catch (e: Exception) { BluetoothDevice::class.java,
// e.printStackTrace() Int::class.java
// } finally { )
// bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy) Log.d(TAG, "calling HEADSET.setConnectionPolicy for ${device?.address} to 100")
// } policyMethod.invoke(proxy, device, 100)
// } val connectMethod =
// } proxy.javaClass.getMethod("connect", BluetoothDevice::class.java)
// connectMethod.invoke(proxy, device)
// override fun onServiceDisconnected(profile: Int) {} } catch (e: Exception) {
// }, BluetoothProfile.HEADSET) e.printStackTrace()
} finally {
bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy)
}
}
}
override fun onServiceDisconnected(profile: Int) {}
}, BluetoothProfile.HEADSET)
} else {
Log.d(TAG, "not connecting HEADSET, no MODIFIY_PHONE_STATE permission")
}
} }
fun setName(name: String) { fun setName(name: String) {
@@ -3011,6 +3058,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
if (config.deviceName != name) { if (config.deviceName != name) {
config.deviceName = name config.deviceName = name
device?.alias = name
sharedPreferences.edit { putString("name", name) } sharedPreferences.edit { putString("name", name) }
} }
@@ -3050,7 +3098,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
} }
telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_NONE) if (checkSelfPermission("android.permission.READ_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) {
telephonyManager.unregisterTelephonyCallback(phoneStateListener)
}
// isConnectedLocally = false // isConnectedLocally = false
// CrossDevice.isAvailable = true // CrossDevice.isAvailable = true
super.onDestroy() super.onDestroy()

View File

@@ -20,7 +20,6 @@ package me.kavishdevar.librepods.utils
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Build import android.os.Build
import me.kavishdevar.librepods.BuildConfig
fun isSupported(sharedPreferences: SharedPreferences): Boolean { fun isSupported(sharedPreferences: SharedPreferences): Boolean {
val isPixel = Build.MANUFACTURER.lowercase() == "google" val isPixel = Build.MANUFACTURER.lowercase() == "google"
@@ -37,8 +36,7 @@ fun isSupported(sharedPreferences: SharedPreferences): Boolean {
} }
} }
} else if (isOppoOrOnePlus) { } else if (isOppoOrOnePlus) {
return true return Build.VERSION.SDK_INT >= 36
} }
return if (BuildConfig.FLAVOR == "xposed") true return sharedPreferences.getBoolean("bypass_device_check.v2", false)
else sharedPreferences.getBoolean("bypass_device_check", false)
} }

View File

@@ -212,4 +212,33 @@
<string name="listening_mode_transparency_description">允許外部聲音</string> <string name="listening_mode_transparency_description">允許外部聲音</string>
<string name="listening_mode_adaptive_description">動態調整外部噪音</string> <string name="listening_mode_adaptive_description">動態調整外部噪音</string>
<string name="listening_mode_noise_cancellation_description">阻隔外部聲音</string> <string name="listening_mode_noise_cancellation_description">阻隔外部聲音</string>
<string name="unlock_advanced_features">解鎖進階功能</string>
<string name="buy_price">購買 %s</string>
<string name="restore_purchases">恢復購買</string>
<string name="ear_detection_description">取下時自動停止播放音訊,戴上時恢復播放。</string>
<string name="battery">電池</string>
<string name="battery_description">在應用程式與通知中查看準確的電池狀態。</string>
<string name="noise_control_description">直接從應用程式或快速設定中切換聽覺模式。</string>
<string name="advanced_device_settings">進階裝置設定</string>
<string name="advanced_device_settings_description">自訂個人化音量、自適應音訊、入睡時暫停媒體及其他輔助使用設定等功能。</string>
<string name="automatic_connection">自動連線</string>
<string name="automatic_connection_description">啟用並自訂自動連接至 AirPods 的功能。</string>
<string name="customizations_description">存取應用程式自訂功能,包括小工具中的手機電量、對話感知音量,以及更多即將推出的自訂功能。</string>
<string name="support_the_development">支援開發</string>
<string name="support_development_description">LibrePods 由單一開發者開發。升級有助於維持應用程式的運作。</string>
<string name="feature_availability_disclaimer">功能的可用性取決於你的 AirPods 型號與韌體版本。</string>
<string name="contact">聯絡</string>
<string name="email">電子郵件</string>
<string name="discord">Discord</string>
<string name="github_issues">GitHub Issues</string>
<string name="version_code">版本代碼</string>
<string name="build_type">建置類型</string>
<string name="no"></string>
<string name="yes"></string>
<string name="settings">設定</string>
<string name="requires_xposed">需要 Xposed</string>
<string name="bypass_compatibility_check">略過相容性檢查</string>
<string name="bypass_compatiblity_check_confirmation">你確定你的裝置原生支援或已啟用 Xposed 模組嗎?</string>
<string name="not_supported">不支援</string>
<string name="check_the_repository_for_more_info">請查看儲存庫以獲取更多資訊。</string>
</resources> </resources>

View File

@@ -211,7 +211,7 @@
<string name="listening_mode_adaptive_description">Dynamically adjust external noise</string> <string name="listening_mode_adaptive_description">Dynamically adjust external noise</string>
<string name="listening_mode_noise_cancellation_description">Blocks out external sounds</string> <string name="listening_mode_noise_cancellation_description">Blocks out external sounds</string>
<string name="unlock_advanced_features">Unlock advanced features</string> <string name="unlock_advanced_features">Unlock advanced features</string>
<string name="buy">Buy</string> <string name="buy_price">Buy %s</string>
<string name="restore_purchases">Restore purchases</string> <string name="restore_purchases">Restore purchases</string>
<string name="ear_detection_description">Automatically stop playing audio when you take them off, and resume playback when you put them back on.</string> <string name="ear_detection_description">Automatically stop playing audio when you take them off, and resume playback when you put them back on.</string>
<string name="battery">Battery</string> <string name="battery">Battery</string>
@@ -236,4 +236,31 @@
<string name="yes">Yes</string> <string name="yes">Yes</string>
<string name="settings">Settings</string> <string name="settings">Settings</string>
<string name="requires_xposed">requires xposed</string> <string name="requires_xposed">requires xposed</string>
<string name="bypass_compatibility_check">Bypass compatibility check</string>
<string name="bypass_compatiblity_check_confirmation">Are you sure your device is supported natively/you have Xposed module enabled?</string>
<string name="not_supported">Not supported</string>
<string name="check_the_repository_for_more_info">
Many devices are not supported due to limitations in the Android Bluetooth stack.
\nOn these devices, root access with an Xposed framework is required for full functionality.
\n\nThis limitation has been addressed in newer Android versions. The following device configurations can run the app natively:
\n• Google Pixel® running Android 16 March update and later with the lateset Play system update
\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\nFor details, see the project documentation.</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.
</string>
<string name="type_ok_to_continue">Type "%s" to continue</string>
<string name="proceed">Proceed</string>
<string name="read_compatibility_requirements">I have read compatibility requirements.</string>
<string name="device_info">Device information</string>
<string name="build_id">Build ID</string>
<string name="manufacturer">Manufacturer</string>
<string name="free_features">Free features</string>
<string name="advanced_features">Advanced features</string>
<string name="digital_assistant_on_long_press">Digital Assistant on Long Press</string>
<string name="digital_assistant_on_long_press_description">Invoke Digital Assistant when long pressing the AirPods Pro stem.</string>
<string name="customizations_unavailable">Customizations unavailable. Connect your AirPods at least once to access.</string>
</resources> </resources>

View File

@@ -1,5 +1,21 @@
package me.kavishdevar.librepods package me.kavishdevar.librepods
import android.app.Application import android.app.Application
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import me.kavishdevar.librepods.billing.BillingManager
import me.kavishdevar.librepods.billing.BillingProviderFactory
class LibrePodsApplication: Application() class LibrePodsApplication: Application(), DefaultLifecycleObserver {
override fun onCreate() {
BillingManager.provider = BillingProviderFactory.create(this)
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
super<Application>.onCreate()
}
override fun onResume(owner: LifecycleOwner) {
BillingManager.provider.queryPurchases()
}
}

View File

@@ -0,0 +1,5 @@
package me.kavishdevar.librepods.utils
object NativeBridge {
fun setSdpHook(enabled: Boolean) { }
}

View File

@@ -1,125 +0,0 @@
package me.kavishdevar.librepods.utils
import android.annotation.SuppressLint
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.widget.ImageView
import androidx.core.net.toUri
import io.github.libxposed.api.XposedModule
import io.github.libxposed.api.XposedModuleInterface.ModuleLoadedParam
import io.github.libxposed.api.XposedModuleInterface.PackageLoadedParam
private const val TAG = "LibrePodsHook"
@SuppressLint("DiscouragedApi", "PrivateApi")
class KotlinModule: XposedModule() {
override fun onModuleLoaded(param: ModuleLoadedParam) {
log(Log.INFO, TAG, "module initialized at :: ${param.processName}")
log(Log.INFO, TAG, "framework: $frameworkName($frameworkVersionCode) API $apiVersion")
}
override fun onPackageLoaded(param: PackageLoadedParam) {
log(Log.INFO, TAG, "onPackageLoaded :: ${param.packageName}")
if (param.packageName == "com.google.android.bluetooth" || param.packageName == "com.android.bluetooth") {
log(Log.INFO, TAG, "Bluetooth app detected, hooking l2c_fcr_chk_chan_modes")
try {
if (param.isFirstPackage) {
log(Log.INFO, TAG, "Loading native library for Bluetooth hook")
NativeBridge.setSdpHook(getRemotePreferences("me.kavishdevar.librepods").getBoolean("vendor_id_hook", false))
System.loadLibrary("l2c_fcr_hook")
log(Log.INFO, TAG, "Native library loaded successfully")
}
} catch (e: Exception) {
log(Log.ERROR, TAG, "Failed to load native library: ${e.message}")
}
}
if (param.packageName == "com.google.android.settings") {
hookSettingsController(param, "com.google.android.settings.bluetooth.AdvancedBluetoothDetailsHeaderController")
}
if (param.packageName == "com.android.settings") {
hookSettingsController(param, "com.android.settings.bluetooth.AdvancedBluetoothDetailsHeaderController")
}
}
private fun hookSettingsController(param: PackageLoadedParam, className: String) {
log(Log.INFO, TAG, "Settings app detected, hooking Bluetooth icon handling")
try {
val headerControllerClass = Class.forName(className, false, param.defaultClassLoader)
val updateIconMethod = headerControllerClass.getDeclaredMethod(
"updateIcon",
ImageView::class.java,
String::class.java
)
hook(updateIconMethod).intercept { chain ->
try {
log(Log.INFO, TAG, "Bluetooth icon hook called with args: ${chain.args.joinToString(", ")}")
val imageView = chain.args[0] as? ImageView
val iconUri = chain.args[1] as? String
if (imageView == null || iconUri == null) {
return@intercept chain.proceed()
}
val uri = iconUri.toUri()
if (!uri.toString().startsWith("android.resource://me.kavishdevar.librepods")) {
return@intercept chain.proceed()
}
log(Log.INFO, TAG, "Handling AirPods icon URI: $uri")
Handler(Looper.getMainLooper()).post {
try {
val context = imageView.context
val packageName = uri.authority ?: return@post
val packageContext = context.createPackageContext(
packageName,
Context.CONTEXT_IGNORE_SECURITY
)
val resPath = uri.pathSegments
if (resPath.size >= 2 && resPath[0] == "drawable") {
val resourceName = resPath[1]
val resourceId = packageContext.resources.getIdentifier(
resourceName, "drawable", packageName
)
if (resourceId != 0) {
val drawable = packageContext.resources.getDrawable(
resourceId, packageContext.theme
)
imageView.setImageDrawable(drawable)
imageView.alpha = 1.0f
log(Log.INFO, TAG, "Successfully loaded icon from resource: $resourceName")
} else {
log(Log.ERROR, TAG, "Resource not found: $resourceName")
}
}
} catch (e: Exception) {
log(Log.ERROR, TAG, "Error loading resource from URI $uri: ${e.message}")
}
}
null
} catch (e: Exception) {
log(Log.ERROR, TAG, "Error in Bluetooth icon hook: ${e.message}")
chain.proceed()
}
}
log(Log.INFO, TAG, "Successfully hooked updateIcon method in Bluetooth settings")
} catch (e: Exception) {
log(Log.ERROR, TAG, "Failed to hook Bluetooth icon handler: ${e.message}")
}
}
}
object NativeBridge {
external fun setSdpHook(enabled: Boolean)
}

View File

@@ -1,28 +0,0 @@
package me.kavishdevar.librepods.utils
import android.content.Context
import io.github.libxposed.service.XposedService
import io.github.libxposed.service.XposedServiceHelper
object XposedServiceHolder {
var service: XposedService? = null
}
object XposedInitializer: XposedServiceHelper.OnServiceListener {
private var initialized = false
fun ensureInit(context: Context) {
if (initialized) return
initialized = true
XposedServiceHelper.registerListener(this)
}
override fun onServiceBind(service: XposedService) {
XposedServiceHolder.service = service
}
override fun onServiceDied(service: XposedService) {
XposedServiceHolder.service = null
}
}

View File

@@ -40,8 +40,8 @@ extern "C" {
static HookFunType hook_func = nullptr; static HookFunType hook_func = nullptr;
static uint8_t (*original_l2c_fcr_chk_chan_modes)(void *) = nullptr; static uint8_t (*original_l2c_fcr_chk_chan_modes)(void *) = nullptr;
static tBTA_STATUS (*original_BTA_DmSetLocalDiRecord)(
tSDP_DI_RECORD*, uint32_t*) = nullptr; static tBTA_STATUS (*original_BTA_DmSetLocalDiRecord)(tSDP_DI_RECORD *, uint32_t *) = nullptr;
static std::atomic<bool> enableSdpHook(false); static std::atomic<bool> enableSdpHook(false);
@@ -55,13 +55,13 @@ uint8_t fake_l2c_fcr_chk_chan_modes(void* p_ccb) {
return 1; return 1;
} }
tBTA_STATUS fake_BTA_DmSetLocalDiRecord( tBTA_STATUS fake_BTA_DmSetLocalDiRecord(tSDP_DI_RECORD *p_device_info, uint32_t *p_handle) {
tSDP_DI_RECORD* p_device_info,
uint32_t* p_handle) {
LOGI("fake_BTA_DmSetLocalDiRecord called"); LOGI("fake_BTA_DmSetLocalDiRecord called");
if (original_BTA_DmSetLocalDiRecord && enableSdpHook.load(std::memory_order_relaxed)) original_BTA_DmSetLocalDiRecord(p_device_info, p_handle); if (original_BTA_DmSetLocalDiRecord &&
enableSdpHook.load(std::memory_order_relaxed))
original_BTA_DmSetLocalDiRecord(p_device_info, p_handle);
LOGI("fake_BTA_DmSetLocalDiRecord: modifying vendor to 0x004C, vendor_id_source to 0x0001"); LOGI("fake_BTA_DmSetLocalDiRecord: modifying vendor to 0x004C, vendor_id_source to 0x0001");
@@ -70,14 +70,15 @@ tBTA_STATUS fake_BTA_DmSetLocalDiRecord(
p_device_info->vendor_id_source = 0x0001; p_device_info->vendor_id_source = 0x0001;
} }
LOGI("fake_BTA_DmSetLocalDiRecord: returning status %d", original_BTA_DmSetLocalDiRecord ? original_BTA_DmSetLocalDiRecord(p_device_info, p_handle) : BTA_FAILURE); LOGI("fake_BTA_DmSetLocalDiRecord: returning status %d",
return original_BTA_DmSetLocalDiRecord ? original_BTA_DmSetLocalDiRecord(p_device_info, p_handle) : BTA_FAILURE; original_BTA_DmSetLocalDiRecord ? original_BTA_DmSetLocalDiRecord(p_device_info, p_handle)
: BTA_FAILURE);
return original_BTA_DmSetLocalDiRecord ? original_BTA_DmSetLocalDiRecord(p_device_info,
p_handle)
: BTA_FAILURE;
} }
static bool decompressXZ( static bool decompressXZ(const uint8_t *input, size_t input_size, std::vector<uint8_t> &output) {
const uint8_t* input,
size_t input_size,
std::vector<uint8_t>& output) {
LOGI("decompressXZ called with input_size: %zu", input_size); LOGI("decompressXZ called with input_size: %zu", input_size);
@@ -106,7 +107,8 @@ static bool decompressXZ(
LOGI("decompressXZ: entering decompression loop"); LOGI("decompressXZ: entering decompression loop");
while (true) { while (true) {
LOGI("decompressXZ: xz_dec_run iteration, buf.in_pos: %zu, buf.out_pos: %zu", buf.in_pos, buf.out_pos); LOGI("decompressXZ: xz_dec_run iteration, buf.in_pos: %zu, buf.out_pos: %zu", buf.in_pos,
buf.out_pos);
enum xz_ret ret = xz_dec_run(dec, &buf); enum xz_ret ret = xz_dec_run(dec, &buf);
LOGI("decompressXZ: xz_dec_run returned %d", ret); LOGI("decompressXZ: xz_dec_run returned %d", ret);
@@ -192,9 +194,62 @@ static uintptr_t getModuleBase(const char* name) {
return base; return base;
} }
static uint64_t findSymbolOffset( static uint64_t
const std::vector<uint8_t>& elf, findSymbolOffsetDynsym(const std::vector<uint8_t> &elf, const char *symbol_substring) {
const char* symbol_substring) {
LOGI("findSymbolOffsetDynsym called with %s", symbol_substring);
auto *eh = reinterpret_cast<const Elf64_Ehdr *>(elf.data());
auto *shdr = reinterpret_cast<const Elf64_Shdr *>(
elf.data() + eh->e_shoff);
const char *shstr = reinterpret_cast<const char *>(
elf.data() + shdr[eh->e_shstrndx].sh_offset);
const Elf64_Shdr *dynsym = nullptr;
const Elf64_Shdr *dynstr = nullptr;
for (int i = 0; i < eh->e_shnum; ++i) {
const char *secname = shstr + shdr[i].sh_name;
if (!strcmp(secname, ".dynsym"))
dynsym = &shdr[i];
if (!strcmp(secname, ".dynstr"))
dynstr = &shdr[i];
}
if (!dynsym || !dynstr) {
LOGE("findSymbolOffsetDynsym: dynsym or dynstr not found");
return 0;
}
auto *symbols = reinterpret_cast<const Elf64_Sym *>(
elf.data() + dynsym->sh_offset);
const char *strings = reinterpret_cast<const char *>(
elf.data() + dynstr->sh_offset);
size_t count = dynsym->sh_size / sizeof(Elf64_Sym);
LOGI("findSymbolOffsetDynsym: scanning %zu symbols", count);
for (size_t i = 0; i < count; ++i) {
const char *name = strings + symbols[i].st_name;
if (strstr(name, symbol_substring) && ELF64_ST_TYPE(symbols[i].st_info) == STT_FUNC) {
LOGI("findSymbolOffsetDynsym: matched %s @ 0x%lx", name,
(unsigned long) symbols[i].st_value);
return symbols[i].st_value;
}
}
LOGI("findSymbolOffsetDynsym: no match for %s", symbol_substring);
return 0;
}
static uint64_t findSymbolOffset(const std::vector<uint8_t> &elf, const char *symbol_substring) {
LOGI("findSymbolOffset called with symbol_substring: %s", symbol_substring); LOGI("findSymbolOffset called with symbol_substring: %s", symbol_substring);
@@ -202,8 +257,7 @@ static uint64_t findSymbolOffset(
auto *shdr = reinterpret_cast<const Elf64_Shdr *>( auto *shdr = reinterpret_cast<const Elf64_Shdr *>(
elf.data() + eh->e_shoff); elf.data() + eh->e_shoff);
const char* shstr = const char *shstr = reinterpret_cast<const char *>(
reinterpret_cast<const char*>(
elf.data() + shdr[eh->e_shstrndx].sh_offset); elf.data() + shdr[eh->e_shstrndx].sh_offset);
const Elf64_Shdr *symtab = nullptr; const Elf64_Shdr *symtab = nullptr;
@@ -227,8 +281,7 @@ static uint64_t findSymbolOffset(
auto *symbols = reinterpret_cast<const Elf64_Sym *>( auto *symbols = reinterpret_cast<const Elf64_Sym *>(
elf.data() + symtab->sh_offset); elf.data() + symtab->sh_offset);
const char* strings = const char *strings = reinterpret_cast<const char *>(
reinterpret_cast<const char*>(
elf.data() + strtab->sh_offset); elf.data() + strtab->sh_offset);
size_t count = symtab->sh_size / sizeof(Elf64_Sym); size_t count = symtab->sh_size / sizeof(Elf64_Sym);
@@ -237,10 +290,10 @@ static uint64_t findSymbolOffset(
for (size_t i = 0; i < count; ++i) { for (size_t i = 0; i < count; ++i) {
const char *name = strings + symbols[i].st_name; const char *name = strings + symbols[i].st_name;
if (strstr(name, symbol_substring) && if (strstr(name, symbol_substring) && ELF64_ST_TYPE(symbols[i].st_info) == STT_FUNC) {
ELF64_ST_TYPE(symbols[i].st_info) == STT_FUNC) {
LOGI("findSymbolOffset: matched symbol %s at 0x%lx", name, (unsigned long)symbols[i].st_value); LOGI("findSymbolOffset: matched symbol %s at 0x%lx", name,
(unsigned long) symbols[i].st_value);
return symbols[i].st_value; return symbols[i].st_value;
} }
@@ -287,76 +340,65 @@ static bool hookLibrary(const char* libname) {
auto *shdr = reinterpret_cast<Elf64_Shdr *>( auto *shdr = reinterpret_cast<Elf64_Shdr *>(
file.data() + eh->e_shoff); file.data() + eh->e_shoff);
const char* shstr = const char *shstr = reinterpret_cast<const char *>(
reinterpret_cast<const char*>(
file.data() + shdr[eh->e_shstrndx].sh_offset); file.data() + shdr[eh->e_shstrndx].sh_offset);
LOGI("hookLibrary: parsing ELF header and sections"); uint64_t chk_offset = 0;
for (int i = 0; i < eh->e_shnum; ++i) { uint64_t sdp_offset = 0;
for (int i = 0; i < eh->e_shnum; ++i) {
if (!strcmp(shstr + shdr[i].sh_name, ".gnu_debugdata")) { if (!strcmp(shstr + shdr[i].sh_name, ".gnu_debugdata")) {
LOGI("hookLibrary: found .gnu_debugdata section"); LOGI("hookLibrary: found .gnu_debugdata section");
std::vector<uint8_t> compressed( std::vector<uint8_t> compressed(file.begin() + shdr[i].sh_offset,
file.begin() + shdr[i].sh_offset,
file.begin() + shdr[i].sh_offset + shdr[i].sh_size); file.begin() + shdr[i].sh_offset + shdr[i].sh_size);
std::vector<uint8_t> decompressed; std::vector<uint8_t> decompressed;
if (!decompressXZ( if (decompressXZ(compressed.data(), compressed.size(), decompressed)) {
compressed.data(),
compressed.size(), chk_offset = findSymbolOffset(decompressed, "l2c_fcr_chk_chan_modes");
decompressed)) {
LOGE("hookLibrary: decompressXZ failed"); sdp_offset = findSymbolOffset(decompressed, "BTA_DmSetLocalDiRecord");
return false; } else {
LOGE("debugdata decompress failed");
}
break;
}
}
if (!chk_offset) {
LOGI("fallback dynsym chk");
chk_offset = findSymbolOffsetDynsym(file, "l2c_fcr_chk_chan_modes");
}
if (!sdp_offset) {
LOGI("fallback dynsym sdp");
sdp_offset = findSymbolOffsetDynsym(file, "BTA_DmSetLocalDiRecord");
} }
LOGI("hookLibrary: decompressed debug data, size: %zu", decompressed.size());
uintptr_t base = getModuleBase(libname); uintptr_t base = getModuleBase(libname);
if (!base) { if (!base) {
LOGE("hookLibrary: getModuleBase failed"); LOGE("hookLibrary: getModuleBase failed");
return false; return false;
} }
LOGI("hookLibrary: module base: 0x%lx", base);
uint64_t chk_offset =
findSymbolOffset(decompressed,
"l2c_fcr_chk_chan_modes");
uint64_t sdp_offset =
findSymbolOffset(decompressed,
"BTA_DmSetLocalDiRecord");
LOGI("hookLibrary: chk_offset: 0x%lx, sdp_offset: 0x%lx", chk_offset, sdp_offset);
if (chk_offset) { if (chk_offset) {
void* target = void *target = reinterpret_cast<void *>(base + chk_offset);
reinterpret_cast<void*>(base + chk_offset); hook_func(target, (void *) fake_l2c_fcr_chk_chan_modes,
hook_func(target,
(void*)fake_l2c_fcr_chk_chan_modes,
(void **) &original_l2c_fcr_chk_chan_modes); (void **) &original_l2c_fcr_chk_chan_modes);
LOGI("hooked chk");
LOGI("hookLibrary: hooked l2c_fcr_chk_chan_modes");
} }
if (sdp_offset) { if (sdp_offset) {
void* target = void *target = reinterpret_cast<void *>(base + sdp_offset);
reinterpret_cast<void*>(base + sdp_offset); hook_func(target, (void *) fake_BTA_DmSetLocalDiRecord,
hook_func(target,
(void*)fake_BTA_DmSetLocalDiRecord,
(void **) &original_BTA_DmSetLocalDiRecord); (void **) &original_BTA_DmSetLocalDiRecord);
LOGI("hooked sdp");
LOGI("hookLibrary: hooked BTA_DmSetLocalDiRecord");
} }
return true; return chk_offset || sdp_offset;
}
}
LOGI("hookLibrary: failed for %s", libname);
return false;
} }
static void on_library_loaded(const char *name, void *) { static void on_library_loaded(const char *name, void *) {
@@ -373,20 +415,19 @@ static void on_library_loaded(const char* name, void*) {
} }
} }
extern "C" extern "C" [[gnu::visibility("default")]]
[[gnu::visibility("default")]]
[[gnu::used]] [[gnu::used]]
NativeOnModuleLoaded native_init(const NativeAPIEntries *entries) { NativeOnModuleLoaded native_init(const NativeAPIEntries *entries) {
LOGI("native_init called with entries: %p", entries); LOGI("native_init called with entries: %p", entries);
hook_func = (HookFunType) entries->hook_func; hook_func = (HookFunType) entries->hook_func;
LOGI("LibrePodsNativeHook initialized, sdp hook enabled: %d", enableSdpHook.load(std::memory_order_relaxed)); LOGI("LibrePodsNativeHook initialized, sdp hook enabled: %d",
enableSdpHook.load(std::memory_order_relaxed));
return on_library_loaded; return on_library_loaded;
} }
extern "C" extern "C" JNIEXPORT void JNICALL
JNIEXPORT void JNICALL Java_me_kavishdevar_librepods_utils_NativeBridge_setSdpHook(JNIEnv *, jobject thiz,
Java_me_kavishdevar_librepods_utils_NativeBridge_setSdpHook( jboolean enable) {
JNIEnv*, jobject thiz, jboolean enable) {
LOGI("setSdpHook called with enable: %d", enable); LOGI("setSdpHook called with enable: %d", enable);
enableSdpHook.store(enable, std::memory_order_relaxed); enableSdpHook.store(enable, std::memory_order_relaxed);

View File

@@ -1,14 +1,27 @@
package me.kavishdevar.librepods package me.kavishdevar.librepods
import android.app.Application import android.app.Application
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import io.github.libxposed.service.XposedService import io.github.libxposed.service.XposedService
import io.github.libxposed.service.XposedServiceHelper import io.github.libxposed.service.XposedServiceHelper
import me.kavishdevar.librepods.billing.BillingManager
import me.kavishdevar.librepods.billing.BillingProviderFactory
import me.kavishdevar.librepods.utils.XposedServiceHolder import me.kavishdevar.librepods.utils.XposedServiceHolder
class LibrePodsApplication: Application(), XposedServiceHelper.OnServiceListener { class LibrePodsApplication: Application(), XposedServiceHelper.OnServiceListener, DefaultLifecycleObserver {
override fun onCreate() { override fun onCreate() {
super.onCreate()
XposedServiceHelper.registerListener(this) XposedServiceHelper.registerListener(this)
BillingManager.provider = BillingProviderFactory.create(this)
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
super<Application>.onCreate()
}
override fun onResume(owner: LifecycleOwner) {
BillingManager.provider.queryPurchases()
} }
override fun onServiceBind(p0: XposedService) { override fun onServiceBind(p0: XposedService) {

View File

@@ -18,6 +18,7 @@ backdrop = "2.0.0-alpha03"
billing = "8.3.0" billing = "8.3.0"
hilt = "2.59.2" hilt = "2.59.2"
xposed = "101.0.0" xposed = "101.0.0"
lifecycleProcess = "2.10.0"
[libraries] [libraries]
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" } accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" }
@@ -47,6 +48,7 @@ hilt = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
libxposed-api = { group = "io.github.libxposed", name = "api", version.ref = "xposed" } libxposed-api = { group = "io.github.libxposed", name = "api", version.ref = "xposed" }
libxposed-service = { group = "io.github.libxposed", name = "service", version.ref = "xposed" } libxposed-service = { group = "io.github.libxposed", name = "service", version.ref = "xposed" }
androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleProcess" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }

View File

@@ -0,0 +1,7 @@
id=librepods
name=LibrePods
version=v0.2.0
versionCode=34
author=@kavishdevar
description=Installs LibrePods as a system app for granting BLUETOOTH_PRIVILEGED and MODIFY_PHONE_STATE permission for better integraion with android.
updateJson=https://raw.githubusercontent.com/kavishdevar/librepods/main/update_nonpatch.json

Binary file not shown.

View File

@@ -1,3 +0,0 @@
#!/bin/sh
exec /data/local/tmp/aln_unzip/busybox/busybox-arm64 xz "$@"

View File

@@ -1,190 +0,0 @@
#!/system/bin/sh
# Note: these two exec redirs are not strictly POSIX-compliant, so they can be commented out if we notice that it shows a syntax error in some environments (unlikely to happen)
# Redirect stdout to ui_print otherwise it's not shown
exec 1> >(while read -r line; do ui_print "[O] $line"; done)
# Redirect stderr to ui_print otherwise it's not shown + ignore useless radare2 warning that clutters the logs
exec 2> >(while read -r line; do echo "$line" | grep -qv "Cannot determine entrypoint, using" && ui_print "[E] $line"; done)
TEMP_DIR="/data/local/tmp/aln_patch"
# Note: this dir cannot be changed without recompiling radare2 because this prefix are hardcoded inside the radare2 binaries: /data/local/tmp/aln_unzip/org.radare.radare2installer/radare2/
UNZIP_DIR="/data/local/tmp/aln_unzip"
SOURCE_FILE=""
LIBRARY_NAME=""
APEX_DIR=false
# Clean things up if the script crashes or exits
trap 'rm -rf "$TEMP_DIR" "$UNZIP_DIR"' EXIT INT TERM
# https://github.com/Magisk-Modules-Repo/busybox-ndk/blob/master/busybox-arm64
BUSYBOX="$UNZIP_DIR/busybox/busybox-arm64"
XZ="$UNZIP_DIR/busybox/xz"
rm -rf "$TEMP_DIR" "$UNZIP_DIR"
mkdir -p "$TEMP_DIR" "$UNZIP_DIR"
# Manually extract the $ZIPFILE to a temporary directory
ui_print "Extracting module files..."
unzip -d "$UNZIP_DIR" -oq "$ZIPFILE" || {
ui_print "Error: Failed to extract module files."
abort "Failed to unzip $ZIPFILE"
}
set_perm "$BUSYBOX" 0 0 755
set_perm "$XZ" 0 0 755
# The bundled radare2 is a custom build that works without Termux: https://github.com/devnoname120/radare2/releases/tag/5.9.8-android-aln
ui_print "Extracting radare2 to /data/local/tmp/aln_unzip..."
$BUSYBOX tar xf "$UNZIP_DIR/radare2-5.9.9-android-aarch64-aln.tar.gz" -C / || {
abort "Failed to extract "$UNZIP_DIR/radare2-5.9.9-android-aarch64-aln.tar.gz"."
}
if [ "$(uname -m)" = "aarch64" ]; then
export LD_LIBRARY_PATH="$UNZIP_DIR/org.radare.radare2installer/radare2/lib:$LD_LIBRARY_PATH"
export PATH="$UNZIP_DIR/org.radare.radare2installer/radare2/bin:$PATH"
export PATH="$UNZIP_DIR/busybox:$PATH"
export RABIN2="$UNZIP_DIR/org.radare.radare2installer/radare2/bin/rabin2"
export RADARE2="$UNZIP_DIR/org.radare.radare2installer/radare2/bin/radare2"
else
abort "arm64 archicture required, arm32 not supported"
fi
set_perm "$RABIN2" 0 0 755
set_perm "$RADARE2" 0 0 755
if [ -f "$RABIN2" ]; then
ui_print "rabin2 binary is ready."
else
ui_print "Error: rabin2 binary not found."
abort "rabin2 binary not found."
fi
if [ -f "$RADARE2" ]; then
ui_print "radare2 binary is ready."
else
ui_print "Error: radare2 binary not found."
abort "radare2 binary not found."
fi
if [ -f "$BUSYBOX" ]; then
ui_print "busybox binary is ready."
else
ui_print "Error: busybox binary not found."
abort "busybox binary not found."
fi
if [ -f "$XZ" ]; then
ui_print "xz shim is ready."
else
ui_print "Error: xz shim not found."
abort "xz shim not found."
fi
for lib_path in \
"/apex/com.android.btservices/lib64/libbluetooth_jni.so" \
"/system/lib64/libbluetooth_jni.so" \
"/system/lib64/libbluetooth_qti.so" \
"/system_ext/lib64/libbluetooth_qti.so"; do
if [ -f "$lib_path" ]; then
ui_print "Detected library: $lib_path"
[ -z "$SOURCE_FILE" ] && SOURCE_FILE="$lib_path"
[ -z "$LIBRARY_NAME" ] && LIBRARY_NAME="$(basename "$lib_path")"
fi
done
[ -z "$SOURCE_FILE" ] && {
ui_print "Error: No target library found."
abort "No target library found."
}
if echo "$LIBRARY_NAME" | grep -q "qti"; then
ui_print "ERROR: \"qti\" Bluetooth libraries are NOT supported by the patcher and you won't be able to use aln. Aborting..."
abort "Bluetooth driver not compatible."
fi
ui_print "Calculating patch addresses for $SOURCE_FILE..."
# export R2_LIBDIR="$UNZIP_DIR/radare2-android/libs/arm64-v8a"
# export R2_BINDIR="$UNZIP_DIR/radare2-android/bin/arm64-v8a"
# $RADARE2 -H 1>&2
# ldd $RABIN2 1>&2
# ldd $RADARE2 1>&2
symbols="$($RABIN2 -q -E "$SOURCE_FILE")" || abort "Failed to extract symbols from $SOURCE_FILE."
get_symbol_address() {
symb_address=$(echo "$symbols" | grep "$1" | cut -d ' ' -f1 | tr -d '\n')
[ -n "$symb_address" ] || abort "Failed to obtain address for symbol $1"
echo "$symb_address"
}
l2c_fcr_chk_chan_modes_address="$(get_symbol_address 'l2c_fcr_chk_chan_modes')"
ui_print " l2c_fcr_chk_chan_modes_address=$l2c_fcr_chk_chan_modes_address"
l2cu_send_peer_info_req_address="$(get_symbol_address 'l2cu_send_peer_info_req')"
ui_print " l2cu_send_peer_info_req_address=$l2cu_send_peer_info_req_address"
cp "$SOURCE_FILE" "$TEMP_DIR"
ui_print "Patching $LIBRARY_NAME..."
apply_patch() {
$RADARE2 -q -e bin.cache=true -w -c "s $1; wx $2; wci" "$TEMP_DIR/$LIBRARY_NAME" || abort "Failed to apply $1 patch."
}
apply_patch "$l2c_fcr_chk_chan_modes_address" "20008052c0035fd6"
apply_patch "$l2cu_send_peer_info_req_address" "c0035fd6"
if [ -f "$TEMP_DIR/$LIBRARY_NAME" ]; then
ui_print "Installing patched file..."
if echo "$SOURCE_FILE" | grep -q "/system/lib64"; then
TARGET_DIR="$MODPATH/system/lib64"
elif echo "$SOURCE_FILE" | grep -q "/apex/"; then
TARGET_DIR="$MODPATH/system/lib64"
APEX_DIR=true
else
TARGET_DIR="$MODPATH/system/lib"
fi
mkdir -p "$TARGET_DIR"
cp "$TEMP_DIR/$LIBRARY_NAME" "$TARGET_DIR/$LIBRARY_NAME"
set_perm "$TARGET_DIR/$LIBRARY_NAME" 0 0 644
ui_print "Patched file installed at $TARGET_DIR/$LIBRARY_NAME"
if [ "$APEX_DIR" = true ]; then
POST_DATA_FS_SCRIPT="$MODPATH/post-data-fs.sh"
APEX_LIB_DIR="/apex/com.android.btservices/lib64"
MOD_APEX_LIB_DIR="$MODPATH/apex/com.android.btservices/lib64"
WORK_DIR="$MODPATH/apex/com.android.btservices/work"
mkdir -p "$MOD_APEX_LIB_DIR" "$WORK_DIR"
cp "$TEMP_DIR/$LIBRARY_NAME" "$MOD_APEX_LIB_DIR/$LIBRARY_NAME"
set_perm "$MOD_APEX_LIB_DIR/$LIBRARY_NAME" 0 0 644
cat <<EOF > "$POST_DATA_FS_SCRIPT"
#!/system/bin/sh
mount -t overlay overlay -o lowerdir=$APEX_LIB_DIR,upperdir=$MOD_APEX_LIB_DIR,workdir=$WORK_DIR $APEX_LIB_DIR
EOF
set_perm "$POST_DATA_FS_SCRIPT" 0 0 755
ui_print "Created script for apex library handling."
ui_print "You can now restart your device and test aln!"
ui_print "Note: If your Bluetooth doesn't work anymore after restarting, then uninstall this module and report the issue at the link below."
ui_print "https://github.com/kavishdevar/librepods/issues/new"
fi
else
ui_print "Error: patched file missing."
rm -rf "$TEMP_DIR" "$UNZIP_DIR"
abort "Failed to patch the library."
fi
rm -rf "$TEMP_DIR" "$UNZIP_DIR"

View File

@@ -1,7 +0,0 @@
id=btl2capfix
name=Bluetooth L2CAP workaround for AirPods
version=v3
versionCode=3
author=@devnoname120 and @kavishdevar
description=Fixes the Bluetooth L2CAP connection issue with AirPods
updateJson=https://raw.githubusercontent.com/kavishdevar/librepods/main/update.json

View File

@@ -1,6 +1,6 @@
{ {
"version": "v0.1.0-rc.4", "version": "v0.2.3",
"versionCode": 3, "versionCode": 36,
"zipUrl": "https://github.com/kavishdevar/librepods/releases/download/v0.1.0-rc.4/LibrePods-v0.1.0-rc.4.zip", "zipUrl": "https://github.com/kavishdevar/librepods/releases/download/v0.2.3/LibrePods-FOSS-v0.2.3-release.zip",
"changelog": "https://raw.githubusercontent.com/kavishdevar/librepods/main/CHANGELOG.md" "changelog": "https://raw.githubusercontent.com/kavishdevar/librepods/main/CHANGELOG.md"
} }