From 0b578d62cfc7ddd766bd951f03f8c8ee4f298667 Mon Sep 17 00:00:00 2001 From: Kavish Devar Date: Fri, 24 Apr 2026 18:03:01 +0530 Subject: [PATCH] android: add more compatibility information, fix FOSS billing, hide upgrade button before first AACP connect closes #538 --- android/app/build.gradle.kts | 7 +- android/app/src/main/AndroidManifest.xml | 17 +- .../me/kavishdevar/librepods/MainActivity.kt | 155 ++--- .../librepods/billing/BillingProvider.kt | 1 + .../librepods/billing/FOSSBillingProvider.kt | 9 +- .../librepods/billing/PlayBillingProvider.kt | 12 +- .../librepods/bluetooth/AACPManager.kt | 10 +- .../me/kavishdevar/librepods/data/Packets.kt | 1 + .../presentation/components/DeviceInfoCard.kt | 171 ++++++ .../components/PlayBypassConfirmation.kt | 254 ++++++++ .../presentation/components/StyledButton.kt | 23 +- .../components/StyledSelectList.kt | 2 - .../screens/AccessibilitySettingsScreen.kt | 546 ++++++++++-------- .../screens/AdaptiveStrengthScreen.kt | 2 +- .../screens/AirPodsSettingsScreen.kt | 10 +- .../presentation/screens/AppSettingsScreen.kt | 459 ++++++++------- .../screens/HeadTrackingScreen.kt | 2 +- .../screens/HearingProtectionScreen.kt | 40 +- .../screens/PressAndHoldSettingsScreen.kt | 2 +- .../presentation/screens/PurchaseScreen.kt | 38 +- .../presentation/screens/RenameScreen.kt | 5 - .../viewmodel/AirPodsViewModel.kt | 54 +- .../viewmodel/AppSettingsViewModel.kt | 20 +- .../viewmodel/PurchaseViewModel.kt | 2 +- .../librepods/services/AirPodsService.kt | 379 ++++++------ .../librepods/utils/RootlessSupport.kt | 2 +- android/app/src/main/res/values/strings.xml | 25 +- .../librepods/LibrePodsApplication.kt | 18 +- .../librepods/LibrePodsApplication.kt | 17 +- android/gradle/libs.versions.toml | 2 + update_nonpatch.json | 6 +- 31 files changed, 1507 insertions(+), 784 deletions(-) create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/presentation/components/DeviceInfoCard.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/presentation/components/PlayBypassConfirmation.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index eea0a24..07507d6 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -28,7 +28,7 @@ android { applicationId = "me.kavishdevar.librepods" minSdk = 33 targetSdk = 37 - versionCode = 34 + versionCode = 36 versionName = "0.2.3" } buildTypes { @@ -50,14 +50,17 @@ android { debug { buildConfigField("Boolean", "PLAY_BUILD", "false") signingConfig = signingConfigs.getByName("release") + versionNameSuffix = "-debug" } create("playRelease") { initWith(getByName("release")) buildConfigField("Boolean", "PLAY_BUILD", "true") + versionNameSuffix = "-play" } create("playDebug") { initWith(getByName("debug")) buildConfigField("Boolean", "PLAY_BUILD", "true") + versionNameSuffix = "-youshouldnothavethis" } } compileOptions { @@ -104,7 +107,6 @@ android { arguments += "-DIS_XPOSED=ON" } } - versionNameSuffix = "-xposed" } } } @@ -113,6 +115,7 @@ dependencies { implementation(platform(libs.androidx.compose.bom)) implementation(libs.accompanist.permissions) implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.process) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) implementation(libs.androidx.ui) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 428ea3e..0474dfd 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -14,9 +14,18 @@ - - - + + + + - diff --git a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt index d3b058d..368a9cb 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt @@ -52,7 +52,6 @@ import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.foundation.Canvas import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -64,7 +63,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -81,8 +79,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -94,8 +90,6 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.res.stringResource @@ -122,12 +116,11 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.rememberHazeState -import kotlinx.coroutines.delay -import me.kavishdevar.librepods.billing.BillingManager -import me.kavishdevar.librepods.billing.BillingProviderFactory import me.kavishdevar.librepods.data.AirPodsNotifications import me.kavishdevar.librepods.data.ControlCommandRepository import me.kavishdevar.librepods.presentation.components.ConfirmationDialog +import me.kavishdevar.librepods.presentation.components.DeviceInfoCard +import me.kavishdevar.librepods.presentation.components.PlayBypassSheet import me.kavishdevar.librepods.presentation.components.StyledButton import me.kavishdevar.librepods.presentation.components.StyledIconButton import me.kavishdevar.librepods.presentation.screens.AccessibilitySettingsScreen @@ -148,6 +141,7 @@ import me.kavishdevar.librepods.presentation.screens.TransparencySettingsScreen import me.kavishdevar.librepods.presentation.screens.TroubleshootingScreen import me.kavishdevar.librepods.presentation.screens.UpdateHearingTestScreen import me.kavishdevar.librepods.presentation.screens.VersionScreen +import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel @@ -175,7 +169,7 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() setContent { - _root_ide_package_.me.kavishdevar.librepods.presentation.theme.LibrePodsTheme { + LibrePodsTheme { Main() } } @@ -224,81 +218,72 @@ fun Main() { val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE) if (!isSupported(sharedPreferences)) { val showDialog = remember { mutableStateOf(false) } - + val showPlayBypassVisible = remember { mutableStateOf(false) } val hazeState = rememberHazeState() + val backdrop = rememberLayerBackdrop() + val isDarkTheme = isSystemInDarkTheme() + val textColor = if (isDarkTheme) Color.White else Color.Black + val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) Box( modifier = Modifier .fillMaxSize() .hazeSource(hazeState) - .background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)), + .layerBackdrop(backdrop) + .background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7)), contentAlignment = Alignment.Center ) { - Box ( - modifier = Modifier - .fillMaxSize() - ) Column ( - verticalArrangement = Arrangement.spacedBy(8.dp) + modifier = Modifier.padding(horizontal = 16.dp), + verticalArrangement = Arrangement + .spacedBy(16.dp) ) { - Text( - text = stringResource(R.string.not_supported), - style = TextStyle( - fontFamily = FontFamily(Font(R.font.sf_pro)), - fontWeight = FontWeight.SemiBold, - color = if (isSystemInDarkTheme()) Color.White else Color.Black, - fontSize = 20.sp - ), - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() - ) - Row ( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center + val innerBackdrop = rememberLayerBackdrop() + + Column( + modifier = Modifier.layerBackdrop(innerBackdrop), + verticalArrangement = Arrangement + .spacedBy(16.dp) ) { Text( - text = "Device Info:", + text = stringResource(R.string.not_supported), style = TextStyle( fontFamily = FontFamily(Font(R.font.sf_pro)), - fontWeight = FontWeight.Medium, - color = if (isSystemInDarkTheme()) Color.White else Color.Black, - fontSize = 16.sp + fontWeight = FontWeight.SemiBold, + color = textColor, + fontSize = 20.sp, + textAlign = TextAlign.Center ), - textAlign = TextAlign.End, - ) - Spacer(modifier = Modifier.width(4.dp)) - Text( - text = - "MANUFACTURER=${Build.MANUFACTURER}\n" + - "MODEL=${Build.MODEL}\n" + - "BUILD_ID=${Build.ID}\n" + - "SDK_INT_FULL= ${Build.VERSION.SDK_INT_FULL}\n", - style = TextStyle( - fontFamily = FontFamily(Font(R.font.hack)), - fontWeight = FontWeight.Medium, - color = if (isSystemInDarkTheme()) Color.White else Color.Black, - fontSize = 16.sp - ), - textAlign = TextAlign.Start, + modifier = Modifier.fillMaxWidth() ) + + DeviceInfoCard() + + Box( + modifier = Modifier + .fillMaxWidth() + .background(backgroundColor, RoundedCornerShape(28.dp)) + .clip(RoundedCornerShape(28.dp)) + ) { + Text( + text = stringResource(R.string.check_the_repository_for_more_info), + style = TextStyle( + fontFamily = FontFamily(Font(R.font.sf_pro)), + fontWeight = FontWeight.Medium, + color = if (isSystemInDarkTheme()) Color.White else Color.Black, + fontSize = 16.sp + ), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 16.dp) + ) + } } - Text( - text = stringResource(R.string.check_the_repository_for_more_info), - style = TextStyle( - fontFamily = FontFamily(Font(R.font.sf_pro)), - fontWeight = FontWeight.Medium, - color = if (isSystemInDarkTheme()) Color.White else Color.Black, - fontSize = 18.sp - ), - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() - ) StyledButton( onClick = { showDialog.value = true }, - backdrop = rememberLayerBackdrop(), + backdrop = innerBackdrop, modifier = Modifier .fillMaxWidth() - .padding(8.dp) ) { Text( text = stringResource(R.string.bypass_compatibility_check), @@ -317,15 +302,19 @@ fun Main() { showDialog = showDialog, title = stringResource(R.string.bypass_compatibility_check), message = stringResource(R.string.bypass_compatiblity_check_confirmation), - confirmText = "Yes", - dismissText = "No", + confirmText = stringResource(R.string.yes), + dismissText = stringResource(R.string.no), onConfirm = { showDialog.value = false - sharedPreferences.edit { - putBoolean("bypass_device_check", true) - val intent = Intent(context, MainActivity::class.java) - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) - context.startActivity(intent) + if (BuildConfig.PLAY_BUILD) { + showPlayBypassVisible.value = true + } else { + sharedPreferences.edit { + putBoolean("bypass_device_check", true) + val intent = Intent(context, MainActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + context.startActivity(intent) + } } }, onDismiss = { @@ -334,6 +323,26 @@ fun Main() { hazeState = hazeState ) + if (BuildConfig.PLAY_BUILD) { + PlayBypassSheet( + visible = showPlayBypassVisible.value, + onDismiss = { + showPlayBypassVisible.value = false + showDialog.value = true + }, + onConfirm = { + showPlayBypassVisible.value = false + sharedPreferences.edit { + putBoolean("bypass_device_check", true) + val intent = Intent(context, MainActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + context.startActivity(intent) + } + }, + backdrop = backdrop + ) + } + return } @@ -347,8 +356,6 @@ fun Main() { ) } - BillingManager.provider = BillingProviderFactory.create(context) - val bluetoothPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { listOf( "android.permission.BLUETOOTH_CONNECT", @@ -484,7 +491,7 @@ fun Main() { if (airPodsViewModel != null) VersionScreen(airPodsViewModel) } composable("hearing_protection") { - if (airPodsViewModel != null) HearingProtectionScreen(airPodsViewModel) + if (airPodsViewModel != null) HearingProtectionScreen(airPodsViewModel, navController) } composable("purchase_screen") { val purchaseViewModel: PurchaseViewModel = viewModel() 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 index 66c97bf..52a6705 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/billing/BillingProvider.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/billing/BillingProvider.kt @@ -26,4 +26,5 @@ interface BillingProvider { val price: StateFlow fun purchase(activity: Activity) fun queryPurchases() + fun restorePurchases() } 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 index 11c8417..4f07db6 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/billing/FOSSBillingProvider.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/billing/FOSSBillingProvider.kt @@ -31,13 +31,13 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext +import me.kavishdevar.librepods.R class FOSSBillingProvider(context: Context): BillingProvider { private val _isPremium = MutableStateFlow(false) override val isPremium: StateFlow = _isPremium - private val _price = MutableStateFlow("Any") + private val _price = MutableStateFlow(context.getString(R.string.name_your_own_price)) override val price: StateFlow = _price private val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE) @@ -69,4 +69,9 @@ class FOSSBillingProvider(context: Context): BillingProvider { _isPremium.value = stored } } + + override fun restorePurchases() { + _isPremium.value = true + sharedPreferences.edit { putBoolean("foss_upgraded", true) } + } } 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 index bc6d35d..02f17b9 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/billing/PlayBillingProvider.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/billing/PlayBillingProvider.kt @@ -162,21 +162,19 @@ class PlayBillingProvider( it.purchaseState == Purchase.PurchaseState.PURCHASED } - -// val navigateToPurchase = purchases.find { +// val purchase = purchases.find { // it.products.contains(PREMIUM_PRODUCT_ID) && it.purchaseState == Purchase.PurchaseState.PURCHASED // } // -// if (navigateToPurchase != null) { +// if (purchase != null) { // val consumeParams = ConsumeParams.newBuilder() -// .setPurchaseToken(navigateToPurchase.purchaseToken) +// .setPurchaseToken(purchase.purchaseToken) // .build() // scope.launch { // billingClient.consumeAsync(consumeParams) { _, _ ->} // } // } - _isPremium.value = hasPremium scope.launch { @@ -201,4 +199,8 @@ class PlayBillingProvider( queryExistingPurchases() } } + + override fun restorePurchases() { + queryPurchases() + } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt index b9c7333..659dfaf 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt @@ -363,7 +363,13 @@ class AACPManager { } val key = ByteArray(keyLength) System.arraycopy(data, offset, key, 0, keyLength) - keys[ProximityKeyType.fromByte(keyType)] = key + try { + keys[ProximityKeyType.fromByte(keyType)] = key + } catch (e: Exception) { + Log.e( + TAG, "incorrect key type received: $keyType, ${key.toHexString()}" + ) + } offset += keyLength Log.d( TAG, "Parsed Proximity Key: Type: ${keyType}, Length: $keyLength, Key: ${ @@ -908,7 +914,7 @@ class AACPManager { ) buffer.put(byteArrayOf(0x01, 0xE5.toByte(), 0x4A)) // unknown, constant buffer.put("PlayingApp".toByteArray()) - buffer.put(byteArrayOf(0x56)) // 'V', seems like a identifier or a separator + buffer.put(byteArrayOf(0x56)) // 'V', seems like an identifier or a separator buffer.put("com.google.ios.youtube".toByteArray()) // package name, hardcoding for now, aforementioned reason buffer.put(byteArrayOf(0x52)) // 'R' buffer.put("HostStreamingState".toByteArray()) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/Packets.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/Packets.kt index a0559db..84a3006 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/data/Packets.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/data/Packets.kt @@ -72,6 +72,7 @@ enum class NoiseControlMode { class AirPodsNotifications { companion object { const val AIRPODS_CONNECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTED" + const val AIRPODS_L2CAP_CONNECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTED" const val AIRPODS_DATA = "me.kavishdevar.librepods.AIRPODS_DATA" const val EAR_DETECTION_DATA = "me.kavishdevar.librepods.EAR_DETECTION_DATA" const val ANC_DATA = "me.kavishdevar.librepods.ANC_DATA" diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/DeviceInfoCard.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/DeviceInfoCard.kt new file mode 100644 index 0000000..ea4f768 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/DeviceInfoCard.kt @@ -0,0 +1,171 @@ +package me.kavishdevar.librepods.presentation.components + +import android.os.Build +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import me.kavishdevar.librepods.R + +@Composable +fun DeviceInfoCard() { + val isDarkTheme = isSystemInDarkTheme() + + val textColor = if (isDarkTheme) Color.White else Color.Black + val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + + val rowHeight = remember { mutableStateOf(0.dp) } + val density = LocalDensity.current + + Column ( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = stringResource(R.string.device_info), style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = textColor.copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ), modifier = Modifier.padding(start = 16.dp) + ) + Column( + modifier = Modifier + .clip(RoundedCornerShape(28.dp)) + .fillMaxWidth() + .background(backgroundColor, RoundedCornerShape(28.dp)) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .onGloballyPositioned { coordinates -> + rowHeight.value = with(density) { coordinates.size.height.toDp() } + }, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.manufacturer), style = TextStyle( + fontSize = 16.sp, + color = textColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + Text( + text = Build.MANUFACTURER, style = TextStyle( + fontSize = 16.sp, + color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy( + alpha = 0.8f + ), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + } + HorizontalDivider( + thickness = 1.dp, + color = Color(0x40888888), + modifier = Modifier.padding(horizontal = 12.dp) + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.model_number), style = TextStyle( + fontSize = 16.sp, + color = textColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + Text( + text = Build.MODEL, style = TextStyle( + fontSize = 16.sp, + color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy( + alpha = 0.8f + ), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + } + HorizontalDivider( + thickness = 1.dp, + color = Color(0x40888888), + modifier = Modifier.padding(horizontal = 12.dp) + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.build_id), style = TextStyle( + fontSize = 16.sp, + color = textColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + Text( + text = Build.DISPLAY, style = TextStyle( + fontSize = 16.sp, + color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy( + alpha = 0.8f + ), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + } + HorizontalDivider( + thickness = 1.dp, + color = Color(0x40888888), + modifier = Modifier.padding(horizontal = 12.dp) + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.version), style = TextStyle( + fontSize = 16.sp, + color = textColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + Text( + text = Build.ID + " (${Build.VERSION.SDK_INT_FULL})", + style = TextStyle( + fontSize = 16.sp, + color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy( + alpha = 0.8f + ), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + } + } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/PlayBypassConfirmation.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/PlayBypassConfirmation.kt new file mode 100644 index 0000000..3ccac3b --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/PlayBypassConfirmation.kt @@ -0,0 +1,254 @@ +package me.kavishdevar.librepods.presentation.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.IconButton +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.kyant.backdrop.backdrops.LayerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.lens +import com.kyant.backdrop.effects.vibrancy +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import me.kavishdevar.librepods.R + + +@ExperimentalHazeMaterialsApi +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PlayBypassSheet( + visible: Boolean, + onDismiss: () -> Unit, + onConfirm: () -> Unit, + backdrop: LayerBackdrop +) { + if (!visible) return + + val dark = isSystemInDarkTheme() + val contentColor = if (dark) Color.White else Color.Black + + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + var acknowledged by remember { mutableStateOf(false) } + val inputState = rememberTextFieldState("") + + val isValid = acknowledged && inputState.text.trim() == "OK" + + val sfPro = FontFamily(Font(R.font.sf_pro)) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = Color.Transparent, + dragHandle = { }, + shape = RoundedCornerShape(48.dp), + scrimColor = Color.Transparent, + modifier = Modifier.padding(16.dp) + ) { + val innerBackdrop = rememberLayerBackdrop() + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(48.dp)) + .drawBackdrop( + backdrop = backdrop, + exportedBackdrop = innerBackdrop, + shape = { RoundedCornerShape(48.dp) }, + effects = { + vibrancy() + blur(6f.dp.toPx()) + lens(12f.dp.toPx(), 48f.dp.toPx(), true) + }, + onDrawSurface = { + drawRect( + if (dark) Color.DarkGray.copy(alpha = 0.3f) else Color.White.copy(alpha = 0.6f) + ) + } + ) + .padding(24.dp) + ) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = stringResource(R.string.bypass_compatibility_check), + style = TextStyle( + fontFamily = sfPro, + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp, + color = contentColor + ), + modifier = Modifier.padding(horizontal = 12.dp) + ) + + Text( + text = stringResource(R.string.compatibility_play_dialog_confirmation), + style = TextStyle( + fontFamily = sfPro, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + color = contentColor + ), + modifier = Modifier.padding(horizontal = 12.dp) + ) + + StyledSelectList( + items = listOf( + SelectItem( + name = stringResource(R.string.read_compatibility_requirements), + selected = acknowledged, + onClick = { acknowledged = !acknowledged } + ) + ) + ) + + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + keyboardController?.show() + } + val backgroundColor = if (dark) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + val textColor = if (dark) Color.White else Color.Black + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .height(58.dp) + .background( + backgroundColor, + RoundedCornerShape(28.dp) + ) + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + BasicTextField( + state = inputState, + textStyle = TextStyle( + fontSize = 16.sp, + color = textColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ), + cursorBrush = SolidColor(textColor), + decorator = { innerTextField -> + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier + .weight(1f) + ) { + Box { + if (inputState.text == "") { + Text( + text = stringResource(R.string.type_ok_to_continue, "OK"), + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Light, + fontFamily = sfPro, + color = textColor.copy(alpha = 0.8f) + ) + ) + } + innerTextField() + } + } + IconButton( + onClick = { + inputState.clearText() + } + ) { + Text( + text = "􀁡", + style = TextStyle( + fontSize = 16.sp, + fontFamily = FontFamily(Font(R.font.sf_pro)), + color = if (dark) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f) + ), + ) + } + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(start = 8.dp) + .focusRequester(focusRequester) + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(24.dp) + ) { + StyledButton( + onClick = onDismiss, + backdrop = innerBackdrop, + modifier = Modifier.weight(1f), + ) { + Text( + text = stringResource(R.string.no), + style = TextStyle( + fontFamily = sfPro, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + color = contentColor + ) + ) + } + StyledButton( + onClick = onConfirm, + backdrop = innerBackdrop, + isInteractive = isValid, + modifier = Modifier.weight(1f), + enabled = isValid, + surfaceColor = if (dark) Color(0xFF0091FF) else Color(0xFF0088FF) + ) { + Text( + text = stringResource(R.string.proceed), + style = TextStyle( + fontFamily = sfPro, + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + color = if (isValid) contentColor else contentColor.copy(alpha = 0.4f) + ) + ) + } + } + } + } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledButton.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledButton.kt index 5012ce0..6c60148 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledButton.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledButton.kt @@ -77,8 +77,10 @@ fun StyledButton( tint: Color = Color.Unspecified, surfaceColor: Color = Color.Unspecified, maxScale: Float = 0.1f, + enabled: Boolean = true, content: @Composable RowScope.() -> Unit, ) { + val isInteractive = enabled && isInteractive val scope = rememberCoroutineScope() val haptics = LocalHapticFeedback.current val progressAnimation = remember { Animatable(0f) } @@ -125,8 +127,8 @@ half4 main(float2 coord) { } else { drawRect(Color.White.copy(0.1f)) } - if (surfaceColor.isSpecified) { - val color = if (!isInteractive && isPressed) { + if (surfaceColor.isSpecified && enabled) { + val color = if (isPressed) { Color( red = surfaceColor.red * 0.5f, green = surfaceColor.green * 0.5f, @@ -137,6 +139,11 @@ half4 main(float2 coord) { surfaceColor } drawRect(color) + } else { + if (isPressed) { + drawRect(Color.Black.copy(alpha = 0.4f)) + drawRect(Color.White.copy(alpha = 0.2f)) + } } }, onDrawFront = null, @@ -245,8 +252,10 @@ half4 main(float2 coord) { indication = null, role = Role.Button, onClick = { - haptics.performHapticFeedback(HapticFeedbackType.ContextClick) - onClick() + if (enabled) { + haptics.performHapticFeedback(HapticFeedbackType.ContextClick) + onClick() + } } ) .then( @@ -302,8 +311,10 @@ half4 main(float2 coord) { isPressed = false }, onTap = { - haptics.performHapticFeedback(HapticFeedbackType.ContextClick) - onClick() + if (enabled) { + haptics.performHapticFeedback(HapticFeedbackType.ContextClick) + onClick() + } } ) } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSelectList.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSelectList.kt index 1b1dd1b..b9a2246 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSelectList.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSelectList.kt @@ -39,7 +39,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -74,7 +73,6 @@ fun StyledSelectList( val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val textColor = if (isDarkTheme) Color.White else Color.Black - val scope = rememberCoroutineScope() val haptics = LocalHapticFeedback.current Column( diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AccessibilitySettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AccessibilitySettingsScreen.kt index 98adcb1..22f80f4 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AccessibilitySettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AccessibilitySettingsScreen.kt @@ -20,6 +20,7 @@ package me.kavishdevar.librepods.presentation.screens // import me.kavishdevar.librepods.utils.RadareOffsetFinder import android.annotation.SuppressLint +import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.gestures.detectTapGestures @@ -39,6 +40,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf @@ -71,6 +73,10 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import me.kavishdevar.librepods.R import me.kavishdevar.librepods.bluetooth.AACPManager @@ -85,8 +91,8 @@ import me.kavishdevar.librepods.presentation.components.StyledToggle import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel import kotlin.io.encoding.ExperimentalEncodingApi -//private var phoneMediaDebounceJob: Job? = null -//private var toneVolumeDebounceJob: Job? = null +private var phoneMediaDebounceJob: Job? = null +private var toneVolumeDebounceJob: Job? = null //private const val TAG = "AccessibilitySettings" @SuppressLint("DefaultLocale") @@ -99,7 +105,13 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC val isDarkTheme = isSystemInDarkTheme() val textColor = if (isDarkTheme) Color.White else Color.Black - val hearingAidEnabled = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(1)?.toInt() == 1 && state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(0)?.toInt() == 1 + val hearingAidEnabled = + state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull( + 1 + ) + ?.toInt() == 1 && state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull( + 0 + )?.toInt() == 1 val backdrop = rememberLayerBackdrop() @@ -125,7 +137,7 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC backdrop = rememberLayerBackdrop(), modifier = Modifier.fillMaxWidth(), maxScale = 0.05f, - tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) + surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) ) { Text( stringResource(R.string.unlock_advanced_features), @@ -149,7 +161,10 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC 2.toByte() to stringResource(R.string.slowest) ) - val selectedPressSpeedValue = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL]?.getOrNull(0) + val selectedPressSpeedValue = + state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL]?.getOrNull( + 0 + ) var selectedPressSpeed by remember { mutableStateOf( pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0] @@ -162,7 +177,10 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC 2.toByte() to stringResource(R.string.slowest) ) - val selectedPressAndHoldDurationValue = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL]?.getOrNull(0) + val selectedPressAndHoldDurationValue = + state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL]?.getOrNull( + 0 + ) var selectedPressAndHoldDuration by remember { mutableStateOf( pressAndHoldDurationOptions[selectedPressAndHoldDurationValue] @@ -175,7 +193,10 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC 2.toByte() to stringResource(R.string.longer), 3.toByte() to stringResource(R.string.longest) ) - val selectedVolumeSwipeSpeedValue = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL]?.getOrNull(0) + val selectedVolumeSwipeSpeedValue = + state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL]?.getOrNull( + 0 + ) var selectedVolumeSwipeSpeed by remember { mutableStateOf( volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue] @@ -183,43 +204,42 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC ) } -// LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) { -// phoneMediaDebounceJob?.cancel() -// phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch { -// delay(150) -// val manager = ServiceManager.getService()?.aacpManager -// if (manager == null) { -// Log.w(TAG, "Cannot write EQ: AACPManager not available") -// return@launch -// } -// try { -// val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte() -// val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte() -// Log.d( -// TAG, -// "Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})" -// ) -// manager.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte) -// } catch (e: Exception) { -// Log.w(TAG, "Error sending phone/media EQ: ${e.message}") -// } -// } -// } - Box ( + val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) } + val phoneEQEnabled = remember { mutableStateOf(false) } + val mediaEQEnabled = remember { mutableStateOf(false) } + + LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) { + phoneMediaDebounceJob?.cancel() + phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch { + delay(150) + try { + val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte() + val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte() + Log.d( + "AccessibilitySettingsScreen", + "Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})" + ) + viewModel.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte) + } catch (e: Exception) { + Log.w( + "AccessibilitySettingsScreen", + "Error sending phone/media EQ: ${e.message}" + ) + } + } + } + Box( modifier = Modifier.then( if (!state.isPremium) { - Modifier - .pointerInput(Unit) { - awaitPointerEventScope { - while (true) { - val event = awaitPointerEvent(PointerEventPass.Initial) - event.changes.forEach { it.consume() } - } - } - } - } else Modifier - ) - ) { + Modifier.pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + event.changes.forEach { it.consume() } + } + } + } + } else Modifier)) { DropdownMenuComponent( label = stringResource(R.string.press_speed), description = stringResource(R.string.press_speed_description), @@ -239,21 +259,18 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC ) } - Box ( + Box( modifier = Modifier.then( - if (!state.isPremium) { - Modifier - .pointerInput(Unit) { - awaitPointerEventScope { - while (true) { - val event = awaitPointerEvent(PointerEventPass.Initial) - event.changes.forEach { it.consume() } - } - } - } - } else Modifier - ) - ) { + if (!state.isPremium) { + Modifier.pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + event.changes.forEach { it.consume() } + } + } + } + } else Modifier)) { DropdownMenuComponent( label = stringResource(R.string.press_and_hold_duration), description = stringResource(R.string.press_and_hold_duration_description), @@ -278,8 +295,14 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC label = stringResource(R.string.noise_cancellation_single_airpod), description = stringResource(R.string.noise_cancellation_single_airpod_description), independent = true, - checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE]?.getOrNull(0) == 0x01.toByte(), - onCheckedChange = { viewModel.setControlCommandBoolean(AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE, it) }, + checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE]?.getOrNull( + 0 + ) == 0x01.toByte(), + onCheckedChange = { + viewModel.setControlCommandBoolean( + AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE, it + ) + }, enabled = state.isPremium ) @@ -288,7 +311,12 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC label = stringResource(R.string.loud_sound_reduction), description = stringResource(R.string.loud_sound_reduction_description), checked = state.loudSoundReductionEnabled, - onCheckedChange = { viewModel.setATTCharacteristicValue(ATTHandles.LOUD_SOUND_REDUCTION, if (it) byteArrayOf(0x01) else byteArrayOf(0x00)) }, + onCheckedChange = { + viewModel.setATTCharacteristicValue( + ATTHandles.LOUD_SOUND_REDUCTION, + if (it) byteArrayOf(0x01) else byteArrayOf(0x00) + ) + }, enabled = state.isPremium ) } @@ -302,13 +330,19 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC ) } - val toneVolumeValue = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME]?.getOrNull(0)?.toFloat() ?: 75f + val toneVolumeValue = + state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME]?.getOrNull( + 0 + )?.toFloat() ?: 75f StyledSlider( label = stringResource(R.string.tone_volume), description = stringResource(R.string.tone_volume_description), value = toneVolumeValue, onValueChange = { - viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME, byteArrayOf(it.toInt().toByte(), 0x50)) + viewModel.setControlCommandValue( + AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME, + byteArrayOf(it.toInt().toByte(), 0x50) + ) }, valueRange = 0f..100f, snapPoints = listOf(75f), @@ -319,30 +353,34 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC ) if (state.capabilities.contains(Capability.SWIPE_FOR_VOLUME)) { - val volumeSwipeEnabled = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE]?.getOrNull(0)?.toInt() == 0x01 + val volumeSwipeEnabled = + state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE]?.getOrNull( + 0 + )?.toInt() == 0x01 StyledToggle( label = stringResource(R.string.volume_control), description = stringResource(R.string.volume_control_description), checked = volumeSwipeEnabled, - onCheckedChange = { viewModel.setControlCommandBoolean(AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE, it) }, + onCheckedChange = { + viewModel.setControlCommandBoolean( + AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE, it + ) + }, enabled = state.isPremium ) - Box ( + Box( modifier = Modifier.then( if (!state.isPremium) { - Modifier - .pointerInput(Unit) { - awaitPointerEventScope { - while (true) { - val event = awaitPointerEvent(PointerEventPass.Initial) - event.changes.forEach { it.consume() } - } - } - } - } else Modifier - ) - ) { + Modifier.pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + event.changes.forEach { it.consume() } + } + } + } + } else Modifier)) { DropdownMenuComponent( label = stringResource(R.string.volume_swipe_speed), description = stringResource(R.string.volume_swipe_speed_description), @@ -364,21 +402,22 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC } } -// if (!hearingAidEnabled.value&& BuildConfig.FLAVOR == "xposed") { +// if (!hearingAidEnabled && BuildConfig.FLAVOR == "xposed") { // Text( -// text = stringResource(R.string.apply_eq_to), -// style = TextStyle( +// text = stringResource(R.string.apply_eq_to), style = TextStyle( // fontSize = 14.sp, // fontWeight = FontWeight.Bold, // color = textColor.copy(alpha = 0.6f), // fontFamily = FontFamily(Font(R.font.sf_pro)) -// ), -// modifier = Modifier.padding(8.dp, bottom = 0.dp) +// ), modifier = Modifier.padding(8.dp, bottom = 0.dp) // ) // Column( // modifier = Modifier // .fillMaxWidth() -// .background(backgroundColor, RoundedCornerShape(28.dp)) +// .background( +// if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF), +// RoundedCornerShape(28.dp) +// ) // .padding(vertical = 0.dp) // ) { // val darkModeLocal = isSystemInDarkTheme() @@ -405,17 +444,19 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC // detectTapGestures( // onPress = { // phoneBackgroundColor = -// if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9) +// if (darkModeLocal) Color(0x40888888) else Color( +// 0x40D9D9D9 +// ) // tryAwaitRelease() // phoneBackgroundColor = -// if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) +// if (darkModeLocal) Color(0xFF1C1C1E) else Color( +// 0xFFFFFFFF +// ) // phoneEQEnabled.value = !phoneEQEnabled.value -// } -// ) +// }) // } // .padding(horizontal = 16.dp), -// verticalAlignment = Alignment.CenterVertically -// ) { +// verticalAlignment = Alignment.CenterVertically) { // Text( // stringResource(R.string.phone), // fontSize = 16.sp, @@ -441,8 +482,7 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC // } // // HorizontalDivider( -// thickness = 1.dp, -// color = Color(0x40888888) +// thickness = 1.dp, color = Color(0x40888888) // ) // // val mediaShape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp) @@ -467,17 +507,19 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC // detectTapGestures( // onPress = { // mediaBackgroundColor = -// if (darkModeLocal) Color(0x40888888) else Color(0x40D9D9D9) +// if (darkModeLocal) Color(0x40888888) else Color( +// 0x40D9D9D9 +// ) // tryAwaitRelease() // mediaBackgroundColor = -// if (darkModeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) +// if (darkModeLocal) Color(0xFF1C1C1E) else Color( +// 0xFFFFFFFF +// ) // mediaEQEnabled.value = !mediaEQEnabled.value -// } -// ) +// }) // } // .padding(horizontal = 16.dp), -// verticalAlignment = Alignment.CenterVertically -// ) { +// verticalAlignment = Alignment.CenterVertically) { // Text( // stringResource(R.string.media), // fontSize = 16.sp, @@ -502,90 +544,97 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC // ) // } // } - - // EQ Settings. Don't seem to have an effect? - // Column( - // modifier = Modifier - // .fillMaxWidth() - // .background(backgroundColor, RoundedCornerShape(28.dp)) - // .padding(12.dp), - // horizontalAlignment = Alignment.CenterHorizontally - // ) { - // for (i in 0 until 8) { - // val eqPhoneValue = - // remember(phoneMediaEQ.value[i]) { mutableFloatStateOf(phoneMediaEQ.value[i]) } - // Row( - // horizontalArrangement = Arrangement.SpaceBetween, - // verticalAlignment = Alignment.CenterVertically, - // modifier = Modifier - // .fillMaxWidth() - // .height(38.dp) - // ) { - // Text( - // text = String.format("%.2f", eqPhoneValue.floatValue), - // fontSize = 12.sp, - // color = textColor, - // modifier = Modifier.padding(bottom = 4.dp) - // ) - - // Slider( - // value = eqPhoneValue.floatValue, - // onValueChange = { newVal -> - // eqPhoneValue.floatValue = newVal - // val newEQ = phoneMediaEQ.value.copyOf() - // newEQ[i] = eqPhoneValue.floatValue - // phoneMediaEQ.value = newEQ - // }, - // valueRange = 0f..100f, - // modifier = Modifier - // .fillMaxWidth(0.9f) - // .height(36.dp), - // colors = SliderDefaults.colors( - // thumbColor = thumbColor, - // activeTrackColor = activeTrackColor, - // inactiveTrackColor = trackColor - // ), - // thumb = { - // Box( - // modifier = Modifier - // .size(24.dp) - // .shadow(4.dp, CircleShape) - // .background(thumbColor, CircleShape) - // ) - // }, - // track = { - // Box( - // modifier = Modifier - // .fillMaxWidth() - // .height(12.dp), - // contentAlignment = Alignment.CenterStart - // ) - // { - // Box( - // modifier = Modifier - // .fillMaxWidth() - // .height(4.dp) - // .background(trackColor, RoundedCornerShape(4.dp)) - // ) - // Box( - // modifier = Modifier - // .fillMaxWidth(eqPhoneValue.floatValue / 100f) - // .height(4.dp) - // .background(activeTrackColor, RoundedCornerShape(4.dp)) - // ) - // } - // } - // ) - - // Text( - // text = stringResource(R.string.band_label, i + 1), - // fontSize = 12.sp, - // color = textColor, - // modifier = Modifier.padding(top = 4.dp) - // ) - // } - // } - // } +// +//// EQ Settings. Don't seem to have an effect? +// Column( +// modifier = Modifier +// .fillMaxWidth() +// .background( +// if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF), +// RoundedCornerShape(28.dp) +// ) +// .padding(12.dp), horizontalAlignment = Alignment.CenterHorizontally +// ) { +// val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491) +// val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5) +// val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF) +// +// for (i in 0 until 8) { +// val eqPhoneValue = +// remember(phoneMediaEQ.value[i]) { mutableFloatStateOf(phoneMediaEQ.value[i]) } +// Row( +// horizontalArrangement = Arrangement.SpaceBetween, +// verticalAlignment = Alignment.CenterVertically, +// modifier = Modifier +// .fillMaxWidth() +// .height(38.dp) +// ) { +// Text( +// text = String.format("%.2f", eqPhoneValue.floatValue), +// fontSize = 12.sp, +// color = textColor, +// modifier = Modifier.padding(bottom = 4.dp) +// ) +// +// Slider( +// value = eqPhoneValue.floatValue, +// onValueChange = { newVal -> +// eqPhoneValue.floatValue = newVal +// val newEQ = phoneMediaEQ.value.copyOf() +// newEQ[i] = eqPhoneValue.floatValue +// phoneMediaEQ.value = newEQ +// }, +// valueRange = 0f..100f, +// modifier = Modifier +// .fillMaxWidth(0.9f) +// .height(36.dp), +// colors = SliderDefaults.colors( +// thumbColor = thumbColor, +// activeTrackColor = activeTrackColor, +// inactiveTrackColor = trackColor +// ), +// thumb = { +// Box( +// modifier = Modifier +// .size(24.dp) +// .shadow(4.dp, CircleShape) +// .background(thumbColor, CircleShape) +// ) +// }, +// track = { +// Box( +// modifier = Modifier +// .fillMaxWidth() +// .height(12.dp), +// contentAlignment = Alignment.CenterStart +// ) { +// Box( +// modifier = Modifier +// .fillMaxWidth() +// .height(4.dp) +// .background(trackColor, RoundedCornerShape(4.dp)) +// ) +// Box( +// modifier = Modifier +// .fillMaxWidth(eqPhoneValue.floatValue / 100f) +// .height(4.dp) +// .background( +// activeTrackColor, RoundedCornerShape(4.dp) +// ) +// ) +// } +// }) +// +// Text( +// text = stringResource(R.string.band_label, i + 1), +// fontSize = 12.sp, +// color = textColor, +// modifier = Modifier.padding(top = 4.dp) +// ) +// } +// } +// } +// } Spacer(modifier = Modifier.height(bottomPadding)) } } @@ -616,7 +665,7 @@ private fun DropdownMenuComponent( val haptics = LocalHapticFeedback.current val scope = rememberCoroutineScope() - Column(modifier = Modifier.fillMaxWidth()){ + Column(modifier = Modifier.fillMaxWidth()) { Column( modifier = Modifier .fillMaxWidth() @@ -630,14 +679,14 @@ private fun DropdownMenuComponent( } else Modifier ) .background( - if (independent) (if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) else Color.Transparent, + if (independent) (if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color( + 0xFFFFFFFF + )) else Color.Transparent, if (independent) RoundedCornerShape(28.dp) else RoundedCornerShape(0.dp) - ) - then( - if (independent) Modifier.padding(horizontal = 4.dp) else Modifier - ) - .clip(if (independent) RoundedCornerShape(28.dp) else RoundedCornerShape(0.dp)) - ){ + ) then (if (independent) Modifier.padding(horizontal = 4.dp) else Modifier).clip( + if (independent) RoundedCornerShape(28.dp) else RoundedCornerShape(0.dp) + ) + ) { Row( modifier = Modifier .fillMaxWidth() @@ -658,98 +707,94 @@ private fun DropdownMenuComponent( } } .pointerInput(Unit) { - detectDragGesturesAfterLongPress( - onDragStart = { offset -> - val now = System.currentTimeMillis() - touchOffset = offset - if (!expanded && now - lastDismissTime > 250L) { - expanded = true - } - lastDismissTime = now - parentDragActive = true - parentHoveredIndex = 0 - }, - onDrag = { change, _ -> - val current = change.position - val touch = touchOffset ?: current - val posInPopupY = current.y - touch.y - val idx = (posInPopupY / itemHeightPx).toInt() - if (idx != previousIdx) { - scope.launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) } - } - parentHoveredIndex = idx - previousIdx = idx - }, - onDragEnd = { - parentDragActive = false - parentHoveredIndex?.let { idx -> - if (idx in options.indices) { - onOptionSelected(options[idx]) - expanded = false - lastDismissTime = System.currentTimeMillis() - } - } - if (parentHoveredIndex != null && parentHoveredIndex in options.indices) { - scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) } - } - parentHoveredIndex = null - }, - onDragCancel = { - parentDragActive = false - parentHoveredIndex = null + detectDragGesturesAfterLongPress(onDragStart = { offset -> + val now = System.currentTimeMillis() + touchOffset = offset + if (!expanded && now - lastDismissTime > 250L) { + expanded = true } - ) + lastDismissTime = now + parentDragActive = true + parentHoveredIndex = 0 + }, onDrag = { change, _ -> + val current = change.position + val touch = touchOffset ?: current + val posInPopupY = current.y - touch.y + val idx = (posInPopupY / itemHeightPx).toInt() + if (idx != previousIdx) { + scope.launch { + haptics.performHapticFeedback( + HapticFeedbackType.SegmentTick + ) + } + } + parentHoveredIndex = idx + previousIdx = idx + }, onDragEnd = { + parentDragActive = false + parentHoveredIndex?.let { idx -> + if (idx in options.indices) { + onOptionSelected(options[idx]) + expanded = false + lastDismissTime = System.currentTimeMillis() + } + } + if (parentHoveredIndex != null && parentHoveredIndex in options.indices) { + scope.launch { + haptics.performHapticFeedback( + HapticFeedbackType.GestureEnd + ) + } + } + parentHoveredIndex = null + }, onDragCancel = { + parentDragActive = false + parentHoveredIndex = null + }) }, horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Column( modifier = Modifier.weight(1f) - ){ + ) { Text( text = label, fontSize = 16.sp, color = textColor, modifier = Modifier.padding(bottom = 4.dp) ) - if (!independent && description != null){ + if (!independent && description != null) { Text( - text = description, - style = TextStyle( + text = description, style = TextStyle( fontSize = 12.sp, fontWeight = FontWeight.Light, color = textColor.copy(alpha = 0.6f), fontFamily = FontFamily(Font(R.font.sf_pro)) - ), - modifier = Modifier.padding(16.dp, top = 0.dp, bottom = 2.dp) + ), modifier = Modifier.padding(16.dp, top = 0.dp, bottom = 2.dp) ) } } Box( modifier = Modifier.onGloballyPositioned { coordinates -> boxPosition = coordinates.positionInParent() - } - ) { + }) { Row( verticalAlignment = Alignment.CenterVertically ) { Text( - text = selectedOption, - style = TextStyle( + text = selectedOption, style = TextStyle( fontSize = 16.sp, color = textColor.copy(alpha = 0.8f), fontFamily = FontFamily(Font(R.font.sf_pro)) ) ) Text( - text = "􀆏", - style = TextStyle( + text = "􀆏", style = TextStyle( fontSize = 16.sp, color = textColor.copy(alpha = 0.6f), fontFamily = FontFamily(Font(R.font.sf_pro)) - ), - modifier = Modifier - .padding(start = 6.dp) + ), modifier = Modifier.padding(start = 6.dp) ) } @@ -774,19 +819,22 @@ private fun DropdownMenuComponent( } } } - if (independent && description != null){ + if (independent && description != null) { Box( modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp) - .background(if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7)) - ){ + .background( + if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7) + ) + ) { Text( - text = description, - style = TextStyle( + text = description, style = TextStyle( fontSize = 12.sp, fontWeight = FontWeight.Light, - color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f), + color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy( + alpha = 0.6f + ), fontFamily = FontFamily(Font(R.font.sf_pro)) ) ) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AdaptiveStrengthScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AdaptiveStrengthScreen.kt index 9a2f9ac..6ba885e 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AdaptiveStrengthScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AdaptiveStrengthScreen.kt @@ -81,7 +81,7 @@ fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel, navController: NavContro backdrop = rememberLayerBackdrop(), modifier = Modifier.fillMaxWidth(), maxScale = 0.05f, - tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) + surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) ) { Text( stringResource(R.string.unlock_advanced_features), diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt index d1b9605..43d1bc9 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt @@ -239,13 +239,11 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl item(key = "spacer_call") { Spacer(modifier = Modifier.height(16.dp)) } item(key = "call_control") { - val flipped = - state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG]?.take( - 2 - )?.equals(byteArrayOf(0x00.toByte(), 0x02.toByte())) + val bytes = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG]?.take(2)?.toByteArray() ?: byteArrayOf(0x00, 0x00) + val flipped = bytes[1] == 0x02.toByte() CallControlSettings( hazeState = hazeState, - flipped = flipped == true, + flipped = flipped, onCallControlValueChanged = { viewModel.setControlCommandValue( AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG, @@ -277,7 +275,7 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl backdrop = rememberLayerBackdrop(), modifier = Modifier.fillMaxWidth(), maxScale = 0.05f, - tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color( + surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color( 0xFFE59900 ) ) { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt index c4e49d1..d4c195c 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt @@ -19,6 +19,7 @@ package me.kavishdevar.librepods.presentation.screens import android.content.Intent +import android.content.pm.PackageManager import android.os.Build import android.widget.Toast import androidx.compose.foundation.background @@ -71,13 +72,13 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop import dev.chrisbanes.haze.hazeSource import me.kavishdevar.librepods.BuildConfig import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.presentation.components.DeviceInfoCard import me.kavishdevar.librepods.presentation.components.NavigationButton import me.kavishdevar.librepods.presentation.components.StyledButton import me.kavishdevar.librepods.presentation.components.StyledScaffold import me.kavishdevar.librepods.presentation.components.StyledSlider import me.kavishdevar.librepods.presentation.components.StyledToggle import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel -import java.util.Locale.getDefault @Composable fun AppSettingsScreen( @@ -106,7 +107,7 @@ fun AppSettingsScreen( val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val textColor = if (isDarkTheme) Color.White else Color.Black - if (!state.isPremium) { + if (!state.isPremium && state.connectionSuccessful) { StyledButton( onClick = { navController.navigate("purchase_screen") @@ -114,7 +115,7 @@ fun AppSettingsScreen( backdrop = rememberLayerBackdrop(), modifier = Modifier.fillMaxWidth(), maxScale = 0.05f, - tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) + surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) ) { Text( stringResource(R.string.unlock_advanced_features), @@ -128,246 +129,270 @@ fun AppSettingsScreen( } } - StyledToggle( - title = stringResource(R.string.widget), - label = stringResource(R.string.show_phone_battery_in_widget), - description = stringResource(R.string.show_phone_battery_in_widget_description), - checked = state.showPhoneBatteryInWidget, - onCheckedChange = viewModel::setShowPhoneBatteryInWidget, - enabled = state.isPremium - ) - - Text( - text = stringResource(R.string.conversational_awareness), style = TextStyle( - fontSize = 14.sp, - fontWeight = FontWeight.Bold, - color = textColor.copy(alpha = 0.6f), - fontFamily = FontFamily(Font(R.font.sf_pro)) - ), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp) - ) - - Spacer(modifier = Modifier.height(2.dp)) - - Column( - modifier = Modifier - .fillMaxWidth() - .background( - backgroundColor, RoundedCornerShape(28.dp) - ) - .padding(vertical = 4.dp) - ) { + if (state.connectionSuccessful) { StyledToggle( - label = stringResource(R.string.conversational_awareness_pause_music), - description = stringResource(R.string.conversational_awareness_pause_music_description), - checked = state.conversationalAwarenessPauseMusicEnabled, - onCheckedChange = viewModel::setConversationalAwarenessPauseMusicEnabled, - independent = false, + title = stringResource(R.string.widget), + label = stringResource(R.string.show_phone_battery_in_widget), + description = stringResource(R.string.show_phone_battery_in_widget_description), + checked = state.showPhoneBatteryInWidget, + onCheckedChange = viewModel::setShowPhoneBatteryInWidget, enabled = state.isPremium ) - HorizontalDivider( - thickness = 1.dp, - color = Color(0x40888888), - modifier = Modifier.padding(horizontal = 12.dp) + Text( + text = stringResource(R.string.conversational_awareness), style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = textColor.copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp) ) - StyledToggle( - label = stringResource(R.string.relative_conversational_awareness_volume), - description = stringResource(R.string.relative_conversational_awareness_volume_description), - checked = state.relativeConversationalAwarenessVolumeEnabled, - onCheckedChange = viewModel::setRelativeConversationalAwarenessVolumeEnabled, - independent = false, - enabled = state.isPremium, - ) - } + Spacer(modifier = Modifier.height(2.dp)) - Spacer(modifier = Modifier.height(16.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .background( + backgroundColor, RoundedCornerShape(28.dp) + ) + .padding(vertical = 4.dp) + ) { + StyledToggle( + label = stringResource(R.string.conversational_awareness_pause_music), + description = stringResource(R.string.conversational_awareness_pause_music_description), + checked = state.conversationalAwarenessPauseMusicEnabled, + onCheckedChange = viewModel::setConversationalAwarenessPauseMusicEnabled, + independent = false, + enabled = state.isPremium + ) - val conversationalAwarenessVolume = state.conversationalAwarenessVolume - LaunchedEffect(conversationalAwarenessVolume) { - viewModel.setConversationalAwarenessVolume(conversationalAwarenessVolume) - } + HorizontalDivider( + thickness = 1.dp, + color = Color(0x40888888), + modifier = Modifier.padding(horizontal = 12.dp) + ) - StyledSlider( - label = stringResource(R.string.conversational_awareness_volume), - value = conversationalAwarenessVolume, - valueRange = 10f..85f, - snapPoints = listOf(44f), - startLabel = "10%", - endLabel = "85%", - onValueChange = { newValue -> viewModel.setConversationalAwarenessVolume(newValue) }, - independent = true, - enabled = state.isPremium - ) + StyledToggle( + label = stringResource(R.string.relative_conversational_awareness_volume), + description = stringResource(R.string.relative_conversational_awareness_volume_description), + checked = state.relativeConversationalAwarenessVolumeEnabled, + onCheckedChange = viewModel::setRelativeConversationalAwarenessVolumeEnabled, + independent = false, + enabled = state.isPremium, + ) + } - if (!BuildConfig.PLAY_BUILD) { Spacer(modifier = Modifier.height(16.dp)) - NavigationButton( - to = "", - title = stringResource(R.string.camera_control), - name = stringResource(R.string.set_custom_camera_package), - navController = navController, - onClick = { - if (state.isPremium) viewModel.setShowCameraDialog(true) + val conversationalAwarenessVolume = state.conversationalAwarenessVolume + LaunchedEffect(conversationalAwarenessVolume) { + viewModel.setConversationalAwarenessVolume(conversationalAwarenessVolume) + } + + StyledSlider( + label = stringResource(R.string.conversational_awareness_volume), + value = conversationalAwarenessVolume, + valueRange = 10f..85f, + snapPoints = listOf(44f), + startLabel = "10%", + endLabel = "85%", + onValueChange = { newValue -> + viewModel.setConversationalAwarenessVolume( + newValue + ) }, independent = true, - description = stringResource(R.string.camera_control_app_description) - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - if (!BuildConfig.PLAY_BUILD) { - StyledToggle( - title = stringResource(R.string.ear_detection), - label = stringResource(R.string.disconnect_when_not_wearing), - description = stringResource(R.string.disconnect_when_not_wearing_description), - checked = state.disconnectWhenNotWearing, - onCheckedChange = viewModel::setDisconnectWhenNotWearing, enabled = state.isPremium ) - } - Text( - text = stringResource(R.string.takeover_airpods_state), style = TextStyle( - fontSize = 14.sp, - fontWeight = FontWeight.Bold, - color = textColor.copy(alpha = 0.6f), - fontFamily = FontFamily(Font(R.font.sf_pro)) - ), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp) - ) +// if (!BuildConfig.PLAY_BUILD) { +// Spacer(modifier = Modifier.height(16.dp)) +// +// NavigationButton( +// to = "", +// title = stringResource(R.string.camera_control), +// name = stringResource(R.string.set_custom_camera_package), +// navController = navController, +// onClick = { +// if (state.isPremium) viewModel.setShowCameraDialog(true) +// }, +// independent = true, +// description = stringResource(R.string.camera_control_app_description) +// ) +// } - Spacer(modifier = Modifier.height(4.dp)) - - Column( - modifier = Modifier - .fillMaxWidth() - .background( - backgroundColor, RoundedCornerShape(28.dp) + Spacer(modifier = Modifier.height(16.dp)) + if (context.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) { + StyledToggle( + title = stringResource(R.string.ear_detection), + label = stringResource(R.string.disconnect_when_not_wearing), + description = stringResource(R.string.disconnect_when_not_wearing_description), + checked = state.disconnectWhenNotWearing, + onCheckedChange = viewModel::setDisconnectWhenNotWearing, + enabled = state.isPremium ) - .padding(vertical = 4.dp) - ) { - StyledToggle( - label = stringResource(R.string.takeover_disconnected), - description = stringResource(R.string.takeover_disconnected_desc), - checked = state.takeoverWhenDisconnected, - onCheckedChange = viewModel::setTakeoverWhenDisconnected, - independent = false, - enabled = state.isPremium - ) - HorizontalDivider( - thickness = 1.dp, - color = Color(0x40888888), - modifier = Modifier.padding(horizontal = 12.dp) + } + + Text( + text = stringResource(R.string.takeover_airpods_state), style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = textColor.copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp) ) - StyledToggle( - label = stringResource(R.string.takeover_idle), - description = stringResource(R.string.takeover_idle_desc), - checked = state.takeoverWhenIdle, - onCheckedChange = viewModel::setTakeoverWhenIdle, - independent = false, - enabled = state.isPremium - ) - HorizontalDivider( - thickness = 1.dp, - color = Color(0x40888888), - modifier = Modifier.padding(horizontal = 12.dp) - ) + Spacer(modifier = Modifier.height(4.dp)) - StyledToggle( - label = stringResource(R.string.takeover_music), - description = stringResource(R.string.takeover_music_desc), - checked = state.takeoverWhenMusic, - onCheckedChange = viewModel::setTakeoverWhenMusic, - independent = false, - enabled = state.isPremium - ) - HorizontalDivider( - thickness = 1.dp, - color = Color(0x40888888), - modifier = Modifier.padding(horizontal = 12.dp) - ) - - StyledToggle( - label = stringResource(R.string.takeover_call), - description = stringResource(R.string.takeover_call_desc), - checked = state.takeoverWhenCall, - onCheckedChange = viewModel::setTakeoverWhenCall, - independent = false, - enabled = state.isPremium - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - - Text( - text = stringResource(R.string.takeover_phone_state), style = TextStyle( - fontSize = 14.sp, - fontWeight = FontWeight.Bold, - color = textColor.copy(alpha = 0.6f), - fontFamily = FontFamily(Font(R.font.sf_pro)) - ), modifier = Modifier.padding(horizontal = 16.dp) - ) - Spacer(modifier = Modifier.height(4.dp)) - Column( - modifier = Modifier - .fillMaxWidth() - .background( - backgroundColor, RoundedCornerShape(28.dp) + Column( + modifier = Modifier + .fillMaxWidth() + .background( + backgroundColor, RoundedCornerShape(28.dp) + ) + .padding(vertical = 4.dp) + ) { + StyledToggle( + label = stringResource(R.string.takeover_disconnected), + description = stringResource(R.string.takeover_disconnected_desc), + checked = state.takeoverWhenDisconnected, + onCheckedChange = viewModel::setTakeoverWhenDisconnected, + independent = false, + enabled = state.isPremium ) - .padding(vertical = 4.dp) - ) { - StyledToggle( - label = stringResource(R.string.takeover_ringing_call), - description = stringResource(R.string.takeover_ringing_call_desc), - checked = state.takeoverWhenRingingCall, - onCheckedChange = viewModel::setTakeoverWhenRingingCall, - independent = false, - enabled = state.isPremium + HorizontalDivider( + thickness = 1.dp, + color = Color(0x40888888), + modifier = Modifier.padding(horizontal = 12.dp) + ) + + StyledToggle( + label = stringResource(R.string.takeover_idle), + description = stringResource(R.string.takeover_idle_desc), + checked = state.takeoverWhenIdle, + onCheckedChange = viewModel::setTakeoverWhenIdle, + independent = false, + enabled = state.isPremium + ) + HorizontalDivider( + thickness = 1.dp, + color = Color(0x40888888), + modifier = Modifier.padding(horizontal = 12.dp) + ) + + StyledToggle( + label = stringResource(R.string.takeover_music), + description = stringResource(R.string.takeover_music_desc), + checked = state.takeoverWhenMusic, + onCheckedChange = viewModel::setTakeoverWhenMusic, + independent = false, + enabled = state.isPremium + ) + HorizontalDivider( + thickness = 1.dp, + color = Color(0x40888888), + modifier = Modifier.padding(horizontal = 12.dp) + ) + + StyledToggle( + label = stringResource(R.string.takeover_call), + description = stringResource(R.string.takeover_call_desc), + checked = state.takeoverWhenCall, + onCheckedChange = viewModel::setTakeoverWhenCall, + independent = false, + enabled = state.isPremium + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.takeover_phone_state), style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = textColor.copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ), modifier = Modifier.padding(horizontal = 16.dp) ) - HorizontalDivider( - thickness = 1.dp, - color = Color(0x40888888), - modifier = Modifier.padding(horizontal = 12.dp) + Spacer(modifier = Modifier.height(4.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .background( + backgroundColor, RoundedCornerShape(28.dp) + ) + .padding(vertical = 4.dp) + ) { + StyledToggle( + label = stringResource(R.string.takeover_ringing_call), + description = stringResource(R.string.takeover_ringing_call_desc), + checked = state.takeoverWhenRingingCall, + onCheckedChange = viewModel::setTakeoverWhenRingingCall, + independent = false, + enabled = state.isPremium + ) + HorizontalDivider( + thickness = 1.dp, + color = Color(0x40888888), + modifier = Modifier.padding(horizontal = 12.dp) + ) + + StyledToggle( + label = stringResource(R.string.takeover_media_start), + description = stringResource(R.string.takeover_media_start_desc), + checked = state.takeoverWhenMediaStart, + onCheckedChange = viewModel::setTakeoverWhenMediaStart, + independent = false, + enabled = state.isPremium + ) + } + + Text( + text = stringResource(R.string.advanced_options), style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = textColor.copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp) ) + Spacer(modifier = Modifier.height(2.dp)) + StyledToggle( - label = stringResource(R.string.takeover_media_start), - description = stringResource(R.string.takeover_media_start_desc), - checked = state.takeoverWhenMediaStart, - onCheckedChange = viewModel::setTakeoverWhenMediaStart, - independent = false, + label = stringResource(R.string.use_alternate_head_tracking_packets), + description = stringResource(R.string.use_alternate_head_tracking_packets_description), + checked = state.useAlternateHeadTrackingPackets, + onCheckedChange = viewModel::setUseAlternateHeadTrackingPackets, + independent = true, enabled = state.isPremium ) + } else { + Text( + text = stringResource(R.string.customizations_unavailable), + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + fontFamily = FontFamily(Font(R.font.sf_pro)), + color = textColor.copy(alpha = 0.6f), + ), + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(top = 16.dp) + ) } - Text( - text = stringResource(R.string.advanced_options), style = TextStyle( - fontSize = 14.sp, - fontWeight = FontWeight.Bold, - color = textColor.copy(alpha = 0.6f), - fontFamily = FontFamily(Font(R.font.sf_pro)) - ), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp) - ) - - Spacer(modifier = Modifier.height(2.dp)) - - StyledToggle( - label = stringResource(R.string.use_alternate_head_tracking_packets), - description = stringResource(R.string.use_alternate_head_tracking_packets_description), - checked = state.useAlternateHeadTrackingPackets, - onCheckedChange = viewModel::setUseAlternateHeadTrackingPackets, - independent = true, - enabled = state.isPremium - ) - if (BuildConfig.FLAVOR == "xposed") { Spacer(modifier = Modifier.height(16.dp)) - val restartBluetoothText = stringResource(R.string.found_offset_restart_bluetooth) + val restartBluetoothText = + stringResource(R.string.found_offset_restart_bluetooth) StyledToggle( - label = stringResource(R.string.act_as_an_apple_device) + " (${stringResource(R.string.requires_xposed)})", + label = stringResource(R.string.act_as_an_apple_device) + " (${ + stringResource( + R.string.requires_xposed + ) + })", description = stringResource(R.string.act_as_an_apple_device_description), checked = state.vendorIdHook, onCheckedChange = { enabled -> @@ -379,8 +404,8 @@ fun AppSettingsScreen( ) } - if (!BuildConfig.PLAY_BUILD) { + Spacer(modifier = Modifier.height(16.dp)) NavigationButton( to = "troubleshooting", name = stringResource(R.string.troubleshooting), @@ -418,15 +443,16 @@ fun AppSettingsScreen( val intent = Intent(Intent.ACTION_SENDTO).apply { data = "mailto:".toUri() putExtra(Intent.EXTRA_EMAIL, arrayOf("contact@kavish.xyz")) - putExtra(Intent.EXTRA_SUBJECT, "LibrePods: ") + putExtra(Intent.EXTRA_SUBJECT, "LibrePods: ") putExtra( Intent.EXTRA_TEXT, - "\n\n\n----------" + + "Describe your issue here:" + + "\n\n\n\n----------" + "\nPhone details:" + - "\nDEVICE: ${Build.DEVICE}" + - "\nMANUFACTURER: ${Build.MANUFACTURER} (${Build.BRAND})" + + "\nMANUFACTURER: ${Build.MANUFACTURER}" + "\nMODEL: ${Build.MODEL} (${Build.PRODUCT})" + - "\nVERSION: ${Build.DISPLAY} (${Build.VERSION.SDK_INT_FULL})" + + "\nDISPLAY_VERSION: ${Build.DISPLAY} (${Build.PRODUCT})" + + "\nID: ${Build.ID} (SDK ${Build.VERSION.SDK_INT_FULL})" + "\n\nApp details:" + "\nVERSION: ${BuildConfig.VERSION_NAME}" + "\nVERSION_CODE: ${BuildConfig.VERSION_CODE}" + @@ -478,7 +504,8 @@ fun AppSettingsScreen( ) } - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(20.dp)) + DeviceInfoCard() Text( text = stringResource(R.string.about), style = TextStyle( @@ -486,7 +513,7 @@ fun AppSettingsScreen( fontWeight = FontWeight.Bold, color = textColor.copy(alpha = 0.6f), fontFamily = FontFamily(Font(R.font.sf_pro)) - ), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp) + ), modifier = Modifier.padding(start = 16.dp, bottom = 2.dp, top = 24.dp) ) val rowHeight = remember { mutableStateOf(0.dp) } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeadTrackingScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeadTrackingScreen.kt index 6137ec1..b5ca451 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeadTrackingScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeadTrackingScreen.kt @@ -175,7 +175,7 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController backdrop = rememberLayerBackdrop(), modifier = Modifier.fillMaxWidth(), maxScale = 0.05f, - tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) + surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) ) { Text( stringResource(R.string.unlock_advanced_features), diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingProtectionScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingProtectionScreen.kt index 7bbbfd5..cf484b8 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingProtectionScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingProtectionScreen.kt @@ -18,29 +18,39 @@ package me.kavishdevar.librepods.presentation.screens +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop -import me.kavishdevar.librepods.BuildConfig import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.presentation.components.StyledScaffold -import me.kavishdevar.librepods.presentation.components.StyledToggle import me.kavishdevar.librepods.bluetooth.AACPManager import me.kavishdevar.librepods.bluetooth.ATTHandles +import me.kavishdevar.librepods.presentation.components.StyledButton +import me.kavishdevar.librepods.presentation.components.StyledScaffold +import me.kavishdevar.librepods.presentation.components.StyledToggle import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel @Composable -fun HearingProtectionScreen(viewModel: AirPodsViewModel) { +fun HearingProtectionScreen(viewModel: AirPodsViewModel, navController: NavController) { val backdrop = rememberLayerBackdrop() val state by viewModel.uiState.collectAsState() StyledScaffold( @@ -53,7 +63,27 @@ fun HearingProtectionScreen(viewModel: AirPodsViewModel) { .padding(horizontal = 16.dp) ) { Spacer(modifier = Modifier.height(spacerHeight)) - + if (!state.isPremium) { + StyledButton( + onClick = { + navController.navigate("purchase_screen") + }, + backdrop = rememberLayerBackdrop(), + modifier = Modifier.fillMaxWidth(), + maxScale = 0.05f, + surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) + ) { + Text( + stringResource(R.string.unlock_advanced_features), + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + fontFamily = FontFamily(Font(R.font.sf_pro)), + color = Color.White + ), + ) + } + } if (state.vendorIdHook) { StyledToggle( title = stringResource(R.string.environmental_noise), diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/PressAndHoldSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/PressAndHoldSettingsScreen.kt index 77dd621..5140d0d 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/PressAndHoldSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/PressAndHoldSettingsScreen.kt @@ -130,7 +130,7 @@ fun LongPress(viewModel: AirPodsViewModel, name: String, navController: NavContr backdrop = rememberLayerBackdrop(), modifier = Modifier.fillMaxWidth(), maxScale = 0.05f, - tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) + surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) ) { Text( stringResource(R.string.unlock_advanced_features), diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/PurchaseScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/PurchaseScreen.kt index 035dfc3..e60d38e 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/PurchaseScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/PurchaseScreen.kt @@ -99,7 +99,7 @@ fun PurchaseScreen( .padding(horizontal = 16.dp, vertical = 4.dp) ) { Text( - text = "Free features", + text = stringResource(R.string.free_features), style = TextStyle( fontSize = 14.sp, fontWeight = FontWeight.Bold, @@ -242,7 +242,7 @@ fun PurchaseScreen( .padding(horizontal = 16.dp, vertical = 4.dp) ) { Text( - text = "Advanced features", + text = stringResource(R.string.advanced_features), style = TextStyle( fontSize = 14.sp, fontWeight = FontWeight.Bold, @@ -288,6 +288,36 @@ fun PurchaseScreen( modifier = Modifier .padding(horizontal = 12.dp) ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = stringResource(R.string.digital_assistant_on_long_press), + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + fontFamily = FontFamily(Font(R.font.sf_pro)), + color = textColor + ) + ) + Text( + text = stringResource(R.string.digital_assistant_on_long_press_description), + style = TextStyle( + fontSize = 12.sp, + color = textColor.copy(0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)), + ) + ) + } + HorizontalDivider( + thickness = 1.dp, + color = Color(0x40888888), + modifier = Modifier + .padding(horizontal = 12.dp) + ) Column( modifier = Modifier .fillMaxWidth() @@ -456,7 +486,8 @@ fun PurchaseScreen( backdrop = rememberLayerBackdrop(), modifier = Modifier.fillMaxWidth(), maxScale = 0.05f, - tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) + surfaceColor = if (isSystemInDarkTheme()) Color(0xFF0091FF) + else Color(0xFF0088FF) // if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) ) { Text( stringResource(R.string.buy_price, state.price), @@ -478,6 +509,7 @@ fun PurchaseScreen( backdrop = rememberLayerBackdrop(), modifier = Modifier.fillMaxWidth(), maxScale = 0.05f, + isInteractive = false ) { Text( stringResource(R.string.restore_purchases), diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/RenameScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/RenameScreen.kt index 8fc352e..95bb80c 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/RenameScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/RenameScreen.kt @@ -56,8 +56,6 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.edit -import com.kyant.backdrop.backdrops.layerBackdrop -import com.kyant.backdrop.backdrops.rememberLayerBackdrop import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import me.kavishdevar.librepods.R import me.kavishdevar.librepods.presentation.components.StyledScaffold @@ -79,15 +77,12 @@ fun RenameScreen(viewModel: AirPodsViewModel) { name.value = name.value.copy(selection = TextRange(name.value.text.length)) } - val backdrop = rememberLayerBackdrop() - StyledScaffold( title = stringResource(R.string.name), ) { spacerHeight -> Column( modifier = Modifier .fillMaxSize() - .layerBackdrop(backdrop) .padding(horizontal = 16.dp) ) { Spacer(modifier = Modifier.height(spacerHeight)) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt index 3d41110..f849ca8 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt @@ -33,7 +33,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import me.kavishdevar.librepods.BuildConfig import me.kavishdevar.librepods.billing.BillingManager import me.kavishdevar.librepods.bluetooth.AACPManager import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers @@ -110,8 +109,6 @@ class AirPodsViewModel( private var isDemoMode = false val demoActivated = MutableSharedFlow() - private var billingFirstCollectDone = false - private val listeners = mutableMapOf() @@ -163,12 +160,12 @@ class AirPodsViewModel( private fun observeBilling() { if (isDemoMode) return viewModelScope.launch { - if (!BuildConfig.PLAY_BUILD) billingFirstCollectDone = true // FOSS doesn't send multiple events +// if (!BuildConfig.PLAY_BUILD) billingFirstCollectDone = true // FOSS doesn't send multiple events BillingManager.provider.isPremium.collect { premium -> - if (!billingFirstCollectDone) { - billingFirstCollectDone = true - return@collect - } +// if (!billingFirstCollectDone) { +// billingFirstCollectDone = true +// return@collect +// } if (!premium) { setControlCommandBoolean( ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG, @@ -184,8 +181,9 @@ class AirPodsViewModel( private fun observeBroadcasts() { broadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { - if (!isDemoMode) when (intent?.action) { - AirPodsNotifications.AIRPODS_CONNECTED -> { + val action = intent?.action ?: return + if (!isDemoMode) when (action) { + AirPodsNotifications.AIRPODS_L2CAP_CONNECTED -> { _uiState.update { it.copy(isLocallyConnected = true) } @@ -198,10 +196,8 @@ class AirPodsViewModel( } AirPodsNotifications.BATTERY_DATA -> { - val data = intent.getParcelableArrayListExtra("data", Battery::class.java) - ?.toList() ?: emptyList() _uiState.update { - it.copy(battery = data) + it.copy(battery = service.getBattery()) } } @@ -276,7 +272,7 @@ class AirPodsViewModel( } } - listeners[identifier] = listener as AACPManager.ControlCommandListener + listeners[identifier] = listener } // I'm lazy, sorry. @@ -435,7 +431,15 @@ class AirPodsViewModel( _uiState.update { it.copy(loudSoundReductionEnabled = value[0].toInt() == 0x01) } } viewModelScope.launch(Dispatchers.IO) { - service.attManager?.write(handle, value) + try { + service.attManager?.connect() + while (service.attManager?.socket?.isConnected != true) { + delay(250) + } + service.attManager?.write(handle, value) + } catch (e: Exception) { + e.printStackTrace() + } } } @@ -458,13 +462,16 @@ class AirPodsViewModel( fun observeATT() { viewModelScope.launch(Dispatchers.IO) { service.attManager?.connect() + while (service.attManager?.socket?.isConnected != true) { + delay(1000) + } service.attManager?.enableNotifications(ATTHandles.LOUD_SOUND_REDUCTION) service.attManager?.enableNotifications(ATTHandles.TRANSPARENCY) service.attManager?.enableNotifications(ATTHandles.HEARING_AID) while (true) { refreshATT() - delay(10000) + delay(15000) } } } @@ -489,10 +496,6 @@ class AirPodsViewModel( } } -// fun purchase(context: Context) { -// BillingManager.provider.purchase(context as Activity) -// } - fun activateDemoMode() { isDemoMode = true viewModelScope.launch { @@ -525,8 +528,17 @@ class AirPodsViewModel( modelName = fakeInstance.model.displayName, actualModel = fakeInstance.actualModelNumber, serialNumbers = listOf("DEMO", "DEMO", "DEMO"), - version3 = "Demo Firmware" + version3 = "Demo Firmware", +// isPremium = true ) } } + + fun sendPhoneMediaEQ(eq: FloatArray, phoneByte: Byte, mediaByte: Byte) { + service.aacpManager.sendPhoneMediaEQ(eq, phoneByte, mediaByte) + } + + fun disconnect() { + service.disconnectAirPods() + } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AppSettingsViewModel.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AppSettingsViewModel.kt index 304db98..4879fba 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AppSettingsViewModel.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AppSettingsViewModel.kt @@ -2,6 +2,7 @@ package me.kavishdevar.librepods.presentation.viewmodel import android.app.Application import android.content.Context +import android.content.SharedPreferences import androidx.core.content.edit import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope @@ -32,7 +33,8 @@ data class AppSettingsUiState( val cameraPackageValue: String = "", val cameraPackageError: String? = null, val vendorIdHook: Boolean = false, - val isPremium: Boolean = false + val isPremium: Boolean = false, + val connectionSuccessful: Boolean = false ) class AppSettingsViewModel(application: Application) : AndroidViewModel(application) { @@ -43,9 +45,22 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat private val xposedRemotePref = XposedRemotePrefProvider.create() + val sharedPrefListener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPref, key -> + if (key == "connection_successful") { + _uiState.update { it.copy(connectionSuccessful = sharedPref.getBoolean(key, false)) } + } + } + + init { loadSettings() observeBilling() + sharedPreferences.registerOnSharedPreferenceChangeListener(sharedPrefListener) + } + + override fun onCleared() { + sharedPreferences.unregisterOnSharedPreferenceChangeListener(sharedPrefListener) + super.onCleared() } private fun observeBilling() { @@ -72,7 +87,8 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat useAlternateHeadTrackingPackets = sharedPreferences.getBoolean("use_alternate_head_tracking_packets", true), conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 43).toFloat(), cameraPackageValue = sharedPreferences.getString("custom_camera_package", "") ?: "", - vendorIdHook = xposedRemotePref.getBoolean("vendor_id_hook", false) + vendorIdHook = xposedRemotePref.getBoolean("vendor_id_hook", false), + connectionSuccessful = sharedPreferences.getBoolean("connection_successful", false) ) } if (BuildConfig.FLAVOR == "xposed") { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/PurchaseViewModel.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/PurchaseViewModel.kt index 3ea9649..b9e5235 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/PurchaseViewModel.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/PurchaseViewModel.kt @@ -42,6 +42,6 @@ class PurchaseViewModel(application: Application) : AndroidViewModel(application } fun restorePurchases() { - BillingManager.provider.queryPurchases() + BillingManager.provider.restorePurchases() } } 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 7240214..b59ebeb 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,7 +16,7 @@ along with this program. If not, see . */ -@file:OptIn(ExperimentalEncodingApi::class) @file:Suppress("DEPRECATION") +@file:OptIn(ExperimentalEncodingApi::class) package me.kavishdevar.librepods.services @@ -58,7 +58,7 @@ import android.os.ParcelUuid import android.os.UserHandle import android.provider.Settings import android.telecom.TelecomManager -import android.telephony.PhoneStateListener +import android.telephony.TelephonyCallback import android.telephony.TelephonyManager import android.util.Log import android.util.TypedValue @@ -69,14 +69,7 @@ import androidx.annotation.RequiresApi import androidx.annotation.RequiresPermission import androidx.compose.material3.ExperimentalMaterial3Api import androidx.core.app.NotificationCompat -import androidx.core.app.ServiceCompat.START_STICKY -import androidx.core.app.ServiceCompat.startForeground -import androidx.core.content.ContextCompat.RECEIVER_EXPORTED -import androidx.core.content.ContextCompat.getSystemService -import androidx.core.content.ContextCompat.registerReceiver -import androidx.core.content.ContextCompat.startActivity import androidx.core.content.edit -import com.google.android.datatransport.runtime.scheduling.persistence.EventStoreModule_PackageNameFactory.packageName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -135,7 +128,6 @@ 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" @@ -230,7 +222,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList val packetLogsFlow: StateFlow> get() = _packetLogsFlow private lateinit var telephonyManager: TelephonyManager - private lateinit var phoneStateListener: PhoneStateListener + private lateinit var phoneStateListener: TelephonyCallback private val maxLogEntries = 1000 private val inMemoryLogs = mutableSetOf() @@ -369,7 +361,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } - @SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag") + @SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag", "HardwareIds") override fun onCreate() { super.onCreate() Log.i(TAG, "lib exempt worked: ${isBluetoothSocketExempted()}") @@ -391,7 +383,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList localMac = config.selfMacAddress if (localMac.isEmpty()) { - if (BuildConfig.FLAVOR == "xposed") { + if (checkSelfPermission("android.permission.LOCAL_MAC_ADDRESS") == PackageManager.PERMISSION_GRANTED) { + val bluetoothManager = getSystemService(BluetoothManager::class.java) + val bluetoothAdapter = bluetoothManager.adapter + localMac = bluetoothAdapter.address + } else { localMac = try { val process = Runtime.getRuntime().exec( arrayOf("su", "-c", "settings get secure bluetooth_address") @@ -602,10 +598,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList macAddress = sharedPreferences.getString("mac_address", "") ?: "" telephonyManager = getSystemService(TELEPHONY_SERVICE) as TelephonyManager - phoneStateListener = object : PhoneStateListener() { - @Deprecated("Deprecated in Java") - override fun onCallStateChanged(state: Int, phoneNumber: String?) { - super.onCallStateChanged(state, phoneNumber) + phoneStateListener = object: TelephonyCallback(), TelephonyCallback.CallStateListener { + override fun onCallStateChanged(state: Int) { when (state) { TelephonyManager.CALL_STATE_RINGING -> { val leAvailableForAudio = @@ -615,7 +609,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList takeOver("call") } if (config.headGestures) { - callNumber = phoneNumber handleIncomingCall() } } @@ -634,13 +627,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList TelephonyManager.CALL_STATE_IDLE -> { isInCall = false - callNumber = null gestureDetector?.stopDetection() } } } } - telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_CALL_STATE) + if (checkSelfPermission("android.permission.READ_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) { + telephonyManager.registerTelephonyCallback(mainExecutor, phoneStateListener) + } if (config.showPhoneBatteryInWidget) { widgetMobileBatteryEnabled = true @@ -850,7 +844,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList ) || (cameraActive && config.cameraAction == StemPressType.LONG_PRESS) Log.d( TAG, - "Setting up stem actions: " + "Single Press Customized: $singlePressCustomized, " + "Double Press Customized: $doublePressCustomized, " + "Triple Press Customized: $triplePressCustomized, " + "Long Press Customized: $longPressCustomized" + "Setting up stem actions: Single Press Customized: $singlePressCustomized, Double Press Customized: $doublePressCustomized, Triple Press Customized: $triplePressCustomized, Long Press Customized: $longPressCustomized" ) aacpManager.sendStemConfigPacket( singlePressCustomized, @@ -1070,6 +1064,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList version2 = config.airpodsVersion2, version3 = config.airpodsVersion3, ) + if (device != null) setMetadatas(device!!) } sendBroadcast( Intent(AirPodsNotifications.AIRPODS_INFORMATION_UPDATED).setPackage( @@ -1722,7 +1717,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList val disconnectedNotificationChannel = NotificationChannel( "background_service_status", "Background Service Status", - NotificationManager.IMPORTANCE_LOW + NotificationManager.IMPORTANCE_NONE ) val connectedNotificationChannel = NotificationChannel( @@ -1813,6 +1808,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } fun sendBatteryBroadcast() { + broadcastBatteryInformation() sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply { putParcelableArrayListExtra("data", ArrayList(batteryNotification.getBattery())) setPackage(packageName) @@ -1829,47 +1825,51 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } fun setBatteryMetadata() { - if (BuildConfig.FLAVOR != "xposed") return - device?.let { it -> - SystemApisUtils.setMetadata( - it, - it.METADATA_UNTETHERED_CASE_BATTERY, - batteryNotification.getBattery() - .find { it.component == BatteryComponent.CASE }?.level.toString().toByteArray() - ) - SystemApisUtils.setMetadata( - it, - it.METADATA_UNTETHERED_CASE_CHARGING, - (if (batteryNotification.getBattery() - .find { it.component == BatteryComponent.CASE }?.status == BatteryStatus.CHARGING - ) "1".toByteArray() else "0".toByteArray()) - ) - SystemApisUtils.setMetadata( - it, - it.METADATA_UNTETHERED_LEFT_BATTERY, - batteryNotification.getBattery() - .find { it.component == BatteryComponent.LEFT }?.level.toString().toByteArray() - ) - SystemApisUtils.setMetadata( - it, - it.METADATA_UNTETHERED_LEFT_CHARGING, - (if (batteryNotification.getBattery() - .find { it.component == BatteryComponent.LEFT }?.status == BatteryStatus.CHARGING - ) "1".toByteArray() else "0".toByteArray()) - ) - SystemApisUtils.setMetadata( - it, - it.METADATA_UNTETHERED_RIGHT_BATTERY, - batteryNotification.getBattery() - .find { it.component == BatteryComponent.RIGHT }?.level.toString().toByteArray() - ) - SystemApisUtils.setMetadata( - it, - it.METADATA_UNTETHERED_RIGHT_CHARGING, - (if (batteryNotification.getBattery() - .find { it.component == BatteryComponent.RIGHT }?.status == BatteryStatus.CHARGING - ) "1".toByteArray() else "0".toByteArray()) - ) + if (checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") != PackageManager.PERMISSION_GRANTED) { + device?.let { it -> + SystemApisUtils.setMetadata( + it, + it.METADATA_UNTETHERED_CASE_BATTERY, + batteryNotification.getBattery() + .find { it.component == BatteryComponent.CASE }?.level.toString() + .toByteArray() + ) + SystemApisUtils.setMetadata( + it, + it.METADATA_UNTETHERED_CASE_CHARGING, + (if (batteryNotification.getBattery() + .find { it.component == BatteryComponent.CASE }?.status == BatteryStatus.CHARGING + ) "1".toByteArray() else "0".toByteArray()) + ) + SystemApisUtils.setMetadata( + it, + it.METADATA_UNTETHERED_LEFT_BATTERY, + batteryNotification.getBattery() + .find { it.component == BatteryComponent.LEFT }?.level.toString() + .toByteArray() + ) + SystemApisUtils.setMetadata( + it, + it.METADATA_UNTETHERED_LEFT_CHARGING, + (if (batteryNotification.getBattery() + .find { it.component == BatteryComponent.LEFT }?.status == BatteryStatus.CHARGING + ) "1".toByteArray() else "0".toByteArray()) + ) + SystemApisUtils.setMetadata( + it, + it.METADATA_UNTETHERED_RIGHT_BATTERY, + batteryNotification.getBattery() + .find { it.component == BatteryComponent.RIGHT }?.level.toString() + .toByteArray() + ) + SystemApisUtils.setMetadata( + it, + it.METADATA_UNTETHERED_RIGHT_CHARGING, + (if (batteryNotification.getBattery() + .find { it.component == BatteryComponent.RIGHT }?.status == BatteryStatus.CHARGING + ) "1".toByteArray() else "0".toByteArray()) + ) + } } } @@ -2020,7 +2020,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList connected: Boolean, airpodsName: String? = null, batteryList: List? = null ) { val notificationManager = getSystemService(NotificationManager::class.java) - var updatedNotification: Notification? val notificationIntent = Intent(this, MainActivity::class.java) val pendingIntent = PendingIntent.getActivity( @@ -2080,13 +2079,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList notificationManager.notify(2, updatedNotification) notificationManager.cancel(1) } else if (!connected) { - updatedNotification = NotificationCompat.Builder(this, "background_service_status") - .setSmallIcon(R.drawable.airpods).setContentTitle("AirPods not connected") - .setContentText("Tap to open app").setContentIntent(pendingIntent) - .setCategory(Notification.CATEGORY_SERVICE) - .setPriority(NotificationCompat.PRIORITY_LOW).setOngoing(true).build() - - notificationManager.notify(1, updatedNotification) notificationManager.cancel(2) } else if (!config.bleOnlyMode && !socket.isConnected) { showSocketConnectionFailureNotification("Socket created, but not connected. Check logs") @@ -2116,7 +2108,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList return suspendCancellableCoroutine { continuation -> gestureDetector?.startDetection(doNotStop = true) { accepted -> if (continuation.isActive) { - continuation.resume(accepted) { + continuation.resume(accepted) { _, _, _ -> gestureDetector?.stopDetection() } } @@ -2129,7 +2121,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val telecomManager = getSystemService(TELECOM_SERVICE) as TelecomManager if (checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) == PackageManager.PERMISSION_GRANTED) { - telecomManager.acceptRingingCall() + telecomManager.acceptRingingCall() // TODO: Switch to InCallService (needs CDM association) } } else { val telephonyService = getSystemService(TELEPHONY_SERVICE) as TelephonyManager @@ -2156,7 +2148,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val telecomManager = getSystemService(TELECOM_SERVICE) as TelecomManager if (checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) == PackageManager.PERMISSION_GRANTED) { - telecomManager.endCall() + telecomManager.endCall() // TODO: Switch to InCallService (needs CDM association) } } else { val telephonyService = getSystemService(TELEPHONY_SERVICE) as TelephonyManager @@ -2229,9 +2221,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList @Suppress("PrivatePropertyName") private val ACTION_ASI_UPDATE_BLUETOOTH_DATA = "batterywidget.impl.action.update_bluetooth_data" - @Suppress("MissingPermission", "unused") + @SuppressLint("MissingPermission") fun broadcastBatteryInformation() { - if (device == null) return + if (device == null || checkSelfPermission("android.permission.INTERACT_ACROSS_USERS") != PackageManager.PERMISSION_GRANTED) return val batteryList = batteryNotification.getBattery() val leftBattery = batteryList.find { it.component == BatteryComponent.LEFT } @@ -2315,7 +2307,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } private fun setMetadatas(d: BluetoothDevice) { - if (BuildConfig.FLAVOR != "xposed") return + if (checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") != PackageManager.PERMISSION_GRANTED) { + Log.d(TAG, "no permission BLUETOOTH_PRIVILEGED, returning") + return + } + Log.d(TAG, "has permission BLUETOOTH_PRIVILEGED, proceeding") d.let { device -> val instance = airpodsInstance if (instance != null) { @@ -2385,7 +2381,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList val context = context?.applicationContext val name = context?.getSharedPreferences("settings", MODE_PRIVATE) ?.getString("name", bluetoothDevice?.name) - if (bluetoothDevice != null && action != null && !action.isEmpty()) { + if (bluetoothDevice != null && !action.isNullOrEmpty()) { Log.d(TAG, "Received bluetooth connection broadcast: action=$action") if (BluetoothDevice.ACTION_ACL_CONNECTED == action) { val uuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a") @@ -2698,6 +2694,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList version2 = config.airpodsVersion2, version3 = config.airpodsVersion3, ) + setMetadatas(device) } } @@ -2705,7 +2702,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList true, config.deviceName, batteryNotification.getBattery() ) Log.d(TAG, " Socket connected") + sharedPreferences.edit { putBoolean("connection_successful", true) } + sendBroadcast(Intent(AirPodsNotifications.AIRPODS_L2CAP_CONNECTED)) } catch (e: Exception) { +// sharedPreferences.edit { putBoolean("connection_successful", false) } Log.d( TAG, " Socket not connected, ${e.message}" ) @@ -2870,19 +2870,36 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList }) val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter - bluetoothAdapter.getProfileProxy(this, object : BluetoothProfile.ServiceListener { - override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { - if (profile == BluetoothProfile.A2DP) { - val connectedDevices = proxy.connectedDevices - if (connectedDevices.isNotEmpty()) { - MediaController.sendPause() + if (checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED){ + bluetoothAdapter.getProfileProxy(this, object : BluetoothProfile.ServiceListener { + override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { + if (profile == BluetoothProfile.A2DP) { + val connectedDevices = proxy.connectedDevices + if (connectedDevices.isNotEmpty()) { + MediaController.sendPause() + } } + bluetoothAdapter.closeProfileProxy(profile, proxy) } - bluetoothAdapter.closeProfileProxy(profile, proxy) - } - override fun onServiceDisconnected(profile: Int) {} - }, BluetoothProfile.A2DP) + override fun onServiceDisconnected(profile: Int) {} + }, BluetoothProfile.A2DP) + } + if (checkSelfPermission("android.permission.MODIFY_PHONE_STATE") == PackageManager.PERMISSION_GRANTED){ + bluetoothAdapter.getProfileProxy(this, object : BluetoothProfile.ServiceListener { + override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { + if (profile == BluetoothProfile.HEADSET) { + val connectedDevices = proxy.connectedDevices + if (connectedDevices.isNotEmpty()) { + MediaController.sendPause() + } + } + bluetoothAdapter.closeProfileProxy(profile, proxy) + } + + override fun onServiceDisconnected(profile: Int) {} + }, BluetoothProfile.HEADSET) + } Log.d(TAG, "Disconnected AirPods upon user request") } @@ -2917,98 +2934,123 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList fun disconnectAudio(context: Context, device: BluetoothDevice?) { val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter - bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { - override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { - if (profile == BluetoothProfile.A2DP) { - try { - if (proxy.getConnectionState(device) == BluetoothProfile.STATE_DISCONNECTED) { - Log.d(TAG, "Already disconnected from A2DP") - return + if (checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) { + bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { + override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { + if (profile == BluetoothProfile.A2DP) { + try { + if (proxy.getConnectionState(device) == BluetoothProfile.STATE_DISCONNECTED) { + Log.d(TAG, "Already disconnected from A2DP") + return + } + val method = proxy.javaClass.getMethod( + "setConnectionPolicy", BluetoothDevice::class.java, Int::class.java + ) + Log.d(TAG, "calling A2DP.setConnectionPolicy for ${device?.address} to 0") + method.invoke(proxy, device, 0) + } catch (e: Exception) { + e.printStackTrace() + } finally { + bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy) } - val method = proxy.javaClass.getMethod( - "setConnectionPolicy", BluetoothDevice::class.java, Int::class.java - ) - method.invoke(proxy, device, 0) - } catch (e: Exception) { - Log.w(TAG, "we probably do not have BLUETOOTH_PRIVILEGED") - } finally { - bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy) } } - } - override fun onServiceDisconnected(profile: Int) {} - }, BluetoothProfile.A2DP) -// requires protected permission (MODIFY_PHONE_STATE) -// bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { -// override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { -// if (profile == BluetoothProfile.HEADSET) { -// try { -// val method = -// proxy.javaClass.getMethod("setConnectionPolicy", BluetoothDevice::class.java, Int::class.java) -// method.invoke(proxy, device, 0) -// } catch (e: Exception) { -// e.printStackTrace() -// } finally { -// bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy) -// } -// } -// } -// -// override fun onServiceDisconnected(profile: Int) {} -// }, BluetoothProfile.HEADSET) + override fun onServiceDisconnected(profile: Int) {} + }, BluetoothProfile.A2DP) + } else { + Log.d(TAG, "not disconnecting A2DP, no BLUETOOTH_PRIVILEGED permission") + } + if (checkSelfPermission("android.permission.MODIFY_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) { + bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { + override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { + if (profile == BluetoothProfile.HEADSET) { + try { + val method = + proxy.javaClass.getMethod( + "setConnectionPolicy", + BluetoothDevice::class.java, + Int::class.java + ) + Log.d(TAG, "calling HEADSET.setConnectionPolicy for ${device?.address} to 0") + method.invoke(proxy, device, 0) + } catch (e: Exception) { + e.printStackTrace() + } finally { + bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy) + } + } + } + override fun onServiceDisconnected(profile: Int) {} + }, BluetoothProfile.HEADSET) + } else { + Log.d(TAG, "not disconnecting HEADSET, no MODIFIY_PHONE_STATE permission") + } } fun connectAudio(context: Context, device: BluetoothDevice?) { val bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter + if (context.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) { - bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { - override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { - if (profile == BluetoothProfile.A2DP) { - try { - val policyMethod = proxy.javaClass.getMethod( - "setConnectionPolicy", BluetoothDevice::class.java, Int::class.java - ) - policyMethod.invoke(proxy, device, 100) - val connectMethod = - proxy.javaClass.getMethod("connect", BluetoothDevice::class.java) - connectMethod.invoke( - proxy, device - ) // reduces the slight delay between allowing and actually connecting - } catch (e: Exception) { - Log.w(TAG, "we probably do not have BLUETOOTH_PRIVILEGED") - } finally { - bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy) - if (MediaController.pausedWhileTakingOver) { - MediaController.sendPlay() + bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { + override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { + if (profile == BluetoothProfile.A2DP) { + try { + val policyMethod = proxy.javaClass.getMethod( + "setConnectionPolicy", BluetoothDevice::class.java, Int::class.java + ) + Log.d(TAG, "calling A2DP.setConnectionPolicy for ${device?.address} to 100") + policyMethod.invoke(proxy, device, 100) + val connectMethod = + proxy.javaClass.getMethod("connect", BluetoothDevice::class.java) + connectMethod.invoke( + proxy, device + ) + } catch (e: Exception) { + e.printStackTrace() + } finally { + bluetoothAdapter.closeProfileProxy(BluetoothProfile.A2DP, proxy) + if (MediaController.pausedWhileTakingOver) { + MediaController.sendPlay() + } } } } - } - override fun onServiceDisconnected(profile: Int) {} - }, BluetoothProfile.A2DP) -// requires protected permission (MODIFY_PHONE_STATE) -// bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { -// override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { -// if (profile == BluetoothProfile.HEADSET) { -// try { -// val policyMethod = proxy.javaClass.getMethod("setConnectionPolicy", BluetoothDevice::class.java, Int::class.java) -// policyMethod.invoke(proxy, device, 100) -// val connectMethod = -// proxy.javaClass.getMethod("connect", BluetoothDevice::class.java) -// connectMethod.invoke(proxy, device) -// } catch (e: Exception) { -// e.printStackTrace() -// } finally { -// bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy) -// } -// } -// } -// -// override fun onServiceDisconnected(profile: Int) {} -// }, BluetoothProfile.HEADSET) + override fun onServiceDisconnected(profile: Int) {} + }, BluetoothProfile.A2DP) + } else { + Log.d(TAG, "not connecting A2DP, no BLUETOOTH_PRIVILEGED permission") + } + if (checkSelfPermission("android.permission.MODIFY_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) { + bluetoothAdapter?.getProfileProxy(context, object : BluetoothProfile.ServiceListener { + override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) { + if (profile == BluetoothProfile.HEADSET) { + try { + val policyMethod = proxy.javaClass.getMethod( + "setConnectionPolicy", + BluetoothDevice::class.java, + Int::class.java + ) + Log.d(TAG, "calling HEADSET.setConnectionPolicy for ${device?.address} to 100") + policyMethod.invoke(proxy, device, 100) + val connectMethod = + proxy.javaClass.getMethod("connect", BluetoothDevice::class.java) + connectMethod.invoke(proxy, device) + } catch (e: Exception) { + e.printStackTrace() + } finally { + bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, proxy) + } + } + } + + override fun onServiceDisconnected(profile: Int) {} + }, BluetoothProfile.HEADSET) + } else { + Log.d(TAG, "not connecting HEADSET, no MODIFIY_PHONE_STATE permission") + } } fun setName(name: String) { @@ -3016,6 +3058,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList if (config.deviceName != name) { config.deviceName = name + device?.alias = name sharedPreferences.edit { putString("name", name) } } @@ -3055,7 +3098,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } catch (e: Exception) { e.printStackTrace() } - telephonyManager.listen(phoneStateListener, PhoneStateListener.LISTEN_NONE) + if (checkSelfPermission("android.permission.READ_PHONE_STATE") == PackageManager.PERMISSION_GRANTED) { + telephonyManager.unregisterTelephonyCallback(phoneStateListener) + } // isConnectedLocally = false // CrossDevice.isAvailable = true super.onDestroy() 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 index 129f61f..052d976 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/RootlessSupport.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/RootlessSupport.kt @@ -36,7 +36,7 @@ fun isSupported(sharedPreferences: SharedPreferences): Boolean { } } } else if (isOppoOrOnePlus) { - return Build.VERSION.SDK_INT == 36 + return Build.VERSION.SDK_INT >= 36 } return sharedPreferences.getBoolean("bypass_device_check", false) } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 67c7e04..d3f7f1b 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -239,5 +239,28 @@ Bypass compatibility check Are you sure your device is supported natively/you have Xposed module enabled? Not supported - Check the repository for more info. + + Many devices are not supported due to limitations in the Android Bluetooth stack. + \nOn these devices, root access with an Xposed framework is required for full functionality. + \n\nThis limitation has been addressed in newer Android versions. The following device configurations can run the app natively: + \n• Google Pixel® running Android 16 March update and later with the lateset Play system update + \n• Google Pixel® running 17 Beta 3 and above + \n• OnePlus devices running OxygenOS 16 or later + \n• Oppo devices running ColorOS 16 or later + \n\nFor details, see the project documentation. + (Name your own price) + + This device may not be supported due to platform limitations and requires an Xposed framework. Tick the checkbox below and type OK to continue. + + Type "%s" to continue + Proceed + I have read compatibility requirements. + Device information + Build ID + Manufacturer + Free features + Advanced features + Digital Assistant on Long Press + Invoke Digital Assistant when long pressing the AirPods Pro stem. + Customizations unavailable. Connect your AirPods at least once to access. diff --git a/android/app/src/normal/java/me/kavishdevar/librepods/LibrePodsApplication.kt b/android/app/src/normal/java/me/kavishdevar/librepods/LibrePodsApplication.kt index 0120900..95774ab 100644 --- a/android/app/src/normal/java/me/kavishdevar/librepods/LibrePodsApplication.kt +++ b/android/app/src/normal/java/me/kavishdevar/librepods/LibrePodsApplication.kt @@ -1,5 +1,21 @@ package me.kavishdevar.librepods import android.app.Application +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import me.kavishdevar.librepods.billing.BillingManager +import me.kavishdevar.librepods.billing.BillingProviderFactory -class LibrePodsApplication: Application() +class LibrePodsApplication: Application(), DefaultLifecycleObserver { + override fun onCreate() { + BillingManager.provider = BillingProviderFactory.create(this) + ProcessLifecycleOwner.get().lifecycle.addObserver(this) + + super.onCreate() + } + + override fun onResume(owner: LifecycleOwner) { + BillingManager.provider.queryPurchases() + } +} diff --git a/android/app/src/xposed/java/me/kavishdevar/librepods/LibrePodsApplication.kt b/android/app/src/xposed/java/me/kavishdevar/librepods/LibrePodsApplication.kt index cca90e5..8923d46 100644 --- a/android/app/src/xposed/java/me/kavishdevar/librepods/LibrePodsApplication.kt +++ b/android/app/src/xposed/java/me/kavishdevar/librepods/LibrePodsApplication.kt @@ -1,14 +1,27 @@ package me.kavishdevar.librepods import android.app.Application +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner import io.github.libxposed.service.XposedService import io.github.libxposed.service.XposedServiceHelper +import me.kavishdevar.librepods.billing.BillingManager +import me.kavishdevar.librepods.billing.BillingProviderFactory import me.kavishdevar.librepods.utils.XposedServiceHolder -class LibrePodsApplication: Application(), XposedServiceHelper.OnServiceListener { +class LibrePodsApplication: Application(), XposedServiceHelper.OnServiceListener, DefaultLifecycleObserver { override fun onCreate() { - super.onCreate() XposedServiceHelper.registerListener(this) + BillingManager.provider = BillingProviderFactory.create(this) + ProcessLifecycleOwner.get().lifecycle.addObserver(this) + + super.onCreate() + + } + + override fun onResume(owner: LifecycleOwner) { + BillingManager.provider.queryPurchases() } override fun onServiceBind(p0: XposedService) { diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 964d282..3c8add6 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -18,6 +18,7 @@ backdrop = "2.0.0-alpha03" billing = "8.3.0" hilt = "2.59.2" xposed = "101.0.0" +lifecycleProcess = "2.10.0" [libraries] accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" } @@ -47,6 +48,7 @@ hilt = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } libxposed-api = { group = "io.github.libxposed", name = "api", version.ref = "xposed" } libxposed-service = { group = "io.github.libxposed", name = "service", version.ref = "xposed" } +androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleProcess" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/update_nonpatch.json b/update_nonpatch.json index 3e98d3e..e931cb7 100644 --- a/update_nonpatch.json +++ b/update_nonpatch.json @@ -1,6 +1,6 @@ { - "version": "v0.1.0-rc.4", - "versionCode": 3, - "zipUrl": "https://github.com/kavishdevar/librepods/releases/download/v0.1.0-rc.4/LibrePods-v0.1.0-rc.4.zip", + "version": "v0.2.3", + "versionCode": 36, + "zipUrl": "https://github.com/kavishdevar/librepods/releases/download/v0.2.3/LibrePods-v0.2.3-release.zip", "changelog": "https://raw.githubusercontent.com/kavishdevar/librepods/main/CHANGELOG.md" }