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