diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 6d2aa93..e39f632 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,4 +1,3 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget import java.util.Properties plugins { @@ -27,10 +26,10 @@ android { defaultConfig { applicationId = "me.kavishdevar.librepods" - minSdk = 36 + minSdk = 33 targetSdk = 37 - versionCode = 21 - versionName = "0.2.0-beta.1" + versionCode = 27 + versionName = "0.2.0" } buildTypes { release { @@ -45,14 +44,19 @@ android { arguments += "-DCMAKE_BUILD_TYPE=Release" } } + buildConfigField("Boolean", "PLAY_BUILD", "false") signingConfig = signingConfigs.getByName("release") } debug { + buildConfigField("Boolean", "PLAY_BUILD", "false") signingConfig = signingConfigs.getByName("release") } create("playRelease") { initWith(getByName("release")) - versionNameSuffix = "-play" + buildConfigField("Boolean", "PLAY_BUILD", "true") + } + create("playDebug") { + initWith(getByName("debug")) buildConfigField("Boolean", "PLAY_BUILD", "true") } } @@ -60,11 +64,6 @@ android { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } - kotlin { - compilerOptions { - jvmTarget.set(JvmTarget.JVM_21) - } - } buildFeatures { compose = true viewBinding = true @@ -105,7 +104,7 @@ android { arguments += "-DIS_XPOSED=ON" } } - applicationIdSuffix = ".xposed" + versionNameSuffix = "-xposed" } } } @@ -134,9 +133,10 @@ dependencies { implementation(libs.aboutlibraries) implementation(libs.aboutlibraries.compose.m3) implementation(libs.backdrop) - implementation(libs.hilt) +// implementation(libs.hilt) // implementation(libs.hilt.compiler) - add("xposedCompileOnly", files("libs/libxposed-api-100.aar")) + add("xposedCompileOnly", libs.libxposed.api) + add("xposedImplementation", libs.libxposed.service) add("playReleaseImplementation", libs.billing) } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 61fc138..428ea3e 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -36,6 +36,7 @@ @@ -60,7 +61,7 @@ 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 08f06d4..57c86e9 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt @@ -22,6 +22,7 @@ package me.kavishdevar.librepods // import me.kavishdevar.librepods.screens.Onboarding // import me.kavishdevar.librepods.utils.RadareOffsetFinder +//import dagger.hilt.android.AndroidEntryPoint import android.annotation.SuppressLint import android.content.BroadcastReceiver import android.content.ComponentName @@ -51,7 +52,9 @@ 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 import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -61,6 +64,7 @@ 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 @@ -77,6 +81,8 @@ 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 @@ -88,6 +94,8 @@ 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 @@ -111,40 +119,44 @@ import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberMultiplePermissionsState import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop -import dagger.hilt.android.AndroidEntryPoint +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.composables.StyledIconButton -import me.kavishdevar.librepods.constants.AirPodsNotifications +import me.kavishdevar.librepods.data.AirPodsNotifications import me.kavishdevar.librepods.data.ControlCommandRepository -import me.kavishdevar.librepods.screens.AccessibilitySettingsScreen -import me.kavishdevar.librepods.screens.AdaptiveStrengthScreen -import me.kavishdevar.librepods.screens.AirPodsSettingsScreen -import me.kavishdevar.librepods.screens.AppSettingsScreen -import me.kavishdevar.librepods.screens.CameraControlScreen -import me.kavishdevar.librepods.screens.DebugScreen -import me.kavishdevar.librepods.screens.HeadTrackingScreen -import me.kavishdevar.librepods.screens.HearingAidAdjustmentsScreen -import me.kavishdevar.librepods.screens.HearingAidScreen -import me.kavishdevar.librepods.screens.HearingProtectionScreen -import me.kavishdevar.librepods.screens.LongPress -import me.kavishdevar.librepods.screens.OpenSourceLicensesScreen -import me.kavishdevar.librepods.screens.RenameScreen -import me.kavishdevar.librepods.screens.TransparencySettingsScreen -import me.kavishdevar.librepods.screens.UpdateHearingTestScreen -import me.kavishdevar.librepods.screens.VersionScreen +import me.kavishdevar.librepods.presentation.components.ConfirmationDialog +import me.kavishdevar.librepods.presentation.components.StyledIconButton +import me.kavishdevar.librepods.presentation.screens.AccessibilitySettingsScreen +import me.kavishdevar.librepods.presentation.screens.AdaptiveStrengthScreen +import me.kavishdevar.librepods.presentation.screens.AirPodsSettingsScreen +import me.kavishdevar.librepods.presentation.screens.AppSettingsScreen +import me.kavishdevar.librepods.presentation.screens.CameraControlScreen +import me.kavishdevar.librepods.presentation.screens.DebugScreen +import me.kavishdevar.librepods.presentation.screens.HeadTrackingScreen +import me.kavishdevar.librepods.presentation.screens.HearingAidAdjustmentsScreen +import me.kavishdevar.librepods.presentation.screens.HearingAidScreen +import me.kavishdevar.librepods.presentation.screens.HearingProtectionScreen +import me.kavishdevar.librepods.presentation.screens.LongPress +import me.kavishdevar.librepods.presentation.screens.OpenSourceLicensesScreen +import me.kavishdevar.librepods.presentation.screens.PurchaseScreen +import me.kavishdevar.librepods.presentation.screens.RenameScreen +import me.kavishdevar.librepods.presentation.screens.TransparencySettingsScreen +import me.kavishdevar.librepods.presentation.screens.UpdateHearingTestScreen +import me.kavishdevar.librepods.presentation.screens.VersionScreen +import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel +import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel +import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel import me.kavishdevar.librepods.services.AirPodsService -import me.kavishdevar.librepods.ui.theme.LibrePodsTheme import me.kavishdevar.librepods.utils.isSupported -import me.kavishdevar.librepods.viewmodel.AirPodsViewModel -import me.kavishdevar.librepods.viewmodel.AppSettingsViewModel import kotlin.io.encoding.ExperimentalEncodingApi lateinit var serviceConnection: ServiceConnection lateinit var connectionStatusReceiver: BroadcastReceiver -@AndroidEntryPoint +//@AndroidEntryPoint @ExperimentalMaterial3Api class MainActivity : ComponentActivity() { companion object { @@ -161,7 +173,7 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() setContent { - LibrePodsTheme { + _root_ide_package_.me.kavishdevar.librepods.presentation.theme.LibrePodsTheme { Main() } } @@ -203,28 +215,153 @@ class MainActivity : ComponentActivity() { @ExperimentalHazeMaterialsApi @SuppressLint("MissingPermission", "InlinedApi", "UnspecifiedRegisterReceiverFlag") -@OptIn(ExperimentalPermissionsApi::class) +@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class) @Composable fun Main() { - if (!isSupported()) { + val context = LocalContext.current + val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE) + if (!isSupported(sharedPreferences)) { + val showDialog = remember { mutableStateOf(false) } + val blockTouches = remember { mutableStateOf(false) } + val tapCount = remember { mutableIntStateOf(0) } + val lastTapTime = remember { mutableLongStateOf(0L) } + + val hazeState = rememberHazeState() + + LaunchedEffect(blockTouches) { + if (blockTouches.value) { + delay(500) + blockTouches.value = false + } + } Box( modifier = Modifier .fillMaxSize() + .hazeSource(hazeState) .background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)), contentAlignment = Alignment.Center ) { - Text( - text = "Not supported. Device Info: BUILD_ID: ${Build.ID} SDK_INT_FULL: ${Build.VERSION.SDK_INT_FULL}, MANUFACTURER: ${Build.MANUFACTURER}.\nCheck out the repository for more info.", - color = if (isSystemInDarkTheme()) Color.White else Color.Black, - textAlign = TextAlign.Center, - modifier = Modifier.padding(16.dp) + Box ( + modifier = Modifier + .fillMaxSize() + .then( + if (blockTouches.value) + { + Modifier.pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + event.changes.forEach { it.consume() } + } + } + } + } + else Modifier + ) ) + Column ( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Not supported", + style = TextStyle( + fontFamily = FontFamily(Font(R.font.sf_pro)), + fontWeight = FontWeight.SemiBold, + color = if (isSystemInDarkTheme()) Color.White else Color.Black, + fontSize = 20.sp + ), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + Row ( + modifier = Modifier.fillMaxWidth().pointerInput(Unit) { + detectTapGestures( + onTap = { + val now = System.currentTimeMillis() + + if (now - lastTapTime.longValue > 400) { + tapCount.intValue = 0 + } + + tapCount.intValue++ + lastTapTime.longValue = now + + if (tapCount.intValue >= 7) { + showDialog.value = true + blockTouches.value = true + } + } + ) + }, + horizontalArrangement = Arrangement.Center + ) { + Text( + text = "Device Info:", + style = TextStyle( + fontFamily = FontFamily(Font(R.font.sf_pro)), + fontWeight = FontWeight.Medium, + color = if (isSystemInDarkTheme()) Color.White else Color.Black, + fontSize = 16.sp + ), + textAlign = TextAlign.End, + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = + "MANUFACTURER=${Build.MANUFACTURER}\n" + + "MODEL=${Build.MODEL}\n" + + "BUILD_ID=${Build.ID}\n" + + "SDK_INT_FULL= ${Build.VERSION.SDK_INT_FULL}\n", + style = TextStyle( + fontFamily = FontFamily(Font(R.font.hack)), + fontWeight = FontWeight.Medium, + color = if (isSystemInDarkTheme()) Color.White else Color.Black, + fontSize = 16.sp + ), + textAlign = TextAlign.Start, + ) + } + Text( + text = "Check the repository for more info.", + style = TextStyle( + fontFamily = FontFamily(Font(R.font.sf_pro)), + fontWeight = FontWeight.Medium, + color = if (isSystemInDarkTheme()) Color.White else Color.Black, + fontSize = 18.sp + ), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } } + + ConfirmationDialog( + showDialog = showDialog, + title = "Confirm device check bypass?", + message = "Are you sure your device is supported with LibrePods?", + confirmText = "Yes", + dismissText = "No", + onConfirm = { + showDialog.value = false + sharedPreferences.edit { + tapCount.intValue = 0 + putBoolean("bypass_device_check", true) + val intent = Intent(context, MainActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + context.startActivity(intent) + } + }, + onDismiss = { + showDialog.value = false + }, + hazeState = hazeState + ) + return } val isConnected = remember { mutableStateOf(false) } - val context = LocalContext.current + var canDrawOverlays by remember { mutableStateOf(Settings.canDrawOverlays(context)) } val overlaySkipped = remember { mutableStateOf( @@ -263,7 +400,7 @@ fun Main() { val airPodsService = remember { mutableStateOf(null) } - val viewModel = remember(airPodsService.value) { + val airPodsViewModel = remember(airPodsService.value) { airPodsService.value?.let { service -> AirPodsViewModel( service = service, @@ -317,19 +454,20 @@ fun Main() { ) }) { composable("settings") { - if (viewModel != null) AirPodsSettingsScreen(viewModel, navController) + if (airPodsViewModel != null) AirPodsSettingsScreen(airPodsViewModel, navController) } composable("debug") { DebugScreen(navController = navController) } composable("long_press/{bud}") { navBackStackEntry -> - if (viewModel != null) LongPress( - viewModel = viewModel, - name = navBackStackEntry.arguments?.getString("bud")!! + if (airPodsViewModel != null) LongPress( + viewModel = airPodsViewModel, + name = navBackStackEntry.arguments?.getString("bud")!!, + navController = navController ) } composable("rename") { - if (viewModel != null) RenameScreen(viewModel) + if (airPodsViewModel != null) RenameScreen(airPodsViewModel) } composable("app_settings") { val appSettingsViewModel: AppSettingsViewModel = viewModel() @@ -339,37 +477,41 @@ fun Main() { // TroubleshootingScreen(navController) // } composable("head_tracking") { - if (viewModel != null) HeadTrackingScreen(viewModel) + if (airPodsViewModel != null) HeadTrackingScreen(airPodsViewModel, navController) } composable("accessibility") { - if (viewModel != null) AccessibilitySettingsScreen(viewModel, navController) + if (airPodsViewModel != null) AccessibilitySettingsScreen(airPodsViewModel, navController) } composable("transparency_customization") { - if (viewModel != null) TransparencySettingsScreen(viewModel) + if (airPodsViewModel != null) TransparencySettingsScreen(airPodsViewModel) } composable("hearing_aid") { - if (viewModel != null) HearingAidScreen(viewModel, navController) + if (airPodsViewModel != null) HearingAidScreen(airPodsViewModel, navController) } composable("hearing_aid_adjustments") { - if (viewModel != null) HearingAidAdjustmentsScreen(viewModel) + if (airPodsViewModel != null) HearingAidAdjustmentsScreen(airPodsViewModel) } composable("adaptive_strength") { - if (viewModel != null) AdaptiveStrengthScreen(viewModel) + if (airPodsViewModel != null) AdaptiveStrengthScreen(airPodsViewModel, navController) } composable("camera_control") { - if (viewModel != null) CameraControlScreen(viewModel) + if (airPodsViewModel != null) CameraControlScreen(airPodsViewModel) } composable("open_source_licenses") { OpenSourceLicensesScreen(navController) } composable("update_hearing_test") { - if (viewModel != null) UpdateHearingTestScreen() + if (airPodsViewModel != null) UpdateHearingTestScreen() } composable("version_info") { - if (viewModel != null) VersionScreen(viewModel) + if (airPodsViewModel != null) VersionScreen(airPodsViewModel) } composable("hearing_protection") { - if (viewModel != null) HearingProtectionScreen(viewModel) + if (airPodsViewModel != null) HearingProtectionScreen(airPodsViewModel) + } + composable("purchase_screen") { + val purchaseViewModel: PurchaseViewModel = viewModel() + PurchaseScreen(purchaseViewModel, navController) } } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/QuickSettingsDialogActivity.kt b/android/app/src/main/java/me/kavishdevar/librepods/QuickSettingsDialogActivity.kt index 85a9573..2aa9158 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/QuickSettingsDialogActivity.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/QuickSettingsDialogActivity.kt @@ -85,15 +85,15 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kotlinx.coroutines.launch -import me.kavishdevar.librepods.composables.AdaptiveRainbowBrush -import me.kavishdevar.librepods.composables.ControlCenterNoiseControlSegmentedButton -import me.kavishdevar.librepods.composables.IconAreaSize -import me.kavishdevar.librepods.composables.VerticalVolumeSlider -import me.kavishdevar.librepods.constants.AirPodsNotifications -import me.kavishdevar.librepods.constants.NoiseControlMode +import me.kavishdevar.librepods.presentation.components.AdaptiveRainbowBrush +import me.kavishdevar.librepods.presentation.components.ControlCenterNoiseControlSegmentedButton +import me.kavishdevar.librepods.presentation.components.IconAreaSize +import me.kavishdevar.librepods.presentation.components.VerticalVolumeSlider +import me.kavishdevar.librepods.data.AirPodsNotifications +import me.kavishdevar.librepods.data.NoiseControlMode import me.kavishdevar.librepods.services.AirPodsService -import me.kavishdevar.librepods.ui.theme.LibrePodsTheme -import me.kavishdevar.librepods.utils.AACPManager +import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme +import me.kavishdevar.librepods.bluetooth.AACPManager import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.math.abs 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 027ad94..66c97bf 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 @@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.StateFlow interface BillingProvider { val isPremium: StateFlow - + val price: StateFlow fun purchase(activity: Activity) + fun queryPurchases() } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/billing/BillingProviderFactory.kt b/android/app/src/main/java/me/kavishdevar/librepods/billing/BillingProviderFactory.kt index 2b83717..33f9b41 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/billing/BillingProviderFactory.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/billing/BillingProviderFactory.kt @@ -27,7 +27,7 @@ object BillingProviderFactory { return if (BuildConfig.PLAY_BUILD) { PlayBillingProvider(context) } else { - FOSSBillingProvider() + FOSSBillingProvider(context) } } } 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 9b06964..4aef636 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 @@ -19,12 +19,56 @@ package me.kavishdevar.librepods.billing import android.app.Activity +import android.content.Context +import android.content.Intent +import androidx.core.content.edit +import androidx.core.net.toUri +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext -class FOSSBillingProvider : BillingProvider { - private val _isPremium = MutableStateFlow(true) +class FOSSBillingProvider(context: Context): BillingProvider { + private val _isPremium = MutableStateFlow(false) override val isPremium: StateFlow = _isPremium - override fun purchase(activity: Activity) { } + private val _price = MutableStateFlow("Any") + override val price: StateFlow = _price + + private val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE) + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var purchaseJob: Job? = null + + init { + queryPurchases() + } + + override fun purchase(activity: Activity) { + activity.startActivity( + Intent(Intent.ACTION_VIEW, "https://github.com/sponsors/kavishdevar".toUri()) + ) + + purchaseJob?.cancel() + + purchaseJob = scope.launch { + delay(2_000) + withContext(Dispatchers.Main) { + _isPremium.value = true + sharedPreferences.edit { putBoolean("foss_upgraded", true) } + } + } + } + + override fun queryPurchases() { + val stored = sharedPreferences.getBoolean("foss_upgraded", false) + if (stored != _isPremium.value) { + _isPremium.value = stored + } + } } 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 790be60..bc6d35d 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 @@ -55,6 +55,10 @@ class PlayBillingProvider( private val _isPremium = MutableStateFlow(false) override val isPremium: StateFlow = _isPremium + private val _price = MutableStateFlow("unknown") + override val price: StateFlow = _price + + private var productDetails: ProductDetails? = null private val billingClient = BillingClient.newBuilder(context) @@ -102,6 +106,13 @@ class PlayBillingProvider( if (result.billingResult.responseCode == BillingClient.BillingResponseCode.OK) { productDetails = result.productDetailsList?.firstOrNull() Log.d(TAG, "Product loaded: ${productDetails?.name}") + val priceString = productDetails + ?.oneTimePurchaseOfferDetails + ?.formattedPrice + + if (priceString != null) { + _price.value = priceString + } } else { Log.w(TAG, "queryProductDetails failed: ${result.billingResult.debugMessage}") } @@ -152,13 +163,13 @@ class PlayBillingProvider( } -// val purchase = purchases.find { +// val navigateToPurchase = purchases.find { // it.products.contains(PREMIUM_PRODUCT_ID) && it.purchaseState == Purchase.PurchaseState.PURCHASED // } // -// if (purchase != null) { +// if (navigateToPurchase != null) { // val consumeParams = ConsumeParams.newBuilder() -// .setPurchaseToken(purchase.purchaseToken) +// .setPurchaseToken(navigateToPurchase.purchaseToken) // .build() // scope.launch { // billingClient.consumeAsync(consumeParams) { _, _ ->} @@ -184,4 +195,10 @@ class PlayBillingProvider( Log.e(TAG, "Acknowledgement failed: ${result.debugMessage}") } } + + override fun queryPurchases() { + scope.launch { + queryExistingPurchases() + } + } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt similarity index 85% rename from android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt rename to android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt index 6149666..5edee79 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt @@ -18,7 +18,7 @@ @file:OptIn(ExperimentalEncodingApi::class) -package me.kavishdevar.librepods.utils +package me.kavishdevar.librepods.bluetooth import android.util.Log import java.nio.ByteBuffer @@ -61,8 +61,7 @@ class AACPManager { private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00) data class ControlCommandStatus( - val identifier: ControlCommandIdentifiers, - val value: ByteArray + val identifier: ControlCommandIdentifiers, val value: ByteArray ) { override fun equals(other: Any?): Boolean { if (this === other) return true @@ -85,42 +84,31 @@ class AACPManager { // @Suppress("unused") enum class ControlCommandIdentifiers(val value: Byte) { - MIC_MODE(0x01), - BUTTON_SEND_MODE(0x05), - VOICE_TRIGGER(0x12), - SINGLE_CLICK_MODE(0x14), - DOUBLE_CLICK_MODE(0x15), - CLICK_HOLD_MODE(0x16), - DOUBLE_CLICK_INTERVAL(0x17), - CLICK_HOLD_INTERVAL(0x18), - LISTENING_MODE_CONFIGS(0x1A), - ONE_BUD_ANC_MODE(0x1B), - CROWN_ROTATION_DIRECTION(0x1C), - LISTENING_MODE(0x0D), - AUTO_ANSWER_MODE(0x1E), - CHIME_VOLUME(0x1F), - VOLUME_SWIPE_INTERVAL(0x23), - CALL_MANAGEMENT_CONFIG(0x24), - VOLUME_SWIPE_MODE(0x25), - ADAPTIVE_VOLUME_CONFIG(0x26), - SOFTWARE_MUTE_CONFIG(0x27), - CONVERSATION_DETECT_CONFIG(0x28), - SSL(0x29), - HEARING_AID(0x2C), - AUTO_ANC_STRENGTH(0x2E), - HPS_GAIN_SWIPE(0x2F), - HRM_STATE(0x30), - IN_CASE_TONE_CONFIG(0x31), - SIRI_MULTITONE_CONFIG(0x32), - HEARING_ASSIST_CONFIG(0x33), - ALLOW_OFF_OPTION(0x34), - STEM_CONFIG(0x39), - SLEEP_DETECTION_CONFIG(0x35), - ALLOW_AUTO_CONNECT(0x36), // not sure what this does, AUTOMATIC_CONNECTION is the only one used, but this is newer... so ¯\_(ツ)_/¯ - EAR_DETECTION_CONFIG(0x0A), - AUTOMATIC_CONNECTION_CONFIG(0x20), - OWNS_CONNECTION(0x06), - PPE_TOGGLE_CONFIG(0x37), + MIC_MODE(0x01), BUTTON_SEND_MODE(0x05), VOICE_TRIGGER(0x12), SINGLE_CLICK_MODE(0x14), DOUBLE_CLICK_MODE( + 0x15 + ), + CLICK_HOLD_MODE(0x16), DOUBLE_CLICK_INTERVAL(0x17), CLICK_HOLD_INTERVAL(0x18), LISTENING_MODE_CONFIGS( + 0x1A + ), + ONE_BUD_ANC_MODE(0x1B), CROWN_ROTATION_DIRECTION(0x1C), LISTENING_MODE(0x0D), AUTO_ANSWER_MODE( + 0x1E + ), + CHIME_VOLUME(0x1F), VOLUME_SWIPE_INTERVAL(0x23), CALL_MANAGEMENT_CONFIG(0x24), VOLUME_SWIPE_MODE( + 0x25 + ), + ADAPTIVE_VOLUME_CONFIG(0x26), SOFTWARE_MUTE_CONFIG(0x27), CONVERSATION_DETECT_CONFIG( + 0x28 + ), + SSL(0x29), HEARING_AID(0x2C), AUTO_ANC_STRENGTH(0x2E), HPS_GAIN_SWIPE(0x2F), HRM_STATE( + 0x30 + ), + IN_CASE_TONE_CONFIG(0x31), SIRI_MULTITONE_CONFIG(0x32), HEARING_ASSIST_CONFIG(0x33), ALLOW_OFF_OPTION( + 0x34 + ), + STEM_CONFIG(0x39), SLEEP_DETECTION_CONFIG(0x35), ALLOW_AUTO_CONNECT(0x36), // not sure what this does, AUTOMATIC_CONNECTION is the only one used, but this is newer... so ¯\_(ツ)_/¯ + EAR_DETECTION_CONFIG(0x0A), AUTOMATIC_CONNECTION_CONFIG(0x20), OWNS_CONNECTION(0x06), PPE_TOGGLE_CONFIG( + 0x37 + ), PPE_CAP_LEVEL_CONFIG(0x38); companion object { @@ -130,59 +118,44 @@ class AACPManager { } enum class ProximityKeyType(val value: Byte) { - IRK(0x01), - ENC_KEY(0x04); + IRK(0x01), ENC_KEY(0x04); companion object { - fun fromByte(byte: Byte): ProximityKeyType = - ProximityKeyType.entries.find { it.value == byte } - ?: throw IllegalArgumentException("Unknown ProximityKeyType: $byte") + fun fromByte(byte: Byte): ProximityKeyType = entries.find { it.value == byte } + ?: throw IllegalArgumentException("Unknown ProximityKeyType: $byte") } } enum class StemPressType(val value: Byte) { - SINGLE_PRESS(0x05), - DOUBLE_PRESS(0x06), - TRIPLE_PRESS(0x07), - LONG_PRESS(0x08); + SINGLE_PRESS(0x05), DOUBLE_PRESS(0x06), TRIPLE_PRESS(0x07), LONG_PRESS(0x08); companion object { - fun fromByte(byte: Byte): StemPressType? = - entries.find { it.value == byte } + fun fromByte(byte: Byte): StemPressType? = entries.find { it.value == byte } } } enum class StemPressBudType(val value: Byte) { - LEFT(0x01), - RIGHT(0x02); + LEFT(0x01), RIGHT(0x02); companion object { - fun fromByte(byte: Byte): StemPressBudType? = - entries.find { it.value == byte } + fun fromByte(byte: Byte): StemPressBudType? = entries.find { it.value == byte } } } enum class AudioSourceType(val value: Byte) { - NONE(0x00), - CALL(0x01), - MEDIA(0x02); + NONE(0x00), CALL(0x01), MEDIA(0x02); companion object { - fun fromByte(byte: Byte): AudioSourceType? = - entries.find { it.value == byte } + fun fromByte(byte: Byte): AudioSourceType? = entries.find { it.value == byte } } } data class AudioSource( - val mac: String, - val type: AudioSourceType + val mac: String, val type: AudioSourceType ) data class ConnectedDevice( - val mac: String, - val info1: Byte, - val info2: Byte, - var type: String? + val mac: String, val info1: Byte, val info2: Byte, var type: String? ) data class AirPodsInformation( @@ -231,8 +204,7 @@ class AACPManager { } private fun setControlCommandStatusValue( - identifier: ControlCommandIdentifiers, - value: ByteArray + identifier: ControlCommandIdentifiers, value: ByteArray ) { val existingStatus = getControlCommandStatus(identifier) if (existingStatus == value) { @@ -289,15 +261,13 @@ class AACPManager { } fun registerControlCommandListener( - identifier: ControlCommandIdentifiers, - callback: ControlCommandListener + identifier: ControlCommandIdentifiers, callback: ControlCommandListener ) { controlCommandListeners.getOrPut(identifier) { mutableListOf() }.add(callback) } fun unregisterControlCommandListener( - identifier: ControlCommandIdentifiers, - callback: ControlCommandListener + identifier: ControlCommandIdentifiers, callback: ControlCommandListener ) { controlCommandListeners[identifier]?.remove(callback) } @@ -332,8 +302,7 @@ class AACPManager { fun sendControlCommand(identifier: Byte, value: ByteArray): Boolean { val controlPacket = createControlCommandPacket(identifier, value) setControlCommandStatusValue( - ControlCommandIdentifiers.fromByte(identifier) ?: return false, - value + ControlCommandIdentifiers.fromByte(identifier) ?: return false, value ) return sendDataPacket(controlPacket) } @@ -342,16 +311,14 @@ class AACPManager { fun sendControlCommand(identifier: Byte, value: Byte): Boolean { val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value)) setControlCommandStatusValue( - ControlCommandIdentifiers.fromByte(identifier) ?: return false, - byteArrayOf(value) + ControlCommandIdentifiers.fromByte(identifier) ?: return false, byteArrayOf(value) ) return sendDataPacket(controlPacket) } fun sendControlCommand(identifier: Byte, value: Boolean): Boolean { val controlPacket = createControlCommandPacket( - identifier, - if (value) byteArrayOf(0x01) else byteArrayOf(0x02) + identifier, if (value) byteArrayOf(0x01) else byteArrayOf(0x02) ) setControlCommandStatusValue( ControlCommandIdentifiers.fromByte(identifier) ?: return false, @@ -371,8 +338,7 @@ class AACPManager { fun parseProximityKeysResponse(data: ByteArray): Map { Log.d( - TAG, - "Parsing Proximity Keys Response: ${data.joinToString(" ") { "%02X".format(it) }}" + TAG, "Parsing Proximity Keys Response: ${data.joinToString(" ") { "%02X".format(it) }}" ) if (data.size < 4) { throw IllegalArgumentException("Data array too short to parse Proximity Keys Response") @@ -400,11 +366,9 @@ class AACPManager { keys[ProximityKeyType.fromByte(keyType)] = key offset += keyLength Log.d( - TAG, - "Parsed Proximity Key: Type: ${keyType}, Length: $keyLength, Key: ${ - key.joinToString(" ") { "%02X".format(it) } - }" - ) + TAG, "Parsed Proximity Key: Type: ${keyType}, Length: $keyLength, Key: ${ + key.joinToString(" ") { "%02X".format(it) } + }") } return keys } @@ -424,26 +388,21 @@ class AACPManager { fun receivePacket(packet: ByteArray) { if (!packet.toHexString().startsWith("04000400")) { Log.w( - TAG, - "Received packet does not start with expected header: ${ - packet.joinToString(" ") { - "%02X".format(it) - } - }" - ) + TAG, "Received packet does not start with expected header: ${ + packet.joinToString(" ") { + "%02X".format(it) + } + }") return } if (packet.size < 6) { Log.w( - TAG, - "Received packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}" + TAG, "Received packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}" ) return } - val opcode = packet[4] - - when (opcode) { + when (val opcode = packet[4]) { Opcodes.BATTERY_INFO -> { callback?.onBatteryInfoReceived(packet) } @@ -458,23 +417,23 @@ class AACPManager { TAG, "Control command received: ${controlCommand.identifier.toHexString()} - ${ controlCommand.value.joinToString(" ") { "%02X".format(it) } - }" - ) + }") val controlCommandListText = try { - controlCommandStatusList.joinToString(", ") { it -> - "${it.identifier.name} (${it.identifier.value.toHexString()}) - ${ - it.value.joinToString( - " " - ) { "%02X".format(it) } - }" - } - } catch (e: Exception) { - e.message + controlCommandStatusList.joinToString(", ") { it -> + "${it.identifier.name} (${it.identifier.value.toHexString()}) - ${ + it.value.joinToString( + " " + ) { "%02X".format(it) } + }" } + } catch (e: Exception) { + e.message + } Log.d( - TAG, "Control command list is now: $controlCommandListText") + TAG, "Control command list is now: $controlCommandListText" + ) val controlCommandIdentifier = ControlCommandIdentifiers.fromByte(controlCommand.identifier) @@ -508,13 +467,11 @@ class AACPManager { Opcodes.HEADTRACKING -> { if (packet.size < 70) { Log.w( - TAG, - "Received HEADTRACKING packet too short: ${ - packet.joinToString(" ") { - "%02X".format(it) - } - }" - ) + TAG, "Received HEADTRACKING packet too short: ${ + packet.joinToString(" ") { + "%02X".format(it) + } + }") return } callback?.onHeadTrackingReceived(packet) @@ -546,7 +503,8 @@ class AACPManager { Opcodes.SMART_ROUTING_RESP -> { val packetString = packet.decodeToString() - val sender = packet.sliceArray(6..11).reversedArray().joinToString(":") { "%02X".format(it) } + val sender = + packet.sliceArray(6..11).reversedArray().joinToString(":") { "%02X".format(it) } // if (connectedDevices.find { it.mac == sender }?.type == null && packetString.contains("btName")) { // val nameStartIndex = packetString.indexOf("btName") + 8 @@ -566,9 +524,15 @@ class AACPManager { } else if ("Android" in packetString) { connectedDevices.find { it.mac == sender }?.type = "Android" } - Log.d(TAG, "Smart Routing Response from $sender: $packetString, type: ${connectedDevices.find { it.mac == sender }?.type}") + Log.d( + TAG, + "Smart Routing Response from $sender: $packetString, type: ${connectedDevices.find { it.mac == sender }?.type}" + ) if (packetString.contains("SetOwnershipToFalse")) { - callback?.onOwnershipToFalseRequest(sender, packetString.contains("ReverseBannerTapped")) + callback?.onOwnershipToFalseRequest( + sender, + packetString.contains("ReverseBannerTapped") + ) } if (packetString.contains("ShowNearbyUI")) { callback?.onShowNearbyUI(sender) @@ -595,15 +559,19 @@ class AACPManager { eqOnPhone = (packet[11] == 0x01.toByte()) // there are 4 eqs. i am not sure what those are for, maybe all 4 listening modes, or maybe phone+media left+right, but then there shouldn't be another flag for phone/media visible. just directly the EQ... weird. // the EQs are little endian floats - val eq1 = ByteBuffer.wrap(packet, 12, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer() - val eq2 = ByteBuffer.wrap(packet, 44, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer() - val eq3 = ByteBuffer.wrap(packet, 76, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer() - val eq4 = ByteBuffer.wrap(packet, 108, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer() + val eq1 = + ByteBuffer.wrap(packet, 12, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer() + ByteBuffer.wrap(packet, 44, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer() + ByteBuffer.wrap(packet, 76, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer() + ByteBuffer.wrap(packet, 108, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer() // for now, taking just the first EQ eqData = FloatArray(8) { i -> eq1.get(i) } - Log.d(TAG, "EQ Data set to: ${eqData.toList()}, eqOnPhone: $eqOnPhone, eqOnMedia: $eqOnMedia") + Log.d( + TAG, + "EQ Data set to: ${eqData.toList()}, eqOnPhone: $eqOnPhone, eqOnMedia: $eqOnMedia" + ) callback?.onEQPacketReceived(eqData) } @@ -613,8 +581,9 @@ class AACPManager { val information = parseInformationPacket(packet) callback?.onDeviceInformationReceived(information) } + else -> { - Log.d(TAG, "Unknown opcode received: ${opcode.toHexString()}") + Log.d(TAG, "Unhandled opcode received: ${opcode.toHexString()}") callback?.onUnknownPacketReceived(packet) } } @@ -644,10 +613,22 @@ class AACPManager { fun createHandshakePacket(): ByteArray { return byteArrayOf( - 0x00, 0x00, 0x04, 0x00, - 0x01, 0x00, 0x02, 0x00, - 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00 + 0x00, + 0x00, + 0x04, + 0x00, + 0x01, + 0x00, + 0x02, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00 ) } @@ -793,17 +774,31 @@ class AACPManager { } fun sendMediaInformationNewDevice(selfMacAddress: String, targetMacAddress: String): Boolean { - if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")) || targetMacAddress.length != 17 || !targetMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) { + if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")) || targetMacAddress.length != 17 || !targetMacAddress.matches( + Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}") + ) + ) { // throw IllegalArgumentException("MAC address must be 6 bytes") - Log.w(TAG, "Invalid MAC address format, got: selfMacAddress=$selfMacAddress, targetMacAddress=$targetMacAddress") + Log.w( + TAG, + "Invalid MAC address format, got: selfMacAddress=$selfMacAddress, targetMacAddress=$targetMacAddress" + ) return false } Log.d(TAG, "SELFMAC: ${selfMacAddress}, TARGETMAC: $targetMacAddress") Log.d(TAG, "Sending Media Information packet to $targetMacAddress") - return sendDataPacket(createMediaInformationNewDevicePacket(selfMacAddress, targetMacAddress)) + return sendDataPacket( + createMediaInformationNewDevicePacket( + selfMacAddress, + targetMacAddress + ) + ) } - fun createMediaInformationNewDevicePacket(selfMacAddress: String, targetMacAddress: String): ByteArray { + fun createMediaInformationNewDevicePacket( + selfMacAddress: String, + targetMacAddress: String + ): ByteArray { val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00) val buffer = ByteBuffer.allocate(116) buffer.put( @@ -892,17 +887,13 @@ class AACPManager { Log.d(TAG, "Sending Media Information packet to $targetMac") return sendDataPacket( createMediaInformationPacket( - selfMacAddress, - targetMac, - streamingState + selfMacAddress, targetMac, streamingState ) ) } fun createMediaInformationPacket( - selfMacAddress: String, - targetMacAddress: String, - streamingState: Boolean = true + selfMacAddress: String, targetMacAddress: String, streamingState: Boolean = true ): ByteArray { val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00) val buffer = ByteBuffer.allocate(138) @@ -935,7 +926,7 @@ class AACPManager { buffer.put("AudioCategory".toByteArray()) buffer.put(byteArrayOf(0x31, 0x2D, 0x01)) - return opcode+buffer.array() + return opcode + buffer.array() } fun sendSmartRoutingShowUI(selfMacAddress: String): Boolean { @@ -1017,9 +1008,15 @@ class AACPManager { fun sendAddTiPiDevice(selfMacAddress: String, targetMacAddress: String): Boolean { - if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")) || targetMacAddress.length != 17 || !targetMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) { + if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")) || targetMacAddress.length != 17 || !targetMacAddress.matches( + Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}") + ) + ) { // throw IllegalArgumentException("MAC address must be 6 bytes") - Log.w(TAG, "Invalid MAC address format, got: selfMacAddress=$selfMacAddress, targetMacAddress=$targetMacAddress") + Log.w( + TAG, + "Invalid MAC address format, got: selfMacAddress=$selfMacAddress, targetMacAddress=$targetMacAddress" + ) return false } Log.d(TAG, "Sending Add TiPi Device packet to $targetMacAddress") @@ -1053,8 +1050,7 @@ class AACPManager { } data class ControlCommand( - val identifier: Byte, - val value: ByteArray + val identifier: Byte, val value: ByteArray ) { override fun equals(other: Any?): Boolean { if (this === other) return true @@ -1106,10 +1102,8 @@ class AACPManager { triplePressCustomized: Boolean = false, longPressCustomized: Boolean = false ): Boolean { - val value = ((if (singlePressCustomized) 0x01 else 0) or - (if (doublePressCustomized) 0x02 else 0) or - (if (triplePressCustomized) 0x04 else 0) or - (if (longPressCustomized) 0x08 else 0)).toByte() + val value = + ((if (singlePressCustomized) 0x01 else 0) or (if (doublePressCustomized) 0x02 else 0) or (if (triplePressCustomized) 0x04 else 0) or (if (longPressCustomized) 0x08 else 0)).toByte() Log.d(TAG, "Sending Stem Config Packet with value: ${value.toHexString()}") return sendControlCommand( ControlCommandIdentifiers.STEM_CONFIG.value, value @@ -1124,19 +1118,18 @@ class AACPManager { if (packet[4] == Opcodes.CONTROL_COMMAND) { val controlCommand = ControlCommand.fromByteArray(packet) Log.d( - TAG, - "Control command: ${controlCommand.identifier.toHexString()} - ${ - controlCommand.value.joinToString(" ") { "%02X".format(it) } - }" - ) + TAG, "Control command: ${controlCommand.identifier.toHexString()} - ${ + controlCommand.value.joinToString(" ") { "%02X".format(it) } + }") setControlCommandStatusValue( ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return false, controlCommand.value ) } - val socket = BluetoothConnectionManager.getCurrentSocket() - if (socket?.isConnected == true) { + val socket = BluetoothConnectionManager.getCurrentSocket() ?: return false + + if (socket.isConnected) { socket.outputStream?.write(packet) socket.outputStream?.flush() return true @@ -1213,7 +1206,10 @@ class AACPManager { var offset = 9 for (i in 0 until deviceCount) { if (offset + 8 > data.size) { - Log.w(TAG, "Data array too short to parse all connected devices, returning what we have") + Log.w( + TAG, + "Data array too short to parse all connected devices, returning what we have" + ) break } val macBytes = data.sliceArray(offset until offset + 6) @@ -1227,6 +1223,7 @@ class AACPManager { return devices } + fun sendSomePacketIDontKnowWhatItIs() { // 2900 00ff ffff ffff ffff -- enables setting EQ sendDataPacket( @@ -1264,7 +1261,7 @@ class AACPManager { val start = index // find next 0x00 byte while (index < data.size && data[index] != 0x00.toByte()) index++ - val str = data.sliceArray(start until index).decodeToString() + val str = data.sliceArray(start..index).decodeToString() strings.add(str) } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/ATTManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/ATTManager.kt similarity index 97% rename from android/app/src/main/java/me/kavishdevar/librepods/utils/ATTManager.kt rename to android/app/src/main/java/me/kavishdevar/librepods/bluetooth/ATTManager.kt index c39a113..41c1483 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/ATTManager.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/ATTManager.kt @@ -21,7 +21,7 @@ * and receiving notifications. It is not a complete implementation of the ATT protocol. */ -package me.kavishdevar.librepods.utils +package me.kavishdevar.librepods.bluetooth import android.annotation.SuppressLint import android.bluetooth.BluetoothAdapter @@ -31,6 +31,7 @@ import android.os.ParcelUuid import android.util.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import java.io.InputStream import java.io.OutputStream @@ -62,7 +63,7 @@ class ATTManager(private val adapter: BluetoothAdapter, private val device: Blue private var input: InputStream? = null private var output: OutputStream? = null private val listeners = mutableMapOf Unit>>() - private var notificationJob: kotlinx.coroutines.Job? = null + private var notificationJob: Job? = null // queue for non-notification PDUs (responses to requests) private val responses = LinkedBlockingQueue() @@ -72,7 +73,12 @@ class ATTManager(private val adapter: BluetoothAdapter, private val device: Blue val uuid = ParcelUuid.fromString("00000000-0000-0000-0000-000000000000") socket = createBluetoothSocket(adapter, device, uuid) - socket!!.connect() + try { + socket!!.connect() + } catch (e: Exception) { + Log.w(TAG, "ATT socket failed to connect") + return + } input = socket!!.inputStream output = socket!!.outputStream Log.d(TAG, "Connected to ATT") diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/BLEManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BLEManager.kt similarity index 99% rename from android/app/src/main/java/me/kavishdevar/librepods/utils/BLEManager.kt rename to android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BLEManager.kt index 73600ee..52fa055 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/BLEManager.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BLEManager.kt @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.utils +package me.kavishdevar.librepods.bluetooth import android.annotation.SuppressLint import android.bluetooth.BluetoothManager @@ -30,8 +30,10 @@ import android.content.SharedPreferences import android.os.Handler import android.os.Looper import android.util.Log +import me.kavishdevar.librepods.utils.BluetoothCryptography import javax.crypto.Cipher import javax.crypto.spec.SecretKeySpec +import kotlin.collections.iterator import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/BluetoothConnectionManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BluetoothConnectionManager.kt similarity index 96% rename from android/app/src/main/java/me/kavishdevar/librepods/utils/BluetoothConnectionManager.kt rename to android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BluetoothConnectionManager.kt index 249cd2d..d98050b 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/BluetoothConnectionManager.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BluetoothConnectionManager.kt @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.utils +package me.kavishdevar.librepods.bluetooth import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothSocket diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/AirPods.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/AirPods.kt similarity index 98% rename from android/app/src/main/java/me/kavishdevar/librepods/utils/AirPods.kt rename to android/app/src/main/java/me/kavishdevar/librepods/data/AirPods.kt index 5dd7719..9d83f05 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/AirPods.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/data/AirPods.kt @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.utils +package me.kavishdevar.librepods.data import me.kavishdevar.librepods.R @@ -256,8 +256,6 @@ data class AirPodsInstance( val version1: String?, val version2: String?, val version3: String?, - val aacpManager: AACPManager, - val attManager: ATTManager? ) object AirPodsModels { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/ControlCommandRepository.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/ControlCommandRepository.kt index 163c204..9097a2e 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/data/ControlCommandRepository.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/data/ControlCommandRepository.kt @@ -18,14 +18,14 @@ package me.kavishdevar.librepods.data -import me.kavishdevar.librepods.utils.AACPManager -import me.kavishdevar.librepods.utils.AACPManager.Companion.ControlCommandIdentifiers +import me.kavishdevar.librepods.bluetooth.AACPManager +import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers class ControlCommandRepository( private val aacpManager: AACPManager ) { fun getValue( - identifier: AACPManager.Companion.ControlCommandIdentifiers + identifier: ControlCommandIdentifiers ): ByteArray? { return aacpManager.controlCommandStatusList .find { it.identifier == identifier } @@ -33,7 +33,7 @@ class ControlCommandRepository( } fun setValue( - id: AACPManager.Companion.ControlCommandIdentifiers, + id: ControlCommandIdentifiers, value: ByteArray ) { aacpManager.sendControlCommand(id.value, value) @@ -41,7 +41,7 @@ class ControlCommandRepository( fun observe( - identifier: AACPManager.Companion.ControlCommandIdentifiers, + identifier: ControlCommandIdentifiers, onChange: (ByteArray) -> Unit ): AACPManager.ControlCommandListener { @@ -56,7 +56,7 @@ class ControlCommandRepository( } fun remove( - identifier: AACPManager.Companion.ControlCommandIdentifiers, + identifier: ControlCommandIdentifiers, listener: AACPManager.ControlCommandListener ) { aacpManager.unregisterControlCommandListener(identifier, listener) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/HearingAidEnums.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/HearingAid.kt similarity index 98% rename from android/app/src/main/java/me/kavishdevar/librepods/utils/HearingAidEnums.kt rename to android/app/src/main/java/me/kavishdevar/librepods/data/HearingAid.kt index 94d1820..bf2f554 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/HearingAidEnums.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/data/HearingAid.kt @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.utils +package me.kavishdevar.librepods.data import android.util.Log import androidx.compose.runtime.MutableState @@ -25,6 +25,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import me.kavishdevar.librepods.bluetooth.ATTHandles +import me.kavishdevar.librepods.bluetooth.ATTManager import java.io.IOException import java.nio.ByteBuffer import java.nio.ByteOrder diff --git a/android/app/src/main/java/me/kavishdevar/librepods/constants/Packets.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/Packets.kt similarity index 99% rename from android/app/src/main/java/me/kavishdevar/librepods/constants/Packets.kt rename to android/app/src/main/java/me/kavishdevar/librepods/data/Packets.kt index 1c179d0..a0559db 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/constants/Packets.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/data/Packets.kt @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.constants +package me.kavishdevar.librepods.data import android.os.Parcelable import android.util.Log diff --git a/android/app/src/main/java/me/kavishdevar/librepods/constants/StemAction.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/StemAction.kt similarity index 93% rename from android/app/src/main/java/me/kavishdevar/librepods/constants/StemAction.kt rename to android/app/src/main/java/me/kavishdevar/librepods/data/StemAction.kt index ddf74c0..5bd9e6c 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/constants/StemAction.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/data/StemAction.kt @@ -16,9 +16,9 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.constants +package me.kavishdevar.librepods.data -import me.kavishdevar.librepods.utils.AACPManager +import me.kavishdevar.librepods.bluetooth.AACPManager enum class StemAction { PLAY_PAUSE, diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/TransparencyUtils.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/Transparency.kt similarity index 98% rename from android/app/src/main/java/me/kavishdevar/librepods/utils/TransparencyUtils.kt rename to android/app/src/main/java/me/kavishdevar/librepods/data/Transparency.kt index 1bbd46c..29afefb 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/TransparencyUtils.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/data/Transparency.kt @@ -16,13 +16,14 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.utils +package me.kavishdevar.librepods.data import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import me.kavishdevar.librepods.bluetooth.ATTHandles import java.io.IOException import java.nio.ByteBuffer import java.nio.ByteOrder diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/XposedRemotePref.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/XposedRemotePref.kt new file mode 100644 index 0000000..1977b04 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/data/XposedRemotePref.kt @@ -0,0 +1,8 @@ +package me.kavishdevar.librepods.data + +interface XposedRemotePref { + fun isAvailable(): Boolean + + fun getBoolean(key: String, def: Boolean): Boolean + fun putBoolean(key: String, value: Boolean) +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/XposedRemotePrefProvider.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/XposedRemotePrefProvider.kt new file mode 100644 index 0000000..9f18e8c --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/data/XposedRemotePrefProvider.kt @@ -0,0 +1,5 @@ +package me.kavishdevar.librepods.data + +object XposedRemotePrefProvider { + fun create(): XposedRemotePref = XposedRemotePrefImpl() +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/AboutCard.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/AboutCard.kt similarity index 97% rename from android/app/src/main/java/me/kavishdevar/librepods/composables/AboutCard.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/components/AboutCard.kt index 416abf1..f0669ba 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/AboutCard.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/AboutCard.kt @@ -18,7 +18,7 @@ @file:OptIn(ExperimentalEncodingApi::class) -package me.kavishdevar.librepods.composables +package me.kavishdevar.librepods.presentation.components import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -75,7 +75,8 @@ fun AboutCard( style = TextStyle( fontSize = 14.sp, fontWeight = FontWeight.Bold, - color = textColor.copy(alpha = 0.6f) + color = textColor.copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) ) ) } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/AudioSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/AudioSettings.kt similarity index 93% rename from android/app/src/main/java/me/kavishdevar/librepods/composables/AudioSettings.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/components/AudioSettings.kt index e4ead08..c7836d0 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/AudioSettings.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/AudioSettings.kt @@ -18,7 +18,7 @@ @file:OptIn(ExperimentalEncodingApi::class) -package me.kavishdevar.librepods.composables +package me.kavishdevar.librepods.presentation.components import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme @@ -35,6 +35,8 @@ import androidx.compose.ui.draw.clip 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.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -61,7 +63,7 @@ fun AudioSettings( loudSoundReductionChecked: Boolean, onLoudSoundReductionCheckedChange: (Boolean) -> Unit, - isXposed: Boolean, + vendorIdHook: Boolean, isPremium: Boolean ) { val isDarkTheme = isSystemInDarkTheme() @@ -80,7 +82,8 @@ fun AudioSettings( style = TextStyle( fontSize = 14.sp, fontWeight = FontWeight.Bold, - color = textColor.copy(alpha = 0.6f) + color = textColor.copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) ) ) } @@ -130,13 +133,14 @@ fun AudioSettings( ) } - if (loudSoundReductionCapability && isXposed){ + if (loudSoundReductionCapability && vendorIdHook){ StyledToggle( label = stringResource(R.string.loud_sound_reduction), description = stringResource(R.string.loud_sound_reduction_description), independent = false, checked = loudSoundReductionChecked, - onCheckedChange = onLoudSoundReductionCheckedChange + onCheckedChange = onLoudSoundReductionCheckedChange, + enabled = isPremium ) HorizontalDivider( thickness = 1.dp, @@ -172,7 +176,7 @@ fun AudioSettingsPreview() { onConversationalAwarenessCheckedChange = { }, loudSoundReductionChecked = true, onLoudSoundReductionCheckedChange = { }, - isXposed = true, + vendorIdHook = true, isPremium = true ) } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryIndicator.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/BatteryIndicator.kt similarity index 68% rename from android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryIndicator.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/components/BatteryIndicator.kt index b34ffc4..c2bff84 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryIndicator.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/BatteryIndicator.kt @@ -16,20 +16,21 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.composables +package me.kavishdevar.librepods.presentation.components import android.content.res.Configuration import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -38,8 +39,10 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.scale +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily @@ -48,11 +51,15 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.data.BatteryStatus +import kotlin.math.cos +import kotlin.math.sin +import kotlin.math.sqrt @Composable fun BatteryIndicator( batteryPercentage: Int, - charging: Boolean = false, + status: Int, prefix: String = "", previousCharging: Boolean = false, ) { @@ -65,6 +72,7 @@ fun BatteryIndicator( val initialScale = if (previousCharging) 1f else 0f val scaleAnim = remember { Animatable(initialScale) } + val charging = status == BatteryStatus.CHARGING || status == BatteryStatus.OPTIMIZED_CHARGING val targetScale = if (charging) 1f else 0f LaunchedEffect(previousCharging, charging) { @@ -80,6 +88,43 @@ fun BatteryIndicator( modifier = Modifier.padding(bottom = 4.dp), contentAlignment = Alignment.Center ) { + val strokeWidthPx = with(LocalDensity.current) { 4.dp.toPx() } + val gapFromCenterPx = with(LocalDensity.current) { 8.sp.toPx() } + + if (status == BatteryStatus.OPTIMIZED_CHARGING) { + Canvas(modifier = Modifier.size(40.dp)) { + val radius = size.minDimension / 2 + val progress = batteryPercentage / 100f + + val angleDeg = -90f + 360f * progress + val angleRad = Math.toRadians(angleDeg.toDouble()) + + val outerX = center.x + (radius - strokeWidthPx) * cos(angleRad).toFloat() + val outerY = center.y + (radius - strokeWidthPx) * sin(angleRad).toFloat() + + val dirX = center.x - outerX + val dirY = center.y - outerY + val length = sqrt(dirX * dirX + dirY * dirY) + + val normX = dirX / length + val normY = dirY / length + + val startX = outerX - normX * strokeWidthPx / 2 + val startY = outerY - normY * strokeWidthPx / 2 + + val endX = center.x - normX * gapFromCenterPx + val endY = center.y - normY * gapFromCenterPx + + drawLine( + color = batteryFillColor, + start = Offset(startX, startY), + end = Offset(endX, endY), + strokeWidth = strokeWidthPx, + cap = StrokeCap.Round + ) + } + } + CircularProgressIndicator( progress = { batteryPercentage / 100f }, modifier = Modifier.size(40.dp), @@ -123,6 +168,6 @@ fun BatteryIndicatorPreview() { Box( modifier = Modifier.background(bg) ) { - BatteryIndicator(batteryPercentage = 24, charging = true, prefix = "\uDBC6\uDCE5", previousCharging = false) + BatteryIndicator(batteryPercentage = 80, status = BatteryStatus.CHARGING, prefix = "\uDBC6\uDCE5", previousCharging = false) } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryView.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/BatteryView.kt similarity index 87% rename from android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryView.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/components/BatteryView.kt index 4803ed3..7accabe 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryView.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/BatteryView.kt @@ -18,10 +18,9 @@ @file:OptIn(ExperimentalEncodingApi::class) -package me.kavishdevar.librepods.composables +package me.kavishdevar.librepods.presentation.components import android.content.res.Configuration -import android.util.Log import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme @@ -41,15 +40,14 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.imageResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.constants.Battery -import me.kavishdevar.librepods.constants.BatteryComponent -import me.kavishdevar.librepods.constants.BatteryStatus +import me.kavishdevar.librepods.data.Battery +import me.kavishdevar.librepods.data.BatteryComponent +import me.kavishdevar.librepods.data.BatteryStatus import kotlin.io.encoding.ExperimentalEncodingApi @Composable @@ -66,12 +64,6 @@ fun BatteryView( val rightLevel = right?.level ?: 0 val caseLevel = case?.level ?: 0 - val leftCharging = left?.status == BatteryStatus.CHARGING || - left?.status == BatteryStatus.OPTIMIZED_CHARGING - - val rightCharging = right?.status == BatteryStatus.CHARGING || - right?.status == BatteryStatus.OPTIMIZED_CHARGING - val caseCharging = case?.status == BatteryStatus.CHARGING || case?.status == BatteryStatus.OPTIMIZED_CHARGING @@ -98,12 +90,12 @@ fun BatteryView( ) if ( - leftCharging == rightCharging && + left?.status == right?.status && (leftLevel - rightLevel) in -3..3 ) { BatteryIndicator( leftLevel.coerceAtMost(rightLevel), - leftCharging + left?.status ?: BatteryStatus.NOT_CHARGING ) singleDisplayed.value = true } else { @@ -116,7 +108,7 @@ fun BatteryView( if (leftLevel > 0 || left?.status != BatteryStatus.DISCONNECTED) { BatteryIndicator( leftLevel, - leftCharging, + left?.status ?: BatteryStatus.NOT_CHARGING, "\uDBC6\uDCE5" ) } @@ -128,7 +120,7 @@ fun BatteryView( if (rightLevel > 0 || right?.status != BatteryStatus.DISCONNECTED) { BatteryIndicator( rightLevel, - rightCharging, + right?.status ?: BatteryStatus.NOT_CHARGING, "\uDBC6\uDCE8" ) } @@ -151,7 +143,7 @@ fun BatteryView( if (caseLevel > 0 || case?.status != BatteryStatus.DISCONNECTED) { BatteryIndicator( caseLevel, - caseCharging, + case?.status ?: BatteryStatus.NOT_CHARGING, prefix = if (!singleDisplayed.value) "\uDBC3\uDE6C" else "" ) } @@ -165,7 +157,7 @@ fun BatteryView( fun BatteryViewPreview() { val fakeBattery = listOf( Battery(BatteryComponent.LEFT, 85, BatteryStatus.CHARGING), - Battery(BatteryComponent.RIGHT, 40, BatteryStatus.CHARGING), + Battery(BatteryComponent.RIGHT, 40, BatteryStatus.OPTIMIZED_CHARGING), Battery(BatteryComponent.CASE, 60, BatteryStatus.NOT_CHARGING) ) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/CallControlSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/CallControlSettings.kt similarity index 91% rename from android/app/src/main/java/me/kavishdevar/librepods/composables/CallControlSettings.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/components/CallControlSettings.kt index ec8ae16..2b00c06 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/CallControlSettings.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/CallControlSettings.kt @@ -18,7 +18,7 @@ @file:OptIn(ExperimentalEncodingApi::class) -package me.kavishdevar.librepods.composables +package me.kavishdevar.librepods.presentation.components import android.util.Log import androidx.compose.foundation.background @@ -41,15 +41,18 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf 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 import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font @@ -59,6 +62,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import kotlinx.coroutines.launch import me.kavishdevar.librepods.R import kotlin.io.encoding.ExperimentalEncodingApi @@ -82,7 +86,8 @@ fun CallControlSettings( style = TextStyle( fontSize = 14.sp, fontWeight = FontWeight.Bold, - color = textColor.copy(alpha = 0.6f) + color = textColor.copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) ) ) } @@ -93,6 +98,10 @@ fun CallControlSettings( .background(backgroundColor, RoundedCornerShape(28.dp)) .padding(top = 2.dp) ) { + + val scope = rememberCoroutineScope() + val haptics = LocalHapticFeedback.current + val pressOnceText = stringResource(R.string.press_once) val pressTwiceText = stringResource(R.string.press_twice) @@ -105,6 +114,7 @@ fun CallControlSettings( var lastDismissTimeSingle by remember { mutableLongStateOf(0L) } var parentHoveredIndexSingle by remember { mutableStateOf(null) } var parentDragActiveSingle by remember { mutableStateOf(false) } + var previousIdxSingle by remember { mutableStateOf(null) } var showDoublePressDropdown by remember { mutableStateOf(false) } var touchOffsetDouble by remember { mutableStateOf(null) } @@ -112,6 +122,7 @@ fun CallControlSettings( var lastDismissTimeDouble by remember { mutableLongStateOf(0L) } var parentHoveredIndexDouble by remember { mutableStateOf(null) } var parentDragActiveDouble by remember { mutableStateOf(false) } + var previousIdxDouble by remember { mutableStateOf(null) } LaunchedEffect(flipped) { Log.d("CallControlSettings", "Call control flipped: $flipped") @@ -187,7 +198,11 @@ fun CallControlSettings( val touch = touchOffsetSingle ?: current val posInPopupY = current.y - touch.y val idx = (posInPopupY / itemHeightPx).toInt() + if (idx != previousIdxSingle) { + scope.launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) } + } parentHoveredIndexSingle = idx + previousIdxSingle = idx }, onDragEnd = { parentDragActiveSingle = false @@ -204,6 +219,9 @@ fun CallControlSettings( } } + if (parentHoveredIndexSingle != null && parentHoveredIndexSingle in 0..1) { + scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) } + } parentHoveredIndexSingle = null }, onDragCancel = { @@ -316,7 +334,11 @@ fun CallControlSettings( val touch = touchOffsetDouble ?: current val posInPopupY = current.y - touch.y val idx = (posInPopupY / itemHeightPx).toInt() + if (idx != previousIdxDouble) { + scope.launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) } + } parentHoveredIndexDouble = idx + previousIdxDouble = idx }, onDragEnd = { parentDragActiveDouble = false @@ -330,9 +352,12 @@ fun CallControlSettings( showDoublePressDropdown = false lastDismissTimeDouble = System.currentTimeMillis() val flipped = option == pressOnceText - onCallControlValueChanged (flipped) + onCallControlValueChanged(flipped) } } + if (parentHoveredIndexDouble != null && parentHoveredIndexDouble in 0..1) { + scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) } + } parentHoveredIndexDouble = null }, onDragCancel = { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/ConfirmationDialog.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ConfirmationDialog.kt similarity index 87% rename from android/app/src/main/java/me/kavishdevar/librepods/composables/ConfirmationDialog.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ConfirmationDialog.kt index e2c347b..337675f 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/ConfirmationDialog.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ConfirmationDialog.kt @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.composables +package me.kavishdevar.librepods.presentation.components import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme @@ -24,6 +24,7 @@ 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.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -38,13 +39,16 @@ import androidx.compose.runtime.MutableState 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 import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily @@ -53,10 +57,12 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.materials.CupertinoMaterials import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import kotlinx.coroutines.launch import me.kavishdevar.librepods.R @ExperimentalHazeMaterialsApi @@ -74,8 +80,18 @@ fun ConfirmationDialog( val isDarkTheme = isSystemInDarkTheme() val textColor = if (isDarkTheme) Color.White else Color.Black val accentColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5) + + val haptics = LocalHapticFeedback.current + val scope = rememberCoroutineScope() + if (showDialog.value) { - Dialog(onDismissRequest = { showDialog.value = false }) { + Dialog( + onDismissRequest = { showDialog.value = false }, + properties = DialogProperties( + dismissOnBackPress = false, + dismissOnClickOutside = false + ) + ) { Box( modifier = Modifier // .fillMaxWidth(0.75f) @@ -90,7 +106,7 @@ fun ConfirmationDialog( ) ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { - androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(24.dp)) Text( title, style = TextStyle( @@ -102,7 +118,7 @@ fun ConfirmationDialog( textAlign = TextAlign.Center, modifier = Modifier.padding(horizontal = 16.dp) ) - androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(12.dp)) Text( message, style = TextStyle( @@ -113,7 +129,7 @@ fun ConfirmationDialog( textAlign = TextAlign.Center, modifier = Modifier.padding(horizontal = 16.dp) ) - androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(16.dp)) HorizontalDivider( thickness = 1.dp, color = Color(0x40888888), @@ -148,6 +164,8 @@ fun ConfirmationDialog( } PointerEventType.Move -> { if (isWithinBounds) { + if (leftPressed != isLeft) scope.launch { haptics.performHapticFeedback( + HapticFeedbackType.SegmentTick) } leftPressed = isLeft rightPressed = !isLeft } else { @@ -158,8 +176,12 @@ fun ConfirmationDialog( PointerEventType.Release -> { if (isWithinBounds) { if (leftPressed) { + scope.launch { haptics.performHapticFeedback( + HapticFeedbackType.Reject) } onDismiss() } else if (rightPressed) { + scope.launch { haptics.performHapticFeedback( + HapticFeedbackType.Confirm) } onConfirm() } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/ConnectionSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ConnectionSettings.kt similarity index 97% rename from android/app/src/main/java/me/kavishdevar/librepods/composables/ConnectionSettings.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ConnectionSettings.kt index b95807c..ba54818 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/ConnectionSettings.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ConnectionSettings.kt @@ -18,7 +18,7 @@ @file:OptIn(ExperimentalEncodingApi::class) -package me.kavishdevar.librepods.composables +package me.kavishdevar.librepods.presentation.components import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/ControlCenterButton.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ControlCenterButton.kt similarity index 98% rename from android/app/src/main/java/me/kavishdevar/librepods/composables/ControlCenterButton.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ControlCenterButton.kt index 241363b..5340b0c 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/ControlCenterButton.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ControlCenterButton.kt @@ -18,7 +18,7 @@ @file:Suppress("unused") -package me.kavishdevar.librepods.composables +package me.kavishdevar.librepods.presentation.components import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/ControlCenterNoiseControlSegmentedButton.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ControlCenterNoiseControlSegmentedButton.kt similarity index 98% rename from android/app/src/main/java/me/kavishdevar/librepods/composables/ControlCenterNoiseControlSegmentedButton.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ControlCenterNoiseControlSegmentedButton.kt index c41fdab..ca0d1f4 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/ControlCenterNoiseControlSegmentedButton.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ControlCenterNoiseControlSegmentedButton.kt @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.composables +package me.kavishdevar.librepods.presentation.components import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateDpAsState @@ -56,7 +56,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.constants.NoiseControlMode +import me.kavishdevar.librepods.data.NoiseControlMode private val ContainerColor = Color(0x593C3C3E) private val SelectedIndicatorColorGray = Color(0xFF6C6C6E) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/HearingHealthSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HearingHealthSettings.kt similarity index 91% rename from android/app/src/main/java/me/kavishdevar/librepods/composables/HearingHealthSettings.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HearingHealthSettings.kt index 1379d27..a444dda 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/HearingHealthSettings.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HearingHealthSettings.kt @@ -18,7 +18,7 @@ @file:OptIn(ExperimentalEncodingApi::class) -package me.kavishdevar.librepods.composables +package me.kavishdevar.librepods.presentation.components import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme @@ -35,6 +35,8 @@ import androidx.compose.ui.draw.clip 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 @@ -47,12 +49,12 @@ fun HearingHealthSettings( navController: NavController, hasPPECapability: Boolean, hasHearingAidCapability: Boolean, - isXposed: Boolean + vendorIdHook: Boolean ) { val isDarkTheme = isSystemInDarkTheme() val textColor = if (isDarkTheme) Color.White else Color.Black val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) - val shouldShowHearingAid = hasHearingAidCapability && isXposed + val shouldShowHearingAid = hasHearingAidCapability && vendorIdHook if (hasPPECapability && shouldShowHearingAid) { Box( @@ -65,7 +67,8 @@ fun HearingHealthSettings( style = TextStyle( fontSize = 14.sp, fontWeight = FontWeight.Bold, - color = textColor.copy(alpha = 0.6f) + color = textColor.copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) ) ) } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/MicrophoneSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/MicrophoneSettings.kt similarity index 92% rename from android/app/src/main/java/me/kavishdevar/librepods/composables/MicrophoneSettings.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/components/MicrophoneSettings.kt index 5ade04d..5f693e9 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/MicrophoneSettings.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/MicrophoneSettings.kt @@ -18,9 +18,8 @@ @file:OptIn(ExperimentalEncodingApi::class) -package me.kavishdevar.librepods.composables +package me.kavishdevar.librepods.presentation.components -import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.gestures.detectTapGestures @@ -39,15 +38,18 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf 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 import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font @@ -56,8 +58,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import kotlinx.coroutines.launch import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.utils.AACPManager import kotlin.io.encoding.ExperimentalEncodingApi @ExperimentalHazeMaterialsApi @@ -93,26 +95,13 @@ fun MicrophoneSettings( var lastDismissTime by remember { mutableLongStateOf(0L) } val reopenThresholdMs = 250L - val listener = object : AACPManager.ControlCommandListener { - override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) { - if (AACPManager.Companion.ControlCommandIdentifiers.fromByte(controlCommand.identifier) == - AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE - ) { - selectedMode = when (controlCommand.value[0]) { - 0x00.toByte() -> "Automatic" - 0x01.toByte() -> "Always Right" - 0x02.toByte() -> "Always Left" - else -> "Automatic" - } - Log.d("MicrophoneSettings", "Microphone mode received: $selectedMode") - } - } - } - val density = LocalDensity.current val itemHeightPx = with(density) { 48.dp.toPx() } var parentHoveredIndex by remember { mutableStateOf(null) } var parentDragActive by remember { mutableStateOf(false) } + var previousIdx by remember { mutableStateOf(null) } + val haptics = LocalHapticFeedback.current + val scope = rememberCoroutineScope() val microphoneAutomaticText = stringResource(R.string.microphone_automatic) val microphoneAlwaysRightText = stringResource(R.string.microphone_always_right) val microphoneAlwaysLeftText = stringResource(R.string.microphone_always_left) @@ -152,7 +141,11 @@ fun MicrophoneSettings( 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 @@ -180,6 +173,9 @@ fun MicrophoneSettings( onMicModeValueChanged(byteValue.toByte()) } } + if (parentHoveredIndex != null && parentHoveredIndex in 0..2) { + scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) } + } parentHoveredIndex = null }, onDragCancel = { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/NavigationButton.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/NavigationButton.kt similarity index 82% rename from android/app/src/main/java/me/kavishdevar/librepods/composables/NavigationButton.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/components/NavigationButton.kt index c66f2bc..ceff731 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/NavigationButton.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/NavigationButton.kt @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.composables +package me.kavishdevar.librepods.presentation.components import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.tween @@ -35,21 +35,25 @@ 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 import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavController +import kotlinx.coroutines.launch import me.kavishdevar.librepods.R @Composable @@ -62,10 +66,14 @@ fun NavigationButton( description: String? = null, currentState: String? = null, height: Dp = 58.dp, + enabled: Boolean = true ) { val isDarkTheme = isSystemInDarkTheme() var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) } val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500)) + val haptics = LocalHapticFeedback.current + val scope = rememberCoroutineScope() + Column { if (title != null) { Box( @@ -79,23 +87,34 @@ fun NavigationButton( fontSize = 14.sp, fontWeight = FontWeight.Bold, color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) ) ) } } Row( modifier = Modifier - .background(animatedBackgroundColor, RoundedCornerShape(if (independent) 28.dp else 0.dp)) + .background( + animatedBackgroundColor, + RoundedCornerShape(if (independent) 28.dp else 0.dp) + ) .height(height) .pointerInput(Unit) { detectTapGestures( onPress = { - backgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9) - tryAwaitRelease() - backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + if (enabled) { + backgroundColor = + if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9) + tryAwaitRelease() + backgroundColor = + if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + } }, onTap = { - if (onClick != null) onClick() else navController.navigate(to) + if (enabled) { + scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) } + if (onClick != null) onClick() else navController.navigate(to) + } } ) } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlButton.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/NoiseControlButton.kt similarity index 97% rename from android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlButton.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/components/NoiseControlButton.kt index 6c7ec36..a5b880f 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlButton.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/NoiseControlButton.kt @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.composables +package me.kavishdevar.librepods.presentation.components import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -74,4 +74,4 @@ fun NoiseControlButtonPreview() { onClick = {}, textColor = Color.White, ) -} \ No newline at end of file +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/NoiseControlSettings.kt similarity index 98% rename from android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/components/NoiseControlSettings.kt index 699ed37..453c35c 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/NoiseControlSettings.kt @@ -18,7 +18,7 @@ @file:OptIn(ExperimentalEncodingApi::class) -package me.kavishdevar.librepods.composables +package me.kavishdevar.librepods.presentation.components import android.annotation.SuppressLint import androidx.compose.animation.core.AnimationSpec @@ -59,6 +59,8 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.imageResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.IntOffset @@ -66,7 +68,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.constants.NoiseControlMode +import me.kavishdevar.librepods.data.NoiseControlMode import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.math.roundToInt @@ -147,6 +149,7 @@ fun NoiseControlSettings( fontSize = 14.sp, fontWeight = FontWeight.Bold, color = textColor.copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) ) ) } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/PressAndHoldSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/PressAndHoldSettings.kt similarity index 97% rename from android/app/src/main/java/me/kavishdevar/librepods/composables/PressAndHoldSettings.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/components/PressAndHoldSettings.kt index 1861298..a29ea72 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/PressAndHoldSettings.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/PressAndHoldSettings.kt @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.composables +package me.kavishdevar.librepods.presentation.components import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme @@ -40,7 +40,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavController import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.constants.StemAction +import me.kavishdevar.librepods.data.StemAction @Composable fun PressAndHoldSettings( diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledButton.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledButton.kt similarity index 81% rename from android/app/src/main/java/me/kavishdevar/librepods/composables/StyledButton.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledButton.kt index 771dd24..5012ce0 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledButton.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledButton.kt @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.composables +package me.kavishdevar.librepods.presentation.components import android.graphics.RuntimeShader import android.os.Build @@ -46,7 +46,9 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ShaderBrush import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastCoerceAtMost @@ -77,7 +79,8 @@ fun StyledButton( maxScale: Float = 0.1f, content: @Composable RowScope.() -> Unit, ) { - val animationScope = rememberCoroutineScope() + val scope = rememberCoroutineScope() + val haptics = LocalHapticFeedback.current val progressAnimation = remember { Animatable(0f) } var pressStartPosition by remember { mutableStateOf(Offset.Zero) } val offsetAnimation = remember { Animatable(Offset.Zero, Offset.VectorConverter) } @@ -163,19 +166,21 @@ half4 main(float2 coord) { val maxOffset = size.minDimension val initialDerivative = 0.05f val offset = offsetAnimation.value - translationX = maxOffset * tanh(initialDerivative * offset.x / maxOffset) - translationY = maxOffset * tanh(initialDerivative * offset.y / maxOffset) + translationX = + maxOffset * tanh(initialDerivative * offset.x / maxOffset) + translationY = + maxOffset * tanh(initialDerivative * offset.y / maxOffset) val maxDragScale = 0.1f val offsetAngle = atan2(offset.y, offset.x) scaleX = scale + - maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) * - (width / height).fastCoerceAtMost(1f) + maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) * + (width / height).fastCoerceAtMost(1f) scaleY = scale + - maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) * - (height / width).fastCoerceAtMost(1f) + maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) * + (height / width).fastCoerceAtMost(1f) }, onDrawSurface = { if (tint.isSpecified) { @@ -209,7 +214,10 @@ half4 main(float2 coord) { interactiveHighlightShader.apply { val offset = pressStartPosition + offsetAnimation.value setFloatUniform("size", size.width, size.height) - setColorUniform("color", Color.White.copy(0.15f * progress).toArgb()) + setColorUniform( + "color", + Color.White.copy(0.15f * progress).toArgb() + ) setFloatUniform("radius", size.maxDimension) setFloatUniform( "offset", @@ -236,31 +244,51 @@ half4 main(float2 coord) { interactionSource = null, indication = null, role = Role.Button, - onClick = onClick + onClick = { + haptics.performHapticFeedback(HapticFeedbackType.ContextClick) + onClick() + } ) .then( if (isInteractive) { - Modifier.pointerInput(animationScope) { + Modifier.pointerInput(scope) { val progressAnimationSpec = spring(0.5f, 300f, 0.001f) val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold) val onDragStop: () -> Unit = { - animationScope.launch { + scope.launch { + launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) } launch { progressAnimation.animateTo(0f, progressAnimationSpec) } - launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) } + launch { + offsetAnimation.animateTo( + Offset.Zero, + offsetAnimationSpec + ) + } } } inspectDragGestures( onDragStart = { down -> pressStartPosition = down.position - animationScope.launch { - launch { progressAnimation.animateTo(1f, progressAnimationSpec) } + scope.launch { + launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) } + launch { + progressAnimation.animateTo( + 1f, + progressAnimationSpec + ) + } launch { offsetAnimation.snapTo(Offset.Zero) } } }, - onDragEnd = { onDragStop() }, + onDragEnd = { + onDragStop() + }, onDragCancel = onDragStop ) { _, dragAmount -> - animationScope.launch { + scope.launch { + if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback( + HapticFeedbackType.SegmentFrequentTick + ) offsetAnimation.snapTo(offsetAnimation.value + dragAmount) } } @@ -274,6 +302,7 @@ half4 main(float2 coord) { isPressed = false }, onTap = { + haptics.performHapticFeedback(HapticFeedbackType.ContextClick) onClick() } ) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledDropdown.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledDropdown.kt similarity index 91% rename from android/app/src/main/java/me/kavishdevar/librepods/composables/StyledDropdown.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledDropdown.kt index 394c155..4446f08 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledDropdown.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledDropdown.kt @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.composables +package me.kavishdevar.librepods.presentation.components import android.annotation.SuppressLint import androidx.compose.animation.AnimatedVisibility @@ -49,14 +49,17 @@ import androidx.compose.runtime.LaunchedEffect 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 import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily @@ -71,6 +74,7 @@ import dev.chrisbanes.haze.HazeTint import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.materials.CupertinoMaterials import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import kotlinx.coroutines.launch import me.kavishdevar.librepods.R @ExperimentalHazeMaterialsApi @@ -110,6 +114,9 @@ fun StyledDropdown( var hoveredIndex by remember { mutableStateOf(null) } val itemHeight = 48.dp + val scope = rememberCoroutineScope() + val haptics = LocalHapticFeedback.current + var popupSize by remember { mutableStateOf(IntSize(0, 0)) } var lastDragPosition by remember { mutableStateOf(null) } @@ -132,7 +139,12 @@ fun StyledDropdown( }, onDrag = { change, _ -> val y = change.position.y - hoveredIndex = (y / itemHeight.toPx()).toInt() + val newHoveredIndex = (y / itemHeight.toPx()).toInt() + if (newHoveredIndex != hoveredIndex) { + scope.launch { haptics.performHapticFeedback( + HapticFeedbackType.SegmentTick) } + } + hoveredIndex = newHoveredIndex lastDragPosition = change.position }, onDragEnd = { @@ -144,6 +156,8 @@ fun StyledDropdown( if (withinBounds) { hoveredIndex?.let { idx -> if (idx in options.indices) { + scope.launch { haptics.performHapticFeedback( + HapticFeedbackType.GestureEnd) } onOptionSelected(options[idx]) } } @@ -174,6 +188,7 @@ fun StyledDropdown( interactionSource = remember { MutableInteractionSource() }, indication = null ) { + scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) } onOptionSelected(text) onDismissRequest() } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledIconButton.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledIconButton.kt similarity index 86% rename from android/app/src/main/java/me/kavishdevar/librepods/composables/StyledIconButton.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledIconButton.kt index 9a12561..baed724 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledIconButton.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledIconButton.kt @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.composables +package me.kavishdevar.librepods.presentation.components import android.graphics.RuntimeShader import android.os.Build @@ -50,7 +50,9 @@ import androidx.compose.ui.graphics.layer.CompositingStrategy import androidx.compose.ui.graphics.layer.drawLayer import androidx.compose.ui.graphics.rememberGraphicsLayer import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily @@ -83,8 +85,9 @@ fun StyledIconButton( backdrop: LayerBackdrop = rememberLayerBackdrop(), onClick: () -> Unit ) { + val haptics = LocalHapticFeedback.current val darkMode = isSystemInDarkTheme() - val animationScope = rememberCoroutineScope() + val scope = rememberCoroutineScope() val progressAnimationSpec = spring(0.5f, 300f, 0.001f) val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold) val progressAnimation = remember { Animatable(0f) } @@ -116,7 +119,10 @@ half4 main(float2 coord) { } val isDarkTheme = isSystemInDarkTheme() TextButton( - onClick = onClick, + onClick = { + scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) } + onClick() + }, shape = RoundedCornerShape(56.dp), modifier = modifier .padding(horizontal = 12.dp) @@ -147,12 +153,12 @@ half4 main(float2 coord) { val offsetAngle = atan2(offset.y, offset.x) scaleX = scale + - maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) * - (width / height).fastCoerceAtMost(1f) + maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) * + (width / height).fastCoerceAtMost(1f) scaleY = scale + - maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) * - (height / width).fastCoerceAtMost(1f) + maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) * + (height / width).fastCoerceAtMost(1f) }, onDrawSurface = { val progress = progressAnimation.value.coerceIn(0f, 1f) @@ -182,7 +188,12 @@ half4 main(float2 coord) { drawLayer(innerShadowLayer) drawRect( - (if (isDarkTheme) Color(0xFFAFAFAF) else Color.White).copy(progress.coerceIn(0.15f, 0.35f)) + (if (isDarkTheme) Color(0xFFAFAFAF) else Color.White).copy( + progress.coerceIn( + 0.15f, + 0.35f + ) + ) ) }, onDrawFront = { @@ -196,7 +207,10 @@ half4 main(float2 coord) { interactiveHighlightShader.apply { val offset = pressStartPosition + offsetAnimation.value setFloatUniform("size", size.width, size.height) - setColorUniform("color", Color.White.copy(0.15f * progress).toArgb()) + setColorUniform( + "color", + Color.White.copy(0.15f * progress).toArgb() + ) setFloatUniform("radius", size.maxDimension) setFloatUniform( "offset", @@ -225,9 +239,10 @@ half4 main(float2 coord) { ) }, ) - .pointerInput(animationScope) { + .pointerInput(scope) { val onDragStop: () -> Unit = { - animationScope.launch { + scope.launch { + launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) } launch { progressAnimation.animateTo(0f, progressAnimationSpec) } launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) } } @@ -235,7 +250,8 @@ half4 main(float2 coord) { inspectDragGestures( onDragStart = { down -> pressStartPosition = down.position - animationScope.launch { + scope.launch { + launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) } launch { progressAnimation.animateTo(1f, progressAnimationSpec) } launch { offsetAnimation.snapTo(Offset.Zero) } } @@ -243,7 +259,10 @@ half4 main(float2 coord) { onDragEnd = { onDragStop() }, onDragCancel = onDragStop ) { _, dragAmount -> - animationScope.launch { + scope.launch { + if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback( + HapticFeedbackType.SegmentFrequentTick + ) offsetAnimation.snapTo(offsetAnimation.value + dragAmount) } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledScaffold.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledScaffold.kt similarity index 99% rename from android/app/src/main/java/me/kavishdevar/librepods/composables/StyledScaffold.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledScaffold.kt index f359853..1c79f25 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledScaffold.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledScaffold.kt @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.composables +package me.kavishdevar.librepods.presentation.components import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSelectList.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSelectList.kt similarity index 92% rename from android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSelectList.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSelectList.kt index 90af965..1b1dd1b 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSelectList.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSelectList.kt @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.composables +package me.kavishdevar.librepods.presentation.components import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateFloatAsState @@ -39,11 +39,14 @@ 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 import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font @@ -71,6 +74,9 @@ 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( modifier = modifier .fillMaxWidth() @@ -104,6 +110,11 @@ fun StyledSelectList( if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9) tryAwaitRelease() itemBackgroundColor = backgroundColor + } + }, + onTap = { + if (item.enabled) { + haptics.performHapticFeedback(HapticFeedbackType.ContextClick) item.onClick() } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSlider.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSlider.kt similarity index 96% rename from android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSlider.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSlider.kt index 78829e2..58c9b57 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSlider.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSlider.kt @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.composables +package me.kavishdevar.librepods.presentation.components import android.annotation.SuppressLint import android.content.res.Configuration @@ -56,6 +56,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.input.pointer.util.addPointerInputChange @@ -64,6 +65,7 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily @@ -246,6 +248,8 @@ fun StyledSlider( val startIconWidthState = remember { mutableFloatStateOf(0f) } val endIconWidthState = remember { mutableFloatStateOf(0f) } val density = LocalDensity.current + val haptics = LocalHapticFeedback.current + var lastDragValue by remember { mutableFloatStateOf(value) } val momentumAnimation = rememberMomentumAnimation(maxScale = 1.5f) @@ -449,10 +453,17 @@ fun StyledSlider( valueRange.start, valueRange.endInclusive ) + snapPoints.forEach { snap -> + if ((lastDragValue < snap && targetValue >= snap) || + (snap in targetValue... */ -package me.kavishdevar.librepods.composables +package me.kavishdevar.librepods.presentation.components import android.content.res.Configuration import androidx.compose.animation.animateColorAsState @@ -58,8 +58,10 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.layer.CompositingStrategy import androidx.compose.ui.graphics.layer.drawLayer import androidx.compose.ui.graphics.rememberGraphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastCoerceIn @@ -73,6 +75,7 @@ import com.kyant.backdrop.highlight.Highlight import com.kyant.backdrop.shadow.Shadow import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch +import kotlin.math.abs @Composable fun StyledSwitch( @@ -81,9 +84,12 @@ fun StyledSwitch( enabled: Boolean = true, ) { val isDarkTheme = isSystemInDarkTheme() + val haptics = LocalHapticFeedback.current val onColor = if (enabled) Color(0xFF34C759) else if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6) - val offColor = if (enabled) if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6) else if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6) + val offColor = if (enabled) if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6) else if (isDarkTheme) Color( + 0x805B5B5E + ) else Color(0xFFD1D1D6) val trackWidth = 64.dp val trackHeight = 28.dp @@ -98,7 +104,7 @@ fun StyledSwitch( val animatedFraction = remember { Animatable(fraction) } val trackWidthPx = remember { mutableFloatStateOf(0f) } val density = LocalDensity.current - val animationScope = rememberCoroutineScope() + val scope = rememberCoroutineScope() val progressAnimationSpec = spring(0.5f, 300f, 0.001f) val progressAnimation = remember { Animatable(0f) } val innerShadowLayer = rememberGraphicsLayer().apply { @@ -111,6 +117,11 @@ fun StyledSwitch( val isFirstComposition = remember { mutableStateOf(true) } LaunchedEffect(checked) { if (!isFirstComposition.value) { + if (checked) { + haptics.performHapticFeedback(HapticFeedbackType.ToggleOn) + } else { + haptics.performHapticFeedback(HapticFeedbackType.ToggleOff) + } coroutineScope { launch { val targetFrac = if (checked) 1f else 0f @@ -150,27 +161,31 @@ fun StyledSwitch( .then(if (enabled) Modifier.draggable( rememberDraggableState { delta -> if (trackWidthPx.floatValue > 0f) { + val oldFraction = animatedFraction.value val newFraction = (animatedFraction.value + delta / trackWidthPx.floatValue).fastCoerceIn(-0.3f, 1.3f) - animationScope.launch { + scope.launch { animatedFraction.snapTo(newFraction) } - totalDrag.floatValue += kotlin.math.abs(delta) + totalDrag.floatValue += abs(delta) val newChecked = newFraction >= 0.5f if (newChecked != checked) { onCheckedChange(newChecked) } + if ((oldFraction < 0.5f && newFraction >= 0.5f) || (oldFraction >= 0.5f && newFraction < 0.5f)) { + haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) + } } }, Orientation.Horizontal, startDragImmediately = true, onDragStarted = { totalDrag.floatValue = 0f - animationScope.launch { + scope.launch { progressAnimation.animateTo(1f, progressAnimationSpec) } }, onDragStopped = { - animationScope.launch { + scope.launch { if (totalDrag.floatValue < tapThreshold) { val newChecked = !checked onCheckedChange(newChecked) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledToggle.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledToggle.kt similarity index 86% rename from android/app/src/main/java/me/kavishdevar/librepods/composables/StyledToggle.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledToggle.kt index b7453ae..4028dc4 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledToggle.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledToggle.kt @@ -18,7 +18,7 @@ @file:OptIn(ExperimentalEncodingApi::class) -package me.kavishdevar.librepods.composables +package me.kavishdevar.librepods.presentation.components import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.tween @@ -40,12 +40,15 @@ 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.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily @@ -53,6 +56,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import kotlinx.coroutines.launch import me.kavishdevar.librepods.R import kotlin.io.encoding.ExperimentalEncodingApi @@ -71,6 +75,9 @@ fun StyledToggle( val isDarkTheme = isSystemInDarkTheme() val textColor = if (isDarkTheme) Color.White else Color.Black + val haptics = LocalHapticFeedback.current + val scope = rememberCoroutineScope() + var backgroundColor by remember { mutableStateOf( if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) @@ -90,7 +97,8 @@ fun StyledToggle( style = TextStyle( fontSize = 14.sp, fontWeight = FontWeight.Bold, - color = textColor.copy(alpha = 0.6f) + color = textColor.copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) ), modifier = Modifier.padding( start = 16.dp, @@ -108,14 +116,17 @@ fun StyledToggle( .pointerInput(Unit) { detectTapGestures( onPress = { - backgroundColor = - if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9) - tryAwaitRelease() - backgroundColor = - if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + if (enabled) { + backgroundColor = + if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9) + tryAwaitRelease() + backgroundColor = + if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + } }, onTap = { if (enabled) { + scope.launch { haptics.performHapticFeedback(if (!currentChecked) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff) } onCheckedChange(!currentChecked) } } @@ -145,6 +156,7 @@ fun StyledToggle( enabled = enabled, onCheckedChange = { if (enabled) { + scope.launch { haptics.performHapticFeedback(if (it) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff) } onCheckedChange(it) } } @@ -200,6 +212,7 @@ fun StyledToggle( interactionSource = remember { MutableInteractionSource() } ) { if (enabled) { + scope.launch { haptics.performHapticFeedback(if (!currentChecked) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff) } onCheckedChange(!currentChecked) } }, diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/VerticalVolumeSlider.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/VerticalVolumeSlider.kt similarity index 99% rename from android/app/src/main/java/me/kavishdevar/librepods/composables/VerticalVolumeSlider.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/components/VerticalVolumeSlider.kt index 8c82da4..37bddd8 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/VerticalVolumeSlider.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/VerticalVolumeSlider.kt @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.composables +package me.kavishdevar.librepods.presentation.components import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/IslandWindow.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/IslandWindow.kt similarity index 99% rename from android/app/src/main/java/me/kavishdevar/librepods/utils/IslandWindow.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/IslandWindow.kt index c93ca15..90ad166 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/IslandWindow.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/IslandWindow.kt @@ -18,7 +18,7 @@ @file:OptIn(ExperimentalEncodingApi::class) -package me.kavishdevar.librepods.utils +package me.kavishdevar.librepods.presentation.overlays import android.animation.Animator import android.animation.AnimatorListenerAdapter @@ -58,10 +58,10 @@ import androidx.dynamicanimation.animation.DynamicAnimation import androidx.dynamicanimation.animation.SpringAnimation import androidx.dynamicanimation.animation.SpringForce import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.constants.AirPodsNotifications -import me.kavishdevar.librepods.constants.Battery -import me.kavishdevar.librepods.constants.BatteryComponent -import me.kavishdevar.librepods.constants.BatteryStatus +import me.kavishdevar.librepods.data.AirPodsNotifications +import me.kavishdevar.librepods.data.Battery +import me.kavishdevar.librepods.data.BatteryComponent +import me.kavishdevar.librepods.data.BatteryStatus import me.kavishdevar.librepods.services.ServiceManager import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.math.abs diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/PopupWindow.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/PopupWindow.kt similarity index 97% rename from android/app/src/main/java/me/kavishdevar/librepods/utils/PopupWindow.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/PopupWindow.kt index e93e709..45bdf9f 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/PopupWindow.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/PopupWindow.kt @@ -17,7 +17,7 @@ */ -package me.kavishdevar.librepods.utils +package me.kavishdevar.librepods.presentation.overlays import android.animation.Animator import android.animation.AnimatorListenerAdapter @@ -45,10 +45,10 @@ import android.widget.LinearLayout import android.widget.TextView import android.widget.VideoView import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.constants.AirPodsNotifications -import me.kavishdevar.librepods.constants.Battery -import me.kavishdevar.librepods.constants.BatteryComponent -import me.kavishdevar.librepods.constants.BatteryStatus +import me.kavishdevar.librepods.data.AirPodsNotifications +import me.kavishdevar.librepods.data.Battery +import me.kavishdevar.librepods.data.BatteryComponent +import me.kavishdevar.librepods.data.BatteryStatus @SuppressLint("InflateParams", "ClickableViewAccessibility") class PopupWindow( diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AccessibilitySettingsScreen.kt similarity index 80% rename from android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AccessibilitySettingsScreen.kt index 176ed77..98adcb1 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AccessibilitySettingsScreen.kt @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.screens +package me.kavishdevar.librepods.presentation.screens // import me.kavishdevar.librepods.utils.RadareOffsetFinder import android.annotation.SuppressLint @@ -44,16 +44,20 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf 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 import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font @@ -67,17 +71,18 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi -import me.kavishdevar.librepods.BuildConfig +import kotlinx.coroutines.launch import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.composables.NavigationButton -import me.kavishdevar.librepods.composables.StyledDropdown -import me.kavishdevar.librepods.composables.StyledScaffold -import me.kavishdevar.librepods.composables.StyledSlider -import me.kavishdevar.librepods.composables.StyledToggle -import me.kavishdevar.librepods.utils.AACPManager -import me.kavishdevar.librepods.utils.ATTHandles -import me.kavishdevar.librepods.utils.Capability -import me.kavishdevar.librepods.viewmodel.AirPodsViewModel +import me.kavishdevar.librepods.bluetooth.AACPManager +import me.kavishdevar.librepods.bluetooth.ATTHandles +import me.kavishdevar.librepods.data.Capability +import me.kavishdevar.librepods.presentation.components.NavigationButton +import me.kavishdevar.librepods.presentation.components.StyledButton +import me.kavishdevar.librepods.presentation.components.StyledDropdown +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.AirPodsViewModel import kotlin.io.encoding.ExperimentalEncodingApi //private var phoneMediaDebounceJob: Job? = null @@ -96,7 +101,6 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC 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() StyledScaffold( @@ -113,6 +117,28 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC ) { Spacer(modifier = Modifier.height(topPadding)) + if (!state.isPremium) { + StyledButton( + onClick = { + navController.navigate("purchase_screen") + }, + backdrop = rememberLayerBackdrop(), + modifier = Modifier.fillMaxWidth(), + maxScale = 0.05f, + tint = 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 + ), + ) + } + } + // val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) } // val phoneEQEnabled = remember { mutableStateOf(false) } // val mediaEQEnabled = remember { mutableStateOf(false) } @@ -179,66 +205,100 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC // } // } // } + 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 + ) + ) { + DropdownMenuComponent( + label = stringResource(R.string.press_speed), + description = stringResource(R.string.press_speed_description), + options = pressSpeedOptions.values.toList(), + selectedOption = selectedPressSpeed ?: stringResource(R.string.default_option), + onOptionSelected = { newValue -> + selectedPressSpeed = newValue + viewModel.setControlCommandByte( + identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL, + value = pressSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() + ?: 0.toByte() + ) + }, + textColor = textColor, + hazeState = hazeState, + independent = true + ) + } - DropdownMenuComponent( - label = stringResource(R.string.press_speed), - description = stringResource(R.string.press_speed_description), - options = pressSpeedOptions.values.toList(), - selectedOption = selectedPressSpeed?: stringResource(R.string.default_option), - onOptionSelected = { newValue -> - selectedPressSpeed = newValue - viewModel.setControlCommandByte( - identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL, - value = pressSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() - ?: 0.toByte() - ) - }, - textColor = textColor, - hazeState = hazeState, - independent = true - ) - - DropdownMenuComponent( - label = stringResource(R.string.press_and_hold_duration), - description = stringResource(R.string.press_and_hold_duration_description), - options = pressAndHoldDurationOptions.values.toList(), - selectedOption = selectedPressAndHoldDuration?: stringResource(R.string.default_option), - onOptionSelected = { newValue -> - selectedPressAndHoldDuration = newValue - viewModel.setControlCommandByte( - identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL, - value = pressAndHoldDurationOptions.filterValues { it == newValue }.keys.firstOrNull() - ?: 0.toByte() - ) - }, - textColor = textColor, - hazeState = hazeState, - independent = true - ) - + 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 + ) + ) { + DropdownMenuComponent( + label = stringResource(R.string.press_and_hold_duration), + description = stringResource(R.string.press_and_hold_duration_description), + options = pressAndHoldDurationOptions.values.toList(), + selectedOption = selectedPressAndHoldDuration + ?: stringResource(R.string.default_option), + onOptionSelected = { newValue -> + selectedPressAndHoldDuration = newValue + viewModel.setControlCommandByte( + identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL, + value = pressAndHoldDurationOptions.filterValues { it == newValue }.keys.firstOrNull() + ?: 0.toByte() + ) + }, + textColor = textColor, + hazeState = hazeState, + independent = true + ) + } StyledToggle( title = stringResource(R.string.noise_control), 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) } + onCheckedChange = { viewModel.setControlCommandBoolean(AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE, it) }, + enabled = state.isPremium ) - if (state.capabilities.contains(Capability.LOUD_SOUND_REDUCTION) && BuildConfig.FLAVOR == "xposed") { + if (state.capabilities.contains(Capability.LOUD_SOUND_REDUCTION) && state.vendorIdHook) { StyledToggle( label = stringResource(R.string.loud_sound_reduction), description = stringResource(R.string.loud_sound_reduction_description), - checked = viewModel.getATTCharacteristicValue(ATTHandles.LOUD_SOUND_REDUCTION)?.get(0) == 1.toByte(), - onCheckedChange = { viewModel.setATTCharacteristicValue(ATTHandles.LOUD_SOUND_REDUCTION, if (it) byteArrayOf(0x01) else byteArrayOf(0x00)) } + checked = state.loudSoundReductionEnabled, + onCheckedChange = { viewModel.setATTCharacteristicValue(ATTHandles.LOUD_SOUND_REDUCTION, if (it) byteArrayOf(0x01) else byteArrayOf(0x00)) }, + enabled = state.isPremium ) } - if (!hearingAidEnabled && BuildConfig.FLAVOR == "xposed") { + if (!hearingAidEnabled && state.vendorIdHook) { NavigationButton( to = "transparency_customization", name = stringResource(R.string.customize_transparency_mode), - navController = navController + navController = navController, + enabled = state.isPremium ) } @@ -254,7 +314,8 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC snapPoints = listOf(75f), startIcon = "\uDBC0\uDEA1", endIcon = "\uDBC0\uDEA9", - independent = true + independent = true, + enabled = state.isPremium ) if (state.capabilities.contains(Capability.SWIPE_FOR_VOLUME)) { @@ -263,26 +324,44 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC 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 ) - DropdownMenuComponent( - label = stringResource(R.string.volume_swipe_speed), - description = stringResource(R.string.volume_swipe_speed_description), - options = volumeSwipeSpeedOptions.values.toList(), - selectedOption = selectedVolumeSwipeSpeed?: stringResource(R.string.default_option), - onOptionSelected = { newValue -> - selectedVolumeSwipeSpeed = newValue - viewModel.setControlCommandByte( - identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL, - value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() - ?: 1.toByte() - ) - }, - textColor = textColor, - hazeState = hazeState, - independent = true - ) + 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 + ) + ) { + DropdownMenuComponent( + label = stringResource(R.string.volume_swipe_speed), + description = stringResource(R.string.volume_swipe_speed_description), + options = volumeSwipeSpeedOptions.values.toList(), + selectedOption = selectedVolumeSwipeSpeed + ?: stringResource(R.string.default_option), + onOptionSelected = { newValue -> + selectedVolumeSwipeSpeed = newValue + viewModel.setControlCommandByte( + identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL, + value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() + ?: 1.toByte() + ) + }, + textColor = textColor, + hazeState = hazeState, + independent = true + ) + } } // if (!hearingAidEnabled.value&& BuildConfig.FLAVOR == "xposed") { @@ -507,7 +586,6 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC // } // } // } -// } Spacer(modifier = Modifier.height(bottomPadding)) } } @@ -534,6 +612,9 @@ private fun DropdownMenuComponent( var lastDismissTime by remember { mutableLongStateOf(0L) } var parentHoveredIndex by remember { mutableStateOf(null) } var parentDragActive by remember { mutableStateOf(false) } + var previousIdx by remember { mutableStateOf(null) } + val haptics = LocalHapticFeedback.current + val scope = rememberCoroutineScope() Column(modifier = Modifier.fillMaxWidth()){ Column( @@ -593,7 +674,11 @@ private fun DropdownMenuComponent( 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 @@ -604,6 +689,9 @@ private fun DropdownMenuComponent( lastDismissTime = System.currentTimeMillis() } } + if (parentHoveredIndex != null && parentHoveredIndex in options.indices) { + scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) } + } parentHoveredIndex = null }, onDragCancel = { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AdaptiveStrengthScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AdaptiveStrengthScreen.kt similarity index 65% rename from android/app/src/main/java/me/kavishdevar/librepods/screens/AdaptiveStrengthScreen.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AdaptiveStrengthScreen.kt index 677f1b6..9a2f9ac 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/AdaptiveStrengthScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AdaptiveStrengthScreen.kt @@ -16,14 +16,17 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.screens +package me.kavishdevar.librepods.presentation.screens +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.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 @@ -33,21 +36,29 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue 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 kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.composables.StyledScaffold -import me.kavishdevar.librepods.composables.StyledSlider -import me.kavishdevar.librepods.utils.AACPManager -import me.kavishdevar.librepods.viewmodel.AirPodsViewModel +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.bluetooth.AACPManager +import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel @Composable -fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel) { +fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel, navController: NavController) { val state by viewModel.uiState.collectAsState() val backdrop = rememberLayerBackdrop() @@ -62,6 +73,27 @@ fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel) { verticalArrangement = Arrangement.spacedBy(16.dp) ) { Spacer(modifier = Modifier.height(spacerHeight)) + if (!state.isPremium) { + StyledButton( + onClick = { + navController.navigate("purchase_screen") + }, + backdrop = rememberLayerBackdrop(), + modifier = Modifier.fillMaxWidth(), + maxScale = 0.05f, + tint = 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 + ), + ) + } + } val sliderValue = remember { mutableFloatStateOf( state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH]?.getOrNull( @@ -90,7 +122,8 @@ fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel) { startIcon = "􀊥", endIcon = "􀊩", independent = true, - description = stringResource(R.string.adaptive_audio_description) + description = stringResource(R.string.adaptive_audio_description), + enabled = state.isPremium ) } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt similarity index 90% rename from android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt index c498313..0c64961 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt @@ -18,13 +18,12 @@ @file:OptIn(ExperimentalEncodingApi::class) -package me.kavishdevar.librepods.screens +package me.kavishdevar.librepods.presentation.screens // import me.kavishdevar.librepods.utils.RadareOffsetFinder import android.annotation.SuppressLint import android.content.Context.MODE_PRIVATE import android.content.SharedPreferences -import android.util.Log import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement @@ -74,25 +73,25 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import kotlinx.coroutines.delay import me.kavishdevar.librepods.BuildConfig import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.composables.AboutCard -import me.kavishdevar.librepods.composables.AudioSettings -import me.kavishdevar.librepods.composables.BatteryView -import me.kavishdevar.librepods.composables.CallControlSettings -import me.kavishdevar.librepods.composables.ConnectionSettings -import me.kavishdevar.librepods.composables.HearingHealthSettings -import me.kavishdevar.librepods.composables.MicrophoneSettings -import me.kavishdevar.librepods.composables.NavigationButton -import me.kavishdevar.librepods.composables.NoiseControlSettings -import me.kavishdevar.librepods.composables.PressAndHoldSettings -import me.kavishdevar.librepods.composables.StyledButton -import me.kavishdevar.librepods.composables.StyledIconButton -import me.kavishdevar.librepods.composables.StyledScaffold -import me.kavishdevar.librepods.composables.StyledToggle -import me.kavishdevar.librepods.utils.AACPManager -import me.kavishdevar.librepods.utils.ATTHandles -import me.kavishdevar.librepods.utils.AirPodsPro3 -import me.kavishdevar.librepods.utils.Capability -import me.kavishdevar.librepods.viewmodel.AirPodsViewModel +import me.kavishdevar.librepods.bluetooth.AACPManager +import me.kavishdevar.librepods.bluetooth.ATTHandles +import me.kavishdevar.librepods.data.AirPodsPro3 +import me.kavishdevar.librepods.data.Capability +import me.kavishdevar.librepods.presentation.components.AboutCard +import me.kavishdevar.librepods.presentation.components.AudioSettings +import me.kavishdevar.librepods.presentation.components.BatteryView +import me.kavishdevar.librepods.presentation.components.CallControlSettings +import me.kavishdevar.librepods.presentation.components.ConnectionSettings +import me.kavishdevar.librepods.presentation.components.HearingHealthSettings +import me.kavishdevar.librepods.presentation.components.MicrophoneSettings +import me.kavishdevar.librepods.presentation.components.NavigationButton +import me.kavishdevar.librepods.presentation.components.NoiseControlSettings +import me.kavishdevar.librepods.presentation.components.PressAndHoldSettings +import me.kavishdevar.librepods.presentation.components.StyledButton +import me.kavishdevar.librepods.presentation.components.StyledIconButton +import me.kavishdevar.librepods.presentation.components.StyledScaffold +import me.kavishdevar.librepods.presentation.components.StyledToggle +import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel import kotlin.io.encoding.ExperimentalEncodingApi @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @@ -162,17 +161,14 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl .fillMaxSize() .hazeSource(hazeState) .padding(horizontal = 16.dp) - .then( - if (blockTouches) Modifier.pointerInput(Unit) { - awaitPointerEventScope { - while (true) { - val event = awaitPointerEvent(PointerEventPass.Initial) - event.changes.forEach { it.consume() } - } + .then(if (blockTouches) Modifier.pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + event.changes.forEach { it.consume() } } - } else Modifier - ) - ) { + } + } else Modifier)) { item(key = "spacer_top") { Spacer(modifier = Modifier.height(topPadding)) } item(key = "battery") { BatteryView( @@ -199,7 +195,7 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl state.instance?.model?.capabilities?.contains(Capability.PPE) == true if (hasHearingAidCapability || hasPPECapability) { - if (hasPPECapability || (BuildConfig.FLAVOR == "xposed" && hasHearingAidCapability)) item( + if (hasPPECapability || (state.vendorIdHook && hasHearingAidCapability)) item( key = "spacer_hearing_health" ) { Spacer(modifier = Modifier.height(24.dp)) } item(key = "hearing_health") { @@ -207,7 +203,7 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl navController = navController, hasPPECapability = hasPPECapability, hasHearingAidCapability = hasHearingAidCapability, - isXposed = BuildConfig.FLAVOR == "xposed" + vendorIdHook = state.vendorIdHook ) } } @@ -272,27 +268,26 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl } item(key = "upgrade_button") { - val context = LocalContext.current - val textColor = if (isSystemInDarkTheme()) Color.White else Color.Black - if (!state.isPremium) { Spacer(modifier = Modifier.height(28.dp)) StyledButton( onClick = { - viewModel.purchase(context) + navController.navigate("purchase_screen") }, backdrop = rememberLayerBackdrop(), modifier = Modifier.fillMaxWidth(), maxScale = 0.05f, - tint = Color(0xFF916100) + tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color( + 0xFFE59900 + ) ) { Text( - stringResource(R.string.unlock_all_features), + stringResource(R.string.unlock_advanced_features), style = TextStyle( fontSize = 16.sp, fontWeight = FontWeight.Medium, fontFamily = FontFamily(Font(R.font.sf_pro)), - color = textColor + color = Color.White ), ) } @@ -320,11 +315,7 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG]?.getOrNull( 0 ) == 0x01.toByte() - val loudSoundReduction = - viewModel.getATTCharacteristicValue(ATTHandles.LOUD_SOUND_REDUCTION) - ?.getOrNull(0) == 0x01.toByte() - val isXposed = BuildConfig.FLAVOR == "xposed" AudioSettings( navController = navController, adaptiveVolumeCapability = adaptiveVolumeCapability, @@ -345,14 +336,14 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl checked ) }, - loudSoundReductionChecked = loudSoundReduction, + loudSoundReductionChecked = state.loudSoundReductionEnabled, onLoudSoundReductionCheckedChange = { viewModel.setATTCharacteristicValue( ATTHandles.LOUD_SOUND_REDUCTION, byteArrayOf(if (it) 0x01.toByte() else 0x00.toByte()) ) }, - isXposed = isXposed, + vendorIdHook = state.vendorIdHook, isPremium = state.isPremium ) } @@ -484,11 +475,8 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl tapCount.intValue = 0 viewModel.activateDemoMode() } - } - ) - } - ) - { + }) + }) { Text( text = stringResource(R.string.airpods_not_connected), style = TextStyle( fontSize = 24.sp, diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt similarity index 54% rename from android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt index a2cc5b3..2cb741c 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt @@ -16,12 +16,16 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.screens +package me.kavishdevar.librepods.presentation.screens +import android.content.Intent +import android.os.Build import android.widget.Toast 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.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -42,9 +46,14 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext +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 @@ -54,6 +63,7 @@ import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.net.toUri import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import com.kyant.backdrop.backdrops.layerBackdrop @@ -61,26 +71,26 @@ 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.composables.NavigationButton -import me.kavishdevar.librepods.composables.StyledButton -import me.kavishdevar.librepods.composables.StyledScaffold -import me.kavishdevar.librepods.composables.StyledSlider -import me.kavishdevar.librepods.composables.StyledToggle -import me.kavishdevar.librepods.viewmodel.AppSettingsViewModel +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( - navController: NavController, - viewModel: AppSettingsViewModel = viewModel() + navController: NavController, viewModel: AppSettingsViewModel = viewModel() ) { val context = LocalContext.current val scrollState = rememberScrollState() - val uiState by viewModel.uiState.collectAsState() + val state by viewModel.uiState.collectAsState() val backdrop = rememberLayerBackdrop() StyledScaffold( - title = stringResource(R.string.app_settings) + title = stringResource(R.string.settings) ) { topPadding, hazeState, bottomPadding -> Column( modifier = Modifier @@ -96,23 +106,23 @@ fun AppSettingsScreen( val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val textColor = if (isDarkTheme) Color.White else Color.Black - if (!uiState.isPremium) { + if (!state.isPremium) { StyledButton( onClick = { - viewModel.purchase(context) + navController.navigate("purchase_screen") }, backdrop = rememberLayerBackdrop(), modifier = Modifier.fillMaxWidth(), maxScale = 0.05f, - tint = Color(0xFF916100) + tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) ) { Text( - stringResource(R.string.unlock_all_features), + stringResource(R.string.unlock_advanced_features), style = TextStyle( fontSize = 16.sp, fontWeight = FontWeight.Medium, fontFamily = FontFamily(Font(R.font.sf_pro)), - color = textColor + color = Color.White ), ) } @@ -122,9 +132,9 @@ fun AppSettingsScreen( 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 = uiState.showPhoneBatteryInWidget, + checked = state.showPhoneBatteryInWidget, onCheckedChange = viewModel::setShowPhoneBatteryInWidget, - enabled = uiState.isPremium + enabled = state.isPremium ) Text( @@ -149,10 +159,10 @@ fun AppSettingsScreen( StyledToggle( label = stringResource(R.string.conversational_awareness_pause_music), description = stringResource(R.string.conversational_awareness_pause_music_description), - checked = uiState.conversationalAwarenessPauseMusicEnabled, + checked = state.conversationalAwarenessPauseMusicEnabled, onCheckedChange = viewModel::setConversationalAwarenessPauseMusicEnabled, independent = false, - enabled = uiState.isPremium + enabled = state.isPremium ) HorizontalDivider( @@ -164,16 +174,16 @@ fun AppSettingsScreen( StyledToggle( label = stringResource(R.string.relative_conversational_awareness_volume), description = stringResource(R.string.relative_conversational_awareness_volume_description), - checked = uiState.relativeConversationalAwarenessVolumeEnabled, + checked = state.relativeConversationalAwarenessVolumeEnabled, onCheckedChange = viewModel::setRelativeConversationalAwarenessVolumeEnabled, independent = false, - enabled = uiState.isPremium + enabled = state.isPremium, ) } Spacer(modifier = Modifier.height(16.dp)) - val conversationalAwarenessVolume = uiState.conversationalAwarenessVolume + val conversationalAwarenessVolume = state.conversationalAwarenessVolume LaunchedEffect(conversationalAwarenessVolume) { viewModel.setConversationalAwarenessVolume(conversationalAwarenessVolume) } @@ -182,11 +192,12 @@ fun AppSettingsScreen( 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 = uiState.isPremium + enabled = state.isPremium ) if (!BuildConfig.PLAY_BUILD) { @@ -198,7 +209,7 @@ fun AppSettingsScreen( name = stringResource(R.string.set_custom_camera_package), navController = navController, onClick = { - if (uiState.isPremium) viewModel.setShowCameraDialog(true) + if (state.isPremium) viewModel.setShowCameraDialog(true) }, independent = true, description = stringResource(R.string.camera_control_app_description) @@ -211,9 +222,9 @@ fun AppSettingsScreen( title = stringResource(R.string.ear_detection), label = stringResource(R.string.disconnect_when_not_wearing), description = stringResource(R.string.disconnect_when_not_wearing_description), - checked = uiState.disconnectWhenNotWearing, + checked = state.disconnectWhenNotWearing, onCheckedChange = viewModel::setDisconnectWhenNotWearing, - enabled = uiState.isPremium + enabled = state.isPremium ) } @@ -239,10 +250,10 @@ fun AppSettingsScreen( StyledToggle( label = stringResource(R.string.takeover_disconnected), description = stringResource(R.string.takeover_disconnected_desc), - checked = uiState.takeoverWhenDisconnected, + checked = state.takeoverWhenDisconnected, onCheckedChange = viewModel::setTakeoverWhenDisconnected, independent = false, - enabled = uiState.isPremium + enabled = state.isPremium ) HorizontalDivider( thickness = 1.dp, @@ -253,10 +264,10 @@ fun AppSettingsScreen( StyledToggle( label = stringResource(R.string.takeover_idle), description = stringResource(R.string.takeover_idle_desc), - checked = uiState.takeoverWhenIdle, + checked = state.takeoverWhenIdle, onCheckedChange = viewModel::setTakeoverWhenIdle, independent = false, - enabled = uiState.isPremium + enabled = state.isPremium ) HorizontalDivider( thickness = 1.dp, @@ -267,10 +278,10 @@ fun AppSettingsScreen( StyledToggle( label = stringResource(R.string.takeover_music), description = stringResource(R.string.takeover_music_desc), - checked = uiState.takeoverWhenMusic, + checked = state.takeoverWhenMusic, onCheckedChange = viewModel::setTakeoverWhenMusic, independent = false, - enabled = uiState.isPremium + enabled = state.isPremium ) HorizontalDivider( thickness = 1.dp, @@ -281,10 +292,10 @@ fun AppSettingsScreen( StyledToggle( label = stringResource(R.string.takeover_call), description = stringResource(R.string.takeover_call_desc), - checked = uiState.takeoverWhenCall, + checked = state.takeoverWhenCall, onCheckedChange = viewModel::setTakeoverWhenCall, independent = false, - enabled = uiState.isPremium + enabled = state.isPremium ) } @@ -310,10 +321,10 @@ fun AppSettingsScreen( StyledToggle( label = stringResource(R.string.takeover_ringing_call), description = stringResource(R.string.takeover_ringing_call_desc), - checked = uiState.takeoverWhenRingingCall, + checked = state.takeoverWhenRingingCall, onCheckedChange = viewModel::setTakeoverWhenRingingCall, independent = false, - enabled = uiState.isPremium + enabled = state.isPremium ) HorizontalDivider( thickness = 1.dp, @@ -324,10 +335,10 @@ fun AppSettingsScreen( StyledToggle( label = stringResource(R.string.takeover_media_start), description = stringResource(R.string.takeover_media_start_desc), - checked = uiState.takeoverWhenMediaStart, + checked = state.takeoverWhenMediaStart, onCheckedChange = viewModel::setTakeoverWhenMediaStart, independent = false, - enabled = uiState.isPremium + enabled = state.isPremium ) } @@ -345,13 +356,31 @@ fun AppSettingsScreen( StyledToggle( label = stringResource(R.string.use_alternate_head_tracking_packets), description = stringResource(R.string.use_alternate_head_tracking_packets_description), - checked = uiState.useAlternateHeadTrackingPackets, + checked = state.useAlternateHeadTrackingPackets, onCheckedChange = viewModel::setUseAlternateHeadTrackingPackets, independent = true, - enabled = uiState.isPremium + enabled = state.isPremium ) - Spacer(modifier = Modifier.height(16.dp)) + + if (BuildConfig.FLAVOR == "xposed") { + Spacer(modifier = Modifier.height(16.dp)) + val restartBluetoothText = stringResource(R.string.found_offset_restart_bluetooth) + StyledToggle( + label = stringResource(R.string.act_as_an_apple_device), + description = stringResource(R.string.act_as_an_apple_device_description) + "\n" + stringResource( + R.string.requires_xposed + ).replaceFirstChar { if (it.isLowerCase()) it.titlecase(getDefault()) else it.toString() }, + checked = state.vendorIdHook, + onCheckedChange = { enabled -> + Toast.makeText(context, restartBluetoothText, Toast.LENGTH_SHORT).show() + viewModel.setVendorIdHook(enabled) + }, + independent = true, + enabled = state.isPremium + ) + } + // NavigationButton( // to = "troubleshooting", @@ -361,6 +390,229 @@ fun AppSettingsScreen( // description = stringResource(R.string.troubleshooting_description) // ) + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.contact), 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(4.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .background( + backgroundColor, RoundedCornerShape(28.dp) + ) + .clip(RoundedCornerShape(28.dp)) + ) { + NavigationButton( + to = "", + name = stringResource(R.string.email), + navController = navController, + onClick = { + 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_TEXT, + "\n\n\n----------" + + "\nPhone details:" + + "\nDEVICE: ${Build.DEVICE}" + + "\nMANUFACTURER: ${Build.MANUFACTURER} (${Build.BRAND})" + + "\nMODEL: ${Build.MODEL} (${Build.PRODUCT})" + + "\nVERSION: ${Build.DISPLAY} (${Build.VERSION.SDK_INT_FULL})" + + "\n\nApp details:" + + "\nVERSION: ${BuildConfig.VERSION_NAME}" + + "\nVERSION_CODE: ${BuildConfig.VERSION_CODE}" + + "\nFLAVOR: ${BuildConfig.FLAVOR}" + + "\nBUILD_TYPE: ${BuildConfig.BUILD_TYPE}" + ) + } + context.startActivity(intent) + }, + independent = false + ) + + HorizontalDivider( + thickness = 1.dp, + color = Color(0x40888888), + modifier = Modifier.padding(horizontal = 12.dp) + ) + + NavigationButton( + to = "", + name = stringResource(R.string.discord), + navController = navController, + onClick = { + val intent = + Intent(Intent.ACTION_VIEW, "https://discord.gg/Ts4wupXcmc".toUri()) + context.startActivity(intent) + }, + independent = false + ) + + HorizontalDivider( + thickness = 1.dp, + color = Color(0x40888888), + modifier = Modifier.padding(horizontal = 12.dp) + ) + + NavigationButton( + to = "", + name = stringResource(R.string.github_issues), + navController = navController, + onClick = { + val intent = Intent( + Intent.ACTION_VIEW, + "https://github.com/kavishdevar/librepods/issues".toUri() + ) + context.startActivity(intent) + }, + independent = false + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.about), 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) + ) + + val rowHeight = remember { mutableStateOf(0.dp) } + val density = LocalDensity.current + + Spacer(modifier = Modifier.height(4.dp)) + Column( + modifier = Modifier + .clip(RoundedCornerShape(28.dp)) + .fillMaxWidth() + .background(backgroundColor, RoundedCornerShape(28.dp)) + .padding(top = 2.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.version), style = TextStyle( + fontSize = 16.sp, + color = textColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + Text( + text = BuildConfig.VERSION_NAME, 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_code), style = TextStyle( + fontSize = 16.sp, + color = textColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + Text( + text = BuildConfig.VERSION_CODE.toString(), 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.flavor), style = TextStyle( + fontSize = 16.sp, + color = textColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + Text( + text = BuildConfig.FLAVOR, 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_type), style = TextStyle( + fontSize = 16.sp, + color = textColor, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + Text( + text = BuildConfig.BUILD_TYPE, + 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)) + ) + ) + } + } + Spacer(modifier = Modifier.height(16.dp)) NavigationButton( @@ -370,9 +622,9 @@ fun AppSettingsScreen( independent = true ) - Spacer(modifier = Modifier.height(32.dp)) + Spacer(modifier = Modifier.height(bottomPadding)) - if (uiState.showCameraDialog) { + if (state.showCameraDialog) { AlertDialog(onDismissRequest = { viewModel.setShowCameraDialog(false) }, title = { Text( stringResource(R.string.set_custom_camera_package), @@ -388,13 +640,13 @@ fun AppSettingsScreen( ) OutlinedTextField( - value = uiState.cameraPackageValue, + value = state.cameraPackageValue, onValueChange = { viewModel.setCameraPackageValue(it) viewModel.setCameraPackageError(null) }, modifier = Modifier.fillMaxWidth(), - isError = uiState.cameraPackageError != null, + isError = state.cameraPackageError != null, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Ascii, capitalization = KeyboardCapitalization.None @@ -406,9 +658,9 @@ fun AppSettingsScreen( unfocusedBorderColor = if (isDarkTheme) Color.Gray else Color.LightGray ), supportingText = { - if (uiState.cameraPackageError != null) { + if (state.cameraPackageError != null) { Text( - uiState.cameraPackageError ?: "", + state.cameraPackageError ?: "", color = MaterialTheme.colorScheme.error ) } @@ -439,7 +691,6 @@ fun AppSettingsScreen( } }) } - Spacer(modifier = Modifier.height(bottomPadding)) } } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/CameraControlScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/CameraControlScreen.kt similarity index 90% rename from android/app/src/main/java/me/kavishdevar/librepods/screens/CameraControlScreen.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/CameraControlScreen.kt index 4c670f5..433e7b3 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/CameraControlScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/CameraControlScreen.kt @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.screens +package me.kavishdevar.librepods.presentation.screens import android.accessibilityservice.AccessibilityServiceInfo import android.content.ComponentName @@ -41,12 +41,12 @@ import androidx.compose.ui.unit.dp import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.composables.SelectItem -import me.kavishdevar.librepods.composables.StyledScaffold -import me.kavishdevar.librepods.composables.StyledSelectList +import me.kavishdevar.librepods.presentation.components.SelectItem +import me.kavishdevar.librepods.presentation.components.StyledScaffold +import me.kavishdevar.librepods.presentation.components.StyledSelectList import me.kavishdevar.librepods.services.AppListenerService -import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressType -import me.kavishdevar.librepods.viewmodel.AirPodsViewModel +import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.StemPressType +import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel @Composable fun CameraControlScreen(viewModel: AirPodsViewModel) { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/DebugScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/DebugScreen.kt similarity index 98% rename from android/app/src/main/java/me/kavishdevar/librepods/screens/DebugScreen.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/DebugScreen.kt index 3ca7190..75c4bc2 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/DebugScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/DebugScreen.kt @@ -18,7 +18,7 @@ @file:OptIn(ExperimentalHazeMaterialsApi::class, ExperimentalEncodingApi::class) -package me.kavishdevar.librepods.screens +package me.kavishdevar.librepods.presentation.screens import android.annotation.SuppressLint import android.content.ClipData @@ -82,10 +82,10 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import kotlinx.coroutines.delay import kotlinx.coroutines.launch import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.composables.StyledIconButton -import me.kavishdevar.librepods.composables.StyledScaffold -import me.kavishdevar.librepods.constants.BatteryStatus -import me.kavishdevar.librepods.constants.isHeadTrackingData +import me.kavishdevar.librepods.presentation.components.StyledIconButton +import me.kavishdevar.librepods.presentation.components.StyledScaffold +import me.kavishdevar.librepods.data.BatteryStatus +import me.kavishdevar.librepods.data.isHeadTrackingData import me.kavishdevar.librepods.services.ServiceManager import kotlin.io.encoding.ExperimentalEncodingApi diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/HeadTrackingScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeadTrackingScreen.kt similarity index 89% rename from android/app/src/main/java/me/kavishdevar/librepods/screens/HeadTrackingScreen.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeadTrackingScreen.kt index 5576b79..6137ec1 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/HeadTrackingScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeadTrackingScreen.kt @@ -21,8 +21,12 @@ @file:OptIn(ExperimentalEncodingApi::class) -package me.kavishdevar.librepods.screens +package me.kavishdevar.librepods.presentation.screens +import android.graphics.Paint +import android.graphics.RadialGradient +import android.graphics.Shader +import android.graphics.Typeface import android.util.Log import androidx.compose.animation.AnimatedContent import androidx.compose.animation.ExperimentalAnimationApi @@ -72,7 +76,6 @@ import androidx.compose.ui.graphics.asAndroidPath import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.drawText @@ -83,6 +86,7 @@ import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.navigation.NavController import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop import dev.chrisbanes.haze.hazeSource @@ -91,13 +95,13 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.composables.StyledButton -import me.kavishdevar.librepods.composables.StyledIconButton -import me.kavishdevar.librepods.composables.StyledScaffold -import me.kavishdevar.librepods.composables.StyledToggle +import me.kavishdevar.librepods.presentation.components.StyledButton +import me.kavishdevar.librepods.presentation.components.StyledIconButton +import me.kavishdevar.librepods.presentation.components.StyledScaffold +import me.kavishdevar.librepods.presentation.components.StyledToggle import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.utils.HeadTracking -import me.kavishdevar.librepods.viewmodel.AirPodsViewModel +import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.math.abs import kotlin.math.cos @@ -107,7 +111,7 @@ import kotlin.random.Random @ExperimentalHazeMaterialsApi @OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class) @Composable -fun HeadTrackingScreen(viewModel: AirPodsViewModel) { +fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController) { val state by viewModel.uiState.collectAsState() DisposableEffect(Unit) { viewModel.startHeadTracking() @@ -163,25 +167,23 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel) { ) { Spacer(modifier = Modifier.height(topPadding)) - val context = LocalContext.current - if (!state.isPremium) { StyledButton( onClick = { - viewModel.purchase(context) + navController.navigate("purchase_screen") }, backdrop = rememberLayerBackdrop(), modifier = Modifier.fillMaxWidth(), maxScale = 0.05f, - tint = Color(0xFF916100) + tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) ) { Text( - stringResource(R.string.unlock_all_features), + stringResource(R.string.unlock_advanced_features), style = TextStyle( fontSize = 16.sp, fontWeight = FontWeight.Medium, fontFamily = FontFamily(Font(R.font.sf_pro)), - color = textColor + color = Color.White ), ) } @@ -192,31 +194,20 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel) { label = "Head Gestures", checked = state.headGesturesEnabled, onCheckedChange = { viewModel.setHeadGesturesEnabled(it) }, - enabled = state.isPremium - ) - - Spacer(modifier = Modifier.height(2.dp)) - Text( - stringResource(R.string.head_gestures_details), - style = TextStyle( - fontSize = 14.sp, - fontWeight = FontWeight.Normal, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = textColor.copy(0.6f) - ), - modifier = Modifier.padding(start = 4.dp) + enabled = state.isPremium, + description = stringResource(R.string.head_gestures_details) ) Spacer(modifier = Modifier.height(16.dp)) Text( "Head Orientation", style = TextStyle( - fontSize = 18.sp, - fontWeight = FontWeight.Medium, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = textColor + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = textColor.copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) ), - modifier = Modifier.padding(start = 4.dp, bottom = 8.dp, top = 8.dp) + modifier = Modifier.padding(start = 16.dp, bottom = 8.dp, top = 8.dp) ) HeadVisualization() @@ -224,12 +215,12 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel) { Text( "Velocity", style = TextStyle( - fontSize = 18.sp, - fontWeight = FontWeight.Medium, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = textColor + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = textColor.copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) ), - modifier = Modifier.padding(start = 4.dp, bottom = 8.dp, top = 8.dp) + modifier = Modifier.padding(start = 16.dp, bottom = 8.dp, top = 8.dp) ) AccelerationPlot() @@ -481,9 +472,9 @@ private fun HeadVisualization() { spherePath.close() drawContext.canvas.nativeCanvas.apply { - val paint = android.graphics.Paint().apply { - style = android.graphics.Paint.Style.FILL - shader = android.graphics.RadialGradient( + val paint = Paint().apply { + style = Paint.Style.FILL + shader = RadialGradient( center.x + sinY * faceRadius * 0.3f, center.y - sinP * faceRadius * 0.3f, faceRadius * 1.4f, @@ -495,14 +486,14 @@ private fun HeadVisualization() { backgroundColor.copy(alpha = 0.7f).toArgb() ), floatArrayOf(0.3f, 0.5f, 0.7f, 0.8f, 1f), - android.graphics.Shader.TileMode.CLAMP + Shader.TileMode.CLAMP ) } drawPath(spherePath.asAndroidPath(), paint) - val highlightPaint = android.graphics.Paint().apply { - style = android.graphics.Paint.Style.FILL - shader = android.graphics.RadialGradient( + val highlightPaint = Paint().apply { + style = Paint.Style.FILL + shader = RadialGradient( center.x - faceRadius * 0.4f - sinY * faceRadius * 0.5f, center.y - faceRadius * 0.4f - sinP * faceRadius * 0.5f, faceRadius * 0.9f, @@ -512,15 +503,15 @@ private fun HeadVisualization() { android.graphics.Color.TRANSPARENT ), floatArrayOf(0f, 0.3f, 1f), - android.graphics.Shader.TileMode.CLAMP + Shader.TileMode.CLAMP ) alpha = if (darkTheme) 30 else 60 } drawPath(spherePath.asAndroidPath(), highlightPaint) - val secondaryHighlightPaint = android.graphics.Paint().apply { - style = android.graphics.Paint.Style.FILL - shader = android.graphics.RadialGradient( + val secondaryHighlightPaint = Paint().apply { + style = Paint.Style.FILL + shader = RadialGradient( center.x + faceRadius * 0.3f + sinY * faceRadius * 0.3f, center.y + faceRadius * 0.3f - sinP * faceRadius * 0.3f, faceRadius * 0.7f, @@ -529,15 +520,15 @@ private fun HeadVisualization() { android.graphics.Color.TRANSPARENT ), floatArrayOf(0f, 1f), - android.graphics.Shader.TileMode.CLAMP + Shader.TileMode.CLAMP ) alpha = if (darkTheme) 15 else 30 } drawPath(spherePath.asAndroidPath(), secondaryHighlightPaint) - val shadowPaint = android.graphics.Paint().apply { - style = android.graphics.Paint.Style.FILL - shader = android.graphics.RadialGradient( + val shadowPaint = Paint().apply { + style = Paint.Style.FILL + shader = RadialGradient( center.x + sinY * faceRadius * 0.5f, center.y - sinP * faceRadius * 0.5f, faceRadius * 1.1f, @@ -546,7 +537,7 @@ private fun HeadVisualization() { android.graphics.Color.BLACK ), floatArrayOf(0.7f, 1f), - android.graphics.Shader.TileMode.CLAMP + Shader.TileMode.CLAMP ) alpha = if (darkTheme) 40 else 20 } @@ -606,13 +597,13 @@ private fun HeadVisualization() { } drawContext.canvas.nativeCanvas.apply { - val paint = android.graphics.Paint().apply { + val paint = Paint().apply { color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK textSize = 12.sp.toPx() - textAlign = android.graphics.Paint.Align.RIGHT - typeface = android.graphics.Typeface.create( + textAlign = Paint.Align.RIGHT + typeface = Typeface.create( "SF Pro", - android.graphics.Typeface.NORMAL + Typeface.NORMAL ) } @@ -726,10 +717,10 @@ private fun AccelerationPlot() { } drawContext.canvas.nativeCanvas.apply { - val paint = android.graphics.Paint().apply { + val paint = Paint().apply { color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK textSize = 12.sp.toPx() - textAlign = android.graphics.Paint.Align.RIGHT + textAlign = Paint.Align.RIGHT } drawText("${maxAbs.toInt()}", 30.dp.toPx(), 20.dp.toPx(), paint) @@ -742,20 +733,20 @@ private fun AccelerationPlot() { drawCircle(Color(0xFF007AFF), 5.dp.toPx(), Offset(width - 150.dp.toPx(), legendY)) drawContext.canvas.nativeCanvas.apply { - val paint = android.graphics.Paint().apply { + val paint = Paint().apply { color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK textSize = 12.sp.toPx() - textAlign = android.graphics.Paint.Align.LEFT + textAlign = Paint.Align.LEFT } drawText("Horizontal", width - 140.dp.toPx(), textOffsetY, paint) } drawCircle(Color(0xFFFF3B30), 5.dp.toPx(), Offset(width - 70.dp.toPx(), legendY)) drawContext.canvas.nativeCanvas.apply { - val paint = android.graphics.Paint().apply { + val paint = Paint().apply { color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK textSize = 12.sp.toPx() - textAlign = android.graphics.Paint.Align.LEFT + textAlign = Paint.Align.LEFT } drawText("Vertical", width - 60.dp.toPx(), textOffsetY, paint) } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidAdjustmentsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingAidAdjustmentsScreen.kt similarity index 95% rename from android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidAdjustmentsScreen.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingAidAdjustmentsScreen.kt index 8fe06b7..92dff14 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidAdjustmentsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingAidAdjustmentsScreen.kt @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.screens +package me.kavishdevar.librepods.presentation.screens import android.annotation.SuppressLint import android.util.Log @@ -50,16 +50,16 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import kotlinx.coroutines.Job import kotlinx.coroutines.delay import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.composables.StyledScaffold -import me.kavishdevar.librepods.composables.StyledSlider -import me.kavishdevar.librepods.composables.StyledToggle +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.services.ServiceManager -import me.kavishdevar.librepods.utils.AACPManager -import me.kavishdevar.librepods.utils.ATTHandles -import me.kavishdevar.librepods.utils.HearingAidSettings -import me.kavishdevar.librepods.utils.parseHearingAidSettingsResponse -import me.kavishdevar.librepods.utils.sendHearingAidSettings -import me.kavishdevar.librepods.viewmodel.AirPodsViewModel +import me.kavishdevar.librepods.bluetooth.AACPManager +import me.kavishdevar.librepods.bluetooth.ATTHandles +import me.kavishdevar.librepods.data.HearingAidSettings +import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse +import me.kavishdevar.librepods.data.sendHearingAidSettings +import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel import java.io.IOException import kotlin.io.encoding.ExperimentalEncodingApi diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingAidScreen.kt similarity index 92% rename from android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidScreen.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingAidScreen.kt index 5c292a1..753c289 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingAidScreen.kt @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.screens +package me.kavishdevar.librepods.presentation.screens import android.annotation.SuppressLint import android.util.Log @@ -62,15 +62,14 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.composables.ConfirmationDialog -import me.kavishdevar.librepods.composables.NavigationButton -import me.kavishdevar.librepods.composables.StyledScaffold -import me.kavishdevar.librepods.composables.StyledToggle -import me.kavishdevar.librepods.utils.AACPManager -import me.kavishdevar.librepods.utils.ATTHandles -import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse -import me.kavishdevar.librepods.utils.sendTransparencySettings -import me.kavishdevar.librepods.viewmodel.AirPodsViewModel +import me.kavishdevar.librepods.bluetooth.AACPManager +import me.kavishdevar.librepods.data.parseTransparencySettingsResponse +import me.kavishdevar.librepods.data.sendTransparencySettings +import me.kavishdevar.librepods.presentation.components.ConfirmationDialog +import me.kavishdevar.librepods.presentation.components.NavigationButton +import me.kavishdevar.librepods.presentation.components.StyledScaffold +import me.kavishdevar.librepods.presentation.components.StyledToggle +import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel import kotlin.io.encoding.ExperimentalEncodingApi private const val TAG = "AccessibilitySettings" @@ -174,7 +173,7 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController) NavigationButton( to = "hearing_aid_adjustments", name = stringResource(R.string.adjustments), - navController, + navController = navController, independent = false ) } @@ -193,7 +192,7 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController) NavigationButton( to = "update_hearing_test", name = stringResource(R.string.update_hearing_test), - navController, + navController = navController, independent = true ) @@ -237,7 +236,6 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController) Spacer(modifier = Modifier.height(bottomPadding)) } } - ConfirmationDialog( showDialog = showDialog, title = "Enable Hearing Aid", @@ -256,12 +254,11 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController) hearingAidEnabled.value = true CoroutineScope(Dispatchers.IO).launch { try { - val data = viewModel.getATTCharacteristicValue(ATTHandles.TRANSPARENCY) ?: byteArrayOf() - if (data.isEmpty()) { + if (state.hearingAidData.isEmpty()) { Log.w(TAG, "read failed") return@launch } - val parsed = parseTransparencySettingsResponse(data) + val parsed = parseTransparencySettingsResponse(state.hearingAidData) val disabledSettings = parsed.copy(enabled = false) sendTransparencySettings(viewModel::setATTCharacteristicValue, disabledSettings) } catch (e: Exception) { @@ -269,6 +266,10 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController) } } }, + onDismiss = { + hearingAidEnabled.value = false + showDialog.value = false + }, hazeState = hazeStateS.value, // backdrop = backdrop ) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingProtectionScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingProtectionScreen.kt similarity index 83% rename from android/app/src/main/java/me/kavishdevar/librepods/screens/HearingProtectionScreen.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingProtectionScreen.kt index 9e6cf28..7bbbfd5 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingProtectionScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingProtectionScreen.kt @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.screens +package me.kavishdevar.librepods.presentation.screens import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -33,11 +33,11 @@ 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.composables.StyledScaffold -import me.kavishdevar.librepods.composables.StyledToggle -import me.kavishdevar.librepods.utils.AACPManager -import me.kavishdevar.librepods.utils.ATTHandles -import me.kavishdevar.librepods.viewmodel.AirPodsViewModel +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.viewmodel.AirPodsViewModel @Composable fun HearingProtectionScreen(viewModel: AirPodsViewModel) { @@ -54,20 +54,19 @@ fun HearingProtectionScreen(viewModel: AirPodsViewModel) { ) { Spacer(modifier = Modifier.height(spacerHeight)) - if (BuildConfig.FLAVOR == "xposed") { + if (state.vendorIdHook) { StyledToggle( title = stringResource(R.string.environmental_noise), label = stringResource(R.string.loud_sound_reduction), description = stringResource(R.string.loud_sound_reduction_description), - checked = viewModel.getATTCharacteristicValue(ATTHandles.LOUD_SOUND_REDUCTION) - ?.get(0)?.toInt() == 1, + checked = state.loudSoundReductionEnabled, onCheckedChange = { viewModel.setATTCharacteristicValue( ATTHandles.LOUD_SOUND_REDUCTION, byteArrayOf(if (it) 1.toByte() else 0.toByte()) ) - } -// attHandle = ATTHandles.LOUD_SOUND_REDUCTION + }, + enabled = state.isPremium ) Spacer(modifier = Modifier.height(12.dp)) @@ -83,7 +82,9 @@ fun HearingProtectionScreen(viewModel: AirPodsViewModel) { viewModel.setControlCommandBoolean( AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG, it ) - }) + }, + enabled = state.isPremium + ) } } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/OpenSourceLicensesScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/OpenSourceLicensesScreen.kt similarity index 80% rename from android/app/src/main/java/me/kavishdevar/librepods/screens/OpenSourceLicensesScreen.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/OpenSourceLicensesScreen.kt index 3a8aa38..8f57cdb 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/OpenSourceLicensesScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/OpenSourceLicensesScreen.kt @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.screens +package me.kavishdevar.librepods.presentation.screens import android.annotation.SuppressLint import androidx.compose.foundation.isSystemInDarkTheme @@ -28,12 +28,9 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.remember import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.navigation.NavController @@ -42,17 +39,9 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer import com.mikepenz.aboutlibraries.ui.compose.produceLibraries import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.composables.StyledIconButton -import me.kavishdevar.librepods.composables.StyledScaffold -import me.kavishdevar.librepods.composables.StyledSlider -import me.kavishdevar.librepods.services.ServiceManager -import me.kavishdevar.librepods.utils.AACPManager +import me.kavishdevar.librepods.presentation.components.StyledScaffold import kotlin.io.encoding.ExperimentalEncodingApi private var debounceJob: Job? = null @@ -76,7 +65,7 @@ fun OpenSourceLicensesScreen(navController: NavController) { verticalArrangement = Arrangement.spacedBy(16.dp) ) { Spacer(modifier = Modifier.height(spacerHeight)) - val context = androidx.compose.ui.platform.LocalContext.current + val context = LocalContext.current val libraries by produceLibraries { context.resources.openRawResource(R.raw.aboutlibraries) .bufferedReader() @@ -90,4 +79,4 @@ fun OpenSourceLicensesScreen(navController: NavController) { ) } } -} \ No newline at end of file +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/PressAndHoldSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/PressAndHoldSettingsScreen.kt similarity index 93% rename from android/app/src/main/java/me/kavishdevar/librepods/screens/PressAndHoldSettingsScreen.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/PressAndHoldSettingsScreen.kt index 096631c..77dd621 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/PressAndHoldSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/PressAndHoldSettingsScreen.kt @@ -18,7 +18,7 @@ @file:OptIn(ExperimentalStdlibApi::class, ExperimentalEncodingApi::class) -package me.kavishdevar.librepods.screens +package me.kavishdevar.librepods.presentation.screens import android.content.Context import android.util.Log @@ -49,25 +49,26 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.edit +import androidx.navigation.NavController import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.composables.SelectItem -import me.kavishdevar.librepods.composables.StyledButton -import me.kavishdevar.librepods.composables.StyledScaffold -import me.kavishdevar.librepods.composables.StyledSelectList -import me.kavishdevar.librepods.constants.StemAction +import me.kavishdevar.librepods.presentation.components.SelectItem +import me.kavishdevar.librepods.presentation.components.StyledButton +import me.kavishdevar.librepods.presentation.components.StyledScaffold +import me.kavishdevar.librepods.presentation.components.StyledSelectList +import me.kavishdevar.librepods.data.StemAction import me.kavishdevar.librepods.services.ServiceManager -import me.kavishdevar.librepods.utils.AACPManager -import me.kavishdevar.librepods.viewmodel.AirPodsViewModel +import me.kavishdevar.librepods.bluetooth.AACPManager +import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel import kotlin.experimental.and import kotlin.io.encoding.ExperimentalEncodingApi @ExperimentalHazeMaterialsApi @OptIn(ExperimentalMaterial3Api::class) @Composable -fun LongPress(viewModel: AirPodsViewModel, name: String) { +fun LongPress(viewModel: AirPodsViewModel, name: String, navController: NavController) { val isDarkTheme = isSystemInDarkTheme() val textColor = if (isDarkTheme) Color.White else Color.Black @@ -124,20 +125,20 @@ fun LongPress(viewModel: AirPodsViewModel, name: String) { Spacer(modifier = Modifier.height(24.dp)) StyledButton( onClick = { - viewModel.purchase(context) + navController.navigate("purchase_screen") }, backdrop = rememberLayerBackdrop(), modifier = Modifier.fillMaxWidth(), maxScale = 0.05f, - tint = Color(0xFF916100) + tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) ) { Text( - stringResource(R.string.unlock_all_features), + stringResource(R.string.unlock_advanced_features), style = TextStyle( fontSize = 16.sp, fontWeight = FontWeight.Medium, fontFamily = FontFamily(Font(R.font.sf_pro)), - color = textColor + color = Color.White ), ) } @@ -152,6 +153,7 @@ fun LongPress(viewModel: AirPodsViewModel, name: String) { fontSize = 14.sp, fontWeight = FontWeight.Bold, color = textColor.copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) ), fontFamily = FontFamily(Font(R.font.sf_pro)), modifier = Modifier 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 new file mode 100644 index 0000000..5655843 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/PurchaseScreen.kt @@ -0,0 +1,496 @@ +/* + LibrePods - AirPods liberated from Apple’s ecosystem + Copyright (C) 2025 LibrePods contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +package me.kavishdevar.librepods.presentation.screens + +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +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.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import dev.chrisbanes.haze.hazeSource +import me.kavishdevar.librepods.BuildConfig +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.presentation.components.StyledButton +import me.kavishdevar.librepods.presentation.components.StyledScaffold +import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel + +@Composable +fun PurchaseScreen( + viewModel: PurchaseViewModel = viewModel(), + navController: NavController +) { + val context = LocalContext.current + val scrollState = rememberScrollState() + val state by viewModel.uiState.collectAsState() + + val backdrop = rememberLayerBackdrop() + + StyledScaffold( + title = stringResource(R.string.unlock_advanced_features) + ) { topPadding, hazeState, bottomPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .layerBackdrop(backdrop) + .hazeSource(state = hazeState) + .verticalScroll(scrollState) + .padding(horizontal = 16.dp) + ) { + Spacer(modifier = Modifier.height(topPadding)) + + val isDarkTheme = isSystemInDarkTheme() + val backgroundColor = if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7) + val cardBackgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + val textColor = if (isDarkTheme) Color.White else Color.Black + LaunchedEffect(state.isPremium) { + if (state.isPremium) { + navController.popBackStack() + } + } + if (!state.isPremium) { + Box( + modifier = Modifier + .background(backgroundColor) + .padding(horizontal = 16.dp, vertical = 4.dp) + ) { + Text( + text = "Free features", + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = textColor.copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + } + Column( + modifier = Modifier + .fillMaxWidth() + .background(cardBackgroundColor, RoundedCornerShape(28.dp)) + .padding(horizontal = 8.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = stringResource(R.string.ear_detection), + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + fontFamily = FontFamily(Font(R.font.sf_pro)), + color = textColor + ) + ) + Text( + text = stringResource(R.string.ear_detection_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() + .padding(horizontal = 12.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = stringResource(R.string.battery), + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + fontFamily = FontFamily(Font(R.font.sf_pro)), + color = textColor + ) + ) + Text( + text = stringResource(R.string.battery_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() + .padding(horizontal = 12.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = stringResource(R.string.noise_control), + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + fontFamily = FontFamily(Font(R.font.sf_pro)), + color = textColor + ) + ) + Text( + text = stringResource(R.string.noise_control_description), + style = TextStyle( + fontSize = 12.sp, + color = textColor.copy(0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)), + ) + ) + } + if (BuildConfig.FLAVOR == "xposed") { + HorizontalDivider( + thickness = 1.dp, + color = Color(0x40888888), + 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.hearing_aid) + " (" + stringResource( + R.string.requires_xposed + ) + ")", + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + fontFamily = FontFamily(Font(R.font.sf_pro)), + color = textColor + ) + ) + Text( + text = stringResource(R.string.hearing_aid_description).split("\n\n")[0], + style = TextStyle( + fontSize = 12.sp, + color = textColor.copy(0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)), + ) + ) + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + Box( + modifier = Modifier + .background(backgroundColor) + .padding(horizontal = 16.dp, vertical = 4.dp) + ) { + Text( + text = "Advanced features", + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = textColor.copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + } + Column( + modifier = Modifier + .fillMaxWidth() + .background(cardBackgroundColor, RoundedCornerShape(28.dp)) + .padding(horizontal = 8.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = stringResource(R.string.conversational_awareness), + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + fontFamily = FontFamily(Font(R.font.sf_pro)), + color = textColor + ) + ) + Text( + text = stringResource(R.string.conversational_awareness_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() + .padding(horizontal = 12.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = stringResource(R.string.head_gestures), + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + fontFamily = FontFamily(Font(R.font.sf_pro)), + color = textColor + ) + ) + Text( + text = stringResource(R.string.head_gestures_details), + 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() + .padding(horizontal = 12.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = stringResource(R.string.advanced_device_settings), + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + fontFamily = FontFamily(Font(R.font.sf_pro)), + color = textColor + ) + ) + Text( + text = stringResource(R.string.advanced_device_settings_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() + .padding(horizontal = 12.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = stringResource(R.string.automatic_connection), + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + fontFamily = FontFamily(Font(R.font.sf_pro)), + color = textColor + ) + ) + Text( + text = stringResource(R.string.automatic_connection_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() + .padding(horizontal = 12.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = stringResource(R.string.customizations), + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + fontFamily = FontFamily(Font(R.font.sf_pro)), + color = textColor + ) + ) + Text( + text = stringResource(R.string.customizations_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() + .padding(horizontal = 12.dp, vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = stringResource(R.string.support_the_development), + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Normal, + fontFamily = FontFamily(Font(R.font.sf_pro)), + color = textColor + ) + ) + Text( + text = stringResource(R.string.support_development_description), + style = TextStyle( + fontSize = 12.sp, + color = textColor.copy(0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)), + ) + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.feature_availability_disclaimer), + modifier = Modifier.fillMaxWidth(), + style = TextStyle( + fontSize = 14.sp, + fontWeight = FontWeight.Light, + fontFamily = FontFamily(Font(R.font.sf_pro)), + color = textColor.copy(alpha = 0.6f), + textAlign = TextAlign.Center + ), + ) + + + Spacer(modifier = Modifier.height(24.dp)) + + StyledButton( + onClick = { + viewModel.purchase(context) + }, + backdrop = rememberLayerBackdrop(), + modifier = Modifier.fillMaxWidth(), + maxScale = 0.05f, + tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) + ) { + Text( + stringResource(R.string.buy), + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + fontFamily = FontFamily(Font(R.font.sf_pro)), + color = Color.White + ), + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + StyledButton( + onClick = { + viewModel.restorePurchases() + }, + backdrop = rememberLayerBackdrop(), + modifier = Modifier.fillMaxWidth(), + maxScale = 0.05f, + ) { + Text( + stringResource(R.string.restore_purchases), + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + fontFamily = FontFamily(Font(R.font.sf_pro)), + color = textColor + ), + ) + } + } + Spacer(modifier = Modifier.height(bottomPadding)) + } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/RenameScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/RenameScreen.kt similarity index 97% rename from android/app/src/main/java/me/kavishdevar/librepods/screens/RenameScreen.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/RenameScreen.kt index 829bdd6..8fc352e 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/RenameScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/RenameScreen.kt @@ -18,7 +18,7 @@ @file:OptIn(ExperimentalEncodingApi::class) -package me.kavishdevar.librepods.screens +package me.kavishdevar.librepods.presentation.screens import android.content.Context import androidx.compose.foundation.background @@ -60,8 +60,8 @@ import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.composables.StyledScaffold -import me.kavishdevar.librepods.viewmodel.AirPodsViewModel +import me.kavishdevar.librepods.presentation.components.StyledScaffold +import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel import kotlin.io.encoding.ExperimentalEncodingApi diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/TransparencySettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/TransparencySettingsScreen.kt similarity index 89% rename from android/app/src/main/java/me/kavishdevar/librepods/screens/TransparencySettingsScreen.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/TransparencySettingsScreen.kt index fedb250..fd7a893 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/TransparencySettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/TransparencySettingsScreen.kt @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.screens +package me.kavishdevar.librepods.presentation.screens // import me.kavishdevar.librepods.utils.RadareOffsetFinder import android.annotation.SuppressLint @@ -42,7 +42,6 @@ import androidx.compose.material3.Slider import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -68,15 +67,13 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import kotlinx.coroutines.delay import me.kavishdevar.librepods.BuildConfig import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.composables.StyledScaffold -import me.kavishdevar.librepods.composables.StyledSlider -import me.kavishdevar.librepods.composables.StyledToggle -import me.kavishdevar.librepods.services.ServiceManager -import me.kavishdevar.librepods.utils.ATTHandles -import me.kavishdevar.librepods.utils.TransparencySettings -import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse -import me.kavishdevar.librepods.utils.sendTransparencySettings -import me.kavishdevar.librepods.viewmodel.AirPodsViewModel +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.data.TransparencySettings +import me.kavishdevar.librepods.data.parseTransparencySettingsResponse +import me.kavishdevar.librepods.data.sendTransparencySettings +import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel import java.io.IOException import kotlin.io.encoding.ExperimentalEncodingApi @@ -91,8 +88,6 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) { val textColor = if (isDarkTheme) Color.White else Color.Black val verticalScrollState = rememberScrollState() - val attManager = ServiceManager.getService()?.attManager ?: return - 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) @@ -151,23 +146,6 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) { ) } - val transparencyListener = remember { - object : (ByteArray) -> Unit { - override fun invoke(value: ByteArray) { - val parsed = parseTransparencySettingsResponse(value) - enabled.value = parsed.enabled - amplificationSliderValue.floatValue = parsed.netAmplification - balanceSliderValue.floatValue = parsed.balance - toneSliderValue.floatValue = parsed.leftTone - ambientNoiseReductionSliderValue.floatValue = - parsed.leftAmbientNoiseReduction - conversationBoostEnabled.value = parsed.leftConversationBoost - eq.value = parsed.leftEQ.copyOf() - Log.d(TAG, "Updated transparency settings from notification") - } - } - } - LaunchedEffect( enabled.value, amplificationSliderValue.floatValue, @@ -211,18 +189,9 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) { sendTransparencySettings(viewModel::setATTCharacteristicValue, transparencySettings.value) } - DisposableEffect(Unit) { - onDispose { - attManager.unregisterListener(ATTHandles.TRANSPARENCY, transparencyListener) - } - } - LaunchedEffect(Unit) { Log.d(TAG, "Connecting to ATT...") try { - attManager.enableNotifications(ATTHandles.TRANSPARENCY) - attManager.registerListener(ATTHandles.TRANSPARENCY, transparencyListener) - // If we have an AACP manager, prefer its EQ data to populate EQ controls first try { Log.d(TAG, "Found AACPManager, reading cached EQ data") @@ -242,7 +211,7 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) { for (attempt in 1..3) { initialReadAttempts.intValue = attempt try { - val data = attManager.read(ATTHandles.TRANSPARENCY) + val data = state.transparencyData parsedSettings = parseTransparencySettingsResponse(data = data) Log.d(TAG, "Parsed settings on attempt $attempt") } catch (e: Exception) { @@ -275,8 +244,7 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) { } } - // Only show transparency mode section if SDP offset is available - if (BuildConfig.FLAVOR == "xposed") { + if (state.vendorIdHook) { StyledToggle( label = stringResource(R.string.transparency_mode), checked = enabled.value, diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/TroubleshootingScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/TroubleshootingScreen.kt similarity index 99% rename from android/app/src/main/java/me/kavishdevar/librepods/screens/TroubleshootingScreen.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/TroubleshootingScreen.kt index 9bec4bb..d919571 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/TroubleshootingScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/TroubleshootingScreen.kt @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.screens +package me.kavishdevar.librepods.presentation.screens import android.content.Intent import android.widget.Toast @@ -94,7 +94,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.composables.StyledScaffold +import me.kavishdevar.librepods.presentation.components.StyledScaffold import me.kavishdevar.librepods.utils.LogCollector import java.io.File import java.text.SimpleDateFormat diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/UpdateHearingTestScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/UpdateHearingTestScreen.kt similarity index 97% rename from android/app/src/main/java/me/kavishdevar/librepods/screens/UpdateHearingTestScreen.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/UpdateHearingTestScreen.kt index e8b6898..635c75b 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/UpdateHearingTestScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/UpdateHearingTestScreen.kt @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.screens +package me.kavishdevar.librepods.presentation.screens import android.util.Log import androidx.compose.foundation.isSystemInDarkTheme @@ -59,12 +59,12 @@ import dev.chrisbanes.haze.hazeSource import kotlinx.coroutines.Job import kotlinx.coroutines.delay import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.composables.StyledScaffold +import me.kavishdevar.librepods.presentation.components.StyledScaffold import me.kavishdevar.librepods.services.ServiceManager -import me.kavishdevar.librepods.utils.ATTHandles -import me.kavishdevar.librepods.utils.HearingAidSettings -import me.kavishdevar.librepods.utils.parseHearingAidSettingsResponse -import me.kavishdevar.librepods.utils.sendHearingAidSettings +import me.kavishdevar.librepods.bluetooth.ATTHandles +import me.kavishdevar.librepods.data.HearingAidSettings +import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse +import me.kavishdevar.librepods.data.sendHearingAidSettings import java.io.IOException private var debounceJob: MutableState = mutableStateOf(null) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/VersionInfoScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/VersionInfoScreen.kt similarity index 95% rename from android/app/src/main/java/me/kavishdevar/librepods/screens/VersionInfoScreen.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/VersionInfoScreen.kt index feadafd..2669587 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/VersionInfoScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/VersionInfoScreen.kt @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.screens +package me.kavishdevar.librepods.presentation.screens import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme @@ -48,8 +48,8 @@ import androidx.compose.ui.unit.sp import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.composables.StyledScaffold -import me.kavishdevar.librepods.viewmodel.AirPodsViewModel +import me.kavishdevar.librepods.presentation.components.StyledScaffold +import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel @Composable fun VersionScreen(viewModel: AirPodsViewModel) { @@ -80,7 +80,8 @@ fun VersionScreen(viewModel: AirPodsViewModel) { style = TextStyle( fontSize = 14.sp, fontWeight = FontWeight.Bold, - color = textColor.copy(alpha = 0.6f) + color = textColor.copy(alpha = 0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)) ) ) } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/ui/theme/Color.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/Color.kt similarity index 92% rename from android/app/src/main/java/me/kavishdevar/librepods/ui/theme/Color.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/Color.kt index 808d951..f2e44c1 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/ui/theme/Color.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/Color.kt @@ -17,7 +17,7 @@ */ -package me.kavishdevar.librepods.ui.theme +package me.kavishdevar.librepods.presentation.theme import androidx.compose.ui.graphics.Color @@ -27,4 +27,4 @@ val Pink80 = Color(0xFFEFB8C8) val Purple40 = Color(0xFF6650a4) val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFF7D5260) \ No newline at end of file +val Pink40 = Color(0xFF7D5260) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/ui/theme/Theme.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/Theme.kt similarity index 97% rename from android/app/src/main/java/me/kavishdevar/librepods/ui/theme/Theme.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/Theme.kt index cd96f1f..04225cc 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/ui/theme/Theme.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/Theme.kt @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.ui.theme +package me.kavishdevar.librepods.presentation.theme import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme diff --git a/android/app/src/main/java/me/kavishdevar/librepods/ui/theme/Type.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/Type.kt similarity index 96% rename from android/app/src/main/java/me/kavishdevar/librepods/ui/theme/Type.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/Type.kt index 80a67aa..72a4424 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/ui/theme/Type.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/Type.kt @@ -16,7 +16,7 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.ui.theme +package me.kavishdevar.librepods.presentation.theme import androidx.compose.material3.Typography import androidx.compose.ui.text.TextStyle @@ -49,4 +49,4 @@ val Typography = Typography( letterSpacing = 0.5.sp ) */ -) \ No newline at end of file +) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/viewmodel/AirPodsViewModel.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt similarity index 73% rename from android/app/src/main/java/me/kavishdevar/librepods/viewmodel/AirPodsViewModel.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt index 3b5d34a..adc527a 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/viewmodel/AirPodsViewModel.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt @@ -16,9 +16,8 @@ along with this program. If not, see . */ -package me.kavishdevar.librepods.viewmodel +package me.kavishdevar.librepods.presentation.viewmodel -import android.app.Activity import android.content.BroadcastReceiver import android.content.Context import android.content.Intent @@ -28,25 +27,29 @@ import android.util.Log import androidx.core.content.edit import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow 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.constants.AirPodsNotifications -import me.kavishdevar.librepods.constants.Battery -import me.kavishdevar.librepods.constants.BatteryComponent -import me.kavishdevar.librepods.constants.BatteryStatus -import me.kavishdevar.librepods.constants.StemAction +import me.kavishdevar.librepods.bluetooth.AACPManager +import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers +import me.kavishdevar.librepods.bluetooth.ATTHandles +import me.kavishdevar.librepods.data.AirPodsInstance +import me.kavishdevar.librepods.data.AirPodsModels +import me.kavishdevar.librepods.data.AirPodsNotifications +import me.kavishdevar.librepods.data.Battery +import me.kavishdevar.librepods.data.BatteryComponent +import me.kavishdevar.librepods.data.BatteryStatus +import me.kavishdevar.librepods.data.Capability import me.kavishdevar.librepods.data.ControlCommandRepository +import me.kavishdevar.librepods.data.StemAction +import me.kavishdevar.librepods.data.XposedRemotePrefProvider import me.kavishdevar.librepods.services.AirPodsService -import me.kavishdevar.librepods.utils.AACPManager -import me.kavishdevar.librepods.utils.AACPManager.Companion.ControlCommandIdentifiers -import me.kavishdevar.librepods.utils.ATTHandles -import me.kavishdevar.librepods.utils.AirPodsInstance -import me.kavishdevar.librepods.utils.AirPodsModels -import me.kavishdevar.librepods.utils.Capability @Suppress("ArrayInDataClass") data class AirPodsUiState( @@ -81,7 +84,12 @@ data class AirPodsUiState( val leftAction: StemAction = StemAction.CYCLE_NOISE_CONTROL_MODES, val rightAction: StemAction = StemAction.CYCLE_NOISE_CONTROL_MODES, + val loudSoundReductionEnabled: Boolean = false, + val transparencyData: ByteArray = byteArrayOf(), + val hearingAidData: ByteArray = byteArrayOf(), + val isPremium: Boolean = false, + val vendorIdHook: Boolean = false ) class AirPodsViewModel( @@ -90,8 +98,14 @@ class AirPodsViewModel( private val controlRepo: ControlCommandRepository, private val appContext: Context ) : ViewModel() { - - private val _uiState = MutableStateFlow(AirPodsUiState(deviceName = sharedPreferences.getString("name", "AirPods Pro") ?: "AirPods Pro")) + private val _uiState = MutableStateFlow( + AirPodsUiState( + deviceName = sharedPreferences.getString( + "name", + "AirPods Pro" + ) ?: "AirPods Pro" + ) + ) val uiState: StateFlow = _uiState private var isDemoMode = false @@ -99,17 +113,16 @@ class AirPodsViewModel( private var billingFirstCollectDone = false - private val listeners = mutableMapOf< - ControlCommandIdentifiers, - AACPManager.ControlCommandListener - >() + private val listeners = + mutableMapOf() + + private val xposedRemotePref = XposedRemotePrefProvider.create() private lateinit var broadcastReceiver: BroadcastReceiver private val _cameraAction = MutableStateFlow( sharedPreferences.getString("camera_action", null) - ?.let { value -> AACPManager.Companion.StemPressType.entries.find { it.name == value } } - ) + ?.let { value -> AACPManager.Companion.StemPressType.entries.find { it.name == value } }) val cameraAction: StateFlow = _cameraAction @@ -129,6 +142,7 @@ class AirPodsViewModel( setupControlObservers() observeBilling() loadControlList() + observeATT() if (isDemoMode) activateDemoMode() } @@ -148,7 +162,9 @@ class AirPodsViewModel( } private fun observeBilling() { - if (!isDemoMode) viewModelScope.launch { + if (isDemoMode) return + viewModelScope.launch { + if (!BuildConfig.PLAY_BUILD) billingFirstCollectDone = true // FOSS doesn't send multiple events BillingManager.provider.isPremium.collect { premium -> if (!billingFirstCollectDone) { billingFirstCollectDone = true @@ -156,7 +172,10 @@ class AirPodsViewModel( } if (!premium) { Log.d("AirPodsViewModel", "we are not premium") - setControlCommandBoolean(ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG, false) + setControlCommandBoolean( + ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG, + false + ) setHeadGesturesEnabled(false) } else { Log.d("AirPodsViewModel", "we are premium") @@ -183,7 +202,8 @@ class AirPodsViewModel( } AirPodsNotifications.BATTERY_DATA -> { - val data = intent.getParcelableArrayListExtra("data", Battery::class.java)?.toList() ?: emptyList() + val data = intent.getParcelableArrayListExtra("data", Battery::class.java) + ?.toList() ?: emptyList() _uiState.update { it.copy(battery = data) } @@ -213,15 +233,12 @@ class AirPodsViewModel( } appContext.registerReceiver( - broadcastReceiver, - filter, - Context.RECEIVER_NOT_EXPORTED + broadcastReceiver, filter, Context.RECEIVER_NOT_EXPORTED ) } fun setControlCommandValue( - identifier: ControlCommandIdentifiers, - value: ByteArray + identifier: ControlCommandIdentifiers, value: ByteArray ) { if (!isDemoMode) controlRepo.setValue(identifier, value) _uiState.update { @@ -232,25 +249,21 @@ class AirPodsViewModel( } fun setControlCommandBoolean( - identifier: ControlCommandIdentifiers, - enabled: Boolean + identifier: ControlCommandIdentifiers, enabled: Boolean ) { setControlCommandValue( - identifier, - if (enabled) byteArrayOf(0x01) else byteArrayOf(0x02) + identifier, if (enabled) byteArrayOf(0x01) else byteArrayOf(0x02) ) } fun setControlCommandInt( - identifier: ControlCommandIdentifiers, - value: Int + identifier: ControlCommandIdentifiers, value: Int ) { setControlCommandValue(identifier, byteArrayOf(value.toByte())) } fun setControlCommandByte( - identifier: ControlCommandIdentifiers, - value: Byte + identifier: ControlCommandIdentifiers, value: Byte ) { setControlCommandValue(identifier, byteArrayOf(value)) } @@ -267,7 +280,7 @@ class AirPodsViewModel( } } - listeners[identifier] = listener + listeners[identifier] = listener as AACPManager.ControlCommandListener } // I'm lazy, sorry. @@ -309,8 +322,7 @@ class AirPodsViewModel( service.let { service -> _uiState.update { it.copy( - isLocallyConnected = service.isConnected(), - battery = service.getBattery() + isLocallyConnected = service.isConnected(), battery = service.getBattery() ) } } @@ -318,11 +330,24 @@ class AirPodsViewModel( private fun loadSharedPreferences() { val offListeningModeEnabled = sharedPreferences.getBoolean("off_listening_mode", true) - val automaticEarDetectionEnabled = sharedPreferences.getBoolean("automatic_ear_detection", true) - val automaticConnectionEnabled = sharedPreferences.getBoolean("automatic_connection_ctrl_cmd", true) + val automaticEarDetectionEnabled = + sharedPreferences.getBoolean("automatic_ear_detection", true) + val automaticConnectionEnabled = + sharedPreferences.getBoolean("automatic_connection_ctrl_cmd", true) val headGesturesEnabled = sharedPreferences.getBoolean("head_gestures", true) - val leftAction = StemAction.valueOf(sharedPreferences.getString("left_long_press_action", "CYCLE_NOISE_CONTROL_MODES") ?: "CYCLE_NOISE_CONTROL_MODES") - val rightAction = StemAction.valueOf(sharedPreferences.getString("right_long_press_action", "CYCLE_NOISE_CONTROL_MODES") ?: "CYCLE_NOISE_CONTROL_MODES") + val leftAction = StemAction.valueOf( + sharedPreferences.getString( + "left_long_press_action", + "CYCLE_NOISE_CONTROL_MODES" + ) ?: "CYCLE_NOISE_CONTROL_MODES" + ) + val rightAction = StemAction.valueOf( + sharedPreferences.getString( + "right_long_press_action", + "CYCLE_NOISE_CONTROL_MODES" + ) ?: "CYCLE_NOISE_CONTROL_MODES" + ) + val vendorIdHook = xposedRemotePref.getBoolean("vendor_id_hook", false) _uiState.update { it.copy( @@ -331,7 +356,8 @@ class AirPodsViewModel( automaticConnectionEnabled = automaticConnectionEnabled, headGesturesEnabled = headGesturesEnabled, leftAction = leftAction, - rightAction = rightAction + rightAction = rightAction, + vendorIdHook = vendorIdHook ) } } @@ -365,14 +391,12 @@ class AirPodsViewModel( name = "AirPods", model = AirPodsModels.getModelByModelNumber("A3049")!!, actualModelNumber = "A3049", - aacpManager = service.aacpManager, serialNumber = null, leftSerialNumber = null, rightSerialNumber = null, version1 = null, version2 = null, version3 = null, - attManager = null ) _uiState.update { @@ -381,7 +405,11 @@ class AirPodsViewModel( instance = instance, modelName = instance.model.displayName, actualModel = instance.actualModelNumber, - serialNumbers = listOf(instance.serialNumber ?: "", instance.leftSerialNumber ?: "", instance.rightSerialNumber ?: ""), + serialNumbers = listOf( + instance.serialNumber ?: "", + instance.leftSerialNumber ?: "", + instance.rightSerialNumber ?: "" + ), version1 = instance.version1 ?: "", version2 = instance.version2 ?: "", version3 = instance.version3 ?: "" @@ -408,11 +436,42 @@ class AirPodsViewModel( } fun setATTCharacteristicValue(handle: ATTHandles, value: ByteArray) { - service.attManager?.write(handle, value) + if (handle == ATTHandles.LOUD_SOUND_REDUCTION) { + _uiState.update { it.copy(loudSoundReductionEnabled = value[0].toInt() == 0x01) } + } + viewModelScope.launch(Dispatchers.IO) { + service.attManager?.write(handle, value) + } } - fun getATTCharacteristicValue(handle: ATTHandles): ByteArray? { - return service.attManager?.read(handle) + fun refreshATT() { + viewModelScope.launch(Dispatchers.IO) { + val loudSoundReduction = + runCatching { service.attManager?.read(ATTHandles.LOUD_SOUND_REDUCTION) }.getOrNull() + val transparencyData = + runCatching { service.attManager?.read(ATTHandles.TRANSPARENCY) }.getOrNull()?: byteArrayOf() + val hearingAid = + runCatching { service.attManager?.read(ATTHandles.HEARING_AID) }.getOrNull()?: byteArrayOf() + _uiState.value = _uiState.value.copy( + loudSoundReductionEnabled = loudSoundReduction?.get(0)?.toInt() == 0x01, + transparencyData = transparencyData, + hearingAidData = hearingAid + ) + } + } + + fun observeATT() { + viewModelScope.launch(Dispatchers.IO) { + service.attManager?.connect() + service.attManager?.enableNotifications(ATTHandles.LOUD_SOUND_REDUCTION) + service.attManager?.enableNotifications(ATTHandles.TRANSPARENCY) + service.attManager?.enableNotifications(ATTHandles.HEARING_AID) + + while (true) { + refreshATT() + delay(10000) + } + } } fun setAutomaticEarDetectionEnabled(enabled: Boolean) { @@ -435,9 +494,9 @@ class AirPodsViewModel( } } - fun purchase(context: Context) { - BillingManager.provider.purchase(context as Activity) - } +// fun purchase(context: Context) { +// BillingManager.provider.purchase(context as Activity) +// } fun activateDemoMode() { isDemoMode = true @@ -448,14 +507,12 @@ class AirPodsViewModel( name = "AirPods Pro (Demo)", model = AirPodsModels.getModelByModelNumber("A3049")!!, actualModelNumber = "A3049", - aacpManager = service.aacpManager, serialNumber = "DEMO123", leftSerialNumber = "L-DEMO", rightSerialNumber = "R-DEMO", version1 = "1.0", version2 = "1.0", version3 = "1.0", - attManager = null ) _uiState.update { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/viewmodel/AppSettingsViewModel.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AppSettingsViewModel.kt similarity index 89% rename from android/app/src/main/java/me/kavishdevar/librepods/viewmodel/AppSettingsViewModel.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AppSettingsViewModel.kt index 5508a65..304db98 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/viewmodel/AppSettingsViewModel.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AppSettingsViewModel.kt @@ -1,6 +1,5 @@ -package me.kavishdevar.librepods.viewmodel +package me.kavishdevar.librepods.presentation.viewmodel -import android.app.Activity import android.app.Application import android.content.Context import androidx.core.content.edit @@ -10,7 +9,10 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import me.kavishdevar.librepods.BuildConfig import me.kavishdevar.librepods.billing.BillingManager +import me.kavishdevar.librepods.data.XposedRemotePrefProvider +import me.kavishdevar.librepods.utils.NativeBridge import kotlin.math.roundToInt data class AppSettingsUiState( @@ -29,6 +31,7 @@ data class AppSettingsUiState( val showCameraDialog: Boolean = false, val cameraPackageValue: String = "", val cameraPackageError: String? = null, + val vendorIdHook: Boolean = false, val isPremium: Boolean = false ) @@ -38,6 +41,8 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat private val _uiState = MutableStateFlow(AppSettingsUiState()) val uiState = _uiState.asStateFlow() + private val xposedRemotePref = XposedRemotePrefProvider.create() + init { loadSettings() observeBilling() @@ -66,9 +71,13 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat takeoverWhenMediaStart = sharedPreferences.getBoolean("takeover_when_media_start", false), useAlternateHeadTrackingPackets = sharedPreferences.getBoolean("use_alternate_head_tracking_packets", true), conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 43).toFloat(), - cameraPackageValue = sharedPreferences.getString("custom_camera_package", "") ?: "" + cameraPackageValue = sharedPreferences.getString("custom_camera_package", "") ?: "", + vendorIdHook = xposedRemotePref.getBoolean("vendor_id_hook", false) ) } + if (BuildConfig.FLAVOR == "xposed") { + NativeBridge.setSdpHook(_uiState.value.vendorIdHook) + } } fun setShowPhoneBatteryInWidget(enabled: Boolean) { @@ -152,7 +161,9 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat setShowCameraDialog(false) } - fun purchase(context: Context) { - BillingManager.provider.purchase(context as Activity) + fun setVendorIdHook(enabled: Boolean) { + NativeBridge.setSdpHook(enabled) + xposedRemotePref.putBoolean("vendor_id_hook", enabled) + _uiState.update { it.copy(vendorIdHook = enabled) } } } 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 new file mode 100644 index 0000000..3ea9649 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/PurchaseViewModel.kt @@ -0,0 +1,47 @@ +package me.kavishdevar.librepods.presentation.viewmodel + +import android.app.Activity +import android.app.Application +import android.content.Context +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import me.kavishdevar.librepods.billing.BillingManager + +data class PurchaseUiState( + val isPremium: Boolean = false, + val price: String = "" +) + +class PurchaseViewModel(application: Application) : AndroidViewModel(application) { + private val _uiState = MutableStateFlow(PurchaseUiState()) + val uiState = _uiState.asStateFlow() + + init { + observeBilling() + } + + private fun observeBilling() { + viewModelScope.launch { + BillingManager.provider.isPremium.collect { premium -> + _uiState.update { it.copy(isPremium = premium) } + } + } + viewModelScope.launch { + BillingManager.provider.price.collect { price -> + _uiState.update { it.copy(price = price) } + } + } + } + + fun purchase(context: Context) { + BillingManager.provider.purchase(context as Activity) + } + + fun restorePurchases() { + BillingManager.provider.queryPurchases() + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/widgets/BatteryWidget.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/widgets/BatteryWidget.kt similarity index 95% rename from android/app/src/main/java/me/kavishdevar/librepods/widgets/BatteryWidget.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/widgets/BatteryWidget.kt index a926e79..20a12d5 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/widgets/BatteryWidget.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/widgets/BatteryWidget.kt @@ -18,7 +18,7 @@ @file:OptIn(ExperimentalEncodingApi::class) -package me.kavishdevar.librepods.widgets +package me.kavishdevar.librepods.presentation.widgets import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetProvider diff --git a/android/app/src/main/java/me/kavishdevar/librepods/widgets/NoiseControlWidget.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/widgets/NoiseControlWidget.kt similarity index 97% rename from android/app/src/main/java/me/kavishdevar/librepods/widgets/NoiseControlWidget.kt rename to android/app/src/main/java/me/kavishdevar/librepods/presentation/widgets/NoiseControlWidget.kt index 9d32f16..17ec190 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/widgets/NoiseControlWidget.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/widgets/NoiseControlWidget.kt @@ -18,7 +18,7 @@ @file:OptIn(ExperimentalEncodingApi::class) -package me.kavishdevar.librepods.widgets +package me.kavishdevar.librepods.presentation.widgets import android.app.PendingIntent import android.appwidget.AppWidgetManager @@ -29,7 +29,7 @@ import android.util.Log import android.widget.RemoteViews import me.kavishdevar.librepods.R import me.kavishdevar.librepods.services.ServiceManager -import me.kavishdevar.librepods.utils.AACPManager +import me.kavishdevar.librepods.bluetooth.AACPManager import kotlin.io.encoding.ExperimentalEncodingApi class NoiseControlWidget : AppWidgetProvider() { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsQSService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsQSService.kt index 4d20339..8514659 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsQSService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsQSService.kt @@ -35,9 +35,9 @@ import android.util.Log import androidx.annotation.RequiresApi import me.kavishdevar.librepods.QuickSettingsDialogActivity import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.constants.AirPodsNotifications -import me.kavishdevar.librepods.constants.NoiseControlMode -import me.kavishdevar.librepods.utils.AACPManager +import me.kavishdevar.librepods.data.AirPodsNotifications +import me.kavishdevar.librepods.data.NoiseControlMode +import me.kavishdevar.librepods.bluetooth.AACPManager import kotlin.io.encoding.ExperimentalEncodingApi @RequiresApi(Build.VERSION_CODES.Q) 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 b7b9b12..753e835 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 @@ -83,25 +83,28 @@ import kotlinx.coroutines.withTimeout import me.kavishdevar.librepods.BuildConfig import me.kavishdevar.librepods.MainActivity import me.kavishdevar.librepods.R -import me.kavishdevar.librepods.constants.AirPodsNotifications -import me.kavishdevar.librepods.constants.Battery -import me.kavishdevar.librepods.constants.BatteryComponent -import me.kavishdevar.librepods.constants.BatteryStatus -import me.kavishdevar.librepods.constants.StemAction -import me.kavishdevar.librepods.constants.isHeadTrackingData -import me.kavishdevar.librepods.utils.AACPManager -import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressType -import me.kavishdevar.librepods.utils.ATTManager -import me.kavishdevar.librepods.utils.AirPodsInstance -import me.kavishdevar.librepods.utils.AirPodsModels -import me.kavishdevar.librepods.utils.BLEManager -import me.kavishdevar.librepods.utils.BluetoothConnectionManager +import me.kavishdevar.librepods.bluetooth.AACPManager +import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.StemPressType +import me.kavishdevar.librepods.bluetooth.ATTManager +import me.kavishdevar.librepods.bluetooth.BLEManager +import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager +import me.kavishdevar.librepods.data.AirPodsInstance +import me.kavishdevar.librepods.data.AirPodsModels +import me.kavishdevar.librepods.data.AirPodsNotifications +import me.kavishdevar.librepods.data.Battery +import me.kavishdevar.librepods.data.BatteryComponent +import me.kavishdevar.librepods.data.BatteryStatus +import me.kavishdevar.librepods.data.StemAction +import me.kavishdevar.librepods.data.XposedRemotePrefProvider +import me.kavishdevar.librepods.data.isHeadTrackingData +import me.kavishdevar.librepods.presentation.overlays.IslandType +import me.kavishdevar.librepods.presentation.overlays.IslandWindow +import me.kavishdevar.librepods.presentation.overlays.PopupWindow +import me.kavishdevar.librepods.presentation.widgets.BatteryWidget +import me.kavishdevar.librepods.presentation.widgets.NoiseControlWidget import me.kavishdevar.librepods.utils.GestureDetector import me.kavishdevar.librepods.utils.HeadTracking -import me.kavishdevar.librepods.utils.IslandType -import me.kavishdevar.librepods.utils.IslandWindow import me.kavishdevar.librepods.utils.MediaController -import me.kavishdevar.librepods.utils.PopupWindow import me.kavishdevar.librepods.utils.SystemApisUtils import me.kavishdevar.librepods.utils.SystemApisUtils.DEVICE_TYPE_UNTETHERED_HEADSET import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_COMPANION_APP @@ -121,8 +124,6 @@ import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_ import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_CHARGING import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_ICON import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD -import me.kavishdevar.librepods.widgets.BatteryWidget -import me.kavishdevar.librepods.widgets.NoiseControlWidget import java.nio.ByteBuffer import java.nio.ByteOrder import kotlin.io.encoding.Base64 @@ -1060,8 +1061,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList version1 = config.airpodsVersion1, version2 = config.airpodsVersion2, version3 = config.airpodsVersion3, - aacpManager = aacpManager, - attManager = attManager ) } sendBroadcast( @@ -1765,8 +1764,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } } + @Suppress("KotlinUnreachableCode") @OptIn(ExperimentalMaterial3Api::class) private fun showSocketConnectionFailureNotification(errorMessage: String) { + return // something causes too many notifications. turning off for now if (BuildConfig.FLAVOR != "xposed") { Log.w( TAG, @@ -1788,7 +1789,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList .setSmallIcon(R.drawable.airpods).setContentTitle("AirPods Connection Issue") .setContentText("Unable to connect to AirPods over L2CAP").setStyle( NotificationCompat.BigTextStyle().bigText( - "Your AirPods are connected via Bluetooth, but LibrePods couldn't connect to AirPods using L2CAP. " + "Error: $errorMessage" + "Your AirPods are connected via Bluetooth, but LibrePods couldn't connect to AirPods using L2CAP. Error: $errorMessage" ) ).setContentIntent(pendingIntent).setCategory(Notification.CATEGORY_ERROR) .setPriority(NotificationCompat.PRIORITY_HIGH).setAutoCancel(true).build() @@ -2178,7 +2179,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList fun processHeadTrackingData(data: ByteArray) { val horizontal = ByteBuffer.wrap(data, 51, 2).order(ByteOrder.LITTLE_ENDIAN).short.toInt() val vertical = ByteBuffer.wrap(data, 53, 2).order(ByteOrder.LITTLE_ENDIAN).short.toInt() - gestureDetector?.processHeadOrientation(horizontal, vertical) + try { + gestureDetector?.processHeadOrientation(horizontal, vertical) + } catch (e: Exception) { + Log.w(TAG, "gesture detector on ${data.toHexString()}: ${e.message}") + } } private lateinit var connectionReceiver: BroadcastReceiver @@ -2666,8 +2671,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList this@AirPodsService.device = device BluetoothConnectionManager.setCurrentConnection(socket, device) - - if (BuildConfig.FLAVOR == "xposed") { + val xposedRemotePref = XposedRemotePrefProvider.create() + if (xposedRemotePref.getBoolean("vendor_id_hook", false)) { attManager = ATTManager(adapter, device) attManager!!.connect() } @@ -2687,8 +2692,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList version1 = config.airpodsVersion1, version2 = config.airpodsVersion2, version3 = config.airpodsVersion3, - aacpManager = aacpManager, - attManager = attManager ) } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AppListenerService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AppListenerService.kt index 83e5b06..7eeea7c 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AppListenerService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AppListenerService.kt @@ -40,13 +40,13 @@ val cameraPackages = mutableSetOf( var cameraOpen = false private var currentCustomPackage: String? = null -class AppListenerService : AccessibilityService() { +class AppListenerService: AccessibilityService() { private lateinit var prefs: android.content.SharedPreferences private val preferenceChangeListener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key -> if (key == "custom_camera_package") { val newPackage = sharedPreferences.getString(key, null) currentCustomPackage?.let { cameraPackages.remove(it) } - if (newPackage != null && newPackage.isNotBlank()) { + if (!newPackage.isNullOrBlank()) { cameraPackages.add(newPackage) } currentCustomPackage = newPackage @@ -57,7 +57,7 @@ class AppListenerService : AccessibilityService() { super.onCreate() prefs = getSharedPreferences("settings", MODE_PRIVATE) val customPackage = prefs.getString("custom_camera_package", null) - if (customPackage != null && customPackage.isNotBlank()) { + if (!customPackage.isNullOrBlank()) { cameraPackages.add(customPackage) currentCustomPackage = customPackage } @@ -95,4 +95,4 @@ class AppListenerService : AccessibilityService() { } override fun onInterrupt() {} -} \ No newline at end of file +} 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 d45e0bb..403ba53 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 @@ -18,32 +18,27 @@ package me.kavishdevar.librepods.utils +import android.content.SharedPreferences import android.os.Build import me.kavishdevar.librepods.BuildConfig -fun isSupported(): Boolean { - if (BuildConfig.PLAY_BUILD) { - val isPixel = Build.MANUFACTURER.lowercase() == "google" - val isOppoOrOnePlus = Build.MANUFACTURER.lowercase() in listOf("oneplus", "oppo") +fun isSupported(sharedPreferences: SharedPreferences): Boolean { + val isPixel = Build.MANUFACTURER.lowercase() == "google" + val isOppoOrOnePlus = Build.MANUFACTURER.lowercase() in listOf("oneplus", "oppo") - if (isPixel) { - when (Build.VERSION.SDK_INT) { - 36 -> { - return Build.ID == "CP1A.260305.018" || Build.ID == "CP1A.260405.005" - } - - 37 -> { - return true - } + if (isPixel) { + when (Build.VERSION.SDK_INT) { + 36 -> { + return Build.ID == "CP1A.260305.018" || Build.ID == "CP1A.260405.005" + } + + 37 -> { + return true } - } else if (isOppoOrOnePlus) { - return true } + } else if (isOppoOrOnePlus) { + return true } - return true + return if (BuildConfig.FLAVOR == "xposed") true + else sharedPreferences.getBoolean("bypass_device_check", false) } - - -/*fun isSupported(): Boolean { - return true -}*/ diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/SystemAPIUtils.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/SystemAPIUtils.kt index 14abb76..cd91e24 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/SystemAPIUtils.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/SystemAPIUtils.kt @@ -296,7 +296,7 @@ object SystemApisUtils { ) method.invoke(device, key, value) as Boolean } catch (e: Exception) { - Log.e("SystemApisUtils", "Failed to set metadata for key $key", e) + Log.w("SystemApisUtils", "Failed to set metadata for key $key: ${e.message}") false } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/XposedServiceHolder.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/XposedServiceHolder.kt new file mode 100644 index 0000000..27a4ffc --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/XposedServiceHolder.kt @@ -0,0 +1,7 @@ +package me.kavishdevar.librepods.utils + +import io.github.libxposed.service.XposedService + +object XposedServiceHolder { + var service: XposedService? = null +} diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index 27e3510..2d7ea81 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -34,7 +34,7 @@ AirPods no conectados Por favor, conecta tus AirPods para acceder a los ajustes. Atrás - Personalización + Personalización Volumen relativo Reduce a un porcentaje del volumen actual en vez del volumen máximo. Pausar música @@ -169,7 +169,7 @@ Introducir 16-byte ENC_KEY como formato hexadecimal (32 caracteres): Debe tener exactamente 32 caracteres hexadecimales Error convirtiendo hex: - Offset encontrado. Por favor, reinicie el proceso Bluetooth + Por favor, reinicie el proceso Bluetooth Asistente Digital Activado Control Remoto de Cámara diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index c87a7d4..8595b37 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -34,7 +34,7 @@ AirPods non connectés Veuillez connecter vos AirPods pour accéder aux réglages. Retour - Personnalisations + Personnalisations Volume relatif Réduit à un pourcentage du volume actuel plutôt qu\'au volume maximum. Mettre la musique en pause diff --git a/android/app/src/main/res/values-pt/strings.xml b/android/app/src/main/res/values-pt/strings.xml index f214026..92e969d 100644 --- a/android/app/src/main/res/values-pt/strings.xml +++ b/android/app/src/main/res/values-pt/strings.xml @@ -34,7 +34,7 @@ AirPods não conectados Por favor, conecte seus AirPods para acessar as configurações. Voltar - Personalizações + Personalizações Volume relativo Reduz para uma porcentagem do volume atual em vez do volume máximo. Pausar Música diff --git a/android/app/src/main/res/values-tr/strings.xml b/android/app/src/main/res/values-tr/strings.xml index 864920d..1e6631c 100644 --- a/android/app/src/main/res/values-tr/strings.xml +++ b/android/app/src/main/res/values-tr/strings.xml @@ -34,7 +34,7 @@ AirPods bağlı değil Ayarlara erişmek için lütfen AirPods\'unuzu bağlayın. Geri - Özelleştirmeler + Özelleştirmeler Göreceli ses Maksimum ses yerine mevcut sesin yüzdesine göre azaltır. Müziği Duraklat diff --git a/android/app/src/main/res/values-uk/strings.xml b/android/app/src/main/res/values-uk/strings.xml index 267d689..ce95694 100644 --- a/android/app/src/main/res/values-uk/strings.xml +++ b/android/app/src/main/res/values-uk/strings.xml @@ -34,7 +34,7 @@ AirPods не підключені Будь ласка, підключіть ваші AirPods, щоб отримати доступ до налаштувань. Назад - Персоналізація + Персоналізація Відносна гучність Зменшує до відсотка від поточної гучності, а не від максимальної. Призупинити Музику diff --git a/android/app/src/main/res/values-vi/strings.xml b/android/app/src/main/res/values-vi/strings.xml index 25436c5..f461c4e 100644 --- a/android/app/src/main/res/values-vi/strings.xml +++ b/android/app/src/main/res/values-vi/strings.xml @@ -34,7 +34,7 @@ AirPods chưa được kết nối Vui lòng kết nối đến AirPods của bạn để truy cập cài đặt. Quay lại - Tùy chỉnh + Tùy chỉnh Âm lượng tương đối Giảm xuống phần trăm của âm lượng hiện tại thay vì âm lượng tối đa. Tạm dừng nhạc @@ -169,7 +169,7 @@ Nhập ENC_KEY 16 byte dưới dạng chuỗi hex (32 ký tự): Phải chính xác 32 ký tự hex Lỗi chuyển đổi hex: - Đã tìm thấy độ lệch, vui lòng khởi động lại tiến trình Bluetooth + vui lòng khởi động lại tiến trình Bluetooth Trợ lý kỹ thuật số Bật Điều khiển máy ảnh từ xa diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml index af388da..6fccbd2 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -33,7 +33,7 @@ AirPods 未连接 请连接 AirPods 以访问设置。 返回 - 自定义 + 自定义 相对音量 降低到当前音量的百分比,而不是最大音量。 暂停音乐 diff --git a/android/app/src/main/res/values-zh-rTW/strings.xml b/android/app/src/main/res/values-zh-rTW/strings.xml index 3a77d69..dff0e65 100644 --- a/android/app/src/main/res/values-zh-rTW/strings.xml +++ b/android/app/src/main/res/values-zh-rTW/strings.xml @@ -34,7 +34,7 @@ 未連接 AirPods 請連接你的 AirPods 以存取設定。 返回 - 自訂 + 自訂 相對音量 降低至當前音量的百分比,而不是最大音量。 暫停音樂 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index beedccf..a31bfc4 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -34,7 +34,7 @@ AirPods not connected Please connect your AirPods to access settings. Back - Customizations + Customizations Relative volume Reduces to a percentage of the current volume instead of the maximum volume. Pause Music @@ -169,7 +169,7 @@ Enter 16-byte ENC_KEY as hex string (32 characters): Must be exactly 32 hex characters Error converting hex: - Found offset please restart the Bluetooth process + Please restart the Bluetooth process Digital Assistant On Camera Remote @@ -210,5 +210,30 @@ Lets in external sounds Dynamically adjust external noise Blocks out external sounds - Unlock all features + Unlock advanced features + Buy + Restore purchases + Automatically stop playing audio when you take them off, and resume playback when you put them back on. + Battery + View accurate battery status in the app and notification. + Switch between listening modes directly from the app or Quick Settings. + Advanced device settings + Customize settings like Personalized Volume, Adaptive Audio, Pause media when falling asleep, and other Accessibility settings. + Automatic Connection + Enable and customize automatic connection to AirPods. + Get access to app customizations, including phone battery in widget, conversational awareness volume, and many more upcoming customization features. + Support the development + LibrePods is developed by a single developer. Upgrading helps keep the app alive. + Feature availability depends on your AirPods model and firmware version. + Contact + E-Mail + Discord + GitHub Issues + Version code + Flavor + Build type + No + Yes + Settings + requires xposed diff --git a/android/app/src/normal/java/me/kavishdevar/librepods/LibrePodsApplication.kt b/android/app/src/normal/java/me/kavishdevar/librepods/LibrePodsApplication.kt new file mode 100644 index 0000000..0120900 --- /dev/null +++ b/android/app/src/normal/java/me/kavishdevar/librepods/LibrePodsApplication.kt @@ -0,0 +1,5 @@ +package me.kavishdevar.librepods + +import android.app.Application + +class LibrePodsApplication: Application() diff --git a/android/app/src/normal/java/me/kavishdevar/librepods/data/XposedRemotePrefImpl.kt b/android/app/src/normal/java/me/kavishdevar/librepods/data/XposedRemotePrefImpl.kt new file mode 100644 index 0000000..072d9c3 --- /dev/null +++ b/android/app/src/normal/java/me/kavishdevar/librepods/data/XposedRemotePrefImpl.kt @@ -0,0 +1,11 @@ +package me.kavishdevar.librepods.data + +class XposedRemotePrefImpl: XposedRemotePref { + override fun isAvailable(): Boolean { return false } + + override fun getBoolean(key: String, def: Boolean): Boolean { + return false + } + + override fun putBoolean(key: String, value: Boolean) { } +} diff --git a/android/app/src/normal/java/me/kavishdevar/librepods/utils/KotlinModule.kt b/android/app/src/normal/java/me/kavishdevar/librepods/utils/KotlinModule.kt new file mode 100644 index 0000000..0d1c2bd --- /dev/null +++ b/android/app/src/normal/java/me/kavishdevar/librepods/utils/KotlinModule.kt @@ -0,0 +1,125 @@ +package me.kavishdevar.librepods.utils + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.widget.ImageView +import androidx.core.net.toUri +import io.github.libxposed.api.XposedModule +import io.github.libxposed.api.XposedModuleInterface.ModuleLoadedParam +import io.github.libxposed.api.XposedModuleInterface.PackageLoadedParam + +private const val TAG = "LibrePodsHook" + +@SuppressLint("DiscouragedApi", "PrivateApi") +class KotlinModule: XposedModule() { + override fun onModuleLoaded(param: ModuleLoadedParam) { + log(Log.INFO, TAG, "module initialized at :: ${param.processName}") + log(Log.INFO, TAG, "framework: $frameworkName($frameworkVersionCode) API $apiVersion") + } + + override fun onPackageLoaded(param: PackageLoadedParam) { + log(Log.INFO, TAG, "onPackageLoaded :: ${param.packageName}") + + if (param.packageName == "com.google.android.bluetooth" || param.packageName == "com.android.bluetooth") { + log(Log.INFO, TAG, "Bluetooth app detected, hooking l2c_fcr_chk_chan_modes") + try { + if (param.isFirstPackage) { + log(Log.INFO, TAG, "Loading native library for Bluetooth hook") + + NativeBridge.setSdpHook(getRemotePreferences("me.kavishdevar.librepods").getBoolean("vendor_id_hook", false)) + System.loadLibrary("l2c_fcr_hook") + log(Log.INFO, TAG, "Native library loaded successfully") + } + } catch (e: Exception) { + log(Log.ERROR, TAG, "Failed to load native library: ${e.message}") + } + } + + if (param.packageName == "com.google.android.settings") { + hookSettingsController(param, "com.google.android.settings.bluetooth.AdvancedBluetoothDetailsHeaderController") + } + + if (param.packageName == "com.android.settings") { + hookSettingsController(param, "com.android.settings.bluetooth.AdvancedBluetoothDetailsHeaderController") + } + } + + private fun hookSettingsController(param: PackageLoadedParam, className: String) { + log(Log.INFO, TAG, "Settings app detected, hooking Bluetooth icon handling") + try { + val headerControllerClass = Class.forName(className, false, param.defaultClassLoader) + val updateIconMethod = headerControllerClass.getDeclaredMethod( + "updateIcon", + ImageView::class.java, + String::class.java + ) + + hook(updateIconMethod).intercept { chain -> + try { + log(Log.INFO, TAG, "Bluetooth icon hook called with args: ${chain.args.joinToString(", ")}") + val imageView = chain.args[0] as? ImageView + val iconUri = chain.args[1] as? String + + if (imageView == null || iconUri == null) { + return@intercept chain.proceed() + } + + val uri = iconUri.toUri() + if (!uri.toString().startsWith("android.resource://me.kavishdevar.librepods")) { + return@intercept chain.proceed() + } + + log(Log.INFO, TAG, "Handling AirPods icon URI: $uri") + + Handler(Looper.getMainLooper()).post { + try { + val context = imageView.context + val packageName = uri.authority ?: return@post + val packageContext = context.createPackageContext( + packageName, + Context.CONTEXT_IGNORE_SECURITY + ) + + val resPath = uri.pathSegments + if (resPath.size >= 2 && resPath[0] == "drawable") { + val resourceName = resPath[1] + val resourceId = packageContext.resources.getIdentifier( + resourceName, "drawable", packageName + ) + + if (resourceId != 0) { + val drawable = packageContext.resources.getDrawable( + resourceId, packageContext.theme + ) + imageView.setImageDrawable(drawable) + imageView.alpha = 1.0f + log(Log.INFO, TAG, "Successfully loaded icon from resource: $resourceName") + } else { + log(Log.ERROR, TAG, "Resource not found: $resourceName") + } + } + } catch (e: Exception) { + log(Log.ERROR, TAG, "Error loading resource from URI $uri: ${e.message}") + } + } + null + } catch (e: Exception) { + log(Log.ERROR, TAG, "Error in Bluetooth icon hook: ${e.message}") + chain.proceed() + } + } + + log(Log.INFO, TAG, "Successfully hooked updateIcon method in Bluetooth settings") + } catch (e: Exception) { + log(Log.ERROR, TAG, "Failed to hook Bluetooth icon handler: ${e.message}") + } + } +} + + +object NativeBridge { + external fun setSdpHook(enabled: Boolean) +} diff --git a/android/app/src/normal/java/me/kavishdevar/librepods/utils/XposedServiceHolder.kt b/android/app/src/normal/java/me/kavishdevar/librepods/utils/XposedServiceHolder.kt new file mode 100644 index 0000000..5412c5f --- /dev/null +++ b/android/app/src/normal/java/me/kavishdevar/librepods/utils/XposedServiceHolder.kt @@ -0,0 +1,28 @@ +package me.kavishdevar.librepods.utils + +import android.content.Context +import io.github.libxposed.service.XposedService +import io.github.libxposed.service.XposedServiceHelper + +object XposedServiceHolder { + var service: XposedService? = null +} + + +object XposedInitializer: XposedServiceHelper.OnServiceListener { + private var initialized = false + + fun ensureInit(context: Context) { + if (initialized) return + initialized = true + XposedServiceHelper.registerListener(this) + } + + override fun onServiceBind(service: XposedService) { + XposedServiceHolder.service = service + } + + override fun onServiceDied(service: XposedService) { + XposedServiceHolder.service = null + } +} diff --git a/android/app/src/xposed/cpp/l2c_fcr_hook.cpp b/android/app/src/xposed/cpp/l2c_fcr_hook.cpp index 8ceb332..e663666 100644 --- a/android/app/src/xposed/cpp/l2c_fcr_hook.cpp +++ b/android/app/src/xposed/cpp/l2c_fcr_hook.cpp @@ -24,6 +24,8 @@ #include #include #include +#include +#include #include "l2c_fcr_hook.h" @@ -31,7 +33,7 @@ extern "C" { #include "xz.h" } -#define LOG_TAG "LibrePods" +#define LOG_TAG "LibrePodsHook" #define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) @@ -41,8 +43,10 @@ static uint8_t (*original_l2c_fcr_chk_chan_modes)(void*) = nullptr; static tBTA_STATUS (*original_BTA_DmSetLocalDiRecord)( tSDP_DI_RECORD*, uint32_t*) = nullptr; +static std::atomic enableSdpHook(false); + uint8_t fake_l2c_fcr_chk_chan_modes(void* p_ccb) { - LOGI("l2c_fcr_chk_chan_modes hooked"); + LOGI("l2c_fcr_chk_chan_modes called"); uint8_t orig = 0; if (original_l2c_fcr_chk_chan_modes) orig = original_l2c_fcr_chk_chan_modes(p_ccb); @@ -55,7 +59,11 @@ tBTA_STATUS fake_BTA_DmSetLocalDiRecord( tSDP_DI_RECORD* p_device_info, uint32_t* p_handle) { - LOGI("BTA_DmSetLocalDiRecord hooked"); + LOGI("BTA_DmSetLocalDiRecord called"); + + if (original_BTA_DmSetLocalDiRecord && enableSdpHook.load(std::memory_order_relaxed)) original_BTA_DmSetLocalDiRecord(p_device_info, p_handle); + + LOGI("BTA_DmSetLocalDiRecord changing vendor id and source"); if (p_device_info) { p_device_info->vendor = 0x004C; @@ -265,9 +273,9 @@ static bool hookLibrary(const char* libname) { findSymbolOffset(decompressed, "l2c_fcr_chk_chan_modes"); -// uint64_t sdp_offset = -// findSymbolOffset(decompressed, -// "BTA_DmSetLocalDiRecord"); + uint64_t sdp_offset = + findSymbolOffset(decompressed, + "BTA_DmSetLocalDiRecord"); if (chk_offset) { void* target = @@ -280,16 +288,16 @@ static bool hookLibrary(const char* libname) { LOGI("Hooked l2c_fcr_chk_chan_modes"); } -// if (sdp_offset) { -// void* target = -// reinterpret_cast(base + sdp_offset); -// -// hook_func(target, -// (void*)fake_BTA_DmSetLocalDiRecord, -// (void**)&original_BTA_DmSetLocalDiRecord); -// -// LOGI("Hooked BTA_DmSetLocalDiRecord"); -// } + if (sdp_offset) { + void* target = + reinterpret_cast(base + sdp_offset); + + hook_func(target, + (void*)fake_BTA_DmSetLocalDiRecord, + (void**)&original_BTA_DmSetLocalDiRecord); + + LOGI("Hooked BTA_DmSetLocalDiRecord"); + } return true; } @@ -315,10 +323,16 @@ extern "C" [[gnu::visibility("default")]] [[gnu::used]] NativeOnModuleLoaded native_init(const NativeAPIEntries* entries) { - - LOGI("LibrePods initialized"); - hook_func = (HookFunType)entries->hook_func; - + LOGI("LibrePodsNativeHook initialized, sdp hook enabled: %d", enableSdpHook.load(std::memory_order_relaxed)); return on_library_loaded; } + +extern "C" +JNIEXPORT void JNICALL +Java_me_kavishdevar_librepods_utils_NativeBridge_setSdpHook( + JNIEnv*, jobject thiz, jboolean enable) { + enableSdpHook.store(enable, std::memory_order_relaxed); + + LOGI("sdp hook enabled: %d", enable); +} diff --git a/android/app/src/xposed/java/me/kavishdevar/librepods/LibrePodsApplication.kt b/android/app/src/xposed/java/me/kavishdevar/librepods/LibrePodsApplication.kt new file mode 100644 index 0000000..cca90e5 --- /dev/null +++ b/android/app/src/xposed/java/me/kavishdevar/librepods/LibrePodsApplication.kt @@ -0,0 +1,21 @@ +package me.kavishdevar.librepods + +import android.app.Application +import io.github.libxposed.service.XposedService +import io.github.libxposed.service.XposedServiceHelper +import me.kavishdevar.librepods.utils.XposedServiceHolder + +class LibrePodsApplication: Application(), XposedServiceHelper.OnServiceListener { + override fun onCreate() { + super.onCreate() + XposedServiceHelper.registerListener(this) + } + + override fun onServiceBind(p0: XposedService) { + XposedServiceHolder.service = p0 + } + + override fun onServiceDied(p0: XposedService) { + XposedServiceHolder.service = null + } +} diff --git a/android/app/src/xposed/java/me/kavishdevar/librepods/data/XposedRemotePrefImpl.kt b/android/app/src/xposed/java/me/kavishdevar/librepods/data/XposedRemotePrefImpl.kt new file mode 100644 index 0000000..112e752 --- /dev/null +++ b/android/app/src/xposed/java/me/kavishdevar/librepods/data/XposedRemotePrefImpl.kt @@ -0,0 +1,21 @@ +package me.kavishdevar.librepods.data + +import androidx.core.content.edit +import me.kavishdevar.librepods.utils.XposedServiceHolder + +class XposedRemotePrefImpl: XposedRemotePref { + override fun isAvailable(): Boolean { + return XposedServiceHolder.service != null + } + + override fun getBoolean(key: String, def: Boolean): Boolean { + val s = XposedServiceHolder.service ?: return def + return s.getRemotePreferences("me.kavishdevar.librepods").getBoolean(key, def) + } + + override fun putBoolean(key: String, value: Boolean) { + val s = XposedServiceHolder.service ?: return + s.getRemotePreferences("me.kavishdevar.librepods") + .edit { putBoolean(key, value) } + } +} diff --git a/android/app/src/xposed/java/me/kavishdevar/librepods/utils/KotlinModule.kt b/android/app/src/xposed/java/me/kavishdevar/librepods/utils/KotlinModule.kt index 69ad51a..e48c4f5 100644 --- a/android/app/src/xposed/java/me/kavishdevar/librepods/utils/KotlinModule.kt +++ b/android/app/src/xposed/java/me/kavishdevar/librepods/utils/KotlinModule.kt @@ -2,148 +2,125 @@ package me.kavishdevar.librepods.utils import android.annotation.SuppressLint import android.content.Context -import android.content.pm.ApplicationInfo +import android.os.Handler +import android.os.Looper import android.util.Log import android.widget.ImageView import androidx.core.net.toUri -import io.github.libxposed.api.XposedInterface -import io.github.libxposed.api.XposedInterface.AfterHookCallback import io.github.libxposed.api.XposedModule -import io.github.libxposed.api.XposedModuleInterface import io.github.libxposed.api.XposedModuleInterface.ModuleLoadedParam -import io.github.libxposed.api.annotations.AfterInvocation -import io.github.libxposed.api.annotations.XposedHooker -import kotlin.jvm.java +import io.github.libxposed.api.XposedModuleInterface.PackageLoadedParam + +private const val TAG = "LibrePodsHook" -private const val TAG = "AirPodsHook" -private lateinit var module: KotlinModule @SuppressLint("DiscouragedApi", "PrivateApi") -class KotlinModule(base: XposedInterface, param: ModuleLoadedParam): XposedModule(base, param) { - init { - Log.i(TAG, "AirPodsHook module initialized at :: ${param.processName}") - module = this +class KotlinModule: XposedModule() { + override fun onModuleLoaded(param: ModuleLoadedParam) { + log(Log.INFO, TAG, "module initialized at :: ${param.processName}") + log(Log.INFO, TAG, "framework: $frameworkName($frameworkVersionCode) API $apiVersion") } - override fun onPackageLoaded(param: XposedModuleInterface.PackageLoadedParam) { - super.onPackageLoaded(param) - Log.i(TAG, "onPackageLoaded :: ${param.packageName}") + override fun onPackageLoaded(param: PackageLoadedParam) { + log(Log.INFO, TAG, "onPackageLoaded :: ${param.packageName}") if (param.packageName == "com.google.android.bluetooth" || param.packageName == "com.android.bluetooth") { - Log.i(TAG, "Bluetooth app detected, hooking l2c_fcr_chk_chan_modes") - + log(Log.INFO, TAG, "Bluetooth app detected, hooking l2c_fcr_chk_chan_modes") try { if (param.isFirstPackage) { - Log.i(TAG, "Loading native library for Bluetooth hook") + log(Log.INFO, TAG, "Loading native library for Bluetooth hook") System.loadLibrary("l2c_fcr_hook") - Log.i(TAG, "Native library loaded successfully") + val remotePrefValue = getRemotePreferences("me.kavishdevar.librepods").getBoolean("vendor_id_hook", false) + log(Log.INFO, TAG, "sdp hook enabled (remote pref): $remotePrefValue") + NativeBridge.setSdpHook(remotePrefValue) + log(Log.INFO, TAG, "Native library loaded successfully") } } catch (e: Exception) { - Log.e(TAG, "Failed to load native library: ${e.message}", e) + log(Log.ERROR, TAG, "Failed to load native library: ${e.message}") } } if (param.packageName == "com.google.android.settings") { - Log.i(TAG, "Settings app detected, hooking Bluetooth icon handling") - try { - val headerControllerClass = param.classLoader.loadClass( - "com.google.android.settings.bluetooth.AdvancedBluetoothDetailsHeaderController") - - val updateIconMethod = headerControllerClass.getDeclaredMethod( - "updateIcon", - ImageView::class.java, - String::class.java) - - hook(updateIconMethod, BluetoothIconHooker::class.java) - Log.i(TAG, "Successfully hooked updateIcon method in Bluetooth settings") - } catch (e: Exception) { - Log.e(TAG, "Failed to hook Bluetooth icon handler: ${e.message}", e) - } + hookSettingsController(param, "com.google.android.settings.bluetooth.AdvancedBluetoothDetailsHeaderController") } if (param.packageName == "com.android.settings") { - Log.i(TAG, "Settings app detected, hooking Bluetooth icon handling") - try { - val headerControllerClass = param.classLoader.loadClass( - "com.android.settings.bluetooth.AdvancedBluetoothDetailsHeaderController") - - val updateIconMethod = headerControllerClass.getDeclaredMethod( - "updateIcon", - ImageView::class.java, - String::class.java) - - hook(updateIconMethod, BluetoothIconHooker::class.java) - Log.i(TAG, "Successfully hooked updateIcon method in Bluetooth settings") - } catch (e: Exception) { - Log.e(TAG, "Failed to hook Bluetooth icon handler: ${e.message}", e) - } + hookSettingsController(param, "com.android.settings.bluetooth.AdvancedBluetoothDetailsHeaderController") } } - @XposedHooker - class BluetoothIconHooker : XposedInterface.Hooker { - companion object { - @JvmStatic - @AfterInvocation - fun afterUpdateIcon(callback: AfterHookCallback) { - Log.i(TAG, "BluetoothIconHooker called with args: ${callback.args.joinToString(", ")}") + private fun hookSettingsController(param: PackageLoadedParam, className: String) { + log(Log.INFO, TAG, "Settings app detected, hooking Bluetooth icon handling") + try { + val headerControllerClass = Class.forName(className, false, param.defaultClassLoader) + val updateIconMethod = headerControllerClass.getDeclaredMethod( + "updateIcon", + ImageView::class.java, + String::class.java + ) + + hook(updateIconMethod).intercept { chain -> try { - val imageView = callback.args[0] as ImageView - val iconUri = callback.args[1] as String + log(Log.INFO, TAG, "Bluetooth icon hook called with args: ${chain.args.joinToString(", ")}") + val imageView = chain.args[0] as? ImageView + val iconUri = chain.args[1] as? String + + if (imageView == null || iconUri == null) { + return@intercept chain.proceed() + } val uri = iconUri.toUri() - if (uri.toString().startsWith("android.resource://me.kavishdevar.librepods")) { - Log.i(TAG, "Handling AirPods icon URI: $uri") + if (!uri.toString().startsWith("android.resource://me.kavishdevar.librepods")) { + return@intercept chain.proceed() + } + log(Log.INFO, TAG, "Handling AirPods icon URI: $uri") + + Handler(Looper.getMainLooper()).post { try { val context = imageView.context + val packageName = uri.authority ?: return@post + val packageContext = context.createPackageContext( + packageName, + Context.CONTEXT_IGNORE_SECURITY + ) - android.os.Handler(android.os.Looper.getMainLooper()).post { - try { - val packageName = uri.authority - val packageContext = context.createPackageContext( - packageName, - Context.CONTEXT_IGNORE_SECURITY + val resPath = uri.pathSegments + if (resPath.size >= 2 && resPath[0] == "drawable") { + val resourceName = resPath[1] + val resourceId = packageContext.resources.getIdentifier( + resourceName, "drawable", packageName + ) + + if (resourceId != 0) { + val drawable = packageContext.resources.getDrawable( + resourceId, packageContext.theme ) - - val resPath = uri.pathSegments - if (resPath.size >= 2 && resPath[0] == "drawable") { - val resourceName = resPath[1] - val resourceId = packageContext.resources.getIdentifier( - resourceName, "drawable", packageName - ) - - if (resourceId != 0) { - val drawable = packageContext.resources.getDrawable( - resourceId, packageContext.theme - ) - - imageView.setImageDrawable(drawable) - imageView.alpha = 1.0f - - callback.result = null - - Log.i(TAG, "Successfully loaded icon from resource: $resourceName") - } else { - Log.e(TAG, "Resource not found: $resourceName") - } - } - } catch (e: Exception) { - Log.e(TAG, "Error loading resource from URI $uri: ${e.message}") + imageView.setImageDrawable(drawable) + imageView.alpha = 1.0f + log(Log.INFO, TAG, "Successfully loaded icon from resource: $resourceName") + } else { + log(Log.ERROR, TAG, "Resource not found: $resourceName") } } } catch (e: Exception) { - Log.e(TAG, "Error accessing context: ${e.message}") + log(Log.ERROR, TAG, "Error loading resource from URI $uri: ${e.message}") } } + null } catch (e: Exception) { - Log.e(TAG, "Error in BluetoothIconHooker: ${e.message}") - e.printStackTrace() + log(Log.ERROR, TAG, "Error in Bluetooth icon hook: ${e.message}") + chain.proceed() } } + + log(Log.INFO, TAG, "Successfully hooked updateIcon method in Bluetooth settings") + } catch (e: Exception) { + log(Log.ERROR, TAG, "Failed to hook Bluetooth icon handler: ${e.message}") } } - - override fun getApplicationInfo(): ApplicationInfo { - return super.applicationInfo - } +} + + +object NativeBridge { + external fun setSdpHook(enabled: Boolean) } diff --git a/android/app/src/xposed/resources/META-INF/xposed/module.prop b/android/app/src/xposed/resources/META-INF/xposed/module.prop index 8dc7ff3..c3975fe 100644 --- a/android/app/src/xposed/resources/META-INF/xposed/module.prop +++ b/android/app/src/xposed/resources/META-INF/xposed/module.prop @@ -1,3 +1,3 @@ -minApiVersion=100 -targetApiVersion=100 +minApiVersion=101 +targetApiVersion=101 staticScope=true diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index de2c0fa..964d282 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -17,6 +17,7 @@ materialIconsCore = "1.7.8" backdrop = "2.0.0-alpha03" billing = "8.3.0" hilt = "2.59.2" +xposed = "101.0.0" [libraries] accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" } @@ -44,6 +45,8 @@ backdrop = { group = "io.github.kyant0", name = "backdrop", version.ref = "backd billing = { group = "com.android.billingclient", name = "billing-ktx", version.ref = "billing" } hilt = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } +libxposed-api = { group = "io.github.libxposed", name = "api", version.ref = "xposed" } +libxposed-service = { group = "io.github.libxposed", name = "service", version.ref = "xposed" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }