diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index d2ed2db..6d2aa93 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -1,42 +1,74 @@
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+import java.util.Properties
+
plugins {
alias(libs.plugins.android.application)
- alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.aboutLibraries)
+// alias(libs.plugins.hilt)
id("kotlin-parcelize")
}
+val props = Properties().apply {
+ load(rootProject.file("local.properties").inputStream())
+}
+
android {
+ signingConfigs {
+ create("release") {
+ storeFile = file(props["RELEASE_STORE_FILE"] as String)
+ storePassword = props["RELEASE_STORE_PASSWORD"] as String
+ keyAlias = props["RELEASE_KEY_ALIAS"] as String
+ keyPassword = props["RELEASE_KEY_PASSWORD"] as String
+ }
+ }
namespace = "me.kavishdevar.librepods"
- compileSdk = 36
+ compileSdk = 37
defaultConfig {
applicationId = "me.kavishdevar.librepods"
- minSdk = 33
- targetSdk = 36
- versionCode = 10
- versionName = "0.2.0-alpha.2"
+ minSdk = 36
+ targetSdk = 37
+ versionCode = 21
+ versionName = "0.2.0-beta.1"
}
-
buildTypes {
release {
- isMinifyEnabled = false
+ isMinifyEnabled = true
+ isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
+ externalNativeBuild {
+ cmake {
+ arguments += "-DCMAKE_BUILD_TYPE=Release"
+ }
+ }
+ signingConfig = signingConfigs.getByName("release")
+ }
+ debug {
+ signingConfig = signingConfigs.getByName("release")
+ }
+ create("playRelease") {
+ initWith(getByName("release"))
+ versionNameSuffix = "-play"
+ buildConfigField("Boolean", "PLAY_BUILD", "true")
}
}
compileOptions {
- sourceCompatibility = JavaVersion.VERSION_1_8
- targetCompatibility = JavaVersion.VERSION_1_8
+ sourceCompatibility = JavaVersion.VERSION_21
+ targetCompatibility = JavaVersion.VERSION_21
}
- kotlinOptions {
- jvmTarget = "1.8"
+ kotlin {
+ compilerOptions {
+ jvmTarget.set(JvmTarget.JVM_21)
+ }
}
buildFeatures {
compose = true
viewBinding = true
+ buildConfig = true
}
androidResources {
generateLocaleConfig = true
@@ -49,17 +81,41 @@ android {
}
sourceSets {
getByName("main") {
- res.srcDirs("src/main/res", "src/main/res-apple")
+ res.directories+="src/main/res-apple"
+ }
+ }
+
+ ndkVersion = "30.0.14904198"
+
+ flavorDimensions += "env"
+
+ productFlavors {
+ create("normal") {
+ dimension = "env"
+ externalNativeBuild {
+ cmake {
+ arguments += "-DIS_XPOSED=OFF"
+ }
+ }
+ }
+ create("xposed") {
+ dimension = "env"
+ externalNativeBuild {
+ cmake {
+ arguments += "-DIS_XPOSED=ON"
+ }
+ }
+ applicationIdSuffix = ".xposed"
}
}
}
dependencies {
+ implementation(platform(libs.androidx.compose.bom))
implementation(libs.accompanist.permissions)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
- implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
@@ -71,15 +127,17 @@ dependencies {
implementation(libs.haze.materials)
implementation(libs.androidx.dynamicanimation)
implementation(libs.androidx.compose.ui)
+ implementation(libs.androidx.compose.material.icons.core)
+ implementation(libs.billing)
debugImplementation(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.compose.foundation.layout)
implementation(libs.aboutlibraries)
implementation(libs.aboutlibraries.compose.m3)
- // compileOnly(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar"))))
- // implementation(fileTree(mapOf("dir" to "lib", "include" to listOf("*.aar"))))
- compileOnly(files("libs/libxposed-api-100.aar"))
- debugImplementation(files("libs/backdrop-debug.aar"))
- releaseImplementation(files("libs/backdrop-release.aar"))
+ implementation(libs.backdrop)
+ implementation(libs.hilt)
+// implementation(libs.hilt.compiler)
+ add("xposedCompileOnly", files("libs/libxposed-api-100.aar"))
+ add("playReleaseImplementation", libs.billing)
}
aboutLibraries {
diff --git a/android/app/libs/backdrop-debug.aar b/android/app/libs/backdrop-debug.aar
index 9ed9a71..2e53719 100644
Binary files a/android/app/libs/backdrop-debug.aar and b/android/app/libs/backdrop-debug.aar differ
diff --git a/android/app/libs/backdrop-release.aar b/android/app/libs/backdrop-release.aar
index bfddcab..238bbf2 100644
Binary files a/android/app/libs/backdrop-release.aar and b/android/app/libs/backdrop-release.aar differ
diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro
index 481bb43..ffbc0f3 100644
--- a/android/app/proguard-rules.pro
+++ b/android/app/proguard-rules.pro
@@ -18,4 +18,7 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
-#-renamesourcefileattribute SourceFile
\ No newline at end of file
+#-renamesourcefileattribute SourceFile
+
+-keep class androidx.compose.** { *; }
+-dontwarn androidx.compose.**
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 20b58c8..61fc138 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -14,9 +14,9 @@
-
+
+
+
-
+
-
-
+
+
+
+
+
+ tools:ignore="UnusedAttribute" >
@@ -114,17 +114,17 @@
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
{
- val queryParams = data.queryParameterNames
- queryParams.forEach { param ->
- val value = data.getQueryParameter(param)
- Log.d("LibrePods", "Parameter: $param = $value")
- }
-
- handleAddMagicKeys(data)
- }
- }
- }
- }
-
- private fun handleAddMagicKeys(uri: Uri) {
- val sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE)
-
- val irkHex = uri.getQueryParameter("irk")
- val encKeyHex = uri.getQueryParameter("enc_key")
-
- try {
- if (irkHex != null && validateHexInput(irkHex)) {
- val irkBytes = hexStringToByteArray(irkHex)
- val irkBase64 = Base64.encode(irkBytes)
- sharedPreferences.edit {putString("IRK", irkBase64)}
- }
-
- if (encKeyHex != null && validateHexInput(encKeyHex)) {
- val encKeyBytes = hexStringToByteArray(encKeyHex)
- val encKeyBase64 = Base64.encode(encKeyBytes)
- sharedPreferences.edit { putString("ENC_KEY", encKeyBase64)}
- }
-
- Toast.makeText(this, "Magic keys added successfully!", Toast.LENGTH_SHORT).show()
- } catch (e: Exception) {
- Toast.makeText(this, "Error processing magic keys: ${e.message}", Toast.LENGTH_LONG).show()
- }
- }
-
- private fun validateHexInput(input: String): Boolean {
- val hexPattern = Regex("^[0-9a-fA-F]{32}$")
- return hexPattern.matches(input)
- }
-
- private fun hexStringToByteArray(hex: String): ByteArray {
- val result = ByteArray(16)
- for (i in 0 until 16) {
- val hexByte = hex.substring(i * 2, i * 2 + 2)
- result[i] = hexByte.toInt(16).toByte()
- }
- return result
- }
}
@ExperimentalHazeMaterialsApi
@@ -265,12 +206,34 @@ class MainActivity : ComponentActivity() {
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun Main() {
+ if (!isSupported()) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = "Not supported. Device Info: BUILD_ID: ${Build.ID} SDK_INT_FULL: ${Build.VERSION.SDK_INT_FULL}, MANUFACTURER: ${Build.MANUFACTURER}.\nCheck out the repository for more info.",
+ color = if (isSystemInDarkTheme()) Color.White else Color.Black,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(16.dp)
+ )
+ }
+ return
+ }
+
val isConnected = remember { mutableStateOf(false) }
- val isRemotelyConnected = remember { mutableStateOf(false) }
-// val hookAvailable = RadareOffsetFinder(LocalContext.current).isHookOffsetAvailable()
val context = LocalContext.current
var canDrawOverlays by remember { mutableStateOf(Settings.canDrawOverlays(context)) }
- val overlaySkipped = remember { mutableStateOf(context.getSharedPreferences("settings", MODE_PRIVATE).getBoolean("overlay_permission_skipped", false)) }
+ val overlaySkipped = remember {
+ mutableStateOf(
+ context.getSharedPreferences("settings", MODE_PRIVATE)
+ .getBoolean("overlay_permission_skipped", false)
+ )
+ }
+
+ BillingManager.provider = BillingProviderFactory.create(context)
val bluetoothPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
listOf(
@@ -297,23 +260,33 @@ fun Main() {
val permissionState = rememberMultiplePermissionsState(
permissions = allPermissions
)
+
val airPodsService = remember { mutableStateOf(null) }
+ val viewModel = remember(airPodsService.value) {
+ airPodsService.value?.let { service ->
+ AirPodsViewModel(
+ service = service,
+ sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE),
+ controlRepo = ControlCommandRepository(service.aacpManager),
+ appContext = context.applicationContext
+ )
+ }
+ }
+
LaunchedEffect(Unit) {
canDrawOverlays = Settings.canDrawOverlays(context)
}
if (permissionState.allPermissionsGranted && (canDrawOverlays || overlaySkipped.value)) {
- val context = LocalContext.current
val navController = rememberNavController()
- Box (
- modifier = Modifier
- .fillMaxSize()
- ){
+ Box(
+ modifier = Modifier.fillMaxSize()
+ ) {
val backButtonBackdrop = rememberLayerBackdrop()
- Box (
+ Box(
modifier = Modifier
.fillMaxSize()
.background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7))
@@ -321,129 +294,125 @@ fun Main() {
) {
NavHost(
navController = navController,
- startDestination = "settings", // if (hookAvailable) "settings" else "onboarding",
+ startDestination = "settings",
enterTransition = {
slideInHorizontally(
- initialOffsetX = { it },
- animationSpec = tween(durationMillis = 300)
- ) // + fadeIn(animationSpec = tween(durationMillis = 300))
+ initialOffsetX = { it }, animationSpec = tween(durationMillis = 300)
+ )
},
exitTransition = {
slideOutHorizontally(
- targetOffsetX = { -it/4 },
- animationSpec = tween(durationMillis = 300)
- ) // + fadeOut(animationSpec = tween(durationMillis = 150))
+ targetOffsetX = { -it / 4 }, animationSpec = tween(durationMillis = 300)
+ )
},
popEnterTransition = {
slideInHorizontally(
- initialOffsetX = { -it/4 },
+ initialOffsetX = { -it / 4 },
animationSpec = tween(durationMillis = 300)
- ) // + fadeIn(animationSpec = tween(durationMillis = 300))
+ )
},
popExitTransition = {
slideOutHorizontally(
- targetOffsetX = { it },
- animationSpec = tween(durationMillis = 300)
- ) // + fadeOut(animationSpec = tween(durationMillis = 150))
- }
- ) {
+ targetOffsetX = { it }, animationSpec = tween(durationMillis = 300)
+ )
+ }) {
composable("settings") {
- if (airPodsService.value != null) {
- AirPodsSettingsScreen(
- dev = airPodsService.value?.device,
- service = airPodsService.value!!,
- navController = navController,
- isConnected = isConnected.value,
- isRemotelyConnected = isRemotelyConnected.value
- )
- }
+ if (viewModel != null) AirPodsSettingsScreen(viewModel, navController)
}
composable("debug") {
DebugScreen(navController = navController)
}
composable("long_press/{bud}") { navBackStackEntry ->
- LongPress(
- navController = navController,
+ if (viewModel != null) LongPress(
+ viewModel = viewModel,
name = navBackStackEntry.arguments?.getString("bud")!!
)
}
composable("rename") {
- RenameScreen(navController)
+ if (viewModel != null) RenameScreen(viewModel)
}
composable("app_settings") {
- AppSettingsScreen(navController)
- }
- composable("troubleshooting") {
- TroubleshootingScreen(navController)
+ val appSettingsViewModel: AppSettingsViewModel = viewModel()
+ AppSettingsScreen(navController, appSettingsViewModel)
}
+// composable("troubleshooting") {
+// TroubleshootingScreen(navController)
+// }
composable("head_tracking") {
- HeadTrackingScreen()
+ if (viewModel != null) HeadTrackingScreen(viewModel)
}
- /*composable("onboarding") {
- Onboarding(navController, context)
- }*/
composable("accessibility") {
- AccessibilitySettingsScreen(navController)
+ if (viewModel != null) AccessibilitySettingsScreen(viewModel, navController)
}
composable("transparency_customization") {
- TransparencySettingsScreen(navController)
+ if (viewModel != null) TransparencySettingsScreen(viewModel)
}
composable("hearing_aid") {
- HearingAidScreen(navController)
+ if (viewModel != null) HearingAidScreen(viewModel, navController)
}
composable("hearing_aid_adjustments") {
- HearingAidAdjustmentsScreen(navController)
+ if (viewModel != null) HearingAidAdjustmentsScreen(viewModel)
}
composable("adaptive_strength") {
- AdaptiveStrengthScreen(navController)
+ if (viewModel != null) AdaptiveStrengthScreen(viewModel)
}
composable("camera_control") {
- CameraControlScreen(navController)
+ if (viewModel != null) CameraControlScreen(viewModel)
}
composable("open_source_licenses") {
OpenSourceLicensesScreen(navController)
}
composable("update_hearing_test") {
- UpdateHearingTestScreen(navController)
+ if (viewModel != null) UpdateHearingTestScreen()
}
composable("version_info") {
- VersionScreen(navController)
+ if (viewModel != null) VersionScreen(viewModel)
}
composable("hearing_protection") {
- HearingProtectionScreen(navController)
+ if (viewModel != null) HearingProtectionScreen(viewModel)
}
}
}
- val showBackButton = remember{ mutableStateOf(false) }
+ val showBackButton = remember { mutableStateOf(false) }
LaunchedEffect(navController) {
navController.addOnDestinationChangedListener { _, destination, _ ->
- showBackButton.value = destination.route != "settings" // && destination.route != "onboarding"
- Log.d("MainActivity", "Navigated to ${destination.route}, showBackButton: ${showBackButton.value}")
+ showBackButton.value =
+ destination.route != "settings" // && destination.route != "onboarding"
+ Log.d(
+ "MainActivity",
+ "Navigated to ${destination.route}, showBackButton: ${showBackButton.value}"
+ )
}
}
AnimatedVisibility(
visible = showBackButton.value,
- enter = fadeIn(animationSpec = tween()) + scaleIn(initialScale = 0f, animationSpec = tween()),
- exit = fadeOut(animationSpec = tween()) + scaleOut(targetScale = 0.5f, animationSpec = tween(100)),
+ enter = fadeIn(animationSpec = tween()) + scaleIn(
+ initialScale = 0f,
+ animationSpec = tween()
+ ),
+ exit = fadeOut(animationSpec = tween()) + scaleOut(
+ targetScale = 0.5f,
+ animationSpec = tween(100)
+ ),
modifier = Modifier
.align(Alignment.TopStart)
.padding(
- start = 8.dp,
- top = (LocalWindowInfo.current.containerSize.width * 0.05f).dp
+ start = 8.dp, top = (LocalWindowInfo.current.containerSize.width * 0.05f).dp
)
) {
StyledIconButton(
- onClick = { navController.popBackStack() },
- icon = "",
- darkMode = isSystemInDarkTheme(),
- backdrop = backButtonBackdrop
- )
+ onClick = { navController.popBackStack() },
+ icon = "",
+ backdrop = backButtonBackdrop
+ )
}
}
+ context.startForegroundService(Intent(context, AirPodsService::class.java))
+
serviceConnection = remember {
object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
@@ -457,17 +426,20 @@ fun Main() {
}
}
- context.bindService(Intent(context, AirPodsService::class.java), serviceConnection, Context.BIND_AUTO_CREATE)
+ context.bindService(
+ Intent(context, AirPodsService::class.java),
+ serviceConnection,
+ Context.BIND_AUTO_CREATE
+ )
- if (airPodsService.value?.isConnectedLocally == true) {
+ if (airPodsService.value?.isConnected() == true) {
isConnected.value = true
}
} else {
PermissionsScreen(
permissionState = permissionState,
canDrawOverlays = canDrawOverlays,
- onOverlaySettingsReturn = { canDrawOverlays = Settings.canDrawOverlays(context) }
- )
+ onOverlaySettingsReturn = { canDrawOverlays = Settings.canDrawOverlays(context) })
}
}
@@ -490,13 +462,9 @@ fun PermissionsScreen(
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
val pulseScale by infiniteTransition.animateFloat(
- initialValue = 1f,
- targetValue = 1.05f,
- animationSpec = infiniteRepeatable(
- animation = tween(1000),
- repeatMode = RepeatMode.Reverse
- ),
- label = "pulse scale"
+ initialValue = 1f, targetValue = 1.05f, animationSpec = infiniteRepeatable(
+ animation = tween(1000), repeatMode = RepeatMode.Reverse
+ ), label = "pulse scale"
)
Column(
@@ -504,18 +472,15 @@ fun PermissionsScreen(
.fillMaxSize()
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7))
.padding(16.dp)
- .verticalScroll(scrollState),
- horizontalAlignment = Alignment.CenterHorizontally
+ .verticalScroll(scrollState), horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.fillMaxWidth()
- .height(180.dp),
- contentAlignment = Alignment.Center
+ .height(180.dp), contentAlignment = Alignment.Center
) {
Text(
- text = "\uDBC2\uDEB7",
- style = TextStyle(
+ text = "\uDBC2\uDEB7", style = TextStyle(
fontSize = 48.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily(Font(R.font.sf_pro)),
@@ -551,29 +516,25 @@ fun PermissionsScreen(
Spacer(modifier = Modifier.height(16.dp))
Text(
- text = "Permission Required",
- style = TextStyle(
+ text = "Permission Required", style = TextStyle(
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor,
textAlign = TextAlign.Center
- ),
- modifier = Modifier.fillMaxWidth()
+ ), modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
Text(
- text = stringResource(R.string.permissions_required),
- style = TextStyle(
+ text = stringResource(R.string.permissions_required), style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor.copy(alpha = 0.7f),
textAlign = TextAlign.Center
- ),
- modifier = Modifier.fillMaxWidth()
+ ), modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(32.dp))
@@ -746,8 +707,7 @@ fun PermissionCard(
if (isGranted) accentColor.copy(alpha = 0.15f) else Color.Gray.copy(
alpha = 0.15f
)
- ),
- contentAlignment = Alignment.Center
+ ), contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
@@ -763,8 +723,7 @@ fun PermissionCard(
.padding(start = 16.dp)
) {
Text(
- text = title,
- style = TextStyle(
+ text = title, style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
@@ -773,8 +732,7 @@ fun PermissionCard(
)
Text(
- text = description,
- style = TextStyle(
+ text = description, style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
@@ -791,11 +749,8 @@ fun PermissionCard(
contentAlignment = Alignment.Center
) {
Text(
- text = if (isGranted) "✓" else "!",
- style = TextStyle(
- fontSize = 14.sp,
- fontWeight = FontWeight.Bold,
- color = Color.White
+ text = if (isGranted) "✓" else "!", style = TextStyle(
+ fontSize = 14.sp, fontWeight = FontWeight.Bold, color = Color.White
)
)
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/billing/BillingManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/billing/BillingManager.kt
new file mode 100644
index 0000000..cfea382
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/billing/BillingManager.kt
@@ -0,0 +1,5 @@
+package me.kavishdevar.librepods.billing
+
+object BillingManager {
+ lateinit var provider: BillingProvider
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/billing/BillingProvider.kt b/android/app/src/main/java/me/kavishdevar/librepods/billing/BillingProvider.kt
new file mode 100644
index 0000000..027ad94
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/billing/BillingProvider.kt
@@ -0,0 +1,28 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+package me.kavishdevar.librepods.billing
+
+import android.app.Activity
+import kotlinx.coroutines.flow.StateFlow
+
+interface BillingProvider {
+ val isPremium: StateFlow
+
+ fun purchase(activity: Activity)
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/billing/BillingProviderFactory.kt b/android/app/src/main/java/me/kavishdevar/librepods/billing/BillingProviderFactory.kt
new file mode 100644
index 0000000..2b83717
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/billing/BillingProviderFactory.kt
@@ -0,0 +1,33 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+package me.kavishdevar.librepods.billing
+
+import android.content.Context
+import me.kavishdevar.librepods.BuildConfig
+
+object BillingProviderFactory {
+
+ fun create(context: Context): BillingProvider {
+ return if (BuildConfig.PLAY_BUILD) {
+ PlayBillingProvider(context)
+ } else {
+ FOSSBillingProvider()
+ }
+ }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/billing/FOSSBillingProvider.kt b/android/app/src/main/java/me/kavishdevar/librepods/billing/FOSSBillingProvider.kt
new file mode 100644
index 0000000..9b06964
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/billing/FOSSBillingProvider.kt
@@ -0,0 +1,30 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+package me.kavishdevar.librepods.billing
+
+import android.app.Activity
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+
+class FOSSBillingProvider : BillingProvider {
+ private val _isPremium = MutableStateFlow(true)
+ override val isPremium: StateFlow = _isPremium
+
+ override fun purchase(activity: Activity) { }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/billing/PlayBillingProvider.kt b/android/app/src/main/java/me/kavishdevar/librepods/billing/PlayBillingProvider.kt
new file mode 100644
index 0000000..790be60
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/billing/PlayBillingProvider.kt
@@ -0,0 +1,187 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+package me.kavishdevar.librepods.billing
+
+import android.app.Activity
+import android.content.Context
+import android.util.Log
+import com.android.billingclient.api.AcknowledgePurchaseParams
+import com.android.billingclient.api.BillingClient
+import com.android.billingclient.api.BillingClientStateListener
+import com.android.billingclient.api.BillingFlowParams
+import com.android.billingclient.api.BillingResult
+import com.android.billingclient.api.PendingPurchasesParams
+import com.android.billingclient.api.ProductDetails
+import com.android.billingclient.api.Purchase
+import com.android.billingclient.api.PurchasesUpdatedListener
+import com.android.billingclient.api.QueryProductDetailsParams
+import com.android.billingclient.api.QueryPurchasesParams
+import com.android.billingclient.api.acknowledgePurchase
+import com.android.billingclient.api.queryProductDetails
+import com.android.billingclient.api.queryPurchasesAsync
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+
+const val TAG = "PlayBillingProvider"
+
+private const val PREMIUM_PRODUCT_ID = "librepods.advanced_features.v2"
+
+class PlayBillingProvider(
+ context: Context
+) : BillingProvider, PurchasesUpdatedListener {
+
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ private val _isPremium = MutableStateFlow(false)
+ override val isPremium: StateFlow = _isPremium
+
+ private var productDetails: ProductDetails? = null
+
+ private val billingClient = BillingClient.newBuilder(context)
+ .setListener(this)
+ .enablePendingPurchases(
+ PendingPurchasesParams.newBuilder().enableOneTimeProducts().build()
+ )
+ .build()
+
+ init {
+ connect()
+ }
+
+ private fun connect() {
+ billingClient.startConnection(object : BillingClientStateListener {
+ override fun onBillingSetupFinished(result: BillingResult) {
+ if (result.responseCode == BillingClient.BillingResponseCode.OK) {
+ scope.launch {
+ queryProductDetails()
+ queryExistingPurchases()
+ }
+ } else {
+ Log.w(TAG, "Billing setup failed: ${result.debugMessage}")
+ }
+ }
+
+ override fun onBillingServiceDisconnected() {
+ connect()
+ }
+ })
+ }
+
+ private suspend fun queryProductDetails() {
+ val params = QueryProductDetailsParams.newBuilder()
+ .setProductList(
+ listOf(
+ QueryProductDetailsParams.Product.newBuilder()
+ .setProductId(PREMIUM_PRODUCT_ID)
+ .setProductType(BillingClient.ProductType.INAPP)
+ .build()
+ )
+ ).build()
+
+ val result = billingClient.queryProductDetails(params)
+ if (result.billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
+ productDetails = result.productDetailsList?.firstOrNull()
+ Log.d(TAG, "Product loaded: ${productDetails?.name}")
+ } else {
+ Log.w(TAG, "queryProductDetails failed: ${result.billingResult.debugMessage}")
+ }
+ }
+
+ private suspend fun queryExistingPurchases() {
+ val result = billingClient.queryPurchasesAsync(
+ QueryPurchasesParams.newBuilder()
+ .setProductType(BillingClient.ProductType.INAPP)
+ .build()
+ )
+ processPurchases(result.purchasesList)
+ }
+
+ override fun purchase(activity: Activity) {
+ val details = productDetails ?: run {
+ Log.e(TAG, "Product details not loaded yet")
+ return
+ }
+
+ val billingFlowParams = BillingFlowParams.newBuilder()
+ .setProductDetailsParamsList(
+ listOf(
+ BillingFlowParams.ProductDetailsParams.newBuilder()
+ .setProductDetails(details)
+ .build()
+ )
+ ).build()
+
+ val result = billingClient.launchBillingFlow(activity, billingFlowParams)
+ if (result.responseCode != BillingClient.BillingResponseCode.OK) {
+ Log.e(TAG, "launchBillingFlow failed: ${result.debugMessage}")
+ }
+ }
+
+ override fun onPurchasesUpdated(result: BillingResult, purchases: List?) {
+ when (result.responseCode) {
+ BillingClient.BillingResponseCode.OK -> purchases?.let { processPurchases(it) }
+ BillingClient.BillingResponseCode.USER_CANCELED -> Log.d(TAG, "User cancelled")
+ else -> Log.w(TAG, "Purchase error ${result.responseCode}: ${result.debugMessage}")
+ }
+ }
+
+ private fun processPurchases(purchases: List) {
+ val hasPremium = purchases.any {
+ it.products.contains(PREMIUM_PRODUCT_ID) &&
+ it.purchaseState == Purchase.PurchaseState.PURCHASED
+ }
+
+
+// val purchase = purchases.find {
+// it.products.contains(PREMIUM_PRODUCT_ID) && it.purchaseState == Purchase.PurchaseState.PURCHASED
+// }
+//
+// if (purchase != null) {
+// val consumeParams = ConsumeParams.newBuilder()
+// .setPurchaseToken(purchase.purchaseToken)
+// .build()
+// scope.launch {
+// billingClient.consumeAsync(consumeParams) { _, _ ->}
+// }
+// }
+
+
+ _isPremium.value = hasPremium
+
+ scope.launch {
+ purchases
+ .filter { it.purchaseState == Purchase.PurchaseState.PURCHASED && !it.isAcknowledged }
+ .forEach { acknowledge(it) }
+ }
+ }
+
+ private suspend fun acknowledge(purchase: Purchase) {
+ val params = AcknowledgePurchaseParams.newBuilder()
+ .setPurchaseToken(purchase.purchaseToken)
+ .build()
+ val result = billingClient.acknowledgePurchase(params)
+ if (result.responseCode != BillingClient.BillingResponseCode.OK) {
+ Log.e(TAG, "Acknowledgement failed: ${result.debugMessage}")
+ }
+ }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/AboutCard.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/AboutCard.kt
index f4c2067..416abf1 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/AboutCard.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/AboutCard.kt
@@ -22,8 +22,8 @@ package me.kavishdevar.librepods.composables
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
-import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -34,35 +34,35 @@ 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.mutableIntStateOf
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.res.stringResource
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.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import me.kavishdevar.librepods.R
-import me.kavishdevar.librepods.composables.NavigationButton
-import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
-fun AboutCard(navController: NavController) {
+fun AboutCard(
+ navController: NavController,
+ modelName: String,
+ actualModel: String,
+ serialNumbers: List,
+ version: String?
+) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
- val service = ServiceManager.getService()
- if (service == null) return
- val airpodsInstance = service.airpodsInstance
- if (airpodsInstance == null) return
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
Box(
@@ -108,7 +108,7 @@ fun AboutCard(navController: NavController) {
)
)
Text(
- text = airpodsInstance.model.displayName,
+ text = modelName,
style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
@@ -137,7 +137,7 @@ fun AboutCard(navController: NavController) {
)
)
Text(
- text = airpodsInstance.actualModelNumber,
+ text = actualModel,
style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
@@ -152,11 +152,11 @@ fun AboutCard(navController: NavController) {
.padding(horizontal = 12.dp)
)
val serialNumbers = listOf(
- airpodsInstance.serialNumber?: "",
- " ${airpodsInstance.leftSerialNumber}",
- " ${airpodsInstance.rightSerialNumber}"
+ serialNumbers[0],
+ " ${serialNumbers[1]}",
+ " ${serialNumbers[2]}"
)
- val serialNumber = remember { mutableStateOf(0) }
+ val serialNumber = remember { mutableIntStateOf(0) }
Row(
modifier = Modifier
.fillMaxWidth()
@@ -172,7 +172,7 @@ fun AboutCard(navController: NavController) {
),
)
Text(
- text = serialNumbers[serialNumber.value],
+ text = serialNumbers[serialNumber.intValue],
style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
@@ -183,7 +183,7 @@ fun AboutCard(navController: NavController) {
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
- serialNumber.value = (serialNumber.value + 1) % serialNumbers.size
+ serialNumber.intValue = (serialNumber.intValue + 1) % serialNumbers.size
}
)
}
@@ -197,9 +197,9 @@ fun AboutCard(navController: NavController) {
to = "version_info",
navController = navController,
name = stringResource(R.string.version),
- currentState = airpodsInstance.version3,
+ currentState = version,
independent = false,
height = rowHeight.value + 32.dp
)
}
-}
\ No newline at end of file
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/AudioSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/AudioSettings.kt
index f6dbaa6..e4ead08 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/AudioSettings.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/AudioSettings.kt
@@ -42,25 +42,32 @@ import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
import me.kavishdevar.librepods.R
-import me.kavishdevar.librepods.services.ServiceManager
-import me.kavishdevar.librepods.utils.AACPManager
-import me.kavishdevar.librepods.utils.ATTHandles
-import me.kavishdevar.librepods.utils.Capability
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
-fun AudioSettings(navController: NavController) {
+fun AudioSettings(
+ navController: NavController,
+ adaptiveVolumeCapability: Boolean,
+ conversationalAwarenessCapability: Boolean,
+ loudSoundReductionCapability: Boolean,
+ adaptiveAudioCapability: Boolean,
+
+ adaptiveVolumeChecked: Boolean,
+ onAdaptiveVolumeCheckedChange: (Boolean) -> Unit,
+
+ conversationalAwarenessChecked: Boolean,
+ onConversationalAwarenessCheckedChange: (Boolean) -> Unit,
+
+ loudSoundReductionChecked: Boolean,
+ onLoudSoundReductionCheckedChange: (Boolean) -> Unit,
+
+ isXposed: Boolean,
+ isPremium: Boolean
+) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
- val service = ServiceManager.getService()
- if (service == null) return
- val airpodsInstance = service.airpodsInstance
- if (airpodsInstance == null) return
- if (!airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_VOLUME) &&
- !airpodsInstance.model.capabilities.contains(Capability.CONVERSATION_AWARENESS) &&
- !airpodsInstance.model.capabilities.contains(Capability.LOUD_SOUND_REDUCTION) &&
- !airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_AUDIO)
- ) {
+
+ if (!adaptiveVolumeCapability && !conversationalAwarenessCapability && !loudSoundReductionCapability && !adaptiveAudioCapability) {
return
}
Box(
@@ -88,12 +95,14 @@ fun AudioSettings(navController: NavController) {
.padding(top = 2.dp)
) {
- if (airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_VOLUME)) {
+ if (adaptiveVolumeCapability) {
StyledToggle(
label = stringResource(R.string.personalized_volume),
description = stringResource(R.string.personalized_volume_description),
- controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG,
- independent = false
+ independent = false,
+ checked = adaptiveVolumeChecked,
+ onCheckedChange = onAdaptiveVolumeCheckedChange,
+ enabled = isPremium
)
HorizontalDivider(
@@ -104,12 +113,14 @@ fun AudioSettings(navController: NavController) {
)
}
- if (airpodsInstance.model.capabilities.contains(Capability.CONVERSATION_AWARENESS)) {
+ if (conversationalAwarenessCapability) {
StyledToggle(
label = stringResource(R.string.conversational_awareness),
description = stringResource(R.string.conversational_awareness_description),
- controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
- independent = false
+ independent = false,
+ checked = conversationalAwarenessChecked,
+ onCheckedChange = onConversationalAwarenessCheckedChange,
+ enabled = isPremium
)
HorizontalDivider(
thickness = 1.dp,
@@ -119,12 +130,13 @@ fun AudioSettings(navController: NavController) {
)
}
- if (airpodsInstance.model.capabilities.contains(Capability.LOUD_SOUND_REDUCTION)){
+ if (loudSoundReductionCapability && isXposed){
StyledToggle(
label = stringResource(R.string.loud_sound_reduction),
description = stringResource(R.string.loud_sound_reduction_description),
- attHandle = ATTHandles.LOUD_SOUND_REDUCTION,
- independent = false
+ independent = false,
+ checked = loudSoundReductionChecked,
+ onCheckedChange = onLoudSoundReductionCheckedChange
)
HorizontalDivider(
thickness = 1.dp,
@@ -134,7 +146,7 @@ fun AudioSettings(navController: NavController) {
)
}
- if (airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_AUDIO)) {
+ if (adaptiveAudioCapability) {
NavigationButton(
to = "adaptive_strength",
name = stringResource(R.string.adaptive_audio),
@@ -148,5 +160,19 @@ fun AudioSettings(navController: NavController) {
@Preview
@Composable
fun AudioSettingsPreview() {
- AudioSettings(rememberNavController())
+ AudioSettings(
+ navController = rememberNavController(),
+ adaptiveVolumeCapability = true,
+ conversationalAwarenessCapability = true,
+ loudSoundReductionCapability = true,
+ adaptiveAudioCapability = true,
+ adaptiveVolumeChecked = true,
+ onAdaptiveVolumeCheckedChange = { },
+ conversationalAwarenessChecked = true,
+ onConversationalAwarenessCheckedChange = { },
+ loudSoundReductionChecked = true,
+ onLoudSoundReductionCheckedChange = { },
+ isXposed = true,
+ isPremium = true
+ )
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryView.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryView.kt
index c98729d..e79ca7c 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryView.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryView.kt
@@ -20,13 +20,7 @@
package me.kavishdevar.librepods.composables
-import android.content.BroadcastReceiver
-import android.content.Context
-import android.content.Intent
-import android.content.IntentFilter
import android.content.res.Configuration
-import android.os.Build
-import android.util.Log
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
@@ -39,169 +33,101 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.imageResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.R
-import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.constants.Battery
import me.kavishdevar.librepods.constants.BatteryComponent
import me.kavishdevar.librepods.constants.BatteryStatus
-import me.kavishdevar.librepods.services.AirPodsService
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
-fun BatteryView(service: AirPodsService, preview: Boolean = false) {
- val batteryStatus = remember { mutableStateOf>(listOf()) }
+fun BatteryView(
+ batteryList: List,
+ budsRes: Int,
+ caseRes: Int
+) {
+ val left = batteryList.find { it.component == BatteryComponent.LEFT }
+ val right = batteryList.find { it.component == BatteryComponent.RIGHT }
+ val case = batteryList.find { it.component == BatteryComponent.CASE }
- val previousBatteryStatus = remember { mutableStateOf>(listOf()) }
-
- @Suppress("DEPRECATION") val batteryReceiver = remember {
- object : BroadcastReceiver() {
- override fun onReceive(context: Context, intent: Intent) {
- if (intent.action == AirPodsNotifications.BATTERY_DATA) {
- batteryStatus.value =
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- intent.getParcelableArrayListExtra("data", Battery::class.java)
- } else {
- intent.getParcelableArrayListExtra("data")
- }?.toList() ?: listOf()
- }
- else if (intent.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
- try {
- context.unregisterReceiver(this)
- }
- catch (_: IllegalArgumentException) {
- Log.wtf("BatteryReceiver", "Receiver already unregistered")
- }
- }
- }
- }
- }
- val context = LocalContext.current
-
- LaunchedEffect(context) {
- val batteryIntentFilter = IntentFilter()
- .apply {
- addAction(AirPodsNotifications.BATTERY_DATA)
- addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
- }
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- context.registerReceiver(
- batteryReceiver,
- batteryIntentFilter,
- Context.RECEIVER_EXPORTED
- )
- }
- }
-
- previousBatteryStatus.value = batteryStatus.value
- batteryStatus.value = service.getBattery()
-
- if (preview) {
- batteryStatus.value = listOf(
- Battery(BatteryComponent.LEFT, 100, BatteryStatus.NOT_CHARGING),
- Battery(BatteryComponent.RIGHT, 94, BatteryStatus.CHARGING),
- Battery(BatteryComponent.CASE, 40, BatteryStatus.CHARGING)
- )
- previousBatteryStatus.value = batteryStatus.value
- }
-
- val left = batteryStatus.value.find { it.component == BatteryComponent.LEFT }
- val right = batteryStatus.value.find { it.component == BatteryComponent.RIGHT }
- val case = batteryStatus.value.find { it.component == BatteryComponent.CASE }
val leftLevel = left?.level ?: 0
val rightLevel = right?.level ?: 0
val caseLevel = case?.level ?: 0
- val leftCharging = left?.status == BatteryStatus.CHARGING || left?.status == BatteryStatus.OPTIMIZED_CHARGING
- val rightCharging = right?.status == BatteryStatus.CHARGING || right?.status == BatteryStatus.OPTIMIZED_CHARGING
- val caseCharging = case?.status == BatteryStatus.CHARGING || case?.status == BatteryStatus.OPTIMIZED_CHARGING
- val prevLeft = previousBatteryStatus.value.find { it.component == BatteryComponent.LEFT }
- val prevRight = previousBatteryStatus.value.find { it.component == BatteryComponent.RIGHT }
- val prevCase = previousBatteryStatus.value.find { it.component == BatteryComponent.CASE }
- val prevLeftCharging = prevLeft?.status == BatteryStatus.CHARGING
- val prevRightCharging = prevRight?.status == BatteryStatus.CHARGING
- val prevCaseCharging = prevCase?.status == BatteryStatus.CHARGING
+ val leftCharging = left?.status == BatteryStatus.CHARGING ||
+ left?.status == BatteryStatus.OPTIMIZED_CHARGING
+
+ val rightCharging = right?.status == BatteryStatus.CHARGING ||
+ right?.status == BatteryStatus.OPTIMIZED_CHARGING
+
+ val caseCharging = case?.status == BatteryStatus.CHARGING ||
+ case?.status == BatteryStatus.OPTIMIZED_CHARGING
val singleDisplayed = remember { mutableStateOf(false) }
- val airpodsInstance = service.airpodsInstance
- if (airpodsInstance == null) {
- return
- }
- val budsRes = airpodsInstance.model.budsRes
- val caseRes = airpodsInstance.model.caseRes
-
Row {
- Column (
- modifier = Modifier
- .fillMaxWidth(0.5f),
+ Column(
+ modifier = Modifier.fillMaxWidth(0.5f),
horizontalAlignment = Alignment.CenterHorizontally
) {
- Image (
+ Image(
bitmap = ImageBitmap.imageResource(budsRes),
contentDescription = stringResource(R.string.buds),
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
)
+
if (
leftCharging == rightCharging &&
(leftLevel - rightLevel) in -3..3
- )
- {
+ ) {
BatteryIndicator(
leftLevel.coerceAtMost(rightLevel),
- leftCharging,
- previousCharging = (prevLeftCharging && prevRightCharging)
+ leftCharging
)
singleDisplayed.value = true
- }
- else {
+ } else {
singleDisplayed.value = false
- Row (
- modifier = Modifier
- .fillMaxWidth(),
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
if (leftLevel > 0 || left?.status != BatteryStatus.DISCONNECTED) {
BatteryIndicator(
leftLevel,
leftCharging,
- "\uDBC6\uDCE5",
- previousCharging = prevLeftCharging
+ "\uDBC6\uDCE5"
)
}
- if (leftLevel > 0 && rightLevel > 0)
- {
+
+ if (leftLevel > 0 && rightLevel > 0) {
Spacer(modifier = Modifier.width(16.dp))
}
- if (rightLevel > 0 || right?.status != BatteryStatus.DISCONNECTED)
- {
+
+ if (rightLevel > 0 || right?.status != BatteryStatus.DISCONNECTED) {
BatteryIndicator(
rightLevel,
rightCharging,
- "\uDBC6\uDCE8",
- previousCharging = prevRightCharging
+ "\uDBC6\uDCE8"
)
}
}
}
}
- Column (
- modifier = Modifier
- .fillMaxWidth(),
+ Column(
+ modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
@@ -211,14 +137,14 @@ fun BatteryView(service: AirPodsService, preview: Boolean = false) {
.fillMaxWidth()
.padding(8.dp)
)
- if (caseLevel > 0 || case?.status != BatteryStatus.DISCONNECTED) {
- BatteryIndicator(
- caseLevel,
- caseCharging,
- prefix = if (!singleDisplayed.value) "\uDBC3\uDE6C" else "",
- previousCharging = prevCaseCharging
- )
- }
+
+ if (caseLevel > 0 || case?.status != BatteryStatus.DISCONNECTED) {
+ BatteryIndicator(
+ caseLevel,
+ caseCharging,
+ prefix = if (!singleDisplayed.value) "\uDBC3\uDE6C" else ""
+ )
+ }
}
}
}
@@ -226,10 +152,23 @@ fun BatteryView(service: AirPodsService, preview: Boolean = false) {
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun BatteryViewPreview() {
+ val fakeBattery = listOf(
+ Battery(BatteryComponent.LEFT, 85, BatteryStatus.CHARGING),
+ Battery(BatteryComponent.RIGHT, 40, BatteryStatus.CHARGING),
+ Battery(BatteryComponent.CASE, 60, BatteryStatus.NOT_CHARGING)
+ )
+
val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)
+
Box(
- modifier = Modifier.background(bg)
+ modifier = Modifier
+ .background(bg)
+ .padding(16.dp)
) {
- BatteryView(AirPodsService(), preview = true)
+ BatteryView(
+ batteryList = fakeBattery,
+ budsRes = R.drawable.airpods_pro_2_buds,
+ caseRes = R.drawable.airpods_pro_2_case
+ )
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/CallControlSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/CallControlSettings.kt
index 09b80ff..ec8ae16 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/CallControlSettings.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/CallControlSettings.kt
@@ -36,7 +36,6 @@ 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.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
@@ -56,19 +55,20 @@ 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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R
-import me.kavishdevar.librepods.services.ServiceManager
-import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
@ExperimentalHazeMaterialsApi
@Composable
-fun CallControlSettings(hazeState: HazeState) {
+fun CallControlSettings(
+ hazeState: HazeState,
+ flipped: Boolean,
+ onCallControlValueChanged: (Boolean) -> Unit
+) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
@@ -93,24 +93,9 @@ fun CallControlSettings(hazeState: HazeState) {
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(top = 2.dp)
) {
- val service = ServiceManager.getService()!!
- val callControlEnabledValue = service.aacpManager.controlCommandStatusList.find {
- it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG
- }?.value ?: byteArrayOf(0x00, 0x03)
-
val pressOnceText = stringResource(R.string.press_once)
val pressTwiceText = stringResource(R.string.press_twice)
- var flipped by remember {
- mutableStateOf(
- callControlEnabledValue.contentEquals(
- byteArrayOf(
- 0x00,
- 0x02
- )
- )
- )
- }
var singlePressAction by remember { mutableStateOf(if (flipped) pressTwiceText else pressOnceText) }
var doublePressAction by remember { mutableStateOf(if (flipped) pressOnceText else pressTwiceText) }
@@ -128,35 +113,6 @@ fun CallControlSettings(hazeState: HazeState) {
var parentHoveredIndexDouble by remember { mutableStateOf(null) }
var parentDragActiveDouble by remember { mutableStateOf(false) }
- LaunchedEffect(Unit) {
- val listener = object : AACPManager.ControlCommandListener {
- override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
- if (AACPManager.Companion.ControlCommandIdentifiers.fromByte(controlCommand.identifier) ==
- AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG
- ) {
- val newFlipped = controlCommand.value.contentEquals(byteArrayOf(0x00, 0x02))
- flipped = newFlipped
- singlePressAction = if (newFlipped) pressTwiceText else pressOnceText
- doublePressAction = if (newFlipped) pressOnceText else pressTwiceText
- Log.d(
- "CallControlSettings",
- "Control command received, flipped: $newFlipped"
- )
- }
- }
- }
-
- service.aacpManager.registerControlCommandListener(
- AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG,
- listener
- )
- }
-
- DisposableEffect(Unit) {
- onDispose {
- service.aacpManager.controlCommandListeners[AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG]?.clear()
- }
- }
LaunchedEffect(flipped) {
Log.d("CallControlSettings", "Call control flipped: $flipped")
}
@@ -244,11 +200,8 @@ fun CallControlSettings(hazeState: HazeState) {
if (option == pressOnceText) pressTwiceText else pressOnceText
showSinglePressDropdown = false
lastDismissTimeSingle = System.currentTimeMillis()
- val bytes = if (option == pressOnceText) byteArrayOf(
- 0x00,
- 0x03
- ) else byteArrayOf(0x00, 0x02)
- service.aacpManager.sendControlCommand(0x24, bytes)
+ onCallControlValueChanged(option != pressOnceText)
+
}
}
parentHoveredIndexSingle = null
@@ -313,11 +266,8 @@ fun CallControlSettings(hazeState: HazeState) {
doublePressAction =
if (option == pressOnceText) pressTwiceText else pressOnceText
showSinglePressDropdown = false
- val bytes = if (option == pressOnceText) byteArrayOf(
- 0x00,
- 0x03
- ) else byteArrayOf(0x00, 0x02)
- service.aacpManager.sendControlCommand(0x24, bytes)
+ val flipped = option != pressOnceText
+ onCallControlValueChanged(flipped)
},
hazeState = hazeState
)
@@ -379,11 +329,8 @@ fun CallControlSettings(hazeState: HazeState) {
if (option == pressOnceText) pressTwiceText else pressOnceText
showDoublePressDropdown = false
lastDismissTimeDouble = System.currentTimeMillis()
- val bytes = if (option == pressOnceText) byteArrayOf(
- 0x00,
- 0x02
- ) else byteArrayOf(0x00, 0x03)
- service.aacpManager.sendControlCommand(0x24, bytes)
+ val flipped = option == pressOnceText
+ onCallControlValueChanged (flipped)
}
}
parentHoveredIndexDouble = null
@@ -448,11 +395,8 @@ fun CallControlSettings(hazeState: HazeState) {
singlePressAction =
if (option == pressOnceText) pressTwiceText else pressOnceText
showDoublePressDropdown = false
- val bytes = if (option == pressOnceText) byteArrayOf(
- 0x00,
- 0x02
- ) else byteArrayOf(0x00, 0x03)
- service.aacpManager.sendControlCommand(0x24, bytes)
+ val flipped = option == pressOnceText
+ onCallControlValueChanged(flipped)
},
hazeState = hazeState
)
@@ -461,10 +405,3 @@ fun CallControlSettings(hazeState: HazeState) {
}
}
}
-
-@ExperimentalHazeMaterialsApi
-@Preview
-@Composable
-fun CallControlSettingsPreview() {
- CallControlSettings(HazeState())
-}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/ConnectionSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/ConnectionSettings.kt
index a21bfd1..b95807c 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/ConnectionSettings.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/ConnectionSettings.kt
@@ -20,7 +20,6 @@
package me.kavishdevar.librepods.composables
-import android.content.Context.MODE_PRIVATE
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
@@ -31,16 +30,18 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.R
-import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
-fun ConnectionSettings() {
+fun ConnectionSettings(
+ automaticEarDetectionEnabled: Boolean,
+ onAutomaticEarDetectionChanged: (Boolean) -> Unit,
+ automaticConnectionEnabled: Boolean,
+ onAutomaticConnectionChanged: (Boolean) -> Unit,
+) {
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
@@ -52,10 +53,9 @@ fun ConnectionSettings() {
) {
StyledToggle(
label = stringResource(R.string.ear_detection),
- controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.EAR_DETECTION_CONFIG,
- sharedPreferenceKey = "automatic_ear_detection",
- sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE),
- independent = false
+ independent = false,
+ checked = automaticEarDetectionEnabled,
+ onCheckedChange = onAutomaticEarDetectionChanged
)
HorizontalDivider(
thickness = 1.dp,
@@ -67,16 +67,9 @@ fun ConnectionSettings() {
StyledToggle(
label = stringResource(R.string.automatically_connect),
description = stringResource(R.string.automatically_connect_description),
- controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG,
- sharedPreferenceKey = "automatic_connection_ctrl_cmd",
- sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE),
- independent = false
+ independent = false,
+ checked = automaticConnectionEnabled,
+ onCheckedChange = onAutomaticConnectionChanged
)
}
}
-
-@Preview
-@Composable
-fun ConnectionSettingsPreview() {
- ConnectionSettings()
-}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/HearingHealthSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/HearingHealthSettings.kt
index fe75489..1379d27 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/HearingHealthSettings.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/HearingHealthSettings.kt
@@ -40,70 +40,76 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import me.kavishdevar.librepods.R
-import me.kavishdevar.librepods.composables.NavigationButton
-import me.kavishdevar.librepods.services.ServiceManager
-import me.kavishdevar.librepods.utils.Capability
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
-fun HearingHealthSettings(navController: NavController) {
- val service = ServiceManager.getService()
- if (service == null) return
- val airpodsInstance = service.airpodsInstance
- if (airpodsInstance == null) return
- if (airpodsInstance.model.capabilities.contains(Capability.HEARING_AID)) {
- val isDarkTheme = isSystemInDarkTheme()
- val textColor = if (isDarkTheme) Color.White else Color.Black
- val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
+fun HearingHealthSettings(
+ navController: NavController,
+ hasPPECapability: Boolean,
+ hasHearingAidCapability: Boolean,
+ isXposed: Boolean
+) {
+ val isDarkTheme = isSystemInDarkTheme()
+ val textColor = if (isDarkTheme) Color.White else Color.Black
+ val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
+ val shouldShowHearingAid = hasHearingAidCapability && isXposed
- if (airpodsInstance.model.capabilities.contains(Capability.PPE)) {
- Box(
+ if (hasPPECapability && shouldShowHearingAid) {
+ Box(
+ modifier = Modifier
+ .background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
+ .padding(horizontal = 16.dp, vertical = 4.dp)
+ ){
+ Text(
+ text = stringResource(R.string.hearing_health),
+ style = TextStyle(
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Bold,
+ color = textColor.copy(alpha = 0.6f)
+ )
+ )
+ }
+ Column(
+ modifier = Modifier
+ .clip(RoundedCornerShape(28.dp))
+ .fillMaxWidth()
+ .background(backgroundColor, RoundedCornerShape(28.dp))
+ .padding(top = 2.dp)
+ ) {
+ NavigationButton(
+ to = "hearing_protection",
+ name = stringResource(R.string.hearing_protection),
+ navController = navController,
+ independent = false
+ )
+
+ HorizontalDivider(
+ thickness = 1.dp,
+ color = Color(0x40888888),
modifier = Modifier
- .background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
- .padding(horizontal = 16.dp, vertical = 4.dp)
- ){
- Text(
- text = stringResource(R.string.hearing_health),
- style = TextStyle(
- fontSize = 14.sp,
- fontWeight = FontWeight.Bold,
- color = textColor.copy(alpha = 0.6f)
- )
- )
- }
- Column(
- modifier = Modifier
- .clip(RoundedCornerShape(28.dp))
- .fillMaxWidth()
- .background(backgroundColor, RoundedCornerShape(28.dp))
- .padding(top = 2.dp)
- ) {
- NavigationButton(
- to = "hearing_protection",
- name = stringResource(R.string.hearing_protection),
- navController = navController,
- independent = false
- )
- HorizontalDivider(
- thickness = 1.dp,
- color = Color(0x40888888),
- modifier = Modifier
- .padding(horizontal = 12.dp)
- )
-
- NavigationButton(
- to = "hearing_aid",
- name = stringResource(R.string.hearing_aid),
- navController = navController,
- independent = false
- )
- }
- } else {
+ .padding(horizontal = 12.dp)
+ )
+
+
NavigationButton(
to = "hearing_aid",
name = stringResource(R.string.hearing_aid),
- navController = navController
+ navController = navController,
+ independent = false
)
}
+ } else if (shouldShowHearingAid) {
+ NavigationButton(
+ to = "hearing_aid",
+ name = stringResource(R.string.hearing_aid),
+ navController = navController
+ )
+ } else if (hasPPECapability) {
+ NavigationButton(
+ to = "hearing_protection",
+ name = stringResource(R.string.hearing_protection),
+ title = stringResource(R.string.hearing_health),
+ navController = navController
+ )
}
-}
\ No newline at end of file
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/MicrophoneSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/MicrophoneSettings.kt
index bba8c70..5ade04d 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/MicrophoneSettings.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/MicrophoneSettings.kt
@@ -35,8 +35,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
@@ -54,19 +52,21 @@ 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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R
-import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
@ExperimentalHazeMaterialsApi
@Composable
-fun MicrophoneSettings(hazeState: HazeState) {
+fun MicrophoneSettings(
+ hazeState: HazeState,
+ micModeValue: Byte,
+ onMicModeValueChanged: (Byte) -> Unit
+) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
@@ -77,11 +77,6 @@ fun MicrophoneSettings(hazeState: HazeState) {
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(top = 2.dp)
) {
- val service = ServiceManager.getService()!!
- val micModeValue = service.aacpManager.controlCommandStatusList.find {
- it.identifier == AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE
- }?.value?.get(0) ?: 0x00.toByte()
-
var selectedMode by remember {
mutableStateOf(
when (micModeValue) {
@@ -114,22 +109,6 @@ fun MicrophoneSettings(hazeState: HazeState) {
}
}
- LaunchedEffect(Unit) {
- service.aacpManager.registerControlCommandListener(
- AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE,
- listener
- )
- }
-
- DisposableEffect(Unit) {
- onDispose {
- service.aacpManager.unregisterControlCommandListener(
- AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE,
- listener
- )
- }
- }
-
val density = LocalDensity.current
val itemHeightPx = with(density) { 48.dp.toPx() }
var parentHoveredIndex by remember { mutableStateOf(null) }
@@ -194,10 +173,11 @@ fun MicrophoneSettings(hazeState: HazeState) {
options[2] -> 0x02
else -> 0x00
}
- service.aacpManager.sendControlCommand(
- AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value,
- byteArrayOf(byteValue.toByte())
- )
+// service.aacpManager.sendControlCommand(
+// AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value,
+// byteArrayOf(byteValue.toByte())
+// )
+ onMicModeValueChanged(byteValue.toByte())
}
}
parentHoveredIndex = null
@@ -277,10 +257,7 @@ fun MicrophoneSettings(hazeState: HazeState) {
microphoneAlwaysLeftText -> 0x02
else -> 0x00
}
- service.aacpManager.sendControlCommand(
- AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value,
- byteArrayOf(byteValue.toByte())
- )
+ onMicModeValueChanged(byteValue.toByte())
},
hazeState = hazeState
)
@@ -288,10 +265,3 @@ fun MicrophoneSettings(hazeState: HazeState) {
}
}
}
-
-@ExperimentalHazeMaterialsApi
-@Preview
-@Composable
-fun MicrophoneSettingsPreview() {
- MicrophoneSettings(HazeState())
-}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt
index 7188100..699ed37 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt
@@ -21,11 +21,6 @@
package me.kavishdevar.librepods.composables
import android.annotation.SuppressLint
-import android.content.BroadcastReceiver
-import android.content.Context
-import android.content.Intent
-import android.content.IntentFilter
-import android.os.Build
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.SpringSpec
@@ -60,48 +55,28 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.imageResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import me.kavishdevar.librepods.R
-import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.constants.NoiseControlMode
-import me.kavishdevar.librepods.services.AirPodsService
-import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.roundToInt
@SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope")
@Composable
fun NoiseControlSettings(
- service: AirPodsService,
+ showOffListeningMode: Boolean,
+ noiseControlModeValue: Int,
+ onNoiseControlModeChanged: (Int) -> Unit
) {
- val context = LocalContext.current
- val offListeningModeConfigValue = service.aacpManager.controlCommandStatusList.find {
- it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
- }?.value?.takeIf { it.isNotEmpty() }?.get(0) != 2.toByte()
- val offListeningMode = remember { mutableStateOf(offListeningModeConfigValue) }
-
- val offListeningModeListener = object: AACPManager.ControlCommandListener {
- override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
- offListeningMode.value = controlCommand.value[0] != 2.toByte()
- }
- }
-
- service.aacpManager.registerControlCommandListener(
- AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION,
- offListeningModeListener
- )
-
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8)
val textColor = if (isDarkTheme) Color.White else Color.Black
@@ -109,7 +84,6 @@ fun NoiseControlSettings(
val selectedBackground = if (isDarkTheme) Color(0xBF5C5A5F) else Color(0xFFFFFFFF)
- val noiseControlModeFromService = service.aacpManager.getControlCommandStatus(AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE)
val noiseControlMode = remember { mutableStateOf(NoiseControlMode.OFF) }
@@ -117,10 +91,11 @@ fun NoiseControlSettings(
val d2a = remember { mutableFloatStateOf(0f) }
val d3a = remember { mutableFloatStateOf(0f) }
+ // this function exists solely for the dividers, should get rid of it
fun onModeSelected(mode: NoiseControlMode, received: Boolean = false) {
val previousMode = noiseControlMode.value
- val targetMode = if (!offListeningMode.value && mode == NoiseControlMode.OFF) {
+ val targetMode = if (!showOffListeningMode && mode == NoiseControlMode.OFF) {
NoiseControlMode.TRANSPARENCY
} else {
mode
@@ -128,9 +103,8 @@ fun NoiseControlSettings(
noiseControlMode.value = targetMode
- if (!received && targetMode != previousMode) {
- service.aacpManager.sendControlCommand(identifier = AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, value = targetMode.ordinal + 1)
- }
+ if (!received && targetMode != previousMode) onNoiseControlModeChanged(targetMode.ordinal + 1)
+
when (noiseControlMode.value) {
NoiseControlMode.NOISE_CANCELLATION -> {
@@ -157,42 +131,11 @@ fun NoiseControlSettings(
}
- if (noiseControlModeFromService != null) {
- val value = noiseControlModeFromService.value
- if (value.isNotEmpty()) {
- val index = (value[0].toInt() - 1).coerceIn(0, NoiseControlMode.entries.size - 1)
- noiseControlMode.value = NoiseControlMode.entries[index]
+ val index = (noiseControlModeValue - 1).coerceIn(0, NoiseControlMode.entries.size - 1)
+ noiseControlMode.value = NoiseControlMode.entries[index]
- onModeSelected(noiseControlMode.value, received = true)
- }
- }
+ onModeSelected(noiseControlMode.value, received = true)
- val noiseControlReceiver = remember {
- object : BroadcastReceiver() {
- override fun onReceive(context: Context, intent: Intent) {
- if (intent.action == AirPodsNotifications.ANC_DATA) {
- noiseControlMode.value = NoiseControlMode.entries.toTypedArray()[intent.getIntExtra("data", 3) - 1]
- onModeSelected(noiseControlMode.value, true)
- } else if (intent.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
- try {
- context.unregisterReceiver(this)
- } catch (e: IllegalArgumentException) {
- e.printStackTrace()
- }
- }
- }
- }
- }
-
- val noiseControlIntentFilter = IntentFilter().apply {
- addAction(AirPodsNotifications.ANC_DATA)
- addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
- }
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter, Context.RECEIVER_EXPORTED)
- } else {
- context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter)
- }
Box(
modifier = Modifier
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
@@ -207,14 +150,14 @@ fun NoiseControlSettings(
)
)
}
- @Suppress("COMPOSE_APPLIER_CALL_MISMATCH")
+
BoxWithConstraints(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
) {
val density = LocalDensity.current
- val buttonCount = if (offListeningMode.value) 4 else 3
+ val buttonCount = if (showOffListeningMode) 4 else 3
val buttonWidth = maxWidth / buttonCount
val isDragging = remember { mutableStateOf(false) }
@@ -222,10 +165,10 @@ fun NoiseControlSettings(
mutableFloatStateOf(
with(density) {
when(noiseControlMode.value) {
- NoiseControlMode.OFF -> if (offListeningMode.value) 0f else buttonWidth.toPx()
- NoiseControlMode.TRANSPARENCY -> if (offListeningMode.value) buttonWidth.toPx() else 0f
- NoiseControlMode.ADAPTIVE -> if (offListeningMode.value) (buttonWidth * 2).toPx() else buttonWidth.toPx()
- NoiseControlMode.NOISE_CANCELLATION -> if (offListeningMode.value) (buttonWidth * 3).toPx() else (buttonWidth * 2).toPx()
+ NoiseControlMode.OFF -> if (showOffListeningMode) 0f else buttonWidth.toPx()
+ NoiseControlMode.TRANSPARENCY -> if (showOffListeningMode) buttonWidth.toPx() else 0f
+ NoiseControlMode.ADAPTIVE -> if (showOffListeningMode) (buttonWidth * 2).toPx() else buttonWidth.toPx()
+ NoiseControlMode.NOISE_CANCELLATION -> if (showOffListeningMode) (buttonWidth * 3).toPx() else (buttonWidth * 2).toPx()
}
}
)
@@ -238,10 +181,10 @@ fun NoiseControlSettings(
)
val targetOffset = buttonWidth * when(noiseControlMode.value) {
- NoiseControlMode.OFF -> if (offListeningMode.value) 0 else 1
- NoiseControlMode.TRANSPARENCY -> if (offListeningMode.value) 1 else 0
- NoiseControlMode.ADAPTIVE -> if (offListeningMode.value) 2 else 1
- NoiseControlMode.NOISE_CANCELLATION -> if (offListeningMode.value) 3 else 2
+ NoiseControlMode.OFF -> if (showOffListeningMode) 0 else 1
+ NoiseControlMode.TRANSPARENCY -> if (showOffListeningMode) 1 else 0
+ NoiseControlMode.ADAPTIVE -> if (showOffListeningMode) 2 else 1
+ NoiseControlMode.NOISE_CANCELLATION -> if (showOffListeningMode) 3 else 2
}
val animatedOffset by animateFloatAsState(
@@ -264,7 +207,7 @@ fun NoiseControlSettings(
Row(
modifier = Modifier.fillMaxWidth()
) {
- if (offListeningMode.value) {
+ if (showOffListeningMode) {
NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
onClick = { onModeSelected(NoiseControlMode.OFF) },
@@ -337,13 +280,12 @@ fun NoiseControlSettings(
val position = dragOffset / with(density) { buttonWidth.toPx() }
val newIndex = position.roundToInt()
val newMode = when(newIndex) {
- 0 -> if (offListeningMode.value) NoiseControlMode.OFF else NoiseControlMode.TRANSPARENCY
- 1 -> if (offListeningMode.value) NoiseControlMode.TRANSPARENCY else NoiseControlMode.ADAPTIVE
- 2 -> if (offListeningMode.value) NoiseControlMode.ADAPTIVE else NoiseControlMode.NOISE_CANCELLATION
+ 0 -> if (showOffListeningMode) NoiseControlMode.OFF else NoiseControlMode.TRANSPARENCY
+ 1 -> if (showOffListeningMode) NoiseControlMode.TRANSPARENCY else NoiseControlMode.ADAPTIVE
+ 2 -> if (showOffListeningMode) NoiseControlMode.ADAPTIVE else NoiseControlMode.NOISE_CANCELLATION
3 -> NoiseControlMode.NOISE_CANCELLATION
else -> noiseControlMode.value // Keep current if index is invalid
}
- // Call onModeSelected which now handles service call but not callback
onModeSelected(newMode)
}
)
@@ -361,7 +303,7 @@ fun NoiseControlSettings(
.fillMaxWidth()
.zIndex(1f)
) {
- if (offListeningMode.value) {
+ if (showOffListeningMode) {
NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
onClick = { onModeSelected(NoiseControlMode.OFF) },
@@ -420,7 +362,7 @@ fun NoiseControlSettings(
.fillMaxWidth()
.padding(top = 4.dp)
) {
- if (offListeningMode.value) {
+ if (showOffListeningMode) {
Text(
text = stringResource(R.string.off),
style = TextStyle(fontSize = 12.sp, color = textColor),
@@ -450,9 +392,3 @@ fun NoiseControlSettings(
}
}
}
-
-@Preview
-@Composable
-fun NoiseControlSettingsPreview() {
- NoiseControlSettings(AirPodsService())
-}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/PressAndHoldSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/PressAndHoldSettings.kt
index 1eddfaf..1861298 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/PressAndHoldSettings.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/PressAndHoldSettings.kt
@@ -18,15 +18,11 @@
package me.kavishdevar.librepods.composables
-import android.content.Context
-import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Spacer
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.material3.HorizontalDivider
@@ -35,13 +31,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.platform.LocalContext
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
@@ -49,24 +43,22 @@ import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.StemAction
@Composable
-fun PressAndHoldSettings(navController: NavController) {
+fun PressAndHoldSettings(
+ navController: NavController,
+ leftAction: StemAction,
+ rightAction: StemAction
+) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val dividerColor = Color(0x40888888)
- val context = LocalContext.current
- val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
-
- val leftAction = sharedPreferences.getString("left_long_press_action", StemAction.CYCLE_NOISE_CONTROL_MODES.name)
- val rightAction = sharedPreferences.getString("right_long_press_action", StemAction.CYCLE_NOISE_CONTROL_MODES.name)
-
- val leftActionText = when (StemAction.valueOf(leftAction ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) {
+ val leftActionText = when (leftAction) {
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
else -> "INVALID!!"
}
- val rightActionText = when (StemAction.valueOf(rightAction ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) {
+ val rightActionText = when (rightAction) {
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
else -> "INVALID!!"
@@ -114,9 +106,3 @@ fun PressAndHoldSettings(navController: NavController) {
)
}
}
-
-@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
-@Composable
-fun PressAndHoldSettingsPreview() {
- PressAndHoldSettings(navController = NavController(LocalContext.current))
-}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledButton.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledButton.kt
index 93ea96e..771dd24 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledButton.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledButton.kt
@@ -55,7 +55,7 @@ import androidx.compose.ui.util.lerp
import com.kyant.backdrop.Backdrop
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur
-import com.kyant.backdrop.effects.refraction
+import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.effects.vibrancy
import com.kyant.backdrop.highlight.Highlight
import kotlinx.coroutines.launch
@@ -146,7 +146,12 @@ half4 main(float2 coord) {
effects = {
vibrancy()
blur(2f.dp.toPx())
- refraction(12f.dp.toPx(), 24f.dp.toPx())
+ lens(
+ refractionHeight = 12f.dp.toPx(),
+ refractionAmount = 24f.dp.toPx(),
+ depthEffect = true,
+ chromaticAberration = true
+ )
},
layerBlock = {
val width = size.width
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledIconButton.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledIconButton.kt
index 6454ee5..9a12561 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledIconButton.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledIconButton.kt
@@ -63,8 +63,7 @@ import androidx.compose.ui.util.lerp
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.refractionWithDispersion
+import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.highlight.Highlight
import com.kyant.backdrop.shadow.Shadow
import kotlinx.coroutines.launch
@@ -78,13 +77,13 @@ import kotlin.math.tanh
@Composable
fun StyledIconButton(
- onClick: () -> Unit,
+ modifier: Modifier = Modifier,
icon: String,
- darkMode: Boolean,
tint: Color = Color.Unspecified,
backdrop: LayerBackdrop = rememberLayerBackdrop(),
- modifier: Modifier = Modifier,
+ onClick: () -> Unit
) {
+ val darkMode = isSystemInDarkTheme()
val animationScope = rememberCoroutineScope()
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
@@ -218,8 +217,12 @@ half4 main(float2 coord) {
}
},
effects = {
- refractionWithDispersion(6f.dp.toPx(), size.height / 2f)
- // blur(24f, TileMode.Decal)
+ lens(
+ refractionHeight = 6f.dp.toPx(),
+ refractionAmount = size.height / 2f,
+ depthEffect = true,
+ chromaticAberration = true
+ )
},
)
.pointerInput(animationScope) {
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledScaffold.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledScaffold.kt
index 21fdc19..bca74ab 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledScaffold.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledScaffold.kt
@@ -61,7 +61,6 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.rememberHazeState
import me.kavishdevar.librepods.R
-@ExperimentalHazeMaterialsApi
@Composable
fun StyledScaffold(
title: String,
@@ -133,7 +132,6 @@ fun StyledScaffold(
}
-@ExperimentalHazeMaterialsApi
@Composable
fun StyledScaffold(
title: String,
@@ -150,7 +148,6 @@ fun StyledScaffold(
}
}
-@ExperimentalHazeMaterialsApi
@Composable
fun StyledScaffold(
title: String,
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSelectList.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSelectList.kt
index c91fa1b..90af965 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSelectList.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSelectList.kt
@@ -48,7 +48,6 @@ import androidx.compose.ui.res.painterResource
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
@@ -59,19 +58,10 @@ data class SelectItem(
val iconRes: Int? = null,
val selected: Boolean,
val onClick: () -> Unit,
+ val visible: Boolean = true,
val enabled: Boolean = true
)
-data class SelectItem2(
- val name: String,
- val description: String? = null,
- val iconRes: Int? = null,
- val selected: () -> Boolean,
- val onClick: () -> Unit,
- val enabled: Boolean = true
-)
-
-
@Composable
fun StyledSelectList(
items: List,
@@ -87,18 +77,19 @@ fun StyledSelectList(
.background(backgroundColor, RoundedCornerShape(28.dp)),
horizontalAlignment = Alignment.CenterHorizontally
) {
- val visibleItems = items.filter { it.enabled }
+ val visibleItems = items.filter { it.visible }
visibleItems.forEachIndexed { index, item ->
val isFirst = index == 0
val isLast = index == visibleItems.size - 1
val hasIcon = item.iconRes != null
val shape = when {
+ isFirst && isLast -> RoundedCornerShape(28.dp)
isFirst -> RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)
isLast -> RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)
else -> RoundedCornerShape(0.dp)
}
- var itemBackgroundColor by remember { mutableStateOf(backgroundColor) }
+ var itemBackgroundColor by remember { mutableStateOf(if (item.enabled) backgroundColor else if (isDarkTheme) Color(0x40050505) else Color(0x40D9D9D9)) }
val animatedBackgroundColor by animateColorAsState(targetValue = itemBackgroundColor, animationSpec = tween(durationMillis = 500))
Row(
@@ -108,10 +99,13 @@ fun StyledSelectList(
.pointerInput(Unit) {
detectTapGestures(
onPress = {
- itemBackgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
- tryAwaitRelease()
- itemBackgroundColor = backgroundColor
- item.onClick()
+ if (item.enabled) {
+ itemBackgroundColor =
+ if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
+ tryAwaitRelease()
+ itemBackgroundColor = backgroundColor
+ item.onClick()
+ }
}
)
}
@@ -121,7 +115,7 @@ fun StyledSelectList(
) {
if (hasIcon) {
Icon(
- painter = painterResource(item.iconRes!!),
+ painter = painterResource(item.iconRes),
contentDescription = "Icon",
tint = Color(0xFF007AFF),
modifier = Modifier
@@ -181,4 +175,4 @@ fun StyledSelectList(
}
}
}
-}
\ No newline at end of file
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSlider.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSlider.kt
index 495b599..78829e2 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSlider.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSlider.kt
@@ -18,6 +18,7 @@
package me.kavishdevar.librepods.composables
+import android.annotation.SuppressLint
import android.content.res.Configuration
import android.util.Log
import androidx.compose.animation.core.Animatable
@@ -43,7 +44,6 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.MutableFloatState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
@@ -81,7 +81,7 @@ import com.kyant.backdrop.backdrops.rememberCombinedBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur
-import com.kyant.backdrop.effects.refractionWithDispersion
+import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.highlight.Highlight
import com.kyant.backdrop.shadow.InnerShadow
import com.kyant.backdrop.shadow.Shadow
@@ -203,10 +203,11 @@ class MomentumAnimation(
}
}
+@SuppressLint("UnrememberedMutableState")
@Composable
fun StyledSlider(
label: String? = null,
- mutableFloatState: MutableFloatState,
+ value: Float,
onValueChange: (Float) -> Unit,
valueRange: ClosedFloatingPointRange,
backdrop: Backdrop = rememberLayerBackdrop(),
@@ -217,23 +218,26 @@ fun StyledSlider(
startLabel: String? = null,
endLabel: String? = null,
independent: Boolean = false,
- description: String? = null
+ description: String? = null,
+ enabled: Boolean = true
) {
val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val isLightTheme = !isSystemInDarkTheme()
- val accentColor =
- if (isLightTheme) Color(0xFF0088FF)
- else Color(0xFF0091FF)
val trackColor =
if (isLightTheme) Color(0xFF787878).copy(0.2f)
else Color(0xFF787880).copy(0.36f)
+ val accentColor =
+ if (enabled) {
+ if (isLightTheme) Color(0xFF0088FF)
+ else Color(0xFF0091FF)
+ } else {
+ trackColor
+ }
val labelTextColor = if (isLightTheme) Color.Black else Color.White
- val fraction by remember {
- derivedStateOf {
- ((mutableFloatState.floatValue - valueRange.start) / (valueRange.endInclusive - valueRange.start))
- .fastCoerceIn(0f, 1f)
- }
+ val fraction by derivedStateOf {
+ ((value - valueRange.start) / (valueRange.endInclusive - valueRange.start))
+ .fastCoerceIn(0f, 1f)
}
val sliderBackdrop = rememberLayerBackdrop()
@@ -427,71 +431,87 @@ fun StyledSlider(
)
translationY = if (startLabel != null || endLabel != null) trackPositionState.floatValue + with(density) { 26.dp.toPx() } + size.height / 2f else trackPositionState.floatValue + with(density) { 8.dp.toPx() }
}
- .draggable(
- rememberDraggableState { delta ->
- val trackWidth = trackWidthState.floatValue
- if (trackWidth > 0f) {
- val targetFraction = fraction + delta / trackWidth
- val targetValue =
- lerp(valueRange.start, valueRange.endInclusive, targetFraction)
- .fastCoerceIn(valueRange.start, valueRange.endInclusive)
- val snappedValue = if (snapPoints.isNotEmpty()) snapIfClose(
- targetValue,
- snapPoints,
- snapThreshold
- ) else targetValue
- onValueChange(snappedValue)
- }
- },
- Orientation.Horizontal,
- startDragImmediately = true,
- onDragStarted = {
- // Remove this block as momentumAnimation handles pressing
- },
- onDragStopped = {
- // Remove this block as momentumAnimation handles pressing
- onValueChange((mutableFloatState.floatValue * 100).roundToInt() / 100f)
- }
- )
- .then(momentumAnimation.modifier)
- .drawBackdrop(
- rememberCombinedBackdrop(backdrop, sliderBackdrop),
- { RoundedCornerShape(28.dp) },
- highlight = {
- val progress = momentumAnimation.progress
- Highlight.Ambient.copy(alpha = progress)
- },
- shadow = {
- Shadow(
- radius = 4f.dp,
- color = Color.Black.copy(0.05f)
- )
- },
- innerShadow = {
- val progress = momentumAnimation.progress
- InnerShadow(
- radius = 4f.dp * progress,
- alpha = progress
- )
- },
- layerBlock = {
- scaleX = momentumAnimation.scaleX
- scaleY = momentumAnimation.scaleY
- val velocity = momentumAnimation.velocity / 5000f
- scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.15f, 0.15f)
- scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(-0.15f, 0.15f)
- },
- onDrawSurface = {
- val progress = momentumAnimation.progress
- drawRect(Color.White.copy(alpha = 1f - progress))
- },
- effects = {
- val progress = momentumAnimation.progress
- blur(8f.dp.toPx() * (1f - progress))
- refractionWithDispersion(
- height = 6f.dp.toPx() * progress,
- amount = size.height / 2f * progress
- )
+ .then(
+ if (enabled) {
+ Modifier
+ .draggable(
+ rememberDraggableState { delta ->
+ val trackWidth = trackWidthState.floatValue
+ if (trackWidth > 0f) {
+ val targetFraction = fraction + delta / trackWidth
+ val targetValue =
+ lerp(
+ valueRange.start,
+ valueRange.endInclusive,
+ targetFraction
+ )
+ .fastCoerceIn(
+ valueRange.start,
+ valueRange.endInclusive
+ )
+ val snappedValue = if (snapPoints.isNotEmpty()) snapIfClose(
+ targetValue,
+ snapPoints,
+ snapThreshold
+ ) else targetValue
+ onValueChange(snappedValue)
+ }
+ },
+ Orientation.Horizontal,
+ startDragImmediately = true,
+ onDragStarted = {
+ // Remove this block as momentumAnimation handles pressing
+ },
+ onDragStopped = {
+ // Remove this block as momentumAnimation handles pressing
+ onValueChange((value * 100).roundToInt() / 100f)
+ }
+ )
+ .then(momentumAnimation.modifier)
+ .drawBackdrop(
+ rememberCombinedBackdrop(backdrop, sliderBackdrop),
+ { RoundedCornerShape(28.dp) },
+ highlight = {
+ val progress = momentumAnimation.progress
+ Highlight.Ambient.copy(alpha = progress)
+ },
+ shadow = {
+ Shadow(
+ radius = 4f.dp,
+ color = Color.Black.copy(0.05f)
+ )
+ },
+ innerShadow = {
+ val progress = momentumAnimation.progress
+ InnerShadow(
+ radius = 4f.dp * progress,
+ alpha = progress
+ )
+ },
+ layerBlock = {
+ scaleX = momentumAnimation.scaleX
+ scaleY = momentumAnimation.scaleY
+ val velocity = momentumAnimation.velocity / 5000f
+ scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.15f, 0.15f)
+ scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(-0.15f, 0.15f)
+ },
+ onDrawSurface = {
+ val progress = momentumAnimation.progress
+ drawRect(Color.White.copy(alpha = 1f - progress))
+ },
+ effects = {
+ val progress = momentumAnimation.progress
+ blur(8f.dp.toPx() * (1f - progress))
+ lens(
+ refractionHeight = 6f.dp.toPx() * progress,
+ refractionAmount = size.height / 2f * progress,
+ depthEffect = true,
+ chromaticAberration = true
+ )
+ }
+ )
+ } else {
+ Modifier.background(trackColor, RoundedCornerShape(28.dp))
}
)
.size(40f.dp, 24f.dp)
@@ -566,12 +586,13 @@ fun StyledSliderPreview() {
.padding(16.dp)
.fillMaxSize()
) {
- Box (
- Modifier.align(Alignment.Center)
+ Column (
+ Modifier.align(Alignment.Center),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
)
{
StyledSlider(
- mutableFloatState = a,
+ value = a.floatValue,
onValueChange = {
a.floatValue = it
},
@@ -582,6 +603,19 @@ fun StyledSliderPreview() {
startIcon = "A",
endIcon = "B",
)
+ StyledSlider(
+ value = a.floatValue,
+ onValueChange = {
+ a.floatValue = it
+ },
+ valueRange = 0f..2f,
+ snapPoints = listOf(1f),
+ snapThreshold = 0.1f,
+ independent = true,
+ startIcon = "A",
+ endIcon = "B",
+ enabled = false
+ )
}
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSwitch.kt
index 0799281..7d8450b 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSwitch.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSwitch.kt
@@ -19,7 +19,7 @@
package me.kavishdevar.librepods.composables
import android.content.res.Configuration
-import androidx.compose.animation.Animatable
+import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.spring
@@ -68,7 +68,7 @@ import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberCombinedBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop
-import com.kyant.backdrop.effects.refractionWithDispersion
+import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.highlight.Highlight
import com.kyant.backdrop.shadow.Shadow
import kotlinx.coroutines.coroutineScope
@@ -100,22 +100,18 @@ fun StyledSwitch(
val density = LocalDensity.current
val animationScope = rememberCoroutineScope()
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
- val colorAnimationSpec = tween(200, easing = FastOutSlowInEasing)
val progressAnimation = remember { Animatable(0f) }
val innerShadowLayer = rememberGraphicsLayer().apply {
compositingStrategy = CompositingStrategy.Offscreen
}
- val animatedTrackColor = remember { Animatable(if (checked) onColor else offColor) }
+ val targetColor = if (checked) onColor else offColor
+ val animatedTrackColor by animateColorAsState(targetColor)
val totalDrag = remember { mutableFloatStateOf(0f) }
val tapThreshold = 10f
val isFirstComposition = remember { mutableStateOf(true) }
LaunchedEffect(checked) {
if (!isFirstComposition.value) {
coroutineScope {
- launch {
- val targetColor = if (checked) onColor else offColor
- animatedTrackColor.animateTo(targetColor, colorAnimationSpec)
- }
launch {
val targetFrac = if (checked) 1f else 0f
animatedFraction.animateTo(targetFrac, progressAnimationSpec)
@@ -140,7 +136,7 @@ fun StyledSwitch(
modifier = Modifier
.layerBackdrop(switchBackdrop)
.clip(RoundedCornerShape(trackHeight / 2))
- .background(animatedTrackColor.value)
+ .background(animatedTrackColor)
.width(trackWidth)
.height(trackHeight)
.onSizeChanged { trackWidthPx.floatValue = it.width.toFloat() }
@@ -262,7 +258,12 @@ fun StyledSwitch(
drawRect(Color.White.copy(1f - progress))
},
effects = {
- refractionWithDispersion(6f.dp.toPx(), size.height / 2f)
+ lens(
+ refractionHeight = 6f.dp.toPx(),
+ refractionAmount = size.height / 2f,
+ depthEffect = true,
+ chromaticAberration = true
+ )
}
)
.width(thumbWidth)
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledToggle.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledToggle.kt
index 4b578e7..b7453ae 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledToggle.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledToggle.kt
@@ -20,8 +20,6 @@
package me.kavishdevar.librepods.composables
-import android.content.SharedPreferences
-import android.util.Log
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
@@ -39,18 +37,15 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
@@ -58,11 +53,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-import androidx.core.content.edit
import me.kavishdevar.librepods.R
-import me.kavishdevar.librepods.services.ServiceManager
-import me.kavishdevar.librepods.utils.AACPManager
-import me.kavishdevar.librepods.utils.ATTHandles
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
@@ -70,32 +61,27 @@ fun StyledToggle(
title: String? = null,
label: String,
description: String? = null,
- checkedState: MutableState = remember { mutableStateOf(false) } ,
- sharedPreferenceKey: String? = null,
- sharedPreferences: SharedPreferences? = null,
+ checked: Boolean = false,
independent: Boolean = true,
enabled: Boolean = true,
- onCheckedChange: ((Boolean) -> Unit)? = null,
+ onCheckedChange: (Boolean) -> Unit,
) {
+ val currentChecked by rememberUpdatedState(checked)
+
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
- var checked by checkedState
- var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
- val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
- if (sharedPreferenceKey != null && sharedPreferences != null) {
- checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked)
- }
- fun cb() {
- if (sharedPreferences != null) {
- if (sharedPreferenceKey == null) {
- Log.e("StyledToggle", "SharedPreferenceKey is null but SharedPreferences is provided.")
- return
- }
- sharedPreferences.edit { putBoolean(sharedPreferenceKey, checked) }
- }
- onCheckedChange?.invoke(checked)
+
+ var backgroundColor by remember {
+ mutableStateOf(
+ if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
+ )
}
+ val animatedBackgroundColor by animateColorAsState(
+ targetValue = backgroundColor,
+ animationSpec = tween(durationMillis = 500)
+ )
+
if (independent) {
Column(modifier = Modifier.padding(vertical = 8.dp)) {
if (title != null) {
@@ -106,9 +92,15 @@ fun StyledToggle(
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f)
),
- modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp)
+ modifier = Modifier.padding(
+ start = 16.dp,
+ end = 16.dp,
+ top = 8.dp,
+ bottom = 4.dp
+ )
)
}
+
Box(
modifier = Modifier
.background(animatedBackgroundColor, RoundedCornerShape(28.dp))
@@ -124,8 +116,7 @@ fun StyledToggle(
},
onTap = {
if (enabled) {
- checked = !checked
- cb()
+ onCheckedChange(!currentChecked)
}
}
)
@@ -148,24 +139,29 @@ fun StyledToggle(
color = textColor
)
)
+
StyledSwitch(
checked = checked,
enabled = enabled,
onCheckedChange = {
if (enabled) {
- checked = it
- cb()
+ onCheckedChange(it)
}
}
)
}
}
+
if (description != null) {
Spacer(modifier = Modifier.height(8.dp))
+
Box(
modifier = Modifier
.padding(horizontal = 16.dp)
- .background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
+ .background(
+ if (isDarkTheme) Color(0xFF000000)
+ else Color(0xFFF2F2F7)
+ )
) {
Text(
text = description,
@@ -181,6 +177,7 @@ fun StyledToggle(
}
} else {
val isPressed = remember { mutableStateOf(false) }
+
Row(
modifier = Modifier
.fillMaxWidth()
@@ -203,8 +200,7 @@ fun StyledToggle(
interactionSource = remember { MutableInteractionSource() }
) {
if (enabled) {
- checked = !checked
- cb()
+ onCheckedChange(!currentChecked)
}
},
verticalAlignment = Alignment.CenterVertically
@@ -223,7 +219,9 @@ fun StyledToggle(
color = textColor
)
)
+
Spacer(modifier = Modifier.height(4.dp))
+
if (description != null) {
Text(
text = description,
@@ -235,438 +233,13 @@ fun StyledToggle(
)
}
}
+
StyledSwitch(
checked = checked,
enabled = enabled,
onCheckedChange = {
if (enabled) {
- checked = it
- cb()
- }
- }
- )
- }
- }
-}
-
-@Composable
-fun StyledToggle(
- title: String? = null,
- label: String,
- description: String? = null,
- controlCommandIdentifier: AACPManager.Companion.ControlCommandIdentifiers,
- independent: Boolean = true,
- enabled: Boolean = true,
- sharedPreferenceKey: String? = null,
- sharedPreferences: SharedPreferences? = null,
- onCheckedChange: ((Boolean) -> Unit)? = null,
-) {
- val service = ServiceManager.getService() ?: return
- val isDarkTheme = isSystemInDarkTheme()
- val textColor = if (isDarkTheme) Color.White else Color.Black
- val checkedValue = service.aacpManager.controlCommandStatusList.find {
- it.identifier == controlCommandIdentifier
- }?.value?.takeIf { it.isNotEmpty() }?.get(0)
- var checked by remember { mutableStateOf(checkedValue == 1.toByte()) }
- var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
- val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
- if (sharedPreferenceKey != null && sharedPreferences != null) {
- checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked)
- }
- fun cb() {
- service.aacpManager.sendControlCommand(identifier = controlCommandIdentifier.value, value = checked)
- if (sharedPreferences != null) {
- if (sharedPreferenceKey == null) {
- Log.e("StyledToggle", "SharedPreferenceKey is null but SharedPreferences is provided.")
- return
- }
- sharedPreferences.edit { putBoolean(sharedPreferenceKey, checked) }
- }
- onCheckedChange?.invoke(checked)
- }
-
- val listener = remember {
- object : AACPManager.ControlCommandListener {
- override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
- if (controlCommand.identifier == controlCommandIdentifier.value) {
- Log.d("StyledToggle", "Received control command for $label: ${controlCommand.value}")
- checked = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0) == 1.toByte()
- }
- }
- }
- }
- LaunchedEffect(Unit) {
- service.aacpManager.registerControlCommandListener(controlCommandIdentifier, listener)
- }
- DisposableEffect(Unit) {
- onDispose {
- service.aacpManager.unregisterControlCommandListener(controlCommandIdentifier, listener)
- }
- }
-
- if (independent) {
- Column(modifier = Modifier.padding(vertical = 8.dp)) {
- if (title != null) {
- Text(
- text = title,
- style = TextStyle(
- fontSize = 14.sp,
- fontWeight = FontWeight.Bold,
- color = textColor.copy(alpha = 0.6f)
- ),
- modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp)
- )
- }
- Box(
- modifier = Modifier
- .background(animatedBackgroundColor, RoundedCornerShape(28.dp))
- .padding(4.dp)
- .pointerInput(Unit) {
- detectTapGestures(
- onPress = {
- backgroundColor =
- if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
- tryAwaitRelease()
- backgroundColor =
- if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
- },
- onTap = {
- if (enabled) {
- checked = !checked
- cb()
- }
- }
- )
- }
- ) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .height(55.dp)
- .padding(horizontal = 12.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Text(
- text = label,
- modifier = Modifier.weight(1f),
- style = TextStyle(
- fontSize = 16.sp,
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- fontWeight = FontWeight.Normal,
- color = textColor
- )
- )
- StyledSwitch(
- checked = checked,
- enabled = enabled,
- onCheckedChange = {
- if (enabled) {
- checked = it
- cb()
- }
- }
- )
- }
- }
- if (description != null) {
- Spacer(modifier = Modifier.height(8.dp))
- Box(
- modifier = Modifier
- .padding(horizontal = 16.dp)
- .background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
- ) {
- Text(
- text = description,
- style = TextStyle(
- fontSize = 12.sp,
- fontWeight = FontWeight.Light,
- color = textColor.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
- )
- }
- }
- }
- } else {
- val isPressed = remember { mutableStateOf(false) }
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .background(
- shape = RoundedCornerShape(28.dp),
- color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
- )
- .padding(16.dp)
- .pointerInput(Unit) {
- detectTapGestures(
- onPress = {
- isPressed.value = true
- tryAwaitRelease()
- isPressed.value = false
- }
- )
- }
- .clickable(
- indication = null,
- interactionSource = remember { MutableInteractionSource() }
- ) {
- if (enabled) {
- checked = !checked
- cb()
- }
- },
- verticalAlignment = Alignment.CenterVertically
- ) {
- Column(
- modifier = Modifier
- .weight(1f)
- .padding(end = 4.dp)
- ) {
- Text(
- text = label,
- style = TextStyle(
- fontSize = 16.sp,
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- fontWeight = FontWeight.Normal,
- color = textColor
- )
- )
- Spacer(modifier = Modifier.height(4.dp))
- if (description != null) {
- Text(
- text = description,
- style = TextStyle(
- fontSize = 12.sp,
- color = textColor.copy(0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- )
- )
- }
- }
- StyledSwitch(
- checked = checked,
- enabled = enabled,
- onCheckedChange = {
- if (enabled) {
- checked = it
- cb()
- }
- }
- )
- }
- }
-}
-
-@Composable
-fun StyledToggle(
- title: String? = null,
- label: String,
- description: String? = null,
- attHandle: ATTHandles,
- independent: Boolean = true,
- enabled: Boolean = true,
- sharedPreferenceKey: String? = null,
- sharedPreferences: SharedPreferences? = null,
- onCheckedChange: ((Boolean) -> Unit)? = null,
-) {
- val attManager = ServiceManager.getService()?.attManager ?: return
- val isDarkTheme = isSystemInDarkTheme()
- val textColor = if (isDarkTheme) Color.White else Color.Black
- val checkedValue = try {
- attManager.read(attHandle).getOrNull(0)?.toInt()
- } catch (e: Exception) {
- Log.w("StyledToggle", "Error reading initial value for $label: ${e.message}")
- null
- } ?: 0
- var checked by remember { mutableStateOf(checkedValue !=0) }
- var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
- val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
-
- attManager.enableNotifications(attHandle)
-
- if (sharedPreferenceKey != null && sharedPreferences != null) {
- checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked)
- }
-
- fun cb() {
- if (sharedPreferences != null) {
- if (sharedPreferenceKey == null) {
- Log.e("StyledToggle", "SharedPreferenceKey is null but SharedPreferences is provided.")
- return
- }
- sharedPreferences.edit { putBoolean(sharedPreferenceKey, checked) }
- }
- onCheckedChange?.invoke(checked)
- }
-
- LaunchedEffect(checked) {
- if (attManager.socket?.isConnected != true) return@LaunchedEffect
- attManager.write(attHandle, if (checked) byteArrayOf(1) else byteArrayOf(0))
- }
-
- val listener = remember {
- object : (ByteArray) -> Unit {
- override fun invoke(value: ByteArray) {
- if (value.isNotEmpty()) {
- checked = value[0].toInt() != 0
- Log.d("StyledToggle", "Updated from notification for $label: enabled=$checked")
- } else {
- Log.w("StyledToggle", "Empty value in notification for $label")
- }
- }
- }
- }
-
- LaunchedEffect(Unit) {
- attManager.registerListener(attHandle, listener)
- }
-
- DisposableEffect(Unit) {
- onDispose {
- attManager.unregisterListener(attHandle, listener)
- }
- }
-
- if (independent) {
- Column(modifier = Modifier.padding(vertical = 8.dp)) {
- if (title != null) {
- Text(
- text = title,
- style = TextStyle(
- fontSize = 14.sp,
- fontWeight = FontWeight.Bold,
- color = textColor.copy(alpha = 0.6f)
- ),
- modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp)
- )
- }
- Box(
- modifier = Modifier
- .background(animatedBackgroundColor, RoundedCornerShape(28.dp))
- .padding(4.dp)
- .pointerInput(Unit) {
- detectTapGestures(
- onPress = {
- backgroundColor =
- if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
- tryAwaitRelease()
- backgroundColor =
- if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
- },
- onTap = {
- if (enabled) {
- checked = !checked
- cb()
- }
- }
- )
- }
- ) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .height(55.dp)
- .padding(horizontal = 12.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Text(
- text = label,
- modifier = Modifier.weight(1f),
- style = TextStyle(
- fontSize = 16.sp,
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- fontWeight = FontWeight.Normal,
- color = textColor
- )
- )
- StyledSwitch(
- checked = checked,
- enabled = enabled,
- onCheckedChange = {
- if (enabled) {
- checked = it
- cb()
- }
- }
- )
- }
- }
- if (description != null) {
- Spacer(modifier = Modifier.height(8.dp))
- Box(
- modifier = Modifier
- .padding(horizontal = 16.dp)
- .background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
- ) {
- Text(
- text = description,
- style = TextStyle(
- fontSize = 12.sp,
- fontWeight = FontWeight.Light,
- color = textColor.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
- )
- }
- }
- }
- } else {
- val isPressed = remember { mutableStateOf(false) }
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .background(
- shape = RoundedCornerShape(28.dp),
- color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
- )
- .padding(16.dp)
- .pointerInput(Unit) {
- detectTapGestures(
- onPress = {
- isPressed.value = true
- tryAwaitRelease()
- isPressed.value = false
- }
- )
- }
- .clickable(
- indication = null,
- interactionSource = remember { MutableInteractionSource() }
- ) {
- if (enabled) {
- checked = !checked
- cb()
- }
- },
- verticalAlignment = Alignment.CenterVertically
- ) {
- Column(
- modifier = Modifier
- .weight(1f)
- .padding(end = 4.dp)
- ) {
- Text(
- text = label,
- fontSize = 16.sp,
- color = textColor
- )
- Spacer(modifier = Modifier.height(4.dp))
- if (description != null) {
- Text(
- text = description,
- fontSize = 12.sp,
- color = textColor.copy(0.6f),
- lineHeight = 14.sp,
- )
- }
- }
- StyledSwitch(
- checked = checked,
- enabled = enabled,
- onCheckedChange = {
- if (enabled) {
- checked = it
- cb()
+ onCheckedChange(it)
}
}
)
@@ -677,11 +250,11 @@ fun StyledToggle(
@Preview
@Composable
fun StyledTogglePreview() {
- val context = LocalContext.current
- val sharedPrefs = context.getSharedPreferences("preview", 0)
+ val checked = remember { mutableStateOf(false) }
StyledToggle(
label = "Example Toggle",
description = "This is an example description for the styled toggle.",
- sharedPreferences = sharedPrefs
+ checked = checked.value,
+ onCheckedChange = { checked.value = !checked.value }
)
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/constants/Packets.kt b/android/app/src/main/java/me/kavishdevar/librepods/constants/Packets.kt
index b4487aa..1c179d0 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/constants/Packets.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/constants/Packets.kt
@@ -80,6 +80,8 @@ class AirPodsNotifications {
const val AIRPODS_DISCONNECTED = "me.kavishdevar.librepods.AIRPODS_DISCONNECTED"
const val AIRPODS_CONNECTION_DETECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTION_DETECTED"
const val DISCONNECT_RECEIVERS = "me.kavishdevar.librepods.DISCONNECT_RECEIVERS"
+ const val EQ_DATA = "me.kavishdevar.librepods.EQ_DATA"
+ const val AIRPODS_INFORMATION_UPDATED = "me.kavishdevar.librepods.AIRPODS_INFORMATION_UPDATED"
}
class EarDetection {
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/ControlCommandRepository.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/ControlCommandRepository.kt
new file mode 100644
index 0000000..3a0727f
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/data/ControlCommandRepository.kt
@@ -0,0 +1,63 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+package me.kavishdevar.librepods.data
+
+import me.kavishdevar.librepods.utils.AACPManager
+
+class ControlCommandRepository(
+ private val aacpManager: AACPManager
+) {
+ fun getValue(
+ identifier: AACPManager.Companion.ControlCommandIdentifiers
+ ): ByteArray? {
+ return aacpManager.controlCommandStatusList
+ .find { it.identifier == identifier }
+ ?.value
+ }
+
+ fun setValue(
+ id: AACPManager.Companion.ControlCommandIdentifiers,
+ value: ByteArray
+ ) {
+ aacpManager.sendControlCommand(id.value, value)
+ }
+
+
+ fun observe(
+ identifier: AACPManager.Companion.ControlCommandIdentifiers,
+ onChange: (ByteArray) -> Unit
+ ): AACPManager.ControlCommandListener {
+
+ val listener = object : AACPManager.ControlCommandListener {
+ override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
+ onChange(controlCommand.value)
+ }
+ }
+
+ aacpManager.registerControlCommandListener(identifier, listener)
+ return listener
+ }
+
+ fun remove(
+ identifier: AACPManager.Companion.ControlCommandIdentifiers,
+ listener: AACPManager.ControlCommandListener
+ ) {
+ aacpManager.unregisterControlCommandListener(identifier, listener)
+ }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt
index 8ded7d6..2bdccdf 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt
@@ -18,8 +18,8 @@
package me.kavishdevar.librepods.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,10 +39,8 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -69,74 +67,35 @@ 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.BuildConfig
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.composables.StyledDropdown
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider
import me.kavishdevar.librepods.composables.StyledToggle
-import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.Capability
-// import me.kavishdevar.librepods.utils.RadareOffsetFinder
+import me.kavishdevar.librepods.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi
-private var phoneMediaDebounceJob: Job? = null
-private var toneVolumeDebounceJob: Job? = null
-private const val TAG = "AccessibilitySettings"
+//private var phoneMediaDebounceJob: Job? = null
+//private var toneVolumeDebounceJob: Job? = null
+//private const val TAG = "AccessibilitySettings"
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
-fun AccessibilitySettingsScreen(navController: NavController) {
+fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavController) {
+ val state by viewModel.uiState.collectAsState()
+
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
- val aacpManager = remember { ServiceManager.getService()?.aacpManager }
- val isSdpOffsetAvailable = remember { mutableStateOf(false) } // always available rn, for testing without radare
-// remember { mutableStateOf(RadareOffsetFinder.isSdpOffsetAvailable()) }
- 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)
+ 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 capabilities = remember { ServiceManager.getService()?.airpodsInstance?.model?.capabilities ?: emptySet() }
-
- val hearingAidEnabled = remember { mutableStateOf(
- aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }?.value?.getOrNull(1) == 0x01.toByte() &&
- aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }?.value?.getOrNull(0) == 0x01.toByte()
- ) }
-
- val hearingAidListener = remember {
- object : AACPManager.ControlCommandListener {
- override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
- if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value ||
- controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value) {
- val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
- val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
- hearingAidEnabled.value = (aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())
- }
- }
- }
- }
-
- LaunchedEffect(Unit) {
- aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
- aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
- }
-
- DisposableEffect(Unit) {
- onDispose {
- aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
- aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
- }
- }
val backdrop = rememberLayerBackdrop()
@@ -153,170 +112,73 @@ fun AccessibilitySettingsScreen(navController: NavController) {
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(spacerHeight))
- val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
- val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
- val phoneEQEnabled = remember { mutableStateOf(false) }
- val mediaEQEnabled = remember { mutableStateOf(false) }
+// val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
+// val phoneEQEnabled = remember { mutableStateOf(false) }
+// val mediaEQEnabled = remember { mutableStateOf(false) }
val pressSpeedOptions = mapOf(
0.toByte() to stringResource(R.string.default_option),
1.toByte() to stringResource(R.string.slower),
2.toByte() to stringResource(R.string.slowest)
)
- val selectedPressSpeedValue =
- aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL }?.value?.takeIf { it.isNotEmpty() }
- ?.get(0)
+
+ val selectedPressSpeedValue = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL]?.getOrNull(0)
var selectedPressSpeed by remember {
mutableStateOf(
pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0]
)
}
- val selectedPressSpeedListener = object : AACPManager.ControlCommandListener {
- override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
- if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL.value) {
- val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
- selectedPressSpeed = pressSpeedOptions[newValue] ?: pressSpeedOptions[0]
- }
- }
- }
- LaunchedEffect(Unit) {
- aacpManager?.registerControlCommandListener(
- AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
- selectedPressSpeedListener
- )
- }
- DisposableEffect(Unit) {
- onDispose {
- aacpManager?.unregisterControlCommandListener(
- AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
- selectedPressSpeedListener
- )
- }
- }
val pressAndHoldDurationOptions = mapOf(
0.toByte() to stringResource(R.string.default_option),
1.toByte() to stringResource(R.string.slower),
2.toByte() to stringResource(R.string.slowest)
)
- val selectedPressAndHoldDurationValue =
- aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL }?.value?.takeIf { it.isNotEmpty() }
- ?.get(0)
+
+ val selectedPressAndHoldDurationValue = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL]?.getOrNull(0)
var selectedPressAndHoldDuration by remember {
mutableStateOf(
pressAndHoldDurationOptions[selectedPressAndHoldDurationValue]
?: pressAndHoldDurationOptions[0]
)
}
- val selectedPressAndHoldDurationListener = object : AACPManager.ControlCommandListener {
- override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
- if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL.value) {
- val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
- selectedPressAndHoldDuration =
- pressAndHoldDurationOptions[newValue] ?: pressAndHoldDurationOptions[0]
- }
- }
- }
- LaunchedEffect(Unit) {
- aacpManager?.registerControlCommandListener(
- AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
- selectedPressAndHoldDurationListener
- )
- }
- DisposableEffect(Unit) {
- onDispose {
- aacpManager?.unregisterControlCommandListener(
- AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
- selectedPressAndHoldDurationListener
- )
- }
- }
val volumeSwipeSpeedOptions = mapOf(
1.toByte() to stringResource(R.string.default_option),
2.toByte() to stringResource(R.string.longer),
3.toByte() to stringResource(R.string.longest)
)
- val selectedVolumeSwipeSpeedValue =
- aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL }?.value?.takeIf { it.isNotEmpty() }
- ?.get(0)
+ val selectedVolumeSwipeSpeedValue = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL]?.getOrNull(0)
var selectedVolumeSwipeSpeed by remember {
mutableStateOf(
volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue]
?: volumeSwipeSpeedOptions[1]
)
}
- val selectedVolumeSwipeSpeedListener = object : AACPManager.ControlCommandListener {
- override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
- if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL.value) {
- val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
- selectedVolumeSwipeSpeed =
- volumeSwipeSpeedOptions[newValue] ?: volumeSwipeSpeedOptions[1]
- }
- }
- }
- LaunchedEffect(Unit) {
- aacpManager?.registerControlCommandListener(
- AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
- selectedVolumeSwipeSpeedListener
- )
- }
- DisposableEffect(Unit) {
- onDispose {
- aacpManager?.unregisterControlCommandListener(
- AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
- selectedVolumeSwipeSpeedListener
- )
- }
- }
- 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}")
- }
- }
- }
- val toneVolumeValue = remember { mutableFloatStateOf(
- aacpManager?.controlCommandStatusList?.find {
- it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME
- }?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toFloat() ?: 75f
- ) }
- LaunchedEffect(toneVolumeValue.floatValue) {
- toneVolumeDebounceJob?.cancel()
- toneVolumeDebounceJob = CoroutineScope(Dispatchers.IO).launch {
- delay(150)
- val manager = ServiceManager.getService()?.aacpManager
- if (manager == null) {
- Log.w(TAG, "Cannot write tone volume: AACPManager not available")
- return@launch
- }
- try {
- manager.sendControlCommand(
- identifier = AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME.value,
- value = byteArrayOf(toneVolumeValue.floatValue.toInt().toByte(), 0x50.toByte())
- )
- } catch (e: Exception) {
- Log.w(TAG, "Error sending tone volume: ${e.message}")
- }
- }
- }
+// 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}")
+// }
+// }
+// }
DropdownMenuComponent(
label = stringResource(R.string.press_speed),
@@ -325,8 +187,8 @@ fun AccessibilitySettingsScreen(navController: NavController) {
selectedOption = selectedPressSpeed?: stringResource(R.string.default_option),
onOptionSelected = { newValue ->
selectedPressSpeed = newValue
- aacpManager?.sendControlCommand(
- identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL.value,
+ viewModel.setControlCommandByte(
+ identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
value = pressSpeedOptions.filterValues { it == newValue }.keys.firstOrNull()
?: 0.toByte()
)
@@ -343,8 +205,8 @@ fun AccessibilitySettingsScreen(navController: NavController) {
selectedOption = selectedPressAndHoldDuration?: stringResource(R.string.default_option),
onOptionSelected = { newValue ->
selectedPressAndHoldDuration = newValue
- aacpManager?.sendControlCommand(
- identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL.value,
+ viewModel.setControlCommandByte(
+ identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
value = pressAndHoldDurationOptions.filterValues { it == newValue }.keys.firstOrNull()
?: 0.toByte()
)
@@ -358,19 +220,21 @@ fun AccessibilitySettingsScreen(navController: NavController) {
title = stringResource(R.string.noise_control),
label = stringResource(R.string.noise_cancellation_single_airpod),
description = stringResource(R.string.noise_cancellation_single_airpod_description),
- controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE,
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) }
)
- if (capabilities.contains(Capability.LOUD_SOUND_REDUCTION)) {
+ if (state.capabilities.contains(Capability.LOUD_SOUND_REDUCTION) && BuildConfig.FLAVOR == "xposed") {
StyledToggle(
label = stringResource(R.string.loud_sound_reduction),
description = stringResource(R.string.loud_sound_reduction_description),
- attHandle = ATTHandles.LOUD_SOUND_REDUCTION
+ checked = viewModel.getATTCharacteristicValue(ATTHandles.LOUD_SOUND_REDUCTION)?.get(0) == 1.toByte(),
+ onCheckedChange = { viewModel.setATTCharacteristicValue(ATTHandles.LOUD_SOUND_REDUCTION, if (it) byteArrayOf(0x01) else byteArrayOf(0x00)) }
)
}
- if (!hearingAidEnabled.value&& isSdpOffsetAvailable.value) {
+ if (!hearingAidEnabled && BuildConfig.FLAVOR == "xposed") {
NavigationButton(
to = "transparency_customization",
name = stringResource(R.string.customize_transparency_mode),
@@ -378,12 +242,13 @@ fun AccessibilitySettingsScreen(navController: NavController) {
)
}
+ 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),
- mutableFloatState = toneVolumeValue,
+ value = toneVolumeValue,
onValueChange = {
- toneVolumeValue.floatValue = it
+ viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME, byteArrayOf(it.toInt().toByte(), 0x50))
},
valueRange = 0f..100f,
snapPoints = listOf(75f),
@@ -392,11 +257,13 @@ fun AccessibilitySettingsScreen(navController: NavController) {
independent = true
)
- if (capabilities.contains(Capability.SWIPE_FOR_VOLUME)) {
+ if (state.capabilities.contains(Capability.SWIPE_FOR_VOLUME)) {
+ val volumeSwipeEnabled = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE]?.getOrNull(0)?.toInt() == 0x01
StyledToggle(
label = stringResource(R.string.volume_control),
description = stringResource(R.string.volume_control_description),
- controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE,
+ checked = volumeSwipeEnabled,
+ onCheckedChange = { viewModel.setControlCommandBoolean(AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE, it) }
)
DropdownMenuComponent(
@@ -406,8 +273,8 @@ fun AccessibilitySettingsScreen(navController: NavController) {
selectedOption = selectedVolumeSwipeSpeed?: stringResource(R.string.default_option),
onOptionSelected = { newValue ->
selectedVolumeSwipeSpeed = newValue
- aacpManager?.sendControlCommand(
- identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL.value,
+ viewModel.setControlCommandByte(
+ identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull()
?: 1.toByte()
)
@@ -418,7 +285,7 @@ fun AccessibilitySettingsScreen(navController: NavController) {
)
}
- if (!hearingAidEnabled.value&& isSdpOffsetAvailable.value) {
+// if (!hearingAidEnabled.value&& BuildConfig.FLAVOR == "xposed") {
// Text(
// text = stringResource(R.string.apply_eq_to),
// style = TextStyle(
@@ -640,7 +507,7 @@ fun AccessibilitySettingsScreen(navController: NavController) {
// }
// }
// }
- }
+// }
}
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AdaptiveStrengthScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/AdaptiveStrengthScreen.kt
index 151be9c..677f1b6 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/AdaptiveStrengthScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/AdaptiveStrengthScreen.kt
@@ -18,84 +18,37 @@
package me.kavishdevar.librepods.screens
-import android.annotation.SuppressLint
-import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
-import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
-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.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider
-import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
-import kotlin.io.encoding.ExperimentalEncodingApi
+import me.kavishdevar.librepods.viewmodel.AirPodsViewModel
-private var debounceJob: Job? = null
-
-@SuppressLint("DefaultLocale")
-@ExperimentalHazeMaterialsApi
-@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
-fun AdaptiveStrengthScreen(navController: NavController) {
- val isDarkTheme = isSystemInDarkTheme()
-
- val sliderValue = remember { mutableFloatStateOf(0f) }
- val service = ServiceManager.getService()!!
-
- LaunchedEffect(sliderValue) {
- val sliderValueFromAACP = service.aacpManager.controlCommandStatusList.find {
- it.identifier == AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH
- }?.value?.takeIf { it.isNotEmpty() }?.get(0)
- sliderValueFromAACP?.toFloat()?.let { sliderValue.floatValue = (100 - it) }
- }
-
- val listener = remember {
- object : AACPManager.ControlCommandListener {
- override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
- if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH.value) {
- controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)?.toFloat()?.let {
- sliderValue.floatValue = (100 - it)
- }
- }
- }
- }
- }
-
- DisposableEffect(Unit) {
- service.aacpManager.registerControlCommandListener(
- AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH,
- listener
- )
- onDispose {
- service.aacpManager.unregisterControlCommandListener(
- AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH,
- listener
- )
- }
- }
-
+fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel) {
+ val state by viewModel.uiState.collectAsState()
val backdrop = rememberLayerBackdrop()
StyledScaffold(
@@ -109,17 +62,26 @@ fun AdaptiveStrengthScreen(navController: NavController) {
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(spacerHeight))
+ val sliderValue = remember {
+ mutableFloatStateOf(
+ state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH]?.getOrNull(
+ 0
+ )?.toFloat() ?: 50f
+ )
+ }
+ var job by remember { mutableStateOf(null) }
+ val scope = rememberCoroutineScope()
StyledSlider(
label = stringResource(R.string.customize_adaptive_audio),
- mutableFloatState = sliderValue,
+ value = sliderValue.floatValue,
onValueChange = {
sliderValue.floatValue = it
- debounceJob?.cancel()
- debounceJob = CoroutineScope(Dispatchers.Default).launch {
- delay(300)
- service.aacpManager.sendControlCommand(
- AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH.value,
- (100 - it).toInt()
+ job?.cancel()
+ job = scope.launch {
+ delay(150)
+ viewModel.setControlCommandValue(
+ AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH,
+ byteArrayOf((100 - it).toInt().toByte())
)
}
},
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt
index 3d002db..742575f 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt
@@ -20,16 +20,10 @@
package me.kavishdevar.librepods.screens
+// import me.kavishdevar.librepods.utils.RadareOffsetFinder
import android.annotation.SuppressLint
-import android.bluetooth.BluetoothDevice
-import android.content.BroadcastReceiver
-import android.content.Context
import android.content.Context.MODE_PRIVATE
-import android.content.Context.RECEIVER_EXPORTED
-import android.content.Intent
-import android.content.IntentFilter
import android.content.SharedPreferences
-import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -46,10 +40,10 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
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
@@ -65,23 +59,19 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-import androidx.core.content.edit
-import androidx.core.net.toUri
import androidx.navigation.NavController
-import androidx.navigation.compose.rememberNavController
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.highlight.Highlight
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
-import kotlinx.coroutines.launch
+import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.AboutCard
import me.kavishdevar.librepods.composables.AudioSettings
import me.kavishdevar.librepods.composables.BatteryView
import me.kavishdevar.librepods.composables.CallControlSettings
-import me.kavishdevar.librepods.composables.ConfirmationDialog
import me.kavishdevar.librepods.composables.ConnectionSettings
import me.kavishdevar.librepods.composables.HearingHealthSettings
import me.kavishdevar.librepods.composables.MicrophoneSettings
@@ -92,39 +82,33 @@ import me.kavishdevar.librepods.composables.StyledButton
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledToggle
-import me.kavishdevar.librepods.constants.AirPodsNotifications
-import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
import me.kavishdevar.librepods.utils.AACPManager
+import me.kavishdevar.librepods.utils.ATTHandles
+import me.kavishdevar.librepods.utils.AirPodsPro3
import me.kavishdevar.librepods.utils.Capability
-// import me.kavishdevar.librepods.utils.RadareOffsetFinder
+import me.kavishdevar.librepods.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
@Composable
-fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
- navController: NavController, isConnected: Boolean, isRemotelyConnected: Boolean) {
- var isLocallyConnected by remember { mutableStateOf(isConnected) }
- var isRemotelyConnected by remember { mutableStateOf(isRemotelyConnected) }
+fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavController) {
+ val state by viewModel.uiState.collectAsState()
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
- var device by remember { mutableStateOf(dev) }
var deviceName by remember {
mutableStateOf(
TextFieldValue(
- sharedPreferences.getString("name", device?.name ?: "AirPods Pro").toString()
+ sharedPreferences.getString("name", state.deviceName).toString()
)
)
}
- LaunchedEffect(service) {
- isLocallyConnected = service.isConnectedLocally
- }
-
val nameChangeListener = remember {
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == "name") {
- deviceName = TextFieldValue(sharedPreferences.getString("name", "AirPods Pro").toString())
+ deviceName =
+ TextFieldValue(sharedPreferences.getString("name", "AirPods Pro").toString())
}
}
}
@@ -137,113 +121,28 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
}
val snackbarHostState = remember { SnackbarHostState() }
- val coroutineScope = rememberCoroutineScope()
- fun handleRemoteConnection(connected: Boolean) {
- isRemotelyConnected = connected
+ LaunchedEffect(Unit) {
+ viewModel.refreshInitialData()
}
- val context = LocalContext.current
-
- val connectionReceiver = remember {
- object : BroadcastReceiver() {
- override fun onReceive(context: Context?, intent: Intent?) {
- when (intent?.action) {
- "me.kavishdevar.librepods.AIRPODS_CONNECTED_REMOTELY" -> {
- coroutineScope.launch {
- handleRemoteConnection(true)
- }
- }
- "me.kavishdevar.librepods.AIRPODS_DISCONNECTED_REMOTELY" -> {
- coroutineScope.launch {
- handleRemoteConnection(false)
- }
- }
- AirPodsNotifications.AIRPODS_CONNECTED -> {
- coroutineScope.launch {
- isLocallyConnected = true
- }
- }
- AirPodsNotifications.AIRPODS_DISCONNECTED -> {
- coroutineScope.launch {
- isLocallyConnected = false
- }
- }
- AirPodsNotifications.DISCONNECT_RECEIVERS -> {
- try {
- context?.unregisterReceiver(this)
- } catch (e: IllegalArgumentException) {
- e.printStackTrace()
- }
- }
- }
- }
- }
- }
-
- DisposableEffect(Unit) {
- val filter = IntentFilter().apply {
- addAction("me.kavishdevar.librepods.AIRPODS_CONNECTED_REMOTELY")
- addAction("me.kavishdevar.librepods.AIRPODS_DISCONNECTED_REMOTELY")
- addAction(AirPodsNotifications.AIRPODS_CONNECTED)
- addAction(AirPodsNotifications.AIRPODS_DISCONNECTED)
- addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
- }
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- context.registerReceiver(connectionReceiver, filter, RECEIVER_EXPORTED)
- } else {
- context.registerReceiver(connectionReceiver, filter)
- }
- onDispose {
- try {
- context.unregisterReceiver(connectionReceiver)
- } catch (e: Exception) {
- e.printStackTrace()
- }
- }
- }
-
- LaunchedEffect(service) {
- service.let {
- it.sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply {
- putParcelableArrayListExtra("data", ArrayList(it.getBattery()))
- })
- it.sendBroadcast(Intent(AirPodsNotifications.ANC_DATA).apply {
- putExtra("data", it.getANC())
- })
- }
- }
-
- val darkMode = isSystemInDarkTheme()
+ isSystemInDarkTheme()
val hazeStateS = remember { mutableStateOf(HazeState()) }
- // val showDialog = remember { mutableStateOf(!sharedPreferences.getBoolean("donationDialogShown", false)) }
-
- val showDialog = remember { mutableStateOf(false) }
-
StyledScaffold(
- title = deviceName.text,
- actionButtons = listOf(
- {scaffoldBackdrop ->
- StyledIconButton(
- onClick = { navController.navigate("app_settings") },
- icon = "",
- darkMode = darkMode,
- backdrop = scaffoldBackdrop
- )
- }
- ),
- snackbarHostState = snackbarHostState
+ title = deviceName.text, actionButtons = listOf(
+ { scaffoldBackdrop ->
+ StyledIconButton(
+ onClick = { navController.navigate("app_settings") },
+ icon = "",
+ backdrop = scaffoldBackdrop
+ )
+ }), snackbarHostState = snackbarHostState
) { spacerHeight, hazeState ->
hazeStateS.value = hazeState
- if (isLocallyConnected || isRemotelyConnected) {
- val instance = service.airpodsInstance
- if (instance == null) {
- Text("Error: AirPods instance is null")
- return@StyledScaffold
- }
- val capabilities = instance.model.capabilities
+
+ if (state.isLocallyConnected) {
+ val capabilities = state.capabilities
LazyColumn(
modifier = Modifier
.fillMaxSize()
@@ -252,7 +151,11 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
) {
item(key = "spacer_top") { Spacer(modifier = Modifier.height(spacerHeight)) }
item(key = "battery") {
- BatteryView(service = service)
+ BatteryView(
+ batteryList = state.battery,
+ budsRes = state.instance?.model?.budsRes ?: R.drawable.airpods_pro_2_case,
+ caseRes = state.instance?.model?.caseRes ?: R.drawable.airpods_pro_2_case
+ )
}
item(key = "spacer_battery") { Spacer(modifier = Modifier.height(32.dp)) }
@@ -265,79 +168,261 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
independent = true
)
}
-// val actAsAppleDeviceHookEnabled = RadareOffsetFinder.isSdpOffsetAvailable()
-// if (actAsAppleDeviceHookEnabled) {
-// item(key = "spacer_hearing_health") { Spacer(modifier = Modifier.height(32.dp)) }
-// item(key = "hearing_health") {
-// HearingHealthSettings(navController = navController)
-// }
-// }
+
+ val hasHearingAidCapability =
+ state.instance?.model?.capabilities?.contains(Capability.HEARING_AID) == true
+ val hasPPECapability =
+ state.instance?.model?.capabilities?.contains(Capability.PPE) == true
+
+ if (hasHearingAidCapability || hasPPECapability) {
+ if (hasPPECapability || (BuildConfig.FLAVOR == "xposed" && hasHearingAidCapability)) item(
+ key = "spacer_hearing_health"
+ ) { Spacer(modifier = Modifier.height(24.dp)) }
+ item(key = "hearing_health") {
+ HearingHealthSettings(
+ navController = navController,
+ hasPPECapability = hasPPECapability,
+ hasHearingAidCapability = hasHearingAidCapability,
+ isXposed = BuildConfig.FLAVOR == "xposed"
+ )
+ }
+ }
if (capabilities.contains(Capability.LISTENING_MODE)) {
item(key = "spacer_noise") { Spacer(modifier = Modifier.height(16.dp)) }
- item(key = "noise_control") { NoiseControlSettings(service = service) }
+ item(key = "noise_control") {
+ NoiseControlSettings(
+ showOffListeningMode = state.offListeningMode,
+ noiseControlModeValue = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE]?.getOrNull(
+ 0
+ )?.toInt() ?: 3,
+ onNoiseControlModeChanged = {
+ viewModel.setControlCommandInt(
+ AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE,
+ it
+ )
+ },
+ )
+ }
}
if (capabilities.contains(Capability.STEM_CONFIG)) {
item(key = "spacer_press_hold") { Spacer(modifier = Modifier.height(16.dp)) }
- item(key = "press_hold") { PressAndHoldSettings(navController = navController) }
+ item(key = "press_hold") {
+ PressAndHoldSettings(
+ navController = navController,
+ leftAction = state.leftAction,
+ rightAction = state.rightAction
+ )
+ }
}
item(key = "spacer_call") { Spacer(modifier = Modifier.height(16.dp)) }
- item(key = "call_control") { CallControlSettings(hazeState = hazeState) }
+ item(key = "call_control") {
+ val flipped =
+ state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG]?.take(
+ 2
+ )?.equals(byteArrayOf(0x00.toByte(), 0x02.toByte()))
+ CallControlSettings(
+ hazeState = hazeState,
+ flipped = flipped == true,
+ onCallControlValueChanged = {
+ viewModel.setControlCommandValue(
+ AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG,
+ if (it) byteArrayOf(0x00, 0x02) else byteArrayOf(0x00, 0x03)
+ )
+ })
+ }
- if (capabilities.contains(Capability.STEM_CONFIG)) {
+ if (capabilities.contains(Capability.STEM_CONFIG) && !BuildConfig.PLAY_BUILD) {
item(key = "spacer_camera") { Spacer(modifier = Modifier.height(16.dp)) }
- item(key = "camera_control") { NavigationButton(to = "camera_control", name = stringResource(R.string.camera_remote), description = stringResource(R.string.camera_control_description), title = stringResource(R.string.camera_control), navController = navController) }
+ item(key = "camera_control") {
+ NavigationButton(
+ to = "camera_control",
+ name = stringResource(R.string.camera_remote),
+ description = stringResource(R.string.camera_control_description),
+ title = stringResource(R.string.camera_control),
+ navController = navController
+ )
+ }
+ }
+
+ item(key = "upgrade_button") {
+ val context = LocalContext.current
+ val textColor = if (isSystemInDarkTheme()) Color.White else Color.Black
+
+ if (!state.isPremium) {
+ Spacer(modifier = Modifier.height(28.dp))
+ StyledButton(
+ onClick = {
+ viewModel.purchase(context)
+ },
+ backdrop = rememberLayerBackdrop(),
+ modifier = Modifier.fillMaxWidth(),
+ maxScale = 0.05f,
+ tint = Color(0xFF916100)
+ ) {
+ Text(
+ stringResource(R.string.unlock_all_features),
+ style = TextStyle(
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Medium,
+ fontFamily = FontFamily(Font(R.font.sf_pro)),
+ color = textColor
+ ),
+ )
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ }
}
item(key = "spacer_audio") { Spacer(modifier = Modifier.height(16.dp)) }
- item(key = "audio") { AudioSettings(navController = navController) }
+ item(key = "audio") {
+ val model = state.instance?.model ?: AirPodsPro3()
+ val adaptiveVolumeCapability =
+ model.capabilities.contains(Capability.ADAPTIVE_VOLUME)
+ val conversationalAwarenessCapability =
+ model.capabilities.contains(Capability.CONVERSATION_AWARENESS)
+ val loudSoundReductionCapability =
+ model.capabilities.contains(Capability.LOUD_SOUND_REDUCTION)
+ val adaptiveAudioCapability =
+ model.capabilities.contains(Capability.ADAPTIVE_VOLUME)
+
+ val adaptiveVolumeChecked =
+ state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG]?.getOrNull(
+ 0
+ ) == 0x01.toByte()
+ val conversationalAwarenessChecked =
+ state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG]?.getOrNull(
+ 0
+ ) == 0x01.toByte()
+ val loudSoundReduction =
+ viewModel.getATTCharacteristicValue(ATTHandles.LOUD_SOUND_REDUCTION)
+ ?.getOrNull(0) == 0x01.toByte()
+
+ val isXposed = BuildConfig.FLAVOR == "xposed"
+ AudioSettings(
+ navController = navController,
+ adaptiveVolumeCapability = adaptiveVolumeCapability,
+ conversationalAwarenessCapability = conversationalAwarenessCapability,
+ loudSoundReductionCapability = loudSoundReductionCapability,
+ adaptiveAudioCapability = adaptiveAudioCapability,
+ adaptiveVolumeChecked = adaptiveVolumeChecked,
+ onAdaptiveVolumeCheckedChange = { checked ->
+ viewModel.setControlCommandBoolean(
+ AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG,
+ checked
+ )
+ },
+ conversationalAwarenessChecked = conversationalAwarenessChecked && state.isPremium,
+ onConversationalAwarenessCheckedChange = { checked ->
+ viewModel.setControlCommandBoolean(
+ AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
+ checked
+ )
+ },
+ loudSoundReductionChecked = loudSoundReduction,
+ onLoudSoundReductionCheckedChange = {
+ viewModel.setATTCharacteristicValue(
+ ATTHandles.LOUD_SOUND_REDUCTION,
+ byteArrayOf(if (it) 0x01.toByte() else 0x00.toByte())
+ )
+ },
+ isXposed = isXposed,
+ isPremium = state.isPremium
+ )
+ }
item(key = "spacer_connection") { Spacer(modifier = Modifier.height(16.dp)) }
- item(key = "connection") { ConnectionSettings() }
+ item(key = "connection") {
+ ConnectionSettings(
+ automaticEarDetectionEnabled = state.automaticEarDetectionEnabled,
+ onAutomaticEarDetectionChanged = {
+ viewModel.setAutomaticEarDetectionEnabled(it)
+ },
+ automaticConnectionEnabled = state.automaticConnectionEnabled,
+ onAutomaticConnectionChanged = { viewModel.setAutomaticConnectionEnabled(it) }
+ )
+ }
item(key = "spacer_microphone") { Spacer(modifier = Modifier.height(16.dp)) }
- item(key = "microphone") { MicrophoneSettings(hazeState) }
+ item(key = "microphone") {
+ val id = AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE
+ MicrophoneSettings(
+ hazeState = hazeState,
+ micModeValue = state.controlStates[id]?.getOrNull(0) ?: 0x00.toByte(),
+ onMicModeValueChanged = { viewModel.setControlCommandByte(id, it) })
+ }
if (capabilities.contains(Capability.SLEEP_DETECTION)) {
item(key = "spacer_sleep") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "sleep_detection") {
+ val id =
+ AACPManager.Companion.ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG
StyledToggle(
label = stringResource(R.string.sleep_detection),
- controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG
+ checked = state.controlStates[id]?.getOrNull(0) == 0x01.toByte(),
+ onCheckedChange = {
+ viewModel.setControlCommandBoolean(id, it)
+ },
+ enabled = state.isPremium
)
}
}
if (capabilities.contains(Capability.HEAD_GESTURES)) {
item(key = "spacer_head_tracking") { Spacer(modifier = Modifier.height(16.dp)) }
- item(key = "head_tracking") { NavigationButton(to = "head_tracking", name = stringResource(R.string.head_gestures), navController = navController, currentState = if (sharedPreferences.getBoolean("head_gestures", false)) stringResource(R.string.on) else stringResource(R.string.off)) }
+ item(key = "head_tracking") {
+ NavigationButton(
+ to = "head_tracking",
+ name = stringResource(R.string.head_gestures),
+ navController = navController,
+ currentState = if (sharedPreferences.getBoolean(
+ "head_gestures", false
+ )
+ ) stringResource(R.string.on) else stringResource(R.string.off)
+ )
+ }
}
item(key = "spacer_accessibility") { Spacer(modifier = Modifier.height(16.dp)) }
- item(key = "accessibility") { NavigationButton(to = "accessibility", name = stringResource(R.string.accessibility), navController = navController) }
+ item(key = "accessibility") {
+ NavigationButton(
+ to = "accessibility",
+ name = stringResource(R.string.accessibility),
+ navController = navController
+ )
+ }
- if (capabilities.contains(Capability.LOUD_SOUND_REDUCTION)){
+ if (capabilities.contains(Capability.LOUD_SOUND_REDUCTION)) {
item(key = "spacer_off_listening") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "off_listening") {
+ val id = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
StyledToggle(
label = stringResource(R.string.off_listening_mode),
- controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION,
- description = stringResource(R.string.off_listening_mode_description)
+ description = stringResource(R.string.off_listening_mode_description),
+ checked = state.controlStates[id]?.getOrNull(0) == 0x01.toByte(),
+ onCheckedChange = viewModel::setOffListeningMode
)
}
}
item(key = "spacer_about") { Spacer(modifier = Modifier.height(32.dp)) }
- item(key = "about") { AboutCard(navController = navController) }
+ item(key = "about") {
+ AboutCard(
+ navController = navController,
+ modelName = state.modelName,
+ actualModel = state.actualModel,
+ serialNumbers = state.serialNumbers,
+ version = state.version3,
+ )
+ }
- item(key = "spacer_debug") { Spacer(modifier = Modifier.height(16.dp)) }
- item(key = "debug") { NavigationButton("debug", "Debug", navController) }
+// item(key = "spacer_debug") { Spacer(modifier = Modifier.height(16.dp)) }
+// item(key = "debug") { NavigationButton("debug", "Debug", navController) }
item(key = "spacer_bottom") { Spacer(Modifier.height(24.dp)) }
}
- }
- else {
+ } else {
val backdrop = rememberLayerBackdrop()
Column(
modifier = Modifier
@@ -348,23 +433,20 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
shape = { RoundedCornerShape(0.dp) },
highlight = {
Highlight.Ambient.copy(alpha = 0f)
- }
- )
+ },
+ effects = {})
.hazeSource(hazeState)
.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
- text = stringResource(R.string.airpods_not_connected),
- style = TextStyle(
+ text = stringResource(R.string.airpods_not_connected), style = TextStyle(
fontSize = 24.sp,
fontWeight = FontWeight.Medium,
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
fontFamily = FontFamily(Font(R.font.sf_pro))
- ),
- textAlign = TextAlign.Center,
- modifier = Modifier.fillMaxWidth()
+ ), textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(24.dp))
Text(
@@ -379,34 +461,30 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(32.dp))
- StyledButton(
- onClick = { navController.navigate("troubleshooting") },
- backdrop = backdrop,
- modifier = Modifier
- .fillMaxWidth(0.9f)
- ) {
- Text(
- text = stringResource(R.string.troubleshooting),
- style = TextStyle(
- fontSize = 16.sp,
- fontWeight = FontWeight.Medium,
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- color = if (isSystemInDarkTheme()) Color.White else Color.Black
- )
- )
- }
- Spacer(Modifier.height(16.dp))
+// StyledButton(
+// onClick = { navController.navigate("troubleshooting") },
+// backdrop = backdrop,
+// modifier = Modifier
+// .fillMaxWidth(0.9f)
+// ) {
+// Text(
+// text = stringResource(R.string.troubleshooting),
+// style = TextStyle(
+// fontSize = 16.sp,
+// fontWeight = FontWeight.Medium,
+// fontFamily = FontFamily(Font(R.font.sf_pro)),
+// color = if (isSystemInDarkTheme()) Color.White else Color.Black
+// )
+// )
+// }
+// Spacer(Modifier.height(16.dp))
StyledButton(
onClick = {
- service.reconnectFromSavedMac()
- },
- backdrop = backdrop,
- modifier = Modifier
- .fillMaxWidth(0.9f)
+ viewModel.reconnectFromSavedMac()
+ }, backdrop = backdrop, modifier = Modifier.fillMaxWidth(0.9f)
) {
Text(
- text = stringResource(R.string.reconnect_to_last_device),
- style = TextStyle(
+ text = stringResource(R.string.reconnect_to_last_device), style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
@@ -417,37 +495,18 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
}
}
}
- ConfirmationDialog(
- showDialog = showDialog,
- title = stringResource(R.string.support_librepods),
- message = stringResource(R.string.support_dialog_description),
- confirmText = stringResource(R.string.support_me) + " \uDBC0\uDEB5",
- dismissText = stringResource(R.string.never_show_again),
- onConfirm = {
- val browserIntent = Intent(
- Intent.ACTION_VIEW,
- "https://github.com/sponsors/kavishdevar".toUri()
- )
- context.startActivity(browserIntent)
- sharedPreferences.edit { putBoolean("donationDialogShown", true) }
- },
- onDismiss = {
- sharedPreferences.edit { putBoolean("donationDialogShown", true) }
- },
- hazeState = hazeStateS.value,
- )
}
@Preview
@Composable
fun AirPodsSettingsScreenPreview() {
- Column (
+ Column(
modifier = Modifier.height(2000.dp)
) {
- LibrePodsTheme (
+ LibrePodsTheme(
darkTheme = true
) {
- AirPodsSettingsScreen(dev = null, service = AirPodsService(), navController = rememberNavController(), isConnected = true, isRemotelyConnected = false)
+// AirPodsSettingsScreen(dev = null, service = AirPodsService(), navController = rememberNavController(), isConnected = true, isRemotelyConnected = false)
}
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt
index 1245deb..bb73a2a 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt
@@ -18,36 +18,21 @@
package me.kavishdevar.librepods.screens
-//import me.kavishdevar.librepods.utils.RadareOffsetFinder
-import android.content.Context
import android.widget.Toast
-import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.interaction.MutableInteractionSource
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.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.foundation.layout.size
-import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.AlertDialog
-import androidx.compose.material3.Button
-import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
-import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
@@ -55,11 +40,8 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.mutableFloatStateOf
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.ui.Alignment
+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.platform.LocalContext
@@ -72,121 +54,28 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-import androidx.core.content.edit
+import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource
-import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
+import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.NavigationButton
+import me.kavishdevar.librepods.composables.StyledButton
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider
import me.kavishdevar.librepods.composables.StyledToggle
-import me.kavishdevar.librepods.utils.AACPManager
-import kotlin.io.encoding.Base64
-import kotlin.io.encoding.ExperimentalEncodingApi
-import kotlin.math.roundToInt
+import me.kavishdevar.librepods.viewmodel.AppSettingsViewModel
-@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class, ExperimentalEncodingApi::class)
@Composable
-fun AppSettingsScreen(navController: NavController) {
- val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
-
- val isDarkTheme = isSystemInDarkTheme()
+fun AppSettingsScreen(
+ navController: NavController,
+ viewModel: AppSettingsViewModel = viewModel()
+) {
val context = LocalContext.current
- val coroutineScope = rememberCoroutineScope()
val scrollState = rememberScrollState()
-
- val showResetDialog = remember { mutableStateOf(false) }
- val showIrkDialog = remember { mutableStateOf(false) }
- val showEncKeyDialog = remember { mutableStateOf(false) }
- val showCameraDialog = remember { mutableStateOf(false) }
- val irkValue = remember { mutableStateOf("") }
- val encKeyValue = remember { mutableStateOf("") }
- val cameraPackageValue = remember { mutableStateOf("") }
- val irkError = remember { mutableStateOf(null) }
- val encKeyError = remember { mutableStateOf(null) }
- val cameraPackageError = remember { mutableStateOf(null) }
-
- LaunchedEffect(Unit) {
- val savedIrk = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.IRK.name, null)
- val savedEncKey = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.ENC_KEY.name, null)
- val savedCameraPackage = sharedPreferences.getString("custom_camera_package", null)
-
- if (savedIrk != null) {
- try {
- val decoded = Base64.decode(savedIrk)
- irkValue.value = decoded.joinToString("") { "%02x".format(it) }
- } catch (e: Exception) {
- irkValue.value = ""
- e.printStackTrace()
- }
- }
-
- if (savedEncKey != null) {
- try {
- val decoded = Base64.decode(savedEncKey)
- encKeyValue.value = decoded.joinToString("") { "%02x".format(it) }
- } catch (e: Exception) {
- encKeyValue.value = ""
- e.printStackTrace()
- }
- }
- if (savedCameraPackage != null) {
- cameraPackageValue.value = savedCameraPackage
- }
- }
-
- val showPhoneBatteryInWidget = remember {
- mutableStateOf(sharedPreferences.getBoolean("show_phone_battery_in_widget", true))
- }
- val conversationalAwarenessPauseMusicEnabled = remember {
- mutableStateOf(sharedPreferences.getBoolean("conversational_awareness_pause_music", false))
- }
- val relativeConversationalAwarenessVolumeEnabled = remember {
- mutableStateOf(sharedPreferences.getBoolean("relative_conversational_awareness_volume", true))
- }
- val openDialogForControlling = remember {
- mutableStateOf(sharedPreferences.getString("qs_click_behavior", "dialog") == "dialog")
- }
- val disconnectWhenNotWearing = remember {
- mutableStateOf(sharedPreferences.getBoolean("disconnect_when_not_wearing", false))
- }
-
- val takeoverWhenDisconnected = remember {
- mutableStateOf(sharedPreferences.getBoolean("takeover_when_disconnected", true))
- }
- val takeoverWhenIdle = remember {
- mutableStateOf(sharedPreferences.getBoolean("takeover_when_idle", true))
- }
- val takeoverWhenMusic = remember {
- mutableStateOf(sharedPreferences.getBoolean("takeover_when_music", false))
- }
- val takeoverWhenCall = remember {
- mutableStateOf(sharedPreferences.getBoolean("takeover_when_call", true))
- }
-
- val takeoverWhenRingingCall = remember {
- mutableStateOf(sharedPreferences.getBoolean("takeover_when_ringing_call", true))
- }
- val takeoverWhenMediaStart = remember {
- mutableStateOf(sharedPreferences.getBoolean("takeover_when_media_start", true))
- }
-
- val useAlternateHeadTrackingPackets = remember {
- mutableStateOf(sharedPreferences.getBoolean("use_alternate_head_tracking_packets", false))
- }
-
- fun validateHexInput(input: String): Boolean {
- val hexPattern = Regex("^[0-9a-fA-F]{32}$")
- return hexPattern.matches(input)
- }
-
- val isProcessingSdp = remember { mutableStateOf(false) }
-// val actAsAppleDevice = remember { mutableStateOf(false) }
-
- BackHandler(enabled = isProcessingSdp.value) {}
+ val uiState by viewModel.uiState.collectAsState()
val backdrop = rememberLayerBackdrop()
@@ -207,86 +96,97 @@ fun AppSettingsScreen(navController: NavController) {
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
+ if (!uiState.isPremium) {
+ StyledButton(
+ onClick = {
+ viewModel.purchase(context)
+ },
+ backdrop = rememberLayerBackdrop(),
+ modifier = Modifier.fillMaxWidth(),
+ maxScale = 0.05f,
+ tint = Color(0xFF916100)
+ ) {
+ Text(
+ stringResource(R.string.unlock_all_features),
+ style = TextStyle(
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Medium,
+ fontFamily = FontFamily(Font(R.font.sf_pro)),
+ color = textColor
+ ),
+ )
+ }
+ }
+
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),
- checkedState = showPhoneBatteryInWidget,
- sharedPreferenceKey = "show_phone_battery_in_widget",
- sharedPreferences = sharedPreferences,
+ checked = uiState.showPhoneBatteryInWidget,
+ onCheckedChange = viewModel::setShowPhoneBatteryInWidget,
+ enabled = uiState.isPremium
)
Text(
- text = stringResource(R.string.conversational_awareness),
- style = TextStyle(
+ 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)
+ ), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
)
Spacer(modifier = Modifier.height(2.dp))
- Column (
+ Column(
modifier = Modifier
.fillMaxWidth()
.background(
- backgroundColor,
- RoundedCornerShape(28.dp)
+ backgroundColor, RoundedCornerShape(28.dp)
)
.padding(vertical = 4.dp)
) {
- fun updateConversationalAwarenessPauseMusic(enabled: Boolean) {
- conversationalAwarenessPauseMusicEnabled.value = enabled
- sharedPreferences.edit { putBoolean("conversational_awareness_pause_music", enabled)}
- }
-
- fun updateRelativeConversationalAwarenessVolume(enabled: Boolean) {
- relativeConversationalAwarenessVolumeEnabled.value = enabled
- sharedPreferences.edit { putBoolean("relative_conversational_awareness_volume", enabled)}
- }
-
StyledToggle(
label = stringResource(R.string.conversational_awareness_pause_music),
description = stringResource(R.string.conversational_awareness_pause_music_description),
- checkedState = conversationalAwarenessPauseMusicEnabled,
- onCheckedChange = { updateConversationalAwarenessPauseMusic(it) },
- independent = false
+ checked = uiState.conversationalAwarenessPauseMusicEnabled,
+ onCheckedChange = viewModel::setConversationalAwarenessPauseMusicEnabled,
+ independent = false,
+ enabled = uiState.isPremium
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
- modifier = Modifier
- .padding(horizontal = 12.dp)
+ modifier = Modifier.padding(horizontal = 12.dp)
)
StyledToggle(
label = stringResource(R.string.relative_conversational_awareness_volume),
description = stringResource(R.string.relative_conversational_awareness_volume_description),
- checkedState = relativeConversationalAwarenessVolumeEnabled,
- onCheckedChange = { updateRelativeConversationalAwarenessVolume(it) },
- independent = false
+ checked = uiState.relativeConversationalAwarenessVolumeEnabled,
+ onCheckedChange = viewModel::setRelativeConversationalAwarenessVolumeEnabled,
+ independent = false,
+ enabled = uiState.isPremium
)
}
Spacer(modifier = Modifier.height(16.dp))
- val conversationalAwarenessVolume = remember { mutableFloatStateOf(sharedPreferences.getInt("conversational_awareness_volume", 43).toFloat()) }
- LaunchedEffect(conversationalAwarenessVolume.floatValue) {
- sharedPreferences.edit { putInt("conversational_awareness_volume", conversationalAwarenessVolume.floatValue.roundToInt()) }
+ val conversationalAwarenessVolume = uiState.conversationalAwarenessVolume
+ LaunchedEffect(conversationalAwarenessVolume) {
+ viewModel.setConversationalAwarenessVolume(conversationalAwarenessVolume)
}
StyledSlider(
label = stringResource(R.string.conversational_awareness_volume),
- mutableFloatState = conversationalAwarenessVolume,
+ value = conversationalAwarenessVolume,
valueRange = 10f..85f,
startLabel = "10%",
endLabel = "85%",
- onValueChange = { newValue -> conversationalAwarenessVolume.floatValue = newValue },
- independent = true
+ onValueChange = { newValue -> viewModel.setConversationalAwarenessVolume(newValue) },
+ independent = true,
+ enabled = uiState.isPremium
)
Spacer(modifier = Modifier.height(16.dp))
@@ -296,44 +196,32 @@ fun AppSettingsScreen(navController: NavController) {
title = stringResource(R.string.camera_control),
name = stringResource(R.string.set_custom_camera_package),
navController = navController,
- onClick = { showCameraDialog.value = true },
+ onClick = {
+ if (uiState.isPremium) viewModel.setShowCameraDialog(true)
+ },
independent = true,
description = stringResource(R.string.camera_control_app_description)
)
-// Spacer(modifier = Modifier.height(16.dp))
-//
-// StyledToggle(
-// title = stringResource(R.string.quick_settings_tile),
-// label = stringResource(R.string.open_dialog_for_controlling),
-// description = stringResource(R.string.open_dialog_for_controlling_description),
-// checkedState = openDialogForControlling,
-// onCheckedChange = {
-// openDialogForControlling.value = it
-// sharedPreferences.edit { putString("qs_click_behavior", if (it) "dialog" else "cycle") }
-// },
-// )
-
Spacer(modifier = Modifier.height(16.dp))
-
- StyledToggle(
- title = stringResource(R.string.ear_detection),
- label = stringResource(R.string.disconnect_when_not_wearing),
- description = stringResource(R.string.disconnect_when_not_wearing_description),
- checkedState = disconnectWhenNotWearing,
- sharedPreferenceKey = "disconnect_when_not_wearing",
- sharedPreferences = sharedPreferences,
- )
+ 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 = uiState.disconnectWhenNotWearing,
+ onCheckedChange = viewModel::setDisconnectWhenNotWearing,
+ enabled = uiState.isPremium
+ )
+ }
Text(
- text = stringResource(R.string.takeover_airpods_state),
- style = TextStyle(
+ 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)
+ ), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
)
Spacer(modifier = Modifier.height(4.dp))
@@ -342,301 +230,134 @@ fun AppSettingsScreen(navController: NavController) {
modifier = Modifier
.fillMaxWidth()
.background(
- backgroundColor,
- RoundedCornerShape(28.dp)
+ backgroundColor, RoundedCornerShape(28.dp)
)
.padding(vertical = 4.dp)
) {
StyledToggle(
label = stringResource(R.string.takeover_disconnected),
description = stringResource(R.string.takeover_disconnected_desc),
- checkedState = takeoverWhenDisconnected,
- onCheckedChange = {
- takeoverWhenDisconnected.value = it
- sharedPreferences.edit { putBoolean("takeover_when_disconnected", it)}
- },
- independent = false
+ checked = uiState.takeoverWhenDisconnected,
+ onCheckedChange = viewModel::setTakeoverWhenDisconnected,
+ independent = false,
+ enabled = uiState.isPremium
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
- modifier = Modifier
- .padding(horizontal = 12.dp)
+ modifier = Modifier.padding(horizontal = 12.dp)
)
StyledToggle(
label = stringResource(R.string.takeover_idle),
description = stringResource(R.string.takeover_idle_desc),
- checkedState = takeoverWhenIdle,
- onCheckedChange = {
- takeoverWhenIdle.value = it
- sharedPreferences.edit { putBoolean("takeover_when_idle", it)}
- },
- independent = false
+ checked = uiState.takeoverWhenIdle,
+ onCheckedChange = viewModel::setTakeoverWhenIdle,
+ independent = false,
+ enabled = uiState.isPremium
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
- modifier = Modifier
- .padding(horizontal = 12.dp)
+ modifier = Modifier.padding(horizontal = 12.dp)
)
StyledToggle(
label = stringResource(R.string.takeover_music),
description = stringResource(R.string.takeover_music_desc),
- checkedState = takeoverWhenMusic,
- onCheckedChange = {
- takeoverWhenMusic.value = it
- sharedPreferences.edit { putBoolean("takeover_when_music", it)}
- },
- independent = false
+ checked = uiState.takeoverWhenMusic,
+ onCheckedChange = viewModel::setTakeoverWhenMusic,
+ independent = false,
+ enabled = uiState.isPremium
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
- modifier = Modifier
- .padding(horizontal = 12.dp)
+ modifier = Modifier.padding(horizontal = 12.dp)
)
StyledToggle(
label = stringResource(R.string.takeover_call),
description = stringResource(R.string.takeover_call_desc),
- checkedState = takeoverWhenCall,
- onCheckedChange = {
- takeoverWhenCall.value = it
- sharedPreferences.edit { putBoolean("takeover_when_call", it)}
- },
- independent = false
+ checked = uiState.takeoverWhenCall,
+ onCheckedChange = viewModel::setTakeoverWhenCall,
+ independent = false,
+ enabled = uiState.isPremium
)
}
Spacer(modifier = Modifier.height(16.dp))
Text(
- text = stringResource(R.string.takeover_phone_state),
- style = TextStyle(
+ 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)
+ ), modifier = Modifier.padding(horizontal = 16.dp)
)
Spacer(modifier = Modifier.height(4.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.background(
- backgroundColor,
- RoundedCornerShape(28.dp)
+ backgroundColor, RoundedCornerShape(28.dp)
)
.padding(vertical = 4.dp)
- ){
+ ) {
StyledToggle(
label = stringResource(R.string.takeover_ringing_call),
description = stringResource(R.string.takeover_ringing_call_desc),
- checkedState = takeoverWhenRingingCall,
- onCheckedChange = {
- takeoverWhenRingingCall.value = it
- sharedPreferences.edit { putBoolean("takeover_when_ringing_call", it)}
- },
- independent = false
+ checked = uiState.takeoverWhenRingingCall,
+ onCheckedChange = viewModel::setTakeoverWhenRingingCall,
+ independent = false,
+ enabled = uiState.isPremium
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
- modifier = Modifier
- .padding(horizontal = 12.dp)
+ modifier = Modifier.padding(horizontal = 12.dp)
)
StyledToggle(
label = stringResource(R.string.takeover_media_start),
description = stringResource(R.string.takeover_media_start_desc),
- checkedState = takeoverWhenMediaStart,
- onCheckedChange = {
- takeoverWhenMediaStart.value = it
- sharedPreferences.edit { putBoolean("takeover_when_media_start", it)}
- },
- independent = false
+ checked = uiState.takeoverWhenMediaStart,
+ onCheckedChange = viewModel::setTakeoverWhenMediaStart,
+ independent = false,
+ enabled = uiState.isPremium
)
}
Text(
- text = stringResource(R.string.advanced_options),
- style = TextStyle(
+ 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)
+ ), 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(horizontal = 16.dp, vertical = 4.dp)
- ) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .clickable (
- onClick = { showIrkDialog.value = true },
- indication = null,
- interactionSource = remember { MutableInteractionSource() }
- ),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Column(
- modifier = Modifier
- .weight(1f)
- .padding(vertical = 8.dp)
- .padding(end = 4.dp)
- ) {
- Text(
- text = stringResource(R.string.set_identity_resolving_key),
- fontSize = 16.sp,
- color = textColor
- )
- Spacer(modifier = Modifier.height(4.dp))
- Text(
- text = stringResource(R.string.set_identity_resolving_key_description),
- fontSize = 14.sp,
- color = textColor.copy(0.6f),
- lineHeight = 16.sp,
- )
- }
- }
-
- HorizontalDivider(
- thickness = 1.dp,
- color = Color(0x40888888),
- )
-
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .clickable (
- onClick = { showEncKeyDialog.value = true },
- indication = null,
- interactionSource = remember { MutableInteractionSource() }
- ),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Column(
- modifier = Modifier
- .weight(1f)
- .padding(vertical = 8.dp)
- .padding(end = 4.dp)
- ) {
- Text(
- text = stringResource(R.string.set_encryption_key),
- fontSize = 16.sp,
- color = textColor
- )
- Spacer(modifier = Modifier.height(4.dp))
- Text(
- text = stringResource(R.string.set_encryption_key_description),
- fontSize = 14.sp,
- color = textColor.copy(0.6f),
- lineHeight = 16.sp,
- )
- }
- }
- }
-
- Spacer(modifier = Modifier.height(16.dp))
-
StyledToggle(
label = stringResource(R.string.use_alternate_head_tracking_packets),
description = stringResource(R.string.use_alternate_head_tracking_packets_description),
- checkedState = useAlternateHeadTrackingPackets,
- onCheckedChange = {
- useAlternateHeadTrackingPackets.value = it
- sharedPreferences.edit { putBoolean("use_alternate_head_tracking_packets", it)}
- },
- independent = true
+ checked = uiState.useAlternateHeadTrackingPackets,
+ onCheckedChange = viewModel::setUseAlternateHeadTrackingPackets,
+ independent = true,
+ enabled = uiState.isPremium
)
Spacer(modifier = Modifier.height(16.dp))
- NavigationButton(
- to = "troubleshooting",
- name = stringResource(R.string.troubleshooting),
- navController = navController,
- independent = true,
- description = stringResource(R.string.troubleshooting_description)
- )
-
-// LaunchedEffect(Unit) {
-// actAsAppleDevice.value = RadareOffsetFinder.isSdpOffsetAvailable()
-// }
- 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),
- checkedState = actAsAppleDevice,
- onCheckedChange = {
- actAsAppleDevice.value = it
- isProcessingSdp.value = true
- coroutineScope.launch {
- if (it) {
- val radareOffsetFinder = RadareOffsetFinder(context)
- val success = radareOffsetFinder.findSdpOffset()
- if (success) {
- Toast.makeText(context, restartBluetoothText, Toast.LENGTH_LONG).show()
- }
- } else {
- RadareOffsetFinder.clearSdpOffset()
- }
- isProcessingSdp.value = false
- }
- },
- independent = true,
- enabled = !isProcessingSdp.value
- )*/
-
- Spacer(modifier = Modifier.height(16.dp))
-
-// Button(
-// onClick = { showResetDialog.value = true },
-// modifier = Modifier
-// .fillMaxWidth()
-// .height(50.dp),
-// colors = ButtonDefaults.buttonColors(
-// containerColor = MaterialTheme.colorScheme.errorContainer
-// ),
-// shape = RoundedCornerShape(28.dp)
-// ) {
-// Row(
-// verticalAlignment = Alignment.CenterVertically,
-// horizontalArrangement = Arrangement.Center
-// ) {
-// Icon(
-// imageVector = Icons.Default.Refresh,
-// contentDescription = "Reset",
-// tint = MaterialTheme.colorScheme.onErrorContainer,
-// modifier = Modifier.size(18.dp)
-// )
-// Spacer(modifier = Modifier.width(8.dp))
-// Text(
-// text = stringResource(R.string.reset_hook_offset),
-// color = MaterialTheme.colorScheme.onErrorContainer,
-// style = TextStyle(
-// fontSize = 16.sp,
-// fontWeight = FontWeight.Medium,
-// fontFamily = FontFamily(Font(R.font.sf_pro))
-// )
-// )
-// }
-// }
+// NavigationButton(
+// to = "troubleshooting",
+// name = stringResource(R.string.troubleshooting),
+// navController = navController,
+// independent = true,
+// description = stringResource(R.string.troubleshooting_description)
+// )
Spacer(modifier = Modifier.height(16.dp))
@@ -649,331 +370,72 @@ fun AppSettingsScreen(navController: NavController) {
Spacer(modifier = Modifier.height(32.dp))
-// if (showResetDialog.value) {
-// AlertDialog(
-// onDismissRequest = { showResetDialog.value = false },
-// title = {
-// Text(
-// "Reset Hook Offset",
-// fontFamily = FontFamily(Font(R.font.sf_pro)),
-// fontWeight = FontWeight.Medium
-// )
-// },
-// text = {
-// Text(
-// stringResource(R.string.reset_hook_offset_description),
-// fontFamily = FontFamily(Font(R.font.sf_pro))
-// )
-// },
-// confirmButton = {
-// val successText = stringResource(R.string.hook_offset_reset_success)
-// val failureText = stringResource(R.string.hook_offset_reset_failure)
-// TextButton(
-// onClick = {
-// if (RadareOffsetFinder.clearHookOffsets()) {
-// Toast.makeText(
-// context,
-// successText,
-// Toast.LENGTH_LONG
-// ).show()
-//
-// navController.navigate("onboarding") {
-// popUpTo("settings") { inclusive = true }
-// }
-// } else {
-// Toast.makeText(
-// context,
-// failureText,
-// Toast.LENGTH_SHORT
-// ).show()
-// }
-// showResetDialog.value = false
-// },
-// colors = ButtonDefaults.textButtonColors(
-// contentColor = MaterialTheme.colorScheme.error
-// )
-// ) {
-// Text(
-// stringResource(R.string.reset),
-// fontFamily = FontFamily(Font(R.font.sf_pro)),
-// fontWeight = FontWeight.Medium
-// )
-// }
-// },
-// dismissButton = {
-// TextButton(
-// onClick = { showResetDialog.value = false }
-// ) {
-// Text(
-// "Cancel",
-// fontFamily = FontFamily(Font(R.font.sf_pro)),
-// fontWeight = FontWeight.Medium
-// )
-// }
-// }
-// )
-// }
-
- if (showIrkDialog.value) {
- AlertDialog(
- onDismissRequest = { showIrkDialog.value = false },
- title = {
+ if (uiState.showCameraDialog) {
+ AlertDialog(onDismissRequest = { viewModel.setShowCameraDialog(false) }, title = {
+ Text(
+ stringResource(R.string.set_custom_camera_package),
+ fontFamily = FontFamily(Font(R.font.sf_pro)),
+ fontWeight = FontWeight.Medium
+ )
+ }, text = {
+ Column {
Text(
- stringResource(R.string.set_identity_resolving_key),
+ stringResource(R.string.enter_custom_camera_package),
+ fontFamily = FontFamily(Font(R.font.sf_pro)),
+ modifier = Modifier.padding(bottom = 8.dp)
+ )
+
+ OutlinedTextField(
+ value = uiState.cameraPackageValue,
+ onValueChange = {
+ viewModel.setCameraPackageValue(it)
+ viewModel.setCameraPackageError(null)
+ },
+ modifier = Modifier.fillMaxWidth(),
+ isError = uiState.cameraPackageError != null,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Ascii,
+ capitalization = KeyboardCapitalization.None
+ ),
+ colors = OutlinedTextFieldDefaults.colors(
+ focusedBorderColor = if (isDarkTheme) Color(0xFF007AFF) else Color(
+ 0xFF3C6DF5
+ ),
+ unfocusedBorderColor = if (isDarkTheme) Color.Gray else Color.LightGray
+ ),
+ supportingText = {
+ if (uiState.cameraPackageError != null) {
+ Text(
+ uiState.cameraPackageError ?: "",
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ },
+ label = { Text(stringResource(R.string.custom_camera_package)) })
+ }
+ }, confirmButton = {
+ val successText = stringResource(R.string.custom_camera_package_set_success)
+ TextButton(
+ onClick = {
+ viewModel.saveCameraPackage()
+ Toast.makeText(context, successText, Toast.LENGTH_SHORT).show()
+ }) {
+ Text(
+ "Save",
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium
)
- },
- text = {
- Column {
- Text(
- stringResource(R.string.enter_irk_hex),
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- modifier = Modifier.padding(bottom = 8.dp)
- )
-
- OutlinedTextField(
- value = irkValue.value,
- onValueChange = {
- irkValue.value = it.lowercase().filter { char -> char.isDigit() || char in 'a'..'f' }
- irkError.value = null
- },
- modifier = Modifier.fillMaxWidth(),
- isError = irkError.value != null,
- keyboardOptions = KeyboardOptions(
- keyboardType = KeyboardType.Ascii,
- capitalization = KeyboardCapitalization.None
- ),
- colors = OutlinedTextFieldDefaults.colors(
- focusedBorderColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
- unfocusedBorderColor = if (isDarkTheme) Color.Gray else Color.LightGray
- ),
- supportingText = {
- if (irkError.value != null) {
- Text(stringResource(R.string.must_be_32_hex_chars), color = MaterialTheme.colorScheme.error)
- }
- },
- label = { Text(stringResource(R.string.irk_hex_value)) }
- )
- }
- },
- confirmButton = {
- val successText = stringResource(R.string.irk_set_success)
- val errorText = stringResource(R.string.error_converting_hex)
- TextButton(
- onClick = {
- if (!validateHexInput(irkValue.value)) {
- irkError.value = "Must be exactly 32 hex characters"
- return@TextButton
- }
-
- try {
- val hexBytes = ByteArray(16)
- for (i in 0 until 16) {
- val hexByte = irkValue.value.substring(i * 2, i * 2 + 2)
- hexBytes[i] = hexByte.toInt(16).toByte()
- }
-
- val base64Value = Base64.encode(hexBytes)
- sharedPreferences.edit { putString(AACPManager.Companion.ProximityKeyType.IRK.name, base64Value)}
-
- Toast.makeText(context, successText, Toast.LENGTH_SHORT).show()
- showIrkDialog.value = false
- } catch (e: Exception) {
- irkError.value = errorText + " " + (e.message ?: "Unknown error")
- }
- }
- ) {
- Text(
- "Save",
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- fontWeight = FontWeight.Medium
- )
- }
- },
- dismissButton = {
- TextButton(
- onClick = { showIrkDialog.value = false }
- ) {
- Text(
- "Cancel",
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- fontWeight = FontWeight.Medium
- )
- }
}
- )
- }
-
- if (showEncKeyDialog.value) {
- AlertDialog(
- onDismissRequest = { showEncKeyDialog.value = false },
- title = {
+ }, dismissButton = {
+ TextButton(
+ onClick = { viewModel.setShowCameraDialog(false) }) {
Text(
- stringResource(R.string.set_encryption_key),
+ "Cancel",
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium
)
- },
- text = {
- Column {
- Text(
- stringResource(R.string.enter_enc_key_hex),
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- modifier = Modifier.padding(bottom = 8.dp)
- )
-
- OutlinedTextField(
- value = encKeyValue.value,
- onValueChange = {
- encKeyValue.value = it.lowercase().filter { char -> char.isDigit() || char in 'a'..'f' }
- encKeyError.value = null
- },
- modifier = Modifier.fillMaxWidth(),
- isError = encKeyError.value != null,
- keyboardOptions = KeyboardOptions(
- keyboardType = KeyboardType.Ascii,
- capitalization = KeyboardCapitalization.None
- ),
- colors = OutlinedTextFieldDefaults.colors(
- focusedBorderColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
- unfocusedBorderColor = if (isDarkTheme) Color.Gray else Color.LightGray
- ),
- supportingText = {
- if (encKeyError.value != null) {
- Text(stringResource(R.string.must_be_32_hex_chars), color = MaterialTheme.colorScheme.error)
- }
- },
- label = { Text(stringResource(R.string.enc_key_hex_value)) }
- )
- }
- },
- confirmButton = {
- val successText = stringResource(R.string.encryption_key_set_success)
- val errorText = stringResource(R.string.error_converting_hex)
- TextButton(
- onClick = {
- if (!validateHexInput(encKeyValue.value)) {
- encKeyError.value = "Must be exactly 32 hex characters"
- return@TextButton
- }
-
- try {
- val hexBytes = ByteArray(16)
- for (i in 0 until 16) {
- val hexByte = encKeyValue.value.substring(i * 2, i * 2 + 2)
- hexBytes[i] = hexByte.toInt(16).toByte()
- }
-
- val base64Value = Base64.encode(hexBytes)
- sharedPreferences.edit { putString(AACPManager.Companion.ProximityKeyType.ENC_KEY.name, base64Value)}
-
- Toast.makeText(context, successText, Toast.LENGTH_SHORT).show()
- showEncKeyDialog.value = false
- } catch (e: Exception) {
- encKeyError.value = errorText + " " + (e.message ?: "Unknown error")
- }
- }
- ) {
- Text(
- "Save",
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- fontWeight = FontWeight.Medium
- )
- }
- },
- dismissButton = {
- TextButton(
- onClick = { showEncKeyDialog.value = false }
- ) {
- Text(
- "Cancel",
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- fontWeight = FontWeight.Medium
- )
- }
}
- )
- }
-
- if (showCameraDialog.value) {
- AlertDialog(
- onDismissRequest = { showCameraDialog.value = false },
- title = {
- Text(
- stringResource(R.string.set_custom_camera_package),
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- fontWeight = FontWeight.Medium
- )
- },
- text = {
- Column {
- Text(
- stringResource(R.string.enter_custom_camera_package),
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- modifier = Modifier.padding(bottom = 8.dp)
- )
-
- OutlinedTextField(
- value = cameraPackageValue.value,
- onValueChange = {
- cameraPackageValue.value = it
- cameraPackageError.value = null
- },
- modifier = Modifier.fillMaxWidth(),
- isError = cameraPackageError.value != null,
- keyboardOptions = KeyboardOptions(
- keyboardType = KeyboardType.Ascii,
- capitalization = KeyboardCapitalization.None
- ),
- colors = OutlinedTextFieldDefaults.colors(
- focusedBorderColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
- unfocusedBorderColor = if (isDarkTheme) Color.Gray else Color.LightGray
- ),
- supportingText = {
- if (cameraPackageError.value != null) {
- Text(cameraPackageError.value!!, color = MaterialTheme.colorScheme.error)
- }
- },
- label = { Text(stringResource(R.string.custom_camera_package)) }
- )
- }
- },
- confirmButton = {
- val successText = stringResource(R.string.custom_camera_package_set_success)
- TextButton(
- onClick = {
- if (cameraPackageValue.value.isBlank()) {
- sharedPreferences.edit { remove("custom_camera_package") }
- Toast.makeText(context, successText, Toast.LENGTH_SHORT).show()
- showCameraDialog.value = false
- return@TextButton
- }
-
- sharedPreferences.edit { putString("custom_camera_package", cameraPackageValue.value) }
- Toast.makeText(context, successText, Toast.LENGTH_SHORT).show()
- showCameraDialog.value = false
- }
- ) {
- Text(
- "Save",
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- fontWeight = FontWeight.Medium
- )
- }
- },
- dismissButton = {
- TextButton(
- onClick = { showCameraDialog.value = false }
- ) {
- Text(
- "Cancel",
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- fontWeight = FontWeight.Medium
- )
- }
- }
- )
+ })
}
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/CameraControlScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/CameraControlScreen.kt
index 7ad0f29..4c670f5 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/CameraControlScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/CameraControlScreen.kt
@@ -18,114 +18,79 @@
package me.kavishdevar.librepods.screens
-import android.annotation.SuppressLint
+import android.accessibilityservice.AccessibilityServiceInfo
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.provider.Settings
import android.view.accessibility.AccessibilityManager
-import android.accessibilityservice.AccessibilityServiceInfo
-import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
-import androidx.navigation.NavController
-import androidx.core.content.edit
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
-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.composables.SelectItem
-import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSelectList
-import me.kavishdevar.librepods.composables.StyledSlider
-import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.services.AppListenerService
-import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressType
-import kotlin.io.encoding.ExperimentalEncodingApi
+import me.kavishdevar.librepods.viewmodel.AirPodsViewModel
-private var debounceJob: Job? = null
-
-@SuppressLint("DefaultLocale")
-@ExperimentalHazeMaterialsApi
-@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
-fun CameraControlScreen(navController: NavController) {
- val isDarkTheme = isSystemInDarkTheme()
+fun CameraControlScreen(viewModel: AirPodsViewModel) {
val context = LocalContext.current
- val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
-
- val service = ServiceManager.getService()!!
- var currentCameraAction by remember {
- mutableStateOf(
- sharedPreferences.getString("camera_action", null)?.let { StemPressType.valueOf(it) }
- )
- }
+ val currentCameraAction by viewModel.cameraAction.collectAsState()
fun isAppListenerServiceEnabled(context: Context): Boolean {
val am = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
- val enabledServices = am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK)
+ val enabledServices =
+ am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK)
val serviceComponent = ComponentName(context, AppListenerService::class.java)
- return enabledServices.any { it.resolveInfo.serviceInfo.packageName == serviceComponent.packageName && it.resolveInfo.serviceInfo.name == serviceComponent.className }
+ return enabledServices.any {
+ it.resolveInfo.serviceInfo.packageName == serviceComponent.packageName &&
+ it.resolveInfo.serviceInfo.name == serviceComponent.className
+ }
}
- val cameraOptions = listOf(
- SelectItem(
- name = stringResource(R.string.off),
- selected = currentCameraAction == null,
- onClick = {
- sharedPreferences.edit { remove("camera_action") }
- currentCameraAction = null
- }
- ),
- SelectItem(
- name = stringResource(R.string.press_once),
- selected = currentCameraAction == StemPressType.SINGLE_PRESS,
- onClick = {
- if (!isAppListenerServiceEnabled(context)) {
- context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
- } else {
- sharedPreferences.edit { putString("camera_action", StemPressType.SINGLE_PRESS.name) }
- currentCameraAction = StemPressType.SINGLE_PRESS
- }
- }
- ),
- SelectItem(
- name = stringResource(R.string.press_and_hold_airpods),
- selected = currentCameraAction == StemPressType.LONG_PRESS,
- onClick = {
- if (!isAppListenerServiceEnabled(context)) {
- context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
- } else {
- sharedPreferences.edit { putString("camera_action", StemPressType.LONG_PRESS.name) }
- currentCameraAction = StemPressType.LONG_PRESS
- }
- }
+ fun handleSelection(action: StemPressType?) {
+ if (action != null && !isAppListenerServiceEnabled(context)) {
+ context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
+ } else {
+ viewModel.setCameraAction(action)
+ }
+ }
+
+ val cameraOptions = remember(currentCameraAction) {
+ listOf(
+ SelectItem(
+ name = "Off",
+ selected = currentCameraAction == null,
+ onClick = { handleSelection(null) }
+ ),
+ SelectItem(
+ name = "Press once",
+ selected = currentCameraAction == StemPressType.SINGLE_PRESS,
+ onClick = { handleSelection(StemPressType.SINGLE_PRESS) }
+ ),
+ SelectItem(
+ name = "Press and hold AirPods",
+ selected = currentCameraAction == StemPressType.LONG_PRESS,
+ onClick = { handleSelection(StemPressType.LONG_PRESS) }
+ )
)
- )
+ }
val backdrop = rememberLayerBackdrop()
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/DebugScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/DebugScreen.kt
index 401fc91..a802847 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/DebugScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/DebugScreen.kt
@@ -335,7 +335,6 @@ fun DebugScreen(navController: NavController) {
expandedItems.value = emptySet()
},
icon = "",
- darkMode = isDarkTheme,
backdrop = scaffoldBackdrop
)
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/HeadTrackingScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/HeadTrackingScreen.kt
index f3c8416..f485495 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/HeadTrackingScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/HeadTrackingScreen.kt
@@ -23,10 +23,7 @@
package me.kavishdevar.librepods.screens
-import android.content.Context
-import android.os.Build
import android.util.Log
-import androidx.annotation.RequiresApi
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.tween
@@ -83,7 +80,6 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.kyant.backdrop.backdrops.layerBackdrop
@@ -100,6 +96,7 @@ import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledToggle
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.HeadTracking
+import me.kavishdevar.librepods.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs
import kotlin.math.cos
@@ -107,14 +104,14 @@ import kotlin.math.sin
import kotlin.random.Random
@ExperimentalHazeMaterialsApi
-@RequiresApi(Build.VERSION_CODES.Q)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
@Composable
-fun HeadTrackingScreen() {
+fun HeadTrackingScreen(viewModel: AirPodsViewModel) {
+ val state by viewModel.uiState.collectAsState()
DisposableEffect(Unit) {
- ServiceManager.getService()?.startHeadTracking()
+ viewModel.startHeadTracking()
onDispose {
- ServiceManager.getService()?.stopHeadTracking()
+ viewModel.stopHeadTracking()
}
}
val isDarkTheme = isSystemInDarkTheme()
@@ -127,25 +124,22 @@ fun HeadTrackingScreen() {
title = stringResource(R.string.head_tracking),
actionButtons = listOf(
{ scaffoldBackdrop ->
- var isActive by remember { mutableStateOf(ServiceManager.getService()?.isHeadTrackingActive == true) }
StyledIconButton(
onClick = {
- if (ServiceManager.getService()?.isHeadTrackingActive == false) {
- ServiceManager.getService()?.startHeadTracking()
+ if (!state.headTrackingActive) {
+ viewModel.startHeadTracking()
Log.d("HeadTrackingScreen", "Head tracking started")
} else {
- ServiceManager.getService()?.stopHeadTracking()
+ viewModel.stopHeadTracking()
Log.d("HeadTrackingScreen", "Head tracking stopped")
}
},
- icon = if (isActive) "" else "",
- darkMode = isDarkTheme,
+ icon = if (state.headTrackingActive) "" else "",
backdrop = scaffoldBackdrop
)
}
),
) { spacerHeight, hazeState ->
- val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
var gestureText by remember { mutableStateOf("") }
val coroutineScope = rememberCoroutineScope()
@@ -167,10 +161,37 @@ fun HeadTrackingScreen() {
.verticalScroll(scrollState)
) {
Spacer(modifier = Modifier.height(spacerHeight))
+
+ val context = LocalContext.current
+
+ if (!state.isPremium) {
+ StyledButton(
+ onClick = {
+ viewModel.purchase(context)
+ },
+ backdrop = rememberLayerBackdrop(),
+ modifier = Modifier.fillMaxWidth(),
+ maxScale = 0.05f,
+ tint = Color(0xFF916100)
+ ) {
+ Text(
+ stringResource(R.string.unlock_all_features),
+ style = TextStyle(
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Medium,
+ fontFamily = FontFamily(Font(R.font.sf_pro)),
+ color = textColor
+ ),
+ )
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+
StyledToggle(
label = "Head Gestures",
- sharedPreferences = sharedPreferences,
- sharedPreferenceKey = "head_gestures",
+ checked = state.headGesturesEnabled,
+ onCheckedChange = { viewModel.setHeadGesturesEnabled(it) },
+ enabled = state.isPremium
)
Spacer(modifier = Modifier.height(2.dp))
@@ -739,11 +760,3 @@ private fun AccelerationPlot() {
}
}
}
-
-@ExperimentalHazeMaterialsApi
-@RequiresApi(Build.VERSION_CODES.Q)
-@Preview
-@Composable
-fun HeadTrackingScreenPreview() {
- HeadTrackingScreen()
-}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidAdjustmentsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidAdjustmentsScreen.kt
index 6925fbb..8fe06b7 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidAdjustmentsScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidAdjustmentsScreen.kt
@@ -31,9 +31,10 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@@ -41,7 +42,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
-import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.HazeState
@@ -59,6 +59,7 @@ import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.HearingAidSettings
import me.kavishdevar.librepods.utils.parseHearingAidSettingsResponse
import me.kavishdevar.librepods.utils.sendHearingAidSettings
+import me.kavishdevar.librepods.viewmodel.AirPodsViewModel
import java.io.IOException
import kotlin.io.encoding.ExperimentalEncodingApi
@@ -69,13 +70,14 @@ private const val TAG = "HearingAidAdjustments"
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
-fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController) {
+fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
isSystemInDarkTheme()
val verticalScrollState = rememberScrollState()
val hazeState = remember { HazeState() }
val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available")
- val aacpManager = remember { ServiceManager.getService()?.aacpManager }
+ val state by viewModel.uiState.collectAsState()
+
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = stringResource(R.string.adjustments)
@@ -125,25 +127,6 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
)
}
- val hearingAidEnabled = remember {
- val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
- val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
- mutableStateOf((aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte()))
- }
-
- val hearingAidListener = remember {
- object : AACPManager.ControlCommandListener {
- override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
- if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value ||
- controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value) {
- val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
- val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
- hearingAidEnabled.value = (aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())
- }
- }
- }
- }
-
val hearingAidATTListener = remember {
object : (ByteArray) -> Unit {
override fun invoke(value: ByteArray) {
@@ -165,19 +148,6 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
}
}
- LaunchedEffect(Unit) {
- aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
- aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
- }
-
- DisposableEffect(Unit) {
- onDispose {
- aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
- aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
- attManager.unregisterListener(ATTHandles.HEARING_AID, hearingAidATTListener)
- }
- }
-
LaunchedEffect(amplificationSliderValue.floatValue, balanceSliderValue.floatValue, toneSliderValue.floatValue, conversationBoostEnabled.value, ambientNoiseReductionSliderValue.floatValue, ownVoiceAmplification.floatValue, initialLoadComplete.value, initialReadSucceeded.value) {
if (!initialLoadComplete.value) {
Log.d(TAG, "Initial device load not complete - skipping send")
@@ -256,7 +226,7 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
StyledSlider(
label = stringResource(R.string.amplification),
valueRange = -1f..1f,
- mutableFloatState = amplificationSliderValue,
+ value = amplificationSliderValue.floatValue,
onValueChange = {
amplificationSliderValue.floatValue = it
},
@@ -268,14 +238,15 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
StyledToggle(
label = stringResource(R.string.swipe_to_control_amplification),
- controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE,
+ checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE]?.getOrNull(0) == 0x01.toByte(),
+ onCheckedChange = { viewModel.setControlCommandBoolean(AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE, it) },
description = stringResource(R.string.swipe_amplification_description)
)
StyledSlider(
label = stringResource(R.string.balance),
valueRange = -1f..1f,
- mutableFloatState = balanceSliderValue,
+ value = balanceSliderValue.floatValue,
onValueChange = {
balanceSliderValue.floatValue = it
},
@@ -288,7 +259,7 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
StyledSlider(
label = stringResource(R.string.tone),
valueRange = -1f..1f,
- mutableFloatState = toneSliderValue,
+ value = toneSliderValue.floatValue,
onValueChange = {
toneSliderValue.floatValue = it
},
@@ -300,7 +271,7 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
StyledSlider(
label = stringResource(R.string.ambient_noise_reduction),
valueRange = 0f..1f,
- mutableFloatState = ambientNoiseReductionSliderValue,
+ value = ambientNoiseReductionSliderValue.floatValue,
onValueChange = {
ambientNoiseReductionSliderValue.floatValue = it
},
@@ -311,7 +282,8 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
StyledToggle(
label = stringResource(R.string.conversation_boost),
- checkedState = conversationBoostEnabled,
+ checked = conversationBoostEnabled.value,
+ onCheckedChange = { conversationBoostEnabled.value = it },
independent = true,
description = stringResource(R.string.conversation_boost_description)
)
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidScreen.kt
index b956d96..78c9918 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidScreen.kt
@@ -37,8 +37,9 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
@@ -65,11 +66,11 @@ import me.kavishdevar.librepods.composables.ConfirmationDialog
import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledToggle
-import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse
import me.kavishdevar.librepods.utils.sendTransparencySettings
+import me.kavishdevar.librepods.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi
private const val TAG = "AccessibilitySettings"
@@ -78,23 +79,22 @@ private const val TAG = "AccessibilitySettings"
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
-fun HearingAidScreen(navController: NavController) {
+fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val verticalScrollState = rememberScrollState()
val snackbarHostState = remember { SnackbarHostState() }
- val attManager = ServiceManager.getService()?.attManager ?: return
-
- val aacpManager = remember { ServiceManager.getService()?.aacpManager }
val showDialog = remember { mutableStateOf(false) }
val backdrop = rememberLayerBackdrop()
val initialLoad = remember { mutableStateOf(true) }
+ val state by viewModel.uiState.collectAsState()
+
val hearingAidEnabled = remember {
- val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
- val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
- mutableStateOf((aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte()))
+ val aidStatus = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]
+ val assistStatus = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG]
+ mutableStateOf((aidStatus?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.getOrNull(0) == 0x01.toByte()))
}
val hazeStateS = remember { mutableStateOf(HazeState()) } // dont question this. i could possibly use something other than initializing it with an empty state and then replacing it with the the one provided by the scaffold
@@ -115,41 +115,16 @@ fun HearingAidScreen(navController: NavController) {
hazeStateS.value = hazeState
Spacer(modifier = Modifier.height(spacerHeight))
- val hearingAidListener = remember {
- object : AACPManager.ControlCommandListener {
- override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
- if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value ||
- controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value) {
- val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
- val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
- hearingAidEnabled.value = (aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())
- }
- }
- }
- }
-
// val mediaAssistEnabled = remember { mutableStateOf(false) }
// val adjustMediaEnabled = remember { mutableStateOf(false) }
// val adjustPhoneEnabled = remember { mutableStateOf(false) }
- LaunchedEffect(Unit) {
- aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
- aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
- }
-
- DisposableEffect(Unit) {
- onDispose {
- aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
- aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
- }
- }
-
LaunchedEffect(hearingAidEnabled.value) {
if (hearingAidEnabled.value && !initialLoad.value) {
showDialog.value = true
} else if (!hearingAidEnabled.value && !initialLoad.value) {
- aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x02))
- aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value, 0x02.toByte())
+ viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x02))
+ viewModel.setControlCommandByte(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, 0x02.toByte())
hearingAidEnabled.value = false
}
initialLoad.value = false
@@ -186,7 +161,8 @@ fun HearingAidScreen(navController: NavController) {
) {
StyledToggle(
label = stringResource(R.string.hearing_aid),
- checkedState = hearingAidEnabled,
+ checked = hearingAidEnabled.value,
+ onCheckedChange = { hearingAidEnabled.value = it },
independent = false
)
HorizontalDivider(
@@ -269,20 +245,24 @@ fun HearingAidScreen(navController: NavController) {
dismissText = "Cancel",
onConfirm = {
showDialog.value = false
- val enrolled = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }?.value?.getOrNull(0) == 0x01.toByte()
+ val enrolled = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(0) == 0x01.toByte()
if (!enrolled) {
- aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x01))
+ viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x01))
} else {
- aacpManager.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x01))
+ viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x01))
}
- aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value, 0x01.toByte())
+ viewModel.setControlCommandByte(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, 0x01.toByte())
hearingAidEnabled.value = true
CoroutineScope(Dispatchers.IO).launch {
try {
- val data = attManager.read(ATTHandles.TRANSPARENCY)
+ val data = viewModel.getATTCharacteristicValue(ATTHandles.TRANSPARENCY) ?: byteArrayOf()
+ if (data.isEmpty()) {
+ Log.w(TAG, "read failed")
+ return@launch
+ }
val parsed = parseTransparencySettingsResponse(data)
val disabledSettings = parsed.copy(enabled = false)
- sendTransparencySettings(attManager, disabledSettings)
+ sendTransparencySettings(viewModel::setATTCharacteristicValue, disabledSettings)
} catch (e: Exception) {
Log.e(TAG, "Error disabling transparency: ${e.message}")
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingProtectionScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingProtectionScreen.kt
index bffac6d..9e6cf28 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingProtectionScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingProtectionScreen.kt
@@ -18,48 +18,31 @@
package me.kavishdevar.librepods.screens
-import android.annotation.SuppressLint
-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.height
import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.ExperimentalMaterial3Api
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.unit.dp
-import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
-import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
-import kotlinx.coroutines.Job
+import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledToggle
-import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles
-import kotlin.io.encoding.ExperimentalEncodingApi
+import me.kavishdevar.librepods.viewmodel.AirPodsViewModel
-private var debounceJob: Job? = null
-
-@SuppressLint("DefaultLocale")
-@ExperimentalHazeMaterialsApi
-@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
-fun HearingProtectionScreen(navController: NavController) {
- val isDarkTheme = isSystemInDarkTheme()
- val service = ServiceManager.getService()
- if (service == null) return
-
- val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
- val textColor = if (isDarkTheme) Color.White else Color.Black
-
+fun HearingProtectionScreen(viewModel: AirPodsViewModel) {
val backdrop = rememberLayerBackdrop()
-
+ val state by viewModel.uiState.collectAsState()
StyledScaffold(
title = stringResource(R.string.hearing_protection),
) { spacerHeight ->
@@ -71,20 +54,36 @@ fun HearingProtectionScreen(navController: NavController) {
) {
Spacer(modifier = Modifier.height(spacerHeight))
- StyledToggle(
- title = stringResource(R.string.environmental_noise),
- label = stringResource(R.string.loud_sound_reduction),
- description = stringResource(R.string.loud_sound_reduction_description),
- attHandle = ATTHandles.LOUD_SOUND_REDUCTION
- )
+ if (BuildConfig.FLAVOR == "xposed") {
+ StyledToggle(
+ title = stringResource(R.string.environmental_noise),
+ label = stringResource(R.string.loud_sound_reduction),
+ description = stringResource(R.string.loud_sound_reduction_description),
+ checked = viewModel.getATTCharacteristicValue(ATTHandles.LOUD_SOUND_REDUCTION)
+ ?.get(0)?.toInt() == 1,
+ onCheckedChange = {
+ viewModel.setATTCharacteristicValue(
+ ATTHandles.LOUD_SOUND_REDUCTION,
+ byteArrayOf(if (it) 1.toByte() else 0.toByte())
+ )
+ }
+// attHandle = ATTHandles.LOUD_SOUND_REDUCTION
+ )
- Spacer(modifier = Modifier.height(12.dp))
+ Spacer(modifier = Modifier.height(12.dp))
+ }
StyledToggle(
title = stringResource(R.string.workspace_use),
label = stringResource(R.string.ppe),
description = stringResource(R.string.workspace_use_description),
- controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG
- )
+ checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG]?.getOrNull(
+ 0
+ )?.toInt() == 1,
+ onCheckedChange = {
+ viewModel.setControlCommandBoolean(
+ AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG, it
+ )
+ })
}
}
-}
\ No newline at end of file
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/PressAndHoldSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/PressAndHoldSettingsScreen.kt
index cc20647..096631c 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/PressAndHoldSettingsScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/PressAndHoldSettingsScreen.kt
@@ -22,37 +22,25 @@ package me.kavishdevar.librepods.screens
import android.content.Context
import android.util.Log
-import androidx.compose.animation.animateColorAsState
-import androidx.compose.animation.core.animateFloatAsState
-import androidx.compose.animation.core.tween
-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.Column
-import androidx.compose.foundation.layout.Row
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.foundation.layout.wrapContentWidth
-import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.HorizontalDivider
-import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
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.graphics.Color
-import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
@@ -61,59 +49,38 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.edit
-import androidx.navigation.NavController
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.composables.SelectItem
-import me.kavishdevar.librepods.composables.StyledIconButton
+import me.kavishdevar.librepods.composables.StyledButton
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSelectList
import me.kavishdevar.librepods.constants.StemAction
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
+import me.kavishdevar.librepods.viewmodel.AirPodsViewModel
import kotlin.experimental.and
import kotlin.io.encoding.ExperimentalEncodingApi
-@Composable
-fun RightDivider() {
- HorizontalDivider(
- thickness = 1.dp,
- color = Color(0x40888888),
- modifier = Modifier
- .padding(start = 72.dp, end = 20.dp)
- )
-}
-
-@Composable
-fun RightDividerNoIcon() {
- HorizontalDivider(
- thickness = 1.dp,
- color = Color(0x40888888),
- modifier = Modifier
- .padding(start = 20.dp, end = 20.dp)
- )
-}
-
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun LongPress(navController: NavController, name: String) {
+fun LongPress(viewModel: AirPodsViewModel, name: String) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
- val modesByte = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
- it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
- }?.value?.takeIf { it.isNotEmpty() }?.get(0)
+ val state by viewModel.uiState.collectAsState()
+
+ val modesByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]?.get(0) ?: 0
+
+ Log.d("PressAndHoldSettingsScreen", "Current modes state: ${modesByte.toString(2)}")
+ Log.d("PressAndHoldSettingsScreen", "Off mode: ${(modesByte and 0x01) != 0.toByte()}")
+ Log.d("PressAndHoldSettingsScreen", "Transparency mode: ${(modesByte and 0x04) != 0.toByte()}")
+ Log.d("PressAndHoldSettingsScreen", "Noise Cancellation mode: ${(modesByte and 0x02) != 0.toByte()}")
+ Log.d("PressAndHoldSettingsScreen", "Adaptive mode: ${(modesByte and 0x08) != 0.toByte()}")
- if (modesByte != null) {
- Log.d("PressAndHoldSettingsScreen", "Current modes state: ${modesByte.toString(2)}")
- Log.d("PressAndHoldSettingsScreen", "Off mode: ${(modesByte and 0x01) != 0.toByte()}")
- Log.d("PressAndHoldSettingsScreen", "Transparency mode: ${(modesByte and 0x04) != 0.toByte()}")
- Log.d("PressAndHoldSettingsScreen", "Noise Cancellation mode: ${(modesByte and 0x02) != 0.toByte()}")
- Log.d("PressAndHoldSettingsScreen", "Adaptive mode: ${(modesByte and 0x08) != 0.toByte()}")
- }
val context = LocalContext.current
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
val prefKey = if (name.lowercase() == "left") "left_long_press_action" else "right_long_press_action"
@@ -124,9 +91,8 @@ fun LongPress(navController: NavController, name: String) {
StyledScaffold(
title = name
) { spacerHeight ->
- val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
Column (
- modifier = Modifier
+ modifier = Modifier
.layerBackdrop(backdrop)
.fillMaxSize()
.padding(top = 8.dp)
@@ -148,11 +114,36 @@ fun LongPress(navController: NavController, name: String) {
onClick = {
longPressAction = StemAction.DIGITAL_ASSISTANT
sharedPreferences.edit { putString(prefKey, StemAction.DIGITAL_ASSISTANT.name) }
- }
+ },
+ enabled = state.isPremium
)
)
StyledSelectList(items = actionItems)
+ if (!state.isPremium) {
+ Spacer(modifier = Modifier.height(24.dp))
+ StyledButton(
+ onClick = {
+ viewModel.purchase(context)
+ },
+ backdrop = rememberLayerBackdrop(),
+ modifier = Modifier.fillMaxWidth(),
+ maxScale = 0.05f,
+ tint = Color(0xFF916100)
+ ) {
+ Text(
+ stringResource(R.string.unlock_all_features),
+ style = TextStyle(
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Medium,
+ fontFamily = FontFamily(Font(R.font.sf_pro)),
+ color = textColor
+ ),
+ )
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+
if (longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES) {
Spacer(modifier = Modifier.height(32.dp))
Text(
@@ -176,10 +167,11 @@ fun LongPress(navController: NavController, name: String) {
val allowOff = offListeningModeValue == 1.toByte()
Log.d("PressAndHoldSettingsScreen", "Allow Off option: $allowOff")
- val initialByte = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
- it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
- }?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toInt() ?: sharedPreferences.getInt("long_press_byte", 0b0101)
- var currentByte by remember { mutableStateOf(initialByte) }
+ val initialByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]
+ ?.get(0)?.toInt()
+ ?: sharedPreferences.getInt("long_press_byte", 0b0101)
+
+ var currentByte by remember { mutableIntStateOf(initialByte) }
val listeningModeItems = mutableListOf()
if (allowOff) {
@@ -197,8 +189,8 @@ fun LongPress(navController: NavController, name: String) {
} else {
currentByte or bit
}
- ServiceManager.getService()!!.aacpManager.sendControlCommand(
- AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
+ viewModel.setControlCommandByte(
+ AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS,
newValue.toByte()
)
sharedPreferences.edit {
@@ -223,8 +215,8 @@ fun LongPress(navController: NavController, name: String) {
} else {
currentByte or bit
}
- ServiceManager.getService()!!.aacpManager.sendControlCommand(
- AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
+ viewModel.setControlCommandByte(
+ AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS,
newValue.toByte()
)
sharedPreferences.edit {
@@ -246,8 +238,8 @@ fun LongPress(navController: NavController, name: String) {
} else {
currentByte or bit
}
- ServiceManager.getService()!!.aacpManager.sendControlCommand(
- AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
+ viewModel.setControlCommandByte(
+ AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS,
newValue.toByte()
)
sharedPreferences.edit {
@@ -269,8 +261,8 @@ fun LongPress(navController: NavController, name: String) {
} else {
currentByte or bit
}
- ServiceManager.getService()!!.aacpManager.sendControlCommand(
- AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value,
+ viewModel.setControlCommandByte(
+ AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS,
newValue.toByte()
)
sharedPreferences.edit {
@@ -296,9 +288,7 @@ fun LongPress(navController: NavController, name: String) {
}
}
}
- Log.d("PressAndHoldSettingsScreen", "Current byte: ${ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find {
- it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
- }?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toString(2)}")
+ Log.d("PressAndHoldSettingsScreen", "Current byte: ${modesByte.toString(2)}")
}
fun countEnabledModes(byteValue: Int): Int {
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/RenameScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/RenameScreen.kt
index 95d412e..829bdd6 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/RenameScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/RenameScreen.kt
@@ -53,26 +53,22 @@ 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.input.TextFieldValue
-import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.edit
-import androidx.navigation.NavController
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.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold
-import me.kavishdevar.librepods.services.ServiceManager
+import me.kavishdevar.librepods.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@Composable
-fun RenameScreen(navController: NavController) {
+fun RenameScreen(viewModel: AirPodsViewModel) {
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
- val isDarkTheme = isSystemInDarkTheme()
val name = remember { mutableStateOf(TextFieldValue(sharedPreferences.getString("name", "") ?: "")) }
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
@@ -115,7 +111,7 @@ fun RenameScreen(navController: NavController) {
onValueChange = {
name.value = it
sharedPreferences.edit {putString("name", it.text)}
- ServiceManager.getService()?.setName(it.text)
+ viewModel.setName(it.text)
},
textStyle = TextStyle(
fontSize = 16.sp,
@@ -159,9 +155,3 @@ fun RenameScreen(navController: NavController) {
}
}
}
-
-@Preview
-@Composable
-fun RenameScreenPreview() {
- RenameScreen(navController = NavController(LocalContext.current))
-}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/TransparencySettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/TransparencySettingsScreen.kt
index 8c28ba6..06f2614 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/TransparencySettingsScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/TransparencySettingsScreen.kt
@@ -18,6 +18,7 @@
package me.kavishdevar.librepods.screens
+// import me.kavishdevar.librepods.utils.RadareOffsetFinder
import android.annotation.SuppressLint
import android.util.Log
import androidx.compose.foundation.background
@@ -43,6 +44,8 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@@ -58,23 +61,22 @@ 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 dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.delay
+import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R
-import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider
import me.kavishdevar.librepods.composables.StyledToggle
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.ATTHandles
-// import me.kavishdevar.librepods.utils.RadareOffsetFinder
import me.kavishdevar.librepods.utils.TransparencySettings
import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse
import me.kavishdevar.librepods.utils.sendTransparencySettings
+import me.kavishdevar.librepods.viewmodel.AirPodsViewModel
import java.io.IOException
import kotlin.io.encoding.ExperimentalEncodingApi
@@ -84,14 +86,12 @@ private const val TAG = "TransparencySettings"
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
-fun TransparencySettingsScreen(navController: NavController) {
+fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val verticalScrollState = rememberScrollState()
+
val attManager = ServiceManager.getService()?.attManager ?: return
- val aacpManager = remember { ServiceManager.getService()?.aacpManager }
- val isSdpOffsetAvailable = remember { mutableStateOf(false) } // always available rn, for testing without radare
-// remember { mutableStateOf(RadareOffsetFinder.isSdpOffsetAvailable()) }
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
@@ -99,6 +99,9 @@ fun TransparencySettingsScreen(navController: NavController) {
val backdrop = rememberLayerBackdrop()
+
+ val state by viewModel.uiState.collectAsState()
+
StyledScaffold(
title = stringResource(R.string.customize_transparency_mode)
){ spacerHeight, hazeState ->
@@ -205,7 +208,7 @@ fun TransparencySettingsScreen(navController: NavController) {
balance = balanceSliderValue.floatValue
)
Log.d("TransparencySettings", "Updated settings: ${transparencySettings.value}")
- sendTransparencySettings(attManager, transparencySettings.value)
+ sendTransparencySettings(viewModel::setATTCharacteristicValue, transparencySettings.value)
}
DisposableEffect(Unit) {
@@ -222,18 +225,14 @@ fun TransparencySettingsScreen(navController: NavController) {
// If we have an AACP manager, prefer its EQ data to populate EQ controls first
try {
- if (aacpManager != null) {
- Log.d(TAG, "Found AACPManager, reading cached EQ data")
- val aacpEQ = aacpManager.eqData
- if (aacpEQ.isNotEmpty()) {
- eq.value = aacpEQ.copyOf()
- phoneMediaEQ.value = aacpEQ.copyOf()
- Log.d(TAG, "Populated EQ from AACPManager: ${aacpEQ.toList()}")
- } else {
- Log.d(TAG, "AACPManager EQ data empty")
- }
+ Log.d(TAG, "Found AACPManager, reading cached EQ data")
+ val aacpEQ = state.eqData
+ if (aacpEQ.isNotEmpty()) {
+ eq.value = aacpEQ.copyOf()
+ phoneMediaEQ.value = aacpEQ.copyOf()
+ Log.d(TAG, "Populated EQ from AACPManager: ${aacpEQ.toList()}")
} else {
- Log.d(TAG, "No AACPManager available")
+ Log.d(TAG, "AACPManager EQ data empty")
}
} catch (e: Exception) {
Log.w(TAG, "Error reading EQ from AACPManager: ${e.message}")
@@ -277,18 +276,19 @@ fun TransparencySettingsScreen(navController: NavController) {
}
// Only show transparency mode section if SDP offset is available
- if (isSdpOffsetAvailable.value) {
+ if (BuildConfig.FLAVOR == "xposed") {
StyledToggle(
label = stringResource(R.string.transparency_mode),
- checkedState = enabled,
+ checked = enabled.value,
independent = true,
- description = stringResource(R.string.customize_transparency_mode_description)
+ description = stringResource(R.string.customize_transparency_mode_description),
+ onCheckedChange = { enabled.value = it }
)
Spacer(modifier = Modifier.height(4.dp))
StyledSlider(
label = stringResource(R.string.amplification),
valueRange = -1f..1f,
- mutableFloatState = amplificationSliderValue,
+ value = amplificationSliderValue.floatValue,
onValueChange = {
amplificationSliderValue.floatValue = it
},
@@ -300,7 +300,7 @@ fun TransparencySettingsScreen(navController: NavController) {
StyledSlider(
label = stringResource(R.string.balance),
valueRange = -1f..1f,
- mutableFloatState = balanceSliderValue,
+ value = balanceSliderValue.floatValue,
onValueChange = {
balanceSliderValue.floatValue = it
},
@@ -313,7 +313,7 @@ fun TransparencySettingsScreen(navController: NavController) {
StyledSlider(
label = stringResource(R.string.tone),
valueRange = -1f..1f,
- mutableFloatState = toneSliderValue,
+ value = toneSliderValue.floatValue,
onValueChange = {
toneSliderValue.floatValue = it
},
@@ -325,7 +325,7 @@ fun TransparencySettingsScreen(navController: NavController) {
StyledSlider(
label = stringResource(R.string.ambient_noise_reduction),
valueRange = 0f..1f,
- mutableFloatState = ambientNoiseReductionSliderValue,
+ value = ambientNoiseReductionSliderValue.floatValue,
onValueChange = {
ambientNoiseReductionSliderValue.floatValue = it
},
@@ -336,14 +336,12 @@ fun TransparencySettingsScreen(navController: NavController) {
StyledToggle(
label = stringResource(R.string.conversation_boost),
- checkedState = conversationBoostEnabled,
+ checked = conversationBoostEnabled.value,
independent = true,
- description = stringResource(R.string.conversation_boost_description)
+ description = stringResource(R.string.conversation_boost_description),
+ onCheckedChange = { conversationBoostEnabled.value = it }
)
- }
- // Only show transparency mode EQ section if SDP offset is available
- if (isSdpOffsetAvailable.value) {
Text(
text = stringResource(R.string.equalizer),
style = TextStyle(
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/UpdateHearingTestScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/UpdateHearingTestScreen.kt
index 89e0791..00bcbdb 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/UpdateHearingTestScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/UpdateHearingTestScreen.kt
@@ -55,7 +55,6 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
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 dev.chrisbanes.haze.hazeSource
@@ -75,11 +74,8 @@ import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: MutableState = mutableStateOf(null)
private const val TAG = "HearingAidAdjustments"
-@SuppressLint("DefaultLocale")
-@ExperimentalHazeMaterialsApi
-@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
-fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
+fun UpdateHearingTestScreen() {
val verticalScrollState = rememberScrollState()
val attManager = ServiceManager.getService()?.attManager
if (attManager == null) {
@@ -138,17 +134,17 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
HearingAidSettings(
leftEQ = leftEQ.value,
rightEQ = rightEQ.value,
- leftAmplification = leftAmplification.value,
- rightAmplification = rightAmplification.value,
- leftTone = tone.value,
- rightTone = tone.value,
+ leftAmplification = leftAmplification.floatValue,
+ rightAmplification = rightAmplification.floatValue,
+ leftTone = tone.floatValue,
+ rightTone = tone.floatValue,
leftConversationBoost = conversationBoostEnabled.value,
rightConversationBoost = conversationBoostEnabled.value,
- leftAmbientNoiseReduction = ambientNoiseReduction.value,
- rightAmbientNoiseReduction = ambientNoiseReduction.value,
- netAmplification = leftAmplification.value + rightAmplification.value / 2,
- balance = 0.5f + (rightAmplification.value - leftAmplification.value) / 2,
- ownVoiceAmplification = ownVoiceAmplification.value
+ leftAmbientNoiseReduction = ambientNoiseReduction.floatValue,
+ rightAmbientNoiseReduction = ambientNoiseReduction.floatValue,
+ netAmplification = leftAmplification.floatValue + rightAmplification.floatValue / 2,
+ balance = 0.5f + (rightAmplification.floatValue - leftAmplification.floatValue) / 2,
+ ownVoiceAmplification = ownVoiceAmplification.floatValue
)
)
}
@@ -161,11 +157,11 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
leftEQ.value = parsed.leftEQ.copyOf()
rightEQ.value = parsed.rightEQ.copyOf()
conversationBoostEnabled.value = parsed.leftConversationBoost
- tone.value = parsed.leftTone
- ambientNoiseReduction.value = parsed.leftAmbientNoiseReduction
- ownVoiceAmplification.value = parsed.ownVoiceAmplification
- leftAmplification.value = parsed.leftAmplification
- rightAmplification.value = parsed.rightAmplification
+ tone.floatValue = parsed.leftTone
+ ambientNoiseReduction.floatValue = parsed.leftAmbientNoiseReduction
+ ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
+ leftAmplification.floatValue = parsed.leftAmplification
+ rightAmplification.floatValue = parsed.rightAmplification
Log.d(TAG, "Updated hearing aid settings from notification")
} else {
Log.w(TAG, "Failed to parse hearing aid settings from notification")
@@ -181,31 +177,45 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
}
}
- LaunchedEffect(leftEQ.value, rightEQ.value, conversationBoostEnabled.value, initialLoadComplete.value, initialReadSucceeded.value, leftAmplification.value, rightAmplification.value, tone.value, ambientNoiseReduction.value, ownVoiceAmplification.value) {
+ LaunchedEffect(
+ leftEQ.value,
+ rightEQ.value,
+ conversationBoostEnabled.value,
+ initialLoadComplete.value,
+ initialReadSucceeded.value,
+ leftAmplification.floatValue,
+ rightAmplification.floatValue,
+ tone.floatValue,
+ ambientNoiseReduction.floatValue,
+ ownVoiceAmplification.floatValue
+ ) {
if (!initialLoadComplete.value) {
Log.d(TAG, "Initial device load not complete - skipping send")
return@LaunchedEffect
}
if (!initialReadSucceeded.value) {
- Log.d(TAG, "Initial device read not successful yet - skipping send until read succeeds")
+ Log.d(
+ TAG,
+ "Initial device read not successful yet - skipping send until read succeeds"
+ )
return@LaunchedEffect
}
hearingAidSettings.value = HearingAidSettings(
leftEQ = leftEQ.value,
rightEQ = rightEQ.value,
- leftAmplification = leftAmplification.value,
- rightAmplification = rightAmplification.value,
- leftTone = tone.value,
- rightTone = tone.value,
+ leftAmplification = leftAmplification.floatValue,
+ rightAmplification = rightAmplification.floatValue,
+ leftTone = tone.floatValue,
+ rightTone = tone.floatValue,
leftConversationBoost = conversationBoostEnabled.value,
rightConversationBoost = conversationBoostEnabled.value,
- leftAmbientNoiseReduction = ambientNoiseReduction.value,
- rightAmbientNoiseReduction = ambientNoiseReduction.value,
- netAmplification = leftAmplification.value + rightAmplification.value / 2,
- balance = 0.5f + (rightAmplification.value - leftAmplification.value) / 2,
- ownVoiceAmplification = ownVoiceAmplification.value
+ leftAmbientNoiseReduction = ambientNoiseReduction.floatValue,
+ rightAmbientNoiseReduction = ambientNoiseReduction.floatValue,
+ netAmplification = leftAmplification.floatValue + rightAmplification.floatValue / 2,
+ balance = 0.5f + (rightAmplification.floatValue - leftAmplification.floatValue) / 2,
+ ownVoiceAmplification = ownVoiceAmplification.floatValue
)
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob)
@@ -240,14 +250,17 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
leftEQ.value = parsedSettings.leftEQ.copyOf()
rightEQ.value = parsedSettings.rightEQ.copyOf()
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
- tone.value = parsedSettings.leftTone
- ambientNoiseReduction.value = parsedSettings.leftAmbientNoiseReduction
- ownVoiceAmplification.value = parsedSettings.ownVoiceAmplification
- leftAmplification.value = parsedSettings.leftAmplification
- rightAmplification.value = parsedSettings.rightAmplification
+ tone.floatValue = parsedSettings.leftTone
+ ambientNoiseReduction.floatValue = parsedSettings.leftAmbientNoiseReduction
+ ownVoiceAmplification.floatValue = parsedSettings.ownVoiceAmplification
+ leftAmplification.floatValue = parsedSettings.leftAmplification
+ rightAmplification.floatValue = parsedSettings.rightAmplification
initialReadSucceeded.value = true
} else {
- Log.d(TAG, "Failed to read/parse initial hearing aid settings after ${initialReadAttempts.intValue} attempts")
+ Log.d(
+ TAG,
+ "Failed to read/parse initial hearing aid settings after ${initialReadAttempts.intValue} attempts"
+ )
}
} catch (e: IOException) {
e.printStackTrace()
@@ -256,7 +269,8 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
}
}
- val frequencies = listOf("250Hz", "500Hz", "1kHz", "2kHz", "3kHz", "4kHz", "6kHz", "8kHz")
+ val frequencies =
+ listOf("250Hz", "500Hz", "1kHz", "2kHz", "3kHz", "4kHz", "6kHz", "8kHz")
Row(
modifier = Modifier.fillMaxWidth(),
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/VersionInfoScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/VersionInfoScreen.kt
index a0ea75e..feadafd 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/VersionInfoScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/VersionInfoScreen.kt
@@ -19,22 +19,22 @@
package me.kavishdevar.librepods.screens
import androidx.compose.foundation.background
-import android.annotation.SuppressLint
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.Spacer
import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxWidth
+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.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
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.draw.clip
import androidx.compose.ui.graphics.Color
@@ -45,36 +45,23 @@ 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 dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
-import kotlinx.coroutines.Job
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledScaffold
-import me.kavishdevar.librepods.services.ServiceManager
-import kotlin.io.encoding.ExperimentalEncodingApi
+import me.kavishdevar.librepods.viewmodel.AirPodsViewModel
-private var debounceJob: Job? = null
-
-@SuppressLint("DefaultLocale")
-@ExperimentalHazeMaterialsApi
-@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
-fun VersionScreen(navController: NavController) {
+fun VersionScreen(viewModel: AirPodsViewModel) {
+ val state by viewModel.uiState.collectAsState()
val isDarkTheme = isSystemInDarkTheme()
- val service = ServiceManager.getService()
- if (service == null) return
- val airpodsInstance = service.airpodsInstance
- if (airpodsInstance == null) return
-
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
val backdrop = rememberLayerBackdrop()
StyledScaffold(
- title = stringResource(R.string.customize_adaptive_audio)
+ title = stringResource(R.string.version)
) { spacerHeight ->
Column(
modifier = Modifier
@@ -120,7 +107,7 @@ fun VersionScreen(navController: NavController) {
)
)
Text(
- text = airpodsInstance.version1 ?: "N/A",
+ text = state.version1,
style = TextStyle(
fontSize = 16.sp,
color = textColor.copy(0.8f),
@@ -149,7 +136,7 @@ fun VersionScreen(navController: NavController) {
)
)
Text(
- text = airpodsInstance.version2 ?: "N/A",
+ text = state.version2,
style = TextStyle(
fontSize = 16.sp,
color = textColor.copy(0.8f),
@@ -178,7 +165,7 @@ fun VersionScreen(navController: NavController) {
)
)
Text(
- text = airpodsInstance.version3 ?: "N/A",
+ text = state.version3,
style = TextStyle(
fontSize = 16.sp,
color = textColor.copy(0.8f),
@@ -189,4 +176,4 @@ fun VersionScreen(navController: NavController) {
}
}
}
-}
\ No newline at end of file
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsQSService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsQSService.kt
index 1fc6fce..4d20339 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsQSService.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsQSService.kt
@@ -244,8 +244,10 @@ class AirPodsQSService : TileService() {
private fun getNextAncMode(): Int {
val availableModes = getAvailableModes()
+ Log.d("AirPodsQSService", "availableModes: $availableModes, currentAncMode: $currentAncMode")
val currentIndex = availableModes.indexOf(currentAncMode)
val nextIndex = (currentIndex + 1) % availableModes.size
+ Log.d("AirPodsQSService", "nextIndex: $nextIndex")
return availableModes[nextIndex]
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt
index 2aff354..b7b9b12 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt
@@ -16,11 +16,12 @@
along with this program. If not, see .
*/
-@file:OptIn(ExperimentalEncodingApi::class)
-@file:Suppress("DEPRECATION")
+@file:OptIn(ExperimentalEncodingApi::class) @file:Suppress("DEPRECATION")
package me.kavishdevar.librepods.services
+//import me.kavishdevar.librepods.utils.CrossDevice
+//import me.kavishdevar.librepods.utils.CrossDevicePackets
import android.Manifest
import android.annotation.SuppressLint
import android.app.Notification
@@ -79,6 +80,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeout
+import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.MainActivity
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.AirPodsNotifications
@@ -94,8 +96,6 @@ import me.kavishdevar.librepods.utils.AirPodsInstance
import me.kavishdevar.librepods.utils.AirPodsModels
import me.kavishdevar.librepods.utils.BLEManager
import me.kavishdevar.librepods.utils.BluetoothConnectionManager
-//import me.kavishdevar.librepods.utils.CrossDevice
-//import me.kavishdevar.librepods.utils.CrossDevicePackets
import me.kavishdevar.librepods.utils.GestureDetector
import me.kavishdevar.librepods.utils.HeadTracking
import me.kavishdevar.librepods.utils.IslandType
@@ -127,21 +127,17 @@ import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
-import kotlin.jvm.java
private const val TAG = "AirPodsService"
object ServiceManager {
- @ExperimentalEncodingApi
private var service: AirPodsService? = null
- @ExperimentalEncodingApi
@Synchronized
fun getService(): AirPodsService? {
return service
}
- @ExperimentalEncodingApi
@Synchronized
fun setService(service: AirPodsService?) {
this.service = service
@@ -149,7 +145,6 @@ object ServiceManager {
}
// @Suppress("unused")
-@ExperimentalEncodingApi
class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeListener {
var macAddress = ""
var localMac = ""
@@ -159,6 +154,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
var cameraActive = false
private var disconnectedBecauseReversed = false
private var otherDeviceTookOver = false
+
data class ServiceConfig(
var deviceName: String = "AirPods",
var earDetectionEnabled: Boolean = true,
@@ -237,38 +233,31 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
companion object {
init {
- System.loadLibrary("socket_private_constructor")
+ System.loadLibrary("bluetooth_socket")
}
}
private val bleStatusListener = object : BLEManager.AirPodsStatusListener {
@SuppressLint("NewApi")
override fun onDeviceStatusChanged(
- device: BLEManager.AirPodsStatus,
- previousStatus: BLEManager.AirPodsStatus?
+ device: BLEManager.AirPodsStatus, previousStatus: BLEManager.AirPodsStatus?
) {
- // Store MAC address for BLE-only mode if not already stored
- if (config.bleOnlyMode && macAddress.isEmpty()) {
- macAddress = device.address
- sharedPreferences.edit {
- putString("mac_address", macAddress)
- }
- Log.d(TAG, "BLE-only mode: stored MAC address ${device.address}")
- }
-
- if (device.connectionState == "Disconnected" && !config.bleOnlyMode) {
+ if (device.connectionState == "Disconnected" && !isConnected()) { // should never happen unless android messes up and sends us a stale broadcast
Log.d(TAG, "Seems no device has taken over, we will.")
val bluetoothManager = getSystemService(BluetoothManager::class.java)
val bluetoothAdapter = bluetoothManager.adapter
- val bluetoothDevice = bluetoothAdapter.getRemoteDevice(sharedPreferences.getString(
- "mac_address", "") ?: "")
+ val bluetoothDevice = bluetoothAdapter.getRemoteDevice(
+ sharedPreferences.getString(
+ "mac_address", ""
+ ) ?: ""
+ )
connectToSocket(bluetoothAdapter, bluetoothDevice)
}
Log.d(TAG, "Device status changed")
- if (isConnectedLocally) return
- val leftLevel = bleManager.getMostRecentStatus()?.leftBattery?: 0
- val rightLevel = bleManager.getMostRecentStatus()?.rightBattery?: 0
- val caseLevel = bleManager.getMostRecentStatus()?.caseBattery?: 0
+ if (socket.isConnected) return
+ val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0
+ val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0
+ val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0
val leftCharging = bleManager.getMostRecentStatus()?.isLeftCharging
val rightCharging = bleManager.getMostRecentStatus()?.isRightCharging
val caseCharging = bleManager.getMostRecentStatus()?.isCaseCharging
@@ -295,12 +284,13 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
Log.d(TAG, "Lid opened")
showPopup(
this@AirPodsService,
- getSharedPreferences("settings", MODE_PRIVATE).getString("name", "AirPods Pro") ?: "AirPods"
+ getSharedPreferences("settings", MODE_PRIVATE).getString("name", "AirPods Pro")
+ ?: "AirPods"
)
- if (isConnectedLocally) return
- val leftLevel = bleManager.getMostRecentStatus()?.leftBattery?: 0
- val rightLevel = bleManager.getMostRecentStatus()?.rightBattery?: 0
- val caseLevel = bleManager.getMostRecentStatus()?.caseBattery?: 0
+ if (socket.isConnected) return
+ val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0
+ val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0
+ val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0
val leftCharging = bleManager.getMostRecentStatus()?.isLeftCharging
val rightCharging = bleManager.getMostRecentStatus()?.isRightCharging
val caseCharging = bleManager.getMostRecentStatus()?.isCaseCharging
@@ -320,9 +310,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
override fun onEarStateChanged(
- device: BLEManager.AirPodsStatus,
- leftInEar: Boolean,
- rightInEar: Boolean
+ device: BLEManager.AirPodsStatus, leftInEar: Boolean, rightInEar: Boolean
) {
Log.d(TAG, "Ear state changed - Left: $leftInEar, Right: $rightInEar")
@@ -333,10 +321,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
override fun onBatteryChanged(device: BLEManager.AirPodsStatus) {
- if (isConnectedLocally) return
- val leftLevel = bleManager.getMostRecentStatus()?.leftBattery?: 0
- val rightLevel = bleManager.getMostRecentStatus()?.rightBattery?: 0
- val caseLevel = bleManager.getMostRecentStatus()?.caseBattery?: 0
+ if (socket.isConnected) return
+ val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0
+ val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0
+ val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0
val leftCharging = bleManager.getMostRecentStatus()?.isLeftCharging
val rightCharging = bleManager.getMostRecentStatus()?.isRightCharging
val caseCharging = bleManager.getMostRecentStatus()?.isCaseCharging
@@ -379,7 +367,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
sharedPreferencesLogs = getSharedPreferences("packet_logs", MODE_PRIVATE)
- inMemoryLogs.addAll(sharedPreferencesLogs.getStringSet(packetLogKey, emptySet()) ?: emptySet())
+ inMemoryLogs.addAll(
+ sharedPreferencesLogs.getStringSet(packetLogKey, emptySet()) ?: emptySet()
+ )
_packetLogsFlow.value = inMemoryLogs.toSet()
sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE)
@@ -392,21 +382,26 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
localMac = config.selfMacAddress
if (localMac.isEmpty()) {
- localMac = try {
- val process = Runtime.getRuntime().exec(
- arrayOf("su", "-c", "settings get secure bluetooth_address")
- )
+ if (BuildConfig.FLAVOR == "xposed") {
+ localMac = try {
+ val process = Runtime.getRuntime().exec(
+ arrayOf("su", "-c", "settings get secure bluetooth_address")
+ )
- val exitCode = process.waitFor()
+ val exitCode = process.waitFor()
- if (exitCode == 0) {
- process.inputStream.bufferedReader().use { it.readLine()?.trim().orEmpty() }
- } else {
+ if (exitCode == 0) {
+ process.inputStream.bufferedReader().use { it.readLine()?.trim().orEmpty() }
+ } else {
+ ""
+ }
+ } catch (e: Exception) {
+ Log.e(
+ TAG,
+ "Error retrieving local MAC address: ${e.message}. We probably aren't rooted."
+ )
""
}
- } catch (e: Exception) {
- Log.e(TAG, "Error retrieving local MAC address: ${e.message}. We probably aren't rooted.")
- ""
}
config.selfMacAddress = localMac
sharedPreferences.edit {
@@ -433,31 +428,25 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
with(sharedPreferences) {
edit {
if (!contains("conversational_awareness_pause_music")) putBoolean(
- "conversational_awareness_pause_music",
- false
+ "conversational_awareness_pause_music", false
)
if (!contains("personalized_volume")) putBoolean("personalized_volume", false)
if (!contains("automatic_ear_detection")) putBoolean(
- "automatic_ear_detection",
- true
+ "automatic_ear_detection", true
)
if (!contains("long_press_nc")) putBoolean("long_press_nc", true)
if (!contains("show_phone_battery_in_widget")) putBoolean(
- "show_phone_battery_in_widget",
- true
+ "show_phone_battery_in_widget", true
)
if (!contains("single_anc")) putBoolean("single_anc", true)
if (!contains("long_press_transparency")) putBoolean(
- "long_press_transparency",
- true
+ "long_press_transparency", true
)
if (!contains("conversational_awareness")) putBoolean(
- "conversational_awareness",
- true
+ "conversational_awareness", true
)
if (!contains("relative_conversational_awareness_volume")) putBoolean(
- "relative_conversational_awareness_volume",
- true
+ "relative_conversational_awareness_volume", true
)
if (!contains("long_press_adaptive")) putBoolean("long_press_adaptive", true)
if (!contains("loud_sound_reduction")) putBoolean("loud_sound_reduction", true)
@@ -465,34 +454,29 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
if (!contains("volume_control")) putBoolean("volume_control", true)
if (!contains("head_gestures")) putBoolean("head_gestures", true)
if (!contains("disconnect_when_not_wearing")) putBoolean(
- "disconnect_when_not_wearing",
- false
+ "disconnect_when_not_wearing", false
)
// AirPods state-based takeover
if (!contains("takeover_when_disconnected")) putBoolean(
- "takeover_when_disconnected",
- true
+ "takeover_when_disconnected", false
)
- if (!contains("takeover_when_idle")) putBoolean("takeover_when_idle", true)
+ if (!contains("takeover_when_idle")) putBoolean("takeover_when_idle", false)
if (!contains("takeover_when_music")) putBoolean("takeover_when_music", false)
- if (!contains("takeover_when_call")) putBoolean("takeover_when_call", true)
+ if (!contains("takeover_when_call")) putBoolean("takeover_when_call", false)
// Phone state-based takeover
if (!contains("takeover_when_ringing_call")) putBoolean(
- "takeover_when_ringing_call",
- true
+ "takeover_when_ringing_call", false
)
if (!contains("takeover_when_media_start")) putBoolean(
- "takeover_when_media_start",
- true
+ "takeover_when_media_start", false
)
if (!contains("adaptive_strength")) putInt("adaptive_strength", 51)
if (!contains("tone_volume")) putInt("tone_volume", 75)
if (!contains("conversational_awareness_volume")) putInt(
- "conversational_awareness_volume",
- 43
+ "conversational_awareness_volume", 43
)
if (!contains("qs_click_behavior")) putString("qs_click_behavior", "cycle")
@@ -550,8 +534,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
} else {
val currentMode = ancNotification.status
- val allowOffModeValue = aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION }
- val allowOffMode = allowOffModeValue?.value?.takeIf { it.isNotEmpty() }?.get(0) == 0x01.toByte()
+ val allowOffModeValue =
+ aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION }
+ val allowOffMode = allowOffModeValue?.value?.takeIf { it.isNotEmpty() }
+ ?.get(0) == 0x01.toByte()
val nextMode = if (allowOffMode) {
when (currentMode) {
@@ -575,7 +561,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value,
nextMode
)
- Log.d(TAG, "Cycling ANC mode from $currentMode to $nextMode (offListeningMode: $allowOffMode)")
+ Log.d(
+ TAG,
+ "Cycling ANC mode from $currentMode to $nextMode (offListeningMode: $allowOffMode)"
+ )
}
}
}
@@ -584,16 +573,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(ancModeReceiver, ancModeFilter, RECEIVER_EXPORTED)
} else {
- @Suppress("UnspecifiedRegisterReceiverFlag")
- registerReceiver(ancModeReceiver, ancModeFilter)
+ @Suppress("UnspecifiedRegisterReceiverFlag") registerReceiver(
+ ancModeReceiver, ancModeFilter
+ )
}
- val audioManager =
- this@AirPodsService.getSystemService(AUDIO_SERVICE) as AudioManager
+ val audioManager = this@AirPodsService.getSystemService(AUDIO_SERVICE) as AudioManager
MediaController.initialize(
- audioManager,
- this@AirPodsService.getSharedPreferences(
- "settings",
- MODE_PRIVATE
+ audioManager, this@AirPodsService.getSharedPreferences(
+ "settings", MODE_PRIVATE
)
)
// Log.d(TAG, "Initializing CrossDevice")
@@ -607,12 +594,13 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
telephonyManager = getSystemService(TELEPHONY_SERVICE) as TelephonyManager
phoneStateListener = object : PhoneStateListener() {
- @SuppressLint("SwitchIntDef", "NewApi")
+ @Deprecated("Deprecated in Java")
override fun onCallStateChanged(state: Int, phoneNumber: String?) {
super.onCallStateChanged(state, phoneNumber)
when (state) {
TelephonyManager.CALL_STATE_RINGING -> {
- val leAvailableForAudio = bleManager.getMostRecentStatus()?.isLeftInEar == true || bleManager.getMostRecentStatus()?.isRightInEar == true
+ val leAvailableForAudio =
+ bleManager.getMostRecentStatus()?.isLeftInEar == true || bleManager.getMostRecentStatus()?.isRightInEar == true
// if ((CrossDevice.isAvailable && !isConnectedLocally && earDetectionNotification.status.contains(0x00)) || leAvailableForAudio) CoroutineScope(Dispatchers.IO).launch {
if (leAvailableForAudio) runBlocking {
takeOver("call")
@@ -622,15 +610,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
handleIncomingCall()
}
}
+
TelephonyManager.CALL_STATE_OFFHOOK -> {
- val leAvailableForAudio = bleManager.getMostRecentStatus()?.isLeftInEar == true || bleManager.getMostRecentStatus()?.isRightInEar == true
+ val leAvailableForAudio =
+ bleManager.getMostRecentStatus()?.isLeftInEar == true || bleManager.getMostRecentStatus()?.isRightInEar == true
// if ((CrossDevice.isAvailable && !isConnectedLocally && earDetectionNotification.status.contains(0x00)) || leAvailableForAudio) CoroutineScope(
if (leAvailableForAudio) CoroutineScope(
- Dispatchers.IO).launch {
+ Dispatchers.IO
+ ).launch {
takeOver("call")
}
isInCall = true
}
+
TelephonyManager.CALL_STATE_IDLE -> {
isInCall = false
callNumber = null
@@ -647,13 +639,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
batteryChangedIntentFilter.addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(
- BatteryChangedIntentReceiver,
- batteryChangedIntentFilter,
- RECEIVER_EXPORTED
+ BatteryChangedIntentReceiver, batteryChangedIntentFilter, RECEIVER_EXPORTED
)
} else {
- @Suppress("UnspecifiedRegisterReceiverFlag")
- registerReceiver(BatteryChangedIntentReceiver, batteryChangedIntentFilter)
+ @Suppress("UnspecifiedRegisterReceiverFlag") registerReceiver(
+ BatteryChangedIntentReceiver, batteryChangedIntentFilter
+ )
}
}
val serviceIntentFilter = IntentFilter().apply {
@@ -692,7 +683,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
Log.d(TAG, "Setting metadata")
setMetadatas(device!!)
- isConnectedLocally = true
+// isConnectedLocally = true
macAddress = device!!.address
sharedPreferences.edit {
putString("mac_address", macAddress)
@@ -701,7 +692,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} else if (intent?.action == AirPodsNotifications.AIRPODS_DISCONNECTED) {
device = null
- isConnectedLocally = false
+// isConnectedLocally = false
popupShown = false
updateNotificationContent(false)
attManager?.disconnect()
@@ -709,10 +700,17 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
}
}
- val showIslandReceiver = object: BroadcastReceiver() {
+ val showIslandReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == "me.kavishdevar.librepods.cross_device_island") {
- showIsland(this@AirPodsService, batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level!!.coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level!!))
+ showIsland(
+ this@AirPodsService,
+ batteryNotification.getBattery()
+ .find { it.component == BatteryComponent.LEFT }?.level!!.coerceAtMost(
+ batteryNotification.getBattery()
+ .find { it.component == BatteryComponent.RIGHT }?.level!!
+ )
+ )
} else if (intent?.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
try {
context?.unregisterReceiver(this)
@@ -731,8 +729,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(showIslandReceiver, showIslandIntentFilter, RECEIVER_EXPORTED)
} else {
- @Suppress("UnspecifiedRegisterReceiverFlag")
- registerReceiver(showIslandReceiver, showIslandIntentFilter)
+ @Suppress("UnspecifiedRegisterReceiverFlag") registerReceiver(
+ showIslandReceiver, showIslandIntentFilter
+ )
}
val deviceIntentFilter = IntentFilter().apply {
@@ -744,8 +743,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
registerReceiver(connectionReceiver, deviceIntentFilter, RECEIVER_EXPORTED)
registerReceiver(bluetoothReceiver, serviceIntentFilter, RECEIVER_EXPORTED)
} else {
- @Suppress("UnspecifiedRegisterReceiverFlag")
- registerReceiver(connectionReceiver, deviceIntentFilter)
+ @Suppress("UnspecifiedRegisterReceiverFlag") registerReceiver(
+ connectionReceiver, deviceIntentFilter
+ )
registerReceiver(bluetoothReceiver, serviceIntentFilter)
}
@@ -756,8 +756,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
if (device.uuids != null) {
if (device.uuids.contains(ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a"))) {
bluetoothAdapter.getProfileProxy(
- this,
- object : BluetoothProfile.ServiceListener {
+ this, object : BluetoothProfile.ServiceListener {
@SuppressLint("NewApi")
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
if (profile == BluetoothProfile.A2DP) {
@@ -773,17 +772,17 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
putString("mac_address", macAddress)
}
// }
- this@AirPodsService.sendBroadcast(
- Intent(AirPodsNotifications.AIRPODS_CONNECTED)
- )
+ sendBroadcast(
+ Intent(AirPodsNotifications.AIRPODS_CONNECTED).apply {
+ setPackage(packageName)
+ })
}
}
bluetoothAdapter.closeProfileProxy(profile, proxy)
}
override fun onServiceDisconnected(profile: Int) {}
- },
- BluetoothProfile.A2DP
+ }, BluetoothProfile.A2DP
)
}
}
@@ -800,7 +799,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
@Suppress("unused")
fun cameraOpened() {
- Log.d(TAG, "Camera opened, gonna handle stem presses and take action if enabled")
+ Log.d(TAG, "Camera opened, gonna handle stem presses and take action if visible")
cameraActive = true
setupStemActions()
}
@@ -812,8 +811,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
fun isCustomAction(
- action: StemAction?,
- default: StemAction?
+ action: StemAction?, default: StemAction?
): Boolean {
return action != default
}
@@ -822,23 +820,29 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
val singlePressDefault = StemAction.defaultActions[StemPressType.SINGLE_PRESS]
val doublePressDefault = StemAction.defaultActions[StemPressType.DOUBLE_PRESS]
val triplePressDefault = StemAction.defaultActions[StemPressType.TRIPLE_PRESS]
- val longPressDefault = StemAction.defaultActions[StemPressType.LONG_PRESS]
+ val longPressDefault = StemAction.defaultActions[StemPressType.LONG_PRESS]
- val singlePressCustomized = isCustomAction(config.leftSinglePressAction, singlePressDefault) ||
- isCustomAction(config.rightSinglePressAction, singlePressDefault) ||
- (cameraActive && config.cameraAction == StemPressType.SINGLE_PRESS)
- val doublePressCustomized = isCustomAction(config.leftDoublePressAction, doublePressDefault) ||
- isCustomAction(config.rightDoublePressAction, doublePressDefault)
- val triplePressCustomized = isCustomAction(config.leftTriplePressAction, triplePressDefault) ||
- isCustomAction(config.rightTriplePressAction, triplePressDefault)
- val longPressCustomized = isCustomAction(config.leftLongPressAction, longPressDefault) ||
- isCustomAction(config.rightLongPressAction, longPressDefault) ||
- (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")
+ val singlePressCustomized =
+ isCustomAction(config.leftSinglePressAction, singlePressDefault) || isCustomAction(
+ config.rightSinglePressAction, singlePressDefault
+ ) || (cameraActive && config.cameraAction == StemPressType.SINGLE_PRESS)
+ val doublePressCustomized =
+ isCustomAction(config.leftDoublePressAction, doublePressDefault) || isCustomAction(
+ config.rightDoublePressAction, doublePressDefault
+ )
+ val triplePressCustomized =
+ isCustomAction(config.leftTriplePressAction, triplePressDefault) || isCustomAction(
+ config.rightTriplePressAction, triplePressDefault
+ )
+ val longPressCustomized = isCustomAction(
+ config.leftLongPressAction, longPressDefault
+ ) || isCustomAction(
+ config.rightLongPressAction, longPressDefault
+ ) || (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"
+ )
aacpManager.sendStemConfigPacket(
singlePressCustomized,
doublePressCustomized,
@@ -855,6 +859,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
batteryNotification.setBattery(batteryInfo)
sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply {
putParcelableArrayListExtra("data", ArrayList(batteryNotification.getBattery()))
+ setPackage(packageName)
})
updateBattery()
updateNotificationContent(
@@ -887,6 +892,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
bytes[0] = list[0]
bytes[1] = list[1]
putExtra("data", bytes)
+ }.apply {
+ setPackage(packageName)
})
Log.d(
"AirPodsParser",
@@ -899,6 +906,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
conversationAwarenessNotification.setData(conversationAwareness)
sendBroadcast(Intent(AirPodsNotifications.CA_DATA).apply {
putExtra("data", conversationAwarenessNotification.status)
+ }.apply {
+ setPackage(packageName)
})
if (conversationAwarenessNotification.status == 1.toByte() || conversationAwarenessNotification.status == 2.toByte()) {
@@ -916,7 +925,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
override fun onControlCommandReceived(controlCommand: ByteArray) {
val command = AACPManager.ControlCommand.fromByteArray(controlCommand)
if (command.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value) {
- ancNotification.setStatus(byteArrayOf(command.value.takeIf { it.isNotEmpty() }?.get(0) ?: 0x00.toByte()))
+ ancNotification.setStatus(byteArrayOf(command.value.takeIf { it.isNotEmpty() }
+ ?.get(0) ?: 0x00.toByte()))
sendANCBroadcast()
updateNoiseControlWidget()
}
@@ -933,8 +943,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
MediaController.pausedForOtherDevice = true
otherDeviceTookOver = true
disconnectAudio(
- this@AirPodsService,
- device
+ this@AirPodsService, device
)
}
}
@@ -943,16 +952,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
// TODO: Show a reverse button, but that's a lot of effort -- i'd have to change the UI too, which i hate doing, and handle other device's reverses too, and disconnect audio etc... so for now, just pause the audio and show the island without asking to reverse.
// handling reverse is a problem because we'd have to disconnect the audio, but there's no option connect audio again natively, so notification would have to be changed. I wish there was a way to just "change the audio output device".
// (20 minutes later) i've done it nonetheless :]
- val senderName = aacpManager.connectedDevices.find { it.mac == sender }?.type ?: "Other device"
- Log.d(TAG, "other device has hijacked the connection, reasonReverseTapped: $reasonReverseTapped")
+ val senderName =
+ aacpManager.connectedDevices.find { it.mac == sender }?.type ?: "Other device"
+ Log.d(
+ TAG,
+ "other device has hijacked the connection, reasonReverseTapped: $reasonReverseTapped"
+ )
aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION.value,
byteArrayOf(0x00)
)
otherDeviceTookOver = true
disconnectAudio(
- this@AirPodsService,
- device
+ this@AirPodsService, device
)
if (reasonReverseTapped) {
Log.d(TAG, "reverse tapped, disconnecting audio")
@@ -960,7 +972,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
disconnectAudio(this@AirPodsService, device)
showIsland(
this@AirPodsService,
- (batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level?: 0).coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level?: 0),
+ (batteryNotification.getBattery()
+ .find { it.component == BatteryComponent.LEFT }?.level
+ ?: 0).coerceAtMost(
+ batteryNotification.getBattery()
+ .find { it.component == BatteryComponent.RIGHT }?.level ?: 0
+ ),
IslandType.MOVED_TO_OTHER_DEVICE,
reversed = true,
otherDeviceName = senderName
@@ -969,7 +986,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
if (!aacpManager.owns) {
showIsland(
this@AirPodsService,
- (batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level?: 0).coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level?: 0),
+ (batteryNotification.getBattery()
+ .find { it.component == BatteryComponent.LEFT }?.level
+ ?: 0).coerceAtMost(
+ batteryNotification.getBattery()
+ .find { it.component == BatteryComponent.RIGHT }?.level ?: 0
+ ),
IslandType.MOVED_TO_OTHER_DEVICE,
reversed = reasonReverseTapped,
otherDeviceName = senderName
@@ -979,10 +1001,15 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
override fun onShowNearbyUI(sender: String) {
- val senderName = aacpManager.connectedDevices.find { it.mac == sender }?.type ?: "Other device"
+ val senderName =
+ aacpManager.connectedDevices.find { it.mac == sender }?.type ?: "Other device"
showIsland(
this@AirPodsService,
- (batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level?: 0).coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level?: 0),
+ (batteryNotification.getBattery()
+ .find { it.component == BatteryComponent.LEFT }?.level ?: 0).coerceAtMost(
+ batteryNotification.getBattery()
+ .find { it.component == BatteryComponent.RIGHT }?.level ?: 0
+ ),
IslandType.MOVED_TO_OTHER_DEVICE,
reversed = false,
otherDeviceName = senderName
@@ -1037,6 +1064,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
attManager = attManager
)
}
+ sendBroadcast(
+ Intent(AirPodsNotifications.AIRPODS_INFORMATION_UPDATED).setPackage(
+ packageName
+ )
+ )
}
@SuppressLint("NewApi")
@@ -1059,21 +1091,34 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
override fun onStemPressReceived(stemPress: ByteArray) {
+
val (stemPressType, bud) = aacpManager.parseStemPressResponse(stemPress)
- Log.d("AirPodsParser", "Stem press received: $stemPressType on $bud, cameraActive: $cameraActive, cameraAction: ${config.cameraAction}")
+ Log.d(
+ "AirPodsParser",
+ "Stem press received: $stemPressType on $bud, cameraActive: $cameraActive, cameraAction: ${config.cameraAction}"
+ )
if (cameraActive && config.cameraAction != null && stemPressType == config.cameraAction) {
- Runtime.getRuntime().exec(arrayOf("su", "-c", "input keyevent 27"))
+ if (BuildConfig.FLAVOR == "xposed") {
+ Runtime.getRuntime().exec(arrayOf("su", "-c", "input keyevent 27"))
+ }
} else {
val action = getActionFor(bud, stemPressType)
Log.d("AirPodsParser", "$bud $stemPressType action: $action")
action?.let { executeStemAction(it) }
}
}
+
override fun onAudioSourceReceived(audioSource: ByteArray) {
- Log.d("AirPodsParser", "Audio source changed mac: ${aacpManager.audioSource?.mac}, type: ${aacpManager.audioSource?.type?.name}")
+ Log.d(
+ "AirPodsParser",
+ "Audio source changed mac: ${aacpManager.audioSource?.mac}, type: ${aacpManager.audioSource?.type?.name}"
+ )
if (aacpManager.audioSource?.type != AACPManager.Companion.AudioSourceType.NONE && aacpManager.audioSource?.mac != localMac) {
- Log.d("AirPodsParser", "Audio source is another device, better to give up aacp control")
+ Log.d(
+ "AirPodsParser",
+ "Audio source is another device, better to give up aacp control"
+ )
aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION.value,
byteArrayOf(0x00)
@@ -1087,28 +1132,55 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
override fun onConnectedDevicesReceived(connectedDevices: List) {
for (device in connectedDevices) {
- Log.d("AirPodsParser", "Connected device: ${device.mac}, info1: ${device.info1}, info2: ${device.info2})")
+ Log.d(
+ "AirPodsParser",
+ "Connected device: ${device.mac}, info1: ${device.info1}, info2: ${device.info2})"
+ )
}
val newDevices = connectedDevices.filter { newDevice ->
- val notInOld = aacpManager.oldConnectedDevices.none { oldDevice -> oldDevice.mac == newDevice.mac }
+ val notInOld =
+ aacpManager.oldConnectedDevices.none { oldDevice -> oldDevice.mac == newDevice.mac }
val notLocal = newDevice.mac != localMac
notInOld && notLocal
}
for (device in newDevices) {
- Log.d("AirPodsParser", "New connected device: ${device.mac}, info1: ${device.info1}, info2: ${device.info2})")
- Log.d(TAG, "Sending new Tipi packet for device ${device.mac}, and sending media info to the device")
- aacpManager.sendMediaInformationNewDevice(selfMacAddress = localMac, targetMacAddress = device.mac)
- aacpManager.sendAddTiPiDevice(selfMacAddress = localMac, targetMacAddress = device.mac)
+ Log.d(
+ "AirPodsParser",
+ "New connected device: ${device.mac}, info1: ${device.info1}, info2: ${device.info2})"
+ )
+ Log.d(
+ TAG,
+ "Sending new Tipi packet for device ${device.mac}, and sending media info to the device"
+ )
+ aacpManager.sendMediaInformationNewDevice(
+ selfMacAddress = localMac, targetMacAddress = device.mac
+ )
+ aacpManager.sendAddTiPiDevice(
+ selfMacAddress = localMac, targetMacAddress = device.mac
+ )
}
}
+
+ override fun onEQPacketReceived(eqData: FloatArray) {
+ sendBroadcast(
+ Intent(AirPodsNotifications.EQ_DATA).putExtra("eqData", eqData).apply {
+ setPackage(packageName)
+ })
+ }
+
override fun onUnknownPacketReceived(packet: ByteArray) {
- Log.d("AACPManager", "Unknown packet received: ${packet.joinToString(" ") { "%02X".format(it) }}")
+ Log.d(
+ "AACPManager",
+ "Unknown packet received: ${packet.joinToString(" ") { "%02X".format(it) }}"
+ )
}
})
}
- private fun getActionFor(bud: AACPManager.Companion.StemPressBudType, type: StemPressType): StemAction? {
+ private fun getActionFor(
+ bud: AACPManager.Companion.StemPressBudType, type: StemPressType
+ ): StemAction? {
return when (type) {
StemPressType.SINGLE_PRESS -> if (bud == AACPManager.Companion.StemPressBudType.LEFT) config.leftSinglePressAction else config.rightSinglePressAction
StemPressType.DOUBLE_PRESS -> if (bud == AACPManager.Companion.StemPressBudType.LEFT) config.leftDoublePressAction else config.rightDoublePressAction
@@ -1120,8 +1192,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
private fun executeStemAction(action: StemAction) {
when (action) {
StemAction.defaultActions[StemPressType.SINGLE_PRESS] -> {
- Log.d("AirPodsParser", "Default single press action: Play/Pause, not taking action.")
+ Log.d(
+ "AirPodsParser", "Default single press action: Play/Pause, not taking action."
+ )
}
+
StemAction.PLAY_PAUSE -> MediaController.sendPlayPause()
StemAction.PREVIOUS_TRACK -> MediaController.sendPreviousTrack()
StemAction.NEXT_TRACK -> MediaController.sendNextTrack()
@@ -1132,19 +1207,28 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
startActivity(intent)
} else {
- Log.w("AirPodsParser", "Digital Assistant action is not supported on this Android version.")
+ Log.w(
+ "AirPodsParser",
+ "Digital Assistant action is not supported on this Android version."
+ )
}
}
+
StemAction.CYCLE_NOISE_CONTROL_MODES -> {
Log.d("AirPodsParser", "Cycling noise control modes")
- sendBroadcast(Intent("me.kavishdevar.librepods.SET_ANC_MODE"))
+ sendBroadcast(Intent("me.kavishdevar.librepods.SET_ANC_MODE").apply {
+ setPackage(packageName)
+ })
}
}
}
private fun processEarDetectionChange(earDetection: ByteArray) {
var inEar: Boolean
- val inEarData = listOf(earDetectionNotification.status[0] == 0x00.toByte(), earDetectionNotification.status[1] == 0x00.toByte())
+ val inEarData = listOf(
+ earDetectionNotification.status[0] == 0x00.toByte(),
+ earDetectionNotification.status[1] == 0x00.toByte()
+ )
var justEnabledA2dp = false
earDetectionNotification.setStatus(earDetection)
if (config.earDetectionEnabled) {
@@ -1152,14 +1236,21 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
inEar = data[0] == 0x00.toByte() && data[1] == 0x00.toByte()
val newInEarData = listOf(
- data[0] == 0x00.toByte(),
- data[1] == 0x00.toByte()
+ data[0] == 0x00.toByte(), data[1] == 0x00.toByte()
)
- if (inEarData.sorted() == listOf(false, false) && newInEarData.sorted() != listOf(false, false) && islandWindow?.isVisible != true) {
+ if (inEarData.sorted() == listOf(false, false) && newInEarData.sorted() != listOf(
+ false, false
+ ) && islandWindow?.isVisible != true
+ ) {
showIsland(
this@AirPodsService,
- (batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level?: 0).coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level?: 0))
+ (batteryNotification.getBattery()
+ .find { it.component == BatteryComponent.LEFT }?.level ?: 0).coerceAtMost(
+ batteryNotification.getBattery()
+ .find { it.component == BatteryComponent.RIGHT }?.level ?: 0
+ )
+ )
}
if (newInEarData == listOf(false, false) && islandWindow?.isVisible == true) {
@@ -1190,7 +1281,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
MediaController.userPlayedTheMedia = false
}
- Log.d("AirPodsParser", "inEarData: ${inEarData.sorted()}, newInEarData: ${newInEarData.sorted()}")
+ Log.d(
+ "AirPodsParser",
+ "inEarData: ${inEarData.sorted()}, newInEarData: ${newInEarData.sorted()}"
+ )
if (newInEarData.sorted() != inEarData.sorted()) {
if (inEar) {
@@ -1209,15 +1303,21 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
val a2dpConnectionStateReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == "android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED") {
- val state = intent.getIntExtra(BluetoothProfile.EXTRA_STATE, BluetoothProfile.STATE_DISCONNECTED)
- val previousState = intent.getIntExtra(BluetoothProfile.EXTRA_PREVIOUS_STATE, BluetoothProfile.STATE_DISCONNECTED)
- val device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
+ val state = intent.getIntExtra(
+ BluetoothProfile.EXTRA_STATE, BluetoothProfile.STATE_DISCONNECTED
+ )
+ val previousState = intent.getIntExtra(
+ BluetoothProfile.EXTRA_PREVIOUS_STATE, BluetoothProfile.STATE_DISCONNECTED
+ )
+ val device =
+ intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)
- Log.d("MediaController", "A2DP state changed: $previousState -> $state for device: ${device?.address}")
+ Log.d(
+ "MediaController",
+ "A2DP state changed: $previousState -> $state for device: ${device?.address}"
+ )
- if (state == BluetoothProfile.STATE_CONNECTED &&
- previousState != BluetoothProfile.STATE_CONNECTED &&
- device?.address == this@AirPodsService.device?.address) {
+ if (state == BluetoothProfile.STATE_CONNECTED && previousState != BluetoothProfile.STATE_CONNECTED && device?.address == this@AirPodsService.device?.address) {
Log.d("MediaController", "A2DP connected, sending play command")
MediaController.sendPlay()
@@ -1229,7 +1329,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
}
- val a2dpIntentFilter = IntentFilter("android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED")
+ val a2dpIntentFilter =
+ IntentFilter("android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(a2dpConnectionStateReceiver, a2dpIntentFilter, RECEIVER_EXPORTED)
} else {
@@ -1241,51 +1342,105 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
config = ServiceConfig(
deviceName = sharedPreferences.getString("name", "AirPods") ?: "AirPods",
earDetectionEnabled = sharedPreferences.getBoolean("automatic_ear_detection", true),
- conversationalAwarenessPauseMusic = sharedPreferences.getBoolean("conversational_awareness_pause_music", false),
- showPhoneBatteryInWidget = sharedPreferences.getBoolean("show_phone_battery_in_widget", true),
- relativeConversationalAwarenessVolume = sharedPreferences.getBoolean("relative_conversational_awareness_volume", true),
+ conversationalAwarenessPauseMusic = sharedPreferences.getBoolean(
+ "conversational_awareness_pause_music", false
+ ),
+ showPhoneBatteryInWidget = sharedPreferences.getBoolean(
+ "show_phone_battery_in_widget", true
+ ),
+ relativeConversationalAwarenessVolume = sharedPreferences.getBoolean(
+ "relative_conversational_awareness_volume", true
+ ),
headGestures = sharedPreferences.getBoolean("head_gestures", true),
- disconnectWhenNotWearing = sharedPreferences.getBoolean("disconnect_when_not_wearing", false),
- conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 43),
+ disconnectWhenNotWearing = sharedPreferences.getBoolean(
+ "disconnect_when_not_wearing", false
+ ),
+ conversationalAwarenessVolume = sharedPreferences.getInt(
+ "conversational_awareness_volume", 43
+ ),
qsClickBehavior = sharedPreferences.getString("qs_click_behavior", "cycle") ?: "cycle",
// AirPods state-based takeover
- takeoverWhenDisconnected = sharedPreferences.getBoolean("takeover_when_disconnected", true),
- takeoverWhenIdle = sharedPreferences.getBoolean("takeover_when_idle", true),
+ takeoverWhenDisconnected = sharedPreferences.getBoolean(
+ "takeover_when_disconnected", false
+ ),
+ takeoverWhenIdle = sharedPreferences.getBoolean("takeover_when_idle", false),
takeoverWhenMusic = sharedPreferences.getBoolean("takeover_when_music", false),
- takeoverWhenCall = sharedPreferences.getBoolean("takeover_when_call", true),
+ takeoverWhenCall = sharedPreferences.getBoolean("takeover_when_call", false),
// Phone state-based takeover
- takeoverWhenRingingCall = sharedPreferences.getBoolean("takeover_when_ringing_call", true),
- takeoverWhenMediaStart = sharedPreferences.getBoolean("takeover_when_media_start", true),
+ takeoverWhenRingingCall = sharedPreferences.getBoolean(
+ "takeover_when_ringing_call", false
+ ),
+ takeoverWhenMediaStart = sharedPreferences.getBoolean(
+ "takeover_when_media_start", false
+ ),
// Stem actions
- leftSinglePressAction = StemAction.fromString(sharedPreferences.getString("left_single_press_action", "PLAY_PAUSE") ?: "PLAY_PAUSE")!!,
- rightSinglePressAction = StemAction.fromString(sharedPreferences.getString("right_single_press_action", "PLAY_PAUSE") ?: "PLAY_PAUSE")!!,
+ leftSinglePressAction = StemAction.fromString(
+ sharedPreferences.getString(
+ "left_single_press_action", "PLAY_PAUSE"
+ ) ?: "PLAY_PAUSE"
+ )!!,
+ rightSinglePressAction = StemAction.fromString(
+ sharedPreferences.getString(
+ "right_single_press_action", "PLAY_PAUSE"
+ ) ?: "PLAY_PAUSE"
+ )!!,
- leftDoublePressAction = StemAction.fromString(sharedPreferences.getString("left_double_press_action", "PREVIOUS_TRACK") ?: "NEXT_TRACK")!!,
- rightDoublePressAction = StemAction.fromString(sharedPreferences.getString("right_double_press_action", "NEXT_TRACK") ?: "NEXT_TRACK")!!,
+ leftDoublePressAction = StemAction.fromString(
+ sharedPreferences.getString(
+ "left_double_press_action", "PREVIOUS_TRACK"
+ ) ?: "NEXT_TRACK"
+ )!!,
+ rightDoublePressAction = StemAction.fromString(
+ sharedPreferences.getString(
+ "right_double_press_action", "NEXT_TRACK"
+ ) ?: "NEXT_TRACK"
+ )!!,
- leftTriplePressAction = StemAction.fromString(sharedPreferences.getString("left_triple_press_action", "PREVIOUS_TRACK") ?: "PREVIOUS_TRACK")!!,
- rightTriplePressAction = StemAction.fromString(sharedPreferences.getString("right_triple_press_action", "PREVIOUS_TRACK") ?: "PREVIOUS_TRACK")!!,
+ leftTriplePressAction = StemAction.fromString(
+ sharedPreferences.getString(
+ "left_triple_press_action", "PREVIOUS_TRACK"
+ ) ?: "PREVIOUS_TRACK"
+ )!!,
+ rightTriplePressAction = StemAction.fromString(
+ sharedPreferences.getString(
+ "right_triple_press_action", "PREVIOUS_TRACK"
+ ) ?: "PREVIOUS_TRACK"
+ )!!,
- leftLongPressAction = StemAction.fromString(sharedPreferences.getString("left_long_press_action", "CYCLE_NOISE_CONTROL_MODES") ?: "CYCLE_NOISE_CONTROL_MODES")!!,
- rightLongPressAction = StemAction.fromString(sharedPreferences.getString("right_long_press_action", "DIGITAL_ASSISTANT") ?: "DIGITAL_ASSISTANT")!!,
+ leftLongPressAction = StemAction.fromString(
+ sharedPreferences.getString(
+ "left_long_press_action", "CYCLE_NOISE_CONTROL_MODES"
+ ) ?: "CYCLE_NOISE_CONTROL_MODES"
+ )!!,
+ rightLongPressAction = StemAction.fromString(
+ sharedPreferences.getString(
+ "right_long_press_action", "DIGITAL_ASSISTANT"
+ ) ?: "DIGITAL_ASSISTANT"
+ )!!,
- cameraAction = sharedPreferences.getString("camera_action", null)?.let { StemPressType.valueOf(it) },
+ cameraAction = sharedPreferences.getString("camera_action", null)
+ ?.let { StemPressType.valueOf(it) },
// AirPods device information
airpodsName = sharedPreferences.getString("airpods_name", "") ?: "",
airpodsModelNumber = sharedPreferences.getString("airpods_model_number", "") ?: "",
airpodsManufacturer = sharedPreferences.getString("airpods_manufacturer", "") ?: "",
airpodsSerialNumber = sharedPreferences.getString("airpods_serial_number", "") ?: "",
- airpodsLeftSerialNumber = sharedPreferences.getString("airpods_left_serial_number", "") ?: "",
- airpodsRightSerialNumber = sharedPreferences.getString("airpods_right_serial_number", "") ?: "",
+ airpodsLeftSerialNumber = sharedPreferences.getString("airpods_left_serial_number", "")
+ ?: "",
+ airpodsRightSerialNumber = sharedPreferences.getString(
+ "airpods_right_serial_number", ""
+ ) ?: "",
airpodsVersion1 = sharedPreferences.getString("airpods_version1", "") ?: "",
airpodsVersion2 = sharedPreferences.getString("airpods_version2", "") ?: "",
airpodsVersion3 = sharedPreferences.getString("airpods_version3", "") ?: "",
- airpodsHardwareRevision = sharedPreferences.getString("airpods_hardware_revision", "") ?: "",
- airpodsUpdaterIdentifier = sharedPreferences.getString("airpods_updater_identifier", "") ?: "",
+ airpodsHardwareRevision = sharedPreferences.getString("airpods_hardware_revision", "")
+ ?: "",
+ airpodsUpdaterIdentifier = sharedPreferences.getString("airpods_updater_identifier", "")
+ ?: "",
selfMacAddress = sharedPreferences.getString("self_mac_address", "") ?: ""
)
@@ -1294,31 +1449,48 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
override fun onSharedPreferenceChanged(preferences: SharedPreferences?, key: String?) {
if (preferences == null || key == null) return
- when(key) {
+ when (key) {
"name" -> config.deviceName = preferences.getString(key, "AirPods") ?: "AirPods"
"mac_address" -> macAddress = preferences.getString(key, "") ?: ""
- "automatic_ear_detection" -> config.earDetectionEnabled = preferences.getBoolean(key, true)
- "conversational_awareness_pause_music" -> config.conversationalAwarenessPauseMusic = preferences.getBoolean(key, false)
+ "automatic_ear_detection" -> config.earDetectionEnabled =
+ preferences.getBoolean(key, true)
+
+ "conversational_awareness_pause_music" -> config.conversationalAwarenessPauseMusic =
+ preferences.getBoolean(key, false)
+
"show_phone_battery_in_widget" -> {
config.showPhoneBatteryInWidget = preferences.getBoolean(key, true)
widgetMobileBatteryEnabled = config.showPhoneBatteryInWidget
updateBattery()
}
- "relative_conversational_awareness_volume" -> config.relativeConversationalAwarenessVolume = preferences.getBoolean(key, true)
+
+ "relative_conversational_awareness_volume" -> config.relativeConversationalAwarenessVolume =
+ preferences.getBoolean(key, true)
+
"head_gestures" -> config.headGestures = preferences.getBoolean(key, true)
- "disconnect_when_not_wearing" -> config.disconnectWhenNotWearing = preferences.getBoolean(key, false)
- "conversational_awareness_volume" -> config.conversationalAwarenessVolume = preferences.getInt(key, 43)
- "qs_click_behavior" -> config.qsClickBehavior = preferences.getString(key, "cycle") ?: "cycle"
+ "disconnect_when_not_wearing" -> config.disconnectWhenNotWearing =
+ preferences.getBoolean(key, false)
+
+ "conversational_awareness_volume" -> config.conversationalAwarenessVolume =
+ preferences.getInt(key, 43)
+
+ "qs_click_behavior" -> config.qsClickBehavior =
+ preferences.getString(key, "cycle") ?: "cycle"
// AirPods state-based takeover
- "takeover_when_disconnected" -> config.takeoverWhenDisconnected = preferences.getBoolean(key, true)
+ "takeover_when_disconnected" -> config.takeoverWhenDisconnected =
+ preferences.getBoolean(key, true)
+
"takeover_when_idle" -> config.takeoverWhenIdle = preferences.getBoolean(key, true)
"takeover_when_music" -> config.takeoverWhenMusic = preferences.getBoolean(key, false)
"takeover_when_call" -> config.takeoverWhenCall = preferences.getBoolean(key, true)
// Phone state-based takeover
- "takeover_when_ringing_call" -> config.takeoverWhenRingingCall = preferences.getBoolean(key, true)
- "takeover_when_media_start" -> config.takeoverWhenMediaStart = preferences.getBoolean(key, true)
+ "takeover_when_ringing_call" -> config.takeoverWhenRingingCall =
+ preferences.getBoolean(key, true)
+
+ "takeover_when_media_start" -> config.takeoverWhenMediaStart =
+ preferences.getBoolean(key, true)
"left_single_press_action" -> {
config.leftSinglePressAction = StemAction.fromString(
@@ -1326,62 +1498,85 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
)!!
setupStemActions()
}
+
"right_single_press_action" -> {
config.rightSinglePressAction = StemAction.fromString(
preferences.getString(key, "PLAY_PAUSE") ?: "PLAY_PAUSE"
)!!
setupStemActions()
}
+
"left_double_press_action" -> {
config.leftDoublePressAction = StemAction.fromString(
preferences.getString(key, "PREVIOUS_TRACK") ?: "PREVIOUS_TRACK"
)!!
setupStemActions()
}
+
"right_double_press_action" -> {
config.rightDoublePressAction = StemAction.fromString(
preferences.getString(key, "NEXT_TRACK") ?: "NEXT_TRACK"
)!!
setupStemActions()
}
+
"left_triple_press_action" -> {
config.leftTriplePressAction = StemAction.fromString(
preferences.getString(key, "PREVIOUS_TRACK") ?: "PREVIOUS_TRACK"
)!!
setupStemActions()
}
+
"right_triple_press_action" -> {
config.rightTriplePressAction = StemAction.fromString(
preferences.getString(key, "PREVIOUS_TRACK") ?: "PREVIOUS_TRACK"
)!!
setupStemActions()
}
+
"left_long_press_action" -> {
config.leftLongPressAction = StemAction.fromString(
- preferences.getString(key, "CYCLE_NOISE_CONTROL_MODES") ?: "CYCLE_NOISE_CONTROL_MODES"
+ preferences.getString(key, "CYCLE_NOISE_CONTROL_MODES")
+ ?: "CYCLE_NOISE_CONTROL_MODES"
)!!
setupStemActions()
}
+
"right_long_press_action" -> {
config.rightLongPressAction = StemAction.fromString(
preferences.getString(key, "DIGITAL_ASSISTANT") ?: "DIGITAL_ASSISTANT"
)!!
setupStemActions()
}
- "camera_action" -> config.cameraAction = preferences.getString(key, null)?.let { StemPressType.valueOf(it) }
+
+ "camera_action" -> config.cameraAction =
+ preferences.getString(key, null)?.let { StemPressType.valueOf(it) }
// AirPods device information
"airpods_name" -> config.airpodsName = preferences.getString(key, "") ?: ""
- "airpods_model_number" -> config.airpodsModelNumber = preferences.getString(key, "") ?: ""
- "airpods_manufacturer" -> config.airpodsManufacturer = preferences.getString(key, "") ?: ""
- "airpods_serial_number" -> config.airpodsSerialNumber = preferences.getString(key, "") ?: ""
- "airpods_left_serial_number" -> config.airpodsLeftSerialNumber = preferences.getString(key, "") ?: ""
- "airpods_right_serial_number" -> config.airpodsRightSerialNumber = preferences.getString(key, "") ?: ""
+ "airpods_model_number" -> config.airpodsModelNumber =
+ preferences.getString(key, "") ?: ""
+
+ "airpods_manufacturer" -> config.airpodsManufacturer =
+ preferences.getString(key, "") ?: ""
+
+ "airpods_serial_number" -> config.airpodsSerialNumber =
+ preferences.getString(key, "") ?: ""
+
+ "airpods_left_serial_number" -> config.airpodsLeftSerialNumber =
+ preferences.getString(key, "") ?: ""
+
+ "airpods_right_serial_number" -> config.airpodsRightSerialNumber =
+ preferences.getString(key, "") ?: ""
+
"airpods_version1" -> config.airpodsVersion1 = preferences.getString(key, "") ?: ""
"airpods_version2" -> config.airpodsVersion2 = preferences.getString(key, "") ?: ""
"airpods_version3" -> config.airpodsVersion3 = preferences.getString(key, "") ?: ""
- "airpods_hardware_revision" -> config.airpodsHardwareRevision = preferences.getString(key, "") ?: ""
- "airpods_updater_identifier" -> config.airpodsUpdaterIdentifier = preferences.getString(key, "") ?: ""
+ "airpods_hardware_revision" -> config.airpodsHardwareRevision =
+ preferences.getString(key, "") ?: ""
+
+ "airpods_updater_identifier" -> config.airpodsUpdaterIdentifier =
+ preferences.getString(key, "") ?: ""
"self_mac_address" -> config.selfMacAddress = preferences.getString(key, "") ?: ""
}
@@ -1403,8 +1598,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
CoroutineScope(Dispatchers.IO).launch {
- val logs = sharedPreferencesLogs.getStringSet(packetLogKey, mutableSetOf())?.toMutableSet()
- ?: mutableSetOf()
+ val logs =
+ sharedPreferencesLogs.getStringSet(packetLogKey, mutableSetOf())?.toMutableSet()
+ ?: mutableSetOf()
logs.add(logEntry)
if (logs.size > maxLogEntries) {
@@ -1460,8 +1656,15 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
var islandOpen = false
var islandWindow: IslandWindow? = null
+
@SuppressLint("MissingPermission")
- fun showIsland(service: Service, batteryPercentage: Int, type: IslandType = IslandType.CONNECTED, reversed: Boolean = false, otherDeviceName: String? = null) {
+ fun showIsland(
+ service: Service,
+ batteryPercentage: Int,
+ type: IslandType = IslandType.CONNECTED,
+ reversed: Boolean = false,
+ otherDeviceName: String? = null
+ ) {
Log.d(TAG, "Showing island window")
if (!Settings.canDrawOverlays(service)) {
Log.d(TAG, "No permission for SYSTEM_ALERT_WINDOW")
@@ -1469,7 +1672,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
CoroutineScope(Dispatchers.Main).launch {
islandWindow = IslandWindow(service.applicationContext)
- islandWindow!!.show(sharedPreferences.getString("name", "AirPods Pro").toString(), batteryPercentage, this@AirPodsService, type, reversed, otherDeviceName)
+ islandWindow!!.show(
+ sharedPreferences.getString("name", "AirPods Pro").toString(),
+ batteryPercentage,
+ this@AirPodsService,
+ type,
+ reversed,
+ otherDeviceName
+ )
}
}
@@ -1480,7 +1690,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
startActivity(intent)
}
- var isConnectedLocally = false
+ // var isConnectedLocally = false
var device: BluetoothDevice? = null
private lateinit var earReceiver: BroadcastReceiver
@@ -1530,10 +1740,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
notificationManager.createNotificationChannel(connectedNotificationChannel)
notificationManager.createNotificationChannel(socketFailureChannel)
- val notificationSettingsIntent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
- putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
- putExtra(Settings.EXTRA_CHANNEL_ID, "background_service_status")
- }
+ val notificationSettingsIntent =
+ Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
+ putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
+ putExtra(Settings.EXTRA_CHANNEL_ID, "background_service_status")
+ }
val pendingIntentNotifDisable = PendingIntent.getActivity(
this,
0,
@@ -1542,14 +1753,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
)
val notification = NotificationCompat.Builder(this, "background_service_status")
- .setSmallIcon(R.drawable.airpods)
- .setContentTitle("Background Service Running")
+ .setSmallIcon(R.drawable.airpods).setContentTitle("Background Service Running")
.setContentText("Useless notification, disable it by clicking on it.")
- .setContentIntent(pendingIntentNotifDisable)
- .setCategory(Notification.CATEGORY_SERVICE)
- .setPriority(NotificationCompat.PRIORITY_LOW)
- .setOngoing(true)
- .build()
+ .setContentIntent(pendingIntentNotifDisable).setCategory(Notification.CATEGORY_SERVICE)
+ .setPriority(NotificationCompat.PRIORITY_LOW).setOngoing(true).build()
try {
startForeground(1, notification)
@@ -1560,6 +1767,13 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
@OptIn(ExperimentalMaterial3Api::class)
private fun showSocketConnectionFailureNotification(errorMessage: String) {
+ if (BuildConfig.FLAVOR != "xposed") {
+ Log.w(
+ TAG,
+ "Not showing socket error notification to user, the service shouldn't be running if it isn't supported."
+ )
+ return
+ }
val notificationManager = getSystemService(NotificationManager::class.java)
val notificationIntent = Intent(this, MainActivity::class.java)
@@ -1571,17 +1785,13 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
)
val notification = NotificationCompat.Builder(this, "socket_connection_failure")
- .setSmallIcon(R.drawable.airpods)
- .setContentTitle("AirPods Connection Issue")
- .setContentText("Unable to connect to AirPods over L2CAP")
- .setStyle(NotificationCompat.BigTextStyle()
- .bigText("Your AirPods are connected via Bluetooth, but LibrePods couldn't connect to AirPods using L2CAP. " +
- "Error: $errorMessage"))
- .setContentIntent(pendingIntent)
- .setCategory(Notification.CATEGORY_ERROR)
- .setPriority(NotificationCompat.PRIORITY_HIGH)
- .setAutoCancel(true)
- .build()
+ .setSmallIcon(R.drawable.airpods).setContentTitle("AirPods Connection Issue")
+ .setContentText("Unable to connect to AirPods over L2CAP").setStyle(
+ NotificationCompat.BigTextStyle().bigText(
+ "Your AirPods are connected via Bluetooth, but LibrePods couldn't connect to AirPods using L2CAP. " + "Error: $errorMessage"
+ )
+ ).setContentIntent(pendingIntent).setCategory(Notification.CATEGORY_ERROR)
+ .setPriority(NotificationCompat.PRIORITY_HIGH).setAutoCancel(true).build()
notificationManager.notify(3, notification)
}
@@ -1589,12 +1799,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
fun sendANCBroadcast() {
sendBroadcast(Intent(AirPodsNotifications.ANC_DATA).apply {
putExtra("data", ancNotification.status)
+ setPackage(packageName)
})
}
fun sendBatteryBroadcast() {
sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply {
putParcelableArrayListExtra("data", ArrayList(batteryNotification.getBattery()))
+ setPackage(packageName)
})
}
@@ -1608,36 +1820,46 @@ 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()
+ 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())
+ (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()
+ 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())
+ (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()
+ 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 (batteryNotification.getBattery()
+ .find { it.component == BatteryComponent.RIGHT }?.status == BatteryStatus.CHARGING
+ ) "1".toByteArray() else "0".toByteArray())
)
}
}
@@ -1649,7 +1871,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
val widgetIds = appWidgetManager.getAppWidgetIds(componentName)
val remoteViews = RemoteViews(packageName, R.layout.battery_widget).also { it ->
- val openActivityIntent = PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
+ val openActivityIntent = PendingIntent.getActivity(
+ this,
+ 0,
+ Intent(this, MainActivity::class.java),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
it.setOnClickPendingIntent(R.id.battery_widget, openActivityIntent)
val leftBattery =
@@ -1659,51 +1886,33 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
val caseBattery =
batteryNotification.getBattery().find { it.component == BatteryComponent.CASE }
- it.setTextViewText(
- R.id.left_battery_widget,
- leftBattery?.let {
- "${it.level}%"
- } ?: ""
- )
+ it.setTextViewText(R.id.left_battery_widget, leftBattery?.let {
+ "${it.level}%"
+ } ?: "")
it.setProgressBar(
- R.id.left_battery_progress,
- 100,
- leftBattery?.level ?: 0,
- false
+ R.id.left_battery_progress, 100, leftBattery?.level ?: 0, false
)
it.setViewVisibility(
R.id.left_charging_icon,
if (leftBattery?.status == BatteryStatus.CHARGING) View.VISIBLE else View.GONE
)
- it.setTextViewText(
- R.id.right_battery_widget,
- rightBattery?.let {
- "${it.level}%"
- } ?: ""
- )
+ it.setTextViewText(R.id.right_battery_widget, rightBattery?.let {
+ "${it.level}%"
+ } ?: "")
it.setProgressBar(
- R.id.right_battery_progress,
- 100,
- rightBattery?.level ?: 0,
- false
+ R.id.right_battery_progress, 100, rightBattery?.level ?: 0, false
)
it.setViewVisibility(
R.id.right_charging_icon,
if (rightBattery?.status == BatteryStatus.CHARGING) View.VISIBLE else View.GONE
)
- it.setTextViewText(
- R.id.case_battery_widget,
- caseBattery?.let {
- "${it.level}%"
- } ?: ""
- )
+ it.setTextViewText(R.id.case_battery_widget, caseBattery?.let {
+ "${it.level}%"
+ } ?: "")
it.setProgressBar(
- R.id.case_battery_progress,
- 100,
- caseBattery?.level ?: 0,
- false
+ R.id.case_battery_progress, 100, caseBattery?.level ?: 0, false
)
it.setViewVisibility(
R.id.case_charging_icon,
@@ -1721,18 +1930,13 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
val charging =
batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_STATUS) == BatteryManager.BATTERY_STATUS_CHARGING
it.setTextViewText(
- R.id.phone_battery_widget,
- "$batteryLevel%"
+ R.id.phone_battery_widget, "$batteryLevel%"
)
it.setViewVisibility(
- R.id.phone_charging_icon,
- if (charging) View.VISIBLE else View.GONE
+ R.id.phone_charging_icon, if (charging) View.VISIBLE else View.GONE
)
it.setProgressBar(
- R.id.phone_battery_progress,
- 100,
- batteryLevel,
- false
+ R.id.phone_battery_progress, 100, batteryLevel, false
)
}
}
@@ -1754,8 +1958,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
val widgetIds = appWidgetManager.getAppWidgetIds(componentName)
val remoteViews = RemoteViews(packageName, R.layout.noise_control_widget).also { it ->
val ancStatus = ancNotification.status
- val allowOffModeValue = aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION }
- val allowOffMode = allowOffModeValue?.value?.takeIf { it.isNotEmpty() }?.get(0) == 0x01.toByte()
+ val allowOffModeValue =
+ aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION }
+ val allowOffMode =
+ allowOffModeValue?.value?.takeIf { it.isNotEmpty() }?.get(0) == 0x01.toByte()
it.setInt(
R.id.widget_off_button,
"setBackgroundResource",
@@ -1777,8 +1983,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
if (ancStatus == 2) R.drawable.widget_button_checked_shape_end else R.drawable.widget_button_shape_end
)
it.setViewVisibility(
- R.id.widget_off_button,
- if (allowOffMode) View.VISIBLE else View.GONE
+ R.id.widget_off_button, if (allowOffMode) View.VISIBLE else View.GONE
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
it.setViewLayoutMargin(
@@ -1803,9 +2008,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
@OptIn(ExperimentalMaterial3Api::class)
fun updateNotificationContent(
- connected: Boolean,
- airpodsName: String? = null,
- batteryList: List? = null
+ connected: Boolean, airpodsName: String? = null, batteryList: List? = null
) {
val notificationManager = getSystemService(NotificationManager::class.java)
var updatedNotification: Notification?
@@ -1822,11 +2025,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
return
}
if (connected && (config.bleOnlyMode || socket.isConnected)) {
- val updatedNotificationBuilder = NotificationCompat.Builder(this, "airpods_connection_status")
- .setSmallIcon(R.drawable.airpods)
- .setContentTitle(airpodsName ?: config.deviceName)
- .setContentText(
- """${
+ val updatedNotificationBuilder =
+ NotificationCompat.Builder(this, "airpods_connection_status")
+ .setSmallIcon(R.drawable.airpods)
+ .setContentTitle(airpodsName ?: config.deviceName).setContentText(
+ """${
batteryList?.find { it.component == BatteryComponent.LEFT }?.let {
if (it.status != BatteryStatus.DISCONNECTED) {
"L: ${if (it.status == BatteryStatus.CHARGING) "⚡" else ""} ${it.level}%"
@@ -1850,23 +2053,15 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
""
}
} ?: ""
- }""")
- .setContentIntent(pendingIntent)
- .setCategory(Notification.CATEGORY_STATUS)
- .setPriority(NotificationCompat.PRIORITY_LOW)
- .setOngoing(true)
+ }""").setContentIntent(pendingIntent).setCategory(Notification.CATEGORY_STATUS)
+ .setPriority(NotificationCompat.PRIORITY_LOW).setOngoing(true)
if (disconnectedBecauseReversed) {
updatedNotificationBuilder.addAction(
- R.drawable.ic_bluetooth,
- "Reconnect",
- PendingIntent.getService(
- this,
- 0,
- Intent(this, AirPodsService::class.java).apply {
+ R.drawable.ic_bluetooth, "Reconnect", PendingIntent.getService(
+ this, 0, Intent(this, AirPodsService::class.java).apply {
action = "me.kavishdevar.librepods.RECONNECT_AFTER_REVERSE"
- },
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ }, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
)
}
@@ -1877,19 +2072,15 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
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)
+ .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()
+ .setPriority(NotificationCompat.PRIORITY_LOW).setOngoing(true).build()
notificationManager.notify(1, updatedNotification)
notificationManager.cancel(2)
- } else if (!config.bleOnlyMode && !socket.isConnected && isConnectedLocally) {
- showSocketConnectionFailureNotification("Socket created, but not connected. Is the Bluetooth process hooked?")
+ } else if (!config.bleOnlyMode && !socket.isConnected) {
+ showSocketConnectionFailureNotification("Socket created, but not connected. Check logs")
}
}
@@ -1923,6 +2114,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
}
}
+
private fun answerCall() {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@@ -1936,7 +2128,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
val method = telephonyClass.getDeclaredMethod("getITelephony")
method.isAccessible = true
val telephonyInterface = method.invoke(telephonyService)
- val answerCallMethod = telephonyInterface.javaClass.getDeclaredMethod("answerRingingCall")
+ val answerCallMethod =
+ telephonyInterface.javaClass.getDeclaredMethod("answerRingingCall")
answerCallMethod.invoke(telephonyInterface)
}
@@ -1948,6 +2141,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
islandWindow?.close()
}
}
+
private fun rejectCall() {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@@ -1991,12 +2185,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
private fun resToUri(resId: Int): Uri? {
return try {
- Uri.Builder()
- .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
+ Uri.Builder().scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
.authority("me.kavishdevar.librepods")
.appendPath(applicationContext.resources.getResourceTypeName(resId))
- .appendPath(applicationContext.resources.getResourceEntryName(resId))
- .build()
+ .appendPath(applicationContext.resources.getResourceEntryName(resId)).build()
} catch (_: Resources.NotFoundException) {
null
}
@@ -2004,16 +2196,23 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
@Suppress("PrivatePropertyName")
private val VENDOR_SPECIFIC_HEADSET_EVENT_IPHONEACCEV = "+IPHONEACCEV"
+
@Suppress("PrivatePropertyName")
private val VENDOR_SPECIFIC_HEADSET_EVENT_IPHONEACCEV_BATTERY_LEVEL = 1
+
@Suppress("PrivatePropertyName")
private val APPLE = 0x004C
+
@Suppress("PrivatePropertyName")
- private val ACTION_BATTERY_LEVEL_CHANGED = "android.bluetooth.device.action.BATTERY_LEVEL_CHANGED"
+ private val ACTION_BATTERY_LEVEL_CHANGED =
+ "android.bluetooth.device.action.BATTERY_LEVEL_CHANGED"
+
@Suppress("PrivatePropertyName")
private val EXTRA_BATTERY_LEVEL = "android.bluetooth.device.extra.BATTERY_LEVEL"
+
@Suppress("PrivatePropertyName")
private val PACKAGE_ASI = "com.google.android.settings.intelligence"
+
@Suppress("PrivatePropertyName")
private val ACTION_ASI_UPDATE_BLUETOOTH_DATA = "batterywidget.impl.action.update_bluetooth_data"
@@ -2027,8 +2226,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
// Calculate unified battery level (minimum of left and right)
val batteryUnified = minOf(
- leftBattery?.level ?: 100,
- rightBattery?.level ?: 100
+ leftBattery?.level ?: 100, rightBattery?.level ?: 100
)
// Check charging status
@@ -2045,8 +2243,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
// Broadcast vendor-specific event
val intent = Intent(BluetoothHeadset.ACTION_VENDOR_SPECIFIC_HEADSET_EVENT).apply {
- putExtra(BluetoothHeadset.EXTRA_VENDOR_SPECIFIC_HEADSET_EVENT_CMD, VENDOR_SPECIFIC_HEADSET_EVENT_IPHONEACCEV)
- putExtra(BluetoothHeadset.EXTRA_VENDOR_SPECIFIC_HEADSET_EVENT_CMD_TYPE, BluetoothHeadset.AT_CMD_TYPE_SET)
+ putExtra(
+ BluetoothHeadset.EXTRA_VENDOR_SPECIFIC_HEADSET_EVENT_CMD,
+ VENDOR_SPECIFIC_HEADSET_EVENT_IPHONEACCEV
+ )
+ putExtra(
+ BluetoothHeadset.EXTRA_VENDOR_SPECIFIC_HEADSET_EVENT_CMD_TYPE,
+ BluetoothHeadset.AT_CMD_TYPE_SET
+ )
putExtra(BluetoothHeadset.EXTRA_VENDOR_SPECIFIC_HEADSET_EVENT_ARGS, arguments)
putExtra(BluetoothDevice.EXTRA_DEVICE, device)
putExtra(BluetoothDevice.EXTRA_NAME, device?.name)
@@ -2098,67 +2302,57 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
private fun setMetadatas(d: BluetoothDevice) {
- d.let{ device ->
+ if (BuildConfig.FLAVOR != "xposed") return
+ d.let { device ->
val instance = airpodsInstance
if (instance != null) {
val metadataSet = SystemApisUtils.setMetadata(
device,
device.METADATA_MAIN_ICON,
resToUri(instance.model.budCaseRes).toString().toByteArray()
- ) &&
- SystemApisUtils.setMetadata(
- device,
- device.METADATA_MODEL_NAME,
- instance.model.name.toByteArray()
- ) &&
- SystemApisUtils.setMetadata(
- device,
- device.METADATA_DEVICE_TYPE,
- device.DEVICE_TYPE_UNTETHERED_HEADSET.toByteArray()
- ) &&
- SystemApisUtils.setMetadata(
- device,
- device.METADATA_UNTETHERED_CASE_ICON,
- resToUri(instance.model.caseRes).toString().toByteArray()
- ) &&
- SystemApisUtils.setMetadata(
- device,
- device.METADATA_UNTETHERED_RIGHT_ICON,
- resToUri(instance.model.rightBudsRes).toString().toByteArray()
- ) &&
- SystemApisUtils.setMetadata(
- device,
- device.METADATA_UNTETHERED_LEFT_ICON,
- resToUri(instance.model.leftBudsRes).toString().toByteArray()
- ) &&
- SystemApisUtils.setMetadata(
- device,
- device.METADATA_MANUFACTURER_NAME,
- instance.model.manufacturer.toByteArray()
- ) &&
- SystemApisUtils.setMetadata(
- device,
- device.METADATA_COMPANION_APP,
- "me.kavishdevar.librepods".toByteArray()
- ) &&
- SystemApisUtils.setMetadata(
- device,
- device.METADATA_UNTETHERED_CASE_LOW_BATTERY_THRESHOLD,
- "20".toByteArray()
- ) &&
- SystemApisUtils.setMetadata(
- device,
- device.METADATA_UNTETHERED_LEFT_LOW_BATTERY_THRESHOLD,
- "20".toByteArray()
- ) &&
- SystemApisUtils.setMetadata(
- device,
- device.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD,
- "20".toByteArray()
- )
+ ) && SystemApisUtils.setMetadata(
+ device, device.METADATA_MODEL_NAME, instance.model.name.toByteArray()
+ ) && SystemApisUtils.setMetadata(
+ device,
+ device.METADATA_DEVICE_TYPE,
+ device.DEVICE_TYPE_UNTETHERED_HEADSET.toByteArray()
+ ) && SystemApisUtils.setMetadata(
+ device,
+ device.METADATA_UNTETHERED_CASE_ICON,
+ resToUri(instance.model.caseRes).toString().toByteArray()
+ ) && SystemApisUtils.setMetadata(
+ device,
+ device.METADATA_UNTETHERED_RIGHT_ICON,
+ resToUri(instance.model.rightBudsRes).toString().toByteArray()
+ ) && SystemApisUtils.setMetadata(
+ device,
+ device.METADATA_UNTETHERED_LEFT_ICON,
+ resToUri(instance.model.leftBudsRes).toString().toByteArray()
+ ) && SystemApisUtils.setMetadata(
+ device,
+ device.METADATA_MANUFACTURER_NAME,
+ instance.model.manufacturer.toByteArray()
+ ) && SystemApisUtils.setMetadata(
+ device, device.METADATA_COMPANION_APP, "me.kavishdevar.librepods".toByteArray()
+ ) && SystemApisUtils.setMetadata(
+ device,
+ device.METADATA_UNTETHERED_CASE_LOW_BATTERY_THRESHOLD,
+ "20".toByteArray()
+ ) && SystemApisUtils.setMetadata(
+ device,
+ device.METADATA_UNTETHERED_LEFT_LOW_BATTERY_THRESHOLD,
+ "20".toByteArray()
+ ) && SystemApisUtils.setMetadata(
+ device,
+ device.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD,
+ "20".toByteArray()
+ )
Log.d(TAG, "Metadata set: $metadataSet")
} else {
- Log.w(TAG, "AirPods instance is not of type AirPodsInstance, skipping metadata setting")
+ Log.w(
+ TAG,
+ "AirPods instance is not of type AirPodsInstance, skipping metadata setting"
+ )
}
}
}
@@ -2167,15 +2361,13 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
private object bluetoothReceiver : BroadcastReceiver() {
@SuppressLint("MissingPermission")
override fun onReceive(context: Context?, intent: Intent) {
- val bluetoothDevice =
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- intent.getParcelableExtra(
- "android.bluetooth.device.extra.DEVICE",
- BluetoothDevice::class.java
- )
- } else {
- intent.getParcelableExtra("android.bluetooth.device.extra.DEVICE") as BluetoothDevice?
- }
+ val bluetoothDevice = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ intent.getParcelableExtra(
+ "android.bluetooth.device.extra.DEVICE", BluetoothDevice::class.java
+ )
+ } else {
+ intent.getParcelableExtra("android.bluetooth.device.extra.DEVICE") as BluetoothDevice?
+ }
val action = intent.action
val context = context?.applicationContext
val name = context?.getSharedPreferences("settings", MODE_PRIVATE)
@@ -2187,8 +2379,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
bluetoothDevice.fetchUuidsWithSdp()
if (bluetoothDevice.uuids != null) {
if (bluetoothDevice.uuids.contains(uuid)) {
- val intent =
- Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED)
+ val intent = Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED)
intent.putExtra("name", name)
intent.putExtra("device", bluetoothDevice)
context?.sendBroadcast(intent)
@@ -2218,11 +2409,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
@RequiresApi(Build.VERSION_CODES.R)
@SuppressLint("MissingPermission", "HardwareIds")
- fun takeOver(takingOverFor: String, manualTakeOverAfterReversed: Boolean = false, startHeadTrackingAgain: Boolean = false) {
+ fun takeOver(
+ takingOverFor: String,
+ manualTakeOverAfterReversed: Boolean = false,
+ startHeadTrackingAgain: Boolean = false
+ ) {
if (takingOverFor == "reverse") {
aacpManager.sendControlCommand(
- AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION.value,
- 1
+ AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION.value, 1
)
aacpManager.sendMediaInformataion(
localMac
@@ -2231,28 +2425,40 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
localMac
)
connectAudio(
- this@AirPodsService,
- device
+ this@AirPodsService, device
)
otherDeviceTookOver = false
}
- Log.d(TAG, "owns connection: ${aacpManager.getControlCommandStatus(AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION)?.value?.get(0)?.toInt()}")
- if (isConnectedLocally) {
+ Log.d(
+ TAG, "owns connection: ${
+ aacpManager.getControlCommandStatus(AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION)?.value?.get(
+ 0
+ )?.toInt()
+ }"
+ )
+ if (!::socket.isInitialized) return
+ if (socket.isConnected) {
+ if (BuildConfig.FLAVOR != "xposed") {
+ Log.d(TAG, "not taking over, vendorid is probably not set to apple")
+ return
+ }
if (aacpManager.getControlCommandStatus(AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION)?.value[0]?.toInt() != 1 || (aacpManager.audioSource?.mac != localMac && aacpManager.audioSource?.type != AACPManager.Companion.AudioSourceType.NONE)) {
if (disconnectedBecauseReversed) {
if (manualTakeOverAfterReversed) {
Log.d(TAG, "forcefully taking over despite reverse as user requested")
disconnectedBecauseReversed = false
} else {
- Log.d(TAG, "connected locally, but can not hijack as other device had reversed")
+ Log.d(
+ TAG,
+ "connected locally, but can not hijack as other device had reversed"
+ )
return
}
}
Log.d(TAG, "already connected locally, hijacking connection by asking AirPods")
aacpManager.sendControlCommand(
- AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION.value,
- 1
+ AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION.value, 1
)
aacpManager.sendMediaInformataion(
localMac
@@ -2265,8 +2471,15 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
)
otherDeviceTookOver = false
connectAudio(this, device)
- showIsland(this, batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level!!.coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level!!),
- IslandType.CONNECTED)
+ showIsland(
+ this,
+ batteryNotification.getBattery()
+ .find { it.component == BatteryComponent.LEFT }?.level!!.coerceAtMost(
+ batteryNotification.getBattery()
+ .find { it.component == BatteryComponent.RIGHT }?.level!!
+ ),
+ IslandType.CONNECTED
+ )
CoroutineScope(Dispatchers.IO).launch {
delay(500) // a2dp takes time, and so does taking control + AirPods pause it for no reason after connecting
@@ -2286,7 +2499,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
}
} else {
- Log.d(TAG, "Already connected locally and already own connection, skipping takeover")
+ Log.d(
+ TAG, "Already connected locally and already own connection, skipping takeover"
+ )
}
return
}
@@ -2357,25 +2572,32 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
// In BLE-only mode, just show connecting status without actual L2CAP connection
Log.d(TAG, "BLE-only mode: showing connecting status without L2CAP connection")
updateNotificationContent(
- true,
- config.deviceName,
- batteryNotification.getBattery()
+ true, config.deviceName, batteryNotification.getBattery()
)
// Set a temporary connecting state
- isConnectedLocally = false // Keep as false since we're not actually connecting to L2CAP
+// isConnectedLocally = false // Keep as false since we're not actually connecting to L2CAP
} else {
connectToSocket(bluetoothAdapter, device!!)
connectAudio(this, device)
- isConnectedLocally = true
+// isConnectedLocally = true
}
}
- showIsland(this, batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level!!.coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level!!),
- IslandType.TAKING_OVER)
+ showIsland(
+ this,
+ batteryNotification.getBattery()
+ .find { it.component == BatteryComponent.LEFT }?.level!!.coerceAtMost(
+ batteryNotification.getBattery()
+ .find { it.component == BatteryComponent.RIGHT }?.level!!
+ ),
+ IslandType.TAKING_OVER
+ )
// CrossDevice.isAvailable = false
}
- private fun createBluetoothSocket(adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid): BluetoothSocket {
+ private fun createBluetoothSocket(
+ adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid
+ ): BluetoothSocket {
val type = 3 // L2CAP
val constructorSpecs = listOf(
arrayOf(adapter, device, type, true, true, 0x1001, uuid), // A16QPR3
@@ -2401,7 +2623,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
Log.d(TAG, "Trying constructor signature #${index + 1}")
attemptedConstructors++
- val paramTypes = params.map { it::class.javaPrimitiveType ?: it::class.java }.toTypedArray()
+ val paramTypes =
+ params.map { it::class.javaPrimitiveType ?: it::class.java }.toTypedArray()
val constructor = BluetoothSocket::class.java.getDeclaredConstructor(*paramTypes)
constructor.isAccessible = true
return constructor.newInstance(*params) as BluetoothSocket
@@ -2412,175 +2635,187 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
}
- val errorMessage = "Failed to create BluetoothSocket after trying $attemptedConstructors constructor signatures"
+ val errorMessage =
+ "Failed to create BluetoothSocket after trying $attemptedConstructors constructor signatures"
Log.e(TAG, errorMessage)
showSocketConnectionFailureNotification(errorMessage)
throw lastException ?: IllegalStateException(errorMessage)
}
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
- fun connectToSocket(adapter: BluetoothAdapter, device: BluetoothDevice, manual: Boolean = false) {
+ fun connectToSocket(
+ adapter: BluetoothAdapter, device: BluetoothDevice, manual: Boolean = false
+ ) {
Log.d(TAG, " Connecting to socket")
val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
- if (!isConnectedLocally) {
- socket = try {
- createBluetoothSocket(adapter, device, uuid)
- } catch (e: Exception) {
- Log.e(TAG, "Failed to create BluetoothSocket: ${e.message}")
- showSocketConnectionFailureNotification("Failed to create Bluetooth socket: ${e.localizedMessage}")
- return
- }
+// if (!isConnectedLocally) {
+ socket = try {
+ createBluetoothSocket(adapter, device, uuid)
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to create BluetoothSocket: ${e.message}")
+ showSocketConnectionFailureNotification("Failed to create Bluetooth socket: ${e.localizedMessage}")
+ return
+ }
- try {
- runBlocking {
- withTimeout(5000L) {
- try {
- socket.connect()
- isConnectedLocally = true
- this@AirPodsService.device = device
+ try {
+ runBlocking {
+ withTimeout(5000L) {
+ try {
+ socket.connect()
+// isConnectedLocally = true
+ this@AirPodsService.device = device
- BluetoothConnectionManager.setCurrentConnection(socket, device)
+ BluetoothConnectionManager.setCurrentConnection(socket, device)
+ if (BuildConfig.FLAVOR == "xposed") {
attManager = ATTManager(adapter, device)
attManager!!.connect()
+ }
- // Create AirPodsInstance from stored config if available
- if (airpodsInstance == null && config.airpodsModelNumber.isNotEmpty()) {
- val model = AirPodsModels.getModelByModelNumber(config.airpodsModelNumber)
- if (model != null) {
- airpodsInstance = AirPodsInstance(
- name = config.airpodsName,
- model = model,
- actualModelNumber = config.airpodsModelNumber,
- serialNumber = config.airpodsSerialNumber,
- leftSerialNumber = config.airpodsLeftSerialNumber,
- rightSerialNumber = config.airpodsRightSerialNumber,
- version1 = config.airpodsVersion1,
- version2 = config.airpodsVersion2,
- version3 = config.airpodsVersion3,
- aacpManager = aacpManager,
- attManager = attManager
- )
- }
- }
-
- updateNotificationContent(
- true,
- config.deviceName,
- batteryNotification.getBattery()
- )
- Log.d(TAG, " Socket connected")
- } catch (e: Exception) {
- Log.d(TAG, " Socket not connected, ${e.message}")
- if (manual) {
- sendToast(
- "Couldn't connect to socket: ${e.localizedMessage}"
+ // Create AirPodsInstance from stored config if available
+ if (airpodsInstance == null && config.airpodsModelNumber.isNotEmpty()) {
+ val model =
+ AirPodsModels.getModelByModelNumber(config.airpodsModelNumber)
+ if (model != null) {
+ airpodsInstance = AirPodsInstance(
+ name = config.airpodsName,
+ model = model,
+ actualModelNumber = config.airpodsModelNumber,
+ serialNumber = config.airpodsSerialNumber,
+ leftSerialNumber = config.airpodsLeftSerialNumber,
+ rightSerialNumber = config.airpodsRightSerialNumber,
+ version1 = config.airpodsVersion1,
+ version2 = config.airpodsVersion2,
+ version3 = config.airpodsVersion3,
+ aacpManager = aacpManager,
+ attManager = attManager
)
- } else {
- showSocketConnectionFailureNotification("Couldn't connect to socket: ${e.localizedMessage}")
}
- return@withTimeout
+ }
+
+ updateNotificationContent(
+ true, config.deviceName, batteryNotification.getBattery()
+ )
+ Log.d(TAG, " Socket connected")
+ } catch (e: Exception) {
+ Log.d(
+ TAG, " Socket not connected, ${e.message}"
+ )
+ if (manual) {
+ sendToast(
+ "Couldn't connect to socket: ${e.localizedMessage}"
+ )
+ } else {
+ showSocketConnectionFailureNotification("Couldn't connect to socket: ${e.localizedMessage}")
+ }
+ return@withTimeout
// throw e // lol how did i not catch this before... gonna comment this line instead of removing to preserve history
- }
}
}
- if (!socket.isConnected) {
- Log.d(TAG, " Socket not connected")
- if (manual) {
- sendToast(
- "Couldn't connect to socket: timeout."
- )
- } else {
- showSocketConnectionFailureNotification("Couldn't connect to socket: Timeout")
- }
- return
+ }
+ if (!socket.isConnected) {
+ Log.d(TAG, " Socket not connected")
+ if (manual) {
+ sendToast(
+ "Couldn't connect to socket: timeout."
+ )
+ } else {
+ showSocketConnectionFailureNotification("Couldn't connect to socket: Timeout")
}
- this@AirPodsService.device = device
- socket.let {
+ return
+ }
+ this@AirPodsService.device = device
+ socket.let {
+ aacpManager.sendPacket(aacpManager.createHandshakePacket())
+ aacpManager.sendSetFeatureFlagsPacket()
+ aacpManager.sendNotificationRequest()
+ Log.d(TAG, "Requesting proximity keys")
+ aacpManager.sendRequestProximityKeys((AACPManager.Companion.ProximityKeyType.IRK.value + AACPManager.Companion.ProximityKeyType.ENC_KEY.value).toByte())
+ CoroutineScope(Dispatchers.IO).launch {
aacpManager.sendPacket(aacpManager.createHandshakePacket())
+ delay(200)
aacpManager.sendSetFeatureFlagsPacket()
+ delay(200)
aacpManager.sendNotificationRequest()
- Log.d(TAG, "Requesting proximity keys")
+ delay(200)
+ aacpManager.sendSomePacketIDontKnowWhatItIs()
+ delay(200)
aacpManager.sendRequestProximityKeys((AACPManager.Companion.ProximityKeyType.IRK.value + AACPManager.Companion.ProximityKeyType.ENC_KEY.value).toByte())
- CoroutineScope(Dispatchers.IO).launch {
+ if (!handleIncomingCallOnceConnected) startHeadTracking() else handleIncomingCall()
+ Handler(Looper.getMainLooper()).postDelayed({
aacpManager.sendPacket(aacpManager.createHandshakePacket())
- delay(200)
aacpManager.sendSetFeatureFlagsPacket()
- delay(200)
aacpManager.sendNotificationRequest()
- delay(200)
- aacpManager.sendSomePacketIDontKnowWhatItIs()
- delay(200)
- aacpManager.sendRequestProximityKeys((AACPManager.Companion.ProximityKeyType.IRK.value+AACPManager.Companion.ProximityKeyType.ENC_KEY.value).toByte())
- if (!handleIncomingCallOnceConnected) startHeadTracking() else handleIncomingCall()
- Handler(Looper.getMainLooper()).postDelayed({
- aacpManager.sendPacket(aacpManager.createHandshakePacket())
- aacpManager.sendSetFeatureFlagsPacket()
- aacpManager.sendNotificationRequest()
- aacpManager.sendRequestProximityKeys(AACPManager.Companion.ProximityKeyType.IRK.value)
- if (!handleIncomingCallOnceConnected) stopHeadTracking()
- }, 5000)
+ aacpManager.sendRequestProximityKeys(AACPManager.Companion.ProximityKeyType.IRK.value)
+ if (!handleIncomingCallOnceConnected) stopHeadTracking()
+ }, 5000)
- sendBroadcast(
- Intent(AirPodsNotifications.AIRPODS_CONNECTED)
- .putExtra("device", device)
- )
+ sendBroadcast(
+ Intent(AirPodsNotifications.AIRPODS_CONNECTED).putExtra("device", device)
+ .apply {
+ setPackage(packageName)
+ })
- setupStemActions()
+ setupStemActions()
- while (socket.isConnected) {
- socket.let { it ->
- val buffer = ByteArray(1024)
- val bytesRead = it.inputStream.read(buffer)
- var data: ByteArray
- if (bytesRead > 0) {
- data = buffer.copyOfRange(0, bytesRead)
- sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DATA).apply {
- putExtra("data", buffer.copyOfRange(0, bytesRead))
- })
- val bytes = buffer.copyOfRange(0, bytesRead)
- val formattedHex = bytes.joinToString(" ") { "%02X".format(it) }
+ while (socket.isConnected) {
+ socket.let { it ->
+ val buffer = ByteArray(1024)
+ val bytesRead = it.inputStream.read(buffer)
+ var data: ByteArray
+ if (bytesRead > 0) {
+ data = buffer.copyOfRange(0, bytesRead)
+ sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DATA).apply {
+ putExtra("data", buffer.copyOfRange(0, bytesRead))
+ setPackage(packageName)
+ })
+ val bytes = buffer.copyOfRange(0, bytesRead)
+ val formattedHex = bytes.joinToString(" ") { "%02X".format(it) }
// CrossDevice.sendReceivedPacket(bytes)
- updateNotificationContent(
- true,
- sharedPreferences.getString("name", device.name),
- batteryNotification.getBattery()
- )
+ updateNotificationContent(
+ true,
+ sharedPreferences.getString("name", device.name),
+ batteryNotification.getBattery()
+ )
- aacpManager.receivePacket(data)
+ aacpManager.receivePacket(data)
- if (!isHeadTrackingData(data)) {
- Log.d("AirPodsData", "Data received: $formattedHex")
- logPacket(data, "AirPods")
- }
-
- } else if (bytesRead == -1) {
- Log.d("AirPods Service", "Socket closed (bytesRead = -1)")
- sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED))
- aacpManager.disconnected()
- return@launch
+ if (!isHeadTrackingData(data)) {
+ Log.d("AirPodsData", "Data received: $formattedHex")
+ logPacket(data, "AirPods")
}
+
+ } else if (bytesRead == -1) {
+ Log.d("AirPods Service", "Socket closed (bytesRead = -1)")
+ sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply {
+ setPackage(packageName)
+ })
+ aacpManager.disconnected()
+ return@launch
}
}
- Log.d("AirPods Service", "Socket closed")
- isConnectedLocally = false
- socket.close()
- aacpManager.disconnected()
- updateNotificationContent(false)
- sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED))
}
+ Log.d("AirPods Service", "Socket closed")
+// isConnectedLocally = false
+ socket.close()
+ aacpManager.disconnected()
+ updateNotificationContent(false)
+ sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply {
+ setPackage(packageName)
+ })
}
- } catch (e: Exception) {
- e.printStackTrace()
- Log.d(TAG, "Failed to connect to socket: ${e.message}")
- showSocketConnectionFailureNotification("Failed to establish connection: ${e.localizedMessage}")
- isConnectedLocally = false
- this@AirPodsService.device = device
- updateNotificationContent(false)
}
- } else {
- Log.d(TAG, "Already connected locally, skipping socket connection (isConnectedLocally = $isConnectedLocally, socket.isConnected = ${this::socket.isInitialized && socket.isConnected})")
+ } catch (e: Exception) {
+ e.printStackTrace()
+ Log.d(TAG, "Failed to connect to socket: ${e.message}")
+ showSocketConnectionFailureNotification("Failed to establish connection: ${e.localizedMessage}")
+// isConnectedLocally = false
+ this@AirPodsService.device = device
+ updateNotificationContent(false)
}
+// } else {
+// Log.d(TAG, "Already connected locally, skipping socket connection (isConnectedLocally = $isConnectedLocally, socket.isConnected = ${this::socket.isInitialized && socket.isConnected})")
+// }
}
fun disconnectForCD() {
@@ -2588,8 +2823,15 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
socket.close()
MediaController.pausedWhileTakingOver = false
Log.d(TAG, "Disconnected from AirPods, showing island.")
- showIsland(this, batteryNotification.getBattery().find { it.component == BatteryComponent.LEFT}?.level!!.coerceAtMost(batteryNotification.getBattery().find { it.component == BatteryComponent.RIGHT}?.level!!),
- IslandType.MOVED_TO_REMOTE)
+ showIsland(
+ this,
+ batteryNotification.getBattery()
+ .find { it.component == BatteryComponent.LEFT }?.level!!.coerceAtMost(
+ batteryNotification.getBattery()
+ .find { it.component == BatteryComponent.RIGHT }?.level!!
+ ),
+ IslandType.MOVED_TO_REMOTE
+ )
val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter
bluetoothAdapter.getProfileProxy(this, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
@@ -2604,18 +2846,20 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
override fun onServiceDisconnected(profile: Int) {}
}, BluetoothProfile.A2DP)
- isConnectedLocally = false
+// isConnectedLocally = false
// CrossDevice.isAvailable = true
}
fun disconnectAirPods() {
if (!this::socket.isInitialized) return
socket.close()
- isConnectedLocally = false
+// isConnectedLocally = false
aacpManager.disconnected()
attManager?.disconnect()
updateNotificationContent(false)
- sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED))
+ sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply {
+ setPackage(packageName)
+ })
val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter
bluetoothAdapter.getProfileProxy(this, object : BluetoothProfile.ServiceListener {
@@ -2673,11 +2917,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
Log.d(TAG, "Already disconnected from A2DP")
return
}
- val method =
- proxy.javaClass.getMethod("setConnectionPolicy", BluetoothDevice::class.java, Int::class.java)
+ val method = proxy.javaClass.getMethod(
+ "setConnectionPolicy", BluetoothDevice::class.java, Int::class.java
+ )
method.invoke(proxy, device, 0)
} catch (e: Exception) {
- e.printStackTrace()
+ Log.w(TAG, "we probably do not have BLUETOOTH_PRIVILEGED")
} finally {
bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy)
}
@@ -2686,24 +2931,25 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
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)
- 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)
}
fun connectAudio(context: Context, device: BluetoothDevice?) {
@@ -2713,13 +2959,17 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
if (profile == BluetoothProfile.A2DP) {
try {
- val policyMethod = proxy.javaClass.getMethod("setConnectionPolicy", BluetoothDevice::class.java, Int::class.java)
+ 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
+ connectMethod.invoke(
+ proxy, device
+ ) // reduces the slight delay between allowing and actually connecting
} catch (e: Exception) {
- e.printStackTrace()
+ Log.w(TAG, "we probably do not have BLUETOOTH_PRIVILEGED")
} finally {
bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy)
if (MediaController.pausedWhileTakingOver) {
@@ -2731,26 +2981,26 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
override fun onServiceDisconnected(profile: Int) {}
}, BluetoothProfile.A2DP)
-
- 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)
+// 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)
}
fun setName(name: String) {
@@ -2798,7 +3048,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
e.printStackTrace()
}
telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_NONE)
- isConnectedLocally = false
+// isConnectedLocally = false
// CrossDevice.isAvailable = true
super.onDestroy()
}
@@ -2807,8 +3057,12 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
fun startHeadTracking() {
isHeadTrackingActive = true
- val useAlternatePackets = sharedPreferences.getBoolean("use_alternate_head_tracking_packets", false)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && aacpManager.getControlCommandStatus(AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION)?.value?.get(0)?.toInt() != 1) {
+ val useAlternatePackets =
+ sharedPreferences.getBoolean("use_alternate_head_tracking_packets", true)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && aacpManager.getControlCommandStatus(
+ AACPManager.Companion.ControlCommandIdentifiers.OWNS_CONNECTION
+ )?.value?.get(0)?.toInt() != 1
+ ) {
takeOver("call", startHeadTrackingAgain = true)
Log.d(TAG, "Taking over for head tracking")
} else {
@@ -2823,7 +3077,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
fun stopHeadTracking() {
- val useAlternatePackets = sharedPreferences.getBoolean("use_alternate_head_tracking_packets", false)
+ val useAlternatePackets =
+ sharedPreferences.getBoolean("use_alternate_head_tracking_packets", true)
if (useAlternatePackets) {
aacpManager.sendDataPacket(aacpManager.createAlternateStopHeadTrackingPacket())
} else {
@@ -2833,7 +3088,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
@SuppressLint("MissingPermission")
- fun reconnectFromSavedMac(){
+ fun reconnectFromSavedMac() {
val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter
device = bluetoothAdapter.bondedDevices.find {
it.address == macAddress
@@ -2846,6 +3101,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
}
+ fun isConnected(): Boolean {
+ return if (::socket.isInitialized) socket.isConnected else false
+ }
}
private fun Int.dpToPx(): Int {
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/ui/theme/Theme.kt b/android/app/src/main/java/me/kavishdevar/librepods/ui/theme/Theme.kt
index 5653123..cd96f1f 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/ui/theme/Theme.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/ui/theme/Theme.kt
@@ -61,4 +61,4 @@ fun LibrePodsTheme(
typography = Typography,
content = content
)
-}
\ No newline at end of file
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt
index 655b718..6149666 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt
@@ -55,6 +55,7 @@ class AACPManager {
const val TIPI_3: Byte = 0x0C // Don't know this one
const val SMART_ROUTING_RESP: Byte = 0x11
const val SEND_CONNECTED_MAC: Byte = 0x14
+ const val AUDIO_SOURCE_2: Byte = 0x0C // seems redundant?
}
private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00)
@@ -216,7 +217,7 @@ class AACPManager {
var audioSource: AudioSource? = null
private set
- var eqData = FloatArray(8) { 0.0f }
+ var eqData = FloatArray(8)
private set
var eqOnPhone: Boolean = false
@@ -265,6 +266,7 @@ class AACPManager {
fun onConnectedDevicesReceived(connectedDevices: List)
fun onOwnershipToFalseRequest(sender: String, reasonReverseTapped: Boolean)
fun onShowNearbyUI(sender: String)
+ fun onEQPacketReceived(eqData: FloatArray)
}
fun parseStemPressResponse(data: ByteArray): Pair {
@@ -458,21 +460,27 @@ class AACPManager {
controlCommand.value.joinToString(" ") { "%02X".format(it) }
}"
)
- Log.d(
- TAG, "Control command list is now: ${
- controlCommandStatusList.joinToString(", ") { it ->
- "${it.identifier.name} (${it.identifier.value.toHexString()}) - ${
- it.value.joinToString(
- " "
- ) { "%02X".format(it) }
- }"
+
+ val controlCommandListText = try {
+ controlCommandStatusList.joinToString(", ") { it ->
+ "${it.identifier.name} (${it.identifier.value.toHexString()}) - ${
+ it.value.joinToString(
+ " "
+ ) { "%02X".format(it) }
+ }"
+ }
+ } catch (e: Exception) {
+ e.message
}
- }")
+
+ Log.d(
+ TAG, "Control command list is now: $controlCommandListText")
val controlCommandIdentifier =
ControlCommandIdentifiers.fromByte(controlCommand.identifier)
if (controlCommandIdentifier != null) {
controlCommandListeners[controlCommandIdentifier]?.forEach { listener ->
+ Log.d(TAG, "calling listener for ${controlCommandIdentifier.name}")
listener.onControlCommandReceived(controlCommand)
}
} else {
@@ -585,7 +593,7 @@ class AACPManager {
eqOnMedia = (packet[10] == 0x01.toByte())
eqOnPhone = (packet[11] == 0x01.toByte())
- // there are 4 eqs. i am not sure what those are for, maybe all 4 listening modes, or maybe phone+media left+right, but then there shouldn't be another flag for phone/media enabled. just directly the EQ... weird.
+ // there are 4 eqs. i am not sure what those are for, maybe all 4 listening modes, or maybe phone+media left+right, but then there shouldn't be another flag for phone/media visible. just directly the EQ... weird.
// the EQs are little endian floats
val eq1 = ByteBuffer.wrap(packet, 12, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
val eq2 = ByteBuffer.wrap(packet, 44, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
@@ -594,11 +602,14 @@ class AACPManager {
// for now, taking just the first EQ
eqData = FloatArray(8) { i -> eq1.get(i) }
+
Log.d(TAG, "EQ Data set to: ${eqData.toList()}, eqOnPhone: $eqOnPhone, eqOnMedia: $eqOnMedia")
+
+ callback?.onEQPacketReceived(eqData)
}
Opcodes.INFORMATION -> {
- Log.e(TAG, "Parsing Information Packet")
+ Log.d(TAG, "Parsing Information Packet")
val information = parseInformationPacket(packet)
callback?.onDeviceInformationReceived(information)
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/CrossDevice.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/CrossDevice.kt
deleted file mode 100644
index 026d0a3..0000000
--- a/android/app/src/main/java/me/kavishdevar/librepods/utils/CrossDevice.kt
+++ /dev/null
@@ -1,289 +0,0 @@
-/*
- LibrePods - AirPods liberated from Apple’s ecosystem
- Copyright (C) 2025 LibrePods contributors
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License
- along with this program. If not, see .
-*/
-
-@file:OptIn(ExperimentalEncodingApi::class)
-
-package me.kavishdevar.librepods.utils
-
-import android.annotation.SuppressLint
-import android.bluetooth.BluetoothAdapter
-import android.bluetooth.BluetoothManager
-import android.bluetooth.BluetoothServerSocket
-import android.bluetooth.BluetoothSocket
-import android.bluetooth.le.AdvertiseCallback
-import android.bluetooth.le.AdvertiseData
-import android.bluetooth.le.AdvertiseSettings
-import android.bluetooth.le.BluetoothLeAdvertiser
-import android.content.Context
-import android.content.Intent
-import android.content.SharedPreferences
-import android.os.ParcelUuid
-import android.util.Log
-import androidx.core.content.edit
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.launch
-import me.kavishdevar.librepods.services.ServiceManager
-import java.io.IOException
-import java.util.UUID
-import kotlin.io.encoding.ExperimentalEncodingApi
-
-enum class CrossDevicePackets(val packet: ByteArray) {
- AIRPODS_CONNECTED(byteArrayOf(0x00, 0x01, 0x00, 0x01)),
- AIRPODS_DISCONNECTED(byteArrayOf(0x00, 0x01, 0x00, 0x00)),
- REQUEST_DISCONNECT(byteArrayOf(0x00, 0x02, 0x00, 0x00)),
- REQUEST_BATTERY_BYTES(byteArrayOf(0x00, 0x02, 0x00, 0x01)),
- REQUEST_ANC_BYTES(byteArrayOf(0x00, 0x02, 0x00, 0x02)),
- REQUEST_CONNECTION_STATUS(byteArrayOf(0x00, 0x02, 0x00, 0x03)),
- AIRPODS_DATA_HEADER(byteArrayOf(0x00, 0x04, 0x00, 0x01)),
-}
-
-
-object CrossDevice {
- var initialized = false
- private val uuid = UUID.fromString("1abbb9a4-10e4-4000-a75c-8953c5471342")
- private var serverSocket: BluetoothServerSocket? = null
- private var clientSocket: BluetoothSocket? = null
- private lateinit var bluetoothAdapter: BluetoothAdapter
- private lateinit var bluetoothLeAdvertiser: BluetoothLeAdvertiser
- private const val MANUFACTURER_ID = 0x1234
- private const val MANUFACTURER_DATA = "ALN_AirPods"
- var isAvailable: Boolean = false // set to true when airpods are connected to another device
- var batteryBytes: ByteArray = byteArrayOf()
- var ancBytes: ByteArray = byteArrayOf()
- private lateinit var sharedPreferences: SharedPreferences
- private const val PACKET_LOG_KEY = "packet_log"
- private var earDetectionStatus = listOf(false, false)
- var disconnectionRequested = false
-
- @SuppressLint("MissingPermission")
- fun init(context: Context) {
- CoroutineScope(Dispatchers.IO).launch {
- Log.d("CrossDevice", "Initializing CrossDevice")
- sharedPreferences = context.getSharedPreferences("packet_logs", Context.MODE_PRIVATE)
- sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", false)}
- this@CrossDevice.bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
- this@CrossDevice.bluetoothLeAdvertiser = bluetoothAdapter.bluetoothLeAdvertiser
- // startAdvertising()
- startServer()
- initialized = true
- }
- }
-
- @SuppressLint("MissingPermission")
- private fun startServer() {
- CoroutineScope(Dispatchers.IO).launch {
- if (!bluetoothAdapter.isEnabled) return@launch
-// serverSocket = bluetoothAdapter.listenUsingRfcommWithServiceRecord("ALNCrossDevice", uuid)
- Log.d("CrossDevice", "Server started")
- while (serverSocket != null) {
- if (!bluetoothAdapter.isEnabled) {
- serverSocket?.close()
- break
- }
- if (clientSocket != null) {
- try {
- clientSocket!!.close()
- } catch (e: IOException) {
- e.printStackTrace()
- }
- }
- try {
- val socket = serverSocket!!.accept()
- handleClientConnection(socket)
- } catch (e: IOException) { }
- }
- }
- }
-
- @SuppressLint("MissingPermission", "unused")
- private fun startAdvertising() {
- CoroutineScope(Dispatchers.IO).launch {
- val settings = AdvertiseSettings.Builder()
- .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
- .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
- .setConnectable(true)
- .build()
-
- val data = AdvertiseData.Builder()
- .setIncludeDeviceName(true)
- .addManufacturerData(MANUFACTURER_ID, MANUFACTURER_DATA.toByteArray())
- .addServiceUuid(ParcelUuid(uuid))
- .build()
- try {
- bluetoothLeAdvertiser.startAdvertising(settings, data, advertiseCallback)
- } catch (e: Exception) {
- Log.e("CrossDevice", "Failed to start BLE Advertising: ${e.message}")
- }
- Log.d("CrossDevice", "BLE Advertising started")
- }
- }
-
- private val advertiseCallback = object : AdvertiseCallback() {
- override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
- Log.d("CrossDevice", "BLE Advertising started successfully")
- }
-
- override fun onStartFailure(errorCode: Int) {
- Log.e("CrossDevice", "BLE Advertising failed with error code: $errorCode")
- }
- }
-
- fun setAirPodsConnected(connected: Boolean) {
- if (connected) {
- isAvailable = false
- sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", false)}
- clientSocket?.outputStream?.write(CrossDevicePackets.AIRPODS_CONNECTED.packet)
- } else {
- clientSocket?.outputStream?.write(CrossDevicePackets.AIRPODS_DISCONNECTED.packet)
- // Reset state variables
- isAvailable = true
- }
- }
-
- fun sendReceivedPacket(packet: ByteArray) {
- if (clientSocket == null || clientSocket!!.outputStream != null) {
- return
- }
- clientSocket?.outputStream?.write(CrossDevicePackets.AIRPODS_DATA_HEADER.packet + packet)
- }
-
- private fun logPacket(packet: ByteArray, source: String) {
- val packetHex = packet.joinToString(" ") { "%02X".format(it) }
- val logEntry = "$source: $packetHex"
- val logs = sharedPreferences.getStringSet(PACKET_LOG_KEY, mutableSetOf())?.toMutableSet() ?: mutableSetOf()
- logs.add(logEntry)
- sharedPreferences.edit { putStringSet(PACKET_LOG_KEY, logs)}
- }
-
- @SuppressLint("MissingPermission")
- private fun handleClientConnection(socket: BluetoothSocket) {
- Log.d("CrossDevice", "Client connected")
- notifyAirPodsConnectedRemotely(ServiceManager.getService()?.applicationContext!!)
- clientSocket = socket
- val inputStream = socket.inputStream
- val buffer = ByteArray(1024)
- var bytes: Int
- setAirPodsConnected(ServiceManager.getService()?.isConnectedLocally == true)
- while (true) {
- try {
- bytes = inputStream.read(buffer)
- } catch (e: IOException) {
- e.printStackTrace()
- notifyAirPodsDisconnectedRemotely(ServiceManager.getService()?.applicationContext!!)
- val s = serverSocket?.accept()
- if (s != null) {
- handleClientConnection(s)
- }
- break
- }
- var packet = buffer.copyOf(bytes)
- logPacket(packet, "Relay")
- Log.d("CrossDevice", "Received packet: ${packet.joinToString("") { "%02x".format(it) }}")
- if (bytes == -1) {
- notifyAirPodsDisconnectedRemotely(ServiceManager.getService()?.applicationContext!!)
- break
- } else if (packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet) || packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet + CrossDevicePackets.AIRPODS_DATA_HEADER.packet)) {
- ServiceManager.getService()?.disconnectForCD()
- disconnectionRequested = true
- CoroutineScope(Dispatchers.IO).launch {
- delay(1000)
- disconnectionRequested = false
- }
- } else if (packet.contentEquals(CrossDevicePackets.AIRPODS_CONNECTED.packet)) {
- isAvailable = true
- sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", true)}
- } else if (packet.contentEquals(CrossDevicePackets.AIRPODS_DISCONNECTED.packet)) {
- isAvailable = false
- sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", false)}
- } else if (packet.contentEquals(CrossDevicePackets.REQUEST_BATTERY_BYTES.packet)) {
- Log.d("CrossDevice", "Received battery request, battery data: ${batteryBytes.joinToString("") { "%02x".format(it) }}")
- sendRemotePacket(batteryBytes)
- } else if (packet.contentEquals(CrossDevicePackets.REQUEST_ANC_BYTES.packet)) {
- Log.d("CrossDevice", "Received ANC request")
- sendRemotePacket(ancBytes)
- } else if (packet.contentEquals(CrossDevicePackets.REQUEST_CONNECTION_STATUS.packet)) {
- Log.d("CrossDevice", "Received connection status request")
- sendRemotePacket(if (ServiceManager.getService()?.isConnectedLocally == true) CrossDevicePackets.AIRPODS_CONNECTED.packet else CrossDevicePackets.AIRPODS_DISCONNECTED.packet)
- } else {
- if (packet.sliceArray(0..3).contentEquals(CrossDevicePackets.AIRPODS_DATA_HEADER.packet)) {
- isAvailable = true
- sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", true) }
- if (packet.size % 2 == 0) {
- val half = packet.size / 2
- if (packet.sliceArray(0 until half).contentEquals(packet.sliceArray(half until packet.size))) {
- Log.d("CrossDevice", "Duplicated packet, trimming")
- packet = packet.sliceArray(0 until half)
- }
- }
- var trimmedPacket = packet.drop(CrossDevicePackets.AIRPODS_DATA_HEADER.packet.size).toByteArray()
- Log.d("CrossDevice", "Received relayed packet: ${trimmedPacket.joinToString("") { "%02x".format(it) }}")
- if (ServiceManager.getService()?.isConnectedLocally == true) {
- val packetInHex = trimmedPacket.joinToString("") { "%02x".format(it) }
-// ServiceManager.getService()?.sendPacket(packetInHex)
- } else if (ServiceManager.getService()?.batteryNotification?.isBatteryData(trimmedPacket) == true) {
- batteryBytes = trimmedPacket
- ServiceManager.getService()?.batteryNotification?.setBattery(trimmedPacket)
- Log.d("CrossDevice", "Battery data: ${ServiceManager.getService()?.batteryNotification?.getBattery()[0]?.level}")
- ServiceManager.getService()?.updateBattery()
- ServiceManager.getService()?.sendBatteryBroadcast()
- ServiceManager.getService()?.sendBatteryNotification()
- } else if (ServiceManager.getService()?.ancNotification?.isANCData(trimmedPacket) == true) {
- ServiceManager.getService()?.ancNotification?.setStatus(trimmedPacket)
- ServiceManager.getService()?.sendANCBroadcast()
- ServiceManager.getService()?.updateNoiseControlWidget()
- ancBytes = trimmedPacket
- } else if (ServiceManager.getService()?.earDetectionNotification?.isEarDetectionData(trimmedPacket) == true) {
- Log.d("CrossDevice", "Ear detection data: ${trimmedPacket.joinToString("") { "%02x".format(it) }}")
- ServiceManager.getService()?.earDetectionNotification?.setStatus(trimmedPacket)
- val newEarDetectionStatus = listOf(
- ServiceManager.getService()?.earDetectionNotification?.status?.get(0) == 0x00.toByte(),
- ServiceManager.getService()?.earDetectionNotification?.status?.get(1) == 0x00.toByte()
- )
- if (earDetectionStatus == listOf(false, false) && newEarDetectionStatus.contains(true)) {
- ServiceManager.getService()?.applicationContext?.sendBroadcast(
- Intent("me.kavishdevar.librepods.cross_device_island")
- )
- }
- earDetectionStatus = newEarDetectionStatus
- }
- }
- }
- }
- }
-
- fun sendRemotePacket(byteArray: ByteArray) {
- if (clientSocket == null || clientSocket!!.outputStream == null) {
- return
- }
- clientSocket?.outputStream?.write(byteArray)
- clientSocket?.outputStream?.flush()
- logPacket(byteArray, "Sent")
- Log.d("CrossDevice", "Sent packet to remote device")
- }
-
- fun notifyAirPodsConnectedRemotely(context: Context) {
- val intent = Intent("me.kavishdevar.librepods.AIRPODS_CONNECTED_REMOTELY")
- context.sendBroadcast(intent)
- }
- fun notifyAirPodsDisconnectedRemotely(context: Context) {
- val intent = Intent("me.kavishdevar.librepods.AIRPODS_DISCONNECTED_REMOTELY")
- context.sendBroadcast(intent)
- }
-}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/IslandWindow.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/IslandWindow.kt
index 09279be..c93ca15 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/utils/IslandWindow.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/IslandWindow.kt
@@ -240,6 +240,7 @@ class IslandWindow(private val context: Context) {
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.WRAP_CONTENT
)
+
containerView.addView(islandView, containerParams)
params = WindowManager.LayoutParams(
@@ -379,7 +380,11 @@ class IslandWindow(private val context: Context) {
videoView.start()
}
- windowManager.addView(containerView, params)
+ try {
+ windowManager.addView(containerView, params)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
islandView.post {
initialHeight = islandView.height
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/PopupWindow.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/PopupWindow.kt
index a60e2ef..e93e709 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/utils/PopupWindow.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/PopupWindow.kt
@@ -139,7 +139,11 @@ class PopupWindow(
vid.start()
}
- mWindowManager.addView(mView, mParams)
+ try {
+ mWindowManager.addView(mView, mParams)
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
val displayMetrics = mView.context.resources.displayMetrics
val screenHeight = displayMetrics.heightPixels
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/RootlessSupport.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/RootlessSupport.kt
new file mode 100644
index 0000000..d45e0bb
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/RootlessSupport.kt
@@ -0,0 +1,49 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+package me.kavishdevar.librepods.utils
+
+import android.os.Build
+import me.kavishdevar.librepods.BuildConfig
+
+fun isSupported(): Boolean {
+ if (BuildConfig.PLAY_BUILD) {
+ val isPixel = Build.MANUFACTURER.lowercase() == "google"
+ val isOppoOrOnePlus = Build.MANUFACTURER.lowercase() in listOf("oneplus", "oppo")
+
+ if (isPixel) {
+ when (Build.VERSION.SDK_INT) {
+ 36 -> {
+ return Build.ID == "CP1A.260305.018" || Build.ID == "CP1A.260405.005"
+ }
+
+ 37 -> {
+ return true
+ }
+ }
+ } else if (isOppoOrOnePlus) {
+ return true
+ }
+ }
+ return true
+}
+
+
+/*fun isSupported(): Boolean {
+ return true
+}*/
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/TransparencyUtils.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/TransparencyUtils.kt
index f085b9b..1bbd46c 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/utils/TransparencyUtils.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/TransparencyUtils.kt
@@ -139,7 +139,7 @@ fun parseTransparencySettingsResponse(data: ByteArray): TransparencySettings {
private var debounceJob: Job? = null
-fun sendTransparencySettings(attManager: ATTManager, transparencySettings: TransparencySettings) {
+fun sendTransparencySettings(writer: (ATTHandles, ByteArray) -> Unit, transparencySettings: TransparencySettings) {
debounceJob?.cancel()
debounceJob = CoroutineScope(Dispatchers.IO).launch {
delay(100)
@@ -171,7 +171,7 @@ fun sendTransparencySettings(attManager: ATTManager, transparencySettings: Trans
}
val data = buffer.array()
- attManager.write(ATTHandles.TRANSPARENCY, value = data)
+ writer(ATTHandles.TRANSPARENCY, data)
} catch (e: IOException) {
e.printStackTrace()
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/viewmodel/AirPodsViewModel.kt b/android/app/src/main/java/me/kavishdevar/librepods/viewmodel/AirPodsViewModel.kt
new file mode 100644
index 0000000..611bf5b
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/viewmodel/AirPodsViewModel.kt
@@ -0,0 +1,417 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+package me.kavishdevar.librepods.viewmodel
+
+import android.app.Activity
+import android.content.BroadcastReceiver
+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
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import me.kavishdevar.librepods.billing.BillingManager
+import me.kavishdevar.librepods.constants.AirPodsNotifications
+import me.kavishdevar.librepods.constants.Battery
+import me.kavishdevar.librepods.constants.StemAction
+import me.kavishdevar.librepods.data.ControlCommandRepository
+import me.kavishdevar.librepods.services.AirPodsService
+import me.kavishdevar.librepods.utils.AACPManager
+import me.kavishdevar.librepods.utils.AACPManager.Companion.ControlCommandIdentifiers
+import me.kavishdevar.librepods.utils.ATTHandles
+import me.kavishdevar.librepods.utils.AirPodsInstance
+import me.kavishdevar.librepods.utils.AirPodsModels
+import me.kavishdevar.librepods.utils.Capability
+
+@Suppress("ArrayInDataClass")
+data class AirPodsUiState(
+ val deviceName: String,
+
+ val isLocallyConnected: Boolean = false,
+
+ val instance: AirPodsInstance? = null,
+ val capabilities: Set = emptySet(),
+
+ val controlStates: Map = emptyMap(),
+ val offListeningMode: Boolean = true,
+
+ val battery: List = emptyList(),
+ val ancMode: Int = 3,
+
+ val modelName: String = "",
+ val actualModel: String = "",
+ val serialNumbers: List = emptyList(),
+ val version1: String = "",
+ val version2: String = "",
+ val version3: String = "",
+
+ val headTrackingActive: Boolean = false,
+ val headGesturesEnabled: Boolean = true,
+
+ val eqData: FloatArray = floatArrayOf(),
+
+ val automaticEarDetectionEnabled: Boolean = true,
+ val automaticConnectionEnabled: Boolean = true,
+
+ val leftAction: StemAction = StemAction.CYCLE_NOISE_CONTROL_MODES,
+ val rightAction: StemAction = StemAction.CYCLE_NOISE_CONTROL_MODES,
+
+ val isPremium: Boolean = false,
+)
+
+class AirPodsViewModel(
+ private val service: AirPodsService,
+ private val sharedPreferences: SharedPreferences,
+ private val controlRepo: ControlCommandRepository,
+ private val appContext: Context
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(AirPodsUiState(deviceName = sharedPreferences.getString("name", "AirPods Pro") ?: "AirPods Pro"))
+ val uiState: StateFlow = _uiState
+
+ private val listeners = mutableMapOf<
+ ControlCommandIdentifiers,
+ AACPManager.ControlCommandListener
+ >()
+
+ private lateinit var broadcastReceiver: BroadcastReceiver
+
+ private val _cameraAction = MutableStateFlow(
+ sharedPreferences.getString("camera_action", null)
+ ?.let { value -> AACPManager.Companion.StemPressType.entries.find { it.name == value } }
+ )
+
+ val cameraAction: StateFlow = _cameraAction
+
+ fun setCameraAction(action: AACPManager.Companion.StemPressType?) {
+ sharedPreferences.edit {
+ if (action == null) remove("camera_action")
+ else putString("camera_action", action.name)
+ }
+ _cameraAction.value = action
+ }
+
+ init {
+ observeBroadcasts()
+ loadName()
+ loadInstance()
+ loadSharedPreferences()
+ setupControlObservers()
+ observeBilling()
+ }
+
+ override fun onCleared() {
+ listeners.forEach { (id, listener) ->
+ controlRepo.remove(id, listener)
+ }
+
+ appContext.unregisterReceiver(broadcastReceiver)
+
+ super.onCleared()
+ }
+
+ private fun loadName() {
+ val name = sharedPreferences.getString("name", "AirPods Pro")!!
+ _uiState.update { it.copy(deviceName = name) }
+ }
+
+ private fun observeBilling() {
+ viewModelScope.launch {
+ BillingManager.provider.isPremium.collect { premium ->
+
+ if (!premium) {
+ setControlCommandBoolean(ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG, false)
+ setHeadGesturesEnabled(false)
+ }
+
+ _uiState.update { it.copy(isPremium = premium) }
+ }
+ }
+ }
+
+ private fun observeBroadcasts() {
+ broadcastReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ when (intent?.action) {
+ AirPodsNotifications.AIRPODS_CONNECTED -> {
+ _uiState.update {
+ it.copy(isLocallyConnected = true)
+ }
+ }
+
+ AirPodsNotifications.AIRPODS_DISCONNECTED -> {
+ _uiState.update {
+ it.copy(isLocallyConnected = false)
+ }
+ }
+
+ AirPodsNotifications.BATTERY_DATA -> {
+ val data = intent.getParcelableArrayListExtra("data", Battery::class.java)?.toList() ?: emptyList()
+ _uiState.update {
+ it.copy(battery = data)
+ }
+ }
+
+ AirPodsNotifications.EQ_DATA -> {
+ val data = intent.getFloatArrayExtra("eqData") ?: floatArrayOf()
+
+ _uiState.update {
+ it.copy(eqData = data)
+ }
+ }
+
+ AirPodsNotifications.AIRPODS_INFORMATION_UPDATED -> {
+ loadInstance()
+ }
+ }
+ }
+ }
+
+ val filter = IntentFilter().apply {
+ addAction(AirPodsNotifications.AIRPODS_CONNECTED)
+ addAction(AirPodsNotifications.AIRPODS_DISCONNECTED)
+ addAction(AirPodsNotifications.BATTERY_DATA)
+ addAction(AirPodsNotifications.EQ_DATA)
+ addAction(AirPodsNotifications.AIRPODS_INFORMATION_UPDATED)
+ }
+
+ appContext.registerReceiver(
+ broadcastReceiver,
+ filter,
+ Context.RECEIVER_NOT_EXPORTED
+ )
+ }
+
+ fun setControlCommandValue(
+ identifier: ControlCommandIdentifiers,
+ value: ByteArray
+ ) {
+ controlRepo.setValue(identifier, value)
+ _uiState.update {
+ it.copy(
+ controlStates = it.controlStates + (identifier to value)
+ )
+ }
+ }
+
+ fun setControlCommandBoolean(
+ identifier: ControlCommandIdentifiers,
+ enabled: Boolean
+ ) {
+ setControlCommandValue(
+ identifier,
+ if (enabled) byteArrayOf(0x01) else byteArrayOf(0x02)
+ )
+ }
+
+ fun setControlCommandInt(
+ identifier: ControlCommandIdentifiers,
+ value: Int
+ ) {
+ setControlCommandValue(identifier, byteArrayOf(value.toByte()))
+ }
+
+ fun setControlCommandByte(
+ identifier: ControlCommandIdentifiers,
+ value: Byte
+ ) {
+ setControlCommandValue(identifier, byteArrayOf(value))
+ }
+
+ fun observeControl(identifier: ControlCommandIdentifiers) {
+ val listener = controlRepo.observe(identifier) { value ->
+ _uiState.update { state ->
+ val current = state.controlStates[identifier]
+ if (current?.contentEquals(value) == true) return@update state
+
+ state.copy(
+ controlStates = state.controlStates + (identifier to value)
+ )
+ }
+ }
+
+ listeners[identifier] = listener
+ }
+
+ // I'm lazy, sorry.
+ fun setupControlObservers() {
+ val identifiersList = listOf(
+ ControlCommandIdentifiers.MIC_MODE,
+ ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
+ ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
+ ControlCommandIdentifiers.LISTENING_MODE_CONFIGS,
+ ControlCommandIdentifiers.ONE_BUD_ANC_MODE,
+ ControlCommandIdentifiers.LISTENING_MODE,
+ ControlCommandIdentifiers.AUTO_ANSWER_MODE,
+ ControlCommandIdentifiers.CHIME_VOLUME,
+ ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
+ ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG,
+ ControlCommandIdentifiers.VOLUME_SWIPE_MODE,
+ ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG,
+ ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
+ ControlCommandIdentifiers.HEARING_AID,
+ ControlCommandIdentifiers.AUTO_ANC_STRENGTH,
+ ControlCommandIdentifiers.HPS_GAIN_SWIPE,
+ ControlCommandIdentifiers.HEARING_ASSIST_CONFIG,
+ ControlCommandIdentifiers.ALLOW_OFF_OPTION,
+ ControlCommandIdentifiers.STEM_CONFIG,
+ ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG,
+ ControlCommandIdentifiers.ALLOW_AUTO_CONNECT,
+ ControlCommandIdentifiers.EAR_DETECTION_CONFIG,
+ ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG,
+ ControlCommandIdentifiers.OWNS_CONNECTION,
+ ControlCommandIdentifiers.PPE_TOGGLE_CONFIG,
+ )
+ for (identifier in identifiersList) {
+ observeControl(identifier)
+ }
+ }
+
+ fun refreshInitialData() {
+ service.let { service ->
+ _uiState.update {
+ it.copy(
+ isLocallyConnected = service.isConnected(),
+ battery = service.getBattery()
+ )
+ }
+ }
+ }
+
+ private fun loadSharedPreferences() {
+ val offListeningModeEnabled = sharedPreferences.getBoolean("off_listening_mode", true)
+ val automaticEarDetectionEnabled = sharedPreferences.getBoolean("automatic_ear_detection", true)
+ val automaticConnectionEnabled = sharedPreferences.getBoolean("automatic_connection_ctrl_cmd", true)
+ val headGesturesEnabled = sharedPreferences.getBoolean("head_gestures", true)
+ val leftAction = StemAction.valueOf(sharedPreferences.getString("left_long_press_action", "CYCLE_NOISE_CONTROL_MODES") ?: "CYCLE_NOISE_CONTROL_MODES")
+ val rightAction = StemAction.valueOf(sharedPreferences.getString("right_long_press_action", "CYCLE_NOISE_CONTROL_MODES") ?: "CYCLE_NOISE_CONTROL_MODES")
+
+ _uiState.update {
+ it.copy(
+ offListeningMode = offListeningModeEnabled,
+ automaticEarDetectionEnabled = automaticEarDetectionEnabled,
+ automaticConnectionEnabled = automaticConnectionEnabled,
+ headGesturesEnabled = headGesturesEnabled,
+ leftAction = leftAction,
+ rightAction = rightAction
+ )
+ }
+ }
+
+ 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)
+ }
+ }
+
+ fun setHeadGesturesEnabled(enabled: Boolean) {
+ sharedPreferences.edit { putBoolean("head_gestures", enabled) }
+ _uiState.update {
+ it.copy(headGesturesEnabled = enabled)
+ }
+ }
+
+ private fun loadInstance() {
+ val instance = service.airpodsInstance ?: AirPodsInstance(
+ name = "AirPods",
+ model = AirPodsModels.getModelByModelNumber("A3049")!!,
+ actualModelNumber = "A3049",
+ aacpManager = service.aacpManager,
+ serialNumber = null,
+ leftSerialNumber = null,
+ rightSerialNumber = null,
+ version1 = null,
+ version2 = null,
+ version3 = null,
+ attManager = null
+ )
+
+ _uiState.update {
+ it.copy(
+ capabilities = instance.model.capabilities,
+ instance = instance,
+ modelName = instance.model.displayName,
+ actualModel = instance.actualModelNumber,
+ serialNumbers = listOf(instance.serialNumber ?: "", instance.leftSerialNumber ?: "", instance.rightSerialNumber ?: ""),
+ version1 = instance.version1 ?: "",
+ version2 = instance.version2 ?: "",
+ version3 = instance.version3 ?: ""
+ )
+ }
+ }
+
+ fun reconnectFromSavedMac() {
+ service.reconnectFromSavedMac()
+ }
+
+ fun setName(name: String) {
+ service.setName(name)
+ }
+
+ fun startHeadTracking() {
+ service.startHeadTracking()
+ _uiState.update { it.copy(headTrackingActive = true) }
+ }
+
+ fun stopHeadTracking() {
+ service.stopHeadTracking()
+ _uiState.update { it.copy(headTrackingActive = false) }
+ }
+
+ fun setATTCharacteristicValue(handle: ATTHandles, value: ByteArray) {
+ service.attManager?.write(handle, value)
+ }
+
+ fun getATTCharacteristicValue(handle: ATTHandles): ByteArray? {
+ return service.attManager?.read(handle)
+ }
+
+ fun setAutomaticEarDetectionEnabled(enabled: Boolean) {
+ sharedPreferences.edit { putBoolean("automatic_ear_detection", enabled) }
+ setControlCommandBoolean(ControlCommandIdentifiers.EAR_DETECTION_CONFIG, enabled)
+ _uiState.update {
+ it.copy(
+ automaticEarDetectionEnabled = enabled
+ )
+ }
+ }
+
+ fun setAutomaticConnectionEnabled(enabled: Boolean) {
+ sharedPreferences.edit { putBoolean("automatic_connection_ctrl_cmd", enabled) }
+ setControlCommandBoolean(ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG, enabled)
+ _uiState.update {
+ it.copy(
+ automaticConnectionEnabled = enabled
+ )
+ }
+ }
+
+ fun purchase(context: Context) {
+ BillingManager.provider.purchase(context as Activity)
+ }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/viewmodel/AppSettingsViewModel.kt b/android/app/src/main/java/me/kavishdevar/librepods/viewmodel/AppSettingsViewModel.kt
new file mode 100644
index 0000000..5508a65
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/viewmodel/AppSettingsViewModel.kt
@@ -0,0 +1,158 @@
+package me.kavishdevar.librepods.viewmodel
+
+import android.app.Activity
+import android.app.Application
+import android.content.Context
+import androidx.core.content.edit
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import me.kavishdevar.librepods.billing.BillingManager
+import kotlin.math.roundToInt
+
+data class AppSettingsUiState(
+ val showPhoneBatteryInWidget: Boolean = false,
+ val conversationalAwarenessPauseMusicEnabled: Boolean = false,
+ val relativeConversationalAwarenessVolumeEnabled: Boolean = true,
+ val disconnectWhenNotWearing: Boolean = false,
+ val takeoverWhenDisconnected: Boolean = false,
+ val takeoverWhenIdle: Boolean = false,
+ val takeoverWhenMusic: Boolean = false,
+ val takeoverWhenCall: Boolean = false,
+ val takeoverWhenRingingCall: Boolean = false,
+ val takeoverWhenMediaStart: Boolean = false,
+ val useAlternateHeadTrackingPackets: Boolean = true,
+ val conversationalAwarenessVolume: Float = 43f,
+ val showCameraDialog: Boolean = false,
+ val cameraPackageValue: String = "",
+ val cameraPackageError: String? = null,
+ val isPremium: Boolean = false
+)
+
+class AppSettingsViewModel(application: Application) : AndroidViewModel(application) {
+ private val sharedPreferences = application.getSharedPreferences("settings", Context.MODE_PRIVATE)
+
+ private val _uiState = MutableStateFlow(AppSettingsUiState())
+ val uiState = _uiState.asStateFlow()
+
+ init {
+ loadSettings()
+ observeBilling()
+ }
+
+ private fun observeBilling() {
+ viewModelScope.launch {
+ BillingManager.provider.isPremium.collect { premium ->
+ _uiState.update { it.copy(isPremium = premium) }
+ }
+ }
+ }
+
+ private fun loadSettings() {
+ _uiState.update { currentState ->
+ currentState.copy(
+ showPhoneBatteryInWidget = sharedPreferences.getBoolean("show_phone_battery_in_widget", false),
+ conversationalAwarenessPauseMusicEnabled = sharedPreferences.getBoolean("conversational_awareness_pause_music", false),
+ relativeConversationalAwarenessVolumeEnabled = sharedPreferences.getBoolean("relative_conversational_awareness_volume", true),
+ disconnectWhenNotWearing = sharedPreferences.getBoolean("disconnect_when_not_wearing", false),
+ takeoverWhenDisconnected = sharedPreferences.getBoolean("takeover_when_disconnected", false),
+ takeoverWhenIdle = sharedPreferences.getBoolean("takeover_when_idle", false),
+ takeoverWhenMusic = sharedPreferences.getBoolean("takeover_when_music", false),
+ takeoverWhenCall = sharedPreferences.getBoolean("takeover_when_call", false),
+ takeoverWhenRingingCall = sharedPreferences.getBoolean("takeover_when_ringing_call", false),
+ takeoverWhenMediaStart = sharedPreferences.getBoolean("takeover_when_media_start", false),
+ useAlternateHeadTrackingPackets = sharedPreferences.getBoolean("use_alternate_head_tracking_packets", true),
+ conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 43).toFloat(),
+ cameraPackageValue = sharedPreferences.getString("custom_camera_package", "") ?: ""
+ )
+ }
+ }
+
+ fun setShowPhoneBatteryInWidget(enabled: Boolean) {
+ sharedPreferences.edit { putBoolean("show_phone_battery_in_widget", enabled) }
+ _uiState.update { it.copy(showPhoneBatteryInWidget = enabled) }
+ }
+
+ fun setConversationalAwarenessPauseMusicEnabled(enabled: Boolean) {
+ sharedPreferences.edit { putBoolean("conversational_awareness_pause_music", enabled) }
+ _uiState.update { it.copy(conversationalAwarenessPauseMusicEnabled = enabled) }
+ }
+
+ fun setRelativeConversationalAwarenessVolumeEnabled(enabled: Boolean) {
+ sharedPreferences.edit { putBoolean("relative_conversational_awareness_volume", enabled) }
+ _uiState.update { it.copy(relativeConversationalAwarenessVolumeEnabled = enabled) }
+ }
+
+ fun setDisconnectWhenNotWearing(enabled: Boolean) {
+ sharedPreferences.edit { putBoolean("disconnect_when_not_wearing", enabled) }
+ _uiState.update { it.copy(disconnectWhenNotWearing = enabled) }
+ }
+
+ fun setTakeoverWhenDisconnected(enabled: Boolean) {
+ sharedPreferences.edit { putBoolean("takeover_when_disconnected", enabled) }
+ _uiState.update { it.copy(takeoverWhenDisconnected = enabled) }
+ }
+
+ fun setTakeoverWhenIdle(enabled: Boolean) {
+ sharedPreferences.edit { putBoolean("takeover_when_idle", enabled) }
+ _uiState.update { it.copy(takeoverWhenIdle = enabled) }
+ }
+
+ fun setTakeoverWhenMusic(enabled: Boolean) {
+ sharedPreferences.edit { putBoolean("takeover_when_music", enabled) }
+ _uiState.update { it.copy(takeoverWhenMusic = enabled) }
+ }
+
+ fun setTakeoverWhenCall(enabled: Boolean) {
+ sharedPreferences.edit { putBoolean("takeover_when_call", enabled) }
+ _uiState.update { it.copy(takeoverWhenCall = enabled) }
+ }
+
+ fun setTakeoverWhenRingingCall(enabled: Boolean) {
+ sharedPreferences.edit { putBoolean("takeover_when_ringing_call", enabled) }
+ _uiState.update { it.copy(takeoverWhenRingingCall = enabled) }
+ }
+
+ fun setTakeoverWhenMediaStart(enabled: Boolean) {
+ sharedPreferences.edit { putBoolean("takeover_when_media_start", enabled) }
+ _uiState.update { it.copy(takeoverWhenMediaStart = enabled) }
+ }
+
+ fun setUseAlternateHeadTrackingPackets(enabled: Boolean) {
+ sharedPreferences.edit { putBoolean("use_alternate_head_tracking_packets", enabled) }
+ _uiState.update { it.copy(useAlternateHeadTrackingPackets = enabled) }
+ }
+
+ fun setConversationalAwarenessVolume(volume: Float) {
+ sharedPreferences.edit { putInt("conversational_awareness_volume", volume.roundToInt()) }
+ _uiState.update { it.copy(conversationalAwarenessVolume = volume) }
+ }
+
+ fun setShowCameraDialog(show: Boolean) {
+ _uiState.update { it.copy(showCameraDialog = show) }
+ }
+
+ fun setCameraPackageValue(value: String) {
+ _uiState.update { it.copy(cameraPackageValue = value) }
+ }
+
+ fun setCameraPackageError(error: String?) {
+ _uiState.update { it.copy(cameraPackageError = error) }
+ }
+
+ fun saveCameraPackage() {
+ if (_uiState.value.cameraPackageValue.isBlank()) {
+ sharedPreferences.edit { remove("custom_camera_package") }
+ } else {
+ sharedPreferences.edit { putString("custom_camera_package", _uiState.value.cameraPackageValue) }
+ }
+ setShowCameraDialog(false)
+ }
+
+ fun purchase(context: Context) {
+ BillingManager.provider.purchase(context as Activity)
+ }
+}
diff --git a/android/app/src/main/res/value-it/strings.xml b/android/app/src/main/res/value-it/strings.xml
index cf02b8e..5933b08 100644
--- a/android/app/src/main/res/value-it/strings.xml
+++ b/android/app/src/main/res/value-it/strings.xml
@@ -1,217 +1,213 @@
-
- LibrePods
- Libera i tuoi AirPods dall'ecosistema Apple.
- Visualizza lo stato della batteria dei tuoi AirPods direttamente dalla schermata principale!
- Accessibilità
- Volume Tono
- Regola il volume del tono degli effetti sonori riprodotti dagli AirPods.
- Audio
- Audio Adattivo
- Personalizza Audio Adattivo
- L'audio adattivo risponde dinamicamente al tuo ambiente e cancella o permette i rumori esterni. Puoi personalizzare l'Audio Adattivo per permettere più o meno rumore.
- Auricolari
- Custodia
- Test
- Nome
- Modalità di Ascolto
- Spento
- Trasparenza
- Adattivo
- Cancellazione del Rumore
- Premi e Tieni Premuto sugli AirPods
- Premi e tieni premuto sullo stelo per alternare tra le modalità di ascolto selezionate.
- Gesti della Testa
- Sinistra
- Destra
- Consapevolezza Conversazionale
- Abbassa il volume dei contenuti multimediali e riduce il rumore di fondo quando inizi a parlare con altre persone.
- Volume Personalizzato
- Regola il volume dei contenuti multimediali in risposta al tuo ambiente.
- Cancellazione del Rumore con un Solo AirPod
- Consenti agli AirPods di essere messi in modalità di cancellazione del rumore quando è presente un solo AirPod nell'orecchio.
- Controllo Volume
- Regola il volume scorrendo verso l'alto o verso il basso sul sensore situato sullo stelo degli AirPods Pro.
- AirPods non connessi
- Si prega di connettere i tuoi AirPods per accedere alle impostazioni.
- Indietro
- Personalizzazioni
- Volume relativo
- Riduce a una percentuale del volume corrente invece del volume massimo.
- Metti in Pausa la Musica
- Quando inizi a parlare, la musica verrà messa in pausa.
- ESEMPIO
- Aggiungi widget
- Controlla la Modalità di Controllo del Rumore direttamente dalla tua Schermata Principale.
- Connesso
- Connesso a Linux
- Connesso
- Spostato su Linux
- Spostato su %1$s
- Riconnetti dalla notifica
- Tracciamento della Testa
- Annuisci per rispondere alle chiamate e scuoti la testa per rifiutarle.
- Generale
- Azione del Tile Impostazioni Rapide
- Mostra la finestra di dialogo per il controllo del rumore al tocco.
- Alterna tra le modalità al tocco.
- Sviluppatore
- Apri le Impostazioni degli AirPods
- Gestisci le funzionalità e le preferenze degli AirPods
- Rilevamento Automatico dell'Orecchio
- Riproduzione Automatica
- Pausa Automatica
- Risoluzione dei Problemi
- Raccogli i log per diagnosticare i problemi con la connessione degli AirPods
- Raccogli Log
- Log Salvati
- Nessun log salvato trovato
- Preferenze di Connessione Automatica
- Connetti ai tuoi AirPods quando il loro stato è:
- Disconnesso
- Gli AirPods non sono connessi a un dispositivo
- Inattivo
- Un dispositivo è connesso ai tuoi AirPods, ma non riproduce contenuti multimediali né è in chiamata
- Riproduzione di contenuti multimediali
- Un dispositivo sta riproducendo contenuti multimediali sui tuoi AirPods
- In chiamata
- Un dispositivo è in chiamata con i tuoi AirPods
- Connetti agli AirPods quando il tuo telefono è:
- Ricezione di una chiamata
- Il tuo telefono inizia a squillare
- Avvio della riproduzione di contenuti multimediali
- Il tuo telefono inizia a riprodurre contenuti multimediali
- Annulla
- Puoi personalizzare la modalità Trasparenza per i tuoi AirPods Pro per aiutarti a sentire ciò che ti circonda.
- La Riduzione dei Suoni Forti può ridurre attivamente la tua esposizione ai forti rumori ambientali quando in modalità Trasparenza e Adattiva. La Riduzione dei Suoni Forti non è attiva in modalità Spento.
- Riduzione dei Suoni Forti
- Controlli Chiamata
- Connetti automaticamente a questo dispositivo
- Quando abilitato, gli AirPods tenteranno di connettersi automaticamente a questo dispositivo. Altrimenti, tenteranno di connettersi automaticamente solo se sono stati connessi in precedenza.
- Metti in pausa i contenuti multimediali quando ti addormenti
- Modalità Ascolto Disattivata
- Quando questa opzione è attiva, le modalità di ascolto degli AirPods includeranno un'opzione "Spento". I livelli di suono forti non vengono ridotti quando la modalità di ascolto è impostata su "Spento".
- Microfono
- Modalità Microfono
- Automatico
- Sempre Destro
- Sempre Sinistro
- Rispondi alla chiamata
- Silenzia/Riattiva
- Riaggancia
- Premi una Volta
- Premi Due Volte
- Apparecchio Acustico
- Regolazioni
- Scorri per controllare l'amplificazione
- Quando sei in modalità Trasparenza e nessun contenuto multimediale è in riproduzione, scorri verso l'alto e verso il basso sui controlli Touch dei tuoi AirPods Pro per aumentare o diminuire l'amplificazione dei suoni ambientali.
- Modalità Trasparenza
- Personalizza la Modalità Trasparenza
- Velocità di Pressione
- Regola la velocità richiesta per premere due o tre volte sui tuoi AirPods.
- Durata della Pressione Prolungata
- Regola la durata richiesta per premere e tenere premuto sui tuoi AirPods.
- Velocità di Scorrimento del Volume
- Per evitare regolazioni involontarie del volume, seleziona il tempo di attesa preferito tra gli scorrimenti.
- Equalizzatore
- Applica EQ a
- Telefono
- Media
- Banda %d
- Predefinito
- Più lento
- Il più lento
- Più lungo
- Il più lungo
- Più scuro
- Più luminoso
- Meno
- Di più
- Amplificazione
- Bilanciamento
- Tono
- Riduzione del Rumore Ambientale
- Potenziamento Conversazione
- Potenziamento Conversazione concentra i tuoi AirPods Pro sulla persona che parla di fronte a te, rendendo più facile sentire in una conversazione faccia a faccia.
- Gli AirPods possono utilizzare i risultati di un test dell'udito per apportare modifiche che migliorano la chiarezza delle voci e dei suoni intorno a te.\n\nApparecchio Acustico è destinato solo a persone con perdita dell'udito da lieve a moderata.
- Assistenza Media
- Gli AirPods Pro possono utilizzare i risultati di un test dell'udito per apportare modifiche che migliorano la chiarezza di musica, video e chiamate.
- Regola Musica e Video
- Regola Chiamate
- Widget
- Mostra la batteria del telefono nel widget
- Visualizza il livello della batteria del tuo telefono nel widget accanto alla batteria degli AirPods
- Volume Consapevolezza Conversazionale
- Tile Impostazioni Rapide
- Apri finestra di dialogo per il controllo
- Se disabilitato, cliccando sul QS si scorrerà tra le modalità. Se abilitato, verrà mostrata una finestra di dialogo per controllare la modalità di controllo del rumore e la consapevolezza conversazionale.
- Disconnetti AirPods quando non indossati
- Sarai ancora in grado di controllarli con l'app - questo disconnette solo l'audio.
- Opzioni Avanzate
- Imposta Chiave di Risoluzione Identità (IRK)
- Imposta manualmente il valore IRK utilizzato per risolvere gli indirizzi casuali BLE
- Imposta Chiave di Crittografia
- Imposta manualmente il valore ENC_KEY utilizzato per decrittografare le pubblicità BLE
- Utilizza pacchetti alternativi di tracciamento della testa
- Abilita questo se il tracciamento della testa non funziona per te. Questo invia dati diversi agli AirPods per richiedere/interrompere i dati di tracciamento della testa.
- Comportati come un dispositivo Apple
- Abilita la connettività multi-dispositivo e le funzionalità di Accessibilità come la personalizzazione della modalità Trasparenza (amplificazione, tono, riduzione del rumore ambientale, potenziamento conversazione ed EQ)
- Potrebbe essere instabile!! Un massimo di due dispositivi possono essere connessi ai tuoi AirPods. Se li stai usando con un dispositivo Apple come un iPad o un Mac, connetti prima quel dispositivo e poi il tuo Android.
- Reimposta Offset Hook
- Questo cancellerà l'offset hook corrente e richiederà di rifare la procedura di configurazione. Sei sicuro di voler continuare?
- Reimposta
- Offset hook è stato resettato. Reindirizzamento alla configurazione...
- Impossibile reimpostare l'offset hook
- IRK impostata correttamente
- Chiave di crittografia impostata correttamente
- Valore Esadecimale IRK
- Valore Esadecimale ENC_KEY
- Inserisci IRK di 16 byte come stringa esadecimale (32 caratteri):
- Inserisci ENC_KEY di 16 byte come stringa esadecimale (32 caratteri):
- Devono essere esattamente 32 caratteri esadecimali
- Errore durante la conversione esadecimale:
- Offset trovato, riavviare il processo Bluetooth
- Assistente Digitale
- Attivo
- Telecomando Fotocamera
- Controllo Fotocamera
- Scatta una foto, avvia o interrompi la registrazione e altro utilizzando Premere una Volta o Premere e Tenere Premuto. Quando si utilizzano gli AirPods per le azioni della fotocamera, se si seleziona Premere una Volta, i gesti di controllo dei media non saranno disponibili e, se si seleziona Premere e Tenere Premuto, la modalità di ascolto e i gesti dell'Assistente Digitale non saranno disponibili.
- Imposta un pacchetto app personalizzato per il rilevamento della fotocamera
- Imposta Appid Fotocamera Personalizzata
- Inserisci l'id dell'applicazione della fotocamera:
- Appid Fotocamera Personalizzata
- Appid fotocamera personalizzata impostata correttamente
- Ascoltatore fotocamera
- Servizio di ascolto per LibrePods per rilevare quando la fotocamera è attiva per attivare il controllo della fotocamera sugli AirPods.
- Licenze Open Source
- Aggiorna Test Uditivo
- Aggiorna Risultato Test Uditivo
- ATT Manager è nullo, prova a riconnetterti.
- Sono richieste le seguenti autorizzazioni per utilizzare l'app. Si prega di concederle per continuare.
- Scuoti la testa o annuisci!
- Accesso Root Richiesto
- Questa app ha bisogno dell'accesso root per agganciarsi alla libreria Bluetooth
- L'accesso root è stato negato. Si prega di concedere i permessi di root.
- Passaggi per la Risoluzione dei Problemi
- Si prega di inserire i valori di perdita in dbHL
- Informazioni
- Nome Modello
- Numero Modello
- Numero di Serie
- Versione
- Salute Uditiva
- Protezione dell'Udito
- Uso in Ambienti di Lavoro
- Protezione EN 352
- La protezione EN 352 limita il livello massimo dei media a 82 dBA e soddisfa i requisiti applicabili dello standard EN 352 per la protezione individuale dell'udito.
- Rumore Ambientale
- Riconnetti all'ultimo dispositivo connesso
- Disconnetti
- Supportami
- Non mostrare più
- Di recente ho perso il mio AirPod sinistro. Se hai trovato utile LibrePods, considera di supportarmi su GitHub Sponsors in modo che possa acquistare un sostituto e continuare a lavorare su questo progetto: anche una piccola somma fa molto. Grazie per il tuo supporto!
- Supporta LibrePods
- Disattiva la gestione del rumore
- Lascia entrare i suoni esterni
- Regola dinamicamente il rumore esterno
- Blocca i suoni esterni
+
+ LibrePods
+ Libera i tuoi AirPods dall'ecosistema Apple.
+ Visualizza lo stato della batteria dei tuoi AirPods direttamente dalla schermata principale!
+ Accessibilità
+ Volume Tono
+ Regola il volume del tono degli effetti sonori riprodotti dagli AirPods.
+ Audio
+ Audio Adattivo
+ Personalizza Audio Adattivo
+ L'audio adattivo risponde dinamicamente al tuo ambiente e cancella o permette i rumori esterni. Puoi personalizzare l'Audio Adattivo per permettere più o meno rumore.
+ Auricolari
+ Custodia
+ Test
+ Nome
+ Modalità di Ascolto
+ Spento
+ Trasparenza
+ Adattivo
+ Cancellazione del Rumore
+ Premi e Tieni Premuto sugli AirPods
+ Premi e tieni premuto sullo stelo per alternare tra le modalità di ascolto selezionate.
+ Gesti della Testa
+ Sinistra
+ Destra
+ Consapevolezza Conversazionale
+ Abbassa il volume dei contenuti multimediali e riduce il rumore di fondo quando inizi a parlare con altre persone.
+ Volume Personalizzato
+ Regola il volume dei contenuti multimediali in risposta al tuo ambiente.
+ Cancellazione del Rumore con un Solo AirPod
+ Consenti agli AirPods di essere messi in modalità di cancellazione del rumore quando è presente un solo AirPod nell'orecchio.
+ Controllo Volume
+ Regola il volume scorrendo verso l'alto o verso il basso sul sensore situato sullo stelo degli AirPods Pro.
+ AirPods non connessi
+ Si prega di connettere i tuoi AirPods per accedere alle impostazioni.
+ Indietro
+ Personalizzazioni
+ Volume relativo
+ Riduce a una percentuale del volume corrente invece del volume massimo.
+ Metti in Pausa la Musica
+ Quando inizi a parlare, la musica verrà messa in pausa.
+ ESEMPIO
+ Aggiungi widget
+ Controlla la Modalità di Controllo del Rumore direttamente dalla tua Schermata Principale.
+ Connesso
+ Connesso a Linux
+ Connesso
+ Spostato su Linux
+ Spostato su %1$s
+ Riconnetti dalla notifica
+ Tracciamento della Testa
+ Annuisci per rispondere alle chiamate e scuoti la testa per rifiutarle.
+ Generale
+ Azione del Tile Impostazioni Rapide
+ Mostra la finestra di dialogo per il controllo del rumore al tocco.
+ Alterna tra le modalità al tocco.
+ Sviluppatore
+ Apri le Impostazioni degli AirPods
+ Gestisci le funzionalità e le preferenze degli AirPods
+ Rilevamento Automatico dell'Orecchio
+ Riproduzione Automatica
+ Pausa Automatica
+ Risoluzione dei Problemi
+ Raccogli i log per diagnosticare i problemi con la connessione degli AirPods
+ Raccogli Log
+ Log Salvati
+ Nessun log salvato trovato
+ Preferenze di Connessione Automatica
+ Connetti ai tuoi AirPods quando il loro stato è:
+ Disconnesso
+ Gli AirPods non sono connessi a un dispositivo
+ Inattivo
+ Un dispositivo è connesso ai tuoi AirPods, ma non riproduce contenuti multimediali né è in chiamata
+ Riproduzione di contenuti multimediali
+ Un dispositivo sta riproducendo contenuti multimediali sui tuoi AirPods
+ In chiamata
+ Un dispositivo è in chiamata con i tuoi AirPods
+ Connetti agli AirPods quando il tuo telefono è:
+ Ricezione di una chiamata
+ Il tuo telefono inizia a squillare
+ Avvio della riproduzione di contenuti multimediali
+ Il tuo telefono inizia a riprodurre contenuti multimediali
+ Annulla
+ Puoi personalizzare la modalità Trasparenza per i tuoi AirPods Pro per aiutarti a sentire ciò che ti circonda.
+ La Riduzione dei Suoni Forti può ridurre attivamente la tua esposizione ai forti rumori ambientali quando in modalità Trasparenza e Adattiva. La Riduzione dei Suoni Forti non è attiva in modalità Spento.
+ Riduzione dei Suoni Forti
+ Controlli Chiamata
+ Connetti automaticamente a questo dispositivo
+ Quando abilitato, gli AirPods tenteranno di connettersi automaticamente a questo dispositivo. Altrimenti, tenteranno di connettersi automaticamente solo se sono stati connessi in precedenza.
+ Metti in pausa i contenuti multimediali quando ti addormenti
+ Modalità Ascolto Disattivata
+ Quando questa opzione è attiva, le modalità di ascolto degli AirPods includeranno un'opzione "Spento". I livelli di suono forti non vengono ridotti quando la modalità di ascolto è impostata su "Spento".
+ Microfono
+ Modalità Microfono
+ Automatico
+ Sempre Destro
+ Sempre Sinistro
+ Rispondi alla chiamata
+ Silenzia/Riattiva
+ Riaggancia
+ Premi una Volta
+ Premi Due Volte
+ Apparecchio Acustico
+ Regolazioni
+ Scorri per controllare l'amplificazione
+ Quando sei in modalità Trasparenza e nessun contenuto multimediale è in riproduzione, scorri verso l'alto e verso il basso sui controlli Touch dei tuoi AirPods Pro per aumentare o diminuire l'amplificazione dei suoni ambientali.
+ Modalità Trasparenza
+ Personalizza la Modalità Trasparenza
+ Velocità di Pressione
+ Regola la velocità richiesta per premere due o tre volte sui tuoi AirPods.
+ Durata della Pressione Prolungata
+ Regola la durata richiesta per premere e tenere premuto sui tuoi AirPods.
+ Velocità di Scorrimento del Volume
+ Per evitare regolazioni involontarie del volume, seleziona il tempo di attesa preferito tra gli scorrimenti.
+ Equalizzatore
+ Applica EQ a
+ Telefono
+ Media
+ Banda %d
+ Predefinito
+ Più lento
+ Il più lento
+ Più lungo
+ Il più lungo
+ Più scuro
+ Più luminoso
+ Meno
+ Di più
+ Amplificazione
+ Bilanciamento
+ Tono
+ Riduzione del Rumore Ambientale
+ Potenziamento Conversazione
+ Potenziamento Conversazione concentra i tuoi AirPods Pro sulla persona che parla di fronte a te, rendendo più facile sentire in una conversazione faccia a faccia.
+ Gli AirPods possono utilizzare i risultati di un test dell'udito per apportare modifiche che migliorano la chiarezza delle voci e dei suoni intorno a te.\n\nApparecchio Acustico è destinato solo a persone con perdita dell'udito da lieve a moderata.
+ Assistenza Media
+ Gli AirPods Pro possono utilizzare i risultati di un test dell'udito per apportare modifiche che migliorano la chiarezza di musica, video e chiamate.
+ Regola Musica e Video
+ Regola Chiamate
+ Widget
+ Mostra la batteria del telefono nel widget
+ Visualizza il livello della batteria del tuo telefono nel widget accanto alla batteria degli AirPods
+ Volume Consapevolezza Conversazionale
+ Tile Impostazioni Rapide
+ Apri finestra di dialogo per il controllo
+ Se disabilitato, cliccando sul QS si scorrerà tra le modalità. Se abilitato, verrà mostrata una finestra di dialogo per controllare la modalità di controllo del rumore e la consapevolezza conversazionale.
+ Disconnetti AirPods quando non indossati
+ Sarai ancora in grado di controllarli con l'app - questo disconnette solo l'audio.
+ Opzioni Avanzate
+ Imposta Chiave di Risoluzione Identità (IRK)
+ Imposta manualmente il valore IRK utilizzato per risolvere gli indirizzi casuali BLE
+ Imposta Chiave di Crittografia
+ Imposta manualmente il valore ENC_KEY utilizzato per decrittografare le pubblicità BLE
+ Utilizza pacchetti alternativi di tracciamento della testa
+ Abilita questo se il tracciamento della testa non funziona per te. Questo invia dati diversi agli AirPods per richiedere/interrompere i dati di tracciamento della testa.
+ Comportati come un dispositivo Apple
+ Abilita la connettività multi-dispositivo e le funzionalità di Accessibilità come la personalizzazione della modalità Trasparenza (amplificazione, tono, riduzione del rumore ambientale, potenziamento conversazione ed EQ)
+ Potrebbe essere instabile!! Un massimo di due dispositivi possono essere connessi ai tuoi AirPods. Se li stai usando con un dispositivo Apple come un iPad o un Mac, connetti prima quel dispositivo e poi il tuo Android.
+ Reimposta Offset Hook
+ Questo cancellerà l'offset hook corrente e richiederà di rifare la procedura di configurazione. Sei sicuro di voler continuare?
+ Reimposta
+ Offset hook è stato resettato. Reindirizzamento alla configurazione...
+ Impossibile reimpostare l'offset hook
+ IRK impostata correttamente
+ Chiave di crittografia impostata correttamente
+ Valore Esadecimale IRK
+ Valore Esadecimale ENC_KEY
+ Inserisci IRK di 16 byte come stringa esadecimale (32 caratteri):
+ Inserisci ENC_KEY di 16 byte come stringa esadecimale (32 caratteri):
+ Devono essere esattamente 32 caratteri esadecimali
+ Errore durante la conversione esadecimale:
+ Offset trovato, riavviare il processo Bluetooth
+ Assistente Digitale
+ Attivo
+ Telecomando Fotocamera
+ Controllo Fotocamera
+ Scatta una foto, avvia o interrompi la registrazione e altro utilizzando Premere una Volta o Premere e Tenere Premuto. Quando si utilizzano gli AirPods per le azioni della fotocamera, se si seleziona Premere una Volta, i gesti di controllo dei media non saranno disponibili e, se si seleziona Premere e Tenere Premuto, la modalità di ascolto e i gesti dell'Assistente Digitale non saranno disponibili.
+ Imposta un pacchetto app personalizzato per il rilevamento della fotocamera
+ Imposta Appid Fotocamera Personalizzata
+ Inserisci l'id dell'applicazione della fotocamera:
+ Appid Fotocamera Personalizzata
+ Appid fotocamera personalizzata impostata correttamente
+ Ascoltatore fotocamera
+ Servizio di ascolto per LibrePods per rilevare quando la fotocamera è attiva per attivare il controllo della fotocamera sugli AirPods.
+ Licenze Open Source
+ Aggiorna Test Uditivo
+ Aggiorna Risultato Test Uditivo
+ ATT Manager è nullo, prova a riconnetterti.
+ Sono richieste le seguenti autorizzazioni per utilizzare l'app. Si prega di concederle per continuare.
+ Scuoti la testa o annuisci!
+ Accesso Root Richiesto
+ Questa app ha bisogno dell'accesso root per agganciarsi alla libreria Bluetooth
+ L'accesso root è stato negato. Si prega di concedere i permessi di root.
+ Passaggi per la Risoluzione dei Problemi
+ Si prega di inserire i valori di perdita in dbHL
+ Informazioni
+ Nome Modello
+ Numero Modello
+ Numero di Serie
+ Versione
+ Salute Uditiva
+ Protezione dell'Udito
+ Uso in Ambienti di Lavoro
+ Protezione EN 352
+ La protezione EN 352 limita il livello massimo dei media a 82 dBA e soddisfa i requisiti applicabili dello standard EN 352 per la protezione individuale dell'udito.
+ Rumore Ambientale
+ Riconnetti all'ultimo dispositivo connesso
+ Disconnetti
+ Disattiva la gestione del rumore
+ Lascia entrare i suoni esterni
+ Regola dinamicamente il rumore esterno
+ Blocca i suoni esterni
diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml
index 9d621d4..27e3510 100644
--- a/android/app/src/main/res/values-es/strings.xml
+++ b/android/app/src/main/res/values-es/strings.xml
@@ -206,10 +206,6 @@
Ruido ambiental
Reconectar al último dispositivo conectado
Desconectar
- Apóyame
- No volver a mostrar
- Hace poco perdí mi AirPod izquierdo. Si LibrePods te ha resultado útil, considera apoyarme en GitHub Sponsors para que pueda comprar un reemplazo y seguir trabajando en este proyecto; incluso una pequeña donación es de gran ayuda. ¡Gracias por tu apoyo!
- Apoya a LibrePods
Desactiva la gestión del ruido
Deja entrar los sonidos externos
Ajuste dinámico del ruido externo
diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml
index ad62e67..c87a7d4 100644
--- a/android/app/src/main/res/values-fr/strings.xml
+++ b/android/app/src/main/res/values-fr/strings.xml
@@ -206,10 +206,6 @@
Bruit environnemental
Reconnecter au dernier appareil
Déconnecter
- Soutenez-moi
- Ne plus afficher
- J\'ai récemment perdu mon AirPod gauche. Si LibrePods vous est utile, pensez à me soutenir sur GitHub Sponsors pour m\'aider à en racheter un et continuer ce projet — même un petit montant aide beaucoup. Merci pour votre soutien !
- Soutenir LibrePods
Désactiver la gestion du bruit
Laisser entrer les sons extérieurs
Ajuster dynamiquement les sons extérieurs
diff --git a/android/app/src/main/res/values-pt/strings.xml b/android/app/src/main/res/values-pt/strings.xml
index 41ed556..f214026 100644
--- a/android/app/src/main/res/values-pt/strings.xml
+++ b/android/app/src/main/res/values-pt/strings.xml
@@ -206,10 +206,6 @@
Ruído Ambiental
Reconectar ao último dispositivo conectado
Desconectar
- Me Apoiar
- Nunca mostrar novamente
- Recentemente perdi meu AirPod esquerdo. Se você achou o LibrePods útil, considere me apoiar no GitHub Sponsors para que eu possa comprar uma substituição e continuar trabalhando neste projeto - mesmo uma pequena quantia faz muita diferença. Obrigado pelo seu apoio!
- Apoiar LibrePods
Desativa o gerenciamento de ruído
Permite sons externos
Ajusta dinamicamente o ruído externo
diff --git a/android/app/src/main/res/values-tr/strings.xml b/android/app/src/main/res/values-tr/strings.xml
index f87544c..864920d 100644
--- a/android/app/src/main/res/values-tr/strings.xml
+++ b/android/app/src/main/res/values-tr/strings.xml
@@ -206,10 +206,6 @@
Çevresel Gürültü
Son bağlanan cihaza yeniden bağlan
Bağlantıyı Kes
- Beni destekle
- Bir daha gösterme
- Yakın zamanda sol AirPod\'umu kaybettim. LibrePods\'u faydalı bulduysanız, bir yedek satın alıp bu proje üzerinde çalışmaya devam edebilmem için GitHub Sponsors\'ta beni desteklemeyi düşünün - küçük bir miktar bile çok işe yarar. Desteğiniz için teşekkürler!
- LibrePods\'u Destekle
Gürültü yönetimini kapatır
Dış sesleri içeri alır
Dış gürültüyü dinamik olarak ayarlar
diff --git a/android/app/src/main/res/values-uk/strings.xml b/android/app/src/main/res/values-uk/strings.xml
index c3ae2a0..267d689 100644
--- a/android/app/src/main/res/values-uk/strings.xml
+++ b/android/app/src/main/res/values-uk/strings.xml
@@ -206,10 +206,6 @@
Навколишній Шум
Перепідключитися до останнього підключеного пристрою
Відʼєднатися
- Підтримати мене
- Ніколи не показувати знову
- Нещодавно я втратив свій лівий AirPod. Якщо LibrePods виявилися корисними для вас, розгляньте можливість підтримати мене на GitHub Sponsors, щоб я міг купити заміну та продовжити роботу над цим проектом — навіть невелика допомога має велике значення. Дякую за вашу підтримку!
- Підтримати LibrePods
Вимикає керування шумом
Пропускає зовнішні звуки
Динамічно налаштовує зовнішній шум
diff --git a/android/app/src/main/res/values-vi/strings.xml b/android/app/src/main/res/values-vi/strings.xml
index 044df73..25436c5 100644
--- a/android/app/src/main/res/values-vi/strings.xml
+++ b/android/app/src/main/res/values-vi/strings.xml
@@ -206,10 +206,6 @@
Tiếng ồn môi trường
Kết nối lại với thiết bị được kết nối lần cuối
Ngắt kết nối
- Hỗ trợ tôi
- Không hiển thị lại
- Gần đây tôi bị mất tai bên trái của AirPod. Nếu bạn thấy LibrePods hữu ích, hãy cân nhắc hỗ trợ tôi trên GitHub Sponsors để tôi có thể mua cái thay thế và tiếp tục làm việc trên dự án này - ngay cả một khoản nhỏ cũng rất có ý nghĩa. Cảm ơn sự hỗ trợ của bạn!
- Hỗ trợ LibrePods
Tắt quản lý tiếng ồn
Cho phép âm thanh bên ngoài
Điều chỉnh động tiếng ồn bên ngoài
diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml
index 3178aba..af388da 100644
--- a/android/app/src/main/res/values-zh-rCN/strings.xml
+++ b/android/app/src/main/res/values-zh-rCN/strings.xml
@@ -207,12 +207,8 @@
环境噪音
重新连接到上次连接的设备
断开连接
- 支持我
- 不再显示
- 我最近丢了我的左耳 AirPod。如果你觉得 LibrePods 有用,请考虑在 GitHub Sponsors 上支持我,这样我就可以购买一个替换品并继续从事这个项目——即使是少量捐助也能发挥很大作用。感谢你的支持!
- 支持 LibrePods
关闭噪音管理
允许外部声音进入
动态调整外部噪音
阻隔外部声音
-
\ No newline at end of file
+
diff --git a/android/app/src/main/res/values-zh-rTW/strings.xml b/android/app/src/main/res/values-zh-rTW/strings.xml
index dc45f8d..3a77d69 100644
--- a/android/app/src/main/res/values-zh-rTW/strings.xml
+++ b/android/app/src/main/res/values-zh-rTW/strings.xml
@@ -208,12 +208,8 @@
環境噪音
重新連接至上次連接的裝置
中斷連線
- 贊助我
- 不再顯示
- 我最近弄丟了左耳的 AirPod。如果你覺得 LibrePods 很好用,請考慮在 GitHub Sponsors 上贊助我,讓我能買個替換品並繼續開發這個專案,一點點金額也能帶來很大的幫助。感謝你的支持!
- 贊助 LibrePods
關閉噪音管理
允許外部聲音
動態調整外部噪音
阻隔外部聲音
-
\ No newline at end of file
+
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 74ae083..beedccf 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -206,12 +206,9 @@
Environmental Noise
Reconnect to last connected device
Disconnect
- Support me
- Never show again
- I recently lost my left AirPod. If you\'ve found LibrePods useful, consider supporting me on GitHub Sponsors so I can buy a replacement and continue working on this project- even a little amount goes a long way. Thank you for your support!
- Support LibrePods
Turns off noise management
Lets in external sounds
Dynamically adjust external noise
Blocks out external sounds
+ Unlock all features
diff --git a/android/app/src/main/cpp/l2c_fcr_hook.cpp b/android/app/src/xposed/cpp/l2c_fcr_hook.cpp
similarity index 100%
rename from android/app/src/main/cpp/l2c_fcr_hook.cpp
rename to android/app/src/xposed/cpp/l2c_fcr_hook.cpp
diff --git a/android/app/src/main/cpp/l2c_fcr_hook.h b/android/app/src/xposed/cpp/l2c_fcr_hook.h
similarity index 100%
rename from android/app/src/main/cpp/l2c_fcr_hook.h
rename to android/app/src/xposed/cpp/l2c_fcr_hook.h
diff --git a/android/app/src/main/cpp/xz/xz.h b/android/app/src/xposed/cpp/xz/xz.h
similarity index 100%
rename from android/app/src/main/cpp/xz/xz.h
rename to android/app/src/xposed/cpp/xz/xz.h
diff --git a/android/app/src/main/cpp/xz/xz_config.h b/android/app/src/xposed/cpp/xz/xz_config.h
similarity index 100%
rename from android/app/src/main/cpp/xz/xz_config.h
rename to android/app/src/xposed/cpp/xz/xz_config.h
diff --git a/android/app/src/main/cpp/xz/xz_crc32.c b/android/app/src/xposed/cpp/xz/xz_crc32.c
similarity index 100%
rename from android/app/src/main/cpp/xz/xz_crc32.c
rename to android/app/src/xposed/cpp/xz/xz_crc32.c
diff --git a/android/app/src/main/cpp/xz/xz_crc64.c b/android/app/src/xposed/cpp/xz/xz_crc64.c
similarity index 100%
rename from android/app/src/main/cpp/xz/xz_crc64.c
rename to android/app/src/xposed/cpp/xz/xz_crc64.c
diff --git a/android/app/src/main/cpp/xz/xz_dec_bcj.c b/android/app/src/xposed/cpp/xz/xz_dec_bcj.c
similarity index 100%
rename from android/app/src/main/cpp/xz/xz_dec_bcj.c
rename to android/app/src/xposed/cpp/xz/xz_dec_bcj.c
diff --git a/android/app/src/main/cpp/xz/xz_dec_lzma2.c b/android/app/src/xposed/cpp/xz/xz_dec_lzma2.c
similarity index 100%
rename from android/app/src/main/cpp/xz/xz_dec_lzma2.c
rename to android/app/src/xposed/cpp/xz/xz_dec_lzma2.c
diff --git a/android/app/src/main/cpp/xz/xz_dec_stream.c b/android/app/src/xposed/cpp/xz/xz_dec_stream.c
similarity index 100%
rename from android/app/src/main/cpp/xz/xz_dec_stream.c
rename to android/app/src/xposed/cpp/xz/xz_dec_stream.c
diff --git a/android/app/src/main/cpp/xz/xz_lzma2.h b/android/app/src/xposed/cpp/xz/xz_lzma2.h
similarity index 100%
rename from android/app/src/main/cpp/xz/xz_lzma2.h
rename to android/app/src/xposed/cpp/xz/xz_lzma2.h
diff --git a/android/app/src/main/cpp/xz/xz_private.h b/android/app/src/xposed/cpp/xz/xz_private.h
similarity index 100%
rename from android/app/src/main/cpp/xz/xz_private.h
rename to android/app/src/xposed/cpp/xz/xz_private.h
diff --git a/android/app/src/main/cpp/xz/xz_sha256.c b/android/app/src/xposed/cpp/xz/xz_sha256.c
similarity index 100%
rename from android/app/src/main/cpp/xz/xz_sha256.c
rename to android/app/src/xposed/cpp/xz/xz_sha256.c
diff --git a/android/app/src/main/cpp/xz/xz_stream.h b/android/app/src/xposed/cpp/xz/xz_stream.h
similarity index 100%
rename from android/app/src/main/cpp/xz/xz_stream.h
rename to android/app/src/xposed/cpp/xz/xz_stream.h
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/KotlinModule.kt b/android/app/src/xposed/java/me/kavishdevar/librepods/utils/KotlinModule.kt
similarity index 100%
rename from android/app/src/main/java/me/kavishdevar/librepods/utils/KotlinModule.kt
rename to android/app/src/xposed/java/me/kavishdevar/librepods/utils/KotlinModule.kt
diff --git a/android/app/src/main/resources/META-INF/xposed/java_init.list b/android/app/src/xposed/resources/META-INF/xposed/java_init.list
similarity index 100%
rename from android/app/src/main/resources/META-INF/xposed/java_init.list
rename to android/app/src/xposed/resources/META-INF/xposed/java_init.list
diff --git a/android/app/src/main/resources/META-INF/xposed/module.prop b/android/app/src/xposed/resources/META-INF/xposed/module.prop
similarity index 100%
rename from android/app/src/main/resources/META-INF/xposed/module.prop
rename to android/app/src/xposed/resources/META-INF/xposed/module.prop
diff --git a/android/app/src/main/resources/META-INF/xposed/native_init.list b/android/app/src/xposed/resources/META-INF/xposed/native_init.list
similarity index 100%
rename from android/app/src/main/resources/META-INF/xposed/native_init.list
rename to android/app/src/xposed/resources/META-INF/xposed/native_init.list
diff --git a/android/app/src/main/resources/META-INF/xposed/scope.list b/android/app/src/xposed/resources/META-INF/xposed/scope.list
similarity index 100%
rename from android/app/src/main/resources/META-INF/xposed/scope.list
rename to android/app/src/xposed/resources/META-INF/xposed/scope.list
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
index 31555c0..45682a0 100644
--- a/android/build.gradle.kts
+++ b/android/build.gradle.kts
@@ -1,7 +1,7 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
- alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.aboutLibraries) apply false
-}
\ No newline at end of file
+// alias(libs.plugins.hilt) apply false
+}
diff --git a/android/gradle.properties b/android/gradle.properties
index 2c138d5..8d81701 100644
--- a/android/gradle.properties
+++ b/android/gradle.properties
@@ -6,7 +6,7 @@
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
-org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+org.gradle.jvmargs=-Xmx8192m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
@@ -22,4 +22,17 @@ kotlin.code.style=official
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
-android.javaCompile.suppressSourceTargetDeprecationWarning=true
\ No newline at end of file
+android.javaCompile.suppressSourceTargetDeprecationWarning=true
+
+org.gradle.caching=true
+org.gradle.configuration-cache=true
+#android.defaults.buildfeatures.resvalues=true
+#android.sdk.defaultTargetSdkToCompileSdkIfUnset=false
+#android.enableAppCompileTimeRClass=false
+#android.usesSdkInManifest.disallowed=false
+#android.uniquePackageNames=false
+#android.dependency.useConstraints=true
+#android.r8.strictFullModeForKeepRules=false
+#android.r8.optimizedResourceShrinking=false
+#android.builtInKotlin=false
+#android.newDsl=false
diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml
index 0b5d1cc..de2c0fa 100644
--- a/android/gradle/libs.versions.toml
+++ b/android/gradle/libs.versions.toml
@@ -1,21 +1,22 @@
[versions]
-accompanistPermissions = "0.36.0"
-agp = "8.9.1"
-kotlin = "2.1.10"
-coreKtx = "1.17.0"
-lifecycleRuntimeKtx = "2.8.7"
-activityCompose = "1.10.1"
-composeBom = "2025.04.00"
-annotations = "26.0.2"
-navigationCompose = "2.8.9"
+accompanistPermissions = "0.37.3"
+agp = "9.1.0"
+kotlin = "2.3.20"
+coreKtx = "1.18.0"
+lifecycleRuntimeKtx = "2.10.0"
+activityCompose = "1.13.0"
+composeBom = "2026.03.01"
+annotations = "26.1.0"
+navigationCompose = "2.9.7"
constraintlayout = "2.2.1"
-haze = "1.6.10"
-hazeMaterials = "1.6.10"
+haze = "1.7.2"
+hazeMaterials = "1.7.2"
dynamicanimation = "1.1.0"
-foundationLayout = "1.9.1"
-uiTooling = "1.9.1"
-ui = "1.9.2"
-aboutLibraries = "13.0.0-rc01"
+aboutLibraries = "14.0.1"
+materialIconsCore = "1.7.8"
+backdrop = "2.0.0-alpha03"
+billing = "8.3.0"
+hilt = "2.59.2"
[libraries]
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" }
@@ -33,14 +34,19 @@ androidx-constraintlayout = { group = "androidx.constraintlayout", name = "const
haze = { group = "dev.chrisbanes.haze", name = "haze", version.ref = "haze" }
haze-materials = { group = "dev.chrisbanes.haze", name = "haze-materials", version.ref = "hazeMaterials" }
androidx-dynamicanimation = { group = "androidx.dynamicanimation", name = "dynamicanimation", version.ref = "dynamicanimation" }
-androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "foundationLayout" }
-androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "uiTooling" }
-androidx-compose-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "ui" }
+androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout"}
+androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
aboutlibraries = { group = "com.mikepenz", name = "aboutlibraries", version.ref = "aboutLibraries" }
aboutlibraries-compose-m3 = { group = "com.mikepenz", name = "aboutlibraries-compose-m3", version.ref = "aboutLibraries" }
+androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core", version.ref = "materialIconsCore" }
+backdrop = { group = "io.github.kyant0", name = "backdrop", version.ref = "backdrop" }
+billing = { group = "com.android.billingclient", name = "billing-ktx", version.ref = "billing" }
+hilt = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
+hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
aboutLibraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "aboutLibraries" }
+hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
index 4e7f0c7..c921434 100644
--- a/android/gradle/wrapper/gradle-wrapper.properties
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
#Mon Oct 07 22:30:36 IST 2024
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists