android: something

This commit is contained in:
Kavish Devar
2026-04-22 23:28:54 +05:30
parent 45915ca560
commit 9355a0cef1
97 changed files with 2639 additions and 1034 deletions
+13 -13
View File
@@ -1,4 +1,3 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.util.Properties import java.util.Properties
plugins { plugins {
@@ -27,10 +26,10 @@ android {
defaultConfig { defaultConfig {
applicationId = "me.kavishdevar.librepods" applicationId = "me.kavishdevar.librepods"
minSdk = 36 minSdk = 33
targetSdk = 37 targetSdk = 37
versionCode = 21 versionCode = 27
versionName = "0.2.0-beta.1" versionName = "0.2.0"
} }
buildTypes { buildTypes {
release { release {
@@ -45,14 +44,19 @@ android {
arguments += "-DCMAKE_BUILD_TYPE=Release" arguments += "-DCMAKE_BUILD_TYPE=Release"
} }
} }
buildConfigField("Boolean", "PLAY_BUILD", "false")
signingConfig = signingConfigs.getByName("release") signingConfig = signingConfigs.getByName("release")
} }
debug { debug {
buildConfigField("Boolean", "PLAY_BUILD", "false")
signingConfig = signingConfigs.getByName("release") signingConfig = signingConfigs.getByName("release")
} }
create("playRelease") { create("playRelease") {
initWith(getByName("release")) initWith(getByName("release"))
versionNameSuffix = "-play" buildConfigField("Boolean", "PLAY_BUILD", "true")
}
create("playDebug") {
initWith(getByName("debug"))
buildConfigField("Boolean", "PLAY_BUILD", "true") buildConfigField("Boolean", "PLAY_BUILD", "true")
} }
} }
@@ -60,11 +64,6 @@ android {
sourceCompatibility = JavaVersion.VERSION_21 sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21
} }
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
}
buildFeatures { buildFeatures {
compose = true compose = true
viewBinding = true viewBinding = true
@@ -105,7 +104,7 @@ android {
arguments += "-DIS_XPOSED=ON" arguments += "-DIS_XPOSED=ON"
} }
} }
applicationIdSuffix = ".xposed" versionNameSuffix = "-xposed"
} }
} }
} }
@@ -134,9 +133,10 @@ dependencies {
implementation(libs.aboutlibraries) implementation(libs.aboutlibraries)
implementation(libs.aboutlibraries.compose.m3) implementation(libs.aboutlibraries.compose.m3)
implementation(libs.backdrop) implementation(libs.backdrop)
implementation(libs.hilt) // implementation(libs.hilt)
// implementation(libs.hilt.compiler) // 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) add("playReleaseImplementation", libs.billing)
} }
+3 -2
View File
@@ -36,6 +36,7 @@
<uses-permission android:name="com.android.vending.BILLING" /> <uses-permission android:name="com.android.vending.BILLING" />
<application <application
android:name=".LibrePodsApplication"
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:enableOnBackInvokedCallback="true" android:enableOnBackInvokedCallback="true"
@@ -48,7 +49,7 @@
android:description="@string/app_description" android:description="@string/app_description"
tools:ignore="UnusedAttribute" > tools:ignore="UnusedAttribute" >
<receiver <receiver
android:name=".widgets.NoiseControlWidget" android:name=".presentation.widgets.NoiseControlWidget"
android:exported="false"> android:exported="false">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" /> <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
@@ -60,7 +61,7 @@
</receiver> </receiver>
<receiver <receiver
android:name=".widgets.BatteryWidget" android:name=".presentation.widgets.BatteryWidget"
android:exported="false"> android:exported="false">
<intent-filter> <intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" /> <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
@@ -22,6 +22,7 @@ package me.kavishdevar.librepods
// import me.kavishdevar.librepods.screens.Onboarding // import me.kavishdevar.librepods.screens.Onboarding
// import me.kavishdevar.librepods.utils.RadareOffsetFinder // import me.kavishdevar.librepods.utils.RadareOffsetFinder
//import dagger.hilt.android.AndroidEntryPoint
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.ComponentName import android.content.ComponentName
@@ -51,7 +52,9 @@ import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@@ -77,6 +81,8 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue 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.Color
import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.vector.ImageVector 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.LocalContext
import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -111,40 +119,44 @@ import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberMultiplePermissionsState import com.google.accompanist.permissions.rememberMultiplePermissionsState
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop 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.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.rememberHazeState
import kotlinx.coroutines.delay
import me.kavishdevar.librepods.billing.BillingManager import me.kavishdevar.librepods.billing.BillingManager
import me.kavishdevar.librepods.billing.BillingProviderFactory import me.kavishdevar.librepods.billing.BillingProviderFactory
import me.kavishdevar.librepods.composables.StyledIconButton import me.kavishdevar.librepods.data.AirPodsNotifications
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.data.ControlCommandRepository import me.kavishdevar.librepods.data.ControlCommandRepository
import me.kavishdevar.librepods.screens.AccessibilitySettingsScreen import me.kavishdevar.librepods.presentation.components.ConfirmationDialog
import me.kavishdevar.librepods.screens.AdaptiveStrengthScreen import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.screens.AirPodsSettingsScreen import me.kavishdevar.librepods.presentation.screens.AccessibilitySettingsScreen
import me.kavishdevar.librepods.screens.AppSettingsScreen import me.kavishdevar.librepods.presentation.screens.AdaptiveStrengthScreen
import me.kavishdevar.librepods.screens.CameraControlScreen import me.kavishdevar.librepods.presentation.screens.AirPodsSettingsScreen
import me.kavishdevar.librepods.screens.DebugScreen import me.kavishdevar.librepods.presentation.screens.AppSettingsScreen
import me.kavishdevar.librepods.screens.HeadTrackingScreen import me.kavishdevar.librepods.presentation.screens.CameraControlScreen
import me.kavishdevar.librepods.screens.HearingAidAdjustmentsScreen import me.kavishdevar.librepods.presentation.screens.DebugScreen
import me.kavishdevar.librepods.screens.HearingAidScreen import me.kavishdevar.librepods.presentation.screens.HeadTrackingScreen
import me.kavishdevar.librepods.screens.HearingProtectionScreen import me.kavishdevar.librepods.presentation.screens.HearingAidAdjustmentsScreen
import me.kavishdevar.librepods.screens.LongPress import me.kavishdevar.librepods.presentation.screens.HearingAidScreen
import me.kavishdevar.librepods.screens.OpenSourceLicensesScreen import me.kavishdevar.librepods.presentation.screens.HearingProtectionScreen
import me.kavishdevar.librepods.screens.RenameScreen import me.kavishdevar.librepods.presentation.screens.LongPress
import me.kavishdevar.librepods.screens.TransparencySettingsScreen import me.kavishdevar.librepods.presentation.screens.OpenSourceLicensesScreen
import me.kavishdevar.librepods.screens.UpdateHearingTestScreen import me.kavishdevar.librepods.presentation.screens.PurchaseScreen
import me.kavishdevar.librepods.screens.VersionScreen 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.services.AirPodsService
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
import me.kavishdevar.librepods.utils.isSupported import me.kavishdevar.librepods.utils.isSupported
import me.kavishdevar.librepods.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.viewmodel.AppSettingsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
lateinit var serviceConnection: ServiceConnection lateinit var serviceConnection: ServiceConnection
lateinit var connectionStatusReceiver: BroadcastReceiver lateinit var connectionStatusReceiver: BroadcastReceiver
@AndroidEntryPoint //@AndroidEntryPoint
@ExperimentalMaterial3Api @ExperimentalMaterial3Api
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
companion object { companion object {
@@ -161,7 +173,7 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
LibrePodsTheme { _root_ide_package_.me.kavishdevar.librepods.presentation.theme.LibrePodsTheme {
Main() Main()
} }
} }
@@ -203,28 +215,153 @@ class MainActivity : ComponentActivity() {
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@SuppressLint("MissingPermission", "InlinedApi", "UnspecifiedRegisterReceiverFlag") @SuppressLint("MissingPermission", "InlinedApi", "UnspecifiedRegisterReceiverFlag")
@OptIn(ExperimentalPermissionsApi::class) @OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
@Composable @Composable
fun Main() { 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( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.hazeSource(hazeState)
.background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)), .background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Box (
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.", modifier = Modifier
color = if (isSystemInDarkTheme()) Color.White else Color.Black, .fillMaxSize()
textAlign = TextAlign.Center, .then(
modifier = Modifier.padding(16.dp) 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 return
} }
val isConnected = remember { mutableStateOf(false) } val isConnected = remember { mutableStateOf(false) }
val context = LocalContext.current
var canDrawOverlays by remember { mutableStateOf(Settings.canDrawOverlays(context)) } var canDrawOverlays by remember { mutableStateOf(Settings.canDrawOverlays(context)) }
val overlaySkipped = remember { val overlaySkipped = remember {
mutableStateOf( mutableStateOf(
@@ -263,7 +400,7 @@ fun Main() {
val airPodsService = remember { mutableStateOf<AirPodsService?>(null) } val airPodsService = remember { mutableStateOf<AirPodsService?>(null) }
val viewModel = remember(airPodsService.value) { val airPodsViewModel = remember(airPodsService.value) {
airPodsService.value?.let { service -> airPodsService.value?.let { service ->
AirPodsViewModel( AirPodsViewModel(
service = service, service = service,
@@ -317,19 +454,20 @@ fun Main() {
) )
}) { }) {
composable("settings") { composable("settings") {
if (viewModel != null) AirPodsSettingsScreen(viewModel, navController) if (airPodsViewModel != null) AirPodsSettingsScreen(airPodsViewModel, navController)
} }
composable("debug") { composable("debug") {
DebugScreen(navController = navController) DebugScreen(navController = navController)
} }
composable("long_press/{bud}") { navBackStackEntry -> composable("long_press/{bud}") { navBackStackEntry ->
if (viewModel != null) LongPress( if (airPodsViewModel != null) LongPress(
viewModel = viewModel, viewModel = airPodsViewModel,
name = navBackStackEntry.arguments?.getString("bud")!! name = navBackStackEntry.arguments?.getString("bud")!!,
navController = navController
) )
} }
composable("rename") { composable("rename") {
if (viewModel != null) RenameScreen(viewModel) if (airPodsViewModel != null) RenameScreen(airPodsViewModel)
} }
composable("app_settings") { composable("app_settings") {
val appSettingsViewModel: AppSettingsViewModel = viewModel() val appSettingsViewModel: AppSettingsViewModel = viewModel()
@@ -339,37 +477,41 @@ fun Main() {
// TroubleshootingScreen(navController) // TroubleshootingScreen(navController)
// } // }
composable("head_tracking") { composable("head_tracking") {
if (viewModel != null) HeadTrackingScreen(viewModel) if (airPodsViewModel != null) HeadTrackingScreen(airPodsViewModel, navController)
} }
composable("accessibility") { composable("accessibility") {
if (viewModel != null) AccessibilitySettingsScreen(viewModel, navController) if (airPodsViewModel != null) AccessibilitySettingsScreen(airPodsViewModel, navController)
} }
composable("transparency_customization") { composable("transparency_customization") {
if (viewModel != null) TransparencySettingsScreen(viewModel) if (airPodsViewModel != null) TransparencySettingsScreen(airPodsViewModel)
} }
composable("hearing_aid") { composable("hearing_aid") {
if (viewModel != null) HearingAidScreen(viewModel, navController) if (airPodsViewModel != null) HearingAidScreen(airPodsViewModel, navController)
} }
composable("hearing_aid_adjustments") { composable("hearing_aid_adjustments") {
if (viewModel != null) HearingAidAdjustmentsScreen(viewModel) if (airPodsViewModel != null) HearingAidAdjustmentsScreen(airPodsViewModel)
} }
composable("adaptive_strength") { composable("adaptive_strength") {
if (viewModel != null) AdaptiveStrengthScreen(viewModel) if (airPodsViewModel != null) AdaptiveStrengthScreen(airPodsViewModel, navController)
} }
composable("camera_control") { composable("camera_control") {
if (viewModel != null) CameraControlScreen(viewModel) if (airPodsViewModel != null) CameraControlScreen(airPodsViewModel)
} }
composable("open_source_licenses") { composable("open_source_licenses") {
OpenSourceLicensesScreen(navController) OpenSourceLicensesScreen(navController)
} }
composable("update_hearing_test") { composable("update_hearing_test") {
if (viewModel != null) UpdateHearingTestScreen() if (airPodsViewModel != null) UpdateHearingTestScreen()
} }
composable("version_info") { composable("version_info") {
if (viewModel != null) VersionScreen(viewModel) if (airPodsViewModel != null) VersionScreen(airPodsViewModel)
} }
composable("hearing_protection") { composable("hearing_protection") {
if (viewModel != null) HearingProtectionScreen(viewModel) if (airPodsViewModel != null) HearingProtectionScreen(airPodsViewModel)
}
composable("purchase_screen") {
val purchaseViewModel: PurchaseViewModel = viewModel()
PurchaseScreen(purchaseViewModel, navController)
} }
} }
} }
@@ -85,15 +85,15 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.composables.AdaptiveRainbowBrush import me.kavishdevar.librepods.presentation.components.AdaptiveRainbowBrush
import me.kavishdevar.librepods.composables.ControlCenterNoiseControlSegmentedButton import me.kavishdevar.librepods.presentation.components.ControlCenterNoiseControlSegmentedButton
import me.kavishdevar.librepods.composables.IconAreaSize import me.kavishdevar.librepods.presentation.components.IconAreaSize
import me.kavishdevar.librepods.composables.VerticalVolumeSlider import me.kavishdevar.librepods.presentation.components.VerticalVolumeSlider
import me.kavishdevar.librepods.constants.AirPodsNotifications import me.kavishdevar.librepods.data.AirPodsNotifications
import me.kavishdevar.librepods.constants.NoiseControlMode import me.kavishdevar.librepods.data.NoiseControlMode
import me.kavishdevar.librepods.services.AirPodsService import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.bluetooth.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs import kotlin.math.abs
@@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.StateFlow
interface BillingProvider { interface BillingProvider {
val isPremium: StateFlow<Boolean> val isPremium: StateFlow<Boolean>
val price: StateFlow<String>
fun purchase(activity: Activity) fun purchase(activity: Activity)
fun queryPurchases()
} }
@@ -27,7 +27,7 @@ object BillingProviderFactory {
return if (BuildConfig.PLAY_BUILD) { return if (BuildConfig.PLAY_BUILD) {
PlayBillingProvider(context) PlayBillingProvider(context)
} else { } else {
FOSSBillingProvider() FOSSBillingProvider(context)
} }
} }
} }
@@ -19,12 +19,56 @@
package me.kavishdevar.librepods.billing package me.kavishdevar.librepods.billing
import android.app.Activity 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class FOSSBillingProvider : BillingProvider { class FOSSBillingProvider(context: Context): BillingProvider {
private val _isPremium = MutableStateFlow(true) private val _isPremium = MutableStateFlow(false)
override val isPremium: StateFlow<Boolean> = _isPremium override val isPremium: StateFlow<Boolean> = _isPremium
override fun purchase(activity: Activity) { } private val _price = MutableStateFlow("Any")
override val price: StateFlow<String> = _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
}
}
} }
@@ -55,6 +55,10 @@ class PlayBillingProvider(
private val _isPremium = MutableStateFlow(false) private val _isPremium = MutableStateFlow(false)
override val isPremium: StateFlow<Boolean> = _isPremium override val isPremium: StateFlow<Boolean> = _isPremium
private val _price = MutableStateFlow("unknown")
override val price: StateFlow<String> = _price
private var productDetails: ProductDetails? = null private var productDetails: ProductDetails? = null
private val billingClient = BillingClient.newBuilder(context) private val billingClient = BillingClient.newBuilder(context)
@@ -102,6 +106,13 @@ class PlayBillingProvider(
if (result.billingResult.responseCode == BillingClient.BillingResponseCode.OK) { if (result.billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
productDetails = result.productDetailsList?.firstOrNull() productDetails = result.productDetailsList?.firstOrNull()
Log.d(TAG, "Product loaded: ${productDetails?.name}") Log.d(TAG, "Product loaded: ${productDetails?.name}")
val priceString = productDetails
?.oneTimePurchaseOfferDetails
?.formattedPrice
if (priceString != null) {
_price.value = priceString
}
} else { } else {
Log.w(TAG, "queryProductDetails failed: ${result.billingResult.debugMessage}") 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 // it.products.contains(PREMIUM_PRODUCT_ID) && it.purchaseState == Purchase.PurchaseState.PURCHASED
// } // }
// //
// if (purchase != null) { // if (navigateToPurchase != null) {
// val consumeParams = ConsumeParams.newBuilder() // val consumeParams = ConsumeParams.newBuilder()
// .setPurchaseToken(purchase.purchaseToken) // .setPurchaseToken(navigateToPurchase.purchaseToken)
// .build() // .build()
// scope.launch { // scope.launch {
// billingClient.consumeAsync(consumeParams) { _, _ ->} // billingClient.consumeAsync(consumeParams) { _, _ ->}
@@ -184,4 +195,10 @@ class PlayBillingProvider(
Log.e(TAG, "Acknowledgement failed: ${result.debugMessage}") Log.e(TAG, "Acknowledgement failed: ${result.debugMessage}")
} }
} }
override fun queryPurchases() {
scope.launch {
queryExistingPurchases()
}
}
} }
@@ -18,7 +18,7 @@
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.bluetooth
import android.util.Log import android.util.Log
import java.nio.ByteBuffer import java.nio.ByteBuffer
@@ -61,8 +61,7 @@ class AACPManager {
private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00) private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00)
data class ControlCommandStatus( data class ControlCommandStatus(
val identifier: ControlCommandIdentifiers, val identifier: ControlCommandIdentifiers, val value: ByteArray
val value: ByteArray
) { ) {
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
@@ -85,42 +84,31 @@ class AACPManager {
// @Suppress("unused") // @Suppress("unused")
enum class ControlCommandIdentifiers(val value: Byte) { enum class ControlCommandIdentifiers(val value: Byte) {
MIC_MODE(0x01), MIC_MODE(0x01), BUTTON_SEND_MODE(0x05), VOICE_TRIGGER(0x12), SINGLE_CLICK_MODE(0x14), DOUBLE_CLICK_MODE(
BUTTON_SEND_MODE(0x05), 0x15
VOICE_TRIGGER(0x12), ),
SINGLE_CLICK_MODE(0x14), CLICK_HOLD_MODE(0x16), DOUBLE_CLICK_INTERVAL(0x17), CLICK_HOLD_INTERVAL(0x18), LISTENING_MODE_CONFIGS(
DOUBLE_CLICK_MODE(0x15), 0x1A
CLICK_HOLD_MODE(0x16), ),
DOUBLE_CLICK_INTERVAL(0x17), ONE_BUD_ANC_MODE(0x1B), CROWN_ROTATION_DIRECTION(0x1C), LISTENING_MODE(0x0D), AUTO_ANSWER_MODE(
CLICK_HOLD_INTERVAL(0x18), 0x1E
LISTENING_MODE_CONFIGS(0x1A), ),
ONE_BUD_ANC_MODE(0x1B), CHIME_VOLUME(0x1F), VOLUME_SWIPE_INTERVAL(0x23), CALL_MANAGEMENT_CONFIG(0x24), VOLUME_SWIPE_MODE(
CROWN_ROTATION_DIRECTION(0x1C), 0x25
LISTENING_MODE(0x0D), ),
AUTO_ANSWER_MODE(0x1E), ADAPTIVE_VOLUME_CONFIG(0x26), SOFTWARE_MUTE_CONFIG(0x27), CONVERSATION_DETECT_CONFIG(
CHIME_VOLUME(0x1F), 0x28
VOLUME_SWIPE_INTERVAL(0x23), ),
CALL_MANAGEMENT_CONFIG(0x24), SSL(0x29), HEARING_AID(0x2C), AUTO_ANC_STRENGTH(0x2E), HPS_GAIN_SWIPE(0x2F), HRM_STATE(
VOLUME_SWIPE_MODE(0x25), 0x30
ADAPTIVE_VOLUME_CONFIG(0x26), ),
SOFTWARE_MUTE_CONFIG(0x27), IN_CASE_TONE_CONFIG(0x31), SIRI_MULTITONE_CONFIG(0x32), HEARING_ASSIST_CONFIG(0x33), ALLOW_OFF_OPTION(
CONVERSATION_DETECT_CONFIG(0x28), 0x34
SSL(0x29), ),
HEARING_AID(0x2C), 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 ¯\_(ツ)_/¯
AUTO_ANC_STRENGTH(0x2E), EAR_DETECTION_CONFIG(0x0A), AUTOMATIC_CONNECTION_CONFIG(0x20), OWNS_CONNECTION(0x06), PPE_TOGGLE_CONFIG(
HPS_GAIN_SWIPE(0x2F), 0x37
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); PPE_CAP_LEVEL_CONFIG(0x38);
companion object { companion object {
@@ -130,59 +118,44 @@ class AACPManager {
} }
enum class ProximityKeyType(val value: Byte) { enum class ProximityKeyType(val value: Byte) {
IRK(0x01), IRK(0x01), ENC_KEY(0x04);
ENC_KEY(0x04);
companion object { companion object {
fun fromByte(byte: Byte): ProximityKeyType = fun fromByte(byte: Byte): ProximityKeyType = entries.find { it.value == byte }
ProximityKeyType.entries.find { it.value == byte } ?: throw IllegalArgumentException("Unknown ProximityKeyType: $byte")
?: throw IllegalArgumentException("Unknown ProximityKeyType: $byte")
} }
} }
enum class StemPressType(val value: Byte) { enum class StemPressType(val value: Byte) {
SINGLE_PRESS(0x05), SINGLE_PRESS(0x05), DOUBLE_PRESS(0x06), TRIPLE_PRESS(0x07), LONG_PRESS(0x08);
DOUBLE_PRESS(0x06),
TRIPLE_PRESS(0x07),
LONG_PRESS(0x08);
companion object { companion object {
fun fromByte(byte: Byte): StemPressType? = fun fromByte(byte: Byte): StemPressType? = entries.find { it.value == byte }
entries.find { it.value == byte }
} }
} }
enum class StemPressBudType(val value: Byte) { enum class StemPressBudType(val value: Byte) {
LEFT(0x01), LEFT(0x01), RIGHT(0x02);
RIGHT(0x02);
companion object { companion object {
fun fromByte(byte: Byte): StemPressBudType? = fun fromByte(byte: Byte): StemPressBudType? = entries.find { it.value == byte }
entries.find { it.value == byte }
} }
} }
enum class AudioSourceType(val value: Byte) { enum class AudioSourceType(val value: Byte) {
NONE(0x00), NONE(0x00), CALL(0x01), MEDIA(0x02);
CALL(0x01),
MEDIA(0x02);
companion object { companion object {
fun fromByte(byte: Byte): AudioSourceType? = fun fromByte(byte: Byte): AudioSourceType? = entries.find { it.value == byte }
entries.find { it.value == byte }
} }
} }
data class AudioSource( data class AudioSource(
val mac: String, val mac: String, val type: AudioSourceType
val type: AudioSourceType
) )
data class ConnectedDevice( data class ConnectedDevice(
val mac: String, val mac: String, val info1: Byte, val info2: Byte, var type: String?
val info1: Byte,
val info2: Byte,
var type: String?
) )
data class AirPodsInformation( data class AirPodsInformation(
@@ -231,8 +204,7 @@ class AACPManager {
} }
private fun setControlCommandStatusValue( private fun setControlCommandStatusValue(
identifier: ControlCommandIdentifiers, identifier: ControlCommandIdentifiers, value: ByteArray
value: ByteArray
) { ) {
val existingStatus = getControlCommandStatus(identifier) val existingStatus = getControlCommandStatus(identifier)
if (existingStatus == value) { if (existingStatus == value) {
@@ -289,15 +261,13 @@ class AACPManager {
} }
fun registerControlCommandListener( fun registerControlCommandListener(
identifier: ControlCommandIdentifiers, identifier: ControlCommandIdentifiers, callback: ControlCommandListener
callback: ControlCommandListener
) { ) {
controlCommandListeners.getOrPut(identifier) { mutableListOf() }.add(callback) controlCommandListeners.getOrPut(identifier) { mutableListOf() }.add(callback)
} }
fun unregisterControlCommandListener( fun unregisterControlCommandListener(
identifier: ControlCommandIdentifiers, identifier: ControlCommandIdentifiers, callback: ControlCommandListener
callback: ControlCommandListener
) { ) {
controlCommandListeners[identifier]?.remove(callback) controlCommandListeners[identifier]?.remove(callback)
} }
@@ -332,8 +302,7 @@ class AACPManager {
fun sendControlCommand(identifier: Byte, value: ByteArray): Boolean { fun sendControlCommand(identifier: Byte, value: ByteArray): Boolean {
val controlPacket = createControlCommandPacket(identifier, value) val controlPacket = createControlCommandPacket(identifier, value)
setControlCommandStatusValue( setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(identifier) ?: return false, ControlCommandIdentifiers.fromByte(identifier) ?: return false, value
value
) )
return sendDataPacket(controlPacket) return sendDataPacket(controlPacket)
} }
@@ -342,16 +311,14 @@ class AACPManager {
fun sendControlCommand(identifier: Byte, value: Byte): Boolean { fun sendControlCommand(identifier: Byte, value: Byte): Boolean {
val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value)) val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value))
setControlCommandStatusValue( setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(identifier) ?: return false, ControlCommandIdentifiers.fromByte(identifier) ?: return false, byteArrayOf(value)
byteArrayOf(value)
) )
return sendDataPacket(controlPacket) return sendDataPacket(controlPacket)
} }
fun sendControlCommand(identifier: Byte, value: Boolean): Boolean { fun sendControlCommand(identifier: Byte, value: Boolean): Boolean {
val controlPacket = createControlCommandPacket( val controlPacket = createControlCommandPacket(
identifier, identifier, if (value) byteArrayOf(0x01) else byteArrayOf(0x02)
if (value) byteArrayOf(0x01) else byteArrayOf(0x02)
) )
setControlCommandStatusValue( setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(identifier) ?: return false, ControlCommandIdentifiers.fromByte(identifier) ?: return false,
@@ -371,8 +338,7 @@ class AACPManager {
fun parseProximityKeysResponse(data: ByteArray): Map<ProximityKeyType, ByteArray> { fun parseProximityKeysResponse(data: ByteArray): Map<ProximityKeyType, ByteArray> {
Log.d( Log.d(
TAG, TAG, "Parsing Proximity Keys Response: ${data.joinToString(" ") { "%02X".format(it) }}"
"Parsing Proximity Keys Response: ${data.joinToString(" ") { "%02X".format(it) }}"
) )
if (data.size < 4) { if (data.size < 4) {
throw IllegalArgumentException("Data array too short to parse Proximity Keys Response") throw IllegalArgumentException("Data array too short to parse Proximity Keys Response")
@@ -400,11 +366,9 @@ class AACPManager {
keys[ProximityKeyType.fromByte(keyType)] = key keys[ProximityKeyType.fromByte(keyType)] = key
offset += keyLength offset += keyLength
Log.d( Log.d(
TAG, TAG, "Parsed Proximity Key: Type: ${keyType}, Length: $keyLength, Key: ${
"Parsed Proximity Key: Type: ${keyType}, Length: $keyLength, Key: ${ key.joinToString(" ") { "%02X".format(it) }
key.joinToString(" ") { "%02X".format(it) } }")
}"
)
} }
return keys return keys
} }
@@ -424,26 +388,21 @@ class AACPManager {
fun receivePacket(packet: ByteArray) { fun receivePacket(packet: ByteArray) {
if (!packet.toHexString().startsWith("04000400")) { if (!packet.toHexString().startsWith("04000400")) {
Log.w( Log.w(
TAG, TAG, "Received packet does not start with expected header: ${
"Received packet does not start with expected header: ${ packet.joinToString(" ") {
packet.joinToString(" ") { "%02X".format(it)
"%02X".format(it) }
} }")
}"
)
return return
} }
if (packet.size < 6) { if (packet.size < 6) {
Log.w( Log.w(
TAG, TAG, "Received packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}"
"Received packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}"
) )
return return
} }
val opcode = packet[4] when (val opcode = packet[4]) {
when (opcode) {
Opcodes.BATTERY_INFO -> { Opcodes.BATTERY_INFO -> {
callback?.onBatteryInfoReceived(packet) callback?.onBatteryInfoReceived(packet)
} }
@@ -458,23 +417,23 @@ class AACPManager {
TAG, TAG,
"Control command received: ${controlCommand.identifier.toHexString()} - ${ "Control command received: ${controlCommand.identifier.toHexString()} - ${
controlCommand.value.joinToString(" ") { "%02X".format(it) } controlCommand.value.joinToString(" ") { "%02X".format(it) }
}" }")
)
val controlCommandListText = try { val controlCommandListText = try {
controlCommandStatusList.joinToString(", ") { it -> controlCommandStatusList.joinToString(", ") { it ->
"${it.identifier.name} (${it.identifier.value.toHexString()}) - ${ "${it.identifier.name} (${it.identifier.value.toHexString()}) - ${
it.value.joinToString( it.value.joinToString(
" " " "
) { "%02X".format(it) } ) { "%02X".format(it) }
}" }"
}
} catch (e: Exception) {
e.message
} }
} catch (e: Exception) {
e.message
}
Log.d( Log.d(
TAG, "Control command list is now: $controlCommandListText") TAG, "Control command list is now: $controlCommandListText"
)
val controlCommandIdentifier = val controlCommandIdentifier =
ControlCommandIdentifiers.fromByte(controlCommand.identifier) ControlCommandIdentifiers.fromByte(controlCommand.identifier)
@@ -508,13 +467,11 @@ class AACPManager {
Opcodes.HEADTRACKING -> { Opcodes.HEADTRACKING -> {
if (packet.size < 70) { if (packet.size < 70) {
Log.w( Log.w(
TAG, TAG, "Received HEADTRACKING packet too short: ${
"Received HEADTRACKING packet too short: ${ packet.joinToString(" ") {
packet.joinToString(" ") { "%02X".format(it)
"%02X".format(it) }
} }")
}"
)
return return
} }
callback?.onHeadTrackingReceived(packet) callback?.onHeadTrackingReceived(packet)
@@ -546,7 +503,8 @@ class AACPManager {
Opcodes.SMART_ROUTING_RESP -> { Opcodes.SMART_ROUTING_RESP -> {
val packetString = packet.decodeToString() 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")) { // if (connectedDevices.find { it.mac == sender }?.type == null && packetString.contains("btName")) {
// val nameStartIndex = packetString.indexOf("btName") + 8 // val nameStartIndex = packetString.indexOf("btName") + 8
@@ -566,9 +524,15 @@ class AACPManager {
} else if ("Android" in packetString) { } else if ("Android" in packetString) {
connectedDevices.find { it.mac == sender }?.type = "Android" 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")) { if (packetString.contains("SetOwnershipToFalse")) {
callback?.onOwnershipToFalseRequest(sender, packetString.contains("ReverseBannerTapped")) callback?.onOwnershipToFalseRequest(
sender,
packetString.contains("ReverseBannerTapped")
)
} }
if (packetString.contains("ShowNearbyUI")) { if (packetString.contains("ShowNearbyUI")) {
callback?.onShowNearbyUI(sender) callback?.onShowNearbyUI(sender)
@@ -595,15 +559,19 @@ class AACPManager {
eqOnPhone = (packet[11] == 0x01.toByte()) eqOnPhone = (packet[11] == 0x01.toByte())
// there are 4 eqs. i am not sure what those are for, maybe all 4 listening modes, or maybe phone+media left+right, but then there shouldn't be another flag for phone/media visible. just directly the EQ... weird. // there are 4 eqs. i am not sure what those are for, maybe all 4 listening modes, or maybe phone+media left+right, but then there shouldn't be another flag for phone/media visible. just directly the EQ... weird.
// the EQs are little endian floats // the EQs are little endian floats
val eq1 = ByteBuffer.wrap(packet, 12, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer() val eq1 =
val eq2 = ByteBuffer.wrap(packet, 44, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer() ByteBuffer.wrap(packet, 12, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
val eq3 = ByteBuffer.wrap(packet, 76, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer() ByteBuffer.wrap(packet, 44, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
val eq4 = ByteBuffer.wrap(packet, 108, 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 // for now, taking just the first EQ
eqData = FloatArray(8) { i -> eq1.get(i) } 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) callback?.onEQPacketReceived(eqData)
} }
@@ -613,8 +581,9 @@ class AACPManager {
val information = parseInformationPacket(packet) val information = parseInformationPacket(packet)
callback?.onDeviceInformationReceived(information) callback?.onDeviceInformationReceived(information)
} }
else -> { else -> {
Log.d(TAG, "Unknown opcode received: ${opcode.toHexString()}") Log.d(TAG, "Unhandled opcode received: ${opcode.toHexString()}")
callback?.onUnknownPacketReceived(packet) callback?.onUnknownPacketReceived(packet)
} }
} }
@@ -644,10 +613,22 @@ class AACPManager {
fun createHandshakePacket(): ByteArray { fun createHandshakePacket(): ByteArray {
return byteArrayOf( return byteArrayOf(
0x00, 0x00, 0x04, 0x00, 0x00,
0x01, 0x00, 0x02, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x04,
0x00, 0x00, 0x00, 0x00 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 { 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") // 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 return false
} }
Log.d(TAG, "SELFMAC: ${selfMacAddress}, TARGETMAC: $targetMacAddress") Log.d(TAG, "SELFMAC: ${selfMacAddress}, TARGETMAC: $targetMacAddress")
Log.d(TAG, "Sending Media Information packet to $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 opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00)
val buffer = ByteBuffer.allocate(116) val buffer = ByteBuffer.allocate(116)
buffer.put( buffer.put(
@@ -892,17 +887,13 @@ class AACPManager {
Log.d(TAG, "Sending Media Information packet to $targetMac") Log.d(TAG, "Sending Media Information packet to $targetMac")
return sendDataPacket( return sendDataPacket(
createMediaInformationPacket( createMediaInformationPacket(
selfMacAddress, selfMacAddress, targetMac, streamingState
targetMac,
streamingState
) )
) )
} }
fun createMediaInformationPacket( fun createMediaInformationPacket(
selfMacAddress: String, selfMacAddress: String, targetMacAddress: String, streamingState: Boolean = true
targetMacAddress: String,
streamingState: Boolean = true
): ByteArray { ): ByteArray {
val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00) val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00)
val buffer = ByteBuffer.allocate(138) val buffer = ByteBuffer.allocate(138)
@@ -935,7 +926,7 @@ class AACPManager {
buffer.put("AudioCategory".toByteArray()) buffer.put("AudioCategory".toByteArray())
buffer.put(byteArrayOf(0x31, 0x2D, 0x01)) buffer.put(byteArrayOf(0x31, 0x2D, 0x01))
return opcode+buffer.array() return opcode + buffer.array()
} }
fun sendSmartRoutingShowUI(selfMacAddress: String): Boolean { fun sendSmartRoutingShowUI(selfMacAddress: String): Boolean {
@@ -1017,9 +1008,15 @@ class AACPManager {
fun sendAddTiPiDevice(selfMacAddress: String, targetMacAddress: String): Boolean { 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") // 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 return false
} }
Log.d(TAG, "Sending Add TiPi Device packet to $targetMacAddress") Log.d(TAG, "Sending Add TiPi Device packet to $targetMacAddress")
@@ -1053,8 +1050,7 @@ class AACPManager {
} }
data class ControlCommand( data class ControlCommand(
val identifier: Byte, val identifier: Byte, val value: ByteArray
val value: ByteArray
) { ) {
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
@@ -1106,10 +1102,8 @@ class AACPManager {
triplePressCustomized: Boolean = false, triplePressCustomized: Boolean = false,
longPressCustomized: Boolean = false longPressCustomized: Boolean = false
): Boolean { ): Boolean {
val value = ((if (singlePressCustomized) 0x01 else 0) or val value =
(if (doublePressCustomized) 0x02 else 0) or ((if (singlePressCustomized) 0x01 else 0) or (if (doublePressCustomized) 0x02 else 0) or (if (triplePressCustomized) 0x04 else 0) or (if (longPressCustomized) 0x08 else 0)).toByte()
(if (triplePressCustomized) 0x04 else 0) or
(if (longPressCustomized) 0x08 else 0)).toByte()
Log.d(TAG, "Sending Stem Config Packet with value: ${value.toHexString()}") Log.d(TAG, "Sending Stem Config Packet with value: ${value.toHexString()}")
return sendControlCommand( return sendControlCommand(
ControlCommandIdentifiers.STEM_CONFIG.value, value ControlCommandIdentifiers.STEM_CONFIG.value, value
@@ -1124,19 +1118,18 @@ class AACPManager {
if (packet[4] == Opcodes.CONTROL_COMMAND) { if (packet[4] == Opcodes.CONTROL_COMMAND) {
val controlCommand = ControlCommand.fromByteArray(packet) val controlCommand = ControlCommand.fromByteArray(packet)
Log.d( Log.d(
TAG, TAG, "Control command: ${controlCommand.identifier.toHexString()} - ${
"Control command: ${controlCommand.identifier.toHexString()} - ${ controlCommand.value.joinToString(" ") { "%02X".format(it) }
controlCommand.value.joinToString(" ") { "%02X".format(it) } }")
}"
)
setControlCommandStatusValue( setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return false, ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return false,
controlCommand.value controlCommand.value
) )
} }
val socket = BluetoothConnectionManager.getCurrentSocket() val socket = BluetoothConnectionManager.getCurrentSocket() ?: return false
if (socket?.isConnected == true) {
if (socket.isConnected) {
socket.outputStream?.write(packet) socket.outputStream?.write(packet)
socket.outputStream?.flush() socket.outputStream?.flush()
return true return true
@@ -1213,7 +1206,10 @@ class AACPManager {
var offset = 9 var offset = 9
for (i in 0 until deviceCount) { for (i in 0 until deviceCount) {
if (offset + 8 > data.size) { 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 break
} }
val macBytes = data.sliceArray(offset until offset + 6) val macBytes = data.sliceArray(offset until offset + 6)
@@ -1227,6 +1223,7 @@ class AACPManager {
return devices return devices
} }
fun sendSomePacketIDontKnowWhatItIs() { fun sendSomePacketIDontKnowWhatItIs() {
// 2900 00ff ffff ffff ffff -- enables setting EQ // 2900 00ff ffff ffff ffff -- enables setting EQ
sendDataPacket( sendDataPacket(
@@ -1264,7 +1261,7 @@ class AACPManager {
val start = index val start = index
// find next 0x00 byte // find next 0x00 byte
while (index < data.size && data[index] != 0x00.toByte()) index++ 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) strings.add(str)
} }
@@ -21,7 +21,7 @@
* and receiving notifications. It is not a complete implementation of the ATT protocol. * 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.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothAdapter
@@ -31,6 +31,7 @@ import android.os.ParcelUuid
import android.util.Log import android.util.Log
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream 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 input: InputStream? = null
private var output: OutputStream? = null private var output: OutputStream? = null
private val listeners = mutableMapOf<Int, MutableList<(ByteArray) -> Unit>>() private val listeners = mutableMapOf<Int, MutableList<(ByteArray) -> Unit>>()
private var notificationJob: kotlinx.coroutines.Job? = null private var notificationJob: Job? = null
// queue for non-notification PDUs (responses to requests) // queue for non-notification PDUs (responses to requests)
private val responses = LinkedBlockingQueue<ByteArray>() private val responses = LinkedBlockingQueue<ByteArray>()
@@ -72,7 +73,12 @@ class ATTManager(private val adapter: BluetoothAdapter, private val device: Blue
val uuid = ParcelUuid.fromString("00000000-0000-0000-0000-000000000000") val uuid = ParcelUuid.fromString("00000000-0000-0000-0000-000000000000")
socket = createBluetoothSocket(adapter, device, uuid) 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 input = socket!!.inputStream
output = socket!!.outputStream output = socket!!.outputStream
Log.d(TAG, "Connected to ATT") Log.d(TAG, "Connected to ATT")
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.bluetooth
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.bluetooth.BluetoothManager import android.bluetooth.BluetoothManager
@@ -30,8 +30,10 @@ import android.content.SharedPreferences
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.util.Log import android.util.Log
import me.kavishdevar.librepods.utils.BluetoothCryptography
import javax.crypto.Cipher import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec import javax.crypto.spec.SecretKeySpec
import kotlin.collections.iterator
import kotlin.io.encoding.Base64 import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.bluetooth
import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket import android.bluetooth.BluetoothSocket
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.data
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
@@ -256,8 +256,6 @@ data class AirPodsInstance(
val version1: String?, val version1: String?,
val version2: String?, val version2: String?,
val version3: String?, val version3: String?,
val aacpManager: AACPManager,
val attManager: ATTManager?
) )
object AirPodsModels { object AirPodsModels {
@@ -18,14 +18,14 @@
package me.kavishdevar.librepods.data package me.kavishdevar.librepods.data
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.utils.AACPManager.Companion.ControlCommandIdentifiers import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers
class ControlCommandRepository( class ControlCommandRepository(
private val aacpManager: AACPManager private val aacpManager: AACPManager
) { ) {
fun getValue( fun getValue(
identifier: AACPManager.Companion.ControlCommandIdentifiers identifier: ControlCommandIdentifiers
): ByteArray? { ): ByteArray? {
return aacpManager.controlCommandStatusList return aacpManager.controlCommandStatusList
.find { it.identifier == identifier } .find { it.identifier == identifier }
@@ -33,7 +33,7 @@ class ControlCommandRepository(
} }
fun setValue( fun setValue(
id: AACPManager.Companion.ControlCommandIdentifiers, id: ControlCommandIdentifiers,
value: ByteArray value: ByteArray
) { ) {
aacpManager.sendControlCommand(id.value, value) aacpManager.sendControlCommand(id.value, value)
@@ -41,7 +41,7 @@ class ControlCommandRepository(
fun observe( fun observe(
identifier: AACPManager.Companion.ControlCommandIdentifiers, identifier: ControlCommandIdentifiers,
onChange: (ByteArray) -> Unit onChange: (ByteArray) -> Unit
): AACPManager.ControlCommandListener { ): AACPManager.ControlCommandListener {
@@ -56,7 +56,7 @@ class ControlCommandRepository(
} }
fun remove( fun remove(
identifier: AACPManager.Companion.ControlCommandIdentifiers, identifier: ControlCommandIdentifiers,
listener: AACPManager.ControlCommandListener listener: AACPManager.ControlCommandListener
) { ) {
aacpManager.unregisterControlCommandListener(identifier, listener) aacpManager.unregisterControlCommandListener(identifier, listener)
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.data
import android.util.Log import android.util.Log
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
@@ -25,6 +25,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.bluetooth.ATTManager
import java.io.IOException import java.io.IOException
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.constants package me.kavishdevar.librepods.data
import android.os.Parcelable import android.os.Parcelable
import android.util.Log import android.util.Log
@@ -16,9 +16,9 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
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 { enum class StemAction {
PLAY_PAUSE, PLAY_PAUSE,
@@ -16,13 +16,14 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.data
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.bluetooth.ATTHandles
import java.io.IOException import java.io.IOException
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
@@ -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)
}
@@ -0,0 +1,5 @@
package me.kavishdevar.librepods.data
object XposedRemotePrefProvider {
fun create(): XposedRemotePref = XposedRemotePrefImpl()
}
@@ -18,7 +18,7 @@
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -75,7 +75,8 @@ fun AboutCard(
style = TextStyle( style = TextStyle(
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f) color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
) )
) )
} }
@@ -18,7 +18,7 @@
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme 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.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle 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.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -61,7 +63,7 @@ fun AudioSettings(
loudSoundReductionChecked: Boolean, loudSoundReductionChecked: Boolean,
onLoudSoundReductionCheckedChange: (Boolean) -> Unit, onLoudSoundReductionCheckedChange: (Boolean) -> Unit,
isXposed: Boolean, vendorIdHook: Boolean,
isPremium: Boolean isPremium: Boolean
) { ) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
@@ -80,7 +82,8 @@ fun AudioSettings(
style = TextStyle( style = TextStyle(
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Bold, 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( StyledToggle(
label = stringResource(R.string.loud_sound_reduction), label = stringResource(R.string.loud_sound_reduction),
description = stringResource(R.string.loud_sound_reduction_description), description = stringResource(R.string.loud_sound_reduction_description),
independent = false, independent = false,
checked = loudSoundReductionChecked, checked = loudSoundReductionChecked,
onCheckedChange = onLoudSoundReductionCheckedChange onCheckedChange = onLoudSoundReductionCheckedChange,
enabled = isPremium
) )
HorizontalDivider( HorizontalDivider(
thickness = 1.dp, thickness = 1.dp,
@@ -172,7 +176,7 @@ fun AudioSettingsPreview() {
onConversationalAwarenessCheckedChange = { }, onConversationalAwarenessCheckedChange = { },
loudSoundReductionChecked = true, loudSoundReductionChecked = true,
onLoudSoundReductionCheckedChange = { }, onLoudSoundReductionCheckedChange = { },
isXposed = true, vendorIdHook = true,
isPremium = true isPremium = true
) )
} }
@@ -16,20 +16,21 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import android.content.res.Configuration import android.content.res.Configuration
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.height 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.CircularProgressIndicator
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -38,8 +39,10 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.data.BatteryStatus
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
@Composable @Composable
fun BatteryIndicator( fun BatteryIndicator(
batteryPercentage: Int, batteryPercentage: Int,
charging: Boolean = false, status: Int,
prefix: String = "", prefix: String = "",
previousCharging: Boolean = false, previousCharging: Boolean = false,
) { ) {
@@ -65,6 +72,7 @@ fun BatteryIndicator(
val initialScale = if (previousCharging) 1f else 0f val initialScale = if (previousCharging) 1f else 0f
val scaleAnim = remember { Animatable(initialScale) } val scaleAnim = remember { Animatable(initialScale) }
val charging = status == BatteryStatus.CHARGING || status == BatteryStatus.OPTIMIZED_CHARGING
val targetScale = if (charging) 1f else 0f val targetScale = if (charging) 1f else 0f
LaunchedEffect(previousCharging, charging) { LaunchedEffect(previousCharging, charging) {
@@ -80,6 +88,43 @@ fun BatteryIndicator(
modifier = Modifier.padding(bottom = 4.dp), modifier = Modifier.padding(bottom = 4.dp),
contentAlignment = Alignment.Center 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( CircularProgressIndicator(
progress = { batteryPercentage / 100f }, progress = { batteryPercentage / 100f },
modifier = Modifier.size(40.dp), modifier = Modifier.size(40.dp),
@@ -123,6 +168,6 @@ fun BatteryIndicatorPreview() {
Box( Box(
modifier = Modifier.background(bg) 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)
} }
} }
@@ -18,10 +18,9 @@
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import android.content.res.Configuration import android.content.res.Configuration
import android.util.Log
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
@@ -41,15 +40,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.imageResource import androidx.compose.ui.res.imageResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.Battery import me.kavishdevar.librepods.data.Battery
import me.kavishdevar.librepods.constants.BatteryComponent import me.kavishdevar.librepods.data.BatteryComponent
import me.kavishdevar.librepods.constants.BatteryStatus import me.kavishdevar.librepods.data.BatteryStatus
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@Composable @Composable
@@ -66,12 +64,6 @@ fun BatteryView(
val rightLevel = right?.level ?: 0 val rightLevel = right?.level ?: 0
val caseLevel = case?.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 || val caseCharging = case?.status == BatteryStatus.CHARGING ||
case?.status == BatteryStatus.OPTIMIZED_CHARGING case?.status == BatteryStatus.OPTIMIZED_CHARGING
@@ -98,12 +90,12 @@ fun BatteryView(
) )
if ( if (
leftCharging == rightCharging && left?.status == right?.status &&
(leftLevel - rightLevel) in -3..3 (leftLevel - rightLevel) in -3..3
) { ) {
BatteryIndicator( BatteryIndicator(
leftLevel.coerceAtMost(rightLevel), leftLevel.coerceAtMost(rightLevel),
leftCharging left?.status ?: BatteryStatus.NOT_CHARGING
) )
singleDisplayed.value = true singleDisplayed.value = true
} else { } else {
@@ -116,7 +108,7 @@ fun BatteryView(
if (leftLevel > 0 || left?.status != BatteryStatus.DISCONNECTED) { if (leftLevel > 0 || left?.status != BatteryStatus.DISCONNECTED) {
BatteryIndicator( BatteryIndicator(
leftLevel, leftLevel,
leftCharging, left?.status ?: BatteryStatus.NOT_CHARGING,
"\uDBC6\uDCE5" "\uDBC6\uDCE5"
) )
} }
@@ -128,7 +120,7 @@ fun BatteryView(
if (rightLevel > 0 || right?.status != BatteryStatus.DISCONNECTED) { if (rightLevel > 0 || right?.status != BatteryStatus.DISCONNECTED) {
BatteryIndicator( BatteryIndicator(
rightLevel, rightLevel,
rightCharging, right?.status ?: BatteryStatus.NOT_CHARGING,
"\uDBC6\uDCE8" "\uDBC6\uDCE8"
) )
} }
@@ -151,7 +143,7 @@ fun BatteryView(
if (caseLevel > 0 || case?.status != BatteryStatus.DISCONNECTED) { if (caseLevel > 0 || case?.status != BatteryStatus.DISCONNECTED) {
BatteryIndicator( BatteryIndicator(
caseLevel, caseLevel,
caseCharging, case?.status ?: BatteryStatus.NOT_CHARGING,
prefix = if (!singleDisplayed.value) "\uDBC3\uDE6C" else "" prefix = if (!singleDisplayed.value) "\uDBC3\uDE6C" else ""
) )
} }
@@ -165,7 +157,7 @@ fun BatteryView(
fun BatteryViewPreview() { fun BatteryViewPreview() {
val fakeBattery = listOf( val fakeBattery = listOf(
Battery(BatteryComponent.LEFT, 85, BatteryStatus.CHARGING), 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) Battery(BatteryComponent.CASE, 60, BatteryStatus.NOT_CHARGING)
) )
@@ -18,7 +18,7 @@
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import android.util.Log import android.util.Log
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -41,15 +41,18 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
@@ -59,6 +62,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@@ -82,7 +86,8 @@ fun CallControlSettings(
style = TextStyle( style = TextStyle(
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Bold, 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)) .background(backgroundColor, RoundedCornerShape(28.dp))
.padding(top = 2.dp) .padding(top = 2.dp)
) { ) {
val scope = rememberCoroutineScope()
val haptics = LocalHapticFeedback.current
val pressOnceText = stringResource(R.string.press_once) val pressOnceText = stringResource(R.string.press_once)
val pressTwiceText = stringResource(R.string.press_twice) val pressTwiceText = stringResource(R.string.press_twice)
@@ -105,6 +114,7 @@ fun CallControlSettings(
var lastDismissTimeSingle by remember { mutableLongStateOf(0L) } var lastDismissTimeSingle by remember { mutableLongStateOf(0L) }
var parentHoveredIndexSingle by remember { mutableStateOf<Int?>(null) } var parentHoveredIndexSingle by remember { mutableStateOf<Int?>(null) }
var parentDragActiveSingle by remember { mutableStateOf(false) } var parentDragActiveSingle by remember { mutableStateOf(false) }
var previousIdxSingle by remember { mutableStateOf<Int?>(null) }
var showDoublePressDropdown by remember { mutableStateOf(false) } var showDoublePressDropdown by remember { mutableStateOf(false) }
var touchOffsetDouble by remember { mutableStateOf<Offset?>(null) } var touchOffsetDouble by remember { mutableStateOf<Offset?>(null) }
@@ -112,6 +122,7 @@ fun CallControlSettings(
var lastDismissTimeDouble by remember { mutableLongStateOf(0L) } var lastDismissTimeDouble by remember { mutableLongStateOf(0L) }
var parentHoveredIndexDouble by remember { mutableStateOf<Int?>(null) } var parentHoveredIndexDouble by remember { mutableStateOf<Int?>(null) }
var parentDragActiveDouble by remember { mutableStateOf(false) } var parentDragActiveDouble by remember { mutableStateOf(false) }
var previousIdxDouble by remember { mutableStateOf<Int?>(null) }
LaunchedEffect(flipped) { LaunchedEffect(flipped) {
Log.d("CallControlSettings", "Call control flipped: $flipped") Log.d("CallControlSettings", "Call control flipped: $flipped")
@@ -187,7 +198,11 @@ fun CallControlSettings(
val touch = touchOffsetSingle ?: current val touch = touchOffsetSingle ?: current
val posInPopupY = current.y - touch.y val posInPopupY = current.y - touch.y
val idx = (posInPopupY / itemHeightPx).toInt() val idx = (posInPopupY / itemHeightPx).toInt()
if (idx != previousIdxSingle) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) }
}
parentHoveredIndexSingle = idx parentHoveredIndexSingle = idx
previousIdxSingle = idx
}, },
onDragEnd = { onDragEnd = {
parentDragActiveSingle = false parentDragActiveSingle = false
@@ -204,6 +219,9 @@ fun CallControlSettings(
} }
} }
if (parentHoveredIndexSingle != null && parentHoveredIndexSingle in 0..1) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) }
}
parentHoveredIndexSingle = null parentHoveredIndexSingle = null
}, },
onDragCancel = { onDragCancel = {
@@ -316,7 +334,11 @@ fun CallControlSettings(
val touch = touchOffsetDouble ?: current val touch = touchOffsetDouble ?: current
val posInPopupY = current.y - touch.y val posInPopupY = current.y - touch.y
val idx = (posInPopupY / itemHeightPx).toInt() val idx = (posInPopupY / itemHeightPx).toInt()
if (idx != previousIdxDouble) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) }
}
parentHoveredIndexDouble = idx parentHoveredIndexDouble = idx
previousIdxDouble = idx
}, },
onDragEnd = { onDragEnd = {
parentDragActiveDouble = false parentDragActiveDouble = false
@@ -330,9 +352,12 @@ fun CallControlSettings(
showDoublePressDropdown = false showDoublePressDropdown = false
lastDismissTimeDouble = System.currentTimeMillis() lastDismissTimeDouble = System.currentTimeMillis()
val flipped = option == pressOnceText val flipped = option == pressOnceText
onCallControlValueChanged (flipped) onCallControlValueChanged(flipped)
} }
} }
if (parentHoveredIndexDouble != null && parentHoveredIndexDouble in 0..1) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) }
}
parentHoveredIndexDouble = null parentHoveredIndexDouble = null
}, },
onDragCancel = { onDragCancel = {
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme 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.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -38,13 +39,16 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color 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.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.CupertinoMaterials import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@@ -74,8 +80,18 @@ fun ConfirmationDialog(
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val accentColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5) val accentColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
val haptics = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
if (showDialog.value) { if (showDialog.value) {
Dialog(onDismissRequest = { showDialog.value = false }) { Dialog(
onDismissRequest = { showDialog.value = false },
properties = DialogProperties(
dismissOnBackPress = false,
dismissOnClickOutside = false
)
) {
Box( Box(
modifier = Modifier modifier = Modifier
// .fillMaxWidth(0.75f) // .fillMaxWidth(0.75f)
@@ -90,7 +106,7 @@ fun ConfirmationDialog(
) )
) { ) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Text( Text(
title, title,
style = TextStyle( style = TextStyle(
@@ -102,7 +118,7 @@ fun ConfirmationDialog(
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp) modifier = Modifier.padding(horizontal = 16.dp)
) )
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Text( Text(
message, message,
style = TextStyle( style = TextStyle(
@@ -113,7 +129,7 @@ fun ConfirmationDialog(
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp) modifier = Modifier.padding(horizontal = 16.dp)
) )
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider( HorizontalDivider(
thickness = 1.dp, thickness = 1.dp,
color = Color(0x40888888), color = Color(0x40888888),
@@ -148,6 +164,8 @@ fun ConfirmationDialog(
} }
PointerEventType.Move -> { PointerEventType.Move -> {
if (isWithinBounds) { if (isWithinBounds) {
if (leftPressed != isLeft) scope.launch { haptics.performHapticFeedback(
HapticFeedbackType.SegmentTick) }
leftPressed = isLeft leftPressed = isLeft
rightPressed = !isLeft rightPressed = !isLeft
} else { } else {
@@ -158,8 +176,12 @@ fun ConfirmationDialog(
PointerEventType.Release -> { PointerEventType.Release -> {
if (isWithinBounds) { if (isWithinBounds) {
if (leftPressed) { if (leftPressed) {
scope.launch { haptics.performHapticFeedback(
HapticFeedbackType.Reject) }
onDismiss() onDismiss()
} else if (rightPressed) { } else if (rightPressed) {
scope.launch { haptics.performHapticFeedback(
HapticFeedbackType.Confirm) }
onConfirm() onConfirm()
} }
} }
@@ -18,7 +18,7 @@
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
@@ -18,7 +18,7 @@
@file:Suppress("unused") @file:Suppress("unused")
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import androidx.compose.animation.core.Spring import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.NoiseControlMode import me.kavishdevar.librepods.data.NoiseControlMode
private val ContainerColor = Color(0x593C3C3E) private val ContainerColor = Color(0x593C3C3E)
private val SelectedIndicatorColorGray = Color(0xFF6C6C6E) private val SelectedIndicatorColorGray = Color(0xFF6C6C6E)
@@ -18,7 +18,7 @@
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme 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.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle 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.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -47,12 +49,12 @@ fun HearingHealthSettings(
navController: NavController, navController: NavController,
hasPPECapability: Boolean, hasPPECapability: Boolean,
hasHearingAidCapability: Boolean, hasHearingAidCapability: Boolean,
isXposed: Boolean vendorIdHook: Boolean
) { ) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val shouldShowHearingAid = hasHearingAidCapability && isXposed val shouldShowHearingAid = hasHearingAidCapability && vendorIdHook
if (hasPPECapability && shouldShowHearingAid) { if (hasPPECapability && shouldShowHearingAid) {
Box( Box(
@@ -65,7 +67,8 @@ fun HearingHealthSettings(
style = TextStyle( style = TextStyle(
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f) color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
) )
) )
} }
@@ -18,9 +18,8 @@
@file:OptIn(ExperimentalEncodingApi::class) @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.background
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
@@ -39,15 +38,18 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
@@ -56,8 +58,8 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@@ -93,26 +95,13 @@ fun MicrophoneSettings(
var lastDismissTime by remember { mutableLongStateOf(0L) } var lastDismissTime by remember { mutableLongStateOf(0L) }
val reopenThresholdMs = 250L 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 density = LocalDensity.current
val itemHeightPx = with(density) { 48.dp.toPx() } val itemHeightPx = with(density) { 48.dp.toPx() }
var parentHoveredIndex by remember { mutableStateOf<Int?>(null) } var parentHoveredIndex by remember { mutableStateOf<Int?>(null) }
var parentDragActive by remember { mutableStateOf(false) } var parentDragActive by remember { mutableStateOf(false) }
var previousIdx by remember { mutableStateOf<Int?>(null) }
val haptics = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
val microphoneAutomaticText = stringResource(R.string.microphone_automatic) val microphoneAutomaticText = stringResource(R.string.microphone_automatic)
val microphoneAlwaysRightText = stringResource(R.string.microphone_always_right) val microphoneAlwaysRightText = stringResource(R.string.microphone_always_right)
val microphoneAlwaysLeftText = stringResource(R.string.microphone_always_left) val microphoneAlwaysLeftText = stringResource(R.string.microphone_always_left)
@@ -152,7 +141,11 @@ fun MicrophoneSettings(
val touch = touchOffset ?: current val touch = touchOffset ?: current
val posInPopupY = current.y - touch.y val posInPopupY = current.y - touch.y
val idx = (posInPopupY / itemHeightPx).toInt() val idx = (posInPopupY / itemHeightPx).toInt()
if (idx != previousIdx) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) }
}
parentHoveredIndex = idx parentHoveredIndex = idx
previousIdx = idx
}, },
onDragEnd = { onDragEnd = {
parentDragActive = false parentDragActive = false
@@ -180,6 +173,9 @@ fun MicrophoneSettings(
onMicModeValueChanged(byteValue.toByte()) onMicModeValueChanged(byteValue.toByte())
} }
} }
if (parentHoveredIndex != null && parentHoveredIndex in 0..2) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) }
}
parentHoveredIndex = null parentHoveredIndex = null
}, },
onDragCancel = { onDragCancel = {
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
@@ -35,21 +35,25 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavController import androidx.navigation.NavController
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
@Composable @Composable
@@ -62,10 +66,14 @@ fun NavigationButton(
description: String? = null, description: String? = null,
currentState: String? = null, currentState: String? = null,
height: Dp = 58.dp, height: Dp = 58.dp,
enabled: Boolean = true
) { ) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) } var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500)) val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
val haptics = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
Column { Column {
if (title != null) { if (title != null) {
Box( Box(
@@ -79,23 +87,34 @@ fun NavigationButton(
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f), color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
) )
) )
} }
} }
Row( Row(
modifier = Modifier modifier = Modifier
.background(animatedBackgroundColor, RoundedCornerShape(if (independent) 28.dp else 0.dp)) .background(
animatedBackgroundColor,
RoundedCornerShape(if (independent) 28.dp else 0.dp)
)
.height(height) .height(height)
.pointerInput(Unit) { .pointerInput(Unit) {
detectTapGestures( detectTapGestures(
onPress = { onPress = {
backgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9) if (enabled) {
tryAwaitRelease() backgroundColor =
backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColor =
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
}
}, },
onTap = { 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)
}
} }
) )
} }
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -18,7 +18,7 @@
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import android.annotation.SuppressLint import android.annotation.SuppressLint
import androidx.compose.animation.core.AnimationSpec 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.imageResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle 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.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset 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.unit.sp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.NoiseControlMode import me.kavishdevar.librepods.data.NoiseControlMode
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -147,6 +149,7 @@ fun NoiseControlSettings(
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f), color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
) )
) )
} }
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
@@ -40,7 +40,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavController import androidx.navigation.NavController
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.StemAction import me.kavishdevar.librepods.data.StemAction
@Composable @Composable
fun PressAndHoldSettings( fun PressAndHoldSettings(
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import android.graphics.RuntimeShader import android.graphics.RuntimeShader
import android.os.Build 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.ShaderBrush
import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastCoerceAtMost import androidx.compose.ui.util.fastCoerceAtMost
@@ -77,7 +79,8 @@ fun StyledButton(
maxScale: Float = 0.1f, maxScale: Float = 0.1f,
content: @Composable RowScope.() -> Unit, content: @Composable RowScope.() -> Unit,
) { ) {
val animationScope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val haptics = LocalHapticFeedback.current
val progressAnimation = remember { Animatable(0f) } val progressAnimation = remember { Animatable(0f) }
var pressStartPosition by remember { mutableStateOf(Offset.Zero) } var pressStartPosition by remember { mutableStateOf(Offset.Zero) }
val offsetAnimation = remember { Animatable(Offset.Zero, Offset.VectorConverter) } val offsetAnimation = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
@@ -163,19 +166,21 @@ half4 main(float2 coord) {
val maxOffset = size.minDimension val maxOffset = size.minDimension
val initialDerivative = 0.05f val initialDerivative = 0.05f
val offset = offsetAnimation.value val offset = offsetAnimation.value
translationX = maxOffset * tanh(initialDerivative * offset.x / maxOffset) translationX =
translationY = maxOffset * tanh(initialDerivative * offset.y / maxOffset) maxOffset * tanh(initialDerivative * offset.x / maxOffset)
translationY =
maxOffset * tanh(initialDerivative * offset.y / maxOffset)
val maxDragScale = 0.1f val maxDragScale = 0.1f
val offsetAngle = atan2(offset.y, offset.x) val offsetAngle = atan2(offset.y, offset.x)
scaleX = scaleX =
scale + scale +
maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) * maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) *
(width / height).fastCoerceAtMost(1f) (width / height).fastCoerceAtMost(1f)
scaleY = scaleY =
scale + scale +
maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) * maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) *
(height / width).fastCoerceAtMost(1f) (height / width).fastCoerceAtMost(1f)
}, },
onDrawSurface = { onDrawSurface = {
if (tint.isSpecified) { if (tint.isSpecified) {
@@ -209,7 +214,10 @@ half4 main(float2 coord) {
interactiveHighlightShader.apply { interactiveHighlightShader.apply {
val offset = pressStartPosition + offsetAnimation.value val offset = pressStartPosition + offsetAnimation.value
setFloatUniform("size", size.width, size.height) 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("radius", size.maxDimension)
setFloatUniform( setFloatUniform(
"offset", "offset",
@@ -236,31 +244,51 @@ half4 main(float2 coord) {
interactionSource = null, interactionSource = null,
indication = null, indication = null,
role = Role.Button, role = Role.Button,
onClick = onClick onClick = {
haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
onClick()
}
) )
.then( .then(
if (isInteractive) { if (isInteractive) {
Modifier.pointerInput(animationScope) { Modifier.pointerInput(scope) {
val progressAnimationSpec = spring(0.5f, 300f, 0.001f) val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold) val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
val onDragStop: () -> Unit = { val onDragStop: () -> Unit = {
animationScope.launch { scope.launch {
launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) }
launch { progressAnimation.animateTo(0f, progressAnimationSpec) } launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) } launch {
offsetAnimation.animateTo(
Offset.Zero,
offsetAnimationSpec
)
}
} }
} }
inspectDragGestures( inspectDragGestures(
onDragStart = { down -> onDragStart = { down ->
pressStartPosition = down.position pressStartPosition = down.position
animationScope.launch { scope.launch {
launch { progressAnimation.animateTo(1f, progressAnimationSpec) } launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) }
launch {
progressAnimation.animateTo(
1f,
progressAnimationSpec
)
}
launch { offsetAnimation.snapTo(Offset.Zero) } launch { offsetAnimation.snapTo(Offset.Zero) }
} }
}, },
onDragEnd = { onDragStop() }, onDragEnd = {
onDragStop()
},
onDragCancel = onDragStop onDragCancel = onDragStop
) { _, dragAmount -> ) { _, dragAmount ->
animationScope.launch { scope.launch {
if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback(
HapticFeedbackType.SegmentFrequentTick
)
offsetAnimation.snapTo(offsetAnimation.value + dragAmount) offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
} }
} }
@@ -274,6 +302,7 @@ half4 main(float2 coord) {
isPressed = false isPressed = false
}, },
onTap = { onTap = {
haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
onClick() onClick()
} }
) )
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import android.annotation.SuppressLint import android.annotation.SuppressLint
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
@@ -49,14 +49,17 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily 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.hazeEffect
import dev.chrisbanes.haze.materials.CupertinoMaterials import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@@ -110,6 +114,9 @@ fun StyledDropdown(
var hoveredIndex by remember { mutableStateOf<Int?>(null) } var hoveredIndex by remember { mutableStateOf<Int?>(null) }
val itemHeight = 48.dp val itemHeight = 48.dp
val scope = rememberCoroutineScope()
val haptics = LocalHapticFeedback.current
var popupSize by remember { mutableStateOf(IntSize(0, 0)) } var popupSize by remember { mutableStateOf(IntSize(0, 0)) }
var lastDragPosition by remember { mutableStateOf<Offset?>(null) } var lastDragPosition by remember { mutableStateOf<Offset?>(null) }
@@ -132,7 +139,12 @@ fun StyledDropdown(
}, },
onDrag = { change, _ -> onDrag = { change, _ ->
val y = change.position.y 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 lastDragPosition = change.position
}, },
onDragEnd = { onDragEnd = {
@@ -144,6 +156,8 @@ fun StyledDropdown(
if (withinBounds) { if (withinBounds) {
hoveredIndex?.let { idx -> hoveredIndex?.let { idx ->
if (idx in options.indices) { if (idx in options.indices) {
scope.launch { haptics.performHapticFeedback(
HapticFeedbackType.GestureEnd) }
onOptionSelected(options[idx]) onOptionSelected(options[idx])
} }
} }
@@ -174,6 +188,7 @@ fun StyledDropdown(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = null indication = null
) { ) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) }
onOptionSelected(text) onOptionSelected(text)
onDismissRequest() onDismissRequest()
} }
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import android.graphics.RuntimeShader import android.graphics.RuntimeShader
import android.os.Build 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.layer.drawLayer
import androidx.compose.ui.graphics.rememberGraphicsLayer import androidx.compose.ui.graphics.rememberGraphicsLayer
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
@@ -83,8 +85,9 @@ fun StyledIconButton(
backdrop: LayerBackdrop = rememberLayerBackdrop(), backdrop: LayerBackdrop = rememberLayerBackdrop(),
onClick: () -> Unit onClick: () -> Unit
) { ) {
val haptics = LocalHapticFeedback.current
val darkMode = isSystemInDarkTheme() val darkMode = isSystemInDarkTheme()
val animationScope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val progressAnimationSpec = spring(0.5f, 300f, 0.001f) val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold) val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
val progressAnimation = remember { Animatable(0f) } val progressAnimation = remember { Animatable(0f) }
@@ -116,7 +119,10 @@ half4 main(float2 coord) {
} }
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
TextButton( TextButton(
onClick = onClick, onClick = {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) }
onClick()
},
shape = RoundedCornerShape(56.dp), shape = RoundedCornerShape(56.dp),
modifier = modifier modifier = modifier
.padding(horizontal = 12.dp) .padding(horizontal = 12.dp)
@@ -147,12 +153,12 @@ half4 main(float2 coord) {
val offsetAngle = atan2(offset.y, offset.x) val offsetAngle = atan2(offset.y, offset.x)
scaleX = scaleX =
scale + scale +
maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) * maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) *
(width / height).fastCoerceAtMost(1f) (width / height).fastCoerceAtMost(1f)
scaleY = scaleY =
scale + scale +
maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) * maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) *
(height / width).fastCoerceAtMost(1f) (height / width).fastCoerceAtMost(1f)
}, },
onDrawSurface = { onDrawSurface = {
val progress = progressAnimation.value.coerceIn(0f, 1f) val progress = progressAnimation.value.coerceIn(0f, 1f)
@@ -182,7 +188,12 @@ half4 main(float2 coord) {
drawLayer(innerShadowLayer) drawLayer(innerShadowLayer)
drawRect( 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 = { onDrawFront = {
@@ -196,7 +207,10 @@ half4 main(float2 coord) {
interactiveHighlightShader.apply { interactiveHighlightShader.apply {
val offset = pressStartPosition + offsetAnimation.value val offset = pressStartPosition + offsetAnimation.value
setFloatUniform("size", size.width, size.height) 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("radius", size.maxDimension)
setFloatUniform( setFloatUniform(
"offset", "offset",
@@ -225,9 +239,10 @@ half4 main(float2 coord) {
) )
}, },
) )
.pointerInput(animationScope) { .pointerInput(scope) {
val onDragStop: () -> Unit = { val onDragStop: () -> Unit = {
animationScope.launch { scope.launch {
launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) }
launch { progressAnimation.animateTo(0f, progressAnimationSpec) } launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) } launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) }
} }
@@ -235,7 +250,8 @@ half4 main(float2 coord) {
inspectDragGestures( inspectDragGestures(
onDragStart = { down -> onDragStart = { down ->
pressStartPosition = down.position pressStartPosition = down.position
animationScope.launch { scope.launch {
launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) }
launch { progressAnimation.animateTo(1f, progressAnimationSpec) } launch { progressAnimation.animateTo(1f, progressAnimationSpec) }
launch { offsetAnimation.snapTo(Offset.Zero) } launch { offsetAnimation.snapTo(Offset.Zero) }
} }
@@ -243,7 +259,10 @@ half4 main(float2 coord) {
onDragEnd = { onDragStop() }, onDragEnd = { onDragStop() },
onDragCancel = onDragStop onDragCancel = onDragStop
) { _, dragAmount -> ) { _, dragAmount ->
animationScope.launch { scope.launch {
if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback(
HapticFeedbackType.SegmentFrequentTick
)
offsetAnimation.snapTo(offsetAnimation.value + dragAmount) offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
} }
} }
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
@@ -39,11 +39,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
@@ -71,6 +74,9 @@ fun StyledSelectList(
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val scope = rememberCoroutineScope()
val haptics = LocalHapticFeedback.current
Column( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
@@ -104,6 +110,11 @@ fun StyledSelectList(
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9) if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease() tryAwaitRelease()
itemBackgroundColor = backgroundColor itemBackgroundColor = backgroundColor
}
},
onTap = {
if (item.enabled) {
haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
item.onClick() item.onClick()
} }
} }
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.res.Configuration import android.content.res.Configuration
@@ -56,6 +56,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer 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.pointerInput
import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.input.pointer.util.addPointerInputChange 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.onSizeChanged
import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
@@ -246,6 +248,8 @@ fun StyledSlider(
val startIconWidthState = remember { mutableFloatStateOf(0f) } val startIconWidthState = remember { mutableFloatStateOf(0f) }
val endIconWidthState = remember { mutableFloatStateOf(0f) } val endIconWidthState = remember { mutableFloatStateOf(0f) }
val density = LocalDensity.current val density = LocalDensity.current
val haptics = LocalHapticFeedback.current
var lastDragValue by remember { mutableFloatStateOf(value) }
val momentumAnimation = rememberMomentumAnimation(maxScale = 1.5f) val momentumAnimation = rememberMomentumAnimation(maxScale = 1.5f)
@@ -449,10 +453,17 @@ fun StyledSlider(
valueRange.start, valueRange.start,
valueRange.endInclusive valueRange.endInclusive
) )
snapPoints.forEach { snap ->
if ((lastDragValue < snap && targetValue >= snap) ||
(snap in targetValue..<lastDragValue)) {
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
}
}
lastDragValue = targetValue
val snappedValue = if (snapPoints.isNotEmpty()) snapIfClose( val snappedValue = if (snapPoints.isNotEmpty()) snapIfClose(
targetValue, targetValue,
snapPoints, snapPoints,
snapThreshold snapThreshold,
) else targetValue ) else targetValue
onValueChange(snappedValue) onValueChange(snappedValue)
} }
@@ -460,10 +471,9 @@ fun StyledSlider(
Orientation.Horizontal, Orientation.Horizontal,
startDragImmediately = true, startDragImmediately = true,
onDragStarted = { onDragStarted = {
// Remove this block as momentumAnimation handles pressing lastDragValue = value
}, },
onDragStopped = { onDragStopped = {
// Remove this block as momentumAnimation handles pressing
onValueChange((value * 100).roundToInt() / 100f) onValueChange((value * 100).roundToInt() / 100f)
} }
) )
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import android.content.res.Configuration import android.content.res.Configuration
import androidx.compose.animation.animateColorAsState 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.CompositingStrategy
import androidx.compose.ui.graphics.layer.drawLayer import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.graphics.rememberGraphicsLayer import androidx.compose.ui.graphics.rememberGraphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastCoerceIn import androidx.compose.ui.util.fastCoerceIn
@@ -73,6 +75,7 @@ import com.kyant.backdrop.highlight.Highlight
import com.kyant.backdrop.shadow.Shadow import com.kyant.backdrop.shadow.Shadow
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.math.abs
@Composable @Composable
fun StyledSwitch( fun StyledSwitch(
@@ -81,9 +84,12 @@ fun StyledSwitch(
enabled: Boolean = true, enabled: Boolean = true,
) { ) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val haptics = LocalHapticFeedback.current
val onColor = if (enabled) Color(0xFF34C759) else if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6) 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 trackWidth = 64.dp
val trackHeight = 28.dp val trackHeight = 28.dp
@@ -98,7 +104,7 @@ fun StyledSwitch(
val animatedFraction = remember { Animatable(fraction) } val animatedFraction = remember { Animatable(fraction) }
val trackWidthPx = remember { mutableFloatStateOf(0f) } val trackWidthPx = remember { mutableFloatStateOf(0f) }
val density = LocalDensity.current val density = LocalDensity.current
val animationScope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val progressAnimationSpec = spring(0.5f, 300f, 0.001f) val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
val progressAnimation = remember { Animatable(0f) } val progressAnimation = remember { Animatable(0f) }
val innerShadowLayer = rememberGraphicsLayer().apply { val innerShadowLayer = rememberGraphicsLayer().apply {
@@ -111,6 +117,11 @@ fun StyledSwitch(
val isFirstComposition = remember { mutableStateOf(true) } val isFirstComposition = remember { mutableStateOf(true) }
LaunchedEffect(checked) { LaunchedEffect(checked) {
if (!isFirstComposition.value) { if (!isFirstComposition.value) {
if (checked) {
haptics.performHapticFeedback(HapticFeedbackType.ToggleOn)
} else {
haptics.performHapticFeedback(HapticFeedbackType.ToggleOff)
}
coroutineScope { coroutineScope {
launch { launch {
val targetFrac = if (checked) 1f else 0f val targetFrac = if (checked) 1f else 0f
@@ -150,27 +161,31 @@ fun StyledSwitch(
.then(if (enabled) Modifier.draggable( .then(if (enabled) Modifier.draggable(
rememberDraggableState { delta -> rememberDraggableState { delta ->
if (trackWidthPx.floatValue > 0f) { if (trackWidthPx.floatValue > 0f) {
val oldFraction = animatedFraction.value
val newFraction = (animatedFraction.value + delta / trackWidthPx.floatValue).fastCoerceIn(-0.3f, 1.3f) val newFraction = (animatedFraction.value + delta / trackWidthPx.floatValue).fastCoerceIn(-0.3f, 1.3f)
animationScope.launch { scope.launch {
animatedFraction.snapTo(newFraction) animatedFraction.snapTo(newFraction)
} }
totalDrag.floatValue += kotlin.math.abs(delta) totalDrag.floatValue += abs(delta)
val newChecked = newFraction >= 0.5f val newChecked = newFraction >= 0.5f
if (newChecked != checked) { if (newChecked != checked) {
onCheckedChange(newChecked) onCheckedChange(newChecked)
} }
if ((oldFraction < 0.5f && newFraction >= 0.5f) || (oldFraction >= 0.5f && newFraction < 0.5f)) {
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
}
} }
}, },
Orientation.Horizontal, Orientation.Horizontal,
startDragImmediately = true, startDragImmediately = true,
onDragStarted = { onDragStarted = {
totalDrag.floatValue = 0f totalDrag.floatValue = 0f
animationScope.launch { scope.launch {
progressAnimation.animateTo(1f, progressAnimationSpec) progressAnimation.animateTo(1f, progressAnimationSpec)
} }
}, },
onDragStopped = { onDragStopped = {
animationScope.launch { scope.launch {
if (totalDrag.floatValue < tapThreshold) { if (totalDrag.floatValue < tapThreshold) {
val newChecked = !checked val newChecked = !checked
onCheckedChange(newChecked) onCheckedChange(newChecked)
@@ -18,7 +18,7 @@
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
@@ -40,12 +40,15 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily 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.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@@ -71,6 +75,9 @@ fun StyledToggle(
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val haptics = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
var backgroundColor by remember { var backgroundColor by remember {
mutableStateOf( mutableStateOf(
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
@@ -90,7 +97,8 @@ fun StyledToggle(
style = TextStyle( style = TextStyle(
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Bold, 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( modifier = Modifier.padding(
start = 16.dp, start = 16.dp,
@@ -108,14 +116,17 @@ fun StyledToggle(
.pointerInput(Unit) { .pointerInput(Unit) {
detectTapGestures( detectTapGestures(
onPress = { onPress = {
backgroundColor = if (enabled) {
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9) backgroundColor =
tryAwaitRelease() if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
backgroundColor = tryAwaitRelease()
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) backgroundColor =
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
}
}, },
onTap = { onTap = {
if (enabled) { if (enabled) {
scope.launch { haptics.performHapticFeedback(if (!currentChecked) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff) }
onCheckedChange(!currentChecked) onCheckedChange(!currentChecked)
} }
} }
@@ -145,6 +156,7 @@ fun StyledToggle(
enabled = enabled, enabled = enabled,
onCheckedChange = { onCheckedChange = {
if (enabled) { if (enabled) {
scope.launch { haptics.performHapticFeedback(if (it) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff) }
onCheckedChange(it) onCheckedChange(it)
} }
} }
@@ -200,6 +212,7 @@ fun StyledToggle(
interactionSource = remember { MutableInteractionSource() } interactionSource = remember { MutableInteractionSource() }
) { ) {
if (enabled) { if (enabled) {
scope.launch { haptics.performHapticFeedback(if (!currentChecked) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff) }
onCheckedChange(!currentChecked) onCheckedChange(!currentChecked)
} }
}, },
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.presentation.components
import androidx.compose.animation.core.Spring import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
@@ -18,7 +18,7 @@
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.presentation.overlays
import android.animation.Animator import android.animation.Animator
import android.animation.AnimatorListenerAdapter import android.animation.AnimatorListenerAdapter
@@ -58,10 +58,10 @@ import androidx.dynamicanimation.animation.DynamicAnimation
import androidx.dynamicanimation.animation.SpringAnimation import androidx.dynamicanimation.animation.SpringAnimation
import androidx.dynamicanimation.animation.SpringForce import androidx.dynamicanimation.animation.SpringForce
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.AirPodsNotifications import me.kavishdevar.librepods.data.AirPodsNotifications
import me.kavishdevar.librepods.constants.Battery import me.kavishdevar.librepods.data.Battery
import me.kavishdevar.librepods.constants.BatteryComponent import me.kavishdevar.librepods.data.BatteryComponent
import me.kavishdevar.librepods.constants.BatteryStatus import me.kavishdevar.librepods.data.BatteryStatus
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs import kotlin.math.abs
@@ -17,7 +17,7 @@
*/ */
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.presentation.overlays
import android.animation.Animator import android.animation.Animator
import android.animation.AnimatorListenerAdapter import android.animation.AnimatorListenerAdapter
@@ -45,10 +45,10 @@ import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import android.widget.VideoView import android.widget.VideoView
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.AirPodsNotifications import me.kavishdevar.librepods.data.AirPodsNotifications
import me.kavishdevar.librepods.constants.Battery import me.kavishdevar.librepods.data.Battery
import me.kavishdevar.librepods.constants.BatteryComponent import me.kavishdevar.librepods.data.BatteryComponent
import me.kavishdevar.librepods.constants.BatteryStatus import me.kavishdevar.librepods.data.BatteryStatus
@SuppressLint("InflateParams", "ClickableViewAccessibility") @SuppressLint("InflateParams", "ClickableViewAccessibility")
class PopupWindow( class PopupWindow(
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.presentation.screens
// import me.kavishdevar.librepods.utils.RadareOffsetFinder // import me.kavishdevar.librepods.utils.RadareOffsetFinder
import android.annotation.SuppressLint import android.annotation.SuppressLint
@@ -44,16 +44,20 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color 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.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font 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.HazeState
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.BuildConfig import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.NavigationButton import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.composables.StyledDropdown import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.data.Capability
import me.kavishdevar.librepods.composables.StyledSlider import me.kavishdevar.librepods.presentation.components.NavigationButton
import me.kavishdevar.librepods.composables.StyledToggle import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.presentation.components.StyledDropdown
import me.kavishdevar.librepods.utils.ATTHandles import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.utils.Capability import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.viewmodel.AirPodsViewModel import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
//private var phoneMediaDebounceJob: Job? = null //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 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() val backdrop = rememberLayerBackdrop()
StyledScaffold( StyledScaffold(
@@ -113,6 +117,28 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
) { ) {
Spacer(modifier = Modifier.height(topPadding)) 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 phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
// val phoneEQEnabled = remember { mutableStateOf(false) } // val phoneEQEnabled = remember { mutableStateOf(false) }
// val mediaEQEnabled = 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( Box (
label = stringResource(R.string.press_speed), modifier = Modifier.then(
description = stringResource(R.string.press_speed_description), if (!state.isPremium) {
options = pressSpeedOptions.values.toList(), Modifier
selectedOption = selectedPressSpeed?: stringResource(R.string.default_option), .pointerInput(Unit) {
onOptionSelected = { newValue -> awaitPointerEventScope {
selectedPressSpeed = newValue while (true) {
viewModel.setControlCommandByte( val event = awaitPointerEvent(PointerEventPass.Initial)
identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL, event.changes.forEach { it.consume() }
value = pressSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() }
?: 0.toByte() }
) }
}, } else Modifier
textColor = textColor, )
hazeState = hazeState, ) {
independent = true DropdownMenuComponent(
) label = stringResource(R.string.press_and_hold_duration),
description = stringResource(R.string.press_and_hold_duration_description),
DropdownMenuComponent( options = pressAndHoldDurationOptions.values.toList(),
label = stringResource(R.string.press_and_hold_duration), selectedOption = selectedPressAndHoldDuration
description = stringResource(R.string.press_and_hold_duration_description), ?: stringResource(R.string.default_option),
options = pressAndHoldDurationOptions.values.toList(), onOptionSelected = { newValue ->
selectedOption = selectedPressAndHoldDuration?: stringResource(R.string.default_option), selectedPressAndHoldDuration = newValue
onOptionSelected = { newValue -> viewModel.setControlCommandByte(
selectedPressAndHoldDuration = newValue identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
viewModel.setControlCommandByte( value = pressAndHoldDurationOptions.filterValues { it == newValue }.keys.firstOrNull()
identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL, ?: 0.toByte()
value = pressAndHoldDurationOptions.filterValues { it == newValue }.keys.firstOrNull() )
?: 0.toByte() },
) textColor = textColor,
}, hazeState = hazeState,
textColor = textColor, independent = true
hazeState = hazeState, )
independent = true }
)
StyledToggle( StyledToggle(
title = stringResource(R.string.noise_control), title = stringResource(R.string.noise_control),
label = stringResource(R.string.noise_cancellation_single_airpod), label = stringResource(R.string.noise_cancellation_single_airpod),
description = stringResource(R.string.noise_cancellation_single_airpod_description), description = stringResource(R.string.noise_cancellation_single_airpod_description),
independent = true, independent = true,
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE]?.getOrNull(0) == 0x01.toByte(), 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( StyledToggle(
label = stringResource(R.string.loud_sound_reduction), label = stringResource(R.string.loud_sound_reduction),
description = stringResource(R.string.loud_sound_reduction_description), description = stringResource(R.string.loud_sound_reduction_description),
checked = viewModel.getATTCharacteristicValue(ATTHandles.LOUD_SOUND_REDUCTION)?.get(0) == 1.toByte(), checked = state.loudSoundReductionEnabled,
onCheckedChange = { viewModel.setATTCharacteristicValue(ATTHandles.LOUD_SOUND_REDUCTION, if (it) byteArrayOf(0x01) else byteArrayOf(0x00)) } onCheckedChange = { viewModel.setATTCharacteristicValue(ATTHandles.LOUD_SOUND_REDUCTION, if (it) byteArrayOf(0x01) else byteArrayOf(0x00)) },
enabled = state.isPremium
) )
} }
if (!hearingAidEnabled && BuildConfig.FLAVOR == "xposed") { if (!hearingAidEnabled && state.vendorIdHook) {
NavigationButton( NavigationButton(
to = "transparency_customization", to = "transparency_customization",
name = stringResource(R.string.customize_transparency_mode), 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), snapPoints = listOf(75f),
startIcon = "\uDBC0\uDEA1", startIcon = "\uDBC0\uDEA1",
endIcon = "\uDBC0\uDEA9", endIcon = "\uDBC0\uDEA9",
independent = true independent = true,
enabled = state.isPremium
) )
if (state.capabilities.contains(Capability.SWIPE_FOR_VOLUME)) { if (state.capabilities.contains(Capability.SWIPE_FOR_VOLUME)) {
@@ -263,26 +324,44 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
label = stringResource(R.string.volume_control), label = stringResource(R.string.volume_control),
description = stringResource(R.string.volume_control_description), description = stringResource(R.string.volume_control_description),
checked = volumeSwipeEnabled, 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( Box (
label = stringResource(R.string.volume_swipe_speed), modifier = Modifier.then(
description = stringResource(R.string.volume_swipe_speed_description), if (!state.isPremium) {
options = volumeSwipeSpeedOptions.values.toList(), Modifier
selectedOption = selectedVolumeSwipeSpeed?: stringResource(R.string.default_option), .pointerInput(Unit) {
onOptionSelected = { newValue -> awaitPointerEventScope {
selectedVolumeSwipeSpeed = newValue while (true) {
viewModel.setControlCommandByte( val event = awaitPointerEvent(PointerEventPass.Initial)
identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL, event.changes.forEach { it.consume() }
value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() }
?: 1.toByte() }
) }
}, } else Modifier
textColor = textColor, )
hazeState = hazeState, ) {
independent = true 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") { // if (!hearingAidEnabled.value&& BuildConfig.FLAVOR == "xposed") {
@@ -507,7 +586,6 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
// } // }
// } // }
// } // }
// }
Spacer(modifier = Modifier.height(bottomPadding)) Spacer(modifier = Modifier.height(bottomPadding))
} }
} }
@@ -534,6 +612,9 @@ private fun DropdownMenuComponent(
var lastDismissTime by remember { mutableLongStateOf(0L) } var lastDismissTime by remember { mutableLongStateOf(0L) }
var parentHoveredIndex by remember { mutableStateOf<Int?>(null) } var parentHoveredIndex by remember { mutableStateOf<Int?>(null) }
var parentDragActive by remember { mutableStateOf(false) } var parentDragActive by remember { mutableStateOf(false) }
var previousIdx by remember { mutableStateOf<Int?>(null) }
val haptics = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
Column(modifier = Modifier.fillMaxWidth()){ Column(modifier = Modifier.fillMaxWidth()){
Column( Column(
@@ -593,7 +674,11 @@ private fun DropdownMenuComponent(
val touch = touchOffset ?: current val touch = touchOffset ?: current
val posInPopupY = current.y - touch.y val posInPopupY = current.y - touch.y
val idx = (posInPopupY / itemHeightPx).toInt() val idx = (posInPopupY / itemHeightPx).toInt()
if (idx != previousIdx) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) }
}
parentHoveredIndex = idx parentHoveredIndex = idx
previousIdx = idx
}, },
onDragEnd = { onDragEnd = {
parentDragActive = false parentDragActive = false
@@ -604,6 +689,9 @@ private fun DropdownMenuComponent(
lastDismissTime = System.currentTimeMillis() lastDismissTime = System.currentTimeMillis()
} }
} }
if (parentHoveredIndex != null && parentHoveredIndex in options.indices) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) }
}
parentHoveredIndex = null parentHoveredIndex = null
}, },
onDragCancel = { onDragCancel = {
@@ -16,14 +16,17 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -33,21 +36,29 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.composables.StyledSlider import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.viewmodel.AirPodsViewModel import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@Composable @Composable
fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel) { fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel, navController: NavController) {
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
@@ -62,6 +73,27 @@ fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel) {
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Spacer(modifier = Modifier.height(spacerHeight)) 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 { val sliderValue = remember {
mutableFloatStateOf( mutableFloatStateOf(
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH]?.getOrNull( state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH]?.getOrNull(
@@ -90,7 +122,8 @@ fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel) {
startIcon = "􀊥", startIcon = "􀊥",
endIcon = "􀊩", endIcon = "􀊩",
independent = true, independent = true,
description = stringResource(R.string.adaptive_audio_description) description = stringResource(R.string.adaptive_audio_description),
enabled = state.isPremium
) )
} }
} }
@@ -18,13 +18,12 @@
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.presentation.screens
// import me.kavishdevar.librepods.utils.RadareOffsetFinder // import me.kavishdevar.librepods.utils.RadareOffsetFinder
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context.MODE_PRIVATE import android.content.Context.MODE_PRIVATE
import android.content.SharedPreferences import android.content.SharedPreferences
import android.util.Log
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -74,25 +73,25 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import me.kavishdevar.librepods.BuildConfig import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.AboutCard import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.composables.AudioSettings import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.composables.BatteryView import me.kavishdevar.librepods.data.AirPodsPro3
import me.kavishdevar.librepods.composables.CallControlSettings import me.kavishdevar.librepods.data.Capability
import me.kavishdevar.librepods.composables.ConnectionSettings import me.kavishdevar.librepods.presentation.components.AboutCard
import me.kavishdevar.librepods.composables.HearingHealthSettings import me.kavishdevar.librepods.presentation.components.AudioSettings
import me.kavishdevar.librepods.composables.MicrophoneSettings import me.kavishdevar.librepods.presentation.components.BatteryView
import me.kavishdevar.librepods.composables.NavigationButton import me.kavishdevar.librepods.presentation.components.CallControlSettings
import me.kavishdevar.librepods.composables.NoiseControlSettings import me.kavishdevar.librepods.presentation.components.ConnectionSettings
import me.kavishdevar.librepods.composables.PressAndHoldSettings import me.kavishdevar.librepods.presentation.components.HearingHealthSettings
import me.kavishdevar.librepods.composables.StyledButton import me.kavishdevar.librepods.presentation.components.MicrophoneSettings
import me.kavishdevar.librepods.composables.StyledIconButton import me.kavishdevar.librepods.presentation.components.NavigationButton
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.presentation.components.NoiseControlSettings
import me.kavishdevar.librepods.composables.StyledToggle import me.kavishdevar.librepods.presentation.components.PressAndHoldSettings
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.utils.ATTHandles import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.utils.AirPodsPro3 import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.utils.Capability import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.viewmodel.AirPodsViewModel import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@@ -162,17 +161,14 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl
.fillMaxSize() .fillMaxSize()
.hazeSource(hazeState) .hazeSource(hazeState)
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.then( .then(if (blockTouches) Modifier.pointerInput(Unit) {
if (blockTouches) Modifier.pointerInput(Unit) { awaitPointerEventScope {
awaitPointerEventScope { while (true) {
while (true) { val event = awaitPointerEvent(PointerEventPass.Initial)
val event = awaitPointerEvent(PointerEventPass.Initial) event.changes.forEach { it.consume() }
event.changes.forEach { it.consume() }
}
} }
} else Modifier }
) } else Modifier)) {
) {
item(key = "spacer_top") { Spacer(modifier = Modifier.height(topPadding)) } item(key = "spacer_top") { Spacer(modifier = Modifier.height(topPadding)) }
item(key = "battery") { item(key = "battery") {
BatteryView( BatteryView(
@@ -199,7 +195,7 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl
state.instance?.model?.capabilities?.contains(Capability.PPE) == true state.instance?.model?.capabilities?.contains(Capability.PPE) == true
if (hasHearingAidCapability || hasPPECapability) { if (hasHearingAidCapability || hasPPECapability) {
if (hasPPECapability || (BuildConfig.FLAVOR == "xposed" && hasHearingAidCapability)) item( if (hasPPECapability || (state.vendorIdHook && hasHearingAidCapability)) item(
key = "spacer_hearing_health" key = "spacer_hearing_health"
) { Spacer(modifier = Modifier.height(24.dp)) } ) { Spacer(modifier = Modifier.height(24.dp)) }
item(key = "hearing_health") { item(key = "hearing_health") {
@@ -207,7 +203,7 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl
navController = navController, navController = navController,
hasPPECapability = hasPPECapability, hasPPECapability = hasPPECapability,
hasHearingAidCapability = hasHearingAidCapability, hasHearingAidCapability = hasHearingAidCapability,
isXposed = BuildConfig.FLAVOR == "xposed" vendorIdHook = state.vendorIdHook
) )
} }
} }
@@ -272,27 +268,26 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl
} }
item(key = "upgrade_button") { item(key = "upgrade_button") {
val context = LocalContext.current
val textColor = if (isSystemInDarkTheme()) Color.White else Color.Black
if (!state.isPremium) { if (!state.isPremium) {
Spacer(modifier = Modifier.height(28.dp)) Spacer(modifier = Modifier.height(28.dp))
StyledButton( StyledButton(
onClick = { onClick = {
viewModel.purchase(context) navController.navigate("purchase_screen")
}, },
backdrop = rememberLayerBackdrop(), backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f, maxScale = 0.05f,
tint = Color(0xFF916100) tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(
0xFFE59900
)
) { ) {
Text( Text(
stringResource(R.string.unlock_all_features), stringResource(R.string.unlock_advanced_features),
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)), 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( state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG]?.getOrNull(
0 0
) == 0x01.toByte() ) == 0x01.toByte()
val loudSoundReduction =
viewModel.getATTCharacteristicValue(ATTHandles.LOUD_SOUND_REDUCTION)
?.getOrNull(0) == 0x01.toByte()
val isXposed = BuildConfig.FLAVOR == "xposed"
AudioSettings( AudioSettings(
navController = navController, navController = navController,
adaptiveVolumeCapability = adaptiveVolumeCapability, adaptiveVolumeCapability = adaptiveVolumeCapability,
@@ -345,14 +336,14 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl
checked checked
) )
}, },
loudSoundReductionChecked = loudSoundReduction, loudSoundReductionChecked = state.loudSoundReductionEnabled,
onLoudSoundReductionCheckedChange = { onLoudSoundReductionCheckedChange = {
viewModel.setATTCharacteristicValue( viewModel.setATTCharacteristicValue(
ATTHandles.LOUD_SOUND_REDUCTION, ATTHandles.LOUD_SOUND_REDUCTION,
byteArrayOf(if (it) 0x01.toByte() else 0x00.toByte()) byteArrayOf(if (it) 0x01.toByte() else 0x00.toByte())
) )
}, },
isXposed = isXposed, vendorIdHook = state.vendorIdHook,
isPremium = state.isPremium isPremium = state.isPremium
) )
} }
@@ -484,11 +475,8 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl
tapCount.intValue = 0 tapCount.intValue = 0
viewModel.activateDemoMode() viewModel.activateDemoMode()
} }
} })
) }) {
}
)
{
Text( Text(
text = stringResource(R.string.airpods_not_connected), style = TextStyle( text = stringResource(R.string.airpods_not_connected), style = TextStyle(
fontSize = 24.sp, fontSize = 24.sp,
@@ -16,12 +16,16 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.presentation.screens
import android.content.Intent
import android.os.Build
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -42,9 +46,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font 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.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
@@ -61,26 +71,26 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import me.kavishdevar.librepods.BuildConfig import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.NavigationButton import me.kavishdevar.librepods.presentation.components.NavigationButton
import me.kavishdevar.librepods.composables.StyledButton import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.composables.StyledToggle import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.viewmodel.AppSettingsViewModel import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
import java.util.Locale.getDefault
@Composable @Composable
fun AppSettingsScreen( fun AppSettingsScreen(
navController: NavController, navController: NavController, viewModel: AppSettingsViewModel = viewModel()
viewModel: AppSettingsViewModel = viewModel()
) { ) {
val context = LocalContext.current val context = LocalContext.current
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
val uiState by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( StyledScaffold(
title = stringResource(R.string.app_settings) title = stringResource(R.string.settings)
) { topPadding, hazeState, bottomPadding -> ) { topPadding, hazeState, bottomPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
@@ -96,23 +106,23 @@ fun AppSettingsScreen(
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
if (!uiState.isPremium) { if (!state.isPremium) {
StyledButton( StyledButton(
onClick = { onClick = {
viewModel.purchase(context) navController.navigate("purchase_screen")
}, },
backdrop = rememberLayerBackdrop(), backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f, maxScale = 0.05f,
tint = Color(0xFF916100) tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) { ) {
Text( Text(
stringResource(R.string.unlock_all_features), stringResource(R.string.unlock_advanced_features),
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)), fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor color = Color.White
), ),
) )
} }
@@ -122,9 +132,9 @@ fun AppSettingsScreen(
title = stringResource(R.string.widget), title = stringResource(R.string.widget),
label = stringResource(R.string.show_phone_battery_in_widget), label = stringResource(R.string.show_phone_battery_in_widget),
description = stringResource(R.string.show_phone_battery_in_widget_description), description = stringResource(R.string.show_phone_battery_in_widget_description),
checked = uiState.showPhoneBatteryInWidget, checked = state.showPhoneBatteryInWidget,
onCheckedChange = viewModel::setShowPhoneBatteryInWidget, onCheckedChange = viewModel::setShowPhoneBatteryInWidget,
enabled = uiState.isPremium enabled = state.isPremium
) )
Text( Text(
@@ -149,10 +159,10 @@ fun AppSettingsScreen(
StyledToggle( StyledToggle(
label = stringResource(R.string.conversational_awareness_pause_music), label = stringResource(R.string.conversational_awareness_pause_music),
description = stringResource(R.string.conversational_awareness_pause_music_description), description = stringResource(R.string.conversational_awareness_pause_music_description),
checked = uiState.conversationalAwarenessPauseMusicEnabled, checked = state.conversationalAwarenessPauseMusicEnabled,
onCheckedChange = viewModel::setConversationalAwarenessPauseMusicEnabled, onCheckedChange = viewModel::setConversationalAwarenessPauseMusicEnabled,
independent = false, independent = false,
enabled = uiState.isPremium enabled = state.isPremium
) )
HorizontalDivider( HorizontalDivider(
@@ -164,16 +174,16 @@ fun AppSettingsScreen(
StyledToggle( StyledToggle(
label = stringResource(R.string.relative_conversational_awareness_volume), label = stringResource(R.string.relative_conversational_awareness_volume),
description = stringResource(R.string.relative_conversational_awareness_volume_description), description = stringResource(R.string.relative_conversational_awareness_volume_description),
checked = uiState.relativeConversationalAwarenessVolumeEnabled, checked = state.relativeConversationalAwarenessVolumeEnabled,
onCheckedChange = viewModel::setRelativeConversationalAwarenessVolumeEnabled, onCheckedChange = viewModel::setRelativeConversationalAwarenessVolumeEnabled,
independent = false, independent = false,
enabled = uiState.isPremium enabled = state.isPremium,
) )
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
val conversationalAwarenessVolume = uiState.conversationalAwarenessVolume val conversationalAwarenessVolume = state.conversationalAwarenessVolume
LaunchedEffect(conversationalAwarenessVolume) { LaunchedEffect(conversationalAwarenessVolume) {
viewModel.setConversationalAwarenessVolume(conversationalAwarenessVolume) viewModel.setConversationalAwarenessVolume(conversationalAwarenessVolume)
} }
@@ -182,11 +192,12 @@ fun AppSettingsScreen(
label = stringResource(R.string.conversational_awareness_volume), label = stringResource(R.string.conversational_awareness_volume),
value = conversationalAwarenessVolume, value = conversationalAwarenessVolume,
valueRange = 10f..85f, valueRange = 10f..85f,
snapPoints = listOf(44f),
startLabel = "10%", startLabel = "10%",
endLabel = "85%", endLabel = "85%",
onValueChange = { newValue -> viewModel.setConversationalAwarenessVolume(newValue) }, onValueChange = { newValue -> viewModel.setConversationalAwarenessVolume(newValue) },
independent = true, independent = true,
enabled = uiState.isPremium enabled = state.isPremium
) )
if (!BuildConfig.PLAY_BUILD) { if (!BuildConfig.PLAY_BUILD) {
@@ -198,7 +209,7 @@ fun AppSettingsScreen(
name = stringResource(R.string.set_custom_camera_package), name = stringResource(R.string.set_custom_camera_package),
navController = navController, navController = navController,
onClick = { onClick = {
if (uiState.isPremium) viewModel.setShowCameraDialog(true) if (state.isPremium) viewModel.setShowCameraDialog(true)
}, },
independent = true, independent = true,
description = stringResource(R.string.camera_control_app_description) description = stringResource(R.string.camera_control_app_description)
@@ -211,9 +222,9 @@ fun AppSettingsScreen(
title = stringResource(R.string.ear_detection), title = stringResource(R.string.ear_detection),
label = stringResource(R.string.disconnect_when_not_wearing), label = stringResource(R.string.disconnect_when_not_wearing),
description = stringResource(R.string.disconnect_when_not_wearing_description), description = stringResource(R.string.disconnect_when_not_wearing_description),
checked = uiState.disconnectWhenNotWearing, checked = state.disconnectWhenNotWearing,
onCheckedChange = viewModel::setDisconnectWhenNotWearing, onCheckedChange = viewModel::setDisconnectWhenNotWearing,
enabled = uiState.isPremium enabled = state.isPremium
) )
} }
@@ -239,10 +250,10 @@ fun AppSettingsScreen(
StyledToggle( StyledToggle(
label = stringResource(R.string.takeover_disconnected), label = stringResource(R.string.takeover_disconnected),
description = stringResource(R.string.takeover_disconnected_desc), description = stringResource(R.string.takeover_disconnected_desc),
checked = uiState.takeoverWhenDisconnected, checked = state.takeoverWhenDisconnected,
onCheckedChange = viewModel::setTakeoverWhenDisconnected, onCheckedChange = viewModel::setTakeoverWhenDisconnected,
independent = false, independent = false,
enabled = uiState.isPremium enabled = state.isPremium
) )
HorizontalDivider( HorizontalDivider(
thickness = 1.dp, thickness = 1.dp,
@@ -253,10 +264,10 @@ fun AppSettingsScreen(
StyledToggle( StyledToggle(
label = stringResource(R.string.takeover_idle), label = stringResource(R.string.takeover_idle),
description = stringResource(R.string.takeover_idle_desc), description = stringResource(R.string.takeover_idle_desc),
checked = uiState.takeoverWhenIdle, checked = state.takeoverWhenIdle,
onCheckedChange = viewModel::setTakeoverWhenIdle, onCheckedChange = viewModel::setTakeoverWhenIdle,
independent = false, independent = false,
enabled = uiState.isPremium enabled = state.isPremium
) )
HorizontalDivider( HorizontalDivider(
thickness = 1.dp, thickness = 1.dp,
@@ -267,10 +278,10 @@ fun AppSettingsScreen(
StyledToggle( StyledToggle(
label = stringResource(R.string.takeover_music), label = stringResource(R.string.takeover_music),
description = stringResource(R.string.takeover_music_desc), description = stringResource(R.string.takeover_music_desc),
checked = uiState.takeoverWhenMusic, checked = state.takeoverWhenMusic,
onCheckedChange = viewModel::setTakeoverWhenMusic, onCheckedChange = viewModel::setTakeoverWhenMusic,
independent = false, independent = false,
enabled = uiState.isPremium enabled = state.isPremium
) )
HorizontalDivider( HorizontalDivider(
thickness = 1.dp, thickness = 1.dp,
@@ -281,10 +292,10 @@ fun AppSettingsScreen(
StyledToggle( StyledToggle(
label = stringResource(R.string.takeover_call), label = stringResource(R.string.takeover_call),
description = stringResource(R.string.takeover_call_desc), description = stringResource(R.string.takeover_call_desc),
checked = uiState.takeoverWhenCall, checked = state.takeoverWhenCall,
onCheckedChange = viewModel::setTakeoverWhenCall, onCheckedChange = viewModel::setTakeoverWhenCall,
independent = false, independent = false,
enabled = uiState.isPremium enabled = state.isPremium
) )
} }
@@ -310,10 +321,10 @@ fun AppSettingsScreen(
StyledToggle( StyledToggle(
label = stringResource(R.string.takeover_ringing_call), label = stringResource(R.string.takeover_ringing_call),
description = stringResource(R.string.takeover_ringing_call_desc), description = stringResource(R.string.takeover_ringing_call_desc),
checked = uiState.takeoverWhenRingingCall, checked = state.takeoverWhenRingingCall,
onCheckedChange = viewModel::setTakeoverWhenRingingCall, onCheckedChange = viewModel::setTakeoverWhenRingingCall,
independent = false, independent = false,
enabled = uiState.isPremium enabled = state.isPremium
) )
HorizontalDivider( HorizontalDivider(
thickness = 1.dp, thickness = 1.dp,
@@ -324,10 +335,10 @@ fun AppSettingsScreen(
StyledToggle( StyledToggle(
label = stringResource(R.string.takeover_media_start), label = stringResource(R.string.takeover_media_start),
description = stringResource(R.string.takeover_media_start_desc), description = stringResource(R.string.takeover_media_start_desc),
checked = uiState.takeoverWhenMediaStart, checked = state.takeoverWhenMediaStart,
onCheckedChange = viewModel::setTakeoverWhenMediaStart, onCheckedChange = viewModel::setTakeoverWhenMediaStart,
independent = false, independent = false,
enabled = uiState.isPremium enabled = state.isPremium
) )
} }
@@ -345,13 +356,31 @@ fun AppSettingsScreen(
StyledToggle( StyledToggle(
label = stringResource(R.string.use_alternate_head_tracking_packets), label = stringResource(R.string.use_alternate_head_tracking_packets),
description = stringResource(R.string.use_alternate_head_tracking_packets_description), description = stringResource(R.string.use_alternate_head_tracking_packets_description),
checked = uiState.useAlternateHeadTrackingPackets, checked = state.useAlternateHeadTrackingPackets,
onCheckedChange = viewModel::setUseAlternateHeadTrackingPackets, onCheckedChange = viewModel::setUseAlternateHeadTrackingPackets,
independent = true, 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( // NavigationButton(
// to = "troubleshooting", // to = "troubleshooting",
@@ -361,6 +390,229 @@ fun AppSettingsScreen(
// description = stringResource(R.string.troubleshooting_description) // 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)) Spacer(modifier = Modifier.height(16.dp))
NavigationButton( NavigationButton(
@@ -370,9 +622,9 @@ fun AppSettingsScreen(
independent = true 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 = { AlertDialog(onDismissRequest = { viewModel.setShowCameraDialog(false) }, title = {
Text( Text(
stringResource(R.string.set_custom_camera_package), stringResource(R.string.set_custom_camera_package),
@@ -388,13 +640,13 @@ fun AppSettingsScreen(
) )
OutlinedTextField( OutlinedTextField(
value = uiState.cameraPackageValue, value = state.cameraPackageValue,
onValueChange = { onValueChange = {
viewModel.setCameraPackageValue(it) viewModel.setCameraPackageValue(it)
viewModel.setCameraPackageError(null) viewModel.setCameraPackageError(null)
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
isError = uiState.cameraPackageError != null, isError = state.cameraPackageError != null,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Ascii, keyboardType = KeyboardType.Ascii,
capitalization = KeyboardCapitalization.None capitalization = KeyboardCapitalization.None
@@ -406,9 +658,9 @@ fun AppSettingsScreen(
unfocusedBorderColor = if (isDarkTheme) Color.Gray else Color.LightGray unfocusedBorderColor = if (isDarkTheme) Color.Gray else Color.LightGray
), ),
supportingText = { supportingText = {
if (uiState.cameraPackageError != null) { if (state.cameraPackageError != null) {
Text( Text(
uiState.cameraPackageError ?: "", state.cameraPackageError ?: "",
color = MaterialTheme.colorScheme.error color = MaterialTheme.colorScheme.error
) )
} }
@@ -439,7 +691,6 @@ fun AppSettingsScreen(
} }
}) })
} }
Spacer(modifier = Modifier.height(bottomPadding))
} }
} }
} }
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.presentation.screens
import android.accessibilityservice.AccessibilityServiceInfo import android.accessibilityservice.AccessibilityServiceInfo
import android.content.ComponentName 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.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.SelectItem import me.kavishdevar.librepods.presentation.components.SelectItem
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSelectList import me.kavishdevar.librepods.presentation.components.StyledSelectList
import me.kavishdevar.librepods.services.AppListenerService import me.kavishdevar.librepods.services.AppListenerService
import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressType import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.StemPressType
import me.kavishdevar.librepods.viewmodel.AirPodsViewModel import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@Composable @Composable
fun CameraControlScreen(viewModel: AirPodsViewModel) { fun CameraControlScreen(viewModel: AirPodsViewModel) {
@@ -18,7 +18,7 @@
@file:OptIn(ExperimentalHazeMaterialsApi::class, ExperimentalEncodingApi::class) @file:OptIn(ExperimentalHazeMaterialsApi::class, ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.presentation.screens
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.ClipData import android.content.ClipData
@@ -82,10 +82,10 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledIconButton import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.constants.BatteryStatus import me.kavishdevar.librepods.data.BatteryStatus
import me.kavishdevar.librepods.constants.isHeadTrackingData import me.kavishdevar.librepods.data.isHeadTrackingData
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@@ -21,8 +21,12 @@
@file:OptIn(ExperimentalEncodingApi::class) @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 android.util.Log
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExperimentalAnimationApi 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.drawscope.Stroke
import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText 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.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
@@ -91,13 +95,13 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledButton import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.composables.StyledIconButton import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.composables.StyledToggle import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.HeadTracking 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.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.cos import kotlin.math.cos
@@ -107,7 +111,7 @@ import kotlin.random.Random
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
@Composable @Composable
fun HeadTrackingScreen(viewModel: AirPodsViewModel) { fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController) {
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
DisposableEffect(Unit) { DisposableEffect(Unit) {
viewModel.startHeadTracking() viewModel.startHeadTracking()
@@ -163,25 +167,23 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel) {
) { ) {
Spacer(modifier = Modifier.height(topPadding)) Spacer(modifier = Modifier.height(topPadding))
val context = LocalContext.current
if (!state.isPremium) { if (!state.isPremium) {
StyledButton( StyledButton(
onClick = { onClick = {
viewModel.purchase(context) navController.navigate("purchase_screen")
}, },
backdrop = rememberLayerBackdrop(), backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f, maxScale = 0.05f,
tint = Color(0xFF916100) tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) { ) {
Text( Text(
stringResource(R.string.unlock_all_features), stringResource(R.string.unlock_advanced_features),
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)), fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor color = Color.White
), ),
) )
} }
@@ -192,31 +194,20 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel) {
label = "Head Gestures", label = "Head Gestures",
checked = state.headGesturesEnabled, checked = state.headGesturesEnabled,
onCheckedChange = { viewModel.setHeadGesturesEnabled(it) }, onCheckedChange = { viewModel.setHeadGesturesEnabled(it) },
enabled = state.isPremium enabled = state.isPremium,
) description = stringResource(R.string.head_gestures_details)
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)
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
"Head Orientation", "Head Orientation",
style = TextStyle( style = TextStyle(
fontSize = 18.sp, fontSize = 14.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Bold,
fontFamily = FontFamily(Font(R.font.sf_pro)), color = textColor.copy(alpha = 0.6f),
color = textColor 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() HeadVisualization()
@@ -224,12 +215,12 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel) {
Text( Text(
"Velocity", "Velocity",
style = TextStyle( style = TextStyle(
fontSize = 18.sp, fontSize = 14.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Bold,
fontFamily = FontFamily(Font(R.font.sf_pro)), color = textColor.copy(alpha = 0.6f),
color = textColor 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() AccelerationPlot()
@@ -481,9 +472,9 @@ private fun HeadVisualization() {
spherePath.close() spherePath.close()
drawContext.canvas.nativeCanvas.apply { drawContext.canvas.nativeCanvas.apply {
val paint = android.graphics.Paint().apply { val paint = Paint().apply {
style = android.graphics.Paint.Style.FILL style = Paint.Style.FILL
shader = android.graphics.RadialGradient( shader = RadialGradient(
center.x + sinY * faceRadius * 0.3f, center.x + sinY * faceRadius * 0.3f,
center.y - sinP * faceRadius * 0.3f, center.y - sinP * faceRadius * 0.3f,
faceRadius * 1.4f, faceRadius * 1.4f,
@@ -495,14 +486,14 @@ private fun HeadVisualization() {
backgroundColor.copy(alpha = 0.7f).toArgb() backgroundColor.copy(alpha = 0.7f).toArgb()
), ),
floatArrayOf(0.3f, 0.5f, 0.7f, 0.8f, 1f), floatArrayOf(0.3f, 0.5f, 0.7f, 0.8f, 1f),
android.graphics.Shader.TileMode.CLAMP Shader.TileMode.CLAMP
) )
} }
drawPath(spherePath.asAndroidPath(), paint) drawPath(spherePath.asAndroidPath(), paint)
val highlightPaint = android.graphics.Paint().apply { val highlightPaint = Paint().apply {
style = android.graphics.Paint.Style.FILL style = Paint.Style.FILL
shader = android.graphics.RadialGradient( shader = RadialGradient(
center.x - faceRadius * 0.4f - sinY * faceRadius * 0.5f, center.x - faceRadius * 0.4f - sinY * faceRadius * 0.5f,
center.y - faceRadius * 0.4f - sinP * faceRadius * 0.5f, center.y - faceRadius * 0.4f - sinP * faceRadius * 0.5f,
faceRadius * 0.9f, faceRadius * 0.9f,
@@ -512,15 +503,15 @@ private fun HeadVisualization() {
android.graphics.Color.TRANSPARENT android.graphics.Color.TRANSPARENT
), ),
floatArrayOf(0f, 0.3f, 1f), floatArrayOf(0f, 0.3f, 1f),
android.graphics.Shader.TileMode.CLAMP Shader.TileMode.CLAMP
) )
alpha = if (darkTheme) 30 else 60 alpha = if (darkTheme) 30 else 60
} }
drawPath(spherePath.asAndroidPath(), highlightPaint) drawPath(spherePath.asAndroidPath(), highlightPaint)
val secondaryHighlightPaint = android.graphics.Paint().apply { val secondaryHighlightPaint = Paint().apply {
style = android.graphics.Paint.Style.FILL style = Paint.Style.FILL
shader = android.graphics.RadialGradient( shader = RadialGradient(
center.x + faceRadius * 0.3f + sinY * faceRadius * 0.3f, center.x + faceRadius * 0.3f + sinY * faceRadius * 0.3f,
center.y + faceRadius * 0.3f - sinP * faceRadius * 0.3f, center.y + faceRadius * 0.3f - sinP * faceRadius * 0.3f,
faceRadius * 0.7f, faceRadius * 0.7f,
@@ -529,15 +520,15 @@ private fun HeadVisualization() {
android.graphics.Color.TRANSPARENT android.graphics.Color.TRANSPARENT
), ),
floatArrayOf(0f, 1f), floatArrayOf(0f, 1f),
android.graphics.Shader.TileMode.CLAMP Shader.TileMode.CLAMP
) )
alpha = if (darkTheme) 15 else 30 alpha = if (darkTheme) 15 else 30
} }
drawPath(spherePath.asAndroidPath(), secondaryHighlightPaint) drawPath(spherePath.asAndroidPath(), secondaryHighlightPaint)
val shadowPaint = android.graphics.Paint().apply { val shadowPaint = Paint().apply {
style = android.graphics.Paint.Style.FILL style = Paint.Style.FILL
shader = android.graphics.RadialGradient( shader = RadialGradient(
center.x + sinY * faceRadius * 0.5f, center.x + sinY * faceRadius * 0.5f,
center.y - sinP * faceRadius * 0.5f, center.y - sinP * faceRadius * 0.5f,
faceRadius * 1.1f, faceRadius * 1.1f,
@@ -546,7 +537,7 @@ private fun HeadVisualization() {
android.graphics.Color.BLACK android.graphics.Color.BLACK
), ),
floatArrayOf(0.7f, 1f), floatArrayOf(0.7f, 1f),
android.graphics.Shader.TileMode.CLAMP Shader.TileMode.CLAMP
) )
alpha = if (darkTheme) 40 else 20 alpha = if (darkTheme) 40 else 20
} }
@@ -606,13 +597,13 @@ private fun HeadVisualization() {
} }
drawContext.canvas.nativeCanvas.apply { 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 color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
textSize = 12.sp.toPx() textSize = 12.sp.toPx()
textAlign = android.graphics.Paint.Align.RIGHT textAlign = Paint.Align.RIGHT
typeface = android.graphics.Typeface.create( typeface = Typeface.create(
"SF Pro", "SF Pro",
android.graphics.Typeface.NORMAL Typeface.NORMAL
) )
} }
@@ -726,10 +717,10 @@ private fun AccelerationPlot() {
} }
drawContext.canvas.nativeCanvas.apply { 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 color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
textSize = 12.sp.toPx() textSize = 12.sp.toPx()
textAlign = android.graphics.Paint.Align.RIGHT textAlign = Paint.Align.RIGHT
} }
drawText("${maxAbs.toInt()}", 30.dp.toPx(), 20.dp.toPx(), paint) 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)) drawCircle(Color(0xFF007AFF), 5.dp.toPx(), Offset(width - 150.dp.toPx(), legendY))
drawContext.canvas.nativeCanvas.apply { 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 color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
textSize = 12.sp.toPx() textSize = 12.sp.toPx()
textAlign = android.graphics.Paint.Align.LEFT textAlign = Paint.Align.LEFT
} }
drawText("Horizontal", width - 140.dp.toPx(), textOffsetY, paint) drawText("Horizontal", width - 140.dp.toPx(), textOffsetY, paint)
} }
drawCircle(Color(0xFFFF3B30), 5.dp.toPx(), Offset(width - 70.dp.toPx(), legendY)) drawCircle(Color(0xFFFF3B30), 5.dp.toPx(), Offset(width - 70.dp.toPx(), legendY))
drawContext.canvas.nativeCanvas.apply { 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 color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
textSize = 12.sp.toPx() textSize = 12.sp.toPx()
textAlign = android.graphics.Paint.Align.LEFT textAlign = Paint.Align.LEFT
} }
drawText("Vertical", width - 60.dp.toPx(), textOffsetY, paint) drawText("Vertical", width - 60.dp.toPx(), textOffsetY, paint)
} }
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.presentation.screens
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.util.Log import android.util.Log
@@ -50,16 +50,16 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.composables.StyledToggle import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.utils.HearingAidSettings import me.kavishdevar.librepods.data.HearingAidSettings
import me.kavishdevar.librepods.utils.parseHearingAidSettingsResponse import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
import me.kavishdevar.librepods.utils.sendHearingAidSettings import me.kavishdevar.librepods.data.sendHearingAidSettings
import me.kavishdevar.librepods.viewmodel.AirPodsViewModel import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import java.io.IOException import java.io.IOException
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.presentation.screens
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.util.Log import android.util.Log
@@ -62,15 +62,14 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.ConfirmationDialog import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.composables.NavigationButton import me.kavishdevar.librepods.data.parseTransparencySettingsResponse
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.data.sendTransparencySettings
import me.kavishdevar.librepods.composables.StyledToggle import me.kavishdevar.librepods.presentation.components.ConfirmationDialog
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.presentation.components.NavigationButton
import me.kavishdevar.librepods.utils.ATTHandles import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.utils.sendTransparencySettings import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
private const val TAG = "AccessibilitySettings" private const val TAG = "AccessibilitySettings"
@@ -174,7 +173,7 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController)
NavigationButton( NavigationButton(
to = "hearing_aid_adjustments", to = "hearing_aid_adjustments",
name = stringResource(R.string.adjustments), name = stringResource(R.string.adjustments),
navController, navController = navController,
independent = false independent = false
) )
} }
@@ -193,7 +192,7 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController)
NavigationButton( NavigationButton(
to = "update_hearing_test", to = "update_hearing_test",
name = stringResource(R.string.update_hearing_test), name = stringResource(R.string.update_hearing_test),
navController, navController = navController,
independent = true independent = true
) )
@@ -237,7 +236,6 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController)
Spacer(modifier = Modifier.height(bottomPadding)) Spacer(modifier = Modifier.height(bottomPadding))
} }
} }
ConfirmationDialog( ConfirmationDialog(
showDialog = showDialog, showDialog = showDialog,
title = "Enable Hearing Aid", title = "Enable Hearing Aid",
@@ -256,12 +254,11 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController)
hearingAidEnabled.value = true hearingAidEnabled.value = true
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
try { try {
val data = viewModel.getATTCharacteristicValue(ATTHandles.TRANSPARENCY) ?: byteArrayOf() if (state.hearingAidData.isEmpty()) {
if (data.isEmpty()) {
Log.w(TAG, "read failed") Log.w(TAG, "read failed")
return@launch return@launch
} }
val parsed = parseTransparencySettingsResponse(data) val parsed = parseTransparencySettingsResponse(state.hearingAidData)
val disabledSettings = parsed.copy(enabled = false) val disabledSettings = parsed.copy(enabled = false)
sendTransparencySettings(viewModel::setATTCharacteristicValue, disabledSettings) sendTransparencySettings(viewModel::setATTCharacteristicValue, disabledSettings)
} catch (e: Exception) { } catch (e: Exception) {
@@ -269,6 +266,10 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController)
} }
} }
}, },
onDismiss = {
hearingAidEnabled.value = false
showDialog.value = false
},
hazeState = hazeStateS.value, hazeState = hazeStateS.value,
// backdrop = backdrop // backdrop = backdrop
) )
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.presentation.screens
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -33,11 +33,11 @@ import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import me.kavishdevar.librepods.BuildConfig import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.composables.StyledToggle import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.viewmodel.AirPodsViewModel import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@Composable @Composable
fun HearingProtectionScreen(viewModel: AirPodsViewModel) { fun HearingProtectionScreen(viewModel: AirPodsViewModel) {
@@ -54,20 +54,19 @@ fun HearingProtectionScreen(viewModel: AirPodsViewModel) {
) { ) {
Spacer(modifier = Modifier.height(spacerHeight)) Spacer(modifier = Modifier.height(spacerHeight))
if (BuildConfig.FLAVOR == "xposed") { if (state.vendorIdHook) {
StyledToggle( StyledToggle(
title = stringResource(R.string.environmental_noise), title = stringResource(R.string.environmental_noise),
label = stringResource(R.string.loud_sound_reduction), label = stringResource(R.string.loud_sound_reduction),
description = stringResource(R.string.loud_sound_reduction_description), description = stringResource(R.string.loud_sound_reduction_description),
checked = viewModel.getATTCharacteristicValue(ATTHandles.LOUD_SOUND_REDUCTION) checked = state.loudSoundReductionEnabled,
?.get(0)?.toInt() == 1,
onCheckedChange = { onCheckedChange = {
viewModel.setATTCharacteristicValue( viewModel.setATTCharacteristicValue(
ATTHandles.LOUD_SOUND_REDUCTION, ATTHandles.LOUD_SOUND_REDUCTION,
byteArrayOf(if (it) 1.toByte() else 0.toByte()) byteArrayOf(if (it) 1.toByte() else 0.toByte())
) )
} },
// attHandle = ATTHandles.LOUD_SOUND_REDUCTION enabled = state.isPremium
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
@@ -83,7 +82,9 @@ fun HearingProtectionScreen(viewModel: AirPodsViewModel) {
viewModel.setControlCommandBoolean( viewModel.setControlCommandBoolean(
AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG, it AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG, it
) )
}) },
enabled = state.isPremium
)
} }
} }
} }
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.presentation.screens
import android.annotation.SuppressLint import android.annotation.SuppressLint
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
@@ -28,12 +28,9 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable 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.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavController 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.m3.LibrariesContainer
import com.mikepenz.aboutlibraries.ui.compose.produceLibraries import com.mikepenz.aboutlibraries.ui.compose.produceLibraries
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledIconButton import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: Job? = null private var debounceJob: Job? = null
@@ -76,7 +65,7 @@ fun OpenSourceLicensesScreen(navController: NavController) {
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Spacer(modifier = Modifier.height(spacerHeight)) Spacer(modifier = Modifier.height(spacerHeight))
val context = androidx.compose.ui.platform.LocalContext.current val context = LocalContext.current
val libraries by produceLibraries { val libraries by produceLibraries {
context.resources.openRawResource(R.raw.aboutlibraries) context.resources.openRawResource(R.raw.aboutlibraries)
.bufferedReader() .bufferedReader()
@@ -18,7 +18,7 @@
@file:OptIn(ExperimentalStdlibApi::class, ExperimentalEncodingApi::class) @file:OptIn(ExperimentalStdlibApi::class, ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.presentation.screens
import android.content.Context import android.content.Context
import android.util.Log 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.edit import androidx.core.content.edit
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.SelectItem import me.kavishdevar.librepods.presentation.components.SelectItem
import me.kavishdevar.librepods.composables.StyledButton import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSelectList import me.kavishdevar.librepods.presentation.components.StyledSelectList
import me.kavishdevar.librepods.constants.StemAction import me.kavishdevar.librepods.data.StemAction
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.viewmodel.AirPodsViewModel import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.experimental.and import kotlin.experimental.and
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LongPress(viewModel: AirPodsViewModel, name: String) { fun LongPress(viewModel: AirPodsViewModel, name: String, navController: NavController) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black 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)) Spacer(modifier = Modifier.height(24.dp))
StyledButton( StyledButton(
onClick = { onClick = {
viewModel.purchase(context) navController.navigate("purchase_screen")
}, },
backdrop = rememberLayerBackdrop(), backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f, maxScale = 0.05f,
tint = Color(0xFF916100) tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) { ) {
Text( Text(
stringResource(R.string.unlock_all_features), stringResource(R.string.unlock_advanced_features),
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)), 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, fontSize = 14.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f), color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
), ),
fontFamily = FontFamily(Font(R.font.sf_pro)), fontFamily = FontFamily(Font(R.font.sf_pro)),
modifier = Modifier modifier = Modifier
@@ -0,0 +1,496 @@
/*
LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
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))
}
}
}
@@ -18,7 +18,7 @@
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.presentation.screens
import android.content.Context import android.content.Context
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -60,8 +60,8 @@ import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.viewmodel.AirPodsViewModel import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.presentation.screens
// import me.kavishdevar.librepods.utils.RadareOffsetFinder // import me.kavishdevar.librepods.utils.RadareOffsetFinder
import android.annotation.SuppressLint import android.annotation.SuppressLint
@@ -42,7 +42,6 @@ import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -68,15 +67,13 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import me.kavishdevar.librepods.BuildConfig import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.composables.StyledToggle import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.data.TransparencySettings
import me.kavishdevar.librepods.utils.ATTHandles import me.kavishdevar.librepods.data.parseTransparencySettingsResponse
import me.kavishdevar.librepods.utils.TransparencySettings import me.kavishdevar.librepods.data.sendTransparencySettings
import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.utils.sendTransparencySettings
import me.kavishdevar.librepods.viewmodel.AirPodsViewModel
import java.io.IOException import java.io.IOException
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@@ -91,8 +88,6 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val verticalScrollState = rememberScrollState() val verticalScrollState = rememberScrollState()
val attManager = ServiceManager.getService()?.attManager ?: return
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491) val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5) val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF) 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( LaunchedEffect(
enabled.value, enabled.value,
amplificationSliderValue.floatValue, amplificationSliderValue.floatValue,
@@ -211,18 +189,9 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
sendTransparencySettings(viewModel::setATTCharacteristicValue, transparencySettings.value) sendTransparencySettings(viewModel::setATTCharacteristicValue, transparencySettings.value)
} }
DisposableEffect(Unit) {
onDispose {
attManager.unregisterListener(ATTHandles.TRANSPARENCY, transparencyListener)
}
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
Log.d(TAG, "Connecting to ATT...") Log.d(TAG, "Connecting to ATT...")
try { 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 // If we have an AACP manager, prefer its EQ data to populate EQ controls first
try { try {
Log.d(TAG, "Found AACPManager, reading cached EQ data") Log.d(TAG, "Found AACPManager, reading cached EQ data")
@@ -242,7 +211,7 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
for (attempt in 1..3) { for (attempt in 1..3) {
initialReadAttempts.intValue = attempt initialReadAttempts.intValue = attempt
try { try {
val data = attManager.read(ATTHandles.TRANSPARENCY) val data = state.transparencyData
parsedSettings = parseTransparencySettingsResponse(data = data) parsedSettings = parseTransparencySettingsResponse(data = data)
Log.d(TAG, "Parsed settings on attempt $attempt") Log.d(TAG, "Parsed settings on attempt $attempt")
} catch (e: Exception) { } catch (e: Exception) {
@@ -275,8 +244,7 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
} }
} }
// Only show transparency mode section if SDP offset is available if (state.vendorIdHook) {
if (BuildConfig.FLAVOR == "xposed") {
StyledToggle( StyledToggle(
label = stringResource(R.string.transparency_mode), label = stringResource(R.string.transparency_mode),
checked = enabled.value, checked = enabled.value,
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.presentation.screens
import android.content.Intent import android.content.Intent
import android.widget.Toast import android.widget.Toast
@@ -94,7 +94,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import me.kavishdevar.librepods.R 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 me.kavishdevar.librepods.utils.LogCollector
import java.io.File import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.presentation.screens
import android.util.Log import android.util.Log
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
@@ -59,12 +59,12 @@ import dev.chrisbanes.haze.hazeSource
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import me.kavishdevar.librepods.R 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.services.ServiceManager
import me.kavishdevar.librepods.utils.ATTHandles import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.utils.HearingAidSettings import me.kavishdevar.librepods.data.HearingAidSettings
import me.kavishdevar.librepods.utils.parseHearingAidSettingsResponse import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
import me.kavishdevar.librepods.utils.sendHearingAidSettings import me.kavishdevar.librepods.data.sendHearingAidSettings
import java.io.IOException import java.io.IOException
private var debounceJob: MutableState<Job?> = mutableStateOf(null) private var debounceJob: MutableState<Job?> = mutableStateOf(null)
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.presentation.screens
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme 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.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.viewmodel.AirPodsViewModel import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@Composable @Composable
fun VersionScreen(viewModel: AirPodsViewModel) { fun VersionScreen(viewModel: AirPodsViewModel) {
@@ -80,7 +80,8 @@ fun VersionScreen(viewModel: AirPodsViewModel) {
style = TextStyle( style = TextStyle(
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f) color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
) )
) )
} }
@@ -17,7 +17,7 @@
*/ */
package me.kavishdevar.librepods.ui.theme package me.kavishdevar.librepods.presentation.theme
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.ui.theme package me.kavishdevar.librepods.presentation.theme
import android.os.Build import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
@@ -16,7 +16,7 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.ui.theme package me.kavishdevar.librepods.presentation.theme
import androidx.compose.material3.Typography import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
@@ -16,9 +16,8 @@
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package me.kavishdevar.librepods.viewmodel package me.kavishdevar.librepods.presentation.viewmodel
import android.app.Activity
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@@ -28,25 +27,29 @@ import android.util.Log
import androidx.core.content.edit import androidx.core.content.edit
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.billing.BillingManager import me.kavishdevar.librepods.billing.BillingManager
import me.kavishdevar.librepods.constants.AirPodsNotifications import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.constants.Battery import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers
import me.kavishdevar.librepods.constants.BatteryComponent import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.constants.BatteryStatus import me.kavishdevar.librepods.data.AirPodsInstance
import me.kavishdevar.librepods.constants.StemAction 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.ControlCommandRepository
import me.kavishdevar.librepods.data.StemAction
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
import me.kavishdevar.librepods.services.AirPodsService 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") @Suppress("ArrayInDataClass")
data class AirPodsUiState( data class AirPodsUiState(
@@ -81,7 +84,12 @@ data class AirPodsUiState(
val leftAction: StemAction = StemAction.CYCLE_NOISE_CONTROL_MODES, val leftAction: StemAction = StemAction.CYCLE_NOISE_CONTROL_MODES,
val rightAction: 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 isPremium: Boolean = false,
val vendorIdHook: Boolean = false
) )
class AirPodsViewModel( class AirPodsViewModel(
@@ -90,8 +98,14 @@ class AirPodsViewModel(
private val controlRepo: ControlCommandRepository, private val controlRepo: ControlCommandRepository,
private val appContext: Context private val appContext: Context
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(
private val _uiState = MutableStateFlow(AirPodsUiState(deviceName = sharedPreferences.getString("name", "AirPods Pro") ?: "AirPods Pro")) AirPodsUiState(
deviceName = sharedPreferences.getString(
"name",
"AirPods Pro"
) ?: "AirPods Pro"
)
)
val uiState: StateFlow<AirPodsUiState> = _uiState val uiState: StateFlow<AirPodsUiState> = _uiState
private var isDemoMode = false private var isDemoMode = false
@@ -99,17 +113,16 @@ class AirPodsViewModel(
private var billingFirstCollectDone = false private var billingFirstCollectDone = false
private val listeners = mutableMapOf< private val listeners =
ControlCommandIdentifiers, mutableMapOf<ControlCommandIdentifiers, AACPManager.ControlCommandListener>()
AACPManager.ControlCommandListener
>() private val xposedRemotePref = XposedRemotePrefProvider.create()
private lateinit var broadcastReceiver: BroadcastReceiver private lateinit var broadcastReceiver: BroadcastReceiver
private val _cameraAction = MutableStateFlow( private val _cameraAction = MutableStateFlow(
sharedPreferences.getString("camera_action", null) 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<AACPManager.Companion.StemPressType?> = _cameraAction val cameraAction: StateFlow<AACPManager.Companion.StemPressType?> = _cameraAction
@@ -129,6 +142,7 @@ class AirPodsViewModel(
setupControlObservers() setupControlObservers()
observeBilling() observeBilling()
loadControlList() loadControlList()
observeATT()
if (isDemoMode) activateDemoMode() if (isDemoMode) activateDemoMode()
} }
@@ -148,7 +162,9 @@ class AirPodsViewModel(
} }
private fun observeBilling() { 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 -> BillingManager.provider.isPremium.collect { premium ->
if (!billingFirstCollectDone) { if (!billingFirstCollectDone) {
billingFirstCollectDone = true billingFirstCollectDone = true
@@ -156,7 +172,10 @@ class AirPodsViewModel(
} }
if (!premium) { if (!premium) {
Log.d("AirPodsViewModel", "we are not premium") Log.d("AirPodsViewModel", "we are not premium")
setControlCommandBoolean(ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG, false) setControlCommandBoolean(
ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
false
)
setHeadGesturesEnabled(false) setHeadGesturesEnabled(false)
} else { } else {
Log.d("AirPodsViewModel", "we are premium") Log.d("AirPodsViewModel", "we are premium")
@@ -183,7 +202,8 @@ class AirPodsViewModel(
} }
AirPodsNotifications.BATTERY_DATA -> { 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 { _uiState.update {
it.copy(battery = data) it.copy(battery = data)
} }
@@ -213,15 +233,12 @@ class AirPodsViewModel(
} }
appContext.registerReceiver( appContext.registerReceiver(
broadcastReceiver, broadcastReceiver, filter, Context.RECEIVER_NOT_EXPORTED
filter,
Context.RECEIVER_NOT_EXPORTED
) )
} }
fun setControlCommandValue( fun setControlCommandValue(
identifier: ControlCommandIdentifiers, identifier: ControlCommandIdentifiers, value: ByteArray
value: ByteArray
) { ) {
if (!isDemoMode) controlRepo.setValue(identifier, value) if (!isDemoMode) controlRepo.setValue(identifier, value)
_uiState.update { _uiState.update {
@@ -232,25 +249,21 @@ class AirPodsViewModel(
} }
fun setControlCommandBoolean( fun setControlCommandBoolean(
identifier: ControlCommandIdentifiers, identifier: ControlCommandIdentifiers, enabled: Boolean
enabled: Boolean
) { ) {
setControlCommandValue( setControlCommandValue(
identifier, identifier, if (enabled) byteArrayOf(0x01) else byteArrayOf(0x02)
if (enabled) byteArrayOf(0x01) else byteArrayOf(0x02)
) )
} }
fun setControlCommandInt( fun setControlCommandInt(
identifier: ControlCommandIdentifiers, identifier: ControlCommandIdentifiers, value: Int
value: Int
) { ) {
setControlCommandValue(identifier, byteArrayOf(value.toByte())) setControlCommandValue(identifier, byteArrayOf(value.toByte()))
} }
fun setControlCommandByte( fun setControlCommandByte(
identifier: ControlCommandIdentifiers, identifier: ControlCommandIdentifiers, value: Byte
value: Byte
) { ) {
setControlCommandValue(identifier, byteArrayOf(value)) setControlCommandValue(identifier, byteArrayOf(value))
} }
@@ -267,7 +280,7 @@ class AirPodsViewModel(
} }
} }
listeners[identifier] = listener listeners[identifier] = listener as AACPManager.ControlCommandListener
} }
// I'm lazy, sorry. // I'm lazy, sorry.
@@ -309,8 +322,7 @@ class AirPodsViewModel(
service.let { service -> service.let { service ->
_uiState.update { _uiState.update {
it.copy( it.copy(
isLocallyConnected = service.isConnected(), isLocallyConnected = service.isConnected(), battery = service.getBattery()
battery = service.getBattery()
) )
} }
} }
@@ -318,11 +330,24 @@ class AirPodsViewModel(
private fun loadSharedPreferences() { private fun loadSharedPreferences() {
val offListeningModeEnabled = sharedPreferences.getBoolean("off_listening_mode", true) val offListeningModeEnabled = sharedPreferences.getBoolean("off_listening_mode", true)
val automaticEarDetectionEnabled = sharedPreferences.getBoolean("automatic_ear_detection", true) val automaticEarDetectionEnabled =
val automaticConnectionEnabled = sharedPreferences.getBoolean("automatic_connection_ctrl_cmd", true) sharedPreferences.getBoolean("automatic_ear_detection", true)
val automaticConnectionEnabled =
sharedPreferences.getBoolean("automatic_connection_ctrl_cmd", true)
val headGesturesEnabled = sharedPreferences.getBoolean("head_gestures", 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 leftAction = StemAction.valueOf(
val rightAction = StemAction.valueOf(sharedPreferences.getString("right_long_press_action", "CYCLE_NOISE_CONTROL_MODES") ?: "CYCLE_NOISE_CONTROL_MODES") 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 { _uiState.update {
it.copy( it.copy(
@@ -331,7 +356,8 @@ class AirPodsViewModel(
automaticConnectionEnabled = automaticConnectionEnabled, automaticConnectionEnabled = automaticConnectionEnabled,
headGesturesEnabled = headGesturesEnabled, headGesturesEnabled = headGesturesEnabled,
leftAction = leftAction, leftAction = leftAction,
rightAction = rightAction rightAction = rightAction,
vendorIdHook = vendorIdHook
) )
} }
} }
@@ -365,14 +391,12 @@ class AirPodsViewModel(
name = "AirPods", name = "AirPods",
model = AirPodsModels.getModelByModelNumber("A3049")!!, model = AirPodsModels.getModelByModelNumber("A3049")!!,
actualModelNumber = "A3049", actualModelNumber = "A3049",
aacpManager = service.aacpManager,
serialNumber = null, serialNumber = null,
leftSerialNumber = null, leftSerialNumber = null,
rightSerialNumber = null, rightSerialNumber = null,
version1 = null, version1 = null,
version2 = null, version2 = null,
version3 = null, version3 = null,
attManager = null
) )
_uiState.update { _uiState.update {
@@ -381,7 +405,11 @@ class AirPodsViewModel(
instance = instance, instance = instance,
modelName = instance.model.displayName, modelName = instance.model.displayName,
actualModel = instance.actualModelNumber, actualModel = instance.actualModelNumber,
serialNumbers = listOf(instance.serialNumber ?: "", instance.leftSerialNumber ?: "", instance.rightSerialNumber ?: ""), serialNumbers = listOf(
instance.serialNumber ?: "",
instance.leftSerialNumber ?: "",
instance.rightSerialNumber ?: ""
),
version1 = instance.version1 ?: "", version1 = instance.version1 ?: "",
version2 = instance.version2 ?: "", version2 = instance.version2 ?: "",
version3 = instance.version3 ?: "" version3 = instance.version3 ?: ""
@@ -408,11 +436,42 @@ class AirPodsViewModel(
} }
fun setATTCharacteristicValue(handle: ATTHandles, value: ByteArray) { 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? { fun refreshATT() {
return service.attManager?.read(handle) 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) { fun setAutomaticEarDetectionEnabled(enabled: Boolean) {
@@ -435,9 +494,9 @@ class AirPodsViewModel(
} }
} }
fun purchase(context: Context) { // fun purchase(context: Context) {
BillingManager.provider.purchase(context as Activity) // BillingManager.provider.purchase(context as Activity)
} // }
fun activateDemoMode() { fun activateDemoMode() {
isDemoMode = true isDemoMode = true
@@ -448,14 +507,12 @@ class AirPodsViewModel(
name = "AirPods Pro (Demo)", name = "AirPods Pro (Demo)",
model = AirPodsModels.getModelByModelNumber("A3049")!!, model = AirPodsModels.getModelByModelNumber("A3049")!!,
actualModelNumber = "A3049", actualModelNumber = "A3049",
aacpManager = service.aacpManager,
serialNumber = "DEMO123", serialNumber = "DEMO123",
leftSerialNumber = "L-DEMO", leftSerialNumber = "L-DEMO",
rightSerialNumber = "R-DEMO", rightSerialNumber = "R-DEMO",
version1 = "1.0", version1 = "1.0",
version2 = "1.0", version2 = "1.0",
version3 = "1.0", version3 = "1.0",
attManager = null
) )
_uiState.update { _uiState.update {
@@ -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.app.Application
import android.content.Context import android.content.Context
import androidx.core.content.edit import androidx.core.content.edit
@@ -10,7 +9,10 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.billing.BillingManager import me.kavishdevar.librepods.billing.BillingManager
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
import me.kavishdevar.librepods.utils.NativeBridge
import kotlin.math.roundToInt import kotlin.math.roundToInt
data class AppSettingsUiState( data class AppSettingsUiState(
@@ -29,6 +31,7 @@ data class AppSettingsUiState(
val showCameraDialog: Boolean = false, val showCameraDialog: Boolean = false,
val cameraPackageValue: String = "", val cameraPackageValue: String = "",
val cameraPackageError: String? = null, val cameraPackageError: String? = null,
val vendorIdHook: Boolean = false,
val isPremium: Boolean = false val isPremium: Boolean = false
) )
@@ -38,6 +41,8 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat
private val _uiState = MutableStateFlow(AppSettingsUiState()) private val _uiState = MutableStateFlow(AppSettingsUiState())
val uiState = _uiState.asStateFlow() val uiState = _uiState.asStateFlow()
private val xposedRemotePref = XposedRemotePrefProvider.create()
init { init {
loadSettings() loadSettings()
observeBilling() observeBilling()
@@ -66,9 +71,13 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat
takeoverWhenMediaStart = sharedPreferences.getBoolean("takeover_when_media_start", false), takeoverWhenMediaStart = sharedPreferences.getBoolean("takeover_when_media_start", false),
useAlternateHeadTrackingPackets = sharedPreferences.getBoolean("use_alternate_head_tracking_packets", true), useAlternateHeadTrackingPackets = sharedPreferences.getBoolean("use_alternate_head_tracking_packets", true),
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 43).toFloat(), 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) { fun setShowPhoneBatteryInWidget(enabled: Boolean) {
@@ -152,7 +161,9 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat
setShowCameraDialog(false) setShowCameraDialog(false)
} }
fun purchase(context: Context) { fun setVendorIdHook(enabled: Boolean) {
BillingManager.provider.purchase(context as Activity) NativeBridge.setSdpHook(enabled)
xposedRemotePref.putBoolean("vendor_id_hook", enabled)
_uiState.update { it.copy(vendorIdHook = enabled) }
} }
} }
@@ -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()
}
}
@@ -18,7 +18,7 @@
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.widgets package me.kavishdevar.librepods.presentation.widgets
import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider import android.appwidget.AppWidgetProvider
@@ -18,7 +18,7 @@
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.widgets package me.kavishdevar.librepods.presentation.widgets
import android.app.PendingIntent import android.app.PendingIntent
import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetManager
@@ -29,7 +29,7 @@ import android.util.Log
import android.widget.RemoteViews import android.widget.RemoteViews
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.bluetooth.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
class NoiseControlWidget : AppWidgetProvider() { class NoiseControlWidget : AppWidgetProvider() {
@@ -35,9 +35,9 @@ import android.util.Log
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import me.kavishdevar.librepods.QuickSettingsDialogActivity import me.kavishdevar.librepods.QuickSettingsDialogActivity
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.AirPodsNotifications import me.kavishdevar.librepods.data.AirPodsNotifications
import me.kavishdevar.librepods.constants.NoiseControlMode import me.kavishdevar.librepods.data.NoiseControlMode
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.bluetooth.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@RequiresApi(Build.VERSION_CODES.Q) @RequiresApi(Build.VERSION_CODES.Q)
@@ -83,25 +83,28 @@ import kotlinx.coroutines.withTimeout
import me.kavishdevar.librepods.BuildConfig import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.MainActivity import me.kavishdevar.librepods.MainActivity
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.AirPodsNotifications import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.constants.Battery import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.StemPressType
import me.kavishdevar.librepods.constants.BatteryComponent import me.kavishdevar.librepods.bluetooth.ATTManager
import me.kavishdevar.librepods.constants.BatteryStatus import me.kavishdevar.librepods.bluetooth.BLEManager
import me.kavishdevar.librepods.constants.StemAction import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager
import me.kavishdevar.librepods.constants.isHeadTrackingData import me.kavishdevar.librepods.data.AirPodsInstance
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.data.AirPodsModels
import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressType import me.kavishdevar.librepods.data.AirPodsNotifications
import me.kavishdevar.librepods.utils.ATTManager import me.kavishdevar.librepods.data.Battery
import me.kavishdevar.librepods.utils.AirPodsInstance import me.kavishdevar.librepods.data.BatteryComponent
import me.kavishdevar.librepods.utils.AirPodsModels import me.kavishdevar.librepods.data.BatteryStatus
import me.kavishdevar.librepods.utils.BLEManager import me.kavishdevar.librepods.data.StemAction
import me.kavishdevar.librepods.utils.BluetoothConnectionManager 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.GestureDetector
import me.kavishdevar.librepods.utils.HeadTracking 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.MediaController
import me.kavishdevar.librepods.utils.PopupWindow
import me.kavishdevar.librepods.utils.SystemApisUtils import me.kavishdevar.librepods.utils.SystemApisUtils
import me.kavishdevar.librepods.utils.SystemApisUtils.DEVICE_TYPE_UNTETHERED_HEADSET import me.kavishdevar.librepods.utils.SystemApisUtils.DEVICE_TYPE_UNTETHERED_HEADSET
import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_COMPANION_APP 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_CHARGING
import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_ICON 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.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.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
import kotlin.io.encoding.Base64 import kotlin.io.encoding.Base64
@@ -1060,8 +1061,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
version1 = config.airpodsVersion1, version1 = config.airpodsVersion1,
version2 = config.airpodsVersion2, version2 = config.airpodsVersion2,
version3 = config.airpodsVersion3, version3 = config.airpodsVersion3,
aacpManager = aacpManager,
attManager = attManager
) )
} }
sendBroadcast( sendBroadcast(
@@ -1765,8 +1764,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} }
} }
@Suppress("KotlinUnreachableCode")
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
private fun showSocketConnectionFailureNotification(errorMessage: String) { private fun showSocketConnectionFailureNotification(errorMessage: String) {
return // something causes too many notifications. turning off for now
if (BuildConfig.FLAVOR != "xposed") { if (BuildConfig.FLAVOR != "xposed") {
Log.w( Log.w(
TAG, TAG,
@@ -1788,7 +1789,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
.setSmallIcon(R.drawable.airpods).setContentTitle("AirPods Connection Issue") .setSmallIcon(R.drawable.airpods).setContentTitle("AirPods Connection Issue")
.setContentText("Unable to connect to AirPods over L2CAP").setStyle( .setContentText("Unable to connect to AirPods over L2CAP").setStyle(
NotificationCompat.BigTextStyle().bigText( 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) ).setContentIntent(pendingIntent).setCategory(Notification.CATEGORY_ERROR)
.setPriority(NotificationCompat.PRIORITY_HIGH).setAutoCancel(true).build() .setPriority(NotificationCompat.PRIORITY_HIGH).setAutoCancel(true).build()
@@ -2178,7 +2179,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
fun processHeadTrackingData(data: ByteArray) { fun processHeadTrackingData(data: ByteArray) {
val horizontal = ByteBuffer.wrap(data, 51, 2).order(ByteOrder.LITTLE_ENDIAN).short.toInt() 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() 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 private lateinit var connectionReceiver: BroadcastReceiver
@@ -2666,8 +2671,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
this@AirPodsService.device = device this@AirPodsService.device = device
BluetoothConnectionManager.setCurrentConnection(socket, device) BluetoothConnectionManager.setCurrentConnection(socket, device)
val xposedRemotePref = XposedRemotePrefProvider.create()
if (BuildConfig.FLAVOR == "xposed") { if (xposedRemotePref.getBoolean("vendor_id_hook", false)) {
attManager = ATTManager(adapter, device) attManager = ATTManager(adapter, device)
attManager!!.connect() attManager!!.connect()
} }
@@ -2687,8 +2692,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
version1 = config.airpodsVersion1, version1 = config.airpodsVersion1,
version2 = config.airpodsVersion2, version2 = config.airpodsVersion2,
version3 = config.airpodsVersion3, version3 = config.airpodsVersion3,
aacpManager = aacpManager,
attManager = attManager
) )
} }
} }
@@ -40,13 +40,13 @@ val cameraPackages = mutableSetOf(
var cameraOpen = false var cameraOpen = false
private var currentCustomPackage: String? = null private var currentCustomPackage: String? = null
class AppListenerService : AccessibilityService() { class AppListenerService: AccessibilityService() {
private lateinit var prefs: android.content.SharedPreferences private lateinit var prefs: android.content.SharedPreferences
private val preferenceChangeListener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key -> private val preferenceChangeListener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key ->
if (key == "custom_camera_package") { if (key == "custom_camera_package") {
val newPackage = sharedPreferences.getString(key, null) val newPackage = sharedPreferences.getString(key, null)
currentCustomPackage?.let { cameraPackages.remove(it) } currentCustomPackage?.let { cameraPackages.remove(it) }
if (newPackage != null && newPackage.isNotBlank()) { if (!newPackage.isNullOrBlank()) {
cameraPackages.add(newPackage) cameraPackages.add(newPackage)
} }
currentCustomPackage = newPackage currentCustomPackage = newPackage
@@ -57,7 +57,7 @@ class AppListenerService : AccessibilityService() {
super.onCreate() super.onCreate()
prefs = getSharedPreferences("settings", MODE_PRIVATE) prefs = getSharedPreferences("settings", MODE_PRIVATE)
val customPackage = prefs.getString("custom_camera_package", null) val customPackage = prefs.getString("custom_camera_package", null)
if (customPackage != null && customPackage.isNotBlank()) { if (!customPackage.isNullOrBlank()) {
cameraPackages.add(customPackage) cameraPackages.add(customPackage)
currentCustomPackage = customPackage currentCustomPackage = customPackage
} }
@@ -18,32 +18,27 @@
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.utils
import android.content.SharedPreferences
import android.os.Build import android.os.Build
import me.kavishdevar.librepods.BuildConfig import me.kavishdevar.librepods.BuildConfig
fun isSupported(): Boolean { fun isSupported(sharedPreferences: SharedPreferences): Boolean {
if (BuildConfig.PLAY_BUILD) { val isPixel = Build.MANUFACTURER.lowercase() == "google"
val isPixel = Build.MANUFACTURER.lowercase() == "google" val isOppoOrOnePlus = Build.MANUFACTURER.lowercase() in listOf("oneplus", "oppo")
val isOppoOrOnePlus = Build.MANUFACTURER.lowercase() in listOf("oneplus", "oppo")
if (isPixel) { if (isPixel) {
when (Build.VERSION.SDK_INT) { when (Build.VERSION.SDK_INT) {
36 -> { 36 -> {
return Build.ID == "CP1A.260305.018" || Build.ID == "CP1A.260405.005" return Build.ID == "CP1A.260305.018" || Build.ID == "CP1A.260405.005"
} }
37 -> { 37 -> {
return true 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
}*/
@@ -296,7 +296,7 @@ object SystemApisUtils {
) )
method.invoke(device, key, value) as Boolean method.invoke(device, key, value) as Boolean
} catch (e: Exception) { } 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 false
} }
} }
@@ -0,0 +1,7 @@
package me.kavishdevar.librepods.utils
import io.github.libxposed.service.XposedService
object XposedServiceHolder {
var service: XposedService? = null
}
@@ -34,7 +34,7 @@
<string name="airpods_not_connected">AirPods no conectados</string> <string name="airpods_not_connected">AirPods no conectados</string>
<string name="airpods_not_connected_description">Por favor, conecta tus AirPods para acceder a los ajustes.</string> <string name="airpods_not_connected_description">Por favor, conecta tus AirPods para acceder a los ajustes.</string>
<string name="back">Atrás</string> <string name="back">Atrás</string>
<string name="app_settings">Personalización</string> <string name="customizations">Personalización</string>
<string name="relative_conversational_awareness_volume">Volumen relativo</string> <string name="relative_conversational_awareness_volume">Volumen relativo</string>
<string name="relative_conversational_awareness_volume_description">Reduce a un porcentaje del volumen actual en vez del volumen máximo.</string> <string name="relative_conversational_awareness_volume_description">Reduce a un porcentaje del volumen actual en vez del volumen máximo.</string>
<string name="conversational_awareness_pause_music">Pausar música</string> <string name="conversational_awareness_pause_music">Pausar música</string>
@@ -169,7 +169,7 @@
<string name="enter_enc_key_hex">Introducir 16-byte ENC_KEY como formato hexadecimal (32 caracteres):</string> <string name="enter_enc_key_hex">Introducir 16-byte ENC_KEY como formato hexadecimal (32 caracteres):</string>
<string name="must_be_32_hex_chars">Debe tener exactamente 32 caracteres hexadecimales</string> <string name="must_be_32_hex_chars">Debe tener exactamente 32 caracteres hexadecimales</string>
<string name="error_converting_hex">Error convirtiendo hex:</string> <string name="error_converting_hex">Error convirtiendo hex:</string>
<string name="found_offset_restart_bluetooth">Offset encontrado. Por favor, reinicie el proceso Bluetooth</string> <string name="found_offset_restart_bluetooth">Por favor, reinicie el proceso Bluetooth</string>
<string name="digital_assistant">Asistente Digital</string> <string name="digital_assistant">Asistente Digital</string>
<string name="on">Activado</string> <string name="on">Activado</string>
<string name="camera_remote">Control Remoto de Cámara</string> <string name="camera_remote">Control Remoto de Cámara</string>
@@ -34,7 +34,7 @@
<string name="airpods_not_connected">AirPods non connectés</string> <string name="airpods_not_connected">AirPods non connectés</string>
<string name="airpods_not_connected_description">Veuillez connecter vos AirPods pour accéder aux réglages.</string> <string name="airpods_not_connected_description">Veuillez connecter vos AirPods pour accéder aux réglages.</string>
<string name="back">Retour</string> <string name="back">Retour</string>
<string name="app_settings">Personnalisations</string> <string name="customizations">Personnalisations</string>
<string name="relative_conversational_awareness_volume">Volume relatif</string> <string name="relative_conversational_awareness_volume">Volume relatif</string>
<string name="relative_conversational_awareness_volume_description">Réduit à un pourcentage du volume actuel plutôt qu\'au volume maximum.</string> <string name="relative_conversational_awareness_volume_description">Réduit à un pourcentage du volume actuel plutôt qu\'au volume maximum.</string>
<string name="conversational_awareness_pause_music">Mettre la musique en pause</string> <string name="conversational_awareness_pause_music">Mettre la musique en pause</string>
@@ -34,7 +34,7 @@
<string name="airpods_not_connected">AirPods não conectados</string> <string name="airpods_not_connected">AirPods não conectados</string>
<string name="airpods_not_connected_description">Por favor, conecte seus AirPods para acessar as configurações.</string> <string name="airpods_not_connected_description">Por favor, conecte seus AirPods para acessar as configurações.</string>
<string name="back">Voltar</string> <string name="back">Voltar</string>
<string name="app_settings">Personalizações</string> <string name="customizations">Personalizações</string>
<string name="relative_conversational_awareness_volume">Volume relativo</string> <string name="relative_conversational_awareness_volume">Volume relativo</string>
<string name="relative_conversational_awareness_volume_description">Reduz para uma porcentagem do volume atual em vez do volume máximo.</string> <string name="relative_conversational_awareness_volume_description">Reduz para uma porcentagem do volume atual em vez do volume máximo.</string>
<string name="conversational_awareness_pause_music">Pausar Música</string> <string name="conversational_awareness_pause_music">Pausar Música</string>
@@ -34,7 +34,7 @@
<string name="airpods_not_connected">AirPods bağlı değil</string> <string name="airpods_not_connected">AirPods bağlı değil</string>
<string name="airpods_not_connected_description">Ayarlara erişmek için lütfen AirPods\'unuzu bağlayın.</string> <string name="airpods_not_connected_description">Ayarlara erişmek için lütfen AirPods\'unuzu bağlayın.</string>
<string name="back">Geri</string> <string name="back">Geri</string>
<string name="app_settings">Özelleştirmeler</string> <string name="customizations">Özelleştirmeler</string>
<string name="relative_conversational_awareness_volume">Göreceli ses</string> <string name="relative_conversational_awareness_volume">Göreceli ses</string>
<string name="relative_conversational_awareness_volume_description">Maksimum ses yerine mevcut sesin yüzdesine göre azaltır.</string> <string name="relative_conversational_awareness_volume_description">Maksimum ses yerine mevcut sesin yüzdesine göre azaltır.</string>
<string name="conversational_awareness_pause_music">Müziği Duraklat</string> <string name="conversational_awareness_pause_music">Müziği Duraklat</string>
@@ -34,7 +34,7 @@
<string name="airpods_not_connected">AirPods не підключені</string> <string name="airpods_not_connected">AirPods не підключені</string>
<string name="airpods_not_connected_description">Будь ласка, підключіть ваші AirPods, щоб отримати доступ до налаштувань.</string> <string name="airpods_not_connected_description">Будь ласка, підключіть ваші AirPods, щоб отримати доступ до налаштувань.</string>
<string name="back">Назад</string> <string name="back">Назад</string>
<string name="app_settings">Персоналізація</string> <string name="customizations">Персоналізація</string>
<string name="relative_conversational_awareness_volume">Відносна гучність</string> <string name="relative_conversational_awareness_volume">Відносна гучність</string>
<string name="relative_conversational_awareness_volume_description">Зменшує до відсотка від поточної гучності, а не від максимальної.</string> <string name="relative_conversational_awareness_volume_description">Зменшує до відсотка від поточної гучності, а не від максимальної.</string>
<string name="conversational_awareness_pause_music">Призупинити Музику</string> <string name="conversational_awareness_pause_music">Призупинити Музику</string>
@@ -34,7 +34,7 @@
<string name="airpods_not_connected">AirPods chưa được kết nối</string> <string name="airpods_not_connected">AirPods chưa được kết nối</string>
<string name="airpods_not_connected_description">Vui lòng kết nối đến AirPods của bạn để truy cập cài đặt.</string> <string name="airpods_not_connected_description">Vui lòng kết nối đến AirPods của bạn để truy cập cài đặt.</string>
<string name="back">Quay lại</string> <string name="back">Quay lại</string>
<string name="app_settings">Tùy chỉnh</string> <string name="customizations">Tùy chỉnh</string>
<string name="relative_conversational_awareness_volume">Âm lượng tương đối</string> <string name="relative_conversational_awareness_volume">Âm lượng tương đối</string>
<string name="relative_conversational_awareness_volume_description">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.</string> <string name="relative_conversational_awareness_volume_description">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.</string>
<string name="conversational_awareness_pause_music">Tạm dừng nhạc</string> <string name="conversational_awareness_pause_music">Tạm dừng nhạc</string>
@@ -169,7 +169,7 @@
<string name="enter_enc_key_hex">Nhập ENC_KEY 16 byte dưới dạng chuỗi hex (32 ký tự):</string> <string name="enter_enc_key_hex">Nhập ENC_KEY 16 byte dưới dạng chuỗi hex (32 ký tự):</string>
<string name="must_be_32_hex_chars">Phải chính xác 32 ký tự hex</string> <string name="must_be_32_hex_chars">Phải chính xác 32 ký tự hex</string>
<string name="error_converting_hex">Lỗi chuyển đổi hex:</string> <string name="error_converting_hex">Lỗi chuyển đổi hex:</string>
<string name="found_offset_restart_bluetooth">Đã tìm thấy độ lệch, vui lòng khởi động lại tiến trình Bluetooth</string> <string name="found_offset_restart_bluetooth">vui lòng khởi động lại tiến trình Bluetooth</string>
<string name="digital_assistant">Trợ lý kỹ thuật số</string> <string name="digital_assistant">Trợ lý kỹ thuật số</string>
<string name="on">Bật</string> <string name="on">Bật</string>
<string name="camera_remote">Điều khiển máy ảnh từ xa</string> <string name="camera_remote">Điều khiển máy ảnh từ xa</string>
@@ -33,7 +33,7 @@
<string name="airpods_not_connected">AirPods 未连接</string> <string name="airpods_not_connected">AirPods 未连接</string>
<string name="airpods_not_connected_description">请连接 AirPods 以访问设置。</string> <string name="airpods_not_connected_description">请连接 AirPods 以访问设置。</string>
<string name="back">返回</string> <string name="back">返回</string>
<string name="app_settings">自定义</string> <string name="customizations">自定义</string>
<string name="relative_conversational_awareness_volume">相对音量</string> <string name="relative_conversational_awareness_volume">相对音量</string>
<string name="relative_conversational_awareness_volume_description">降低到当前音量的百分比,而不是最大音量。</string> <string name="relative_conversational_awareness_volume_description">降低到当前音量的百分比,而不是最大音量。</string>
<string name="conversational_awareness_pause_music">暂停音乐</string> <string name="conversational_awareness_pause_music">暂停音乐</string>
@@ -34,7 +34,7 @@
<string name="airpods_not_connected">未連接 AirPods</string> <string name="airpods_not_connected">未連接 AirPods</string>
<string name="airpods_not_connected_description">請連接你的 AirPods 以存取設定。</string> <string name="airpods_not_connected_description">請連接你的 AirPods 以存取設定。</string>
<string name="back">返回</string> <string name="back">返回</string>
<string name="app_settings">自訂</string> <string name="customizations">自訂</string>
<string name="relative_conversational_awareness_volume">相對音量</string> <string name="relative_conversational_awareness_volume">相對音量</string>
<string name="relative_conversational_awareness_volume_description">降低至當前音量的百分比,而不是最大音量。</string> <string name="relative_conversational_awareness_volume_description">降低至當前音量的百分比,而不是最大音量。</string>
<string name="conversational_awareness_pause_music">暫停音樂</string> <string name="conversational_awareness_pause_music">暫停音樂</string>
+28 -3
View File
@@ -34,7 +34,7 @@
<string name="airpods_not_connected">AirPods not connected</string> <string name="airpods_not_connected">AirPods not connected</string>
<string name="airpods_not_connected_description">Please connect your AirPods to access settings.</string> <string name="airpods_not_connected_description">Please connect your AirPods to access settings.</string>
<string name="back">Back</string> <string name="back">Back</string>
<string name="app_settings">Customizations</string> <string name="customizations">Customizations</string>
<string name="relative_conversational_awareness_volume">Relative volume</string> <string name="relative_conversational_awareness_volume">Relative volume</string>
<string name="relative_conversational_awareness_volume_description">Reduces to a percentage of the current volume instead of the maximum volume.</string> <string name="relative_conversational_awareness_volume_description">Reduces to a percentage of the current volume instead of the maximum volume.</string>
<string name="conversational_awareness_pause_music">Pause Music</string> <string name="conversational_awareness_pause_music">Pause Music</string>
@@ -169,7 +169,7 @@
<string name="enter_enc_key_hex">Enter 16-byte ENC_KEY as hex string (32 characters):</string> <string name="enter_enc_key_hex">Enter 16-byte ENC_KEY as hex string (32 characters):</string>
<string name="must_be_32_hex_chars">Must be exactly 32 hex characters</string> <string name="must_be_32_hex_chars">Must be exactly 32 hex characters</string>
<string name="error_converting_hex">Error converting hex:</string> <string name="error_converting_hex">Error converting hex:</string>
<string name="found_offset_restart_bluetooth">Found offset please restart the Bluetooth process</string> <string name="found_offset_restart_bluetooth">Please restart the Bluetooth process</string>
<string name="digital_assistant">Digital Assistant</string> <string name="digital_assistant">Digital Assistant</string>
<string name="on">On</string> <string name="on">On</string>
<string name="camera_remote">Camera Remote</string> <string name="camera_remote">Camera Remote</string>
@@ -210,5 +210,30 @@
<string name="listening_mode_transparency_description">Lets in external sounds</string> <string name="listening_mode_transparency_description">Lets in external sounds</string>
<string name="listening_mode_adaptive_description">Dynamically adjust external noise</string> <string name="listening_mode_adaptive_description">Dynamically adjust external noise</string>
<string name="listening_mode_noise_cancellation_description">Blocks out external sounds</string> <string name="listening_mode_noise_cancellation_description">Blocks out external sounds</string>
<string name="unlock_all_features">Unlock all features</string> <string name="unlock_advanced_features">Unlock advanced features</string>
<string name="buy">Buy</string>
<string name="restore_purchases">Restore purchases</string>
<string name="ear_detection_description">Automatically stop playing audio when you take them off, and resume playback when you put them back on.</string>
<string name="battery">Battery</string>
<string name="battery_description">View accurate battery status in the app and notification.</string>
<string name="noise_control_description">Switch between listening modes directly from the app or Quick Settings.</string>
<string name="advanced_device_settings">Advanced device settings</string>
<string name="advanced_device_settings_description">Customize settings like Personalized Volume, Adaptive Audio, Pause media when falling asleep, and other Accessibility settings.</string>
<string name="automatic_connection">Automatic Connection</string>
<string name="automatic_connection_description">Enable and customize automatic connection to AirPods.</string>
<string name="customizations_description">Get access to app customizations, including phone battery in widget, conversational awareness volume, and many more upcoming customization features.</string>
<string name="support_the_development">Support the development</string>
<string name="support_development_description">LibrePods is developed by a single developer. Upgrading helps keep the app alive.</string>
<string name="feature_availability_disclaimer">Feature availability depends on your AirPods model and firmware version.</string>
<string name="contact">Contact</string>
<string name="email">E-Mail</string>
<string name="discord">Discord</string>
<string name="github_issues">GitHub Issues</string>
<string name="version_code">Version code</string>
<string name="flavor" translatable="false">Flavor</string>
<string name="build_type">Build type</string>
<string name="no">No</string>
<string name="yes">Yes</string>
<string name="settings">Settings</string>
<string name="requires_xposed">requires xposed</string>
</resources> </resources>
@@ -0,0 +1,5 @@
package me.kavishdevar.librepods
import android.app.Application
class LibrePodsApplication: Application()
@@ -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) { }
}
@@ -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)
}
@@ -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
}
}
+34 -20
View File
@@ -24,6 +24,8 @@
#include <unistd.h> #include <unistd.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <elf.h> #include <elf.h>
#include <atomic>
#include <jni.h>
#include "l2c_fcr_hook.h" #include "l2c_fcr_hook.h"
@@ -31,7 +33,7 @@ extern "C" {
#include "xz.h" #include "xz.h"
} }
#define LOG_TAG "LibrePods" #define LOG_TAG "LibrePodsHook"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) #define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, 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)( static tBTA_STATUS (*original_BTA_DmSetLocalDiRecord)(
tSDP_DI_RECORD*, uint32_t*) = nullptr; tSDP_DI_RECORD*, uint32_t*) = nullptr;
static std::atomic<bool> enableSdpHook(false);
uint8_t fake_l2c_fcr_chk_chan_modes(void* p_ccb) { uint8_t fake_l2c_fcr_chk_chan_modes(void* p_ccb) {
LOGI("l2c_fcr_chk_chan_modes hooked"); LOGI("l2c_fcr_chk_chan_modes called");
uint8_t orig = 0; uint8_t orig = 0;
if (original_l2c_fcr_chk_chan_modes) if (original_l2c_fcr_chk_chan_modes)
orig = original_l2c_fcr_chk_chan_modes(p_ccb); orig = original_l2c_fcr_chk_chan_modes(p_ccb);
@@ -55,7 +59,11 @@ tBTA_STATUS fake_BTA_DmSetLocalDiRecord(
tSDP_DI_RECORD* p_device_info, tSDP_DI_RECORD* p_device_info,
uint32_t* p_handle) { 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) { if (p_device_info) {
p_device_info->vendor = 0x004C; p_device_info->vendor = 0x004C;
@@ -265,9 +273,9 @@ static bool hookLibrary(const char* libname) {
findSymbolOffset(decompressed, findSymbolOffset(decompressed,
"l2c_fcr_chk_chan_modes"); "l2c_fcr_chk_chan_modes");
// uint64_t sdp_offset = uint64_t sdp_offset =
// findSymbolOffset(decompressed, findSymbolOffset(decompressed,
// "BTA_DmSetLocalDiRecord"); "BTA_DmSetLocalDiRecord");
if (chk_offset) { if (chk_offset) {
void* target = void* target =
@@ -280,16 +288,16 @@ static bool hookLibrary(const char* libname) {
LOGI("Hooked l2c_fcr_chk_chan_modes"); LOGI("Hooked l2c_fcr_chk_chan_modes");
} }
// if (sdp_offset) { if (sdp_offset) {
// void* target = void* target =
// reinterpret_cast<void*>(base + sdp_offset); reinterpret_cast<void*>(base + sdp_offset);
//
// hook_func(target, hook_func(target,
// (void*)fake_BTA_DmSetLocalDiRecord, (void*)fake_BTA_DmSetLocalDiRecord,
// (void**)&original_BTA_DmSetLocalDiRecord); (void**)&original_BTA_DmSetLocalDiRecord);
//
// LOGI("Hooked BTA_DmSetLocalDiRecord"); LOGI("Hooked BTA_DmSetLocalDiRecord");
// } }
return true; return true;
} }
@@ -315,10 +323,16 @@ extern "C"
[[gnu::visibility("default")]] [[gnu::visibility("default")]]
[[gnu::used]] [[gnu::used]]
NativeOnModuleLoaded native_init(const NativeAPIEntries* entries) { NativeOnModuleLoaded native_init(const NativeAPIEntries* entries) {
LOGI("LibrePods initialized");
hook_func = (HookFunType)entries->hook_func; hook_func = (HookFunType)entries->hook_func;
LOGI("LibrePodsNativeHook initialized, sdp hook enabled: %d", enableSdpHook.load(std::memory_order_relaxed));
return on_library_loaded; 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);
}
@@ -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
}
}
@@ -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) }
}
}
@@ -2,148 +2,125 @@ package me.kavishdevar.librepods.utils
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.pm.ApplicationInfo import android.os.Handler
import android.os.Looper
import android.util.Log import android.util.Log
import android.widget.ImageView import android.widget.ImageView
import androidx.core.net.toUri 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.XposedModule
import io.github.libxposed.api.XposedModuleInterface
import io.github.libxposed.api.XposedModuleInterface.ModuleLoadedParam import io.github.libxposed.api.XposedModuleInterface.ModuleLoadedParam
import io.github.libxposed.api.annotations.AfterInvocation import io.github.libxposed.api.XposedModuleInterface.PackageLoadedParam
import io.github.libxposed.api.annotations.XposedHooker
import kotlin.jvm.java private const val TAG = "LibrePodsHook"
private const val TAG = "AirPodsHook"
private lateinit var module: KotlinModule
@SuppressLint("DiscouragedApi", "PrivateApi") @SuppressLint("DiscouragedApi", "PrivateApi")
class KotlinModule(base: XposedInterface, param: ModuleLoadedParam): XposedModule(base, param) { class KotlinModule: XposedModule() {
init { override fun onModuleLoaded(param: ModuleLoadedParam) {
Log.i(TAG, "AirPodsHook module initialized at :: ${param.processName}") log(Log.INFO, TAG, "module initialized at :: ${param.processName}")
module = this log(Log.INFO, TAG, "framework: $frameworkName($frameworkVersionCode) API $apiVersion")
} }
override fun onPackageLoaded(param: XposedModuleInterface.PackageLoadedParam) { override fun onPackageLoaded(param: PackageLoadedParam) {
super.onPackageLoaded(param) log(Log.INFO, TAG, "onPackageLoaded :: ${param.packageName}")
Log.i(TAG, "onPackageLoaded :: ${param.packageName}")
if (param.packageName == "com.google.android.bluetooth" || param.packageName == "com.android.bluetooth") { 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 { try {
if (param.isFirstPackage) { 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") 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) { } 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") { if (param.packageName == "com.google.android.settings") {
Log.i(TAG, "Settings app detected, hooking Bluetooth icon handling") hookSettingsController(param, "com.google.android.settings.bluetooth.AdvancedBluetoothDetailsHeaderController")
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)
}
} }
if (param.packageName == "com.android.settings") { if (param.packageName == "com.android.settings") {
Log.i(TAG, "Settings app detected, hooking Bluetooth icon handling") hookSettingsController(param, "com.android.settings.bluetooth.AdvancedBluetoothDetailsHeaderController")
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)
}
} }
} }
@XposedHooker private fun hookSettingsController(param: PackageLoadedParam, className: String) {
class BluetoothIconHooker : XposedInterface.Hooker { log(Log.INFO, TAG, "Settings app detected, hooking Bluetooth icon handling")
companion object { try {
@JvmStatic val headerControllerClass = Class.forName(className, false, param.defaultClassLoader)
@AfterInvocation val updateIconMethod = headerControllerClass.getDeclaredMethod(
fun afterUpdateIcon(callback: AfterHookCallback) { "updateIcon",
Log.i(TAG, "BluetoothIconHooker called with args: ${callback.args.joinToString(", ")}") ImageView::class.java,
String::class.java
)
hook(updateIconMethod).intercept { chain ->
try { try {
val imageView = callback.args[0] as ImageView log(Log.INFO, TAG, "Bluetooth icon hook called with args: ${chain.args.joinToString(", ")}")
val iconUri = callback.args[1] as String 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() val uri = iconUri.toUri()
if (uri.toString().startsWith("android.resource://me.kavishdevar.librepods")) { if (!uri.toString().startsWith("android.resource://me.kavishdevar.librepods")) {
Log.i(TAG, "Handling AirPods icon URI: $uri") return@intercept chain.proceed()
}
log(Log.INFO, TAG, "Handling AirPods icon URI: $uri")
Handler(Looper.getMainLooper()).post {
try { try {
val context = imageView.context 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 { val resPath = uri.pathSegments
try { if (resPath.size >= 2 && resPath[0] == "drawable") {
val packageName = uri.authority val resourceName = resPath[1]
val packageContext = context.createPackageContext( val resourceId = packageContext.resources.getIdentifier(
packageName, resourceName, "drawable", packageName
Context.CONTEXT_IGNORE_SECURITY )
if (resourceId != 0) {
val drawable = packageContext.resources.getDrawable(
resourceId, packageContext.theme
) )
imageView.setImageDrawable(drawable)
val resPath = uri.pathSegments imageView.alpha = 1.0f
if (resPath.size >= 2 && resPath[0] == "drawable") { log(Log.INFO, TAG, "Successfully loaded icon from resource: $resourceName")
val resourceName = resPath[1] } else {
val resourceId = packageContext.resources.getIdentifier( log(Log.ERROR, TAG, "Resource not found: $resourceName")
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}")
} }
} }
} catch (e: Exception) { } 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) { } catch (e: Exception) {
Log.e(TAG, "Error in BluetoothIconHooker: ${e.message}") log(Log.ERROR, TAG, "Error in Bluetooth icon hook: ${e.message}")
e.printStackTrace() 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)
} }
@@ -1,3 +1,3 @@
minApiVersion=100 minApiVersion=101
targetApiVersion=100 targetApiVersion=101
staticScope=true staticScope=true
+3
View File
@@ -17,6 +17,7 @@ materialIconsCore = "1.7.8"
backdrop = "2.0.0-alpha03" backdrop = "2.0.0-alpha03"
billing = "8.3.0" billing = "8.3.0"
hilt = "2.59.2" hilt = "2.59.2"
xposed = "101.0.0"
[libraries] [libraries]
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" } 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" } billing = { group = "com.android.billingclient", name = "billing-ktx", version.ref = "billing" }
hilt = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } hilt = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }