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

View File

@@ -1,4 +1,3 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.util.Properties
plugins {
@@ -27,10 +26,10 @@ android {
defaultConfig {
applicationId = "me.kavishdevar.librepods"
minSdk = 36
minSdk = 33
targetSdk = 37
versionCode = 21
versionName = "0.2.0-beta.1"
versionCode = 27
versionName = "0.2.0"
}
buildTypes {
release {
@@ -45,14 +44,19 @@ android {
arguments += "-DCMAKE_BUILD_TYPE=Release"
}
}
buildConfigField("Boolean", "PLAY_BUILD", "false")
signingConfig = signingConfigs.getByName("release")
}
debug {
buildConfigField("Boolean", "PLAY_BUILD", "false")
signingConfig = signingConfigs.getByName("release")
}
create("playRelease") {
initWith(getByName("release"))
versionNameSuffix = "-play"
buildConfigField("Boolean", "PLAY_BUILD", "true")
}
create("playDebug") {
initWith(getByName("debug"))
buildConfigField("Boolean", "PLAY_BUILD", "true")
}
}
@@ -60,11 +64,6 @@ android {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
}
buildFeatures {
compose = true
viewBinding = true
@@ -105,7 +104,7 @@ android {
arguments += "-DIS_XPOSED=ON"
}
}
applicationIdSuffix = ".xposed"
versionNameSuffix = "-xposed"
}
}
}
@@ -134,9 +133,10 @@ dependencies {
implementation(libs.aboutlibraries)
implementation(libs.aboutlibraries.compose.m3)
implementation(libs.backdrop)
implementation(libs.hilt)
// implementation(libs.hilt)
// implementation(libs.hilt.compiler)
add("xposedCompileOnly", files("libs/libxposed-api-100.aar"))
add("xposedCompileOnly", libs.libxposed.api)
add("xposedImplementation", libs.libxposed.service)
add("playReleaseImplementation", libs.billing)
}

View File

@@ -36,6 +36,7 @@
<uses-permission android:name="com.android.vending.BILLING" />
<application
android:name=".LibrePodsApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:enableOnBackInvokedCallback="true"
@@ -48,7 +49,7 @@
android:description="@string/app_description"
tools:ignore="UnusedAttribute" >
<receiver
android:name=".widgets.NoiseControlWidget"
android:name=".presentation.widgets.NoiseControlWidget"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
@@ -60,7 +61,7 @@
</receiver>
<receiver
android:name=".widgets.BatteryWidget"
android:name=".presentation.widgets.BatteryWidget"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />

View File

