mirror of
https://github.com/kavishdevar/librepods.git
synced 2026-04-29 09:33:04 +00:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b24ac49e2 | ||
|
|
d2dd722bc7 | ||
|
|
67fc93bde5 | ||
|
|
0b578d62cf | ||
|
|
072b9b4dac | ||
|
|
0af60cd8a9 | ||
|
|
be29a46dab | ||
|
|
7461f7dfb7 | ||
|
|
904c00afce | ||
|
|
6272357d84 | ||
|
|
6ac6700be6 | ||
|
|
113ee0a966 | ||
|
|
d82e4e2427 | ||
|
|
481d5f13cf | ||
|
|
ef221af505 | ||
|
|
c19190f031 | ||
|
|
d0b8574c68 | ||
|
|
294d733e71 | ||
|
|
f6d7e97796 | ||
|
|
ae174bc9ea | ||
|
|
1804e80cba | ||
|
|
0b8bd5a5b8 | ||
|
|
d1d48562d7 | ||
|
|
c84e64e656 | ||
|
|
51739514fa |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,10 +1,5 @@
|
||||
root-module/radare2-5.9.9-android-aarch64.tar.gz
|
||||
wak.toml
|
||||
log.txt
|
||||
btl2capfix.zip
|
||||
root-module-manual
|
||||
release
|
||||
.vscode
|
||||
testing.py
|
||||
.DS_Store
|
||||
CMakeLists.txt.user*
|
||||
|
||||
|
||||
23
README.md
23
README.md
@@ -76,10 +76,23 @@ https://github.com/user-attachments/assets/43911243-0576-4093-8c55-89c1db5ea533
|
||||
|
||||
### 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:
|
||||
**You must have a rooted device with Xposed to use LibrePods on Android.**
|
||||
[https://issuetracker.google.com/issues/371713238](https://issuetracker.google.com/issues/371713238)
|
||||
|
||||
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
|
||||
|
||||
@@ -129,7 +142,7 @@ A huge thank you to everyone supporting the project!
|
||||
- MagicPods for Steam Deck ([website](https://magicpods.app/steamdeck/))
|
||||
- MagicPods - if you're looking for "LibrePods for Windows" ([ms store](https://apps.microsoft.com/store/detail/9P6SKKFKSHKM) [installer](https://magicpods.app/installer/MagicPods.appinstaller) | [website](https://magicpods.app/))
|
||||
|
||||
# Nightly / Development Builds
|
||||
# Nightly/Development Builds
|
||||
|
||||
Want to try the latest features before they're officially released? You can grab nightly builds from GitHub Actions:
|
||||
|
||||
@@ -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
|
||||
|
||||
> [!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)
|
||||
1. Go to the [Actions tab](https://github.com/kavishdevar/librepods/actions/workflows/ci-linux-rust.yml)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import java.util.Properties
|
||||
|
||||
val versionName = "0.2.3"
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
@@ -28,16 +30,15 @@ android {
|
||||
applicationId = "me.kavishdevar.librepods"
|
||||
minSdk = 33
|
||||
targetSdk = 37
|
||||
versionCode = 28
|
||||
versionName = "0.2.0"
|
||||
versionCode = 38
|
||||
versionName = versionName
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
|
||||
)
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
@@ -50,14 +51,17 @@ android {
|
||||
debug {
|
||||
buildConfigField("Boolean", "PLAY_BUILD", "false")
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
versionNameSuffix = "-debug"
|
||||
}
|
||||
create("playRelease") {
|
||||
initWith(getByName("release"))
|
||||
buildConfigField("Boolean", "PLAY_BUILD", "true")
|
||||
versionNameSuffix = "-play"
|
||||
}
|
||||
create("playDebug") {
|
||||
initWith(getByName("debug"))
|
||||
buildConfigField("Boolean", "PLAY_BUILD", "true")
|
||||
versionNameSuffix = "-youshouldnothavethis"
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
@@ -80,7 +84,7 @@ android {
|
||||
}
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
res.directories+="src/main/res-apple"
|
||||
res.directories += "src/main/res-apple"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +108,6 @@ android {
|
||||
arguments += "-DIS_XPOSED=ON"
|
||||
}
|
||||
}
|
||||
versionNameSuffix = "-xposed"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,6 +116,7 @@ dependencies {
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.accompanist.permissions)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.process)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.ui)
|
||||
@@ -141,9 +145,83 @@ dependencies {
|
||||
}
|
||||
|
||||
aboutLibraries {
|
||||
export{
|
||||
export {
|
||||
prettyPrint = true
|
||||
excludeFields = listOf("generated")
|
||||
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)
|
||||
}
|
||||
|
||||
Binary file not shown.
3
android/app/proguard-rules.pro
vendored
3
android/app/proguard-rules.pro
vendored
@@ -20,5 +20,4 @@
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
||||
-keep class androidx.compose.** { *; }
|
||||
-dontwarn androidx.compose.**
|
||||
-keep class me.kavishdevar.librepods.utils.KotlinModule { *; }
|
||||
|
||||
@@ -14,9 +14,18 @@
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
<!-- <uses-permission-->
|
||||
<!-- android:name="android.permission.BLUETOOTH_PRIVILEGED"-->
|
||||
<!-- tools:ignore="ProtectedPermissions" />-->
|
||||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH_PRIVILEGED"
|
||||
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_SCAN"
|
||||
@@ -27,8 +36,6 @@
|
||||
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<!-- <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"-->
|
||||
<!-- android:maxSdkVersion="30" />-->
|
||||
<!-- <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"-->
|
||||
|
||||
@@ -52,7 +52,6 @@ import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
@@ -81,8 +79,6 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
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.drawscope.rotate
|
||||
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.LocalWindowInfo
|
||||
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.materials.ExperimentalHazeMaterialsApi
|
||||
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.ControlCommandRepository
|
||||
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.screens.AccessibilitySettingsScreen
|
||||
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.UpdateHearingTestScreen
|
||||
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.AppSettingsViewModel
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
|
||||
@@ -174,7 +169,7 @@ class MainActivity : ComponentActivity() {
|
||||
enableEdgeToEdge()
|
||||
|
||||
setContent {
|
||||
_root_ide_package_.me.kavishdevar.librepods.presentation.theme.LibrePodsTheme {
|
||||
LibrePodsTheme {
|
||||
Main()
|
||||
}
|
||||
}
|
||||
@@ -223,133 +218,108 @@ fun Main() {
|
||||
val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE)
|
||||
if (!isSupported(sharedPreferences)) {
|
||||
val showDialog = remember { mutableStateOf(false) }
|
||||
val blockTouches = remember { mutableStateOf(false) }
|
||||
val tapCount = remember { mutableIntStateOf(0) }
|
||||
val lastTapTime = remember { mutableLongStateOf(0L) }
|
||||
|
||||
val showPlayBypassVisible = remember { mutableStateOf(false) }
|
||||
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(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.hazeSource(hazeState)
|
||||
.background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)),
|
||||
.layerBackdrop(backdrop)
|
||||
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7)),
|
||||
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 (
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.verticalScroll(scrollState),
|
||||
verticalArrangement = Arrangement
|
||||
.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Not supported",
|
||||
style = TextStyle(
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
|
||||
fontSize = 20.sp
|
||||
),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Row (
|
||||
modifier = Modifier.fillMaxWidth().pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onTap = {
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
if (now - lastTapTime.longValue > 400) {
|
||||
tapCount.intValue = 0
|
||||
}
|
||||
|
||||
tapCount.intValue++
|
||||
lastTapTime.longValue = now
|
||||
|
||||
if (tapCount.intValue >= 7) {
|
||||
showDialog.value = true
|
||||
blockTouches.value = true
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
horizontalArrangement = Arrangement.Center
|
||||
val innerBackdrop = rememberLayerBackdrop()
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
Column(
|
||||
modifier = Modifier.layerBackdrop(innerBackdrop),
|
||||
verticalArrangement = Arrangement
|
||||
.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Device Info:",
|
||||
text = stringResource(R.string.not_supported),
|
||||
style = TextStyle(
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = textColor,
|
||||
fontSize = 20.sp,
|
||||
textAlign = TextAlign.Center
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
DeviceInfoCard()
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
.clip(RoundedCornerShape(28.dp))
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.check_the_repository_for_more_info),
|
||||
style = TextStyle(
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
|
||||
fontSize = 16.sp
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
StyledButton(
|
||||
onClick = { showDialog.value = true },
|
||||
backdrop = innerBackdrop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.bypass_compatibility_check),
|
||||
style = TextStyle(
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
|
||||
fontSize = 16.sp
|
||||
),
|
||||
textAlign = TextAlign.End,
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.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,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "Check the repository for more info.",
|
||||
style = TextStyle(
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
|
||||
fontSize = 18.sp
|
||||
),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
}
|
||||
}
|
||||
|
||||
ConfirmationDialog(
|
||||
showDialog = showDialog,
|
||||
title = "Confirm device check bypass?",
|
||||
message = "Are you sure your device is supported with LibrePods?",
|
||||
confirmText = "Yes",
|
||||
dismissText = "No",
|
||||
title = stringResource(R.string.bypass_compatibility_check),
|
||||
message = stringResource(R.string.bypass_compatiblity_check_confirmation),
|
||||
confirmText = stringResource(R.string.yes),
|
||||
dismissText = stringResource(R.string.no),
|
||||
onConfirm = {
|
||||
showDialog.value = false
|
||||
sharedPreferences.edit {
|
||||
tapCount.intValue = 0
|
||||
putBoolean("bypass_device_check", true)
|
||||
val intent = Intent(context, MainActivity::class.java)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||
context.startActivity(intent)
|
||||
if (BuildConfig.PLAY_BUILD) {
|
||||
showPlayBypassVisible.value = true
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
}
|
||||
},
|
||||
onDismiss = {
|
||||
@@ -358,6 +328,26 @@ fun Main() {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -371,8 +361,6 @@ fun Main() {
|
||||
)
|
||||
}
|
||||
|
||||
BillingManager.provider = BillingProviderFactory.create(context)
|
||||
|
||||
val bluetoothPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
listOf(
|
||||
"android.permission.BLUETOOTH_CONNECT",
|
||||
@@ -508,7 +496,7 @@ fun Main() {
|
||||
if (airPodsViewModel != null) VersionScreen(airPodsViewModel)
|
||||
}
|
||||
composable("hearing_protection") {
|
||||
if (airPodsViewModel != null) HearingProtectionScreen(airPodsViewModel)
|
||||
if (airPodsViewModel != null) HearingProtectionScreen(airPodsViewModel, navController)
|
||||
}
|
||||
composable("purchase_screen") {
|
||||
val purchaseViewModel: PurchaseViewModel = viewModel()
|
||||
@@ -523,10 +511,6 @@ fun Main() {
|
||||
navController.addOnDestinationChangedListener { _, destination, _ ->
|
||||
showBackButton.value =
|
||||
destination.route != "settings" // && destination.route != "onboarding"
|
||||
Log.d(
|
||||
"MainActivity",
|
||||
"Navigated to ${destination.route}, showBackButton: ${showBackButton.value}"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,4 +26,5 @@ interface BillingProvider {
|
||||
val price: StateFlow<String>
|
||||
fun purchase(activity: Activity)
|
||||
fun queryPurchases()
|
||||
fun restorePurchases()
|
||||
}
|
||||
|
||||
@@ -31,13 +31,13 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.kavishdevar.librepods.R
|
||||
|
||||
class FOSSBillingProvider(context: Context): BillingProvider {
|
||||
private val _isPremium = MutableStateFlow(false)
|
||||
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
|
||||
|
||||
private val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
@@ -57,11 +57,9 @@ class FOSSBillingProvider(context: Context): BillingProvider {
|
||||
purchaseJob?.cancel()
|
||||
|
||||
purchaseJob = scope.launch {
|
||||
delay(2_000)
|
||||
withContext(Dispatchers.Main) {
|
||||
_isPremium.value = true
|
||||
sharedPreferences.edit { putBoolean("foss_upgraded", true) }
|
||||
}
|
||||
delay(5_000)
|
||||
_isPremium.value = true
|
||||
sharedPreferences.edit { putBoolean("foss_upgraded", true) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,4 +69,9 @@ class FOSSBillingProvider(context: Context): BillingProvider {
|
||||
_isPremium.value = stored
|
||||
}
|
||||
}
|
||||
|
||||
override fun restorePurchases() {
|
||||
_isPremium.value = true
|
||||
sharedPreferences.edit { putBoolean("foss_upgraded", true) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,21 +162,19 @@ class PlayBillingProvider(
|
||||
it.purchaseState == Purchase.PurchaseState.PURCHASED
|
||||
}
|
||||
|
||||
|
||||
// val navigateToPurchase = purchases.find {
|
||||
// val purchase = purchases.find {
|
||||
// it.products.contains(PREMIUM_PRODUCT_ID) && it.purchaseState == Purchase.PurchaseState.PURCHASED
|
||||
// }
|
||||
//
|
||||
// if (navigateToPurchase != null) {
|
||||
// if (purchase != null) {
|
||||
// val consumeParams = ConsumeParams.newBuilder()
|
||||
// .setPurchaseToken(navigateToPurchase.purchaseToken)
|
||||
// .setPurchaseToken(purchase.purchaseToken)
|
||||
// .build()
|
||||
// scope.launch {
|
||||
// billingClient.consumeAsync(consumeParams) { _, _ ->}
|
||||
// }
|
||||
// }
|
||||
|
||||
|
||||
_isPremium.value = hasPremium
|
||||
|
||||
scope.launch {
|
||||
@@ -201,4 +199,8 @@ class PlayBillingProvider(
|
||||
queryExistingPurchases()
|
||||
}
|
||||
}
|
||||
|
||||
override fun restorePurchases() {
|
||||
queryPurchases()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,7 +363,13 @@ class AACPManager {
|
||||
}
|
||||
val key = ByteArray(keyLength)
|
||||
System.arraycopy(data, offset, key, 0, keyLength)
|
||||
keys[ProximityKeyType.fromByte(keyType)] = key
|
||||
try {
|
||||
keys[ProximityKeyType.fromByte(keyType)] = key
|
||||
} catch (e: Exception) {
|
||||
Log.e(
|
||||
TAG, "incorrect key type received: $keyType, ${key.toHexString()}"
|
||||
)
|
||||
}
|
||||
offset += keyLength
|
||||
Log.d(
|
||||
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("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(byteArrayOf(0x52)) // 'R'
|
||||
buffer.put("HostStreamingState".toByteArray())
|
||||
|
||||
@@ -72,7 +72,11 @@ class ATTManager(private val adapter: BluetoothAdapter, private val device: Blue
|
||||
fun connect() {
|
||||
val uuid = ParcelUuid.fromString("00000000-0000-0000-0000-000000000000")
|
||||
|
||||
socket = createBluetoothSocket(adapter, device, uuid)
|
||||
try {
|
||||
socket = createBluetoothSocket(adapter, device, uuid)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to create socket")
|
||||
}
|
||||
try {
|
||||
socket!!.connect()
|
||||
} 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 {
|
||||
val type = 3 // L2CAP
|
||||
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, 1, true, true, 31, uuid),
|
||||
arrayOf(type, 1, true, true, device, 31, uuid),
|
||||
|
||||
@@ -72,6 +72,7 @@ enum class NoiseControlMode {
|
||||
class AirPodsNotifications {
|
||||
companion object {
|
||||
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 EAR_DETECTION_DATA = "me.kavishdevar.librepods.EAR_DETECTION_DATA"
|
||||
const val ANC_DATA = "me.kavishdevar.librepods.ANC_DATA"
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,8 +77,10 @@ fun StyledButton(
|
||||
tint: Color = Color.Unspecified,
|
||||
surfaceColor: Color = Color.Unspecified,
|
||||
maxScale: Float = 0.1f,
|
||||
enabled: Boolean = true,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) {
|
||||
val isInteractive = enabled && isInteractive
|
||||
val scope = rememberCoroutineScope()
|
||||
val haptics = LocalHapticFeedback.current
|
||||
val progressAnimation = remember { Animatable(0f) }
|
||||
@@ -125,8 +127,8 @@ half4 main(float2 coord) {
|
||||
} else {
|
||||
drawRect(Color.White.copy(0.1f))
|
||||
}
|
||||
if (surfaceColor.isSpecified) {
|
||||
val color = if (!isInteractive && isPressed) {
|
||||
if (surfaceColor.isSpecified && enabled) {
|
||||
val color = if (isPressed) {
|
||||
Color(
|
||||
red = surfaceColor.red * 0.5f,
|
||||
green = surfaceColor.green * 0.5f,
|
||||
@@ -137,6 +139,11 @@ half4 main(float2 coord) {
|
||||
surfaceColor
|
||||
}
|
||||
drawRect(color)
|
||||
} else {
|
||||
if (isPressed) {
|
||||
drawRect(Color.Black.copy(alpha = 0.4f))
|
||||
drawRect(Color.White.copy(alpha = 0.2f))
|
||||
}
|
||||
}
|
||||
},
|
||||
onDrawFront = null,
|
||||
@@ -245,8 +252,10 @@ half4 main(float2 coord) {
|
||||
indication = null,
|
||||
role = Role.Button,
|
||||
onClick = {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
|
||||
onClick()
|
||||
if (enabled) {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
|
||||
onClick()
|
||||
}
|
||||
}
|
||||
)
|
||||
.then(
|
||||
@@ -302,8 +311,10 @@ half4 main(float2 coord) {
|
||||
isPressed = false
|
||||
},
|
||||
onTap = {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
|
||||
onClick()
|
||||
if (enabled) {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
|
||||
onClick()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -74,7 +73,6 @@ fun StyledSelectList(
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val haptics = LocalHapticFeedback.current
|
||||
|
||||
Column(
|
||||
|
||||
@@ -20,6 +20,7 @@ package me.kavishdevar.librepods.presentation.screens
|
||||
|
||||
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
||||
import android.annotation.SuppressLint
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
@@ -39,6 +40,7 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
@@ -71,6 +73,10 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
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 me.kavishdevar.librepods.R
|
||||
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 kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
//private var phoneMediaDebounceJob: Job? = null
|
||||
//private var toneVolumeDebounceJob: Job? = null
|
||||
private var phoneMediaDebounceJob: Job? = null
|
||||
private var toneVolumeDebounceJob: Job? = null
|
||||
//private const val TAG = "AccessibilitySettings"
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
@@ -99,7 +105,13 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
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()
|
||||
|
||||
@@ -125,7 +137,7 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
|
||||
backdrop = rememberLayerBackdrop(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxScale = 0.05f,
|
||||
tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
|
||||
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.unlock_advanced_features),
|
||||
@@ -149,7 +161,10 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
|
||||
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 {
|
||||
mutableStateOf(
|
||||
pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0]
|
||||
@@ -162,7 +177,10 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
|
||||
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 {
|
||||
mutableStateOf(
|
||||
pressAndHoldDurationOptions[selectedPressAndHoldDurationValue]
|
||||
@@ -175,7 +193,10 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
|
||||
2.toByte() to stringResource(R.string.longer),
|
||||
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 {
|
||||
mutableStateOf(
|
||||
volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue]
|
||||
@@ -183,43 +204,42 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
|
||||
)
|
||||
}
|
||||
|
||||
// LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) {
|
||||
// phoneMediaDebounceJob?.cancel()
|
||||
// phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch {
|
||||
// delay(150)
|
||||
// val manager = ServiceManager.getService()?.aacpManager
|
||||
// if (manager == null) {
|
||||
// Log.w(TAG, "Cannot write EQ: AACPManager not available")
|
||||
// return@launch
|
||||
// }
|
||||
// try {
|
||||
// val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte()
|
||||
// val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte()
|
||||
// Log.d(
|
||||
// TAG,
|
||||
// "Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})"
|
||||
// )
|
||||
// manager.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte)
|
||||
// } catch (e: Exception) {
|
||||
// Log.w(TAG, "Error sending phone/media EQ: ${e.message}")
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
Box (
|
||||
val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
|
||||
val phoneEQEnabled = remember { mutableStateOf(false) }
|
||||
val mediaEQEnabled = remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) {
|
||||
phoneMediaDebounceJob?.cancel()
|
||||
phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch {
|
||||
delay(150)
|
||||
try {
|
||||
val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte()
|
||||
val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte()
|
||||
Log.d(
|
||||
"AccessibilitySettingsScreen",
|
||||
"Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})"
|
||||
)
|
||||
viewModel.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte)
|
||||
} catch (e: Exception) {
|
||||
Log.w(
|
||||
"AccessibilitySettingsScreen",
|
||||
"Error sending phone/media EQ: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.then(
|
||||
if (!state.isPremium) {
|
||||
Modifier
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||
event.changes.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else Modifier
|
||||
)
|
||||
) {
|
||||
Modifier.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||
event.changes.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else Modifier)) {
|
||||
DropdownMenuComponent(
|
||||
label = stringResource(R.string.press_speed),
|
||||
description = stringResource(R.string.press_speed_description),
|
||||
@@ -239,21 +259,18 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
|
||||
)
|
||||
}
|
||||
|
||||
Box (
|
||||
Box(
|
||||
modifier = Modifier.then(
|
||||
if (!state.isPremium) {
|
||||
Modifier
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||
event.changes.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else Modifier
|
||||
)
|
||||
) {
|
||||
if (!state.isPremium) {
|
||||
Modifier.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||
event.changes.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else Modifier)) {
|
||||
DropdownMenuComponent(
|
||||
label = stringResource(R.string.press_and_hold_duration),
|
||||
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),
|
||||
description = stringResource(R.string.noise_cancellation_single_airpod_description),
|
||||
independent = true,
|
||||
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE]?.getOrNull(0) == 0x01.toByte(),
|
||||
onCheckedChange = { viewModel.setControlCommandBoolean(AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE, it) },
|
||||
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE]?.getOrNull(
|
||||
0
|
||||
) == 0x01.toByte(),
|
||||
onCheckedChange = {
|
||||
viewModel.setControlCommandBoolean(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE, it
|
||||
)
|
||||
},
|
||||
enabled = state.isPremium
|
||||
)
|
||||
|
||||
@@ -288,7 +311,12 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
|
||||
label = stringResource(R.string.loud_sound_reduction),
|
||||
description = stringResource(R.string.loud_sound_reduction_description),
|
||||
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
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
label = stringResource(R.string.tone_volume),
|
||||
description = stringResource(R.string.tone_volume_description),
|
||||
value = toneVolumeValue,
|
||||
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,
|
||||
snapPoints = listOf(75f),
|
||||
@@ -319,30 +353,34 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
|
||||
)
|
||||
|
||||
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(
|
||||
label = stringResource(R.string.volume_control),
|
||||
description = stringResource(R.string.volume_control_description),
|
||||
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
|
||||
)
|
||||
|
||||
Box (
|
||||
Box(
|
||||
modifier = Modifier.then(
|
||||
if (!state.isPremium) {
|
||||
Modifier
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||
event.changes.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else Modifier
|
||||
)
|
||||
) {
|
||||
Modifier.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||
event.changes.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else Modifier)) {
|
||||
DropdownMenuComponent(
|
||||
label = stringResource(R.string.volume_swipe_speed),
|
||||
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 = stringResource(R.string.apply_eq_to),
|
||||
// style = TextStyle(
|
||||
// text = stringResource(R.string.apply_eq_to), style = TextStyle(
|
||||
// fontSize = 14.sp,
|
||||
// fontWeight = FontWeight.Bold,
|
||||
// color = textColor.copy(alpha = 0.6f),
|
||||
// fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
// ),
|
||||
// modifier = Modifier.padding(8.dp, bottom = 0.dp)
|
||||
// ), modifier = Modifier.padding(8.dp, bottom = 0.dp)
|
||||
// )
|
||||
// Column(
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
// .background(
|
||||
// if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF),
|
||||
// RoundedCornerShape(28.dp)
|
||||
// )
|
||||
// .padding(vertical = 0.dp)
|
||||
// ) {
|
||||
// val darkModeLocal = isSystemInDarkTheme()
|
||||
@@ -405,17 +444,19 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
|
||||
// detectTapGestures(
|
||||
// onPress = {
|
||||
// phoneBackgroundColor =
|
||||
// if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9)
|
||||
// if (darkModeLocal) Color(0x40888888) else Color(
|
||||
// 0x40D9D9D9
|
||||
// )
|
||||
// tryAwaitRelease()
|
||||
// phoneBackgroundColor =
|
||||
// if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
// if (darkModeLocal) Color(0xFF1C1C1E) else Color(
|
||||
// 0xFFFFFFFF
|
||||
// )
|
||||
// phoneEQEnabled.value = !phoneEQEnabled.value
|
||||
// }
|
||||
// )
|
||||
// })
|
||||
// }
|
||||
// .padding(horizontal = 16.dp),
|
||||
// verticalAlignment = Alignment.CenterVertically
|
||||
// ) {
|
||||
// verticalAlignment = Alignment.CenterVertically) {
|
||||
// Text(
|
||||
// stringResource(R.string.phone),
|
||||
// fontSize = 16.sp,
|
||||
@@ -441,8 +482,7 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
|
||||
// }
|
||||
//
|
||||
// HorizontalDivider(
|
||||
// thickness = 1.dp,
|
||||
// color = Color(0x40888888)
|
||||
// thickness = 1.dp, color = Color(0x40888888)
|
||||
// )
|
||||
//
|
||||
// val mediaShape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)
|
||||
@@ -467,17 +507,19 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
|
||||
// detectTapGestures(
|
||||
// onPress = {
|
||||
// mediaBackgroundColor =
|
||||
// if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9)
|
||||
// if (darkModeLocal) Color(0x40888888) else Color(
|
||||
// 0x40D9D9D9
|
||||
// )
|
||||
// tryAwaitRelease()
|
||||
// mediaBackgroundColor =
|
||||
// if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
// if (darkModeLocal) Color(0xFF1C1C1E) else Color(
|
||||
// 0xFFFFFFFF
|
||||
// )
|
||||
// mediaEQEnabled.value = !mediaEQEnabled.value
|
||||
// }
|
||||
// )
|
||||
// })
|
||||
// }
|
||||
// .padding(horizontal = 16.dp),
|
||||
// verticalAlignment = Alignment.CenterVertically
|
||||
// ) {
|
||||
// verticalAlignment = Alignment.CenterVertically) {
|
||||
// Text(
|
||||
// stringResource(R.string.media),
|
||||
// fontSize = 16.sp,
|
||||
@@ -502,90 +544,97 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
|
||||
// EQ Settings. Don't seem to have an effect?
|
||||
// Column(
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
// .padding(12.dp),
|
||||
// horizontalAlignment = Alignment.CenterHorizontally
|
||||
// ) {
|
||||
// for (i in 0 until 8) {
|
||||
// val eqPhoneValue =
|
||||
// remember(phoneMediaEQ.value[i]) { mutableFloatStateOf(phoneMediaEQ.value[i]) }
|
||||
// Row(
|
||||
// horizontalArrangement = Arrangement.SpaceBetween,
|
||||
// verticalAlignment = Alignment.CenterVertically,
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .height(38.dp)
|
||||
// ) {
|
||||
// Text(
|
||||
// text = String.format("%.2f", eqPhoneValue.floatValue),
|
||||
// fontSize = 12.sp,
|
||||
// color = textColor,
|
||||
// modifier = Modifier.padding(bottom = 4.dp)
|
||||
// )
|
||||
|
||||
// Slider(
|
||||
// value = eqPhoneValue.floatValue,
|
||||
// onValueChange = { newVal ->
|
||||
// eqPhoneValue.floatValue = newVal
|
||||
// val newEQ = phoneMediaEQ.value.copyOf()
|
||||
// newEQ[i] = eqPhoneValue.floatValue
|
||||
// phoneMediaEQ.value = newEQ
|
||||
// },
|
||||
// valueRange = 0f..100f,
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth(0.9f)
|
||||
// .height(36.dp),
|
||||
// colors = SliderDefaults.colors(
|
||||
// thumbColor = thumbColor,
|
||||
// activeTrackColor = activeTrackColor,
|
||||
// inactiveTrackColor = trackColor
|
||||
// ),
|
||||
// thumb = {
|
||||
// Box(
|
||||
// modifier = Modifier
|
||||
// .size(24.dp)
|
||||
// .shadow(4.dp, CircleShape)
|
||||
// .background(thumbColor, CircleShape)
|
||||
// )
|
||||
// },
|
||||
// track = {
|
||||
// Box(
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .height(12.dp),
|
||||
// contentAlignment = Alignment.CenterStart
|
||||
// )
|
||||
// {
|
||||
// Box(
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .height(4.dp)
|
||||
// .background(trackColor, RoundedCornerShape(4.dp))
|
||||
// )
|
||||
// Box(
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth(eqPhoneValue.floatValue / 100f)
|
||||
// .height(4.dp)
|
||||
// .background(activeTrackColor, RoundedCornerShape(4.dp))
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// )
|
||||
|
||||
// Text(
|
||||
// text = stringResource(R.string.band_label, i + 1),
|
||||
// fontSize = 12.sp,
|
||||
// color = textColor,
|
||||
// modifier = Modifier.padding(top = 4.dp)
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
//// EQ Settings. Don't seem to have an effect?
|
||||
// Column(
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .background(
|
||||
// if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF),
|
||||
// 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) {
|
||||
// val eqPhoneValue =
|
||||
// remember(phoneMediaEQ.value[i]) { mutableFloatStateOf(phoneMediaEQ.value[i]) }
|
||||
// Row(
|
||||
// horizontalArrangement = Arrangement.SpaceBetween,
|
||||
// verticalAlignment = Alignment.CenterVertically,
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .height(38.dp)
|
||||
// ) {
|
||||
// Text(
|
||||
// text = String.format("%.2f", eqPhoneValue.floatValue),
|
||||
// fontSize = 12.sp,
|
||||
// color = textColor,
|
||||
// modifier = Modifier.padding(bottom = 4.dp)
|
||||
// )
|
||||
//
|
||||
// Slider(
|
||||
// value = eqPhoneValue.floatValue,
|
||||
// onValueChange = { newVal ->
|
||||
// eqPhoneValue.floatValue = newVal
|
||||
// val newEQ = phoneMediaEQ.value.copyOf()
|
||||
// newEQ[i] = eqPhoneValue.floatValue
|
||||
// phoneMediaEQ.value = newEQ
|
||||
// },
|
||||
// valueRange = 0f..100f,
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth(0.9f)
|
||||
// .height(36.dp),
|
||||
// colors = SliderDefaults.colors(
|
||||
// thumbColor = thumbColor,
|
||||
// activeTrackColor = activeTrackColor,
|
||||
// inactiveTrackColor = trackColor
|
||||
// ),
|
||||
// thumb = {
|
||||
// Box(
|
||||
// modifier = Modifier
|
||||
// .size(24.dp)
|
||||
// .shadow(4.dp, CircleShape)
|
||||
// .background(thumbColor, CircleShape)
|
||||
// )
|
||||
// },
|
||||
// track = {
|
||||
// Box(
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .height(12.dp),
|
||||
// contentAlignment = Alignment.CenterStart
|
||||
// ) {
|
||||
// Box(
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .height(4.dp)
|
||||
// .background(trackColor, RoundedCornerShape(4.dp))
|
||||
// )
|
||||
// Box(
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth(eqPhoneValue.floatValue / 100f)
|
||||
// .height(4.dp)
|
||||
// .background(
|
||||
// activeTrackColor, RoundedCornerShape(4.dp)
|
||||
// )
|
||||
// )
|
||||
// }
|
||||
// })
|
||||
//
|
||||
// Text(
|
||||
// text = stringResource(R.string.band_label, i + 1),
|
||||
// fontSize = 12.sp,
|
||||
// color = textColor,
|
||||
// modifier = Modifier.padding(top = 4.dp)
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
}
|
||||
}
|
||||
@@ -616,7 +665,7 @@ private fun DropdownMenuComponent(
|
||||
val haptics = LocalHapticFeedback.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()){
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -630,14 +679,14 @@ private fun DropdownMenuComponent(
|
||||
} else Modifier
|
||||
)
|
||||
.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))
|
||||
){
|
||||
) then (if (independent) Modifier.padding(horizontal = 4.dp) else Modifier).clip(
|
||||
if (independent) RoundedCornerShape(28.dp) else RoundedCornerShape(0.dp)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -658,98 +707,94 @@ private fun DropdownMenuComponent(
|
||||
}
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = { offset ->
|
||||
val now = System.currentTimeMillis()
|
||||
touchOffset = offset
|
||||
if (!expanded && now - lastDismissTime > 250L) {
|
||||
expanded = true
|
||||
}
|
||||
lastDismissTime = now
|
||||
parentDragActive = true
|
||||
parentHoveredIndex = 0
|
||||
},
|
||||
onDrag = { change, _ ->
|
||||
val current = change.position
|
||||
val touch = touchOffset ?: current
|
||||
val posInPopupY = current.y - touch.y
|
||||
val idx = (posInPopupY / itemHeightPx).toInt()
|
||||
if (idx != previousIdx) {
|
||||
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) }
|
||||
}
|
||||
parentHoveredIndex = idx
|
||||
previousIdx = idx
|
||||
},
|
||||
onDragEnd = {
|
||||
parentDragActive = false
|
||||
parentHoveredIndex?.let { idx ->
|
||||
if (idx in options.indices) {
|
||||
onOptionSelected(options[idx])
|
||||
expanded = false
|
||||
lastDismissTime = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
if (parentHoveredIndex != null && parentHoveredIndex in options.indices) {
|
||||
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) }
|
||||
}
|
||||
parentHoveredIndex = null
|
||||
},
|
||||
onDragCancel = {
|
||||
parentDragActive = false
|
||||
parentHoveredIndex = null
|
||||
detectDragGesturesAfterLongPress(onDragStart = { offset ->
|
||||
val now = System.currentTimeMillis()
|
||||
touchOffset = offset
|
||||
if (!expanded && now - lastDismissTime > 250L) {
|
||||
expanded = true
|
||||
}
|
||||
)
|
||||
lastDismissTime = now
|
||||
parentDragActive = true
|
||||
parentHoveredIndex = 0
|
||||
}, onDrag = { change, _ ->
|
||||
val current = change.position
|
||||
val touch = touchOffset ?: current
|
||||
val posInPopupY = current.y - touch.y
|
||||
val idx = (posInPopupY / itemHeightPx).toInt()
|
||||
if (idx != previousIdx) {
|
||||
scope.launch {
|
||||
haptics.performHapticFeedback(
|
||||
HapticFeedbackType.SegmentTick
|
||||
)
|
||||
}
|
||||
}
|
||||
parentHoveredIndex = idx
|
||||
previousIdx = idx
|
||||
}, onDragEnd = {
|
||||
parentDragActive = false
|
||||
parentHoveredIndex?.let { idx ->
|
||||
if (idx in options.indices) {
|
||||
onOptionSelected(options[idx])
|
||||
expanded = false
|
||||
lastDismissTime = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
if (parentHoveredIndex != null && parentHoveredIndex in options.indices) {
|
||||
scope.launch {
|
||||
haptics.performHapticFeedback(
|
||||
HapticFeedbackType.GestureEnd
|
||||
)
|
||||
}
|
||||
}
|
||||
parentHoveredIndex = null
|
||||
}, onDragCancel = {
|
||||
parentDragActive = false
|
||||
parentHoveredIndex = null
|
||||
})
|
||||
},
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
){
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
if (!independent && description != null){
|
||||
if (!independent && description != null) {
|
||||
Text(
|
||||
text = description,
|
||||
style = TextStyle(
|
||||
text = description, style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
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(
|
||||
modifier = Modifier.onGloballyPositioned { coordinates ->
|
||||
boxPosition = coordinates.positionInParent()
|
||||
}
|
||||
) {
|
||||
}) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = selectedOption,
|
||||
style = TextStyle(
|
||||
text = selectedOption, style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor.copy(alpha = 0.8f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = "",
|
||||
style = TextStyle(
|
||||
text = "", style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(start = 6.dp)
|
||||
), modifier = Modifier.padding(start = 6.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -774,19 +819,22 @@ private fun DropdownMenuComponent(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (independent && description != null){
|
||||
if (independent && description != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.background(if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7))
|
||||
){
|
||||
.background(
|
||||
if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7)
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = description,
|
||||
style = TextStyle(
|
||||
text = description, style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
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))
|
||||
)
|
||||
)
|
||||
|
||||
@@ -81,7 +81,7 @@ fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel, navController: NavContro
|
||||
backdrop = rememberLayerBackdrop(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxScale = 0.05f,
|
||||
tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
|
||||
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.unlock_advanced_features),
|
||||
|
||||
@@ -239,13 +239,11 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl
|
||||
|
||||
item(key = "spacer_call") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||
item(key = "call_control") {
|
||||
val flipped =
|
||||
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG]?.take(
|
||||
2
|
||||
)?.equals(byteArrayOf(0x00.toByte(), 0x02.toByte()))
|
||||
val bytes = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG]?.take(2)?.toByteArray() ?: byteArrayOf(0x00, 0x00)
|
||||
val flipped = bytes[1] == 0x02.toByte()
|
||||
CallControlSettings(
|
||||
hazeState = hazeState,
|
||||
flipped = flipped == true,
|
||||
flipped = flipped,
|
||||
onCallControlValueChanged = {
|
||||
viewModel.setControlCommandValue(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG,
|
||||
@@ -277,7 +275,7 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl
|
||||
backdrop = rememberLayerBackdrop(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxScale = 0.05f,
|
||||
tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(
|
||||
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(
|
||||
0xFFE59900
|
||||
)
|
||||
) {
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
package me.kavishdevar.librepods.presentation.screens
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
@@ -71,13 +72,13 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import me.kavishdevar.librepods.BuildConfig
|
||||
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.StyledButton
|
||||
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||
import me.kavishdevar.librepods.presentation.components.StyledSlider
|
||||
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
|
||||
import java.util.Locale.getDefault
|
||||
|
||||
@Composable
|
||||
fun AppSettingsScreen(
|
||||
@@ -106,7 +107,7 @@ fun AppSettingsScreen(
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
if (!state.isPremium) {
|
||||
if (!state.isPremium && state.connectionSuccessful) {
|
||||
StyledButton(
|
||||
onClick = {
|
||||
navController.navigate("purchase_screen")
|
||||
@@ -114,7 +115,7 @@ fun AppSettingsScreen(
|
||||
backdrop = rememberLayerBackdrop(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxScale = 0.05f,
|
||||
tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
|
||||
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.unlock_advanced_features),
|
||||
@@ -128,249 +129,271 @@ fun AppSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
StyledToggle(
|
||||
title = stringResource(R.string.widget),
|
||||
label = stringResource(R.string.show_phone_battery_in_widget),
|
||||
description = stringResource(R.string.show_phone_battery_in_widget_description),
|
||||
checked = state.showPhoneBatteryInWidget,
|
||||
onCheckedChange = viewModel::setShowPhoneBatteryInWidget,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.conversational_awareness), style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
backgroundColor, RoundedCornerShape(28.dp)
|
||||
)
|
||||
.padding(vertical = 4.dp)
|
||||
) {
|
||||
if (state.connectionSuccessful) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.conversational_awareness_pause_music),
|
||||
description = stringResource(R.string.conversational_awareness_pause_music_description),
|
||||
checked = state.conversationalAwarenessPauseMusicEnabled,
|
||||
onCheckedChange = viewModel::setConversationalAwarenessPauseMusicEnabled,
|
||||
independent = false,
|
||||
title = stringResource(R.string.widget),
|
||||
label = stringResource(R.string.show_phone_battery_in_widget),
|
||||
description = stringResource(R.string.show_phone_battery_in_widget_description),
|
||||
checked = state.showPhoneBatteryInWidget,
|
||||
onCheckedChange = viewModel::setShowPhoneBatteryInWidget,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier.padding(horizontal = 12.dp)
|
||||
Text(
|
||||
text = stringResource(R.string.conversational_awareness), style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
|
||||
)
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.relative_conversational_awareness_volume),
|
||||
description = stringResource(R.string.relative_conversational_awareness_volume_description),
|
||||
checked = state.relativeConversationalAwarenessVolumeEnabled,
|
||||
onCheckedChange = viewModel::setRelativeConversationalAwarenessVolumeEnabled,
|
||||
independent = false,
|
||||
enabled = state.isPremium,
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
backgroundColor, RoundedCornerShape(28.dp)
|
||||
)
|
||||
.padding(vertical = 4.dp)
|
||||
) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.conversational_awareness_pause_music),
|
||||
description = stringResource(R.string.conversational_awareness_pause_music_description),
|
||||
checked = state.conversationalAwarenessPauseMusicEnabled,
|
||||
onCheckedChange = viewModel::setConversationalAwarenessPauseMusicEnabled,
|
||||
independent = false,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
|
||||
val conversationalAwarenessVolume = state.conversationalAwarenessVolume
|
||||
LaunchedEffect(conversationalAwarenessVolume) {
|
||||
viewModel.setConversationalAwarenessVolume(conversationalAwarenessVolume)
|
||||
}
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier.padding(horizontal = 12.dp)
|
||||
)
|
||||
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.conversational_awareness_volume),
|
||||
value = conversationalAwarenessVolume,
|
||||
valueRange = 10f..85f,
|
||||
snapPoints = listOf(44f),
|
||||
startLabel = "10%",
|
||||
endLabel = "85%",
|
||||
onValueChange = { newValue -> viewModel.setConversationalAwarenessVolume(newValue) },
|
||||
independent = true,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.relative_conversational_awareness_volume),
|
||||
description = stringResource(R.string.relative_conversational_awareness_volume_description),
|
||||
checked = state.relativeConversationalAwarenessVolumeEnabled,
|
||||
onCheckedChange = viewModel::setRelativeConversationalAwarenessVolumeEnabled,
|
||||
independent = false,
|
||||
enabled = state.isPremium,
|
||||
)
|
||||
}
|
||||
|
||||
if (!BuildConfig.PLAY_BUILD) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
NavigationButton(
|
||||
to = "",
|
||||
title = stringResource(R.string.camera_control),
|
||||
name = stringResource(R.string.set_custom_camera_package),
|
||||
navController = navController,
|
||||
onClick = {
|
||||
if (state.isPremium) viewModel.setShowCameraDialog(true)
|
||||
val conversationalAwarenessVolume = state.conversationalAwarenessVolume
|
||||
LaunchedEffect(conversationalAwarenessVolume) {
|
||||
viewModel.setConversationalAwarenessVolume(conversationalAwarenessVolume)
|
||||
}
|
||||
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.conversational_awareness_volume),
|
||||
value = conversationalAwarenessVolume,
|
||||
valueRange = 10f..85f,
|
||||
snapPoints = listOf(44f),
|
||||
startLabel = "10%",
|
||||
endLabel = "85%",
|
||||
onValueChange = { newValue ->
|
||||
viewModel.setConversationalAwarenessVolume(
|
||||
newValue
|
||||
)
|
||||
},
|
||||
independent = true,
|
||||
description = stringResource(R.string.camera_control_app_description)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
if (BuildConfig.FLAVOR == "xposed") {
|
||||
StyledToggle(
|
||||
title = stringResource(R.string.ear_detection),
|
||||
label = stringResource(R.string.disconnect_when_not_wearing),
|
||||
description = stringResource(R.string.disconnect_when_not_wearing_description),
|
||||
checked = state.disconnectWhenNotWearing,
|
||||
onCheckedChange = viewModel::setDisconnectWhenNotWearing,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.takeover_airpods_state), style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
|
||||
)
|
||||
// if (!BuildConfig.PLAY_BUILD) {
|
||||
// Spacer(modifier = Modifier.height(16.dp))
|
||||
//
|
||||
// NavigationButton(
|
||||
// to = "",
|
||||
// title = stringResource(R.string.camera_control),
|
||||
// name = stringResource(R.string.set_custom_camera_package),
|
||||
// navController = navController,
|
||||
// onClick = {
|
||||
// if (state.isPremium) viewModel.setShowCameraDialog(true)
|
||||
// },
|
||||
// independent = true,
|
||||
// description = stringResource(R.string.camera_control_app_description)
|
||||
// )
|
||||
// }
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
backgroundColor, RoundedCornerShape(28.dp)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
if (context.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) {
|
||||
StyledToggle(
|
||||
title = stringResource(R.string.ear_detection),
|
||||
label = stringResource(R.string.disconnect_when_not_wearing),
|
||||
description = stringResource(R.string.disconnect_when_not_wearing_description),
|
||||
checked = state.disconnectWhenNotWearing,
|
||||
onCheckedChange = viewModel::setDisconnectWhenNotWearing,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
.padding(vertical = 4.dp)
|
||||
) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.takeover_disconnected),
|
||||
description = stringResource(R.string.takeover_disconnected_desc),
|
||||
checked = state.takeoverWhenDisconnected,
|
||||
onCheckedChange = viewModel::setTakeoverWhenDisconnected,
|
||||
independent = false,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier.padding(horizontal = 12.dp)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.takeover_airpods_state), style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
|
||||
)
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.takeover_idle),
|
||||
description = stringResource(R.string.takeover_idle_desc),
|
||||
checked = state.takeoverWhenIdle,
|
||||
onCheckedChange = viewModel::setTakeoverWhenIdle,
|
||||
independent = false,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier.padding(horizontal = 12.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.takeover_music),
|
||||
description = stringResource(R.string.takeover_music_desc),
|
||||
checked = state.takeoverWhenMusic,
|
||||
onCheckedChange = viewModel::setTakeoverWhenMusic,
|
||||
independent = false,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier.padding(horizontal = 12.dp)
|
||||
)
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.takeover_call),
|
||||
description = stringResource(R.string.takeover_call_desc),
|
||||
checked = state.takeoverWhenCall,
|
||||
onCheckedChange = viewModel::setTakeoverWhenCall,
|
||||
independent = false,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.takeover_phone_state), style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
), modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
backgroundColor, RoundedCornerShape(28.dp)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
backgroundColor, RoundedCornerShape(28.dp)
|
||||
)
|
||||
.padding(vertical = 4.dp)
|
||||
) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.takeover_disconnected),
|
||||
description = stringResource(R.string.takeover_disconnected_desc),
|
||||
checked = state.takeoverWhenDisconnected,
|
||||
onCheckedChange = viewModel::setTakeoverWhenDisconnected,
|
||||
independent = false,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
.padding(vertical = 4.dp)
|
||||
) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.takeover_ringing_call),
|
||||
description = stringResource(R.string.takeover_ringing_call_desc),
|
||||
checked = state.takeoverWhenRingingCall,
|
||||
onCheckedChange = viewModel::setTakeoverWhenRingingCall,
|
||||
independent = false,
|
||||
enabled = state.isPremium
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier.padding(horizontal = 12.dp)
|
||||
)
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.takeover_idle),
|
||||
description = stringResource(R.string.takeover_idle_desc),
|
||||
checked = state.takeoverWhenIdle,
|
||||
onCheckedChange = viewModel::setTakeoverWhenIdle,
|
||||
independent = false,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier.padding(horizontal = 12.dp)
|
||||
)
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.takeover_music),
|
||||
description = stringResource(R.string.takeover_music_desc),
|
||||
checked = state.takeoverWhenMusic,
|
||||
onCheckedChange = viewModel::setTakeoverWhenMusic,
|
||||
independent = false,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier.padding(horizontal = 12.dp)
|
||||
)
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.takeover_call),
|
||||
description = stringResource(R.string.takeover_call_desc),
|
||||
checked = state.takeoverWhenCall,
|
||||
onCheckedChange = viewModel::setTakeoverWhenCall,
|
||||
independent = false,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.takeover_phone_state), style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
), modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier.padding(horizontal = 12.dp)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
backgroundColor, RoundedCornerShape(28.dp)
|
||||
)
|
||||
.padding(vertical = 4.dp)
|
||||
) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.takeover_ringing_call),
|
||||
description = stringResource(R.string.takeover_ringing_call_desc),
|
||||
checked = state.takeoverWhenRingingCall,
|
||||
onCheckedChange = viewModel::setTakeoverWhenRingingCall,
|
||||
independent = false,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = Color(0x40888888),
|
||||
modifier = Modifier.padding(horizontal = 12.dp)
|
||||
)
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.takeover_media_start),
|
||||
description = stringResource(R.string.takeover_media_start_desc),
|
||||
checked = state.takeoverWhenMediaStart,
|
||||
onCheckedChange = viewModel::setTakeoverWhenMediaStart,
|
||||
independent = false,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.advanced_options), style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.takeover_media_start),
|
||||
description = stringResource(R.string.takeover_media_start_desc),
|
||||
checked = state.takeoverWhenMediaStart,
|
||||
onCheckedChange = viewModel::setTakeoverWhenMediaStart,
|
||||
independent = false,
|
||||
label = stringResource(R.string.use_alternate_head_tracking_packets),
|
||||
description = stringResource(R.string.use_alternate_head_tracking_packets_description),
|
||||
checked = state.useAlternateHeadTrackingPackets,
|
||||
onCheckedChange = viewModel::setUseAlternateHeadTrackingPackets,
|
||||
independent = true,
|
||||
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)
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.advanced_options), style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.use_alternate_head_tracking_packets),
|
||||
description = stringResource(R.string.use_alternate_head_tracking_packets_description),
|
||||
checked = state.useAlternateHeadTrackingPackets,
|
||||
onCheckedChange = viewModel::setUseAlternateHeadTrackingPackets,
|
||||
independent = true,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
|
||||
|
||||
if (BuildConfig.FLAVOR == "xposed") {
|
||||
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(
|
||||
label = stringResource(R.string.act_as_an_apple_device),
|
||||
description = stringResource(R.string.act_as_an_apple_device_description) + "\n" + stringResource(
|
||||
R.string.requires_xposed
|
||||
).replaceFirstChar { if (it.isLowerCase()) it.titlecase(getDefault()) else it.toString() },
|
||||
label = stringResource(R.string.act_as_an_apple_device) + " (${
|
||||
stringResource(
|
||||
R.string.requires_xposed
|
||||
)
|
||||
})",
|
||||
description = stringResource(R.string.act_as_an_apple_device_description),
|
||||
checked = state.vendorIdHook,
|
||||
onCheckedChange = { enabled ->
|
||||
Toast.makeText(context, restartBluetoothText, Toast.LENGTH_SHORT).show()
|
||||
@@ -381,8 +404,8 @@ fun AppSettingsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
if (!BuildConfig.PLAY_BUILD) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
NavigationButton(
|
||||
to = "troubleshooting",
|
||||
name = stringResource(R.string.troubleshooting),
|
||||
@@ -420,15 +443,16 @@ fun AppSettingsScreen(
|
||||
val intent = Intent(Intent.ACTION_SENDTO).apply {
|
||||
data = "mailto:".toUri()
|
||||
putExtra(Intent.EXTRA_EMAIL, arrayOf("contact@kavish.xyz"))
|
||||
putExtra(Intent.EXTRA_SUBJECT, "LibrePods: ")
|
||||
putExtra(Intent.EXTRA_SUBJECT, "LibrePods: <SUBJECT>")
|
||||
putExtra(
|
||||
Intent.EXTRA_TEXT,
|
||||
"\n\n\n----------" +
|
||||
"Describe your issue here:" +
|
||||
"\n\n\n\n----------" +
|
||||
"\nPhone details:" +
|
||||
"\nDEVICE: ${Build.DEVICE}" +
|
||||
"\nMANUFACTURER: ${Build.MANUFACTURER} (${Build.BRAND})" +
|
||||
"\nMANUFACTURER: ${Build.MANUFACTURER}" +
|
||||
"\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:" +
|
||||
"\nVERSION: ${BuildConfig.VERSION_NAME}" +
|
||||
"\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 = stringResource(R.string.about), style = TextStyle(
|
||||
@@ -488,7 +513,7 @@ fun AppSettingsScreen(
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
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) }
|
||||
|
||||
@@ -175,7 +175,7 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController
|
||||
backdrop = rememberLayerBackdrop(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxScale = 0.05f,
|
||||
tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
|
||||
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.unlock_advanced_features),
|
||||
|
||||
@@ -18,29 +18,39 @@
|
||||
|
||||
package me.kavishdevar.librepods.presentation.screens
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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 androidx.navigation.NavController
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import me.kavishdevar.librepods.BuildConfig
|
||||
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.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
|
||||
|
||||
@Composable
|
||||
fun HearingProtectionScreen(viewModel: AirPodsViewModel) {
|
||||
fun HearingProtectionScreen(viewModel: AirPodsViewModel, navController: NavController) {
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
StyledScaffold(
|
||||
@@ -53,7 +63,27 @@ fun HearingProtectionScreen(viewModel: AirPodsViewModel) {
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
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) {
|
||||
StyledToggle(
|
||||
title = stringResource(R.string.environmental_noise),
|
||||
|
||||
@@ -130,7 +130,7 @@ fun LongPress(viewModel: AirPodsViewModel, name: String, navController: NavContr
|
||||
backdrop = rememberLayerBackdrop(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxScale = 0.05f,
|
||||
tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
|
||||
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.unlock_advanced_features),
|
||||
|
||||
@@ -99,7 +99,7 @@ fun PurchaseScreen(
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Free features",
|
||||
text = stringResource(R.string.free_features),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
@@ -242,7 +242,7 @@ fun PurchaseScreen(
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Advanced features",
|
||||
text = stringResource(R.string.advanced_features),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
@@ -288,6 +288,36 @@ fun PurchaseScreen(
|
||||
modifier = Modifier
|
||||
.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(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -456,10 +486,11 @@ fun PurchaseScreen(
|
||||
backdrop = rememberLayerBackdrop(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
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(
|
||||
stringResource(R.string.buy),
|
||||
stringResource(R.string.buy_price, state.price),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
@@ -478,6 +509,7 @@ fun PurchaseScreen(
|
||||
backdrop = rememberLayerBackdrop(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxScale = 0.05f,
|
||||
isInteractive = false
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.restore_purchases),
|
||||
|
||||
@@ -56,8 +56,6 @@ import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.edit
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import me.kavishdevar.librepods.R
|
||||
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))
|
||||
}
|
||||
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.name),
|
||||
) { spacerHeight ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.layerBackdrop(backdrop)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(spacerHeight))
|
||||
|
||||
@@ -23,7 +23,6 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import androidx.core.content.edit
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
@@ -34,7 +33,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.BuildConfig
|
||||
import me.kavishdevar.librepods.billing.BillingManager
|
||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers
|
||||
@@ -111,8 +109,6 @@ class AirPodsViewModel(
|
||||
private var isDemoMode = false
|
||||
val demoActivated = MutableSharedFlow<Unit>()
|
||||
|
||||
private var billingFirstCollectDone = false
|
||||
|
||||
private val listeners =
|
||||
mutableMapOf<ControlCommandIdentifiers, AACPManager.ControlCommandListener>()
|
||||
|
||||
@@ -164,21 +160,18 @@ class AirPodsViewModel(
|
||||
private fun observeBilling() {
|
||||
if (isDemoMode) return
|
||||
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 ->
|
||||
if (!billingFirstCollectDone) {
|
||||
billingFirstCollectDone = true
|
||||
return@collect
|
||||
}
|
||||
// if (!billingFirstCollectDone) {
|
||||
// billingFirstCollectDone = true
|
||||
// return@collect
|
||||
// }
|
||||
if (!premium) {
|
||||
Log.d("AirPodsViewModel", "we are not premium")
|
||||
setControlCommandBoolean(
|
||||
ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
|
||||
false
|
||||
)
|
||||
setHeadGesturesEnabled(false)
|
||||
} else {
|
||||
Log.d("AirPodsViewModel", "we are premium")
|
||||
}
|
||||
_uiState.update { it.copy(isPremium = premium) }
|
||||
}
|
||||
@@ -188,8 +181,9 @@ class AirPodsViewModel(
|
||||
private fun observeBroadcasts() {
|
||||
broadcastReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (!isDemoMode) when (intent?.action) {
|
||||
AirPodsNotifications.AIRPODS_CONNECTED -> {
|
||||
val action = intent?.action ?: return
|
||||
if (!isDemoMode) when (action) {
|
||||
AirPodsNotifications.AIRPODS_L2CAP_CONNECTED -> {
|
||||
_uiState.update {
|
||||
it.copy(isLocallyConnected = true)
|
||||
}
|
||||
@@ -202,10 +196,8 @@ class AirPodsViewModel(
|
||||
}
|
||||
|
||||
AirPodsNotifications.BATTERY_DATA -> {
|
||||
val data = intent.getParcelableArrayListExtra("data", Battery::class.java)
|
||||
?.toList() ?: emptyList()
|
||||
_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.
|
||||
@@ -365,7 +357,6 @@ class AirPodsViewModel(
|
||||
fun setOffListeningMode(enabled: Boolean) {
|
||||
sharedPreferences.edit { putBoolean("off_listening_mode", enabled) }
|
||||
setControlCommandBoolean(ControlCommandIdentifiers.ALLOW_OFF_OPTION, enabled)
|
||||
Log.d("AirPodsViewModel", "Hello???? $enabled")
|
||||
_uiState.update {
|
||||
it.copy(offListeningMode = enabled)
|
||||
}
|
||||
@@ -440,7 +431,15 @@ class AirPodsViewModel(
|
||||
_uiState.update { it.copy(loudSoundReductionEnabled = value[0].toInt() == 0x01) }
|
||||
}
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
service.attManager?.write(handle, value)
|
||||
try {
|
||||
service.attManager?.connect()
|
||||
while (service.attManager?.socket?.isConnected != true) {
|
||||
delay(250)
|
||||
}
|
||||
service.attManager?.write(handle, value)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,13 +462,16 @@ class AirPodsViewModel(
|
||||
fun observeATT() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
service.attManager?.connect()
|
||||
while (service.attManager?.socket?.isConnected != true) {
|
||||
delay(1000)
|
||||
}
|
||||
service.attManager?.enableNotifications(ATTHandles.LOUD_SOUND_REDUCTION)
|
||||
service.attManager?.enableNotifications(ATTHandles.TRANSPARENCY)
|
||||
service.attManager?.enableNotifications(ATTHandles.HEARING_AID)
|
||||
|
||||
while (true) {
|
||||
refreshATT()
|
||||
delay(10000)
|
||||
delay(15000)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -494,10 +496,6 @@ class AirPodsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
// fun purchase(context: Context) {
|
||||
// BillingManager.provider.purchase(context as Activity)
|
||||
// }
|
||||
|
||||
fun activateDemoMode() {
|
||||
isDemoMode = true
|
||||
viewModelScope.launch {
|
||||
@@ -530,8 +528,17 @@ class AirPodsViewModel(
|
||||
modelName = fakeInstance.model.displayName,
|
||||
actualModel = fakeInstance.actualModelNumber,
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package me.kavishdevar.librepods.presentation.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
@@ -32,7 +33,8 @@ data class AppSettingsUiState(
|
||||
val cameraPackageValue: String = "",
|
||||
val cameraPackageError: String? = null,
|
||||
val vendorIdHook: Boolean = false,
|
||||
val isPremium: Boolean = false
|
||||
val isPremium: Boolean = false,
|
||||
val connectionSuccessful: Boolean = false
|
||||
)
|
||||
|
||||
class AppSettingsViewModel(application: Application) : AndroidViewModel(application) {
|
||||
@@ -43,9 +45,22 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat
|
||||
|
||||
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 {
|
||||
loadSettings()
|
||||
observeBilling()
|
||||
sharedPreferences.registerOnSharedPreferenceChangeListener(sharedPrefListener)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
sharedPreferences.unregisterOnSharedPreferenceChangeListener(sharedPrefListener)
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
private fun observeBilling() {
|
||||
@@ -72,7 +87,8 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat
|
||||
useAlternateHeadTrackingPackets = sharedPreferences.getBoolean("use_alternate_head_tracking_packets", true),
|
||||
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 43).toFloat(),
|
||||
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") {
|
||||
|
||||
@@ -42,6 +42,6 @@ class PurchaseViewModel(application: Application) : AndroidViewModel(application
|
||||
}
|
||||
|
||||
fun restorePurchases() {
|
||||
BillingManager.provider.queryPurchases()
|
||||
BillingManager.provider.restorePurchases()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
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
|
||||
|
||||
@@ -58,7 +58,7 @@ import android.os.ParcelUuid
|
||||
import android.os.UserHandle
|
||||
import android.provider.Settings
|
||||
import android.telecom.TelecomManager
|
||||
import android.telephony.PhoneStateListener
|
||||
import android.telephony.TelephonyCallback
|
||||
import android.telephony.TelephonyManager
|
||||
import android.util.Log
|
||||
import android.util.TypedValue
|
||||
@@ -222,7 +222,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
val packetLogsFlow: StateFlow<Set<String>> get() = _packetLogsFlow
|
||||
|
||||
private lateinit var telephonyManager: TelephonyManager
|
||||
private lateinit var phoneStateListener: PhoneStateListener
|
||||
private lateinit var phoneStateListener: TelephonyCallback
|
||||
private val maxLogEntries = 1000
|
||||
private val inMemoryLogs = mutableSetOf<String>()
|
||||
|
||||
@@ -361,7 +361,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
}
|
||||
|
||||
|
||||
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
|
||||
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag", "HardwareIds")
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.i(TAG, "lib exempt worked: ${isBluetoothSocketExempted()}")
|
||||
@@ -383,7 +383,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
|
||||
localMac = config.selfMacAddress
|
||||
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 {
|
||||
val process = Runtime.getRuntime().exec(
|
||||
arrayOf("su", "-c", "settings get secure bluetooth_address")
|
||||
@@ -594,10 +598,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
macAddress = sharedPreferences.getString("mac_address", "") ?: ""
|
||||
|
||||
telephonyManager = getSystemService(TELEPHONY_SERVICE) as TelephonyManager
|
||||
phoneStateListener = object : PhoneStateListener() {
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onCallStateChanged(state: Int, phoneNumber: String?) {
|
||||
super.onCallStateChanged(state, phoneNumber)
|
||||
phoneStateListener = object: TelephonyCallback(), TelephonyCallback.CallStateListener {
|
||||
override fun onCallStateChanged(state: Int) {
|
||||
when (state) {
|
||||
TelephonyManager.CALL_STATE_RINGING -> {
|
||||
val leAvailableForAudio =
|
||||
@@ -607,7 +609,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
takeOver("call")
|
||||
}
|
||||
if (config.headGestures) {
|
||||
callNumber = phoneNumber
|
||||
handleIncomingCall()
|
||||
}
|
||||
}
|
||||
@@ -626,13 +627,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
|
||||
TelephonyManager.CALL_STATE_IDLE -> {
|
||||
isInCall = false
|
||||
callNumber = null
|
||||
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) {
|
||||
widgetMobileBatteryEnabled = true
|
||||
@@ -842,7 +844,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
) || (cameraActive && config.cameraAction == StemPressType.LONG_PRESS)
|
||||
Log.d(
|
||||
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(
|
||||
singlePressCustomized,
|
||||
@@ -913,7 +915,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
|
||||
if (conversationAwarenessNotification.status == 1.toByte() || conversationAwarenessNotification.status == 2.toByte()) {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -1062,6 +1064,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
version2 = config.airpodsVersion2,
|
||||
version3 = config.airpodsVersion3,
|
||||
)
|
||||
if (device != null) setMetadatas(device!!)
|
||||
}
|
||||
sendBroadcast(
|
||||
Intent(AirPodsNotifications.AIRPODS_INFORMATION_UPDATED).setPackage(
|
||||
@@ -1714,7 +1717,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
val disconnectedNotificationChannel = NotificationChannel(
|
||||
"background_service_status",
|
||||
"Background Service Status",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
NotificationManager.IMPORTANCE_NONE
|
||||
)
|
||||
|
||||
val connectedNotificationChannel = NotificationChannel(
|
||||
@@ -1805,6 +1808,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
}
|
||||
|
||||
fun sendBatteryBroadcast() {
|
||||
broadcastBatteryInformation()
|
||||
sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply {
|
||||
putParcelableArrayListExtra("data", ArrayList(batteryNotification.getBattery()))
|
||||
setPackage(packageName)
|
||||
@@ -1821,47 +1825,51 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
}
|
||||
|
||||
fun setBatteryMetadata() {
|
||||
if (BuildConfig.FLAVOR != "xposed") return
|
||||
device?.let { it ->
|
||||
SystemApisUtils.setMetadata(
|
||||
it,
|
||||
it.METADATA_UNTETHERED_CASE_BATTERY,
|
||||
batteryNotification.getBattery()
|
||||
.find { it.component == BatteryComponent.CASE }?.level.toString().toByteArray()
|
||||
)
|
||||
SystemApisUtils.setMetadata(
|
||||
it,
|
||||
it.METADATA_UNTETHERED_CASE_CHARGING,
|
||||
(if (batteryNotification.getBattery()
|
||||
.find { it.component == BatteryComponent.CASE }?.status == BatteryStatus.CHARGING
|
||||
) "1".toByteArray() else "0".toByteArray())
|
||||
)
|
||||
SystemApisUtils.setMetadata(
|
||||
it,
|
||||
it.METADATA_UNTETHERED_LEFT_BATTERY,
|
||||
batteryNotification.getBattery()
|
||||
.find { it.component == BatteryComponent.LEFT }?.level.toString().toByteArray()
|
||||
)
|
||||
SystemApisUtils.setMetadata(
|
||||
it,
|
||||
it.METADATA_UNTETHERED_LEFT_CHARGING,
|
||||
(if (batteryNotification.getBattery()
|
||||
.find { it.component == BatteryComponent.LEFT }?.status == BatteryStatus.CHARGING
|
||||
) "1".toByteArray() else "0".toByteArray())
|
||||
)
|
||||
SystemApisUtils.setMetadata(
|
||||
it,
|
||||
it.METADATA_UNTETHERED_RIGHT_BATTERY,
|
||||
batteryNotification.getBattery()
|
||||
.find { it.component == BatteryComponent.RIGHT }?.level.toString().toByteArray()
|
||||
)
|
||||
SystemApisUtils.setMetadata(
|
||||
it,
|
||||
it.METADATA_UNTETHERED_RIGHT_CHARGING,
|
||||
(if (batteryNotification.getBattery()
|
||||
.find { it.component == BatteryComponent.RIGHT }?.status == BatteryStatus.CHARGING
|
||||
) "1".toByteArray() else "0".toByteArray())
|
||||
)
|
||||
if (checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") != PackageManager.PERMISSION_GRANTED) {
|
||||
device?.let { it ->
|
||||
SystemApisUtils.setMetadata(
|
||||
it,
|
||||
it.METADATA_UNTETHERED_CASE_BATTERY,
|
||||
batteryNotification.getBattery()
|
||||
.find { it.component == BatteryComponent.CASE }?.level.toString()
|
||||
.toByteArray()
|
||||
)
|
||||
SystemApisUtils.setMetadata(
|
||||
it,
|
||||
it.METADATA_UNTETHERED_CASE_CHARGING,
|
||||
(if (batteryNotification.getBattery()
|
||||
.find { it.component == BatteryComponent.CASE }?.status == BatteryStatus.CHARGING
|
||||
) "1".toByteArray() else "0".toByteArray())
|
||||
)
|
||||
SystemApisUtils.setMetadata(
|
||||
it,
|
||||
it.METADATA_UNTETHERED_LEFT_BATTERY,
|
||||
batteryNotification.getBattery()
|
||||
.find { it.component == BatteryComponent.LEFT }?.level.toString()
|
||||
.toByteArray()
|
||||
)
|
||||
SystemApisUtils.setMetadata(
|
||||
it,
|
||||
it.METADATA_UNTETHERED_LEFT_CHARGING,
|
||||
(if (batteryNotification.getBattery()
|
||||
.find { it.component == BatteryComponent.LEFT }?.status == BatteryStatus.CHARGING
|
||||
) "1".toByteArray() else "0".toByteArray())
|
||||
)
|
||||
SystemApisUtils.setMetadata(
|
||||
it,
|
||||
it.METADATA_UNTETHERED_RIGHT_BATTERY,
|
||||
batteryNotification.getBattery()
|
||||
.find { it.component == BatteryComponent.RIGHT }?.level.toString()
|
||||
.toByteArray()
|
||||
)
|
||||
SystemApisUtils.setMetadata(
|
||||
it,
|
||||
it.METADATA_UNTETHERED_RIGHT_CHARGING,
|
||||
(if (batteryNotification.getBattery()
|
||||
.find { it.component == BatteryComponent.RIGHT }?.status == BatteryStatus.CHARGING
|
||||
) "1".toByteArray() else "0".toByteArray())
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2012,7 +2020,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
connected: Boolean, airpodsName: String? = null, batteryList: List<Battery>? = null
|
||||
) {
|
||||
val notificationManager = getSystemService(NotificationManager::class.java)
|
||||
var updatedNotification: Notification?
|
||||
|
||||
val notificationIntent = Intent(this, MainActivity::class.java)
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
@@ -2072,13 +2079,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
notificationManager.notify(2, updatedNotification)
|
||||
notificationManager.cancel(1)
|
||||
} 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)
|
||||
} else if (!config.bleOnlyMode && !socket.isConnected) {
|
||||
showSocketConnectionFailureNotification("Socket created, but not connected. Check logs")
|
||||
@@ -2108,7 +2108,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
return suspendCancellableCoroutine { continuation ->
|
||||
gestureDetector?.startDetection(doNotStop = true) { accepted ->
|
||||
if (continuation.isActive) {
|
||||
continuation.resume(accepted) {
|
||||
continuation.resume(accepted) { _, _, _ ->
|
||||
gestureDetector?.stopDetection()
|
||||
}
|
||||
}
|
||||
@@ -2121,7 +2121,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val telecomManager = getSystemService(TELECOM_SERVICE) as TelecomManager
|
||||
if (checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) == PackageManager.PERMISSION_GRANTED) {
|
||||
telecomManager.acceptRingingCall()
|
||||
telecomManager.acceptRingingCall() // TODO: Switch to InCallService (needs CDM association)
|
||||
}
|
||||
} else {
|
||||
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) {
|
||||
val telecomManager = getSystemService(TELECOM_SERVICE) as TelecomManager
|
||||
if (checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) == PackageManager.PERMISSION_GRANTED) {
|
||||
telecomManager.endCall()
|
||||
telecomManager.endCall() // TODO: Switch to InCallService (needs CDM association)
|
||||
}
|
||||
} else {
|
||||
val telephonyService = getSystemService(TELEPHONY_SERVICE) as TelephonyManager
|
||||
@@ -2221,9 +2221,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
@Suppress("PrivatePropertyName")
|
||||
private val ACTION_ASI_UPDATE_BLUETOOTH_DATA = "batterywidget.impl.action.update_bluetooth_data"
|
||||
|
||||
@Suppress("MissingPermission", "unused")
|
||||
@SuppressLint("MissingPermission")
|
||||
fun broadcastBatteryInformation() {
|
||||
if (device == null) return
|
||||
if (device == null || checkSelfPermission("android.permission.INTERACT_ACROSS_USERS") != PackageManager.PERMISSION_GRANTED) return
|
||||
|
||||
val batteryList = batteryNotification.getBattery()
|
||||
val leftBattery = batteryList.find { it.component == BatteryComponent.LEFT }
|
||||
@@ -2307,7 +2307,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
}
|
||||
|
||||
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 ->
|
||||
val instance = airpodsInstance
|
||||
if (instance != null) {
|
||||
@@ -2377,7 +2381,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
val context = context?.applicationContext
|
||||
val name = context?.getSharedPreferences("settings", MODE_PRIVATE)
|
||||
?.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")
|
||||
if (BluetoothDevice.ACTION_ACL_CONNECTED == action) {
|
||||
val uuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
|
||||
@@ -2434,16 +2438,13 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
)
|
||||
otherDeviceTookOver = false
|
||||
}
|
||||
val ownsConnection = aacpManager.getControlCommandStatus(AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION)?.value?.get(0)?.toInt()
|
||||
Log.d(
|
||||
TAG, "owns connection: ${
|
||||
aacpManager.getControlCommandStatus(AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION)?.value?.get(
|
||||
0
|
||||
)?.toInt()
|
||||
}"
|
||||
TAG, "owns connection: $ownsConnection"
|
||||
)
|
||||
if (!::socket.isInitialized) return
|
||||
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")
|
||||
return
|
||||
}
|
||||
@@ -2693,6 +2694,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
version2 = config.airpodsVersion2,
|
||||
version3 = config.airpodsVersion3,
|
||||
)
|
||||
setMetadatas(device)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2700,7 +2702,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
true, config.deviceName, batteryNotification.getBattery()
|
||||
)
|
||||
Log.d(TAG, "<LogCollector:Complete:Success> Socket connected")
|
||||
sharedPreferences.edit { putBoolean("connection_successful", true) }
|
||||
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_L2CAP_CONNECTED))
|
||||
} catch (e: Exception) {
|
||||
// sharedPreferences.edit { putBoolean("connection_successful", false) }
|
||||
Log.d(
|
||||
TAG, "<LogCollector:Complete:Failed> Socket not connected, ${e.message}"
|
||||
)
|
||||
@@ -2865,19 +2870,36 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
})
|
||||
|
||||
val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter
|
||||
bluetoothAdapter.getProfileProxy(this, object : BluetoothProfile.ServiceListener {
|
||||
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
|
||||
if (profile == BluetoothProfile.A2DP) {
|
||||
val connectedDevices = proxy.connectedDevices
|
||||
if (connectedDevices.isNotEmpty()) {
|
||||
MediaController.sendPause()
|
||||
if (checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED){
|
||||
bluetoothAdapter.getProfileProxy(this, object : BluetoothProfile.ServiceListener {
|
||||
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
|
||||
if (profile == BluetoothProfile.A2DP) {
|
||||
val connectedDevices = proxy.connectedDevices
|
||||
if (connectedDevices.isNotEmpty()) {
|
||||
MediaController.sendPause()
|
||||
}
|
||||
}
|
||||
bluetoothAdapter.closeProfileProxy(profile, proxy)
|
||||
}
|
||||
bluetoothAdapter.closeProfileProxy(profile, proxy)
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(profile: Int) {}
|
||||
}, BluetoothProfile.A2DP)
|
||||
override fun onServiceDisconnected(profile: Int) {}
|
||||
}, 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")
|
||||
|
||||
}
|
||||
@@ -2912,98 +2934,123 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
|
||||
fun disconnectAudio(context: Context, device: BluetoothDevice?) {
|
||||
val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
|
||||
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
|
||||
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
|
||||
if (profile == BluetoothProfile.A2DP) {
|
||||
try {
|
||||
if (proxy.getConnectionState(device) == BluetoothProfile.STATE_DISCONNECTED) {
|
||||
Log.d(TAG, "Already disconnected from A2DP")
|
||||
return
|
||||
if (checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) {
|
||||
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
|
||||
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
|
||||
if (profile == BluetoothProfile.A2DP) {
|
||||
try {
|
||||
if (proxy.getConnectionState(device) == BluetoothProfile.STATE_DISCONNECTED) {
|
||||
Log.d(TAG, "Already disconnected from A2DP")
|
||||
return
|
||||
}
|
||||
val method = proxy.javaClass.getMethod(
|
||||
"setConnectionPolicy", BluetoothDevice::class.java, Int::class.java
|
||||
)
|
||||
Log.d(TAG, "calling A2DP.setConnectionPolicy for ${device?.address} to 0")
|
||||
method.invoke(proxy, device, 0)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
} finally {
|
||||
bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy)
|
||||
}
|
||||
val method = proxy.javaClass.getMethod(
|
||||
"setConnectionPolicy", BluetoothDevice::class.java, Int::class.java
|
||||
)
|
||||
method.invoke(proxy, device, 0)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "we probably do not have BLUETOOTH_PRIVILEGED")
|
||||
} finally {
|
||||
bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(profile: Int) {}
|
||||
}, BluetoothProfile.A2DP)
|
||||
// requires protected permission (MODIFY_PHONE_STATE)
|
||||
// bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
|
||||
// override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
|
||||
// if (profile == BluetoothProfile.HEADSET) {
|
||||
// try {
|
||||
// val method =
|
||||
// proxy.javaClass.getMethod("setConnectionPolicy", BluetoothDevice::class.java, Int::class.java)
|
||||
// method.invoke(proxy, device, 0)
|
||||
// } catch (e: Exception) {
|
||||
// e.printStackTrace()
|
||||
// } finally {
|
||||
// bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// override fun onServiceDisconnected(profile: Int) {}
|
||||
// }, BluetoothProfile.HEADSET)
|
||||
override fun onServiceDisconnected(profile: Int) {}
|
||||
}, BluetoothProfile.A2DP)
|
||||
} else {
|
||||
Log.d(TAG, "not disconnecting A2DP, no BLUETOOTH_PRIVILEGED permission")
|
||||
}
|
||||
if (checkSelfPermission("android.permission.MODIFY_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) {
|
||||
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
|
||||
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
|
||||
if (profile == BluetoothProfile.HEADSET) {
|
||||
try {
|
||||
val method =
|
||||
proxy.javaClass.getMethod(
|
||||
"setConnectionPolicy",
|
||||
BluetoothDevice::class.java,
|
||||
Int::class.java
|
||||
)
|
||||
Log.d(TAG, "calling HEADSET.setConnectionPolicy for ${device?.address} to 0")
|
||||
method.invoke(proxy, device, 0)
|
||||
} catch (e: Exception) {
|
||||
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?) {
|
||||
val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
|
||||
if (context.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) {
|
||||
|
||||
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
|
||||
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
|
||||
if (profile == BluetoothProfile.A2DP) {
|
||||
try {
|
||||
val policyMethod = proxy.javaClass.getMethod(
|
||||
"setConnectionPolicy", BluetoothDevice::class.java, Int::class.java
|
||||
)
|
||||
policyMethod.invoke(proxy, device, 100)
|
||||
val connectMethod =
|
||||
proxy.javaClass.getMethod("connect", BluetoothDevice::class.java)
|
||||
connectMethod.invoke(
|
||||
proxy, device
|
||||
) // reduces the slight delay between allowing and actually connecting
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "we probably do not have BLUETOOTH_PRIVILEGED")
|
||||
} finally {
|
||||
bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy)
|
||||
if (MediaController.pausedWhileTakingOver) {
|
||||
MediaController.sendPlay()
|
||||
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
|
||||
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
|
||||
if (profile == BluetoothProfile.A2DP) {
|
||||
try {
|
||||
val policyMethod = proxy.javaClass.getMethod(
|
||||
"setConnectionPolicy", BluetoothDevice::class.java, Int::class.java
|
||||
)
|
||||
Log.d(TAG, "calling A2DP.setConnectionPolicy for ${device?.address} to 100")
|
||||
policyMethod.invoke(proxy, device, 100)
|
||||
val connectMethod =
|
||||
proxy.javaClass.getMethod("connect", BluetoothDevice::class.java)
|
||||
connectMethod.invoke(
|
||||
proxy, device
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
} finally {
|
||||
bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy)
|
||||
if (MediaController.pausedWhileTakingOver) {
|
||||
MediaController.sendPlay()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(profile: Int) {}
|
||||
}, BluetoothProfile.A2DP)
|
||||
// requires protected permission (MODIFY_PHONE_STATE)
|
||||
// bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
|
||||
// override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
|
||||
// if (profile == BluetoothProfile.HEADSET) {
|
||||
// try {
|
||||
// val policyMethod = proxy.javaClass.getMethod("setConnectionPolicy", BluetoothDevice::class.java, Int::class.java)
|
||||
// policyMethod.invoke(proxy, device, 100)
|
||||
// val connectMethod =
|
||||
// proxy.javaClass.getMethod("connect", BluetoothDevice::class.java)
|
||||
// connectMethod.invoke(proxy, device)
|
||||
// } catch (e: Exception) {
|
||||
// e.printStackTrace()
|
||||
// } finally {
|
||||
// bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// override fun onServiceDisconnected(profile: Int) {}
|
||||
// }, BluetoothProfile.HEADSET)
|
||||
override fun onServiceDisconnected(profile: Int) {}
|
||||
}, BluetoothProfile.A2DP)
|
||||
} else {
|
||||
Log.d(TAG, "not connecting A2DP, no BLUETOOTH_PRIVILEGED permission")
|
||||
}
|
||||
if (checkSelfPermission("android.permission.MODIFY_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) {
|
||||
bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener {
|
||||
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
|
||||
if (profile == BluetoothProfile.HEADSET) {
|
||||
try {
|
||||
val policyMethod = proxy.javaClass.getMethod(
|
||||
"setConnectionPolicy",
|
||||
BluetoothDevice::class.java,
|
||||
Int::class.java
|
||||
)
|
||||
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)
|
||||
} catch (e: Exception) {
|
||||
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) {
|
||||
@@ -3011,6 +3058,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
|
||||
if (config.deviceName != name) {
|
||||
config.deviceName = name
|
||||
device?.alias = name
|
||||
sharedPreferences.edit { putString("name", name) }
|
||||
}
|
||||
|
||||
@@ -3050,7 +3098,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_NONE)
|
||||
if (checkSelfPermission("android.permission.READ_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) {
|
||||
telephonyManager.unregisterTelephonyCallback(phoneStateListener)
|
||||
}
|
||||
// isConnectedLocally = false
|
||||
// CrossDevice.isAvailable = true
|
||||
super.onDestroy()
|
||||
|
||||
@@ -20,7 +20,6 @@ package me.kavishdevar.librepods.utils
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Build
|
||||
import me.kavishdevar.librepods.BuildConfig
|
||||
|
||||
fun isSupported(sharedPreferences: SharedPreferences): Boolean {
|
||||
val isPixel = Build.MANUFACTURER.lowercase() == "google"
|
||||
@@ -37,8 +36,7 @@ fun isSupported(sharedPreferences: SharedPreferences): Boolean {
|
||||
}
|
||||
}
|
||||
} else if (isOppoOrOnePlus) {
|
||||
return true
|
||||
return Build.VERSION.SDK_INT >= 36
|
||||
}
|
||||
return if (BuildConfig.FLAVOR == "xposed") true
|
||||
else sharedPreferences.getBoolean("bypass_device_check", false)
|
||||
return sharedPreferences.getBoolean("bypass_device_check.v2", false)
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@
|
||||
<string name="set_identity_resolving_key">設定身分解析金鑰 (IRK)</string>
|
||||
<string name="set_identity_resolving_key_description">手動設定用於解析 BLE 隨機位址的 IRK 值</string>
|
||||
<string name="set_encryption_key">設定加密金鑰</string>
|
||||
<string name="set_encryption_key_description">手動設定用於解密 BLE 廣播的 ENC_KEY值</string>
|
||||
<string name="set_encryption_key_description">手動設定用於解密 BLE 廣播的 ENC_KEY 值</string>
|
||||
<string name="use_alternate_head_tracking_packets">使用替代頭部追蹤封包</string>
|
||||
<string name="use_alternate_head_tracking_packets_description">如果頭部追蹤對你無效,請啟用此選項。這會傳送不同的資料給 AirPods 以請求/停止頭部追蹤資料。</string>
|
||||
<string name="act_as_an_apple_device">作為 Apple 裝置</string>
|
||||
@@ -212,4 +212,33 @@
|
||||
<string name="listening_mode_transparency_description">允許外部聲音</string>
|
||||
<string name="listening_mode_adaptive_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>
|
||||
|
||||
@@ -211,7 +211,7 @@
|
||||
<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="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="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>
|
||||
@@ -236,4 +236,31 @@
|
||||
<string name="yes">Yes</string>
|
||||
<string name="settings">Settings</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>
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
package me.kavishdevar.librepods
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package me.kavishdevar.librepods.utils
|
||||
|
||||
object NativeBridge {
|
||||
fun setSdpHook(enabled: Boolean) { }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@
|
||||
#include "l2c_fcr_hook.h"
|
||||
|
||||
extern "C" {
|
||||
#include "xz.h"
|
||||
#include "xz.h"
|
||||
}
|
||||
|
||||
#define LOG_TAG "LibrePodsHook"
|
||||
@@ -39,13 +39,13 @@ extern "C" {
|
||||
|
||||
static HookFunType hook_func = 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 uint8_t (*original_l2c_fcr_chk_chan_modes)(void *) = nullptr;
|
||||
|
||||
static tBTA_STATUS (*original_BTA_DmSetLocalDiRecord)(tSDP_DI_RECORD *, uint32_t *) = nullptr;
|
||||
|
||||
static std::atomic<bool> enableSdpHook(false);
|
||||
|
||||
uint8_t fake_l2c_fcr_chk_chan_modes(void* p_ccb) {
|
||||
uint8_t fake_l2c_fcr_chk_chan_modes(void *p_ccb) {
|
||||
LOGI("fake_l2c_fcr_chk_chan_modes called");
|
||||
uint8_t orig = 0;
|
||||
if (original_l2c_fcr_chk_chan_modes)
|
||||
@@ -55,13 +55,13 @@ uint8_t fake_l2c_fcr_chk_chan_modes(void* p_ccb) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
tBTA_STATUS fake_BTA_DmSetLocalDiRecord(
|
||||
tSDP_DI_RECORD* p_device_info,
|
||||
uint32_t* p_handle) {
|
||||
tBTA_STATUS fake_BTA_DmSetLocalDiRecord(tSDP_DI_RECORD *p_device_info, uint32_t *p_handle) {
|
||||
|
||||
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");
|
||||
|
||||
@@ -70,14 +70,15 @@ tBTA_STATUS fake_BTA_DmSetLocalDiRecord(
|
||||
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);
|
||||
return original_BTA_DmSetLocalDiRecord ? original_BTA_DmSetLocalDiRecord(p_device_info, p_handle) : BTA_FAILURE;
|
||||
LOGI("fake_BTA_DmSetLocalDiRecord: returning status %d",
|
||||
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(
|
||||
const uint8_t* input,
|
||||
size_t input_size,
|
||||
std::vector<uint8_t>& output) {
|
||||
static bool decompressXZ(const uint8_t *input, size_t input_size, std::vector<uint8_t> &output) {
|
||||
|
||||
LOGI("decompressXZ called with input_size: %zu", input_size);
|
||||
|
||||
@@ -86,7 +87,7 @@ static bool decompressXZ(
|
||||
xz_crc64_init();
|
||||
#endif
|
||||
|
||||
struct xz_dec* dec = xz_dec_init(XZ_DYNALLOC, 64U << 20);
|
||||
struct xz_dec *dec = xz_dec_init(XZ_DYNALLOC, 64U << 20);
|
||||
if (!dec) {
|
||||
LOGE("decompressXZ: xz_dec_init failed");
|
||||
return false;
|
||||
@@ -106,7 +107,8 @@ static bool decompressXZ(
|
||||
|
||||
LOGI("decompressXZ: entering decompression loop");
|
||||
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);
|
||||
|
||||
LOGI("decompressXZ: xz_dec_run returned %d", ret);
|
||||
@@ -135,10 +137,10 @@ static bool decompressXZ(
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool getLibraryPath(const char* name, std::string& out) {
|
||||
static bool getLibraryPath(const char *name, std::string &out) {
|
||||
LOGI("getLibraryPath called with name: %s", name);
|
||||
|
||||
FILE* fp = fopen("/proc/self/maps", "r");
|
||||
FILE *fp = fopen("/proc/self/maps", "r");
|
||||
if (!fp) {
|
||||
LOGE("getLibraryPath: fopen failed");
|
||||
return false;
|
||||
@@ -150,7 +152,7 @@ static bool getLibraryPath(const char* name, std::string& out) {
|
||||
while (fgets(line, sizeof(line), fp)) {
|
||||
if (strstr(line, name)) {
|
||||
LOGI("getLibraryPath: found line containing %s", name);
|
||||
char* path = strchr(line, '/');
|
||||
char *path = strchr(line, '/');
|
||||
if (path) {
|
||||
out = path;
|
||||
out.erase(out.find('\n'));
|
||||
@@ -166,10 +168,10 @@ static bool getLibraryPath(const char* name, std::string& out) {
|
||||
return false;
|
||||
}
|
||||
|
||||
static uintptr_t getModuleBase(const char* name) {
|
||||
static uintptr_t getModuleBase(const char *name) {
|
||||
LOGI("getModuleBase called with name: %s", name);
|
||||
|
||||
FILE* fp = fopen("/proc/self/maps", "r");
|
||||
FILE *fp = fopen("/proc/self/maps", "r");
|
||||
if (!fp) {
|
||||
LOGE("getModuleBase: fopen failed");
|
||||
return 0;
|
||||
@@ -192,26 +194,78 @@ static uintptr_t getModuleBase(const char* name) {
|
||||
return base;
|
||||
}
|
||||
|
||||
static uint64_t findSymbolOffset(
|
||||
const std::vector<uint8_t>& elf,
|
||||
const char* symbol_substring) {
|
||||
static uint64_t
|
||||
findSymbolOffsetDynsym(const std::vector<uint8_t> &elf, 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);
|
||||
|
||||
auto* eh = reinterpret_cast<const Elf64_Ehdr*>(elf.data());
|
||||
auto* shdr = reinterpret_cast<const Elf64_Shdr*>(
|
||||
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 char *shstr = reinterpret_cast<const char *>(
|
||||
elf.data() + shdr[eh->e_shstrndx].sh_offset);
|
||||
|
||||
const Elf64_Shdr* symtab = nullptr;
|
||||
const Elf64_Shdr* strtab = nullptr;
|
||||
const Elf64_Shdr *symtab = nullptr;
|
||||
const Elf64_Shdr *strtab = nullptr;
|
||||
|
||||
LOGI("findSymbolOffset: parsing ELF sections");
|
||||
for (int i = 0; i < eh->e_shnum; ++i) {
|
||||
const char* secname = shstr + shdr[i].sh_name;
|
||||
const char *secname = shstr + shdr[i].sh_name;
|
||||
if (!strcmp(secname, ".symtab"))
|
||||
symtab = &shdr[i];
|
||||
if (!strcmp(secname, ".strtab"))
|
||||
@@ -224,23 +278,22 @@ static uint64_t findSymbolOffset(
|
||||
}
|
||||
LOGI("findSymbolOffset: found symtab and strtab");
|
||||
|
||||
auto* symbols = reinterpret_cast<const Elf64_Sym*>(
|
||||
auto *symbols = reinterpret_cast<const Elf64_Sym *>(
|
||||
elf.data() + symtab->sh_offset);
|
||||
|
||||
const char* strings =
|
||||
reinterpret_cast<const char*>(
|
||||
elf.data() + strtab->sh_offset);
|
||||
const char *strings = reinterpret_cast<const char *>(
|
||||
elf.data() + strtab->sh_offset);
|
||||
|
||||
size_t count = symtab->sh_size / sizeof(Elf64_Sym);
|
||||
|
||||
LOGI("findSymbolOffset: scanning %zu symbols", count);
|
||||
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) &&
|
||||
ELF64_ST_TYPE(symbols[i].st_info) == STT_FUNC) {
|
||||
if (strstr(name, symbol_substring) && 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;
|
||||
}
|
||||
@@ -250,7 +303,7 @@ static uint64_t findSymbolOffset(
|
||||
return 0;
|
||||
}
|
||||
|
||||
static bool hookLibrary(const char* libname) {
|
||||
static bool hookLibrary(const char *libname) {
|
||||
LOGI("hookLibrary called with libname: %s", libname);
|
||||
|
||||
if (!hook_func) {
|
||||
@@ -277,89 +330,78 @@ static bool hookLibrary(const char* libname) {
|
||||
close(fd);
|
||||
return false;
|
||||
}
|
||||
LOGI("hookLibrary: opened file, size: %lld", (long long)st.st_size);
|
||||
LOGI("hookLibrary: opened file, size: %lld", (long long) st.st_size);
|
||||
|
||||
std::vector<uint8_t> file(st.st_size);
|
||||
read(fd, file.data(), st.st_size);
|
||||
close(fd);
|
||||
|
||||
auto* eh = reinterpret_cast<Elf64_Ehdr*>(file.data());
|
||||
auto* shdr = reinterpret_cast<Elf64_Shdr*>(
|
||||
auto *eh = reinterpret_cast<Elf64_Ehdr *>(file.data());
|
||||
auto *shdr = reinterpret_cast<Elf64_Shdr *>(
|
||||
file.data() + eh->e_shoff);
|
||||
|
||||
const char* shstr =
|
||||
reinterpret_cast<const char*>(
|
||||
file.data() + shdr[eh->e_shstrndx].sh_offset);
|
||||
const char *shstr = reinterpret_cast<const char *>(
|
||||
file.data() + shdr[eh->e_shstrndx].sh_offset);
|
||||
|
||||
uint64_t chk_offset = 0;
|
||||
uint64_t sdp_offset = 0;
|
||||
|
||||
LOGI("hookLibrary: parsing ELF header and sections");
|
||||
for (int i = 0; i < eh->e_shnum; ++i) {
|
||||
|
||||
if (!strcmp(shstr + shdr[i].sh_name, ".gnu_debugdata")) {
|
||||
LOGI("hookLibrary: found .gnu_debugdata section");
|
||||
|
||||
std::vector<uint8_t> compressed(
|
||||
file.begin() + shdr[i].sh_offset,
|
||||
file.begin() + shdr[i].sh_offset + shdr[i].sh_size);
|
||||
std::vector<uint8_t> compressed(file.begin() + shdr[i].sh_offset,
|
||||
file.begin() + shdr[i].sh_offset + shdr[i].sh_size);
|
||||
|
||||
std::vector<uint8_t> decompressed;
|
||||
|
||||
if (!decompressXZ(
|
||||
compressed.data(),
|
||||
compressed.size(),
|
||||
decompressed)) {
|
||||
LOGE("hookLibrary: decompressXZ failed");
|
||||
return false;
|
||||
}
|
||||
LOGI("hookLibrary: decompressed debug data, size: %zu", decompressed.size());
|
||||
if (decompressXZ(compressed.data(), compressed.size(), decompressed)) {
|
||||
|
||||
uintptr_t base = getModuleBase(libname);
|
||||
if (!base) {
|
||||
LOGE("hookLibrary: getModuleBase failed");
|
||||
return false;
|
||||
}
|
||||
LOGI("hookLibrary: module base: 0x%lx", base);
|
||||
chk_offset = findSymbolOffset(decompressed, "l2c_fcr_chk_chan_modes");
|
||||
|
||||
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) {
|
||||
void* target =
|
||||
reinterpret_cast<void*>(base + chk_offset);
|
||||
|
||||
hook_func(target,
|
||||
(void*)fake_l2c_fcr_chk_chan_modes,
|
||||
(void**)&original_l2c_fcr_chk_chan_modes);
|
||||
|
||||
LOGI("hookLibrary: hooked l2c_fcr_chk_chan_modes");
|
||||
sdp_offset = findSymbolOffset(decompressed, "BTA_DmSetLocalDiRecord");
|
||||
} else {
|
||||
LOGE("debugdata decompress failed");
|
||||
}
|
||||
|
||||
if (sdp_offset) {
|
||||
void* target =
|
||||
reinterpret_cast<void*>(base + sdp_offset);
|
||||
|
||||
hook_func(target,
|
||||
(void*)fake_BTA_DmSetLocalDiRecord,
|
||||
(void**)&original_BTA_DmSetLocalDiRecord);
|
||||
|
||||
LOGI("hookLibrary: hooked BTA_DmSetLocalDiRecord");
|
||||
}
|
||||
|
||||
return true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
LOGI("hookLibrary: failed for %s", libname);
|
||||
return false;
|
||||
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");
|
||||
}
|
||||
|
||||
uintptr_t base = getModuleBase(libname);
|
||||
if (!base) {
|
||||
LOGE("hookLibrary: getModuleBase failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (chk_offset) {
|
||||
void *target = reinterpret_cast<void *>(base + chk_offset);
|
||||
hook_func(target, (void *) fake_l2c_fcr_chk_chan_modes,
|
||||
(void **) &original_l2c_fcr_chk_chan_modes);
|
||||
LOGI("hooked chk");
|
||||
}
|
||||
|
||||
if (sdp_offset) {
|
||||
void *target = reinterpret_cast<void *>(base + sdp_offset);
|
||||
hook_func(target, (void *) fake_BTA_DmSetLocalDiRecord,
|
||||
(void **) &original_BTA_DmSetLocalDiRecord);
|
||||
LOGI("hooked sdp");
|
||||
}
|
||||
|
||||
return chk_offset || sdp_offset;
|
||||
}
|
||||
|
||||
static void on_library_loaded(const char* name, void*) {
|
||||
static void on_library_loaded(const char *name, void *) {
|
||||
LOGI("on_library_loaded called with name: %s", name);
|
||||
|
||||
if (strstr(name, "libbluetooth_jni.so")) {
|
||||
@@ -373,20 +415,19 @@ static void on_library_loaded(const char* name, void*) {
|
||||
}
|
||||
}
|
||||
|
||||
extern "C"
|
||||
[[gnu::visibility("default")]]
|
||||
extern "C" [[gnu::visibility("default")]]
|
||||
[[gnu::used]]
|
||||
NativeOnModuleLoaded native_init(const NativeAPIEntries* entries) {
|
||||
NativeOnModuleLoaded native_init(const NativeAPIEntries *entries) {
|
||||
LOGI("native_init called with entries: %p", entries);
|
||||
hook_func = (HookFunType)entries->hook_func;
|
||||
LOGI("LibrePodsNativeHook initialized, sdp hook enabled: %d", enableSdpHook.load(std::memory_order_relaxed));
|
||||
hook_func = (HookFunType) entries->hook_func;
|
||||
LOGI("LibrePodsNativeHook initialized, sdp hook enabled: %d",
|
||||
enableSdpHook.load(std::memory_order_relaxed));
|
||||
return on_library_loaded;
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_me_kavishdevar_librepods_utils_NativeBridge_setSdpHook(
|
||||
JNIEnv*, jobject thiz, jboolean enable) {
|
||||
extern "C" JNIEXPORT void JNICALL
|
||||
Java_me_kavishdevar_librepods_utils_NativeBridge_setSdpHook(JNIEnv *, jobject thiz,
|
||||
jboolean enable) {
|
||||
LOGI("setSdpHook called with enable: %d", enable);
|
||||
enableSdpHook.store(enable, std::memory_order_relaxed);
|
||||
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
package me.kavishdevar.librepods
|
||||
|
||||
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.XposedServiceHelper
|
||||
import me.kavishdevar.librepods.billing.BillingManager
|
||||
import me.kavishdevar.librepods.billing.BillingProviderFactory
|
||||
import me.kavishdevar.librepods.utils.XposedServiceHolder
|
||||
|
||||
class LibrePodsApplication: Application(), XposedServiceHelper.OnServiceListener {
|
||||
class LibrePodsApplication: Application(), XposedServiceHelper.OnServiceListener, DefaultLifecycleObserver {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
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) {
|
||||
|
||||
@@ -18,6 +18,7 @@ backdrop = "2.0.0-alpha03"
|
||||
billing = "8.3.0"
|
||||
hilt = "2.59.2"
|
||||
xposed = "101.0.0"
|
||||
lifecycleProcess = "2.10.0"
|
||||
|
||||
[libraries]
|
||||
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" }
|
||||
libxposed-api = { group = "io.github.libxposed", name = "api", 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]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
7
root-module-manual/module.prop
Normal file
7
root-module-manual/module.prop
Normal 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.
@@ -1,3 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
exec /data/local/tmp/aln_unzip/busybox/busybox-arm64 xz "$@"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "v0.1.0-rc.4",
|
||||
"versionCode": 3,
|
||||
"zipUrl": "https://github.com/kavishdevar/librepods/releases/download/v0.1.0-rc.4/LibrePods-v0.1.0-rc.4.zip",
|
||||
"version": "v0.2.3",
|
||||
"versionCode": 36,
|
||||
"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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user