@@ -22,6 +22,7 @@ package me.kavishdevar.librepods
// import me.kavishdevar.librepods.screens.Onboarding
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
//import dagger.hilt.android.AndroidEntryPoint
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.ComponentName
@@ -51,7 +52,9 @@ import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -61,6 +64,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
@@ -77,6 +81,8 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -88,6 +94,8 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.res.stringResource
@@ -111,40 +119,44 @@ import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dagger.hilt.android.AndroidEntryPoint
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.rememberHazeState
import kotlinx.coroutines.delay
import me.kavishdevar.librepods.billing.BillingManager
import me.kavishdevar.librepods.billing.BillingProviderFactory
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.data.AirPodsNotifications
import me.kavishdevar.librepods.data.ControlCommandRepository
import me.kavishdevar.librepods.screens.AccessibilitySettingsScreen
import me.kavishdevar.librepods.screens.AdaptiveStrengthScreen
import me.kavishdevar.librepods.screens.AirPodsSettingsScreen
import me.kavishdevar.librepods.screens.AppSettingsScreen
import me.kavishdevar.librepods.screens.CameraControlScreen
import me.kavishdevar.librepods.screens.DebugScreen
import me.kavishdevar.librepods.screens.HeadTrackingScreen
import me.kavishdevar.librepods.screens.HearingAidAdjustmentsScreen
import me.kavishdevar.librepods.screens.HearingAidScreen
import me.kavishdevar.librepods.screens.HearingProtectionScreen
import me.kavishdevar.librepods.screens.LongPress
import me.kavishdevar.librepods.screens.OpenSourceLicensesScreen
import me.kavishdevar.librepods.screens.RenameScreen
import me.kavishdevar.librepods.screens.TransparencySettingsScreen
import me.kavishdevar.librepods.screens.UpdateHearingTestScreen
import me.kavishdevar.librepods.screens.VersionScreen
import me.kavishdevar.librepods.presentation.components.ConfirmationDialog
import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.presentation.screens.AccessibilitySettingsScreen
import me.kavishdevar.librepods.presentation.screens.AdaptiveStrengthScreen
import me.kavishdevar.librepods.presentation.screens.AirPodsSettingsScreen
import me.kavishdevar.librepods.presentation.screens.AppSettingsScreen
import me.kavishdevar.librepods.presentation.screens.CameraControlScreen
import me.kavishdevar.librepods.presentation.screens.DebugScreen
import me.kavishdevar.librepods.presentation.screens.HeadTrackingScreen
import me.kavishdevar.librepods.presentation.screens.HearingAidAdjustmentsScreen
import me.kavishdevar.librepods.presentation.screens.HearingAidScreen
import me.kavishdevar.librepods.presentation.screens.HearingProtectionScreen
import me.kavishdevar.librepods.presentation.screens.LongPress
import me.kavishdevar.librepods.presentation.screens.OpenSourceLicensesScreen
import me.kavishdevar.librepods.presentation.screens.PurchaseScreen
import me.kavishdevar.librepods.presentation.screens.RenameScreen
import me.kavishdevar.librepods.presentation.screens.TransparencySettingsScreen
import me.kavishdevar.librepods.presentation.screens.UpdateHearingTestScreen
import me.kavishdevar.librepods.presentation.screens.VersionScreen
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
import me.kavishdevar.librepods.utils.isSupported
import me.kavishdevar.librepods.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.viewmodel.AppSettingsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi
lateinit var serviceConnection: ServiceConnection
lateinit var connectionStatusReceiver: BroadcastReceiver
@AndroidEntryPoint
//@AndroidEntryPoint
@ExperimentalMaterial3Api
class MainActivity : ComponentActivity() {
companion object {
@@ -161,7 +173,7 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
setContent {
LibrePodsTheme {
_root_ide_package_.me.kavishdevar.librepods.presentation.theme.LibrePodsTheme {
Main()
}
}
@@ -203,28 +215,153 @@ class MainActivity : ComponentActivity() {
@ExperimentalHazeMaterialsApi
@SuppressLint("MissingPermission", "InlinedApi", "UnspecifiedRegisterReceiverFlag")
@OptIn(ExperimentalPermissionsApi::class)
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
@Composable
fun Main() {
if (!isSupported()) {
val context = LocalContext.current
val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE)
if (!isSupported(sharedPreferences)) {
val showDialog = remember { mutableStateOf(false) }
val blockTouches = remember { mutableStateOf(false) }
val tapCount = remember { mutableIntStateOf(0) }
val lastTapTime = remember { mutableLongStateOf(0L) }
val hazeState = rememberHazeState()
LaunchedEffect(blockTouches) {
if (blockTouches.value) {
delay(500)
blockTouches.value = false
}
}
Box(
modifier = Modifier
.fillMaxSize()
.hazeSource(hazeState)
.background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)),
contentAlignment = Alignment.Center
) {
Text(
text = "Not supported. Device Info: BUILD_ID: ${Build.ID} SDK_INT_FULL: ${Build.VERSION.SDK_INT_FULL}, MANUFACTURER: ${Build.MANUFACTURER}.\nCheck out the repository for more info.",
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
textAlign = TextAlign.Center,
modifier = Modifier.padding(16.dp)
Box (
modifier = Modifier
.fillMaxSize()
.then(
if (blockTouches.value)
{
Modifier.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
event.changes.forEach { it.consume() }
}
}
}
}
else Modifier
)
)
Column (
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "Not supported",
style = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.SemiBold,
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
fontSize = 20.sp
),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Row (
modifier = Modifier.fillMaxWidth().pointerInput(Unit) {
detectTapGestures(
onTap = {
val now = System.currentTimeMillis()
if (now - lastTapTime.longValue > 400) {
tapCount.intValue = 0
}
tapCount.intValue++
lastTapTime.longValue = now
if (tapCount.intValue >= 7) {
showDialog.value = true
blockTouches.value = true
}
}
)
},
horizontalArrangement = Arrangement.Center
) {
Text(
text = "Device Info:",
style = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium,
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
fontSize = 16.sp
),
textAlign = TextAlign.End,
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text =
"MANUFACTURER=${Build.MANUFACTURER}\n" +
"MODEL=${Build.MODEL}\n" +
"BUILD_ID=${Build.ID}\n" +
"SDK_INT_FULL= ${Build.VERSION.SDK_INT_FULL}\n",
style = TextStyle(
fontFamily = FontFamily(Font(R.font.hack)),
fontWeight = FontWeight.Medium,
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
fontSize = 16.sp
),
textAlign = TextAlign.Start,
)
}
Text(
text = "Check the repository for more info.",
style = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium,
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
fontSize = 18.sp
),
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
}
ConfirmationDialog(
showDialog = showDialog,
title = "Confirm device check bypass?",
message = "Are you sure your device is supported with LibrePods?",
confirmText = "Yes",
dismissText = "No",
onConfirm = {
showDialog.value = false
sharedPreferences.edit {
tapCount.intValue = 0
putBoolean("bypass_device_check", true)
val intent = Intent(context, MainActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
context.startActivity(intent)
}
},
onDismiss = {
showDialog.value = false
},
hazeState = hazeState
)
return
}
val isConnected = remember { mutableStateOf(false) }
val context = LocalContext.current
var canDrawOverlays by remember { mutableStateOf(Settings.canDrawOverlays(context)) }
val overlaySkipped = remember {
mutableStateOf(
@@ -263,7 +400,7 @@ fun Main() {
val airPodsService = remember { mutableStateOf<AirPodsService?>(null) }
val viewModel = remember(airPodsService.value) {
val airPodsViewModel = remember(airPodsService.value) {
airPodsService.value?.let { service ->
AirPodsViewModel(
service = service,
@@ -317,19 +454,20 @@ fun Main() {
)
}) {
composable("settings") {
if (viewModel != null) AirPodsSettingsScreen(viewModel, navController)
if (airPodsViewModel != null) AirPodsSettingsScreen(airPodsViewModel, navController)
}
composable("debug") {
DebugScreen(navController = navController)
}
composable("long_press/{bud}") { navBackStackEntry ->
if (viewModel != null) LongPress(
viewModel = viewModel,
name = navBackStackEntry.arguments?.getString("bud")!!
if (airPodsViewModel != null) LongPress(
viewModel = airPodsViewModel,
name = navBackStackEntry.arguments?.getString("bud")!!,
navController = navController
)
}
composable("rename") {
if (viewModel != null) RenameScreen(viewModel)
if (airPodsViewModel != null) RenameScreen(airPodsViewModel)
}
composable("app_settings") {
val appSettingsViewModel: AppSettingsViewModel = viewModel()
@@ -339,37 +477,41 @@ fun Main() {
// TroubleshootingScreen(navController)
// }
composable("head_tracking") {
if (viewModel != null) HeadTrackingScreen(viewModel)
if (airPodsViewModel != null) HeadTrackingScreen(airPodsViewModel, navController)
}
composable("accessibility") {
if (viewModel != null) AccessibilitySettingsScreen(viewModel, navController)
if (airPodsViewModel != null) AccessibilitySettingsScreen(airPodsViewModel, navController)
}
composable("transparency_customization") {
if (viewModel != null) TransparencySettingsScreen(viewModel)
if (airPodsViewModel != null) TransparencySettingsScreen(airPodsViewModel)
}
composable("hearing_aid") {
if (viewModel != null) HearingAidScreen(viewModel, navController)
if (airPodsViewModel != null) HearingAidScreen(airPodsViewModel, navController)
}
composable("hearing_aid_adjustments") {
if (viewModel != null) HearingAidAdjustmentsScreen(viewModel)
if (airPodsViewModel != null) HearingAidAdjustmentsScreen(airPodsViewModel)
}
composable("adaptive_strength") {
if (viewModel != null) AdaptiveStrengthScreen(viewModel)
if (airPodsViewModel != null) AdaptiveStrengthScreen(airPodsViewModel, navController)
}
composable("camera_control") {
if (viewModel != null) CameraControlScreen(viewModel)
if (airPodsViewModel != null) CameraControlScreen(airPodsViewModel)
}
composable("open_source_licenses") {
OpenSourceLicensesScreen(navController)
}
composable("update_hearing_test") {
if (viewModel != null) UpdateHearingTestScreen()
if (airPodsViewModel != null) UpdateHearingTestScreen()
}
composable("version_info") {
if (viewModel != null) VersionScreen(viewModel)
if (airPodsViewModel != null) VersionScreen(airPodsViewModel)
}
composable("hearing_protection") {
if (viewModel != null) HearingProtectionScreen(viewModel)
if (airPodsViewModel != null) HearingProtectionScreen(airPodsViewModel)
}
composable("purchase_screen") {
val purchaseViewModel: PurchaseViewModel = viewModel()
PurchaseScreen(purchaseViewModel, navController)
}
}
}

View File

@@ -85,15 +85,15 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.composables.AdaptiveRainbowBrush
import me.kavishdevar.librepods.composables.ControlCenterNoiseControlSegmentedButton
import me.kavishdevar.librepods.composables.IconAreaSize
import me.kavishdevar.librepods.composables.VerticalVolumeSlider
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.constants.NoiseControlMode
import me.kavishdevar.librepods.presentation.components.AdaptiveRainbowBrush
import me.kavishdevar.librepods.presentation.components.ControlCenterNoiseControlSegmentedButton
import me.kavishdevar.librepods.presentation.components.IconAreaSize
import me.kavishdevar.librepods.presentation.components.VerticalVolumeSlider
import me.kavishdevar.librepods.data.AirPodsNotifications
import me.kavishdevar.librepods.data.NoiseControlMode
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.bluetooth.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs

View File

@@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.StateFlow
interface BillingProvider {
val isPremium: StateFlow<Boolean>
val price: StateFlow<String>
fun purchase(activity: Activity)
fun queryPurchases()
}

View File

@@ -27,7 +27,7 @@ object BillingProviderFactory {
return if (BuildConfig.PLAY_BUILD) {
PlayBillingProvider(context)
} else {
FOSSBillingProvider()
FOSSBillingProvider(context)
}
}
}

View File

@@ -19,12 +19,56 @@
package me.kavishdevar.librepods.billing
import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.core.content.edit
import androidx.core.net.toUri
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class FOSSBillingProvider : BillingProvider {
private val _isPremium = MutableStateFlow(true)
class FOSSBillingProvider(context: Context): BillingProvider {
private val _isPremium = MutableStateFlow(false)
override val isPremium: StateFlow<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
}
}
}

View File

@@ -55,6 +55,10 @@ class PlayBillingProvider(
private val _isPremium = MutableStateFlow(false)
override val isPremium: StateFlow<Boolean> = _isPremium
private val _price = MutableStateFlow("unknown")
override val price: StateFlow<String> = _price
private var productDetails: ProductDetails? = null
private val billingClient = BillingClient.newBuilder(context)
@@ -102,6 +106,13 @@ class PlayBillingProvider(
if (result.billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
productDetails = result.productDetailsList?.firstOrNull()
Log.d(TAG, "Product loaded: ${productDetails?.name}")
val priceString = productDetails
?.oneTimePurchaseOfferDetails
?.formattedPrice
if (priceString != null) {
_price.value = priceString
}
} else {
Log.w(TAG, "queryProductDetails failed: ${result.billingResult.debugMessage}")
}
@@ -152,13 +163,13 @@ class PlayBillingProvider(
}
// val purchase = purchases.find {
// val navigateToPurchase = purchases.find {
// it.products.contains(PREMIUM_PRODUCT_ID) && it.purchaseState == Purchase.PurchaseState.PURCHASED
// }
//
// if (purchase != null) {
// if (navigateToPurchase != null) {
// val consumeParams = ConsumeParams.newBuilder()
// .setPurchaseToken(purchase.purchaseToken)
// .setPurchaseToken(navigateToPurchase.purchaseToken)
// .build()
// scope.launch {
// billingClient.consumeAsync(consumeParams) { _, _ ->}
@@ -184,4 +195,10 @@ class PlayBillingProvider(
Log.e(TAG, "Acknowledgement failed: ${result.debugMessage}")
}
}
override fun queryPurchases() {
scope.launch {
queryExistingPurchases()
}
}
}

View File

@@ -18,7 +18,7 @@
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.utils
package me.kavishdevar.librepods.bluetooth
import android.util.Log
import java.nio.ByteBuffer
@@ -61,8 +61,7 @@ class AACPManager {
private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00)
data class ControlCommandStatus(
val identifier: ControlCommandIdentifiers,
val value: ByteArray
val identifier: ControlCommandIdentifiers, val value: ByteArray
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
@@ -85,42 +84,31 @@ class AACPManager {
// @Suppress("unused")
enum class ControlCommandIdentifiers(val value: Byte) {
MIC_MODE(0x01),
BUTTON_SEND_MODE(0x05),
VOICE_TRIGGER(0x12),
SINGLE_CLICK_MODE(0x14),
DOUBLE_CLICK_MODE(0x15),
CLICK_HOLD_MODE(0x16),
DOUBLE_CLICK_INTERVAL(0x17),
CLICK_HOLD_INTERVAL(0x18),
LISTENING_MODE_CONFIGS(0x1A),
ONE_BUD_ANC_MODE(0x1B),
CROWN_ROTATION_DIRECTION(0x1C),
LISTENING_MODE(0x0D),
AUTO_ANSWER_MODE(0x1E),
CHIME_VOLUME(0x1F),
VOLUME_SWIPE_INTERVAL(0x23),
CALL_MANAGEMENT_CONFIG(0x24),
VOLUME_SWIPE_MODE(0x25),
ADAPTIVE_VOLUME_CONFIG(0x26),
SOFTWARE_MUTE_CONFIG(0x27),
CONVERSATION_DETECT_CONFIG(0x28),
SSL(0x29),
HEARING_AID(0x2C),
AUTO_ANC_STRENGTH(0x2E),
HPS_GAIN_SWIPE(0x2F),
HRM_STATE(0x30),
IN_CASE_TONE_CONFIG(0x31),
SIRI_MULTITONE_CONFIG(0x32),
HEARING_ASSIST_CONFIG(0x33),
ALLOW_OFF_OPTION(0x34),
STEM_CONFIG(0x39),
SLEEP_DETECTION_CONFIG(0x35),
ALLOW_AUTO_CONNECT(0x36), // not sure what this does, AUTOMATIC_CONNECTION is the only one used, but this is newer... so ¯\_(ツ)_/¯
EAR_DETECTION_CONFIG(0x0A),
AUTOMATIC_CONNECTION_CONFIG(0x20),
OWNS_CONNECTION(0x06),
PPE_TOGGLE_CONFIG(0x37),
MIC_MODE(0x01), BUTTON_SEND_MODE(0x05), VOICE_TRIGGER(0x12), SINGLE_CLICK_MODE(0x14), DOUBLE_CLICK_MODE(
0x15
),
CLICK_HOLD_MODE(0x16), DOUBLE_CLICK_INTERVAL(0x17), CLICK_HOLD_INTERVAL(0x18), LISTENING_MODE_CONFIGS(
0x1A
),
ONE_BUD_ANC_MODE(0x1B), CROWN_ROTATION_DIRECTION(0x1C), LISTENING_MODE(0x0D), AUTO_ANSWER_MODE(
0x1E
),
CHIME_VOLUME(0x1F), VOLUME_SWIPE_INTERVAL(0x23), CALL_MANAGEMENT_CONFIG(0x24), VOLUME_SWIPE_MODE(
0x25
),
ADAPTIVE_VOLUME_CONFIG(0x26), SOFTWARE_MUTE_CONFIG(0x27), CONVERSATION_DETECT_CONFIG(
0x28
),
SSL(0x29), HEARING_AID(0x2C), AUTO_ANC_STRENGTH(0x2E), HPS_GAIN_SWIPE(0x2F), HRM_STATE(
0x30
),
IN_CASE_TONE_CONFIG(0x31), SIRI_MULTITONE_CONFIG(0x32), HEARING_ASSIST_CONFIG(0x33), ALLOW_OFF_OPTION(
0x34
),
STEM_CONFIG(0x39), SLEEP_DETECTION_CONFIG(0x35), ALLOW_AUTO_CONNECT(0x36), // not sure what this does, AUTOMATIC_CONNECTION is the only one used, but this is newer... so ¯\_(ツ)_/¯
EAR_DETECTION_CONFIG(0x0A), AUTOMATIC_CONNECTION_CONFIG(0x20), OWNS_CONNECTION(0x06), PPE_TOGGLE_CONFIG(
0x37
),
PPE_CAP_LEVEL_CONFIG(0x38);
companion object {
@@ -130,59 +118,44 @@ class AACPManager {
}
enum class ProximityKeyType(val value: Byte) {
IRK(0x01),
ENC_KEY(0x04);
IRK(0x01), ENC_KEY(0x04);
companion object {
fun fromByte(byte: Byte): ProximityKeyType =
ProximityKeyType.entries.find { it.value == byte }
?: throw IllegalArgumentException("Unknown ProximityKeyType: $byte")
fun fromByte(byte: Byte): ProximityKeyType = entries.find { it.value == byte }
?: throw IllegalArgumentException("Unknown ProximityKeyType: $byte")
}
}
enum class StemPressType(val value: Byte) {
SINGLE_PRESS(0x05),
DOUBLE_PRESS(0x06),
TRIPLE_PRESS(0x07),
LONG_PRESS(0x08);
SINGLE_PRESS(0x05), DOUBLE_PRESS(0x06), TRIPLE_PRESS(0x07), LONG_PRESS(0x08);
companion object {
fun fromByte(byte: Byte): StemPressType? =
entries.find { it.value == byte }
fun fromByte(byte: Byte): StemPressType? = entries.find { it.value == byte }
}
}
enum class StemPressBudType(val value: Byte) {
LEFT(0x01),
RIGHT(0x02);
LEFT(0x01), RIGHT(0x02);
companion object {
fun fromByte(byte: Byte): StemPressBudType? =
entries.find { it.value == byte }
fun fromByte(byte: Byte): StemPressBudType? = entries.find { it.value == byte }
}
}
enum class AudioSourceType(val value: Byte) {
NONE(0x00),
CALL(0x01),
MEDIA(0x02);
NONE(0x00), CALL(0x01), MEDIA(0x02);
companion object {
fun fromByte(byte: Byte): AudioSourceType? =
entries.find { it.value == byte }
fun fromByte(byte: Byte): AudioSourceType? = entries.find { it.value == byte }
}
}
data class AudioSource(
val mac: String,
val type: AudioSourceType
val mac: String, val type: AudioSourceType
)
data class ConnectedDevice(
val mac: String,
val info1: Byte,
val info2: Byte,
var type: String?
val mac: String, val info1: Byte, val info2: Byte, var type: String?
)
data class AirPodsInformation(
@@ -231,8 +204,7 @@ class AACPManager {
}
private fun setControlCommandStatusValue(
identifier: ControlCommandIdentifiers,
value: ByteArray
identifier: ControlCommandIdentifiers, value: ByteArray
) {
val existingStatus = getControlCommandStatus(identifier)
if (existingStatus == value) {
@@ -289,15 +261,13 @@ class AACPManager {
}
fun registerControlCommandListener(
identifier: ControlCommandIdentifiers,
callback: ControlCommandListener
identifier: ControlCommandIdentifiers, callback: ControlCommandListener
) {
controlCommandListeners.getOrPut(identifier) { mutableListOf() }.add(callback)
}
fun unregisterControlCommandListener(
identifier: ControlCommandIdentifiers,
callback: ControlCommandListener
identifier: ControlCommandIdentifiers, callback: ControlCommandListener
) {
controlCommandListeners[identifier]?.remove(callback)
}
@@ -332,8 +302,7 @@ class AACPManager {
fun sendControlCommand(identifier: Byte, value: ByteArray): Boolean {
val controlPacket = createControlCommandPacket(identifier, value)
setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
value
ControlCommandIdentifiers.fromByte(identifier) ?: return false, value
)
return sendDataPacket(controlPacket)
}
@@ -342,16 +311,14 @@ class AACPManager {
fun sendControlCommand(identifier: Byte, value: Byte): Boolean {
val controlPacket = createControlCommandPacket(identifier, byteArrayOf(value))
setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
byteArrayOf(value)
ControlCommandIdentifiers.fromByte(identifier) ?: return false, byteArrayOf(value)
)
return sendDataPacket(controlPacket)
}
fun sendControlCommand(identifier: Byte, value: Boolean): Boolean {
val controlPacket = createControlCommandPacket(
identifier,
if (value) byteArrayOf(0x01) else byteArrayOf(0x02)
identifier, if (value) byteArrayOf(0x01) else byteArrayOf(0x02)
)
setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(identifier) ?: return false,
@@ -371,8 +338,7 @@ class AACPManager {
fun parseProximityKeysResponse(data: ByteArray): Map<ProximityKeyType, ByteArray> {
Log.d(
TAG,
"Parsing Proximity Keys Response: ${data.joinToString(" ") { "%02X".format(it) }}"
TAG, "Parsing Proximity Keys Response: ${data.joinToString(" ") { "%02X".format(it) }}"
)
if (data.size < 4) {
throw IllegalArgumentException("Data array too short to parse Proximity Keys Response")
@@ -400,11 +366,9 @@ class AACPManager {
keys[ProximityKeyType.fromByte(keyType)] = key
offset += keyLength
Log.d(
TAG,
"Parsed Proximity Key: Type: ${keyType}, Length: $keyLength, Key: ${
key.joinToString(" ") { "%02X".format(it) }
}"
)
TAG, "Parsed Proximity Key: Type: ${keyType}, Length: $keyLength, Key: ${
key.joinToString(" ") { "%02X".format(it) }
}")
}
return keys
}
@@ -424,26 +388,21 @@ class AACPManager {
fun receivePacket(packet: ByteArray) {
if (!packet.toHexString().startsWith("04000400")) {
Log.w(
TAG,
"Received packet does not start with expected header: ${
packet.joinToString(" ") {
"%02X".format(it)
}
}"
)
TAG, "Received packet does not start with expected header: ${
packet.joinToString(" ") {
"%02X".format(it)
}
}")
return
}
if (packet.size < 6) {
Log.w(
TAG,
"Received packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}"
TAG, "Received packet too short: ${packet.joinToString(" ") { "%02X".format(it) }}"
)
return
}
val opcode = packet[4]
when (opcode) {
when (val opcode = packet[4]) {
Opcodes.BATTERY_INFO -> {
callback?.onBatteryInfoReceived(packet)
}
@@ -458,23 +417,23 @@ class AACPManager {
TAG,
"Control command received: ${controlCommand.identifier.toHexString()} - ${
controlCommand.value.joinToString(" ") { "%02X".format(it) }
}"
)
}")
val controlCommandListText = try {
controlCommandStatusList.joinToString(", ") { it ->
"${it.identifier.name} (${it.identifier.value.toHexString()}) - ${
it.value.joinToString(
" "
) { "%02X".format(it) }
}"
}
} catch (e: Exception) {
e.message
controlCommandStatusList.joinToString(", ") { it ->
"${it.identifier.name} (${it.identifier.value.toHexString()}) - ${
it.value.joinToString(
" "
) { "%02X".format(it) }
}"
}
} catch (e: Exception) {
e.message
}
Log.d(
TAG, "Control command list is now: $controlCommandListText")
TAG, "Control command list is now: $controlCommandListText"
)
val controlCommandIdentifier =
ControlCommandIdentifiers.fromByte(controlCommand.identifier)
@@ -508,13 +467,11 @@ class AACPManager {
Opcodes.HEADTRACKING -> {
if (packet.size < 70) {
Log.w(
TAG,
"Received HEADTRACKING packet too short: ${
packet.joinToString(" ") {
"%02X".format(it)
}
}"
)
TAG, "Received HEADTRACKING packet too short: ${
packet.joinToString(" ") {
"%02X".format(it)
}
}")
return
}
callback?.onHeadTrackingReceived(packet)
@@ -546,7 +503,8 @@ class AACPManager {
Opcodes.SMART_ROUTING_RESP -> {
val packetString = packet.decodeToString()
val sender = packet.sliceArray(6..11).reversedArray().joinToString(":") { "%02X".format(it) }
val sender =
packet.sliceArray(6..11).reversedArray().joinToString(":") { "%02X".format(it) }
// if (connectedDevices.find { it.mac == sender }?.type == null && packetString.contains("btName")) {
// val nameStartIndex = packetString.indexOf("btName") + 8
@@ -566,9 +524,15 @@ class AACPManager {
} else if ("Android" in packetString) {
connectedDevices.find { it.mac == sender }?.type = "Android"
}
Log.d(TAG, "Smart Routing Response from $sender: $packetString, type: ${connectedDevices.find { it.mac == sender }?.type}")
Log.d(
TAG,
"Smart Routing Response from $sender: $packetString, type: ${connectedDevices.find { it.mac == sender }?.type}"
)
if (packetString.contains("SetOwnershipToFalse")) {
callback?.onOwnershipToFalseRequest(sender, packetString.contains("ReverseBannerTapped"))
callback?.onOwnershipToFalseRequest(
sender,
packetString.contains("ReverseBannerTapped")
)
}
if (packetString.contains("ShowNearbyUI")) {
callback?.onShowNearbyUI(sender)
@@ -595,15 +559,19 @@ class AACPManager {
eqOnPhone = (packet[11] == 0x01.toByte())
// there are 4 eqs. i am not sure what those are for, maybe all 4 listening modes, or maybe phone+media left+right, but then there shouldn't be another flag for phone/media visible. just directly the EQ... weird.
// the EQs are little endian floats
val eq1 = ByteBuffer.wrap(packet, 12, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
val eq2 = ByteBuffer.wrap(packet, 44, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
val eq3 = ByteBuffer.wrap(packet, 76, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
val eq4 = ByteBuffer.wrap(packet, 108, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
val eq1 =
ByteBuffer.wrap(packet, 12, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
ByteBuffer.wrap(packet, 44, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
ByteBuffer.wrap(packet, 76, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
ByteBuffer.wrap(packet, 108, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
// for now, taking just the first EQ
eqData = FloatArray(8) { i -> eq1.get(i) }
Log.d(TAG, "EQ Data set to: ${eqData.toList()}, eqOnPhone: $eqOnPhone, eqOnMedia: $eqOnMedia")
Log.d(
TAG,
"EQ Data set to: ${eqData.toList()}, eqOnPhone: $eqOnPhone, eqOnMedia: $eqOnMedia"
)
callback?.onEQPacketReceived(eqData)
}
@@ -613,8 +581,9 @@ class AACPManager {
val information = parseInformationPacket(packet)
callback?.onDeviceInformationReceived(information)
}
else -> {
Log.d(TAG, "Unknown opcode received: ${opcode.toHexString()}")
Log.d(TAG, "Unhandled opcode received: ${opcode.toHexString()}")
callback?.onUnknownPacketReceived(packet)
}
}
@@ -644,10 +613,22 @@ class AACPManager {
fun createHandshakePacket(): ByteArray {
return byteArrayOf(
0x00, 0x00, 0x04, 0x00,
0x01, 0x00, 0x02, 0x00,
0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00
0x00,
0x00,
0x04,
0x00,
0x01,
0x00,
0x02,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00,
0x00
)
}
@@ -793,17 +774,31 @@ class AACPManager {
}
fun sendMediaInformationNewDevice(selfMacAddress: String, targetMacAddress: String): Boolean {
if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")) || targetMacAddress.length != 17 || !targetMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) {
if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")) || targetMacAddress.length != 17 || !targetMacAddress.matches(
Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")
)
) {
// throw IllegalArgumentException("MAC address must be 6 bytes")
Log.w(TAG, "Invalid MAC address format, got: selfMacAddress=$selfMacAddress, targetMacAddress=$targetMacAddress")
Log.w(
TAG,
"Invalid MAC address format, got: selfMacAddress=$selfMacAddress, targetMacAddress=$targetMacAddress"
)
return false
}
Log.d(TAG, "SELFMAC: ${selfMacAddress}, TARGETMAC: $targetMacAddress")
Log.d(TAG, "Sending Media Information packet to $targetMacAddress")
return sendDataPacket(createMediaInformationNewDevicePacket(selfMacAddress, targetMacAddress))
return sendDataPacket(
createMediaInformationNewDevicePacket(
selfMacAddress,
targetMacAddress
)
)
}
fun createMediaInformationNewDevicePacket(selfMacAddress: String, targetMacAddress: String): ByteArray {
fun createMediaInformationNewDevicePacket(
selfMacAddress: String,
targetMacAddress: String
): ByteArray {
val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00)
val buffer = ByteBuffer.allocate(116)
buffer.put(
@@ -892,17 +887,13 @@ class AACPManager {
Log.d(TAG, "Sending Media Information packet to $targetMac")
return sendDataPacket(
createMediaInformationPacket(
selfMacAddress,
targetMac,
streamingState
selfMacAddress, targetMac, streamingState
)
)
}
fun createMediaInformationPacket(
selfMacAddress: String,
targetMacAddress: String,
streamingState: Boolean = true
selfMacAddress: String, targetMacAddress: String, streamingState: Boolean = true
): ByteArray {
val opcode = byteArrayOf(Opcodes.SMART_ROUTING, 0x00)
val buffer = ByteBuffer.allocate(138)
@@ -935,7 +926,7 @@ class AACPManager {
buffer.put("AudioCategory".toByteArray())
buffer.put(byteArrayOf(0x31, 0x2D, 0x01))
return opcode+buffer.array()
return opcode + buffer.array()
}
fun sendSmartRoutingShowUI(selfMacAddress: String): Boolean {
@@ -1017,9 +1008,15 @@ class AACPManager {
fun sendAddTiPiDevice(selfMacAddress: String, targetMacAddress: String): Boolean {
if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")) || targetMacAddress.length != 17 || !targetMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"))) {
if (selfMacAddress.length != 17 || !selfMacAddress.matches(Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")) || targetMacAddress.length != 17 || !targetMacAddress.matches(
Regex("([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}")
)
) {
// throw IllegalArgumentException("MAC address must be 6 bytes")
Log.w(TAG, "Invalid MAC address format, got: selfMacAddress=$selfMacAddress, targetMacAddress=$targetMacAddress")
Log.w(
TAG,
"Invalid MAC address format, got: selfMacAddress=$selfMacAddress, targetMacAddress=$targetMacAddress"
)
return false
}
Log.d(TAG, "Sending Add TiPi Device packet to $targetMacAddress")
@@ -1053,8 +1050,7 @@ class AACPManager {
}
data class ControlCommand(
val identifier: Byte,
val value: ByteArray
val identifier: Byte, val value: ByteArray
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
@@ -1106,10 +1102,8 @@ class AACPManager {
triplePressCustomized: Boolean = false,
longPressCustomized: Boolean = false
): Boolean {
val value = ((if (singlePressCustomized) 0x01 else 0) or
(if (doublePressCustomized) 0x02 else 0) or
(if (triplePressCustomized) 0x04 else 0) or
(if (longPressCustomized) 0x08 else 0)).toByte()
val value =
((if (singlePressCustomized) 0x01 else 0) or (if (doublePressCustomized) 0x02 else 0) or (if (triplePressCustomized) 0x04 else 0) or (if (longPressCustomized) 0x08 else 0)).toByte()
Log.d(TAG, "Sending Stem Config Packet with value: ${value.toHexString()}")
return sendControlCommand(
ControlCommandIdentifiers.STEM_CONFIG.value, value
@@ -1124,19 +1118,18 @@ class AACPManager {
if (packet[4] == Opcodes.CONTROL_COMMAND) {
val controlCommand = ControlCommand.fromByteArray(packet)
Log.d(
TAG,
"Control command: ${controlCommand.identifier.toHexString()} - ${
controlCommand.value.joinToString(" ") { "%02X".format(it) }
}"
)
TAG, "Control command: ${controlCommand.identifier.toHexString()} - ${
controlCommand.value.joinToString(" ") { "%02X".format(it) }
}")
setControlCommandStatusValue(
ControlCommandIdentifiers.fromByte(controlCommand.identifier) ?: return false,
controlCommand.value
)
}
val socket = BluetoothConnectionManager.getCurrentSocket()
if (socket?.isConnected == true) {
val socket = BluetoothConnectionManager.getCurrentSocket() ?: return false
if (socket.isConnected) {
socket.outputStream?.write(packet)
socket.outputStream?.flush()
return true
@@ -1213,7 +1206,10 @@ class AACPManager {
var offset = 9
for (i in 0 until deviceCount) {
if (offset + 8 > data.size) {
Log.w(TAG, "Data array too short to parse all connected devices, returning what we have")
Log.w(
TAG,
"Data array too short to parse all connected devices, returning what we have"
)
break
}
val macBytes = data.sliceArray(offset until offset + 6)
@@ -1227,6 +1223,7 @@ class AACPManager {
return devices
}
fun sendSomePacketIDontKnowWhatItIs() {
// 2900 00ff ffff ffff ffff -- enables setting EQ
sendDataPacket(
@@ -1264,7 +1261,7 @@ class AACPManager {
val start = index
// find next 0x00 byte
while (index < data.size && data[index] != 0x00.toByte()) index++
val str = data.sliceArray(start until index).decodeToString()
val str = data.sliceArray(start..index).decodeToString()
strings.add(str)
}

View File

@@ -21,7 +21,7 @@
* and receiving notifications. It is not a complete implementation of the ATT protocol.
*/
package me.kavishdevar.librepods.utils
package me.kavishdevar.librepods.bluetooth
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
@@ -31,6 +31,7 @@ import android.os.ParcelUuid
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.io.InputStream
import java.io.OutputStream
@@ -62,7 +63,7 @@ class ATTManager(private val adapter: BluetoothAdapter, private val device: Blue
private var input: InputStream? = null
private var output: OutputStream? = null
private val listeners = mutableMapOf<Int, MutableList<(ByteArray) -> Unit>>()
private var notificationJob: kotlinx.coroutines.Job? = null
private var notificationJob: Job? = null
// queue for non-notification PDUs (responses to requests)
private val responses = LinkedBlockingQueue<ByteArray>()
@@ -72,7 +73,12 @@ class ATTManager(private val adapter: BluetoothAdapter, private val device: Blue
val uuid = ParcelUuid.fromString("00000000-0000-0000-0000-000000000000")
socket = createBluetoothSocket(adapter, device, uuid)
socket!!.connect()
try {
socket!!.connect()
} catch (e: Exception) {
Log.w(TAG, "ATT socket failed to connect")
return
}
input = socket!!.inputStream
output = socket!!.outputStream
Log.d(TAG, "Connected to ATT")

View File

@@ -16,7 +16,7 @@
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.bluetooth.BluetoothManager
@@ -30,8 +30,10 @@ import android.content.SharedPreferences
import android.os.Handler
import android.os.Looper
import android.util.Log
import me.kavishdevar.librepods.utils.BluetoothCryptography
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
import kotlin.collections.iterator
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi

View File

@@ -16,7 +16,7 @@
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.BluetoothSocket

View File

@@ -16,7 +16,7 @@
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
@@ -256,8 +256,6 @@ data class AirPodsInstance(
val version1: String?,
val version2: String?,
val version3: String?,
val aacpManager: AACPManager,
val attManager: ATTManager?
)
object AirPodsModels {

View File

@@ -18,14 +18,14 @@
package me.kavishdevar.librepods.data
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.AACPManager.Companion.ControlCommandIdentifiers
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers
class ControlCommandRepository(
private val aacpManager: AACPManager
) {
fun getValue(
identifier: AACPManager.Companion.ControlCommandIdentifiers
identifier: ControlCommandIdentifiers
): ByteArray? {
return aacpManager.controlCommandStatusList
.find { it.identifier == identifier }
@@ -33,7 +33,7 @@ class ControlCommandRepository(
}
fun setValue(
id: AACPManager.Companion.ControlCommandIdentifiers,
id: ControlCommandIdentifiers,
value: ByteArray
) {
aacpManager.sendControlCommand(id.value, value)
@@ -41,7 +41,7 @@ class ControlCommandRepository(
fun observe(
identifier: AACPManager.Companion.ControlCommandIdentifiers,
identifier: ControlCommandIdentifiers,
onChange: (ByteArray) -> Unit
): AACPManager.ControlCommandListener {
@@ -56,7 +56,7 @@ class ControlCommandRepository(
}
fun remove(
identifier: AACPManager.Companion.ControlCommandIdentifiers,
identifier: ControlCommandIdentifiers,
listener: AACPManager.ControlCommandListener
) {
aacpManager.unregisterControlCommandListener(identifier, listener)

View File

@@ -16,7 +16,7 @@
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 androidx.compose.runtime.MutableState
@@ -25,6 +25,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.bluetooth.ATTManager
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.ByteOrder

View File

@@ -16,7 +16,7 @@
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.util.Log

View File

@@ -16,9 +16,9 @@
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 {
PLAY_PAUSE,

View File

@@ -16,13 +16,14 @@
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.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.bluetooth.ATTHandles
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.ByteOrder

View File

@@ -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)
}

View File

@@ -0,0 +1,5 @@
package me.kavishdevar.librepods.data
object XposedRemotePrefProvider {
fun create(): XposedRemotePref = XposedRemotePrefImpl()
}

View File

@@ -18,7 +18,7 @@
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
package me.kavishdevar.librepods.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -75,7 +75,8 @@ fun AboutCard(
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f)
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}

View File

@@ -18,7 +18,7 @@
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
package me.kavishdevar.librepods.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
@@ -35,6 +35,8 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -61,7 +63,7 @@ fun AudioSettings(
loudSoundReductionChecked: Boolean,
onLoudSoundReductionCheckedChange: (Boolean) -> Unit,
isXposed: Boolean,
vendorIdHook: Boolean,
isPremium: Boolean
) {
val isDarkTheme = isSystemInDarkTheme()
@@ -80,7 +82,8 @@ fun AudioSettings(
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f)
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
@@ -130,13 +133,14 @@ fun AudioSettings(
)
}
if (loudSoundReductionCapability && isXposed){
if (loudSoundReductionCapability && vendorIdHook){
StyledToggle(
label = stringResource(R.string.loud_sound_reduction),
description = stringResource(R.string.loud_sound_reduction_description),
independent = false,
checked = loudSoundReductionChecked,
onCheckedChange = onLoudSoundReductionCheckedChange
onCheckedChange = onLoudSoundReductionCheckedChange,
enabled = isPremium
)
HorizontalDivider(
thickness = 1.dp,
@@ -172,7 +176,7 @@ fun AudioSettingsPreview() {
onConversationalAwarenessCheckedChange = { },
loudSoundReductionChecked = true,
onLoudSoundReductionCheckedChange = { },
isXposed = true,
vendorIdHook = true,
isPremium = true
)
}

View File

@@ -16,20 +16,21 @@
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 androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -38,8 +39,10 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
@@ -48,11 +51,15 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.data.BatteryStatus
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
@Composable
fun BatteryIndicator(
batteryPercentage: Int,
charging: Boolean = false,
status: Int,
prefix: String = "",
previousCharging: Boolean = false,
) {
@@ -65,6 +72,7 @@ fun BatteryIndicator(
val initialScale = if (previousCharging) 1f else 0f
val scaleAnim = remember { Animatable(initialScale) }
val charging = status == BatteryStatus.CHARGING || status == BatteryStatus.OPTIMIZED_CHARGING
val targetScale = if (charging) 1f else 0f
LaunchedEffect(previousCharging, charging) {
@@ -80,6 +88,43 @@ fun BatteryIndicator(
modifier = Modifier.padding(bottom = 4.dp),
contentAlignment = Alignment.Center
) {
val strokeWidthPx = with(LocalDensity.current) { 4.dp.toPx() }
val gapFromCenterPx = with(LocalDensity.current) { 8.sp.toPx() }
if (status == BatteryStatus.OPTIMIZED_CHARGING) {
Canvas(modifier = Modifier.size(40.dp)) {
val radius = size.minDimension / 2
val progress = batteryPercentage / 100f
val angleDeg = -90f + 360f * progress
val angleRad = Math.toRadians(angleDeg.toDouble())
val outerX = center.x + (radius - strokeWidthPx) * cos(angleRad).toFloat()
val outerY = center.y + (radius - strokeWidthPx) * sin(angleRad).toFloat()
val dirX = center.x - outerX
val dirY = center.y - outerY
val length = sqrt(dirX * dirX + dirY * dirY)
val normX = dirX / length
val normY = dirY / length
val startX = outerX - normX * strokeWidthPx / 2
val startY = outerY - normY * strokeWidthPx / 2
val endX = center.x - normX * gapFromCenterPx
val endY = center.y - normY * gapFromCenterPx
drawLine(
color = batteryFillColor,
start = Offset(startX, startY),
end = Offset(endX, endY),
strokeWidth = strokeWidthPx,
cap = StrokeCap.Round
)
}
}
CircularProgressIndicator(
progress = { batteryPercentage / 100f },
modifier = Modifier.size(40.dp),
@@ -123,6 +168,6 @@ fun BatteryIndicatorPreview() {
Box(
modifier = Modifier.background(bg)
) {
BatteryIndicator(batteryPercentage = 24, charging = true, prefix = "\uDBC6\uDCE5", previousCharging = false)
BatteryIndicator(batteryPercentage = 80, status = BatteryStatus.CHARGING, prefix = "\uDBC6\uDCE5", previousCharging = false)
}
}

View File

@@ -18,10 +18,9 @@
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
package me.kavishdevar.librepods.presentation.components
import android.content.res.Configuration
import android.util.Log
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
@@ -41,15 +40,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.imageResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.Battery
import me.kavishdevar.librepods.constants.BatteryComponent
import me.kavishdevar.librepods.constants.BatteryStatus
import me.kavishdevar.librepods.data.Battery
import me.kavishdevar.librepods.data.BatteryComponent
import me.kavishdevar.librepods.data.BatteryStatus
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
@@ -66,12 +64,6 @@ fun BatteryView(
val rightLevel = right?.level ?: 0
val caseLevel = case?.level ?: 0
val leftCharging = left?.status == BatteryStatus.CHARGING ||
left?.status == BatteryStatus.OPTIMIZED_CHARGING
val rightCharging = right?.status == BatteryStatus.CHARGING ||
right?.status == BatteryStatus.OPTIMIZED_CHARGING
val caseCharging = case?.status == BatteryStatus.CHARGING ||
case?.status == BatteryStatus.OPTIMIZED_CHARGING
@@ -98,12 +90,12 @@ fun BatteryView(
)
if (
leftCharging == rightCharging &&
left?.status == right?.status &&
(leftLevel - rightLevel) in -3..3
) {
BatteryIndicator(
leftLevel.coerceAtMost(rightLevel),
leftCharging
left?.status ?: BatteryStatus.NOT_CHARGING
)
singleDisplayed.value = true
} else {
@@ -116,7 +108,7 @@ fun BatteryView(
if (leftLevel > 0 || left?.status != BatteryStatus.DISCONNECTED) {
BatteryIndicator(
leftLevel,
leftCharging,
left?.status ?: BatteryStatus.NOT_CHARGING,
"\uDBC6\uDCE5"
)
}
@@ -128,7 +120,7 @@ fun BatteryView(
if (rightLevel > 0 || right?.status != BatteryStatus.DISCONNECTED) {
BatteryIndicator(
rightLevel,
rightCharging,
right?.status ?: BatteryStatus.NOT_CHARGING,
"\uDBC6\uDCE8"
)
}
@@ -151,7 +143,7 @@ fun BatteryView(
if (caseLevel > 0 || case?.status != BatteryStatus.DISCONNECTED) {
BatteryIndicator(
caseLevel,
caseCharging,
case?.status ?: BatteryStatus.NOT_CHARGING,
prefix = if (!singleDisplayed.value) "\uDBC3\uDE6C" else ""
)
}
@@ -165,7 +157,7 @@ fun BatteryView(
fun BatteryViewPreview() {
val fakeBattery = listOf(
Battery(BatteryComponent.LEFT, 85, BatteryStatus.CHARGING),
Battery(BatteryComponent.RIGHT, 40, BatteryStatus.CHARGING),
Battery(BatteryComponent.RIGHT, 40, BatteryStatus.OPTIMIZED_CHARGING),
Battery(BatteryComponent.CASE, 60, BatteryStatus.NOT_CHARGING)
)

View File

@@ -18,7 +18,7 @@
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
package me.kavishdevar.librepods.presentation.components
import android.util.Log
import androidx.compose.foundation.background
@@ -41,15 +41,18 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
@@ -59,6 +62,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import kotlin.io.encoding.ExperimentalEncodingApi
@@ -82,7 +86,8 @@ fun CallControlSettings(
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f)
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
@@ -93,6 +98,10 @@ fun CallControlSettings(
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(top = 2.dp)
) {
val scope = rememberCoroutineScope()
val haptics = LocalHapticFeedback.current
val pressOnceText = stringResource(R.string.press_once)
val pressTwiceText = stringResource(R.string.press_twice)
@@ -105,6 +114,7 @@ fun CallControlSettings(
var lastDismissTimeSingle by remember { mutableLongStateOf(0L) }
var parentHoveredIndexSingle by remember { mutableStateOf<Int?>(null) }
var parentDragActiveSingle by remember { mutableStateOf(false) }
var previousIdxSingle by remember { mutableStateOf<Int?>(null) }
var showDoublePressDropdown by remember { mutableStateOf(false) }
var touchOffsetDouble by remember { mutableStateOf<Offset?>(null) }
@@ -112,6 +122,7 @@ fun CallControlSettings(
var lastDismissTimeDouble by remember { mutableLongStateOf(0L) }
var parentHoveredIndexDouble by remember { mutableStateOf<Int?>(null) }
var parentDragActiveDouble by remember { mutableStateOf(false) }
var previousIdxDouble by remember { mutableStateOf<Int?>(null) }
LaunchedEffect(flipped) {
Log.d("CallControlSettings", "Call control flipped: $flipped")
@@ -187,7 +198,11 @@ fun CallControlSettings(
val touch = touchOffsetSingle ?: current
val posInPopupY = current.y - touch.y
val idx = (posInPopupY / itemHeightPx).toInt()
if (idx != previousIdxSingle) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) }
}
parentHoveredIndexSingle = idx
previousIdxSingle = idx
},
onDragEnd = {
parentDragActiveSingle = false
@@ -204,6 +219,9 @@ fun CallControlSettings(
}
}
if (parentHoveredIndexSingle != null && parentHoveredIndexSingle in 0..1) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) }
}
parentHoveredIndexSingle = null
},
onDragCancel = {
@@ -316,7 +334,11 @@ fun CallControlSettings(
val touch = touchOffsetDouble ?: current
val posInPopupY = current.y - touch.y
val idx = (posInPopupY / itemHeightPx).toInt()
if (idx != previousIdxDouble) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) }
}
parentHoveredIndexDouble = idx
previousIdxDouble = idx
},
onDragEnd = {
parentDragActiveDouble = false
@@ -330,9 +352,12 @@ fun CallControlSettings(
showDoublePressDropdown = false
lastDismissTimeDouble = System.currentTimeMillis()
val flipped = option == pressOnceText
onCallControlValueChanged (flipped)
onCallControlValueChanged(flipped)
}
}
if (parentHoveredIndexDouble != null && parentHoveredIndexDouble in 0..1) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) }
}
parentHoveredIndexDouble = null
},
onDragCancel = {

View File

@@ -16,7 +16,7 @@
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.isSystemInDarkTheme
@@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -38,13 +39,16 @@ import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
@@ -53,10 +57,12 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
@ExperimentalHazeMaterialsApi
@@ -74,8 +80,18 @@ fun ConfirmationDialog(
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val accentColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
val haptics = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
if (showDialog.value) {
Dialog(onDismissRequest = { showDialog.value = false }) {
Dialog(
onDismissRequest = { showDialog.value = false },
properties = DialogProperties(
dismissOnBackPress = false,
dismissOnClickOutside = false
)
) {
Box(
modifier = Modifier
// .fillMaxWidth(0.75f)
@@ -90,7 +106,7 @@ fun ConfirmationDialog(
)
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(24.dp))
Spacer(modifier = Modifier.height(24.dp))
Text(
title,
style = TextStyle(
@@ -102,7 +118,7 @@ fun ConfirmationDialog(
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp)
)
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(12.dp))
Spacer(modifier = Modifier.height(12.dp))
Text(
message,
style = TextStyle(
@@ -113,7 +129,7 @@ fun ConfirmationDialog(
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp)
)
androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.height(16.dp))
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
@@ -148,6 +164,8 @@ fun ConfirmationDialog(
}
PointerEventType.Move -> {
if (isWithinBounds) {
if (leftPressed != isLeft) scope.launch { haptics.performHapticFeedback(
HapticFeedbackType.SegmentTick) }
leftPressed = isLeft
rightPressed = !isLeft
} else {
@@ -158,8 +176,12 @@ fun ConfirmationDialog(
PointerEventType.Release -> {
if (isWithinBounds) {
if (leftPressed) {
scope.launch { haptics.performHapticFeedback(
HapticFeedbackType.Reject) }
onDismiss()
} else if (rightPressed) {
scope.launch { haptics.performHapticFeedback(
HapticFeedbackType.Confirm) }
onConfirm()
}
}

View File

@@ -18,7 +18,7 @@
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
package me.kavishdevar.librepods.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme

View File

@@ -18,7 +18,7 @@
@file:Suppress("unused")
package me.kavishdevar.librepods.composables
package me.kavishdevar.librepods.presentation.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background

View File

@@ -16,7 +16,7 @@
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.animateDpAsState
@@ -56,7 +56,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.NoiseControlMode
import me.kavishdevar.librepods.data.NoiseControlMode
private val ContainerColor = Color(0x593C3C3E)
private val SelectedIndicatorColorGray = Color(0xFF6C6C6E)

View File

@@ -18,7 +18,7 @@
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
package me.kavishdevar.librepods.presentation.components
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
@@ -35,6 +35,8 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -47,12 +49,12 @@ fun HearingHealthSettings(
navController: NavController,
hasPPECapability: Boolean,
hasHearingAidCapability: Boolean,
isXposed: Boolean
vendorIdHook: Boolean
) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val shouldShowHearingAid = hasHearingAidCapability && isXposed
val shouldShowHearingAid = hasHearingAidCapability && vendorIdHook
if (hasPPECapability && shouldShowHearingAid) {
Box(
@@ -65,7 +67,8 @@ fun HearingHealthSettings(
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f)
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}

View File

@@ -18,9 +18,8 @@
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
package me.kavishdevar.librepods.presentation.components
import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.detectTapGestures
@@ -39,15 +38,18 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
@@ -56,8 +58,8 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
@ExperimentalHazeMaterialsApi
@@ -93,26 +95,13 @@ fun MicrophoneSettings(
var lastDismissTime by remember { mutableLongStateOf(0L) }
val reopenThresholdMs = 250L
val listener = object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (AACPManager.Companion.ControlCommandIdentifiers.fromByte(controlCommand.identifier) ==
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE
) {
selectedMode = when (controlCommand.value[0]) {
0x00.toByte() -> "Automatic"
0x01.toByte() -> "Always Right"
0x02.toByte() -> "Always Left"
else -> "Automatic"
}
Log.d("MicrophoneSettings", "Microphone mode received: $selectedMode")
}
}
}
val density = LocalDensity.current
val itemHeightPx = with(density) { 48.dp.toPx() }
var parentHoveredIndex by remember { mutableStateOf<Int?>(null) }
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 microphoneAlwaysRightText = stringResource(R.string.microphone_always_right)
val microphoneAlwaysLeftText = stringResource(R.string.microphone_always_left)
@@ -152,7 +141,11 @@ fun MicrophoneSettings(
val touch = touchOffset ?: current
val posInPopupY = current.y - touch.y
val idx = (posInPopupY / itemHeightPx).toInt()
if (idx != previousIdx) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) }
}
parentHoveredIndex = idx
previousIdx = idx
},
onDragEnd = {
parentDragActive = false
@@ -180,6 +173,9 @@ fun MicrophoneSettings(
onMicModeValueChanged(byteValue.toByte())
}
}
if (parentHoveredIndex != null && parentHoveredIndex in 0..2) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) }
}
parentHoveredIndex = null
},
onDragCancel = {

View File

@@ -16,7 +16,7 @@
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.core.tween
@@ -35,21 +35,25 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
@Composable
@@ -62,10 +66,14 @@ fun NavigationButton(
description: String? = null,
currentState: String? = null,
height: Dp = 58.dp,
enabled: Boolean = true
) {
val isDarkTheme = isSystemInDarkTheme()
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
val haptics = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
Column {
if (title != null) {
Box(
@@ -79,23 +87,34 @@ fun NavigationButton(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
Row(
modifier = Modifier
.background(animatedBackgroundColor, RoundedCornerShape(if (independent) 28.dp else 0.dp))
.background(
animatedBackgroundColor,
RoundedCornerShape(if (independent) 28.dp else 0.dp)
)
.height(height)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
backgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
if (enabled) {
backgroundColor =
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColor =
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
}
},
onTap = {
if (onClick != null) onClick() else navController.navigate(to)
if (enabled) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) }
if (onClick != null) onClick() else navController.navigate(to)
}
}
)
}

View File

@@ -16,7 +16,7 @@
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.interaction.MutableInteractionSource
@@ -74,4 +74,4 @@ fun NoiseControlButtonPreview() {
onClick = {},
textColor = Color.White,
)
}
}

View File

@@ -18,7 +18,7 @@
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
package me.kavishdevar.librepods.presentation.components
import android.annotation.SuppressLint
import androidx.compose.animation.core.AnimationSpec
@@ -59,6 +59,8 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.imageResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
@@ -66,7 +68,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.NoiseControlMode
import me.kavishdevar.librepods.data.NoiseControlMode
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.roundToInt
@@ -147,6 +149,7 @@ fun NoiseControlSettings(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}

View File

@@ -16,7 +16,7 @@
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.isSystemInDarkTheme
@@ -40,7 +40,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.StemAction
import me.kavishdevar.librepods.data.StemAction
@Composable
fun PressAndHoldSettings(

View File

@@ -16,7 +16,7 @@
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.os.Build
@@ -46,7 +46,9 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastCoerceAtMost
@@ -77,7 +79,8 @@ fun StyledButton(
maxScale: Float = 0.1f,
content: @Composable RowScope.() -> Unit,
) {
val animationScope = rememberCoroutineScope()
val scope = rememberCoroutineScope()
val haptics = LocalHapticFeedback.current
val progressAnimation = remember { Animatable(0f) }
var pressStartPosition by remember { mutableStateOf(Offset.Zero) }
val offsetAnimation = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
@@ -163,19 +166,21 @@ half4 main(float2 coord) {
val maxOffset = size.minDimension
val initialDerivative = 0.05f
val offset = offsetAnimation.value
translationX = maxOffset * tanh(initialDerivative * offset.x / maxOffset)
translationY = maxOffset * tanh(initialDerivative * offset.y / maxOffset)
translationX =
maxOffset * tanh(initialDerivative * offset.x / maxOffset)
translationY =
maxOffset * tanh(initialDerivative * offset.y / maxOffset)
val maxDragScale = 0.1f
val offsetAngle = atan2(offset.y, offset.x)
scaleX =
scale +
maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) *
(width / height).fastCoerceAtMost(1f)
maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) *
(width / height).fastCoerceAtMost(1f)
scaleY =
scale +
maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) *
(height / width).fastCoerceAtMost(1f)
maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) *
(height / width).fastCoerceAtMost(1f)
},
onDrawSurface = {
if (tint.isSpecified) {
@@ -209,7 +214,10 @@ half4 main(float2 coord) {
interactiveHighlightShader.apply {
val offset = pressStartPosition + offsetAnimation.value
setFloatUniform("size", size.width, size.height)
setColorUniform("color", Color.White.copy(0.15f * progress).toArgb())
setColorUniform(
"color",
Color.White.copy(0.15f * progress).toArgb()
)
setFloatUniform("radius", size.maxDimension)
setFloatUniform(
"offset",
@@ -236,31 +244,51 @@ half4 main(float2 coord) {
interactionSource = null,
indication = null,
role = Role.Button,
onClick = onClick
onClick = {
haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
onClick()
}
)
.then(
if (isInteractive) {
Modifier.pointerInput(animationScope) {
Modifier.pointerInput(scope) {
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
val onDragStop: () -> Unit = {
animationScope.launch {
scope.launch {
launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) }
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) }
launch {
offsetAnimation.animateTo(
Offset.Zero,
offsetAnimationSpec
)
}
}
}
inspectDragGestures(
onDragStart = { down ->
pressStartPosition = down.position
animationScope.launch {
launch { progressAnimation.animateTo(1f, progressAnimationSpec) }
scope.launch {
launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) }
launch {
progressAnimation.animateTo(
1f,
progressAnimationSpec
)
}
launch { offsetAnimation.snapTo(Offset.Zero) }
}
},
onDragEnd = { onDragStop() },
onDragEnd = {
onDragStop()
},
onDragCancel = onDragStop
) { _, dragAmount ->
animationScope.launch {
scope.launch {
if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback(
HapticFeedbackType.SegmentFrequentTick
)
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
}
}
@@ -274,6 +302,7 @@ half4 main(float2 coord) {
isPressed = false
},
onTap = {
haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
onClick()
}
)

View File

@@ -16,7 +16,7 @@
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 androidx.compose.animation.AnimatedVisibility
@@ -49,14 +49,17 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
@@ -71,6 +74,7 @@ import dev.chrisbanes.haze.HazeTint
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
@ExperimentalHazeMaterialsApi
@@ -110,6 +114,9 @@ fun StyledDropdown(
var hoveredIndex by remember { mutableStateOf<Int?>(null) }
val itemHeight = 48.dp
val scope = rememberCoroutineScope()
val haptics = LocalHapticFeedback.current
var popupSize by remember { mutableStateOf(IntSize(0, 0)) }
var lastDragPosition by remember { mutableStateOf<Offset?>(null) }
@@ -132,7 +139,12 @@ fun StyledDropdown(
},
onDrag = { change, _ ->
val y = change.position.y
hoveredIndex = (y / itemHeight.toPx()).toInt()
val newHoveredIndex = (y / itemHeight.toPx()).toInt()
if (newHoveredIndex != hoveredIndex) {
scope.launch { haptics.performHapticFeedback(
HapticFeedbackType.SegmentTick) }
}
hoveredIndex = newHoveredIndex
lastDragPosition = change.position
},
onDragEnd = {
@@ -144,6 +156,8 @@ fun StyledDropdown(
if (withinBounds) {
hoveredIndex?.let { idx ->
if (idx in options.indices) {
scope.launch { haptics.performHapticFeedback(
HapticFeedbackType.GestureEnd) }
onOptionSelected(options[idx])
}
}
@@ -174,6 +188,7 @@ fun StyledDropdown(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) }
onOptionSelected(text)
onDismissRequest()
}

View File

@@ -16,7 +16,7 @@
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.os.Build
@@ -50,7 +50,9 @@ import androidx.compose.ui.graphics.layer.CompositingStrategy
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.graphics.rememberGraphicsLayer
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
@@ -83,8 +85,9 @@ fun StyledIconButton(
backdrop: LayerBackdrop = rememberLayerBackdrop(),
onClick: () -> Unit
) {
val haptics = LocalHapticFeedback.current
val darkMode = isSystemInDarkTheme()
val animationScope = rememberCoroutineScope()
val scope = rememberCoroutineScope()
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
val progressAnimation = remember { Animatable(0f) }
@@ -116,7 +119,10 @@ half4 main(float2 coord) {
}
val isDarkTheme = isSystemInDarkTheme()
TextButton(
onClick = onClick,
onClick = {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) }
onClick()
},
shape = RoundedCornerShape(56.dp),
modifier = modifier
.padding(horizontal = 12.dp)
@@ -147,12 +153,12 @@ half4 main(float2 coord) {
val offsetAngle = atan2(offset.y, offset.x)
scaleX =
scale +
maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) *
(width / height).fastCoerceAtMost(1f)
maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) *
(width / height).fastCoerceAtMost(1f)
scaleY =
scale +
maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) *
(height / width).fastCoerceAtMost(1f)
maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) *
(height / width).fastCoerceAtMost(1f)
},
onDrawSurface = {
val progress = progressAnimation.value.coerceIn(0f, 1f)
@@ -182,7 +188,12 @@ half4 main(float2 coord) {
drawLayer(innerShadowLayer)
drawRect(
(if (isDarkTheme) Color(0xFFAFAFAF) else Color.White).copy(progress.coerceIn(0.15f, 0.35f))
(if (isDarkTheme) Color(0xFFAFAFAF) else Color.White).copy(
progress.coerceIn(
0.15f,
0.35f
)
)
)
},
onDrawFront = {
@@ -196,7 +207,10 @@ half4 main(float2 coord) {
interactiveHighlightShader.apply {
val offset = pressStartPosition + offsetAnimation.value
setFloatUniform("size", size.width, size.height)
setColorUniform("color", Color.White.copy(0.15f * progress).toArgb())
setColorUniform(
"color",
Color.White.copy(0.15f * progress).toArgb()
)
setFloatUniform("radius", size.maxDimension)
setFloatUniform(
"offset",
@@ -225,9 +239,10 @@ half4 main(float2 coord) {
)
},
)
.pointerInput(animationScope) {
.pointerInput(scope) {
val onDragStop: () -> Unit = {
animationScope.launch {
scope.launch {
launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) }
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) }
}
@@ -235,7 +250,8 @@ half4 main(float2 coord) {
inspectDragGestures(
onDragStart = { down ->
pressStartPosition = down.position
animationScope.launch {
scope.launch {
launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) }
launch { progressAnimation.animateTo(1f, progressAnimationSpec) }
launch { offsetAnimation.snapTo(Offset.Zero) }
}
@@ -243,7 +259,10 @@ half4 main(float2 coord) {
onDragEnd = { onDragStop() },
onDragCancel = onDragStop
) { _, dragAmount ->
animationScope.launch {
scope.launch {
if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback(
HapticFeedbackType.SegmentFrequentTick
)
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
}
}

View File

@@ -16,7 +16,7 @@
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.layout.Box

View File

@@ -16,7 +16,7 @@
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.core.animateFloatAsState
@@ -39,11 +39,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
@@ -71,6 +74,9 @@ fun StyledSelectList(
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
val scope = rememberCoroutineScope()
val haptics = LocalHapticFeedback.current
Column(
modifier = modifier
.fillMaxWidth()
@@ -104,6 +110,11 @@ fun StyledSelectList(
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
itemBackgroundColor = backgroundColor
}
},
onTap = {
if (item.enabled) {
haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
item.onClick()
}
}

View File

@@ -16,7 +16,7 @@
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.content.res.Configuration
@@ -56,6 +56,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.input.pointer.util.addPointerInputChange
@@ -64,6 +65,7 @@ import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
@@ -246,6 +248,8 @@ fun StyledSlider(
val startIconWidthState = remember { mutableFloatStateOf(0f) }
val endIconWidthState = remember { mutableFloatStateOf(0f) }
val density = LocalDensity.current
val haptics = LocalHapticFeedback.current
var lastDragValue by remember { mutableFloatStateOf(value) }
val momentumAnimation = rememberMomentumAnimation(maxScale = 1.5f)
@@ -449,10 +453,17 @@ fun StyledSlider(
valueRange.start,
valueRange.endInclusive
)
snapPoints.forEach { snap ->
if ((lastDragValue < snap && targetValue >= snap) ||
(snap in targetValue..<lastDragValue)) {
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
}
}
lastDragValue = targetValue
val snappedValue = if (snapPoints.isNotEmpty()) snapIfClose(
targetValue,
snapPoints,
snapThreshold
snapThreshold,
) else targetValue
onValueChange(snappedValue)
}
@@ -460,10 +471,9 @@ fun StyledSlider(
Orientation.Horizontal,
startDragImmediately = true,
onDragStarted = {
// Remove this block as momentumAnimation handles pressing
lastDragValue = value
},
onDragStopped = {
// Remove this block as momentumAnimation handles pressing
onValueChange((value * 100).roundToInt() / 100f)
}
)

View File

@@ -16,7 +16,7 @@
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 androidx.compose.animation.animateColorAsState
@@ -58,8 +58,10 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.layer.CompositingStrategy
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.graphics.rememberGraphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastCoerceIn
@@ -73,6 +75,7 @@ import com.kyant.backdrop.highlight.Highlight
import com.kyant.backdrop.shadow.Shadow
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlin.math.abs
@Composable
fun StyledSwitch(
@@ -81,9 +84,12 @@ fun StyledSwitch(
enabled: Boolean = true,
) {
val isDarkTheme = isSystemInDarkTheme()
val haptics = LocalHapticFeedback.current
val onColor = if (enabled) Color(0xFF34C759) else if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6)
val offColor = if (enabled) if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6) else if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6)
val offColor = if (enabled) if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6) else if (isDarkTheme) Color(
0x805B5B5E
) else Color(0xFFD1D1D6)
val trackWidth = 64.dp
val trackHeight = 28.dp
@@ -98,7 +104,7 @@ fun StyledSwitch(
val animatedFraction = remember { Animatable(fraction) }
val trackWidthPx = remember { mutableFloatStateOf(0f) }
val density = LocalDensity.current
val animationScope = rememberCoroutineScope()
val scope = rememberCoroutineScope()
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
val progressAnimation = remember { Animatable(0f) }
val innerShadowLayer = rememberGraphicsLayer().apply {
@@ -111,6 +117,11 @@ fun StyledSwitch(
val isFirstComposition = remember { mutableStateOf(true) }
LaunchedEffect(checked) {
if (!isFirstComposition.value) {
if (checked) {
haptics.performHapticFeedback(HapticFeedbackType.ToggleOn)
} else {
haptics.performHapticFeedback(HapticFeedbackType.ToggleOff)
}
coroutineScope {
launch {
val targetFrac = if (checked) 1f else 0f
@@ -150,27 +161,31 @@ fun StyledSwitch(
.then(if (enabled) Modifier.draggable(
rememberDraggableState { delta ->
if (trackWidthPx.floatValue > 0f) {
val oldFraction = animatedFraction.value
val newFraction = (animatedFraction.value + delta / trackWidthPx.floatValue).fastCoerceIn(-0.3f, 1.3f)
animationScope.launch {
scope.launch {
animatedFraction.snapTo(newFraction)
}
totalDrag.floatValue += kotlin.math.abs(delta)
totalDrag.floatValue += abs(delta)
val newChecked = newFraction >= 0.5f
if (newChecked != checked) {
onCheckedChange(newChecked)
}
if ((oldFraction < 0.5f && newFraction >= 0.5f) || (oldFraction >= 0.5f && newFraction < 0.5f)) {
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
}
}
},
Orientation.Horizontal,
startDragImmediately = true,
onDragStarted = {
totalDrag.floatValue = 0f
animationScope.launch {
scope.launch {
progressAnimation.animateTo(1f, progressAnimationSpec)
}
},
onDragStopped = {
animationScope.launch {
scope.launch {
if (totalDrag.floatValue < tapThreshold) {
val newChecked = !checked
onCheckedChange(newChecked)

View File

@@ -18,7 +18,7 @@
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
package me.kavishdevar.librepods.presentation.components
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
@@ -40,12 +40,15 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
@@ -53,6 +56,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import kotlin.io.encoding.ExperimentalEncodingApi
@@ -71,6 +75,9 @@ fun StyledToggle(
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val haptics = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
var backgroundColor by remember {
mutableStateOf(
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
@@ -90,7 +97,8 @@ fun StyledToggle(
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f)
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(
start = 16.dp,
@@ -108,14 +116,17 @@ fun StyledToggle(
.pointerInput(Unit) {
detectTapGestures(
onPress = {
backgroundColor =
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColor =
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
if (enabled) {
backgroundColor =
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColor =
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
}
},
onTap = {
if (enabled) {
scope.launch { haptics.performHapticFeedback(if (!currentChecked) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff) }
onCheckedChange(!currentChecked)
}
}
@@ -145,6 +156,7 @@ fun StyledToggle(
enabled = enabled,
onCheckedChange = {
if (enabled) {
scope.launch { haptics.performHapticFeedback(if (it) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff) }
onCheckedChange(it)
}
}
@@ -200,6 +212,7 @@ fun StyledToggle(
interactionSource = remember { MutableInteractionSource() }
) {
if (enabled) {
scope.launch { haptics.performHapticFeedback(if (!currentChecked) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff) }
onCheckedChange(!currentChecked)
}
},

View File

@@ -16,7 +16,7 @@
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.animateFloatAsState

View File

@@ -18,7 +18,7 @@
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.utils
package me.kavishdevar.librepods.presentation.overlays
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
@@ -58,10 +58,10 @@ import androidx.dynamicanimation.animation.DynamicAnimation
import androidx.dynamicanimation.animation.SpringAnimation
import androidx.dynamicanimation.animation.SpringForce
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.constants.Battery
import me.kavishdevar.librepods.constants.BatteryComponent
import me.kavishdevar.librepods.constants.BatteryStatus
import me.kavishdevar.librepods.data.AirPodsNotifications
import me.kavishdevar.librepods.data.Battery
import me.kavishdevar.librepods.data.BatteryComponent
import me.kavishdevar.librepods.data.BatteryStatus
import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs

View File

@@ -17,7 +17,7 @@
*/
package me.kavishdevar.librepods.utils
package me.kavishdevar.librepods.presentation.overlays
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
@@ -45,10 +45,10 @@ import android.widget.LinearLayout
import android.widget.TextView
import android.widget.VideoView
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.constants.Battery
import me.kavishdevar.librepods.constants.BatteryComponent
import me.kavishdevar.librepods.constants.BatteryStatus
import me.kavishdevar.librepods.data.AirPodsNotifications
import me.kavishdevar.librepods.data.Battery
import me.kavishdevar.librepods.data.BatteryComponent
import me.kavishdevar.librepods.data.BatteryStatus
@SuppressLint("InflateParams", "ClickableViewAccessibility")
class PopupWindow(

View File

@@ -16,7 +16,7 @@
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 android.annotation.SuppressLint
@@ -44,16 +44,20 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
@@ -67,17 +71,18 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.BuildConfig
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.composables.StyledDropdown
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider
import me.kavishdevar.librepods.composables.StyledToggle
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.Capability
import me.kavishdevar.librepods.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.data.Capability
import me.kavishdevar.librepods.presentation.components.NavigationButton
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledDropdown
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi
//private var phoneMediaDebounceJob: Job? = null
@@ -96,7 +101,6 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
val hearingAidEnabled = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(1)?.toInt() == 1 && state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(0)?.toInt() == 1
val backdrop = rememberLayerBackdrop()
StyledScaffold(
@@ -113,6 +117,28 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
) {
Spacer(modifier = Modifier.height(topPadding))
if (!state.isPremium) {
StyledButton(
onClick = {
navController.navigate("purchase_screen")
},
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) {
Text(
stringResource(R.string.unlock_advanced_features),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color.White
),
)
}
}
// val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
// val phoneEQEnabled = remember { mutableStateOf(false) }
// val mediaEQEnabled = remember { mutableStateOf(false) }
@@ -179,66 +205,100 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
// }
// }
// }
Box (
modifier = Modifier.then(
if (!state.isPremium) {
Modifier
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
event.changes.forEach { it.consume() }
}
}
}
} else Modifier
)
) {
DropdownMenuComponent(
label = stringResource(R.string.press_speed),
description = stringResource(R.string.press_speed_description),
options = pressSpeedOptions.values.toList(),
selectedOption = selectedPressSpeed ?: stringResource(R.string.default_option),
onOptionSelected = { newValue ->
selectedPressSpeed = newValue
viewModel.setControlCommandByte(
identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
value = pressSpeedOptions.filterValues { it == newValue }.keys.firstOrNull()
?: 0.toByte()
)
},
textColor = textColor,
hazeState = hazeState,
independent = true
)
}
DropdownMenuComponent(
label = stringResource(R.string.press_speed),
description = stringResource(R.string.press_speed_description),
options = pressSpeedOptions.values.toList(),
selectedOption = selectedPressSpeed?: stringResource(R.string.default_option),
onOptionSelected = { newValue ->
selectedPressSpeed = newValue
viewModel.setControlCommandByte(
identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
value = pressSpeedOptions.filterValues { it == newValue }.keys.firstOrNull()
?: 0.toByte()
)
},
textColor = textColor,
hazeState = hazeState,
independent = true
)
DropdownMenuComponent(
label = stringResource(R.string.press_and_hold_duration),
description = stringResource(R.string.press_and_hold_duration_description),
options = pressAndHoldDurationOptions.values.toList(),
selectedOption = selectedPressAndHoldDuration?: stringResource(R.string.default_option),
onOptionSelected = { newValue ->
selectedPressAndHoldDuration = newValue
viewModel.setControlCommandByte(
identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
value = pressAndHoldDurationOptions.filterValues { it == newValue }.keys.firstOrNull()
?: 0.toByte()
)
},
textColor = textColor,
hazeState = hazeState,
independent = true
)
Box (
modifier = Modifier.then(
if (!state.isPremium) {
Modifier
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
event.changes.forEach { it.consume() }
}
}
}
} else Modifier
)
) {
DropdownMenuComponent(
label = stringResource(R.string.press_and_hold_duration),
description = stringResource(R.string.press_and_hold_duration_description),
options = pressAndHoldDurationOptions.values.toList(),
selectedOption = selectedPressAndHoldDuration
?: stringResource(R.string.default_option),
onOptionSelected = { newValue ->
selectedPressAndHoldDuration = newValue
viewModel.setControlCommandByte(
identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
value = pressAndHoldDurationOptions.filterValues { it == newValue }.keys.firstOrNull()
?: 0.toByte()
)
},
textColor = textColor,
hazeState = hazeState,
independent = true
)
}
StyledToggle(
title = stringResource(R.string.noise_control),
label = stringResource(R.string.noise_cancellation_single_airpod),
description = stringResource(R.string.noise_cancellation_single_airpod_description),
independent = true,
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE]?.getOrNull(0) == 0x01.toByte(),
onCheckedChange = { viewModel.setControlCommandBoolean(AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE, it) }
onCheckedChange = { viewModel.setControlCommandBoolean(AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE, it) },
enabled = state.isPremium
)
if (state.capabilities.contains(Capability.LOUD_SOUND_REDUCTION) && BuildConfig.FLAVOR == "xposed") {
if (state.capabilities.contains(Capability.LOUD_SOUND_REDUCTION) && state.vendorIdHook) {
StyledToggle(
label = stringResource(R.string.loud_sound_reduction),
description = stringResource(R.string.loud_sound_reduction_description),
checked = viewModel.getATTCharacteristicValue(ATTHandles.LOUD_SOUND_REDUCTION)?.get(0) == 1.toByte(),
onCheckedChange = { viewModel.setATTCharacteristicValue(ATTHandles.LOUD_SOUND_REDUCTION, if (it) byteArrayOf(0x01) else byteArrayOf(0x00)) }
checked = state.loudSoundReductionEnabled,
onCheckedChange = { viewModel.setATTCharacteristicValue(ATTHandles.LOUD_SOUND_REDUCTION, if (it) byteArrayOf(0x01) else byteArrayOf(0x00)) },
enabled = state.isPremium
)
}
if (!hearingAidEnabled && BuildConfig.FLAVOR == "xposed") {
if (!hearingAidEnabled && state.vendorIdHook) {
NavigationButton(
to = "transparency_customization",
name = stringResource(R.string.customize_transparency_mode),
navController = navController
navController = navController,
enabled = state.isPremium
)
}
@@ -254,7 +314,8 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
snapPoints = listOf(75f),
startIcon = "\uDBC0\uDEA1",
endIcon = "\uDBC0\uDEA9",
independent = true
independent = true,
enabled = state.isPremium
)
if (state.capabilities.contains(Capability.SWIPE_FOR_VOLUME)) {
@@ -263,26 +324,44 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
label = stringResource(R.string.volume_control),
description = stringResource(R.string.volume_control_description),
checked = volumeSwipeEnabled,
onCheckedChange = { viewModel.setControlCommandBoolean(AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE, it) }
onCheckedChange = { viewModel.setControlCommandBoolean(AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE, it) },
enabled = state.isPremium
)
DropdownMenuComponent(
label = stringResource(R.string.volume_swipe_speed),
description = stringResource(R.string.volume_swipe_speed_description),
options = volumeSwipeSpeedOptions.values.toList(),
selectedOption = selectedVolumeSwipeSpeed?: stringResource(R.string.default_option),
onOptionSelected = { newValue ->
selectedVolumeSwipeSpeed = newValue
viewModel.setControlCommandByte(
identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull()
?: 1.toByte()
)
},
textColor = textColor,
hazeState = hazeState,
independent = true
)
Box (
modifier = Modifier.then(
if (!state.isPremium) {
Modifier
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
event.changes.forEach { it.consume() }
}
}
}
} else Modifier
)
) {
DropdownMenuComponent(
label = stringResource(R.string.volume_swipe_speed),
description = stringResource(R.string.volume_swipe_speed_description),
options = volumeSwipeSpeedOptions.values.toList(),
selectedOption = selectedVolumeSwipeSpeed
?: stringResource(R.string.default_option),
onOptionSelected = { newValue ->
selectedVolumeSwipeSpeed = newValue
viewModel.setControlCommandByte(
identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull()
?: 1.toByte()
)
},
textColor = textColor,
hazeState = hazeState,
independent = true
)
}
}
// if (!hearingAidEnabled.value&& BuildConfig.FLAVOR == "xposed") {
@@ -507,7 +586,6 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
// }
// }
// }
// }
Spacer(modifier = Modifier.height(bottomPadding))
}
}
@@ -534,6 +612,9 @@ private fun DropdownMenuComponent(
var lastDismissTime by remember { mutableLongStateOf(0L) }
var parentHoveredIndex by remember { mutableStateOf<Int?>(null) }
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(
@@ -593,7 +674,11 @@ private fun DropdownMenuComponent(
val touch = touchOffset ?: current
val posInPopupY = current.y - touch.y
val idx = (posInPopupY / itemHeightPx).toInt()
if (idx != previousIdx) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) }
}
parentHoveredIndex = idx
previousIdx = idx
},
onDragEnd = {
parentDragActive = false
@@ -604,6 +689,9 @@ private fun DropdownMenuComponent(
lastDismissTime = System.currentTimeMillis()
}
}
if (parentHoveredIndex != null && parentHoveredIndex in options.indices) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) }
}
parentHoveredIndex = null
},
onDragCancel = {

View File

@@ -16,14 +16,17 @@
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.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -33,21 +36,29 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@Composable
fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel) {
fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel, navController: NavController) {
val state by viewModel.uiState.collectAsState()
val backdrop = rememberLayerBackdrop()
@@ -62,6 +73,27 @@ fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel) {
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(spacerHeight))
if (!state.isPremium) {
StyledButton(
onClick = {
navController.navigate("purchase_screen")
},
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) {
Text(
stringResource(R.string.unlock_advanced_features),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color.White
),
)
}
}
val sliderValue = remember {
mutableFloatStateOf(
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH]?.getOrNull(
@@ -90,7 +122,8 @@ fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel) {
startIcon = "􀊥",
endIcon = "􀊩",
independent = true,
description = stringResource(R.string.adaptive_audio_description)
description = stringResource(R.string.adaptive_audio_description),
enabled = state.isPremium
)
}
}

View File

@@ -18,13 +18,12 @@
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.screens
package me.kavishdevar.librepods.presentation.screens
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
import android.annotation.SuppressLint
import android.content.Context.MODE_PRIVATE
import android.content.SharedPreferences
import android.util.Log
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
@@ -74,25 +73,25 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.delay
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.AboutCard
import me.kavishdevar.librepods.composables.AudioSettings
import me.kavishdevar.librepods.composables.BatteryView
import me.kavishdevar.librepods.composables.CallControlSettings
import me.kavishdevar.librepods.composables.ConnectionSettings
import me.kavishdevar.librepods.composables.HearingHealthSettings
import me.kavishdevar.librepods.composables.MicrophoneSettings
import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.composables.NoiseControlSettings
import me.kavishdevar.librepods.composables.PressAndHoldSettings
import me.kavishdevar.librepods.composables.StyledButton
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledToggle
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.AirPodsPro3
import me.kavishdevar.librepods.utils.Capability
import me.kavishdevar.librepods.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.data.AirPodsPro3
import me.kavishdevar.librepods.data.Capability
import me.kavishdevar.librepods.presentation.components.AboutCard
import me.kavishdevar.librepods.presentation.components.AudioSettings
import me.kavishdevar.librepods.presentation.components.BatteryView
import me.kavishdevar.librepods.presentation.components.CallControlSettings
import me.kavishdevar.librepods.presentation.components.ConnectionSettings
import me.kavishdevar.librepods.presentation.components.HearingHealthSettings
import me.kavishdevar.librepods.presentation.components.MicrophoneSettings
import me.kavishdevar.librepods.presentation.components.NavigationButton
import me.kavishdevar.librepods.presentation.components.NoiseControlSettings
import me.kavishdevar.librepods.presentation.components.PressAndHoldSettings
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@@ -162,17 +161,14 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl
.fillMaxSize()
.hazeSource(hazeState)
.padding(horizontal = 16.dp)
.then(
if (blockTouches) Modifier.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
event.changes.forEach { it.consume() }
}
.then(if (blockTouches) Modifier.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
event.changes.forEach { it.consume() }
}
} else Modifier
)
) {
}
} else Modifier)) {
item(key = "spacer_top") { Spacer(modifier = Modifier.height(topPadding)) }
item(key = "battery") {
BatteryView(
@@ -199,7 +195,7 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl
state.instance?.model?.capabilities?.contains(Capability.PPE) == true
if (hasHearingAidCapability || hasPPECapability) {
if (hasPPECapability || (BuildConfig.FLAVOR == "xposed" && hasHearingAidCapability)) item(
if (hasPPECapability || (state.vendorIdHook && hasHearingAidCapability)) item(
key = "spacer_hearing_health"
) { Spacer(modifier = Modifier.height(24.dp)) }
item(key = "hearing_health") {
@@ -207,7 +203,7 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl
navController = navController,
hasPPECapability = hasPPECapability,
hasHearingAidCapability = hasHearingAidCapability,
isXposed = BuildConfig.FLAVOR == "xposed"
vendorIdHook = state.vendorIdHook
)
}
}
@@ -272,27 +268,26 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl
}
item(key = "upgrade_button") {
val context = LocalContext.current
val textColor = if (isSystemInDarkTheme()) Color.White else Color.Black
if (!state.isPremium) {
Spacer(modifier = Modifier.height(28.dp))
StyledButton(
onClick = {
viewModel.purchase(context)
navController.navigate("purchase_screen")
},
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
tint = Color(0xFF916100)
tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(
0xFFE59900
)
) {
Text(
stringResource(R.string.unlock_all_features),
stringResource(R.string.unlock_advanced_features),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
color = Color.White
),
)
}
@@ -320,11 +315,7 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG]?.getOrNull(
0
) == 0x01.toByte()
val loudSoundReduction =
viewModel.getATTCharacteristicValue(ATTHandles.LOUD_SOUND_REDUCTION)
?.getOrNull(0) == 0x01.toByte()
val isXposed = BuildConfig.FLAVOR == "xposed"
AudioSettings(
navController = navController,
adaptiveVolumeCapability = adaptiveVolumeCapability,
@@ -345,14 +336,14 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl
checked
)
},
loudSoundReductionChecked = loudSoundReduction,
loudSoundReductionChecked = state.loudSoundReductionEnabled,
onLoudSoundReductionCheckedChange = {
viewModel.setATTCharacteristicValue(
ATTHandles.LOUD_SOUND_REDUCTION,
byteArrayOf(if (it) 0x01.toByte() else 0x00.toByte())
)
},
isXposed = isXposed,
vendorIdHook = state.vendorIdHook,
isPremium = state.isPremium
)
}
@@ -484,11 +475,8 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl
tapCount.intValue = 0
viewModel.activateDemoMode()
}
}
)
}
)
{
})
}) {
Text(
text = stringResource(R.string.airpods_not_connected), style = TextStyle(
fontSize = 24.sp,

View File

@@ -16,12 +16,16 @@
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 androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -42,9 +46,14 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
@@ -54,6 +63,7 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop
@@ -61,26 +71,26 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.composables.StyledButton
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider
import me.kavishdevar.librepods.composables.StyledToggle
import me.kavishdevar.librepods.viewmodel.AppSettingsViewModel
import me.kavishdevar.librepods.presentation.components.NavigationButton
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
import java.util.Locale.getDefault
@Composable
fun AppSettingsScreen(
navController: NavController,
viewModel: AppSettingsViewModel = viewModel()
navController: NavController, viewModel: AppSettingsViewModel = viewModel()
) {
val context = LocalContext.current
val scrollState = rememberScrollState()
val uiState by viewModel.uiState.collectAsState()
val state by viewModel.uiState.collectAsState()
val backdrop = rememberLayerBackdrop()
StyledScaffold(
title = stringResource(R.string.app_settings)
title = stringResource(R.string.settings)
) { topPadding, hazeState, bottomPadding ->
Column(
modifier = Modifier
@@ -96,23 +106,23 @@ fun AppSettingsScreen(
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
if (!uiState.isPremium) {
if (!state.isPremium) {
StyledButton(
onClick = {
viewModel.purchase(context)
navController.navigate("purchase_screen")
},
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
tint = Color(0xFF916100)
tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) {
Text(
stringResource(R.string.unlock_all_features),
stringResource(R.string.unlock_advanced_features),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
color = Color.White
),
)
}
@@ -122,9 +132,9 @@ fun AppSettingsScreen(
title = stringResource(R.string.widget),
label = stringResource(R.string.show_phone_battery_in_widget),
description = stringResource(R.string.show_phone_battery_in_widget_description),
checked = uiState.showPhoneBatteryInWidget,
checked = state.showPhoneBatteryInWidget,
onCheckedChange = viewModel::setShowPhoneBatteryInWidget,
enabled = uiState.isPremium
enabled = state.isPremium
)
Text(
@@ -149,10 +159,10 @@ fun AppSettingsScreen(
StyledToggle(
label = stringResource(R.string.conversational_awareness_pause_music),
description = stringResource(R.string.conversational_awareness_pause_music_description),
checked = uiState.conversationalAwarenessPauseMusicEnabled,
checked = state.conversationalAwarenessPauseMusicEnabled,
onCheckedChange = viewModel::setConversationalAwarenessPauseMusicEnabled,
independent = false,
enabled = uiState.isPremium
enabled = state.isPremium
)
HorizontalDivider(
@@ -164,16 +174,16 @@ fun AppSettingsScreen(
StyledToggle(
label = stringResource(R.string.relative_conversational_awareness_volume),
description = stringResource(R.string.relative_conversational_awareness_volume_description),
checked = uiState.relativeConversationalAwarenessVolumeEnabled,
checked = state.relativeConversationalAwarenessVolumeEnabled,
onCheckedChange = viewModel::setRelativeConversationalAwarenessVolumeEnabled,
independent = false,
enabled = uiState.isPremium
enabled = state.isPremium,
)
}
Spacer(modifier = Modifier.height(16.dp))
val conversationalAwarenessVolume = uiState.conversationalAwarenessVolume
val conversationalAwarenessVolume = state.conversationalAwarenessVolume
LaunchedEffect(conversationalAwarenessVolume) {
viewModel.setConversationalAwarenessVolume(conversationalAwarenessVolume)
}
@@ -182,11 +192,12 @@ fun AppSettingsScreen(
label = stringResource(R.string.conversational_awareness_volume),
value = conversationalAwarenessVolume,
valueRange = 10f..85f,
snapPoints = listOf(44f),
startLabel = "10%",
endLabel = "85%",
onValueChange = { newValue -> viewModel.setConversationalAwarenessVolume(newValue) },
independent = true,
enabled = uiState.isPremium
enabled = state.isPremium
)
if (!BuildConfig.PLAY_BUILD) {
@@ -198,7 +209,7 @@ fun AppSettingsScreen(
name = stringResource(R.string.set_custom_camera_package),
navController = navController,
onClick = {
if (uiState.isPremium) viewModel.setShowCameraDialog(true)
if (state.isPremium) viewModel.setShowCameraDialog(true)
},
independent = true,
description = stringResource(R.string.camera_control_app_description)
@@ -211,9 +222,9 @@ fun AppSettingsScreen(
title = stringResource(R.string.ear_detection),
label = stringResource(R.string.disconnect_when_not_wearing),
description = stringResource(R.string.disconnect_when_not_wearing_description),
checked = uiState.disconnectWhenNotWearing,
checked = state.disconnectWhenNotWearing,
onCheckedChange = viewModel::setDisconnectWhenNotWearing,
enabled = uiState.isPremium
enabled = state.isPremium
)
}
@@ -239,10 +250,10 @@ fun AppSettingsScreen(
StyledToggle(
label = stringResource(R.string.takeover_disconnected),
description = stringResource(R.string.takeover_disconnected_desc),
checked = uiState.takeoverWhenDisconnected,
checked = state.takeoverWhenDisconnected,
onCheckedChange = viewModel::setTakeoverWhenDisconnected,
independent = false,
enabled = uiState.isPremium
enabled = state.isPremium
)
HorizontalDivider(
thickness = 1.dp,
@@ -253,10 +264,10 @@ fun AppSettingsScreen(
StyledToggle(
label = stringResource(R.string.takeover_idle),
description = stringResource(R.string.takeover_idle_desc),
checked = uiState.takeoverWhenIdle,
checked = state.takeoverWhenIdle,
onCheckedChange = viewModel::setTakeoverWhenIdle,
independent = false,
enabled = uiState.isPremium
enabled = state.isPremium
)
HorizontalDivider(
thickness = 1.dp,
@@ -267,10 +278,10 @@ fun AppSettingsScreen(
StyledToggle(
label = stringResource(R.string.takeover_music),
description = stringResource(R.string.takeover_music_desc),
checked = uiState.takeoverWhenMusic,
checked = state.takeoverWhenMusic,
onCheckedChange = viewModel::setTakeoverWhenMusic,
independent = false,
enabled = uiState.isPremium
enabled = state.isPremium
)
HorizontalDivider(
thickness = 1.dp,
@@ -281,10 +292,10 @@ fun AppSettingsScreen(
StyledToggle(
label = stringResource(R.string.takeover_call),
description = stringResource(R.string.takeover_call_desc),
checked = uiState.takeoverWhenCall,
checked = state.takeoverWhenCall,
onCheckedChange = viewModel::setTakeoverWhenCall,
independent = false,
enabled = uiState.isPremium
enabled = state.isPremium
)
}
@@ -310,10 +321,10 @@ fun AppSettingsScreen(
StyledToggle(
label = stringResource(R.string.takeover_ringing_call),
description = stringResource(R.string.takeover_ringing_call_desc),
checked = uiState.takeoverWhenRingingCall,
checked = state.takeoverWhenRingingCall,
onCheckedChange = viewModel::setTakeoverWhenRingingCall,
independent = false,
enabled = uiState.isPremium
enabled = state.isPremium
)
HorizontalDivider(
thickness = 1.dp,
@@ -324,10 +335,10 @@ fun AppSettingsScreen(
StyledToggle(
label = stringResource(R.string.takeover_media_start),
description = stringResource(R.string.takeover_media_start_desc),
checked = uiState.takeoverWhenMediaStart,
checked = state.takeoverWhenMediaStart,
onCheckedChange = viewModel::setTakeoverWhenMediaStart,
independent = false,
enabled = uiState.isPremium
enabled = state.isPremium
)
}
@@ -345,13 +356,31 @@ fun AppSettingsScreen(
StyledToggle(
label = stringResource(R.string.use_alternate_head_tracking_packets),
description = stringResource(R.string.use_alternate_head_tracking_packets_description),
checked = uiState.useAlternateHeadTrackingPackets,
checked = state.useAlternateHeadTrackingPackets,
onCheckedChange = viewModel::setUseAlternateHeadTrackingPackets,
independent = true,
enabled = uiState.isPremium
enabled = state.isPremium
)
Spacer(modifier = Modifier.height(16.dp))
if (BuildConfig.FLAVOR == "xposed") {
Spacer(modifier = Modifier.height(16.dp))
val restartBluetoothText = stringResource(R.string.found_offset_restart_bluetooth)
StyledToggle(
label = stringResource(R.string.act_as_an_apple_device),
description = stringResource(R.string.act_as_an_apple_device_description) + "\n" + stringResource(
R.string.requires_xposed
).replaceFirstChar { if (it.isLowerCase()) it.titlecase(getDefault()) else it.toString() },
checked = state.vendorIdHook,
onCheckedChange = { enabled ->
Toast.makeText(context, restartBluetoothText, Toast.LENGTH_SHORT).show()
viewModel.setVendorIdHook(enabled)
},
independent = true,
enabled = state.isPremium
)
}
// NavigationButton(
// to = "troubleshooting",
@@ -361,6 +390,229 @@ fun AppSettingsScreen(
// description = stringResource(R.string.troubleshooting_description)
// )
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.contact), style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
)
Spacer(modifier = Modifier.height(4.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.background(
backgroundColor, RoundedCornerShape(28.dp)
)
.clip(RoundedCornerShape(28.dp))
) {
NavigationButton(
to = "",
name = stringResource(R.string.email),
navController = navController,
onClick = {
val intent = Intent(Intent.ACTION_SENDTO).apply {
data = "mailto:".toUri()
putExtra(Intent.EXTRA_EMAIL, arrayOf("contact@kavish.xyz"))
putExtra(Intent.EXTRA_SUBJECT, "LibrePods: ")
putExtra(
Intent.EXTRA_TEXT,
"\n\n\n----------" +
"\nPhone details:" +
"\nDEVICE: ${Build.DEVICE}" +
"\nMANUFACTURER: ${Build.MANUFACTURER} (${Build.BRAND})" +
"\nMODEL: ${Build.MODEL} (${Build.PRODUCT})" +
"\nVERSION: ${Build.DISPLAY} (${Build.VERSION.SDK_INT_FULL})" +
"\n\nApp details:" +
"\nVERSION: ${BuildConfig.VERSION_NAME}" +
"\nVERSION_CODE: ${BuildConfig.VERSION_CODE}" +
"\nFLAVOR: ${BuildConfig.FLAVOR}" +
"\nBUILD_TYPE: ${BuildConfig.BUILD_TYPE}"
)
}
context.startActivity(intent)
},
independent = false
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
NavigationButton(
to = "",
name = stringResource(R.string.discord),
navController = navController,
onClick = {
val intent =
Intent(Intent.ACTION_VIEW, "https://discord.gg/Ts4wupXcmc".toUri())
context.startActivity(intent)
},
independent = false
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
NavigationButton(
to = "",
name = stringResource(R.string.github_issues),
navController = navController,
onClick = {
val intent = Intent(
Intent.ACTION_VIEW,
"https://github.com/kavishdevar/librepods/issues".toUri()
)
context.startActivity(intent)
},
independent = false
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.about), style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
)
val rowHeight = remember { mutableStateOf(0.dp) }
val density = LocalDensity.current
Spacer(modifier = Modifier.height(4.dp))
Column(
modifier = Modifier
.clip(RoundedCornerShape(28.dp))
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(top = 2.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.onGloballyPositioned { coordinates ->
rowHeight.value = with(density) { coordinates.size.height.toDp() }
},
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.version), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = BuildConfig.VERSION_NAME, style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
alpha = 0.8f
),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.version_code), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = BuildConfig.VERSION_CODE.toString(), style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
alpha = 0.8f
),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.flavor), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = BuildConfig.FLAVOR, style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
alpha = 0.8f
),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier.padding(horizontal = 12.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.build_type), style = TextStyle(
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = BuildConfig.BUILD_TYPE,
style = TextStyle(
fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
alpha = 0.8f
),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
Spacer(modifier = Modifier.height(16.dp))
NavigationButton(
@@ -370,9 +622,9 @@ fun AppSettingsScreen(
independent = true
)
Spacer(modifier = Modifier.height(32.dp))
Spacer(modifier = Modifier.height(bottomPadding))
if (uiState.showCameraDialog) {
if (state.showCameraDialog) {
AlertDialog(onDismissRequest = { viewModel.setShowCameraDialog(false) }, title = {
Text(
stringResource(R.string.set_custom_camera_package),
@@ -388,13 +640,13 @@ fun AppSettingsScreen(
)
OutlinedTextField(
value = uiState.cameraPackageValue,
value = state.cameraPackageValue,
onValueChange = {
viewModel.setCameraPackageValue(it)
viewModel.setCameraPackageError(null)
},
modifier = Modifier.fillMaxWidth(),
isError = uiState.cameraPackageError != null,
isError = state.cameraPackageError != null,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Ascii,
capitalization = KeyboardCapitalization.None
@@ -406,9 +658,9 @@ fun AppSettingsScreen(
unfocusedBorderColor = if (isDarkTheme) Color.Gray else Color.LightGray
),
supportingText = {
if (uiState.cameraPackageError != null) {
if (state.cameraPackageError != null) {
Text(
uiState.cameraPackageError ?: "",
state.cameraPackageError ?: "",
color = MaterialTheme.colorScheme.error
)
}
@@ -439,7 +691,6 @@ fun AppSettingsScreen(
}
})
}
Spacer(modifier = Modifier.height(bottomPadding))
}
}
}

View File

@@ -16,7 +16,7 @@
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.content.ComponentName
@@ -41,12 +41,12 @@ import androidx.compose.ui.unit.dp
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.SelectItem
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSelectList
import me.kavishdevar.librepods.presentation.components.SelectItem
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledSelectList
import me.kavishdevar.librepods.services.AppListenerService
import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressType
import me.kavishdevar.librepods.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.StemPressType
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@Composable
fun CameraControlScreen(viewModel: AirPodsViewModel) {

View File

@@ -18,7 +18,7 @@
@file:OptIn(ExperimentalHazeMaterialsApi::class, ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.screens
package me.kavishdevar.librepods.presentation.screens
import android.annotation.SuppressLint
import android.content.ClipData
@@ -82,10 +82,10 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.constants.BatteryStatus
import me.kavishdevar.librepods.constants.isHeadTrackingData
import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.data.BatteryStatus
import me.kavishdevar.librepods.data.isHeadTrackingData
import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi

View File

@@ -21,8 +21,12 @@
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.screens
package me.kavishdevar.librepods.presentation.screens
import android.graphics.Paint
import android.graphics.RadialGradient
import android.graphics.Shader
import android.graphics.Typeface
import android.util.Log
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExperimentalAnimationApi
@@ -72,7 +76,6 @@ import androidx.compose.ui.graphics.asAndroidPath
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
@@ -83,6 +86,7 @@ import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource
@@ -91,13 +95,13 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledButton
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledToggle
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.HeadTracking
import me.kavishdevar.librepods.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs
import kotlin.math.cos
@@ -107,7 +111,7 @@ import kotlin.random.Random
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
@Composable
fun HeadTrackingScreen(viewModel: AirPodsViewModel) {
fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController) {
val state by viewModel.uiState.collectAsState()
DisposableEffect(Unit) {
viewModel.startHeadTracking()
@@ -163,25 +167,23 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel) {
) {
Spacer(modifier = Modifier.height(topPadding))
val context = LocalContext.current
if (!state.isPremium) {
StyledButton(
onClick = {
viewModel.purchase(context)
navController.navigate("purchase_screen")
},
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
tint = Color(0xFF916100)
tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) {
Text(
stringResource(R.string.unlock_all_features),
stringResource(R.string.unlock_advanced_features),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
color = Color.White
),
)
}
@@ -192,31 +194,20 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel) {
label = "Head Gestures",
checked = state.headGesturesEnabled,
onCheckedChange = { viewModel.setHeadGesturesEnabled(it) },
enabled = state.isPremium
)
Spacer(modifier = Modifier.height(2.dp))
Text(
stringResource(R.string.head_gestures_details),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor.copy(0.6f)
),
modifier = Modifier.padding(start = 4.dp)
enabled = state.isPremium,
description = stringResource(R.string.head_gestures_details)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
"Head Orientation",
style = TextStyle(
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(start = 4.dp, bottom = 8.dp, top = 8.dp)
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp, top = 8.dp)
)
HeadVisualization()
@@ -224,12 +215,12 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel) {
Text(
"Velocity",
style = TextStyle(
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(start = 4.dp, bottom = 8.dp, top = 8.dp)
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp, top = 8.dp)
)
AccelerationPlot()
@@ -481,9 +472,9 @@ private fun HeadVisualization() {
spherePath.close()
drawContext.canvas.nativeCanvas.apply {
val paint = android.graphics.Paint().apply {
style = android.graphics.Paint.Style.FILL
shader = android.graphics.RadialGradient(
val paint = Paint().apply {
style = Paint.Style.FILL
shader = RadialGradient(
center.x + sinY * faceRadius * 0.3f,
center.y - sinP * faceRadius * 0.3f,
faceRadius * 1.4f,
@@ -495,14 +486,14 @@ private fun HeadVisualization() {
backgroundColor.copy(alpha = 0.7f).toArgb()
),
floatArrayOf(0.3f, 0.5f, 0.7f, 0.8f, 1f),
android.graphics.Shader.TileMode.CLAMP
Shader.TileMode.CLAMP
)
}
drawPath(spherePath.asAndroidPath(), paint)
val highlightPaint = android.graphics.Paint().apply {
style = android.graphics.Paint.Style.FILL
shader = android.graphics.RadialGradient(
val highlightPaint = Paint().apply {
style = Paint.Style.FILL
shader = RadialGradient(
center.x - faceRadius * 0.4f - sinY * faceRadius * 0.5f,
center.y - faceRadius * 0.4f - sinP * faceRadius * 0.5f,
faceRadius * 0.9f,
@@ -512,15 +503,15 @@ private fun HeadVisualization() {
android.graphics.Color.TRANSPARENT
),
floatArrayOf(0f, 0.3f, 1f),
android.graphics.Shader.TileMode.CLAMP
Shader.TileMode.CLAMP
)
alpha = if (darkTheme) 30 else 60
}
drawPath(spherePath.asAndroidPath(), highlightPaint)
val secondaryHighlightPaint = android.graphics.Paint().apply {
style = android.graphics.Paint.Style.FILL
shader = android.graphics.RadialGradient(
val secondaryHighlightPaint = Paint().apply {
style = Paint.Style.FILL
shader = RadialGradient(
center.x + faceRadius * 0.3f + sinY * faceRadius * 0.3f,
center.y + faceRadius * 0.3f - sinP * faceRadius * 0.3f,
faceRadius * 0.7f,
@@ -529,15 +520,15 @@ private fun HeadVisualization() {
android.graphics.Color.TRANSPARENT
),
floatArrayOf(0f, 1f),
android.graphics.Shader.TileMode.CLAMP
Shader.TileMode.CLAMP
)
alpha = if (darkTheme) 15 else 30
}
drawPath(spherePath.asAndroidPath(), secondaryHighlightPaint)
val shadowPaint = android.graphics.Paint().apply {
style = android.graphics.Paint.Style.FILL
shader = android.graphics.RadialGradient(
val shadowPaint = Paint().apply {
style = Paint.Style.FILL
shader = RadialGradient(
center.x + sinY * faceRadius * 0.5f,
center.y - sinP * faceRadius * 0.5f,
faceRadius * 1.1f,
@@ -546,7 +537,7 @@ private fun HeadVisualization() {
android.graphics.Color.BLACK
),
floatArrayOf(0.7f, 1f),
android.graphics.Shader.TileMode.CLAMP
Shader.TileMode.CLAMP
)
alpha = if (darkTheme) 40 else 20
}
@@ -606,13 +597,13 @@ private fun HeadVisualization() {
}
drawContext.canvas.nativeCanvas.apply {
val paint = android.graphics.Paint().apply {
val paint = Paint().apply {
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
textSize = 12.sp.toPx()
textAlign = android.graphics.Paint.Align.RIGHT
typeface = android.graphics.Typeface.create(
textAlign = Paint.Align.RIGHT
typeface = Typeface.create(
"SF Pro",
android.graphics.Typeface.NORMAL
Typeface.NORMAL
)
}
@@ -726,10 +717,10 @@ private fun AccelerationPlot() {
}
drawContext.canvas.nativeCanvas.apply {
val paint = android.graphics.Paint().apply {
val paint = Paint().apply {
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
textSize = 12.sp.toPx()
textAlign = android.graphics.Paint.Align.RIGHT
textAlign = Paint.Align.RIGHT
}
drawText("${maxAbs.toInt()}", 30.dp.toPx(), 20.dp.toPx(), paint)
@@ -742,20 +733,20 @@ private fun AccelerationPlot() {
drawCircle(Color(0xFF007AFF), 5.dp.toPx(), Offset(width - 150.dp.toPx(), legendY))
drawContext.canvas.nativeCanvas.apply {
val paint = android.graphics.Paint().apply {
val paint = Paint().apply {
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
textSize = 12.sp.toPx()
textAlign = android.graphics.Paint.Align.LEFT
textAlign = Paint.Align.LEFT
}
drawText("Horizontal", width - 140.dp.toPx(), textOffsetY, paint)
}
drawCircle(Color(0xFFFF3B30), 5.dp.toPx(), Offset(width - 70.dp.toPx(), legendY))
drawContext.canvas.nativeCanvas.apply {
val paint = android.graphics.Paint().apply {
val paint = Paint().apply {
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
textSize = 12.sp.toPx()
textAlign = android.graphics.Paint.Align.LEFT
textAlign = Paint.Align.LEFT
}
drawText("Vertical", width - 60.dp.toPx(), textOffsetY, paint)
}

View File

@@ -16,7 +16,7 @@
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.util.Log
@@ -50,16 +50,16 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider
import me.kavishdevar.librepods.composables.StyledToggle
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.HearingAidSettings
import me.kavishdevar.librepods.utils.parseHearingAidSettingsResponse
import me.kavishdevar.librepods.utils.sendHearingAidSettings
import me.kavishdevar.librepods.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.data.HearingAidSettings
import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
import me.kavishdevar.librepods.data.sendHearingAidSettings
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import java.io.IOException
import kotlin.io.encoding.ExperimentalEncodingApi

View File

@@ -16,7 +16,7 @@
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.util.Log
@@ -62,15 +62,14 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.ConfirmationDialog
import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledToggle
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse
import me.kavishdevar.librepods.utils.sendTransparencySettings
import me.kavishdevar.librepods.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.data.parseTransparencySettingsResponse
import me.kavishdevar.librepods.data.sendTransparencySettings
import me.kavishdevar.librepods.presentation.components.ConfirmationDialog
import me.kavishdevar.librepods.presentation.components.NavigationButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi
private const val TAG = "AccessibilitySettings"
@@ -174,7 +173,7 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController)
NavigationButton(
to = "hearing_aid_adjustments",
name = stringResource(R.string.adjustments),
navController,
navController = navController,
independent = false
)
}
@@ -193,7 +192,7 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController)
NavigationButton(
to = "update_hearing_test",
name = stringResource(R.string.update_hearing_test),
navController,
navController = navController,
independent = true
)
@@ -237,7 +236,6 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController)
Spacer(modifier = Modifier.height(bottomPadding))
}
}
ConfirmationDialog(
showDialog = showDialog,
title = "Enable Hearing Aid",
@@ -256,12 +254,11 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController)
hearingAidEnabled.value = true
CoroutineScope(Dispatchers.IO).launch {
try {
val data = viewModel.getATTCharacteristicValue(ATTHandles.TRANSPARENCY) ?: byteArrayOf()
if (data.isEmpty()) {
if (state.hearingAidData.isEmpty()) {
Log.w(TAG, "read failed")
return@launch
}
val parsed = parseTransparencySettingsResponse(data)
val parsed = parseTransparencySettingsResponse(state.hearingAidData)
val disabledSettings = parsed.copy(enabled = false)
sendTransparencySettings(viewModel::setATTCharacteristicValue, disabledSettings)
} catch (e: Exception) {
@@ -269,6 +266,10 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController)
}
}
},
onDismiss = {
hearingAidEnabled.value = false
showDialog.value = false
},
hazeState = hazeStateS.value,
// backdrop = backdrop
)

View File

@@ -16,7 +16,7 @@
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.Spacer
@@ -33,11 +33,11 @@ import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledToggle
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@Composable
fun HearingProtectionScreen(viewModel: AirPodsViewModel) {
@@ -54,20 +54,19 @@ fun HearingProtectionScreen(viewModel: AirPodsViewModel) {
) {
Spacer(modifier = Modifier.height(spacerHeight))
if (BuildConfig.FLAVOR == "xposed") {
if (state.vendorIdHook) {
StyledToggle(
title = stringResource(R.string.environmental_noise),
label = stringResource(R.string.loud_sound_reduction),
description = stringResource(R.string.loud_sound_reduction_description),
checked = viewModel.getATTCharacteristicValue(ATTHandles.LOUD_SOUND_REDUCTION)
?.get(0)?.toInt() == 1,
checked = state.loudSoundReductionEnabled,
onCheckedChange = {
viewModel.setATTCharacteristicValue(
ATTHandles.LOUD_SOUND_REDUCTION,
byteArrayOf(if (it) 1.toByte() else 0.toByte())
)
}
// attHandle = ATTHandles.LOUD_SOUND_REDUCTION
},
enabled = state.isPremium
)
Spacer(modifier = Modifier.height(12.dp))
@@ -83,7 +82,9 @@ fun HearingProtectionScreen(viewModel: AirPodsViewModel) {
viewModel.setControlCommandBoolean(
AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG, it
)
})
},
enabled = state.isPremium
)
}
}
}

View File

@@ -16,7 +16,7 @@
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 androidx.compose.foundation.isSystemInDarkTheme
@@ -28,12 +28,9 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
@@ -42,17 +39,9 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
import com.mikepenz.aboutlibraries.ui.compose.produceLibraries
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: Job? = null
@@ -76,7 +65,7 @@ fun OpenSourceLicensesScreen(navController: NavController) {
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(spacerHeight))
val context = androidx.compose.ui.platform.LocalContext.current
val context = LocalContext.current
val libraries by produceLibraries {
context.resources.openRawResource(R.raw.aboutlibraries)
.bufferedReader()
@@ -90,4 +79,4 @@ fun OpenSourceLicensesScreen(navController: NavController) {
)
}
}
}
}

View File

@@ -18,7 +18,7 @@
@file:OptIn(ExperimentalStdlibApi::class, ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.screens
package me.kavishdevar.librepods.presentation.screens
import android.content.Context
import android.util.Log
@@ -49,25 +49,26 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.edit
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.SelectItem
import me.kavishdevar.librepods.composables.StyledButton
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSelectList
import me.kavishdevar.librepods.constants.StemAction
import me.kavishdevar.librepods.presentation.components.SelectItem
import me.kavishdevar.librepods.presentation.components.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledSelectList
import me.kavishdevar.librepods.data.StemAction
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.experimental.and
import kotlin.io.encoding.ExperimentalEncodingApi
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LongPress(viewModel: AirPodsViewModel, name: String) {
fun LongPress(viewModel: AirPodsViewModel, name: String, navController: NavController) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
@@ -124,20 +125,20 @@ fun LongPress(viewModel: AirPodsViewModel, name: String) {
Spacer(modifier = Modifier.height(24.dp))
StyledButton(
onClick = {
viewModel.purchase(context)
navController.navigate("purchase_screen")
},
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
tint = Color(0xFF916100)
tint = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
) {
Text(
stringResource(R.string.unlock_all_features),
stringResource(R.string.unlock_advanced_features),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
color = Color.White
),
)
}
@@ -152,6 +153,7 @@ fun LongPress(viewModel: AirPodsViewModel, name: String) {
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
fontFamily = FontFamily(Font(R.font.sf_pro)),
modifier = Modifier

View File

@@ -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))
}
}
}

View File

@@ -18,7 +18,7 @@
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.screens
package me.kavishdevar.librepods.presentation.screens
import android.content.Context
import androidx.compose.foundation.background
@@ -60,8 +60,8 @@ import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi

View File

@@ -16,7 +16,7 @@
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 android.annotation.SuppressLint
@@ -42,7 +42,6 @@ import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -68,15 +67,13 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.delay
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider
import me.kavishdevar.librepods.composables.StyledToggle
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.TransparencySettings
import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse
import me.kavishdevar.librepods.utils.sendTransparencySettings
import me.kavishdevar.librepods.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle
import me.kavishdevar.librepods.data.TransparencySettings
import me.kavishdevar.librepods.data.parseTransparencySettingsResponse
import me.kavishdevar.librepods.data.sendTransparencySettings
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import java.io.IOException
import kotlin.io.encoding.ExperimentalEncodingApi
@@ -91,8 +88,6 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
val textColor = if (isDarkTheme) Color.White else Color.Black
val verticalScrollState = rememberScrollState()
val attManager = ServiceManager.getService()?.attManager ?: return
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
@@ -151,23 +146,6 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
)
}
val transparencyListener = remember {
object : (ByteArray) -> Unit {
override fun invoke(value: ByteArray) {
val parsed = parseTransparencySettingsResponse(value)
enabled.value = parsed.enabled
amplificationSliderValue.floatValue = parsed.netAmplification
balanceSliderValue.floatValue = parsed.balance
toneSliderValue.floatValue = parsed.leftTone
ambientNoiseReductionSliderValue.floatValue =
parsed.leftAmbientNoiseReduction
conversationBoostEnabled.value = parsed.leftConversationBoost
eq.value = parsed.leftEQ.copyOf()
Log.d(TAG, "Updated transparency settings from notification")
}
}
}
LaunchedEffect(
enabled.value,
amplificationSliderValue.floatValue,
@@ -211,18 +189,9 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
sendTransparencySettings(viewModel::setATTCharacteristicValue, transparencySettings.value)
}
DisposableEffect(Unit) {
onDispose {
attManager.unregisterListener(ATTHandles.TRANSPARENCY, transparencyListener)
}
}
LaunchedEffect(Unit) {
Log.d(TAG, "Connecting to ATT...")
try {
attManager.enableNotifications(ATTHandles.TRANSPARENCY)
attManager.registerListener(ATTHandles.TRANSPARENCY, transparencyListener)
// If we have an AACP manager, prefer its EQ data to populate EQ controls first
try {
Log.d(TAG, "Found AACPManager, reading cached EQ data")
@@ -242,7 +211,7 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
for (attempt in 1..3) {
initialReadAttempts.intValue = attempt
try {
val data = attManager.read(ATTHandles.TRANSPARENCY)
val data = state.transparencyData
parsedSettings = parseTransparencySettingsResponse(data = data)
Log.d(TAG, "Parsed settings on attempt $attempt")
} catch (e: Exception) {
@@ -275,8 +244,7 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
}
}
// Only show transparency mode section if SDP offset is available
if (BuildConfig.FLAVOR == "xposed") {
if (state.vendorIdHook) {
StyledToggle(
label = stringResource(R.string.transparency_mode),
checked = enabled.value,

View File

@@ -16,7 +16,7 @@
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.widget.Toast
@@ -94,7 +94,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.utils.LogCollector
import java.io.File
import java.text.SimpleDateFormat

View File

@@ -16,7 +16,7 @@
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 androidx.compose.foundation.isSystemInDarkTheme
@@ -59,12 +59,12 @@ import dev.chrisbanes.haze.hazeSource
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.HearingAidSettings
import me.kavishdevar.librepods.utils.parseHearingAidSettingsResponse
import me.kavishdevar.librepods.utils.sendHearingAidSettings
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.data.HearingAidSettings
import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
import me.kavishdevar.librepods.data.sendHearingAidSettings
import java.io.IOException
private var debounceJob: MutableState<Job?> = mutableStateOf(null)

View File

@@ -16,7 +16,7 @@
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.isSystemInDarkTheme
@@ -48,8 +48,8 @@ import androidx.compose.ui.unit.sp
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@Composable
fun VersionScreen(viewModel: AirPodsViewModel) {
@@ -80,7 +80,8 @@ fun VersionScreen(viewModel: AirPodsViewModel) {
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f)
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}

View File

@@ -17,7 +17,7 @@
*/
package me.kavishdevar.librepods.ui.theme
package me.kavishdevar.librepods.presentation.theme
import androidx.compose.ui.graphics.Color
@@ -27,4 +27,4 @@ val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
val Pink40 = Color(0xFF7D5260)

View File

@@ -16,7 +16,7 @@
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 androidx.compose.foundation.isSystemInDarkTheme

View File

@@ -16,7 +16,7 @@
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.ui.text.TextStyle
@@ -49,4 +49,4 @@ val Typography = Typography(
letterSpacing = 0.5.sp
)
*/
)
)

View File

@@ -16,9 +16,8 @@
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.Context
import android.content.Intent
@@ -28,25 +27,29 @@ import android.util.Log
import androidx.core.content.edit
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.billing.BillingManager
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.constants.Battery
import me.kavishdevar.librepods.constants.BatteryComponent
import me.kavishdevar.librepods.constants.BatteryStatus
import me.kavishdevar.librepods.constants.StemAction
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.data.AirPodsInstance
import me.kavishdevar.librepods.data.AirPodsModels
import me.kavishdevar.librepods.data.AirPodsNotifications
import me.kavishdevar.librepods.data.Battery
import me.kavishdevar.librepods.data.BatteryComponent
import me.kavishdevar.librepods.data.BatteryStatus
import me.kavishdevar.librepods.data.Capability
import me.kavishdevar.librepods.data.ControlCommandRepository
import me.kavishdevar.librepods.data.StemAction
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.AACPManager.Companion.ControlCommandIdentifiers
import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.AirPodsInstance
import me.kavishdevar.librepods.utils.AirPodsModels
import me.kavishdevar.librepods.utils.Capability
@Suppress("ArrayInDataClass")
data class AirPodsUiState(
@@ -81,7 +84,12 @@ data class AirPodsUiState(
val leftAction: StemAction = StemAction.CYCLE_NOISE_CONTROL_MODES,
val rightAction: StemAction = StemAction.CYCLE_NOISE_CONTROL_MODES,
val loudSoundReductionEnabled: Boolean = false,
val transparencyData: ByteArray = byteArrayOf(),
val hearingAidData: ByteArray = byteArrayOf(),
val isPremium: Boolean = false,
val vendorIdHook: Boolean = false
)
class AirPodsViewModel(
@@ -90,8 +98,14 @@ class AirPodsViewModel(
private val controlRepo: ControlCommandRepository,
private val appContext: Context
) : ViewModel() {
private val _uiState = MutableStateFlow(AirPodsUiState(deviceName = sharedPreferences.getString("name", "AirPods Pro") ?: "AirPods Pro"))
private val _uiState = MutableStateFlow(
AirPodsUiState(
deviceName = sharedPreferences.getString(
"name",
"AirPods Pro"
) ?: "AirPods Pro"
)
)
val uiState: StateFlow<AirPodsUiState> = _uiState
private var isDemoMode = false
@@ -99,17 +113,16 @@ class AirPodsViewModel(
private var billingFirstCollectDone = false
private val listeners = mutableMapOf<
ControlCommandIdentifiers,
AACPManager.ControlCommandListener
>()
private val listeners =
mutableMapOf<ControlCommandIdentifiers, AACPManager.ControlCommandListener>()
private val xposedRemotePref = XposedRemotePrefProvider.create()
private lateinit var broadcastReceiver: BroadcastReceiver
private val _cameraAction = MutableStateFlow(
sharedPreferences.getString("camera_action", null)
?.let { value -> AACPManager.Companion.StemPressType.entries.find { it.name == value } }
)
?.let { value -> AACPManager.Companion.StemPressType.entries.find { it.name == value } })
val cameraAction: StateFlow<AACPManager.Companion.StemPressType?> = _cameraAction
@@ -129,6 +142,7 @@ class AirPodsViewModel(
setupControlObservers()
observeBilling()
loadControlList()
observeATT()
if (isDemoMode) activateDemoMode()
}
@@ -148,7 +162,9 @@ class AirPodsViewModel(
}
private fun observeBilling() {
if (!isDemoMode) viewModelScope.launch {
if (isDemoMode) return
viewModelScope.launch {
if (!BuildConfig.PLAY_BUILD) billingFirstCollectDone = true // FOSS doesn't send multiple events
BillingManager.provider.isPremium.collect { premium ->
if (!billingFirstCollectDone) {
billingFirstCollectDone = true
@@ -156,7 +172,10 @@ class AirPodsViewModel(
}
if (!premium) {
Log.d("AirPodsViewModel", "we are not premium")
setControlCommandBoolean(ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG, false)
setControlCommandBoolean(
ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
false
)
setHeadGesturesEnabled(false)
} else {
Log.d("AirPodsViewModel", "we are premium")
@@ -183,7 +202,8 @@ class AirPodsViewModel(
}
AirPodsNotifications.BATTERY_DATA -> {
val data = intent.getParcelableArrayListExtra("data", Battery::class.java)?.toList() ?: emptyList()
val data = intent.getParcelableArrayListExtra("data", Battery::class.java)
?.toList() ?: emptyList()
_uiState.update {
it.copy(battery = data)
}
@@ -213,15 +233,12 @@ class AirPodsViewModel(
}
appContext.registerReceiver(
broadcastReceiver,
filter,
Context.RECEIVER_NOT_EXPORTED
broadcastReceiver, filter, Context.RECEIVER_NOT_EXPORTED
)
}
fun setControlCommandValue(
identifier: ControlCommandIdentifiers,
value: ByteArray
identifier: ControlCommandIdentifiers, value: ByteArray
) {
if (!isDemoMode) controlRepo.setValue(identifier, value)
_uiState.update {
@@ -232,25 +249,21 @@ class AirPodsViewModel(
}
fun setControlCommandBoolean(
identifier: ControlCommandIdentifiers,
enabled: Boolean
identifier: ControlCommandIdentifiers, enabled: Boolean
) {
setControlCommandValue(
identifier,
if (enabled) byteArrayOf(0x01) else byteArrayOf(0x02)
identifier, if (enabled) byteArrayOf(0x01) else byteArrayOf(0x02)
)
}
fun setControlCommandInt(
identifier: ControlCommandIdentifiers,
value: Int
identifier: ControlCommandIdentifiers, value: Int
) {
setControlCommandValue(identifier, byteArrayOf(value.toByte()))
}
fun setControlCommandByte(
identifier: ControlCommandIdentifiers,
value: Byte
identifier: ControlCommandIdentifiers, value: Byte
) {
setControlCommandValue(identifier, byteArrayOf(value))
}
@@ -267,7 +280,7 @@ class AirPodsViewModel(
}
}
listeners[identifier] = listener
listeners[identifier] = listener as AACPManager.ControlCommandListener
}
// I'm lazy, sorry.
@@ -309,8 +322,7 @@ class AirPodsViewModel(
service.let { service ->
_uiState.update {
it.copy(
isLocallyConnected = service.isConnected(),
battery = service.getBattery()
isLocallyConnected = service.isConnected(), battery = service.getBattery()
)
}
}
@@ -318,11 +330,24 @@ class AirPodsViewModel(
private fun loadSharedPreferences() {
val offListeningModeEnabled = sharedPreferences.getBoolean("off_listening_mode", true)
val automaticEarDetectionEnabled = sharedPreferences.getBoolean("automatic_ear_detection", true)
val automaticConnectionEnabled = sharedPreferences.getBoolean("automatic_connection_ctrl_cmd", true)
val automaticEarDetectionEnabled =
sharedPreferences.getBoolean("automatic_ear_detection", true)
val automaticConnectionEnabled =
sharedPreferences.getBoolean("automatic_connection_ctrl_cmd", true)
val headGesturesEnabled = sharedPreferences.getBoolean("head_gestures", true)
val leftAction = StemAction.valueOf(sharedPreferences.getString("left_long_press_action", "CYCLE_NOISE_CONTROL_MODES") ?: "CYCLE_NOISE_CONTROL_MODES")
val rightAction = StemAction.valueOf(sharedPreferences.getString("right_long_press_action", "CYCLE_NOISE_CONTROL_MODES") ?: "CYCLE_NOISE_CONTROL_MODES")
val leftAction = StemAction.valueOf(
sharedPreferences.getString(
"left_long_press_action",
"CYCLE_NOISE_CONTROL_MODES"
) ?: "CYCLE_NOISE_CONTROL_MODES"
)
val rightAction = StemAction.valueOf(
sharedPreferences.getString(
"right_long_press_action",
"CYCLE_NOISE_CONTROL_MODES"
) ?: "CYCLE_NOISE_CONTROL_MODES"
)
val vendorIdHook = xposedRemotePref.getBoolean("vendor_id_hook", false)
_uiState.update {
it.copy(
@@ -331,7 +356,8 @@ class AirPodsViewModel(
automaticConnectionEnabled = automaticConnectionEnabled,
headGesturesEnabled = headGesturesEnabled,
leftAction = leftAction,
rightAction = rightAction
rightAction = rightAction,
vendorIdHook = vendorIdHook
)
}
}
@@ -365,14 +391,12 @@ class AirPodsViewModel(
name = "AirPods",
model = AirPodsModels.getModelByModelNumber("A3049")!!,
actualModelNumber = "A3049",
aacpManager = service.aacpManager,
serialNumber = null,
leftSerialNumber = null,
rightSerialNumber = null,
version1 = null,
version2 = null,
version3 = null,
attManager = null
)
_uiState.update {
@@ -381,7 +405,11 @@ class AirPodsViewModel(
instance = instance,
modelName = instance.model.displayName,
actualModel = instance.actualModelNumber,
serialNumbers = listOf(instance.serialNumber ?: "", instance.leftSerialNumber ?: "", instance.rightSerialNumber ?: ""),
serialNumbers = listOf(
instance.serialNumber ?: "",
instance.leftSerialNumber ?: "",
instance.rightSerialNumber ?: ""
),
version1 = instance.version1 ?: "",
version2 = instance.version2 ?: "",
version3 = instance.version3 ?: ""
@@ -408,11 +436,42 @@ class AirPodsViewModel(
}
fun setATTCharacteristicValue(handle: ATTHandles, value: ByteArray) {
service.attManager?.write(handle, value)
if (handle == ATTHandles.LOUD_SOUND_REDUCTION) {
_uiState.update { it.copy(loudSoundReductionEnabled = value[0].toInt() == 0x01) }
}
viewModelScope.launch(Dispatchers.IO) {
service.attManager?.write(handle, value)
}
}
fun getATTCharacteristicValue(handle: ATTHandles): ByteArray? {
return service.attManager?.read(handle)
fun refreshATT() {
viewModelScope.launch(Dispatchers.IO) {
val loudSoundReduction =
runCatching { service.attManager?.read(ATTHandles.LOUD_SOUND_REDUCTION) }.getOrNull()
val transparencyData =
runCatching { service.attManager?.read(ATTHandles.TRANSPARENCY) }.getOrNull()?: byteArrayOf()
val hearingAid =
runCatching { service.attManager?.read(ATTHandles.HEARING_AID) }.getOrNull()?: byteArrayOf()
_uiState.value = _uiState.value.copy(
loudSoundReductionEnabled = loudSoundReduction?.get(0)?.toInt() == 0x01,
transparencyData = transparencyData,
hearingAidData = hearingAid
)
}
}
fun observeATT() {
viewModelScope.launch(Dispatchers.IO) {
service.attManager?.connect()
service.attManager?.enableNotifications(ATTHandles.LOUD_SOUND_REDUCTION)
service.attManager?.enableNotifications(ATTHandles.TRANSPARENCY)
service.attManager?.enableNotifications(ATTHandles.HEARING_AID)
while (true) {
refreshATT()
delay(10000)
}
}
}
fun setAutomaticEarDetectionEnabled(enabled: Boolean) {
@@ -435,9 +494,9 @@ class AirPodsViewModel(
}
}
fun purchase(context: Context) {
BillingManager.provider.purchase(context as Activity)
}
// fun purchase(context: Context) {
// BillingManager.provider.purchase(context as Activity)
// }
fun activateDemoMode() {
isDemoMode = true
@@ -448,14 +507,12 @@ class AirPodsViewModel(
name = "AirPods Pro (Demo)",
model = AirPodsModels.getModelByModelNumber("A3049")!!,
actualModelNumber = "A3049",
aacpManager = service.aacpManager,
serialNumber = "DEMO123",
leftSerialNumber = "L-DEMO",
rightSerialNumber = "R-DEMO",
version1 = "1.0",
version2 = "1.0",
version3 = "1.0",
attManager = null
)
_uiState.update {

View File

@@ -1,6 +1,5 @@
package me.kavishdevar.librepods.viewmodel
package me.kavishdevar.librepods.presentation.viewmodel
import android.app.Activity
import android.app.Application
import android.content.Context
import androidx.core.content.edit
@@ -10,7 +9,10 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.billing.BillingManager
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
import me.kavishdevar.librepods.utils.NativeBridge
import kotlin.math.roundToInt
data class AppSettingsUiState(
@@ -29,6 +31,7 @@ data class AppSettingsUiState(
val showCameraDialog: Boolean = false,
val cameraPackageValue: String = "",
val cameraPackageError: String? = null,
val vendorIdHook: Boolean = false,
val isPremium: Boolean = false
)
@@ -38,6 +41,8 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat
private val _uiState = MutableStateFlow(AppSettingsUiState())
val uiState = _uiState.asStateFlow()
private val xposedRemotePref = XposedRemotePrefProvider.create()
init {
loadSettings()
observeBilling()
@@ -66,9 +71,13 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat
takeoverWhenMediaStart = sharedPreferences.getBoolean("takeover_when_media_start", false),
useAlternateHeadTrackingPackets = sharedPreferences.getBoolean("use_alternate_head_tracking_packets", true),
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 43).toFloat(),
cameraPackageValue = sharedPreferences.getString("custom_camera_package", "") ?: ""
cameraPackageValue = sharedPreferences.getString("custom_camera_package", "") ?: "",
vendorIdHook = xposedRemotePref.getBoolean("vendor_id_hook", false)
)
}
if (BuildConfig.FLAVOR == "xposed") {
NativeBridge.setSdpHook(_uiState.value.vendorIdHook)
}
}
fun setShowPhoneBatteryInWidget(enabled: Boolean) {
@@ -152,7 +161,9 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat
setShowCameraDialog(false)
}
fun purchase(context: Context) {
BillingManager.provider.purchase(context as Activity)
fun setVendorIdHook(enabled: Boolean) {
NativeBridge.setSdpHook(enabled)
xposedRemotePref.putBoolean("vendor_id_hook", enabled)
_uiState.update { it.copy(vendorIdHook = enabled) }
}
}

View File

@@ -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()
}
}

View File

@@ -18,7 +18,7 @@
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.widgets
package me.kavishdevar.librepods.presentation.widgets
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider

View File

@@ -18,7 +18,7 @@
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.widgets
package me.kavishdevar.librepods.presentation.widgets
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
@@ -29,7 +29,7 @@ import android.util.Log
import android.widget.RemoteViews
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.bluetooth.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
class NoiseControlWidget : AppWidgetProvider() {

View File

@@ -35,9 +35,9 @@ import android.util.Log
import androidx.annotation.RequiresApi
import me.kavishdevar.librepods.QuickSettingsDialogActivity
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.constants.NoiseControlMode
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.data.AirPodsNotifications
import me.kavishdevar.librepods.data.NoiseControlMode
import me.kavishdevar.librepods.bluetooth.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
@RequiresApi(Build.VERSION_CODES.Q)

View File

@@ -83,25 +83,28 @@ import kotlinx.coroutines.withTimeout
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.MainActivity
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.constants.Battery
import me.kavishdevar.librepods.constants.BatteryComponent
import me.kavishdevar.librepods.constants.BatteryStatus
import me.kavishdevar.librepods.constants.StemAction
import me.kavishdevar.librepods.constants.isHeadTrackingData
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressType
import me.kavishdevar.librepods.utils.ATTManager
import me.kavishdevar.librepods.utils.AirPodsInstance
import me.kavishdevar.librepods.utils.AirPodsModels
import me.kavishdevar.librepods.utils.BLEManager
import me.kavishdevar.librepods.utils.BluetoothConnectionManager
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.StemPressType
import me.kavishdevar.librepods.bluetooth.ATTManager
import me.kavishdevar.librepods.bluetooth.BLEManager
import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager
import me.kavishdevar.librepods.data.AirPodsInstance
import me.kavishdevar.librepods.data.AirPodsModels
import me.kavishdevar.librepods.data.AirPodsNotifications
import me.kavishdevar.librepods.data.Battery
import me.kavishdevar.librepods.data.BatteryComponent
import me.kavishdevar.librepods.data.BatteryStatus
import me.kavishdevar.librepods.data.StemAction
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
import me.kavishdevar.librepods.data.isHeadTrackingData
import me.kavishdevar.librepods.presentation.overlays.IslandType
import me.kavishdevar.librepods.presentation.overlays.IslandWindow
import me.kavishdevar.librepods.presentation.overlays.PopupWindow
import me.kavishdevar.librepods.presentation.widgets.BatteryWidget
import me.kavishdevar.librepods.presentation.widgets.NoiseControlWidget
import me.kavishdevar.librepods.utils.GestureDetector
import me.kavishdevar.librepods.utils.HeadTracking
import me.kavishdevar.librepods.utils.IslandType
import me.kavishdevar.librepods.utils.IslandWindow
import me.kavishdevar.librepods.utils.MediaController
import me.kavishdevar.librepods.utils.PopupWindow
import me.kavishdevar.librepods.utils.SystemApisUtils
import me.kavishdevar.librepods.utils.SystemApisUtils.DEVICE_TYPE_UNTETHERED_HEADSET
import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_COMPANION_APP
@@ -121,8 +124,6 @@ import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_
import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_CHARGING
import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_ICON
import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD
import me.kavishdevar.librepods.widgets.BatteryWidget
import me.kavishdevar.librepods.widgets.NoiseControlWidget
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.io.encoding.Base64
@@ -1060,8 +1061,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
version1 = config.airpodsVersion1,
version2 = config.airpodsVersion2,
version3 = config.airpodsVersion3,
aacpManager = aacpManager,
attManager = attManager
)
}
sendBroadcast(
@@ -1765,8 +1764,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
}
@Suppress("KotlinUnreachableCode")
@OptIn(ExperimentalMaterial3Api::class)
private fun showSocketConnectionFailureNotification(errorMessage: String) {
return // something causes too many notifications. turning off for now
if (BuildConfig.FLAVOR != "xposed") {
Log.w(
TAG,
@@ -1788,7 +1789,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
.setSmallIcon(R.drawable.airpods).setContentTitle("AirPods Connection Issue")
.setContentText("Unable to connect to AirPods over L2CAP").setStyle(
NotificationCompat.BigTextStyle().bigText(
"Your AirPods are connected via Bluetooth, but LibrePods couldn't connect to AirPods using L2CAP. " + "Error: $errorMessage"
"Your AirPods are connected via Bluetooth, but LibrePods couldn't connect to AirPods using L2CAP. Error: $errorMessage"
)
).setContentIntent(pendingIntent).setCategory(Notification.CATEGORY_ERROR)
.setPriority(NotificationCompat.PRIORITY_HIGH).setAutoCancel(true).build()
@@ -2178,7 +2179,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
fun processHeadTrackingData(data: ByteArray) {
val horizontal = ByteBuffer.wrap(data, 51, 2).order(ByteOrder.LITTLE_ENDIAN).short.toInt()
val vertical = ByteBuffer.wrap(data, 53, 2).order(ByteOrder.LITTLE_ENDIAN).short.toInt()
gestureDetector?.processHeadOrientation(horizontal, vertical)
try {
gestureDetector?.processHeadOrientation(horizontal, vertical)
} catch (e: Exception) {
Log.w(TAG, "gesture detector on ${data.toHexString()}: ${e.message}")
}
}
private lateinit var connectionReceiver: BroadcastReceiver
@@ -2666,8 +2671,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
this@AirPodsService.device = device
BluetoothConnectionManager.setCurrentConnection(socket, device)
if (BuildConfig.FLAVOR == "xposed") {
val xposedRemotePref = XposedRemotePrefProvider.create()
if (xposedRemotePref.getBoolean("vendor_id_hook", false)) {
attManager = ATTManager(adapter, device)
attManager!!.connect()
}
@@ -2687,8 +2692,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
version1 = config.airpodsVersion1,
version2 = config.airpodsVersion2,
version3 = config.airpodsVersion3,
aacpManager = aacpManager,
attManager = attManager
)
}
}

View File

@@ -40,13 +40,13 @@ val cameraPackages = mutableSetOf(
var cameraOpen = false
private var currentCustomPackage: String? = null
class AppListenerService : AccessibilityService() {
class AppListenerService: AccessibilityService() {
private lateinit var prefs: android.content.SharedPreferences
private val preferenceChangeListener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key ->
if (key == "custom_camera_package") {
val newPackage = sharedPreferences.getString(key, null)
currentCustomPackage?.let { cameraPackages.remove(it) }
if (newPackage != null && newPackage.isNotBlank()) {
if (!newPackage.isNullOrBlank()) {
cameraPackages.add(newPackage)
}
currentCustomPackage = newPackage
@@ -57,7 +57,7 @@ class AppListenerService : AccessibilityService() {
super.onCreate()
prefs = getSharedPreferences("settings", MODE_PRIVATE)
val customPackage = prefs.getString("custom_camera_package", null)
if (customPackage != null && customPackage.isNotBlank()) {
if (!customPackage.isNullOrBlank()) {
cameraPackages.add(customPackage)
currentCustomPackage = customPackage
}
@@ -95,4 +95,4 @@ class AppListenerService : AccessibilityService() {
}
override fun onInterrupt() {}
}
}

View File

@@ -18,32 +18,27 @@
package me.kavishdevar.librepods.utils
import android.content.SharedPreferences
import android.os.Build
import me.kavishdevar.librepods.BuildConfig
fun isSupported(): Boolean {
if (BuildConfig.PLAY_BUILD) {
val isPixel = Build.MANUFACTURER.lowercase() == "google"
val isOppoOrOnePlus = Build.MANUFACTURER.lowercase() in listOf("oneplus", "oppo")
fun isSupported(sharedPreferences: SharedPreferences): Boolean {
val isPixel = Build.MANUFACTURER.lowercase() == "google"
val isOppoOrOnePlus = Build.MANUFACTURER.lowercase() in listOf("oneplus", "oppo")
if (isPixel) {
when (Build.VERSION.SDK_INT) {
36 -> {
return Build.ID == "CP1A.260305.018" || Build.ID == "CP1A.260405.005"
}
37 -> {
return true
}
if (isPixel) {
when (Build.VERSION.SDK_INT) {
36 -> {
return Build.ID == "CP1A.260305.018" || Build.ID == "CP1A.260405.005"
}
37 -> {
return true
}
} else if (isOppoOrOnePlus) {
return true
}
} else if (isOppoOrOnePlus) {
return true
}
return true
return if (BuildConfig.FLAVOR == "xposed") true
else sharedPreferences.getBoolean("bypass_device_check", false)
}
/*fun isSupported(): Boolean {
return true
}*/

View File

@@ -296,7 +296,7 @@ object SystemApisUtils {
)
method.invoke(device, key, value) as Boolean
} catch (e: Exception) {
Log.e("SystemApisUtils", "Failed to set metadata for key $key", e)
Log.w("SystemApisUtils", "Failed to set metadata for key $key: ${e.message}")
false
}
}

View File

@@ -0,0 +1,7 @@
package me.kavishdevar.librepods.utils
import io.github.libxposed.service.XposedService
object XposedServiceHolder {
var service: XposedService? = null
}

View File

@@ -34,7 +34,7 @@
<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="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_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>
@@ -169,7 +169,7 @@
<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="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="on">Activado</string>
<string name="camera_remote">Control Remoto de Cámara</string>

View File

@@ -34,7 +34,7 @@
<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="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_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>

View File

@@ -34,7 +34,7 @@
<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="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_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>

View File

@@ -34,7 +34,7 @@
<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="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_description">Maksimum ses yerine mevcut sesin yüzdesine göre azaltır.</string>
<string name="conversational_awareness_pause_music">Müziği Duraklat</string>

View File

@@ -34,7 +34,7 @@
<string name="airpods_not_connected">AirPods не підключені</string>
<string name="airpods_not_connected_description">Будь ласка, підключіть ваші AirPods, щоб отримати доступ до налаштувань.</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_description">Зменшує до відсотка від поточної гучності, а не від максимальної.</string>
<string name="conversational_awareness_pause_music">Призупинити Музику</string>

View File

@@ -34,7 +34,7 @@
<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="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_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>
@@ -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="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="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="on">Bật</string>
<string name="camera_remote">Điều khiển máy ảnh từ xa</string>

View File

@@ -33,7 +33,7 @@
<string name="airpods_not_connected">AirPods 未连接</string>
<string name="airpods_not_connected_description">请连接 AirPods 以访问设置。</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_description">降低到当前音量的百分比,而不是最大音量。</string>
<string name="conversational_awareness_pause_music">暂停音乐</string>

View File

@@ -34,7 +34,7 @@
<string name="airpods_not_connected">未連接 AirPods</string>
<string name="airpods_not_connected_description">請連接你的 AirPods 以存取設定。</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_description">降低至當前音量的百分比,而不是最大音量。</string>
<string name="conversational_awareness_pause_music">暫停音樂</string>

View File

@@ -34,7 +34,7 @@
<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="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_description">Reduces to a percentage of the current volume instead of the maximum volume.</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="must_be_32_hex_chars">Must be exactly 32 hex characters</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="on">On</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_adaptive_description">Dynamically adjust external noise</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>

View File

@@ -0,0 +1,5 @@
package me.kavishdevar.librepods
import android.app.Application
class LibrePodsApplication: Application()

View File

@@ -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) { }
}

View File

@@ -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)
}

View File

@@ -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
}
}

View File

@@ -24,6 +24,8 @@
#include <unistd.h>
#include <sys/stat.h>
#include <elf.h>
#include <atomic>
#include <jni.h>
#include "l2c_fcr_hook.h"
@@ -31,7 +33,7 @@ extern "C" {
#include "xz.h"
}
#define LOG_TAG "LibrePods"
#define LOG_TAG "LibrePodsHook"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
@@ -41,8 +43,10 @@ static uint8_t (*original_l2c_fcr_chk_chan_modes)(void*) = nullptr;
static tBTA_STATUS (*original_BTA_DmSetLocalDiRecord)(
tSDP_DI_RECORD*, uint32_t*) = nullptr;
static std::atomic<bool> enableSdpHook(false);
uint8_t fake_l2c_fcr_chk_chan_modes(void* p_ccb) {
LOGI("l2c_fcr_chk_chan_modes hooked");
LOGI("l2c_fcr_chk_chan_modes called");
uint8_t orig = 0;
if (original_l2c_fcr_chk_chan_modes)
orig = original_l2c_fcr_chk_chan_modes(p_ccb);
@@ -55,7 +59,11 @@ tBTA_STATUS fake_BTA_DmSetLocalDiRecord(
tSDP_DI_RECORD* p_device_info,
uint32_t* p_handle) {
LOGI("BTA_DmSetLocalDiRecord hooked");
LOGI("BTA_DmSetLocalDiRecord called");
if (original_BTA_DmSetLocalDiRecord && enableSdpHook.load(std::memory_order_relaxed)) original_BTA_DmSetLocalDiRecord(p_device_info, p_handle);
LOGI("BTA_DmSetLocalDiRecord changing vendor id and source");
if (p_device_info) {
p_device_info->vendor = 0x004C;
@@ -265,9 +273,9 @@ static bool hookLibrary(const char* libname) {
findSymbolOffset(decompressed,
"l2c_fcr_chk_chan_modes");
// uint64_t sdp_offset =
// findSymbolOffset(decompressed,
// "BTA_DmSetLocalDiRecord");
uint64_t sdp_offset =
findSymbolOffset(decompressed,
"BTA_DmSetLocalDiRecord");
if (chk_offset) {
void* target =
@@ -280,16 +288,16 @@ static bool hookLibrary(const char* libname) {
LOGI("Hooked l2c_fcr_chk_chan_modes");
}
// if (sdp_offset) {
// void* target =
// reinterpret_cast<void*>(base + sdp_offset);
//
// hook_func(target,
// (void*)fake_BTA_DmSetLocalDiRecord,
// (void**)&original_BTA_DmSetLocalDiRecord);
//
// LOGI("Hooked BTA_DmSetLocalDiRecord");
// }
if (sdp_offset) {
void* target =
reinterpret_cast<void*>(base + sdp_offset);
hook_func(target,
(void*)fake_BTA_DmSetLocalDiRecord,
(void**)&original_BTA_DmSetLocalDiRecord);
LOGI("Hooked BTA_DmSetLocalDiRecord");
}
return true;
}
@@ -315,10 +323,16 @@ extern "C"
[[gnu::visibility("default")]]
[[gnu::used]]
NativeOnModuleLoaded native_init(const NativeAPIEntries* entries) {
LOGI("LibrePods initialized");
hook_func = (HookFunType)entries->hook_func;
LOGI("LibrePodsNativeHook initialized, sdp hook enabled: %d", enableSdpHook.load(std::memory_order_relaxed));
return on_library_loaded;
}
extern "C"
JNIEXPORT void JNICALL
Java_me_kavishdevar_librepods_utils_NativeBridge_setSdpHook(
JNIEnv*, jobject thiz, jboolean enable) {
enableSdpHook.store(enable, std::memory_order_relaxed);
LOGI("sdp hook enabled: %d", enable);
}

View File

@@ -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
}
}

View File

@@ -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) }
}
}

View File

@@ -2,148 +2,125 @@ package me.kavishdevar.librepods.utils
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.ApplicationInfo
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.widget.ImageView
import androidx.core.net.toUri
import io.github.libxposed.api.XposedInterface
import io.github.libxposed.api.XposedInterface.AfterHookCallback
import io.github.libxposed.api.XposedModule
import io.github.libxposed.api.XposedModuleInterface
import io.github.libxposed.api.XposedModuleInterface.ModuleLoadedParam
import io.github.libxposed.api.annotations.AfterInvocation
import io.github.libxposed.api.annotations.XposedHooker
import kotlin.jvm.java
import io.github.libxposed.api.XposedModuleInterface.PackageLoadedParam
private const val TAG = "LibrePodsHook"
private const val TAG = "AirPodsHook"
private lateinit var module: KotlinModule
@SuppressLint("DiscouragedApi", "PrivateApi")
class KotlinModule(base: XposedInterface, param: ModuleLoadedParam): XposedModule(base, param) {
init {
Log.i(TAG, "AirPodsHook module initialized at :: ${param.processName}")
module = this
class KotlinModule: XposedModule() {
override fun onModuleLoaded(param: ModuleLoadedParam) {
log(Log.INFO, TAG, "module initialized at :: ${param.processName}")
log(Log.INFO, TAG, "framework: $frameworkName($frameworkVersionCode) API $apiVersion")
}
override fun onPackageLoaded(param: XposedModuleInterface.PackageLoadedParam) {
super.onPackageLoaded(param)
Log.i(TAG, "onPackageLoaded :: ${param.packageName}")
override fun onPackageLoaded(param: PackageLoadedParam) {
log(Log.INFO, TAG, "onPackageLoaded :: ${param.packageName}")
if (param.packageName == "com.google.android.bluetooth" || param.packageName == "com.android.bluetooth") {
Log.i(TAG, "Bluetooth app detected, hooking l2c_fcr_chk_chan_modes")
log(Log.INFO, TAG, "Bluetooth app detected, hooking l2c_fcr_chk_chan_modes")
try {
if (param.isFirstPackage) {
Log.i(TAG, "Loading native library for Bluetooth hook")
log(Log.INFO, TAG, "Loading native library for Bluetooth hook")
System.loadLibrary("l2c_fcr_hook")
Log.i(TAG, "Native library loaded successfully")
val remotePrefValue = getRemotePreferences("me.kavishdevar.librepods").getBoolean("vendor_id_hook", false)
log(Log.INFO, TAG, "sdp hook enabled (remote pref): $remotePrefValue")
NativeBridge.setSdpHook(remotePrefValue)
log(Log.INFO, TAG, "Native library loaded successfully")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to load native library: ${e.message}", e)
log(Log.ERROR, TAG, "Failed to load native library: ${e.message}")
}
}
if (param.packageName == "com.google.android.settings") {
Log.i(TAG, "Settings app detected, hooking Bluetooth icon handling")
try {
val headerControllerClass = param.classLoader.loadClass(
"com.google.android.settings.bluetooth.AdvancedBluetoothDetailsHeaderController")
val updateIconMethod = headerControllerClass.getDeclaredMethod(
"updateIcon",
ImageView::class.java,
String::class.java)
hook(updateIconMethod, BluetoothIconHooker::class.java)
Log.i(TAG, "Successfully hooked updateIcon method in Bluetooth settings")
} catch (e: Exception) {
Log.e(TAG, "Failed to hook Bluetooth icon handler: ${e.message}", e)
}
hookSettingsController(param, "com.google.android.settings.bluetooth.AdvancedBluetoothDetailsHeaderController")
}
if (param.packageName == "com.android.settings") {
Log.i(TAG, "Settings app detected, hooking Bluetooth icon handling")
try {
val headerControllerClass = param.classLoader.loadClass(
"com.android.settings.bluetooth.AdvancedBluetoothDetailsHeaderController")
val updateIconMethod = headerControllerClass.getDeclaredMethod(
"updateIcon",
ImageView::class.java,
String::class.java)
hook(updateIconMethod, BluetoothIconHooker::class.java)
Log.i(TAG, "Successfully hooked updateIcon method in Bluetooth settings")
} catch (e: Exception) {
Log.e(TAG, "Failed to hook Bluetooth icon handler: ${e.message}", e)
}
hookSettingsController(param, "com.android.settings.bluetooth.AdvancedBluetoothDetailsHeaderController")
}
}
@XposedHooker
class BluetoothIconHooker : XposedInterface.Hooker {
companion object {
@JvmStatic
@AfterInvocation
fun afterUpdateIcon(callback: AfterHookCallback) {
Log.i(TAG, "BluetoothIconHooker called with args: ${callback.args.joinToString(", ")}")
private fun hookSettingsController(param: PackageLoadedParam, className: String) {
log(Log.INFO, TAG, "Settings app detected, hooking Bluetooth icon handling")
try {
val headerControllerClass = Class.forName(className, false, param.defaultClassLoader)
val updateIconMethod = headerControllerClass.getDeclaredMethod(
"updateIcon",
ImageView::class.java,
String::class.java
)
hook(updateIconMethod).intercept { chain ->
try {
val imageView = callback.args[0] as ImageView
val iconUri = callback.args[1] as String
log(Log.INFO, TAG, "Bluetooth icon hook called with args: ${chain.args.joinToString(", ")}")
val imageView = chain.args[0] as? ImageView
val iconUri = chain.args[1] as? String
if (imageView == null || iconUri == null) {
return@intercept chain.proceed()
}
val uri = iconUri.toUri()
if (uri.toString().startsWith("android.resource://me.kavishdevar.librepods")) {
Log.i(TAG, "Handling AirPods icon URI: $uri")
if (!uri.toString().startsWith("android.resource://me.kavishdevar.librepods")) {
return@intercept chain.proceed()
}
log(Log.INFO, TAG, "Handling AirPods icon URI: $uri")
Handler(Looper.getMainLooper()).post {
try {
val context = imageView.context
val packageName = uri.authority ?: return@post
val packageContext = context.createPackageContext(
packageName,
Context.CONTEXT_IGNORE_SECURITY
)
android.os.Handler(android.os.Looper.getMainLooper()).post {
try {
val packageName = uri.authority
val packageContext = context.createPackageContext(
packageName,
Context.CONTEXT_IGNORE_SECURITY
val resPath = uri.pathSegments
if (resPath.size >= 2 && resPath[0] == "drawable") {
val resourceName = resPath[1]
val resourceId = packageContext.resources.getIdentifier(
resourceName, "drawable", packageName
)
if (resourceId != 0) {
val drawable = packageContext.resources.getDrawable(
resourceId, packageContext.theme
)
val resPath = uri.pathSegments
if (resPath.size >= 2 && resPath[0] == "drawable") {
val resourceName = resPath[1]
val resourceId = packageContext.resources.getIdentifier(
resourceName, "drawable", packageName
)
if (resourceId != 0) {
val drawable = packageContext.resources.getDrawable(
resourceId, packageContext.theme
)
imageView.setImageDrawable(drawable)
imageView.alpha = 1.0f
callback.result = null
Log.i(TAG, "Successfully loaded icon from resource: $resourceName")
} else {
Log.e(TAG, "Resource not found: $resourceName")
}
}
} catch (e: Exception) {
Log.e(TAG, "Error loading resource from URI $uri: ${e.message}")
imageView.setImageDrawable(drawable)
imageView.alpha = 1.0f
log(Log.INFO, TAG, "Successfully loaded icon from resource: $resourceName")
} else {
log(Log.ERROR, TAG, "Resource not found: $resourceName")
}
}
} catch (e: Exception) {
Log.e(TAG, "Error accessing context: ${e.message}")
log(Log.ERROR, TAG, "Error loading resource from URI $uri: ${e.message}")
}
}
null
} catch (e: Exception) {
Log.e(TAG, "Error in BluetoothIconHooker: ${e.message}")
e.printStackTrace()
log(Log.ERROR, TAG, "Error in Bluetooth icon hook: ${e.message}")
chain.proceed()
}
}
log(Log.INFO, TAG, "Successfully hooked updateIcon method in Bluetooth settings")
} catch (e: Exception) {
log(Log.ERROR, TAG, "Failed to hook Bluetooth icon handler: ${e.message}")
}
}
override fun getApplicationInfo(): ApplicationInfo {
return super.applicationInfo
}
}
object NativeBridge {
external fun setSdpHook(enabled: Boolean)
}

View File

@@ -1,3 +1,3 @@
minApiVersion=100
targetApiVersion=100
minApiVersion=101
targetApiVersion=101
staticScope=true

View File

@@ -17,6 +17,7 @@ materialIconsCore = "1.7.8"
backdrop = "2.0.0-alpha03"
billing = "8.3.0"
hilt = "2.59.2"
xposed = "101.0.0"
[libraries]
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" }
@@ -44,6 +45,8 @@ backdrop = { group = "io.github.kyant0", name = "backdrop", version.ref = "backd
billing = { group = "com.android.billingclient", name = "billing-ktx", version.ref = "billing" }
hilt = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
libxposed-api = { group = "io.github.libxposed", name = "api", version.ref = "xposed" }
libxposed-service = { group = "io.github.libxposed", name = "service", version.ref = "xposed" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }