android: 'testing' for Play relase

yeah... no big changes, unfortunately
This commit is contained in:
Kavish Devar
2026-04-20 04:11:16 +05:30
parent e2308387fa
commit 7d58fb502a
89 changed files with 3669 additions and 4044 deletions
+77 -19
View File
@@ -1,42 +1,74 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.util.Properties
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.aboutLibraries) alias(libs.plugins.aboutLibraries)
// alias(libs.plugins.hilt)
id("kotlin-parcelize") id("kotlin-parcelize")
} }
val props = Properties().apply {
load(rootProject.file("local.properties").inputStream())
}
android { android {
signingConfigs {
create("release") {
storeFile = file(props["RELEASE_STORE_FILE"] as String)
storePassword = props["RELEASE_STORE_PASSWORD"] as String
keyAlias = props["RELEASE_KEY_ALIAS"] as String
keyPassword = props["RELEASE_KEY_PASSWORD"] as String
}
}
namespace = "me.kavishdevar.librepods" namespace = "me.kavishdevar.librepods"
compileSdk = 36 compileSdk = 37
defaultConfig { defaultConfig {
applicationId = "me.kavishdevar.librepods" applicationId = "me.kavishdevar.librepods"
minSdk = 33 minSdk = 36
targetSdk = 36 targetSdk = 37
versionCode = 10 versionCode = 21
versionName = "0.2.0-alpha.2" versionName = "0.2.0-beta.1"
} }
buildTypes { buildTypes {
release { release {
isMinifyEnabled = false isMinifyEnabled = true
isShrinkResources = true
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"
) )
externalNativeBuild {
cmake {
arguments += "-DCMAKE_BUILD_TYPE=Release"
}
}
signingConfig = signingConfigs.getByName("release")
}
debug {
signingConfig = signingConfigs.getByName("release")
}
create("playRelease") {
initWith(getByName("release"))
versionNameSuffix = "-play"
buildConfigField("Boolean", "PLAY_BUILD", "true")
} }
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8 sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_21
} }
kotlinOptions { kotlin {
jvmTarget = "1.8" compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
} }
buildFeatures { buildFeatures {
compose = true compose = true
viewBinding = true viewBinding = true
buildConfig = true
} }
androidResources { androidResources {
generateLocaleConfig = true generateLocaleConfig = true
@@ -49,17 +81,41 @@ android {
} }
sourceSets { sourceSets {
getByName("main") { getByName("main") {
res.srcDirs("src/main/res", "src/main/res-apple") res.directories+="src/main/res-apple"
}
}
ndkVersion = "30.0.14904198"
flavorDimensions += "env"
productFlavors {
create("normal") {
dimension = "env"
externalNativeBuild {
cmake {
arguments += "-DIS_XPOSED=OFF"
}
}
}
create("xposed") {
dimension = "env"
externalNativeBuild {
cmake {
arguments += "-DIS_XPOSED=ON"
}
}
applicationIdSuffix = ".xposed"
} }
} }
} }
dependencies { dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.accompanist.permissions) implementation(libs.accompanist.permissions)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui) implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.ui.tooling.preview)
@@ -71,15 +127,17 @@ dependencies {
implementation(libs.haze.materials) implementation(libs.haze.materials)
implementation(libs.androidx.dynamicanimation) implementation(libs.androidx.dynamicanimation)
implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.material.icons.core)
implementation(libs.billing)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.compose.foundation.layout) implementation(libs.androidx.compose.foundation.layout)
implementation(libs.aboutlibraries) implementation(libs.aboutlibraries)
implementation(libs.aboutlibraries.compose.m3) implementation(libs.aboutlibraries.compose.m3)
// compileOnly(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar")))) implementation(libs.backdrop)
// implementation(fileTree(mapOf("dir" to "lib", "include" to listOf("*.aar")))) implementation(libs.hilt)
compileOnly(files("libs/libxposed-api-100.aar")) // implementation(libs.hilt.compiler)
debugImplementation(files("libs/backdrop-debug.aar")) add("xposedCompileOnly", files("libs/libxposed-api-100.aar"))
releaseImplementation(files("libs/backdrop-release.aar")) add("playReleaseImplementation", libs.billing)
} }
aboutLibraries { aboutLibraries {
Binary file not shown.
Binary file not shown.
+4 -1
View File
@@ -18,4 +18,7 @@
# If you keep the line number information, uncomment this to # If you keep the line number information, uncomment this to
# hide the original source file name. # hide the original source file name.
#-renamesourcefileattribute SourceFile #-renamesourcefileattribute SourceFile
-keep class androidx.compose.** { *; }
-dontwarn androidx.compose.**
+21 -21
View File
@@ -14,9 +14,9 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" /> <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission <!-- <uses-permission-->
android:name="android.permission.BLUETOOTH_PRIVILEGED" <!-- android:name="android.permission.BLUETOOTH_PRIVILEGED"-->
tools:ignore="ProtectedPermissions" /> <!-- tools:ignore="ProtectedPermissions" />-->
<uses-permission android:name="android.permission.BLUETOOTH" /> <uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission <uses-permission
android:name="android.permission.BLUETOOTH_SCAN" android:name="android.permission.BLUETOOTH_SCAN"
@@ -26,13 +26,14 @@
<uses-permission android:name="android.permission.READ_PHONE_STATE" /> <uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" /> <uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.INTERNET" /> <!-- <uses-permission android:name="android.permission.INTERNET" />-->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" /> tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" <!-- <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"-->
android:maxSdkVersion="30" /> <!-- android:maxSdkVersion="30" />-->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" <!-- <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"-->
android:maxSdkVersion="30" /> <!-- android:maxSdkVersion="30" />-->
<uses-permission android:name="com.android.vending.BILLING" />
<application <application
android:allowBackup="true" android:allowBackup="true"
@@ -45,8 +46,7 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.LibrePods" android:theme="@style/Theme.LibrePods"
android:description="@string/app_description" android:description="@string/app_description"
tools:ignore="UnusedAttribute" tools:ignore="UnusedAttribute" >
tools:targetApi="31">
<receiver <receiver
android:name=".widgets.NoiseControlWidget" android:name=".widgets.NoiseControlWidget"
android:exported="false"> android:exported="false">
@@ -114,17 +114,17 @@
<action android:name="android.service.quicksettings.action.QS_TILE" /> <action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter> </intent-filter>
</service> </service>
<service <!-- <service-->
android:name=".services.AppListenerService" <!-- android:name=".services.AppListenerService"-->
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE" <!-- android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"-->
android:exported="true"> <!-- android:exported="true">-->
<intent-filter> <!-- <intent-filter>-->
<action android:name="android.accessibilityservice.AccessibilityService" /> <!-- <action android:name="android.accessibilityservice.AccessibilityService" />-->
</intent-filter> <!-- </intent-filter>-->
<meta-data <!-- <meta-data-->
android:name="android.accessibilityservice" <!-- android:name="android.accessibilityservice"-->
android:resource="@xml/app_listener_service_config" /> <!-- android:resource="@xml/app_listener_service_config" />-->
</service> <!-- </service>-->
<receiver <receiver
android:name=".receivers.BootReceiver" android:name=".receivers.BootReceiver"
android:enabled="true" android:enabled="true"
+43 -34
View File
@@ -3,52 +3,61 @@ cmake_minimum_required(VERSION 3.22.1)
project("l2c_fcr_hook") project("l2c_fcr_hook")
set(CMAKE_CXX_STANDARD 23) set(CMAKE_CXX_STANDARD 23)
add_library(l2c_fcr_hook SHARED option(IS_XPOSED "Build Xposed components" OFF)
l2c_fcr_hook.cpp
xz/xz_crc32.c add_library(bluetooth_socket SHARED
xz/xz_crc64.c bluetooth_socket.cpp
xz/xz_sha256.c
xz/xz_dec_stream.c
xz/xz_dec_lzma2.c
xz/xz_dec_bcj.c
) )
add_library(socket_private_constructor SHARED target_compile_options(bluetooth_socket PRIVATE
socket_private_constructor.cpp
)
target_include_directories(l2c_fcr_hook PRIVATE
xz
)
target_compile_definitions(l2c_fcr_hook PRIVATE
XZ_DEC_X86
XZ_DEC_ARM
XZ_DEC_ARMTHUMB
XZ_DEC_ARM64
XZ_DEC_ANY_CHECK
XZ_USE_CRC64
XZ_USE_SHA256
XZ_DEC_CONCATENATED
)
target_compile_options(socket_private_constructor PRIVATE
-O2 -O2
-fvisibility=hidden -fvisibility=hidden
) )
target_link_options(socket_private_constructor PRIVATE target_link_options(bluetooth_socket PRIVATE
-Wl,--strip-all -Wl,--strip-all
-Wl,--gc-sections -Wl,--gc-sections
) )
target_link_libraries(l2c_fcr_hook target_link_libraries(bluetooth_socket
android android
log log
) )
target_link_libraries(socket_private_constructor if(IS_XPOSED)
android
log set(XPOSED_SRC_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../xposed/cpp)
)
add_library(l2c_fcr_hook SHARED
${XPOSED_SRC_DIR}/l2c_fcr_hook.cpp
${XPOSED_SRC_DIR}/xz/xz_crc32.c
${XPOSED_SRC_DIR}/xz/xz_crc64.c
${XPOSED_SRC_DIR}/xz/xz_sha256.c
${XPOSED_SRC_DIR}/xz/xz_dec_stream.c
${XPOSED_SRC_DIR}/xz/xz_dec_lzma2.c
${XPOSED_SRC_DIR}/xz/xz_dec_bcj.c
)
target_include_directories(l2c_fcr_hook PRIVATE
${XPOSED_SRC_DIR}
${XPOSED_SRC_DIR}/xz
)
target_compile_definitions(l2c_fcr_hook PRIVATE
XZ_DEC_X86
XZ_DEC_ARM
XZ_DEC_ARMTHUMB
XZ_DEC_ARM64
XZ_DEC_ANY_CHECK
XZ_USE_CRC64
XZ_USE_SHA256
XZ_DEC_CONCATENATED
)
target_link_libraries(l2c_fcr_hook
android
log
)
endif()
@@ -20,6 +20,8 @@
package me.kavishdevar.librepods package me.kavishdevar.librepods
// import me.kavishdevar.librepods.screens.Onboarding
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.ComponentName import android.content.ComponentName
@@ -27,13 +29,11 @@ import android.content.Context
import android.content.Context.MODE_PRIVATE import android.content.Context.MODE_PRIVATE
import android.content.Intent import android.content.Intent
import android.content.ServiceConnection import android.content.ServiceConnection
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.IBinder import android.os.IBinder
import android.provider.Settings import android.provider.Settings
import android.util.Log import android.util.Log
import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
@@ -73,7 +73,6 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -88,7 +87,6 @@ import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.platform.LocalWindowInfo
@@ -103,6 +101,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.edit import androidx.core.content.edit
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
@@ -112,9 +111,13 @@ import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberMultiplePermissionsState import com.google.accompanist.permissions.rememberMultiplePermissionsState
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dagger.hilt.android.AndroidEntryPoint
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.billing.BillingManager
import me.kavishdevar.librepods.billing.BillingProviderFactory
import me.kavishdevar.librepods.composables.StyledIconButton import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.constants.AirPodsNotifications import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.data.ControlCommandRepository
import me.kavishdevar.librepods.screens.AccessibilitySettingsScreen import me.kavishdevar.librepods.screens.AccessibilitySettingsScreen
import me.kavishdevar.librepods.screens.AdaptiveStrengthScreen import me.kavishdevar.librepods.screens.AdaptiveStrengthScreen
import me.kavishdevar.librepods.screens.AirPodsSettingsScreen import me.kavishdevar.librepods.screens.AirPodsSettingsScreen
@@ -126,30 +129,33 @@ import me.kavishdevar.librepods.screens.HearingAidAdjustmentsScreen
import me.kavishdevar.librepods.screens.HearingAidScreen import me.kavishdevar.librepods.screens.HearingAidScreen
import me.kavishdevar.librepods.screens.HearingProtectionScreen import me.kavishdevar.librepods.screens.HearingProtectionScreen
import me.kavishdevar.librepods.screens.LongPress import me.kavishdevar.librepods.screens.LongPress
// import me.kavishdevar.librepods.screens.Onboarding
import me.kavishdevar.librepods.screens.OpenSourceLicensesScreen import me.kavishdevar.librepods.screens.OpenSourceLicensesScreen
import me.kavishdevar.librepods.screens.RenameScreen import me.kavishdevar.librepods.screens.RenameScreen
import me.kavishdevar.librepods.screens.TransparencySettingsScreen import me.kavishdevar.librepods.screens.TransparencySettingsScreen
import me.kavishdevar.librepods.screens.TroubleshootingScreen
import me.kavishdevar.librepods.screens.UpdateHearingTestScreen import me.kavishdevar.librepods.screens.UpdateHearingTestScreen
import me.kavishdevar.librepods.screens.VersionScreen import me.kavishdevar.librepods.screens.VersionScreen
import me.kavishdevar.librepods.services.AirPodsService import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
// import me.kavishdevar.librepods.utils.RadareOffsetFinder import me.kavishdevar.librepods.utils.isSupported
import kotlin.io.encoding.Base64 import me.kavishdevar.librepods.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.viewmodel.AppSettingsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
lateinit var serviceConnection: ServiceConnection lateinit var serviceConnection: ServiceConnection
lateinit var connectionStatusReceiver: BroadcastReceiver lateinit var connectionStatusReceiver: BroadcastReceiver
@AndroidEntryPoint
@ExperimentalMaterial3Api @ExperimentalMaterial3Api
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
companion object { companion object {
init { init {
System.loadLibrary("l2c_fcr_hook") if (BuildConfig.FLAVOR == "xposed") {
System.loadLibrary("l2c_fcr_hook")
}
} }
} }
@ExperimentalHazeMaterialsApi
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
@@ -159,8 +165,6 @@ class MainActivity : ComponentActivity() {
Main() Main()
} }
} }
handleIncomingIntent(intent)
} }
override fun onDestroy() { override fun onDestroy() {
@@ -195,69 +199,6 @@ class MainActivity : ComponentActivity() {
} }
super.onStop() super.onStop()
} }
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleIncomingIntent(intent)
}
private fun handleIncomingIntent(intent: Intent) {
val data: Uri? = intent.data
if (data != null && data.scheme == "librepods") {
when (data.host) {
"add-magic-keys" -> {
val queryParams = data.queryParameterNames
queryParams.forEach { param ->
val value = data.getQueryParameter(param)
Log.d("LibrePods", "Parameter: $param = $value")
}
handleAddMagicKeys(data)
}
}
}
}
private fun handleAddMagicKeys(uri: Uri) {
val sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE)
val irkHex = uri.getQueryParameter("irk")
val encKeyHex = uri.getQueryParameter("enc_key")
try {
if (irkHex != null && validateHexInput(irkHex)) {
val irkBytes = hexStringToByteArray(irkHex)
val irkBase64 = Base64.encode(irkBytes)
sharedPreferences.edit {putString("IRK", irkBase64)}
}
if (encKeyHex != null && validateHexInput(encKeyHex)) {
val encKeyBytes = hexStringToByteArray(encKeyHex)
val encKeyBase64 = Base64.encode(encKeyBytes)
sharedPreferences.edit { putString("ENC_KEY", encKeyBase64)}
}
Toast.makeText(this, "Magic keys added successfully!", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Toast.makeText(this, "Error processing magic keys: ${e.message}", Toast.LENGTH_LONG).show()
}
}
private fun validateHexInput(input: String): Boolean {
val hexPattern = Regex("^[0-9a-fA-F]{32}$")
return hexPattern.matches(input)
}
private fun hexStringToByteArray(hex: String): ByteArray {
val result = ByteArray(16)
for (i in 0 until 16) {
val hexByte = hex.substring(i * 2, i * 2 + 2)
result[i] = hexByte.toInt(16).toByte()
}
return result
}
} }
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@@ -265,12 +206,34 @@ class MainActivity : ComponentActivity() {
@OptIn(ExperimentalPermissionsApi::class) @OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
fun Main() { fun Main() {
if (!isSupported()) {
Box(
modifier = Modifier
.fillMaxSize()
.background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)),
contentAlignment = Alignment.Center
) {
Text(
text = "Not supported. Device Info: BUILD_ID: ${Build.ID} SDK_INT_FULL: ${Build.VERSION.SDK_INT_FULL}, MANUFACTURER: ${Build.MANUFACTURER}.\nCheck out the repository for more info.",
color = if (isSystemInDarkTheme()) Color.White else Color.Black,
textAlign = TextAlign.Center,
modifier = Modifier.padding(16.dp)
)
}
return
}
val isConnected = remember { mutableStateOf(false) } val isConnected = remember { mutableStateOf(false) }
val isRemotelyConnected = remember { mutableStateOf(false) }
// val hookAvailable = RadareOffsetFinder(LocalContext.current).isHookOffsetAvailable()
val context = LocalContext.current val context = LocalContext.current
var canDrawOverlays by remember { mutableStateOf(Settings.canDrawOverlays(context)) } var canDrawOverlays by remember { mutableStateOf(Settings.canDrawOverlays(context)) }
val overlaySkipped = remember { mutableStateOf(context.getSharedPreferences("settings", MODE_PRIVATE).getBoolean("overlay_permission_skipped", false)) } val overlaySkipped = remember {
mutableStateOf(
context.getSharedPreferences("settings", MODE_PRIVATE)
.getBoolean("overlay_permission_skipped", false)
)
}
BillingManager.provider = BillingProviderFactory.create(context)
val bluetoothPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val bluetoothPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
listOf( listOf(
@@ -297,23 +260,33 @@ fun Main() {
val permissionState = rememberMultiplePermissionsState( val permissionState = rememberMultiplePermissionsState(
permissions = allPermissions permissions = allPermissions
) )
val airPodsService = remember { mutableStateOf<AirPodsService?>(null) } val airPodsService = remember { mutableStateOf<AirPodsService?>(null) }
val viewModel = remember(airPodsService.value) {
airPodsService.value?.let { service ->
AirPodsViewModel(
service = service,
sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE),
controlRepo = ControlCommandRepository(service.aacpManager),
appContext = context.applicationContext
)
}
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
canDrawOverlays = Settings.canDrawOverlays(context) canDrawOverlays = Settings.canDrawOverlays(context)
} }
if (permissionState.allPermissionsGranted && (canDrawOverlays || overlaySkipped.value)) { if (permissionState.allPermissionsGranted && (canDrawOverlays || overlaySkipped.value)) {
val context = LocalContext.current
val navController = rememberNavController() val navController = rememberNavController()
Box ( Box(
modifier = Modifier modifier = Modifier.fillMaxSize()
.fillMaxSize() ) {
){
val backButtonBackdrop = rememberLayerBackdrop() val backButtonBackdrop = rememberLayerBackdrop()
Box ( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)) .background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7))
@@ -321,129 +294,125 @@ fun Main() {
) { ) {
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = "settings", // if (hookAvailable) "settings" else "onboarding", startDestination = "settings",
enterTransition = { enterTransition = {
slideInHorizontally( slideInHorizontally(
initialOffsetX = { it }, initialOffsetX = { it }, animationSpec = tween(durationMillis = 300)
animationSpec = tween(durationMillis = 300) )
) // + fadeIn(animationSpec = tween(durationMillis = 300))
}, },
exitTransition = { exitTransition = {
slideOutHorizontally( slideOutHorizontally(
targetOffsetX = { -it/4 }, targetOffsetX = { -it / 4 }, animationSpec = tween(durationMillis = 300)
animationSpec = tween(durationMillis = 300) )
) // + fadeOut(animationSpec = tween(durationMillis = 150))
}, },
popEnterTransition = { popEnterTransition = {
slideInHorizontally( slideInHorizontally(
initialOffsetX = { -it/4 }, initialOffsetX = { -it / 4 },
animationSpec = tween(durationMillis = 300) animationSpec = tween(durationMillis = 300)
) // + fadeIn(animationSpec = tween(durationMillis = 300)) )
}, },
popExitTransition = { popExitTransition = {
slideOutHorizontally( slideOutHorizontally(
targetOffsetX = { it }, targetOffsetX = { it }, animationSpec = tween(durationMillis = 300)
animationSpec = tween(durationMillis = 300) )
) // + fadeOut(animationSpec = tween(durationMillis = 150)) }) {
}
) {
composable("settings") { composable("settings") {
if (airPodsService.value != null) { if (viewModel != null) AirPodsSettingsScreen(viewModel, navController)
AirPodsSettingsScreen(
dev = airPodsService.value?.device,
service = airPodsService.value!!,
navController = navController,
isConnected = isConnected.value,
isRemotelyConnected = isRemotelyConnected.value
)
}
} }
composable("debug") { composable("debug") {
DebugScreen(navController = navController) DebugScreen(navController = navController)
} }
composable("long_press/{bud}") { navBackStackEntry -> composable("long_press/{bud}") { navBackStackEntry ->
LongPress( if (viewModel != null) LongPress(
navController = navController, viewModel = viewModel,
name = navBackStackEntry.arguments?.getString("bud")!! name = navBackStackEntry.arguments?.getString("bud")!!
) )
} }
composable("rename") { composable("rename") {
RenameScreen(navController) if (viewModel != null) RenameScreen(viewModel)
} }
composable("app_settings") { composable("app_settings") {
AppSettingsScreen(navController) val appSettingsViewModel: AppSettingsViewModel = viewModel()
} AppSettingsScreen(navController, appSettingsViewModel)
composable("troubleshooting") {
TroubleshootingScreen(navController)
} }
// composable("troubleshooting") {
// TroubleshootingScreen(navController)
// }
composable("head_tracking") { composable("head_tracking") {
HeadTrackingScreen() if (viewModel != null) HeadTrackingScreen(viewModel)
} }
/*composable("onboarding") {
Onboarding(navController, context)
}*/
composable("accessibility") { composable("accessibility") {
AccessibilitySettingsScreen(navController) if (viewModel != null) AccessibilitySettingsScreen(viewModel, navController)
} }
composable("transparency_customization") { composable("transparency_customization") {
TransparencySettingsScreen(navController) if (viewModel != null) TransparencySettingsScreen(viewModel)
} }
composable("hearing_aid") { composable("hearing_aid") {
HearingAidScreen(navController) if (viewModel != null) HearingAidScreen(viewModel, navController)
} }
composable("hearing_aid_adjustments") { composable("hearing_aid_adjustments") {
HearingAidAdjustmentsScreen(navController) if (viewModel != null) HearingAidAdjustmentsScreen(viewModel)
} }
composable("adaptive_strength") { composable("adaptive_strength") {
AdaptiveStrengthScreen(navController) if (viewModel != null) AdaptiveStrengthScreen(viewModel)
} }
composable("camera_control") { composable("camera_control") {
CameraControlScreen(navController) if (viewModel != null) CameraControlScreen(viewModel)
} }
composable("open_source_licenses") { composable("open_source_licenses") {
OpenSourceLicensesScreen(navController) OpenSourceLicensesScreen(navController)
} }
composable("update_hearing_test") { composable("update_hearing_test") {
UpdateHearingTestScreen(navController) if (viewModel != null) UpdateHearingTestScreen()
} }
composable("version_info") { composable("version_info") {
VersionScreen(navController) if (viewModel != null) VersionScreen(viewModel)
} }
composable("hearing_protection") { composable("hearing_protection") {
HearingProtectionScreen(navController) if (viewModel != null) HearingProtectionScreen(viewModel)
} }
} }
} }
val showBackButton = remember{ mutableStateOf(false) } val showBackButton = remember { mutableStateOf(false) }
LaunchedEffect(navController) { LaunchedEffect(navController) {
navController.addOnDestinationChangedListener { _, destination, _ -> navController.addOnDestinationChangedListener { _, destination, _ ->
showBackButton.value = destination.route != "settings" // && destination.route != "onboarding" showBackButton.value =
Log.d("MainActivity", "Navigated to ${destination.route}, showBackButton: ${showBackButton.value}") destination.route != "settings" // && destination.route != "onboarding"
Log.d(
"MainActivity",
"Navigated to ${destination.route}, showBackButton: ${showBackButton.value}"
)
} }
} }
AnimatedVisibility( AnimatedVisibility(
visible = showBackButton.value, visible = showBackButton.value,
enter = fadeIn(animationSpec = tween()) + scaleIn(initialScale = 0f, animationSpec = tween()), enter = fadeIn(animationSpec = tween()) + scaleIn(
exit = fadeOut(animationSpec = tween()) + scaleOut(targetScale = 0.5f, animationSpec = tween(100)), initialScale = 0f,
animationSpec = tween()
),
exit = fadeOut(animationSpec = tween()) + scaleOut(
targetScale = 0.5f,
animationSpec = tween(100)
),
modifier = Modifier modifier = Modifier
.align(Alignment.TopStart) .align(Alignment.TopStart)
.padding( .padding(
start = 8.dp, start = 8.dp, top = (LocalWindowInfo.current.containerSize.width * 0.05f).dp
top = (LocalWindowInfo.current.containerSize.width * 0.05f).dp
) )
) { ) {
StyledIconButton( StyledIconButton(
onClick = { navController.popBackStack() }, onClick = { navController.popBackStack() },
icon = "􀯶", icon = "􀯶",
darkMode = isSystemInDarkTheme(), backdrop = backButtonBackdrop
backdrop = backButtonBackdrop )
)
} }
} }
context.startForegroundService(Intent(context, AirPodsService::class.java))
serviceConnection = remember { serviceConnection = remember {
object : ServiceConnection { object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) { override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
@@ -457,17 +426,20 @@ fun Main() {
} }
} }
context.bindService(Intent(context, AirPodsService::class.java), serviceConnection, Context.BIND_AUTO_CREATE) context.bindService(
Intent(context, AirPodsService::class.java),
serviceConnection,
Context.BIND_AUTO_CREATE
)
if (airPodsService.value?.isConnectedLocally == true) { if (airPodsService.value?.isConnected() == true) {
isConnected.value = true isConnected.value = true
} }
} else { } else {
PermissionsScreen( PermissionsScreen(
permissionState = permissionState, permissionState = permissionState,
canDrawOverlays = canDrawOverlays, canDrawOverlays = canDrawOverlays,
onOverlaySettingsReturn = { canDrawOverlays = Settings.canDrawOverlays(context) } onOverlaySettingsReturn = { canDrawOverlays = Settings.canDrawOverlays(context) })
)
} }
} }
@@ -490,13 +462,9 @@ fun PermissionsScreen(
val infiniteTransition = rememberInfiniteTransition(label = "pulse") val infiniteTransition = rememberInfiniteTransition(label = "pulse")
val pulseScale by infiniteTransition.animateFloat( val pulseScale by infiniteTransition.animateFloat(
initialValue = 1f, initialValue = 1f, targetValue = 1.05f, animationSpec = infiniteRepeatable(
targetValue = 1.05f, animation = tween(1000), repeatMode = RepeatMode.Reverse
animationSpec = infiniteRepeatable( ), label = "pulse scale"
animation = tween(1000),
repeatMode = RepeatMode.Reverse
),
label = "pulse scale"
) )
Column( Column(
@@ -504,18 +472,15 @@ fun PermissionsScreen(
.fillMaxSize() .fillMaxSize()
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7)) .background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7))
.padding(16.dp) .padding(16.dp)
.verticalScroll(scrollState), .verticalScroll(scrollState), horizontalAlignment = Alignment.CenterHorizontally
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(180.dp), .height(180.dp), contentAlignment = Alignment.Center
contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = "\uDBC2\uDEB7", text = "\uDBC2\uDEB7", style = TextStyle(
style = TextStyle(
fontSize = 48.sp, fontSize = 48.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontFamily = FontFamily(Font(R.font.sf_pro)), fontFamily = FontFamily(Font(R.font.sf_pro)),
@@ -551,29 +516,25 @@ fun PermissionsScreen(
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
text = "Permission Required", text = "Permission Required", style = TextStyle(
style = TextStyle(
fontSize = 24.sp, fontSize = 24.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontFamily = FontFamily(Font(R.font.sf_pro)), fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor, color = textColor,
textAlign = TextAlign.Center textAlign = TextAlign.Center
), ), modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth()
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = stringResource(R.string.permissions_required), text = stringResource(R.string.permissions_required), style = TextStyle(
style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)), fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor.copy(alpha = 0.7f), color = textColor.copy(alpha = 0.7f),
textAlign = TextAlign.Center textAlign = TextAlign.Center
), ), modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth()
) )
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
@@ -746,8 +707,7 @@ fun PermissionCard(
if (isGranted) accentColor.copy(alpha = 0.15f) else Color.Gray.copy( if (isGranted) accentColor.copy(alpha = 0.15f) else Color.Gray.copy(
alpha = 0.15f alpha = 0.15f
) )
), ), contentAlignment = Alignment.Center
contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
imageVector = icon, imageVector = icon,
@@ -763,8 +723,7 @@ fun PermissionCard(
.padding(start = 16.dp) .padding(start = 16.dp)
) { ) {
Text( Text(
text = title, text = title, style = TextStyle(
style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)), fontFamily = FontFamily(Font(R.font.sf_pro)),
@@ -773,8 +732,7 @@ fun PermissionCard(
) )
Text( Text(
text = description, text = description, style = TextStyle(
style = TextStyle(
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontFamily = FontFamily(Font(R.font.sf_pro)), fontFamily = FontFamily(Font(R.font.sf_pro)),
@@ -791,11 +749,8 @@ fun PermissionCard(
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = if (isGranted) "" else "!", text = if (isGranted) "" else "!", style = TextStyle(
style = TextStyle( fontSize = 14.sp, fontWeight = FontWeight.Bold, color = Color.White
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = Color.White
) )
) )
} }
@@ -0,0 +1,5 @@
package me.kavishdevar.librepods.billing
object BillingManager {
lateinit var provider: BillingProvider
}
@@ -0,0 +1,28 @@
/*
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.billing
import android.app.Activity
import kotlinx.coroutines.flow.StateFlow
interface BillingProvider {
val isPremium: StateFlow<Boolean>
fun purchase(activity: Activity)
}
@@ -0,0 +1,33 @@
/*
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.billing
import android.content.Context
import me.kavishdevar.librepods.BuildConfig
object BillingProviderFactory {
fun create(context: Context): BillingProvider {
return if (BuildConfig.PLAY_BUILD) {
PlayBillingProvider(context)
} else {
FOSSBillingProvider()
}
}
}
@@ -0,0 +1,30 @@
/*
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.billing
import android.app.Activity
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
class FOSSBillingProvider : BillingProvider {
private val _isPremium = MutableStateFlow(true)
override val isPremium: StateFlow<Boolean> = _isPremium
override fun purchase(activity: Activity) { }
}
@@ -0,0 +1,187 @@
/*
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.billing
import android.app.Activity
import android.content.Context
import android.util.Log
import com.android.billingclient.api.AcknowledgePurchaseParams
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingClientStateListener
import com.android.billingclient.api.BillingFlowParams
import com.android.billingclient.api.BillingResult
import com.android.billingclient.api.PendingPurchasesParams
import com.android.billingclient.api.ProductDetails
import com.android.billingclient.api.Purchase
import com.android.billingclient.api.PurchasesUpdatedListener
import com.android.billingclient.api.QueryProductDetailsParams
import com.android.billingclient.api.QueryPurchasesParams
import com.android.billingclient.api.acknowledgePurchase
import com.android.billingclient.api.queryProductDetails
import com.android.billingclient.api.queryPurchasesAsync
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
const val TAG = "PlayBillingProvider"
private const val PREMIUM_PRODUCT_ID = "librepods.advanced_features.v2"
class PlayBillingProvider(
context: Context
) : BillingProvider, PurchasesUpdatedListener {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val _isPremium = MutableStateFlow(false)
override val isPremium: StateFlow<Boolean> = _isPremium
private var productDetails: ProductDetails? = null
private val billingClient = BillingClient.newBuilder(context)
.setListener(this)
.enablePendingPurchases(
PendingPurchasesParams.newBuilder().enableOneTimeProducts().build()
)
.build()
init {
connect()
}
private fun connect() {
billingClient.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(result: BillingResult) {
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
scope.launch {
queryProductDetails()
queryExistingPurchases()
}
} else {
Log.w(TAG, "Billing setup failed: ${result.debugMessage}")
}
}
override fun onBillingServiceDisconnected() {
connect()
}
})
}
private suspend fun queryProductDetails() {
val params = QueryProductDetailsParams.newBuilder()
.setProductList(
listOf(
QueryProductDetailsParams.Product.newBuilder()
.setProductId(PREMIUM_PRODUCT_ID)
.setProductType(BillingClient.ProductType.INAPP)
.build()
)
).build()
val result = billingClient.queryProductDetails(params)
if (result.billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
productDetails = result.productDetailsList?.firstOrNull()
Log.d(TAG, "Product loaded: ${productDetails?.name}")
} else {
Log.w(TAG, "queryProductDetails failed: ${result.billingResult.debugMessage}")
}
}
private suspend fun queryExistingPurchases() {
val result = billingClient.queryPurchasesAsync(
QueryPurchasesParams.newBuilder()
.setProductType(BillingClient.ProductType.INAPP)
.build()
)
processPurchases(result.purchasesList)
}
override fun purchase(activity: Activity) {
val details = productDetails ?: run {
Log.e(TAG, "Product details not loaded yet")
return
}
val billingFlowParams = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(
listOf(
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(details)
.build()
)
).build()
val result = billingClient.launchBillingFlow(activity, billingFlowParams)
if (result.responseCode != BillingClient.BillingResponseCode.OK) {
Log.e(TAG, "launchBillingFlow failed: ${result.debugMessage}")
}
}
override fun onPurchasesUpdated(result: BillingResult, purchases: List<Purchase>?) {
when (result.responseCode) {
BillingClient.BillingResponseCode.OK -> purchases?.let { processPurchases(it) }
BillingClient.BillingResponseCode.USER_CANCELED -> Log.d(TAG, "User cancelled")
else -> Log.w(TAG, "Purchase error ${result.responseCode}: ${result.debugMessage}")
}
}
private fun processPurchases(purchases: List<Purchase>) {
val hasPremium = purchases.any {
it.products.contains(PREMIUM_PRODUCT_ID) &&
it.purchaseState == Purchase.PurchaseState.PURCHASED
}
// val purchase = purchases.find {
// it.products.contains(PREMIUM_PRODUCT_ID) && it.purchaseState == Purchase.PurchaseState.PURCHASED
// }
//
// if (purchase != null) {
// val consumeParams = ConsumeParams.newBuilder()
// .setPurchaseToken(purchase.purchaseToken)
// .build()
// scope.launch {
// billingClient.consumeAsync(consumeParams) { _, _ ->}
// }
// }
_isPremium.value = hasPremium
scope.launch {
purchases
.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED && !it.isAcknowledged }
.forEach { acknowledge(it) }
}
}
private suspend fun acknowledge(purchase: Purchase) {
val params = AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchase.purchaseToken)
.build()
val result = billingClient.acknowledgePurchase(params)
if (result.responseCode != BillingClient.BillingResponseCode.OK) {
Log.e(TAG, "Acknowledgement failed: ${result.debugMessage}")
}
}
}
@@ -22,8 +22,8 @@ package me.kavishdevar.librepods.composables
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -34,35 +34,35 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavController import androidx.navigation.NavController
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@Composable @Composable
fun AboutCard(navController: NavController) { fun AboutCard(
navController: NavController,
modelName: String,
actualModel: String,
serialNumbers: List<String>,
version: String?
) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val service = ServiceManager.getService()
if (service == null) return
val airpodsInstance = service.airpodsInstance
if (airpodsInstance == null) return
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
Box( Box(
@@ -108,7 +108,7 @@ fun AboutCard(navController: NavController) {
) )
) )
Text( Text(
text = airpodsInstance.model.displayName, text = modelName,
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f), color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
@@ -137,7 +137,7 @@ fun AboutCard(navController: NavController) {
) )
) )
Text( Text(
text = airpodsInstance.actualModelNumber, text = actualModel,
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f), color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
@@ -152,11 +152,11 @@ fun AboutCard(navController: NavController) {
.padding(horizontal = 12.dp) .padding(horizontal = 12.dp)
) )
val serialNumbers = listOf( val serialNumbers = listOf(
airpodsInstance.serialNumber?: "", serialNumbers[0],
"􀀛 ${airpodsInstance.leftSerialNumber}", "􀀛 ${serialNumbers[1]}",
"􀀧 ${airpodsInstance.rightSerialNumber}" "􀀧 ${serialNumbers[2]}"
) )
val serialNumber = remember { mutableStateOf(0) } val serialNumber = remember { mutableIntStateOf(0) }
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -172,7 +172,7 @@ fun AboutCard(navController: NavController) {
), ),
) )
Text( Text(
text = serialNumbers[serialNumber.value], text = serialNumbers[serialNumber.intValue],
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f), color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
@@ -183,7 +183,7 @@ fun AboutCard(navController: NavController) {
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = null indication = null
) { ) {
serialNumber.value = (serialNumber.value + 1) % serialNumbers.size serialNumber.intValue = (serialNumber.intValue + 1) % serialNumbers.size
} }
) )
} }
@@ -197,9 +197,9 @@ fun AboutCard(navController: NavController) {
to = "version_info", to = "version_info",
navController = navController, navController = navController,
name = stringResource(R.string.version), name = stringResource(R.string.version),
currentState = airpodsInstance.version3, currentState = version,
independent = false, independent = false,
height = rowHeight.value + 32.dp height = rowHeight.value + 32.dp
) )
} }
} }
@@ -42,25 +42,32 @@ import androidx.compose.ui.unit.sp
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.Capability
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@Composable @Composable
fun AudioSettings(navController: NavController) { fun AudioSettings(
navController: NavController,
adaptiveVolumeCapability: Boolean,
conversationalAwarenessCapability: Boolean,
loudSoundReductionCapability: Boolean,
adaptiveAudioCapability: Boolean,
adaptiveVolumeChecked: Boolean,
onAdaptiveVolumeCheckedChange: (Boolean) -> Unit,
conversationalAwarenessChecked: Boolean,
onConversationalAwarenessCheckedChange: (Boolean) -> Unit,
loudSoundReductionChecked: Boolean,
onLoudSoundReductionCheckedChange: (Boolean) -> Unit,
isXposed: Boolean,
isPremium: Boolean
) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val service = ServiceManager.getService()
if (service == null) return if (!adaptiveVolumeCapability && !conversationalAwarenessCapability && !loudSoundReductionCapability && !adaptiveAudioCapability) {
val airpodsInstance = service.airpodsInstance
if (airpodsInstance == null) return
if (!airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_VOLUME) &&
!airpodsInstance.model.capabilities.contains(Capability.CONVERSATION_AWARENESS) &&
!airpodsInstance.model.capabilities.contains(Capability.LOUD_SOUND_REDUCTION) &&
!airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_AUDIO)
) {
return return
} }
Box( Box(
@@ -88,12 +95,14 @@ fun AudioSettings(navController: NavController) {
.padding(top = 2.dp) .padding(top = 2.dp)
) { ) {
if (airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_VOLUME)) { if (adaptiveVolumeCapability) {
StyledToggle( StyledToggle(
label = stringResource(R.string.personalized_volume), label = stringResource(R.string.personalized_volume),
description = stringResource(R.string.personalized_volume_description), description = stringResource(R.string.personalized_volume_description),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG, independent = false,
independent = false checked = adaptiveVolumeChecked,
onCheckedChange = onAdaptiveVolumeCheckedChange,
enabled = isPremium
) )
HorizontalDivider( HorizontalDivider(
@@ -104,12 +113,14 @@ fun AudioSettings(navController: NavController) {
) )
} }
if (airpodsInstance.model.capabilities.contains(Capability.CONVERSATION_AWARENESS)) { if (conversationalAwarenessCapability) {
StyledToggle( StyledToggle(
label = stringResource(R.string.conversational_awareness), label = stringResource(R.string.conversational_awareness),
description = stringResource(R.string.conversational_awareness_description), description = stringResource(R.string.conversational_awareness_description),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG, independent = false,
independent = false checked = conversationalAwarenessChecked,
onCheckedChange = onConversationalAwarenessCheckedChange,
enabled = isPremium
) )
HorizontalDivider( HorizontalDivider(
thickness = 1.dp, thickness = 1.dp,
@@ -119,12 +130,13 @@ fun AudioSettings(navController: NavController) {
) )
} }
if (airpodsInstance.model.capabilities.contains(Capability.LOUD_SOUND_REDUCTION)){ if (loudSoundReductionCapability && isXposed){
StyledToggle( StyledToggle(
label = stringResource(R.string.loud_sound_reduction), label = stringResource(R.string.loud_sound_reduction),
description = stringResource(R.string.loud_sound_reduction_description), description = stringResource(R.string.loud_sound_reduction_description),
attHandle = ATTHandles.LOUD_SOUND_REDUCTION, independent = false,
independent = false checked = loudSoundReductionChecked,
onCheckedChange = onLoudSoundReductionCheckedChange
) )
HorizontalDivider( HorizontalDivider(
thickness = 1.dp, thickness = 1.dp,
@@ -134,7 +146,7 @@ fun AudioSettings(navController: NavController) {
) )
} }
if (airpodsInstance.model.capabilities.contains(Capability.ADAPTIVE_AUDIO)) { if (adaptiveAudioCapability) {
NavigationButton( NavigationButton(
to = "adaptive_strength", to = "adaptive_strength",
name = stringResource(R.string.adaptive_audio), name = stringResource(R.string.adaptive_audio),
@@ -148,5 +160,19 @@ fun AudioSettings(navController: NavController) {
@Preview @Preview
@Composable @Composable
fun AudioSettingsPreview() { fun AudioSettingsPreview() {
AudioSettings(rememberNavController()) AudioSettings(
navController = rememberNavController(),
adaptiveVolumeCapability = true,
conversationalAwarenessCapability = true,
loudSoundReductionCapability = true,
adaptiveAudioCapability = true,
adaptiveVolumeChecked = true,
onAdaptiveVolumeCheckedChange = { },
conversationalAwarenessChecked = true,
onConversationalAwarenessCheckedChange = { },
loudSoundReductionChecked = true,
onLoudSoundReductionCheckedChange = { },
isXposed = true,
isPremium = true
)
} }
@@ -20,13 +20,7 @@
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.res.Configuration import android.content.res.Configuration
import android.os.Build
import android.util.Log
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
@@ -39,169 +33,101 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.imageResource import androidx.compose.ui.res.imageResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.constants.Battery import me.kavishdevar.librepods.constants.Battery
import me.kavishdevar.librepods.constants.BatteryComponent import me.kavishdevar.librepods.constants.BatteryComponent
import me.kavishdevar.librepods.constants.BatteryStatus import me.kavishdevar.librepods.constants.BatteryStatus
import me.kavishdevar.librepods.services.AirPodsService
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@Composable @Composable
fun BatteryView(service: AirPodsService, preview: Boolean = false) { fun BatteryView(
val batteryStatus = remember { mutableStateOf<List<Battery>>(listOf()) } batteryList: List<Battery>,
budsRes: Int,
caseRes: Int
) {
val left = batteryList.find { it.component == BatteryComponent.LEFT }
val right = batteryList.find { it.component == BatteryComponent.RIGHT }
val case = batteryList.find { it.component == BatteryComponent.CASE }
val previousBatteryStatus = remember { mutableStateOf<List<Battery>>(listOf()) }
@Suppress("DEPRECATION") val batteryReceiver = remember {
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == AirPodsNotifications.BATTERY_DATA) {
batteryStatus.value =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableArrayListExtra("data", Battery::class.java)
} else {
intent.getParcelableArrayListExtra("data")
}?.toList() ?: listOf()
}
else if (intent.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
try {
context.unregisterReceiver(this)
}
catch (_: IllegalArgumentException) {
Log.wtf("BatteryReceiver", "Receiver already unregistered")
}
}
}
}
}
val context = LocalContext.current
LaunchedEffect(context) {
val batteryIntentFilter = IntentFilter()
.apply {
addAction(AirPodsNotifications.BATTERY_DATA)
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(
batteryReceiver,
batteryIntentFilter,
Context.RECEIVER_EXPORTED
)
}
}
previousBatteryStatus.value = batteryStatus.value
batteryStatus.value = service.getBattery()
if (preview) {
batteryStatus.value = listOf(
Battery(BatteryComponent.LEFT, 100, BatteryStatus.NOT_CHARGING),
Battery(BatteryComponent.RIGHT, 94, BatteryStatus.CHARGING),
Battery(BatteryComponent.CASE, 40, BatteryStatus.CHARGING)
)
previousBatteryStatus.value = batteryStatus.value
}
val left = batteryStatus.value.find { it.component == BatteryComponent.LEFT }
val right = batteryStatus.value.find { it.component == BatteryComponent.RIGHT }
val case = batteryStatus.value.find { it.component == BatteryComponent.CASE }
val leftLevel = left?.level ?: 0 val leftLevel = left?.level ?: 0
val rightLevel = right?.level ?: 0 val rightLevel = right?.level ?: 0
val caseLevel = case?.level ?: 0 val caseLevel = case?.level ?: 0
val leftCharging = left?.status == BatteryStatus.CHARGING || left?.status == BatteryStatus.OPTIMIZED_CHARGING
val rightCharging = right?.status == BatteryStatus.CHARGING || right?.status == BatteryStatus.OPTIMIZED_CHARGING
val caseCharging = case?.status == BatteryStatus.CHARGING || case?.status == BatteryStatus.OPTIMIZED_CHARGING
val prevLeft = previousBatteryStatus.value.find { it.component == BatteryComponent.LEFT } val leftCharging = left?.status == BatteryStatus.CHARGING ||
val prevRight = previousBatteryStatus.value.find { it.component == BatteryComponent.RIGHT } left?.status == BatteryStatus.OPTIMIZED_CHARGING
val prevCase = previousBatteryStatus.value.find { it.component == BatteryComponent.CASE }
val prevLeftCharging = prevLeft?.status == BatteryStatus.CHARGING val rightCharging = right?.status == BatteryStatus.CHARGING ||
val prevRightCharging = prevRight?.status == BatteryStatus.CHARGING right?.status == BatteryStatus.OPTIMIZED_CHARGING
val prevCaseCharging = prevCase?.status == BatteryStatus.CHARGING
val caseCharging = case?.status == BatteryStatus.CHARGING ||
case?.status == BatteryStatus.OPTIMIZED_CHARGING
val singleDisplayed = remember { mutableStateOf(false) } val singleDisplayed = remember { mutableStateOf(false) }
val airpodsInstance = service.airpodsInstance
if (airpodsInstance == null) {
return
}
val budsRes = airpodsInstance.model.budsRes
val caseRes = airpodsInstance.model.caseRes
Row { Row {
Column ( Column(
modifier = Modifier modifier = Modifier.fillMaxWidth(0.5f),
.fillMaxWidth(0.5f),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Image ( Image(
bitmap = ImageBitmap.imageResource(budsRes), bitmap = ImageBitmap.imageResource(budsRes),
contentDescription = stringResource(R.string.buds), contentDescription = stringResource(R.string.buds),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(8.dp) .padding(8.dp)
) )
if ( if (
leftCharging == rightCharging && leftCharging == rightCharging &&
(leftLevel - rightLevel) in -3..3 (leftLevel - rightLevel) in -3..3
) ) {
{
BatteryIndicator( BatteryIndicator(
leftLevel.coerceAtMost(rightLevel), leftLevel.coerceAtMost(rightLevel),
leftCharging, leftCharging
previousCharging = (prevLeftCharging && prevRightCharging)
) )
singleDisplayed.value = true singleDisplayed.value = true
} } else {
else {
singleDisplayed.value = false singleDisplayed.value = false
Row (
modifier = Modifier Row(
.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
) { ) {
if (leftLevel > 0 || left?.status != BatteryStatus.DISCONNECTED) { if (leftLevel > 0 || left?.status != BatteryStatus.DISCONNECTED) {
BatteryIndicator( BatteryIndicator(
leftLevel, leftLevel,
leftCharging, leftCharging,
"\uDBC6\uDCE5", "\uDBC6\uDCE5"
previousCharging = prevLeftCharging
) )
} }
if (leftLevel > 0 && rightLevel > 0)
{ if (leftLevel > 0 && rightLevel > 0) {
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(16.dp))
} }
if (rightLevel > 0 || right?.status != BatteryStatus.DISCONNECTED)
{ if (rightLevel > 0 || right?.status != BatteryStatus.DISCONNECTED) {
BatteryIndicator( BatteryIndicator(
rightLevel, rightLevel,
rightCharging, rightCharging,
"\uDBC6\uDCE8", "\uDBC6\uDCE8"
previousCharging = prevRightCharging
) )
} }
} }
} }
} }
Column ( Column(
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Image( Image(
@@ -211,14 +137,14 @@ fun BatteryView(service: AirPodsService, preview: Boolean = false) {
.fillMaxWidth() .fillMaxWidth()
.padding(8.dp) .padding(8.dp)
) )
if (caseLevel > 0 || case?.status != BatteryStatus.DISCONNECTED) {
BatteryIndicator( if (caseLevel > 0 || case?.status != BatteryStatus.DISCONNECTED) {
caseLevel, BatteryIndicator(
caseCharging, caseLevel,
prefix = if (!singleDisplayed.value) "\uDBC3\uDE6C" else "", caseCharging,
previousCharging = prevCaseCharging prefix = if (!singleDisplayed.value) "\uDBC3\uDE6C" else ""
) )
} }
} }
} }
} }
@@ -226,10 +152,23 @@ fun BatteryView(service: AirPodsService, preview: Boolean = false) {
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable @Composable
fun BatteryViewPreview() { fun BatteryViewPreview() {
val fakeBattery = listOf(
Battery(BatteryComponent.LEFT, 85, BatteryStatus.CHARGING),
Battery(BatteryComponent.RIGHT, 40, BatteryStatus.CHARGING),
Battery(BatteryComponent.CASE, 60, BatteryStatus.NOT_CHARGING)
)
val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7) val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)
Box( Box(
modifier = Modifier.background(bg) modifier = Modifier
.background(bg)
.padding(16.dp)
) { ) {
BatteryView(AirPodsService(), preview = true) BatteryView(
batteryList = fakeBattery,
budsRes = R.drawable.airpods_pro_2_buds,
caseRes = R.drawable.airpods_pro_2_case
)
} }
} }
@@ -36,7 +36,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
@@ -56,19 +55,20 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@Composable @Composable
fun CallControlSettings(hazeState: HazeState) { fun CallControlSettings(
hazeState: HazeState,
flipped: Boolean,
onCallControlValueChanged: (Boolean) -> Unit
) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
@@ -93,24 +93,9 @@ fun CallControlSettings(hazeState: HazeState) {
.background(backgroundColor, RoundedCornerShape(28.dp)) .background(backgroundColor, RoundedCornerShape(28.dp))
.padding(top = 2.dp) .padding(top = 2.dp)
) { ) {
val service = ServiceManager.getService()!!
val callControlEnabledValue = service.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG
}?.value ?: byteArrayOf(0x00, 0x03)
val pressOnceText = stringResource(R.string.press_once) val pressOnceText = stringResource(R.string.press_once)
val pressTwiceText = stringResource(R.string.press_twice) val pressTwiceText = stringResource(R.string.press_twice)
var flipped by remember {
mutableStateOf(
callControlEnabledValue.contentEquals(
byteArrayOf(
0x00,
0x02
)
)
)
}
var singlePressAction by remember { mutableStateOf(if (flipped) pressTwiceText else pressOnceText) } var singlePressAction by remember { mutableStateOf(if (flipped) pressTwiceText else pressOnceText) }
var doublePressAction by remember { mutableStateOf(if (flipped) pressOnceText else pressTwiceText) } var doublePressAction by remember { mutableStateOf(if (flipped) pressOnceText else pressTwiceText) }
@@ -128,35 +113,6 @@ fun CallControlSettings(hazeState: HazeState) {
var parentHoveredIndexDouble by remember { mutableStateOf<Int?>(null) } var parentHoveredIndexDouble by remember { mutableStateOf<Int?>(null) }
var parentDragActiveDouble by remember { mutableStateOf(false) } var parentDragActiveDouble by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
val listener = object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (AACPManager.Companion.ControlCommandIdentifiers.fromByte(controlCommand.identifier) ==
AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG
) {
val newFlipped = controlCommand.value.contentEquals(byteArrayOf(0x00, 0x02))
flipped = newFlipped
singlePressAction = if (newFlipped) pressTwiceText else pressOnceText
doublePressAction = if (newFlipped) pressOnceText else pressTwiceText
Log.d(
"CallControlSettings",
"Control command received, flipped: $newFlipped"
)
}
}
}
service.aacpManager.registerControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG,
listener
)
}
DisposableEffect(Unit) {
onDispose {
service.aacpManager.controlCommandListeners[AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG]?.clear()
}
}
LaunchedEffect(flipped) { LaunchedEffect(flipped) {
Log.d("CallControlSettings", "Call control flipped: $flipped") Log.d("CallControlSettings", "Call control flipped: $flipped")
} }
@@ -244,11 +200,8 @@ fun CallControlSettings(hazeState: HazeState) {
if (option == pressOnceText) pressTwiceText else pressOnceText if (option == pressOnceText) pressTwiceText else pressOnceText
showSinglePressDropdown = false showSinglePressDropdown = false
lastDismissTimeSingle = System.currentTimeMillis() lastDismissTimeSingle = System.currentTimeMillis()
val bytes = if (option == pressOnceText) byteArrayOf( onCallControlValueChanged(option != pressOnceText)
0x00,
0x03
) else byteArrayOf(0x00, 0x02)
service.aacpManager.sendControlCommand(0x24, bytes)
} }
} }
parentHoveredIndexSingle = null parentHoveredIndexSingle = null
@@ -313,11 +266,8 @@ fun CallControlSettings(hazeState: HazeState) {
doublePressAction = doublePressAction =
if (option == pressOnceText) pressTwiceText else pressOnceText if (option == pressOnceText) pressTwiceText else pressOnceText
showSinglePressDropdown = false showSinglePressDropdown = false
val bytes = if (option == pressOnceText) byteArrayOf( val flipped = option != pressOnceText
0x00, onCallControlValueChanged(flipped)
0x03
) else byteArrayOf(0x00, 0x02)
service.aacpManager.sendControlCommand(0x24, bytes)
}, },
hazeState = hazeState hazeState = hazeState
) )
@@ -379,11 +329,8 @@ fun CallControlSettings(hazeState: HazeState) {
if (option == pressOnceText) pressTwiceText else pressOnceText if (option == pressOnceText) pressTwiceText else pressOnceText
showDoublePressDropdown = false showDoublePressDropdown = false
lastDismissTimeDouble = System.currentTimeMillis() lastDismissTimeDouble = System.currentTimeMillis()
val bytes = if (option == pressOnceText) byteArrayOf( val flipped = option == pressOnceText
0x00, onCallControlValueChanged (flipped)
0x02
) else byteArrayOf(0x00, 0x03)
service.aacpManager.sendControlCommand(0x24, bytes)
} }
} }
parentHoveredIndexDouble = null parentHoveredIndexDouble = null
@@ -448,11 +395,8 @@ fun CallControlSettings(hazeState: HazeState) {
singlePressAction = singlePressAction =
if (option == pressOnceText) pressTwiceText else pressOnceText if (option == pressOnceText) pressTwiceText else pressOnceText
showDoublePressDropdown = false showDoublePressDropdown = false
val bytes = if (option == pressOnceText) byteArrayOf( val flipped = option == pressOnceText
0x00, onCallControlValueChanged(flipped)
0x02
) else byteArrayOf(0x00, 0x03)
service.aacpManager.sendControlCommand(0x24, bytes)
}, },
hazeState = hazeState hazeState = hazeState
) )
@@ -461,10 +405,3 @@ fun CallControlSettings(hazeState: HazeState) {
} }
} }
} }
@ExperimentalHazeMaterialsApi
@Preview
@Composable
fun CallControlSettingsPreview() {
CallControlSettings(HazeState())
}
@@ -20,7 +20,6 @@
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables
import android.content.Context.MODE_PRIVATE
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -31,16 +30,18 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@Composable @Composable
fun ConnectionSettings() { fun ConnectionSettings(
automaticEarDetectionEnabled: Boolean,
onAutomaticEarDetectionChanged: (Boolean) -> Unit,
automaticConnectionEnabled: Boolean,
onAutomaticConnectionChanged: (Boolean) -> Unit,
) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
@@ -52,10 +53,9 @@ fun ConnectionSettings() {
) { ) {
StyledToggle( StyledToggle(
label = stringResource(R.string.ear_detection), label = stringResource(R.string.ear_detection),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.EAR_DETECTION_CONFIG, independent = false,
sharedPreferenceKey = "automatic_ear_detection", checked = automaticEarDetectionEnabled,
sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE), onCheckedChange = onAutomaticEarDetectionChanged
independent = false
) )
HorizontalDivider( HorizontalDivider(
thickness = 1.dp, thickness = 1.dp,
@@ -67,16 +67,9 @@ fun ConnectionSettings() {
StyledToggle( StyledToggle(
label = stringResource(R.string.automatically_connect), label = stringResource(R.string.automatically_connect),
description = stringResource(R.string.automatically_connect_description), description = stringResource(R.string.automatically_connect_description),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG, independent = false,
sharedPreferenceKey = "automatic_connection_ctrl_cmd", checked = automaticConnectionEnabled,
sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE), onCheckedChange = onAutomaticConnectionChanged
independent = false
) )
} }
} }
@Preview
@Composable
fun ConnectionSettingsPreview() {
ConnectionSettings()
}
@@ -40,70 +40,76 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavController import androidx.navigation.NavController
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.Capability
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@Composable @Composable
fun HearingHealthSettings(navController: NavController) { fun HearingHealthSettings(
val service = ServiceManager.getService() navController: NavController,
if (service == null) return hasPPECapability: Boolean,
val airpodsInstance = service.airpodsInstance hasHearingAidCapability: Boolean,
if (airpodsInstance == null) return isXposed: Boolean
if (airpodsInstance.model.capabilities.contains(Capability.HEARING_AID)) { ) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val shouldShowHearingAid = hasHearingAidCapability && isXposed
if (airpodsInstance.model.capabilities.contains(Capability.PPE)) { if (hasPPECapability && shouldShowHearingAid) {
Box( Box(
modifier = Modifier
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
.padding(horizontal = 16.dp, vertical = 4.dp)
){
Text(
text = stringResource(R.string.hearing_health),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f)
)
)
}
Column(
modifier = Modifier
.clip(RoundedCornerShape(28.dp))
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(top = 2.dp)
) {
NavigationButton(
to = "hearing_protection",
name = stringResource(R.string.hearing_protection),
navController = navController,
independent = false
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier modifier = Modifier
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)) .padding(horizontal = 12.dp)
.padding(horizontal = 16.dp, vertical = 4.dp) )
){
Text(
text = stringResource(R.string.hearing_health),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f)
)
)
}
Column(
modifier = Modifier
.clip(RoundedCornerShape(28.dp))
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(top = 2.dp)
) {
NavigationButton(
to = "hearing_protection",
name = stringResource(R.string.hearing_protection),
navController = navController,
independent = false
)
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
NavigationButton(
to = "hearing_aid",
name = stringResource(R.string.hearing_aid),
navController = navController,
independent = false
)
}
} else {
NavigationButton( NavigationButton(
to = "hearing_aid", to = "hearing_aid",
name = stringResource(R.string.hearing_aid), name = stringResource(R.string.hearing_aid),
navController = navController navController = navController,
independent = false
) )
} }
} else if (shouldShowHearingAid) {
NavigationButton(
to = "hearing_aid",
name = stringResource(R.string.hearing_aid),
navController = navController
)
} else if (hasPPECapability) {
NavigationButton(
to = "hearing_protection",
name = stringResource(R.string.hearing_protection),
title = stringResource(R.string.hearing_health),
navController = navController
)
} }
} }
@@ -35,8 +35,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -54,19 +52,21 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@Composable @Composable
fun MicrophoneSettings(hazeState: HazeState) { fun MicrophoneSettings(
hazeState: HazeState,
micModeValue: Byte,
onMicModeValueChanged: (Byte) -> Unit
) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
@@ -77,11 +77,6 @@ fun MicrophoneSettings(hazeState: HazeState) {
.background(backgroundColor, RoundedCornerShape(28.dp)) .background(backgroundColor, RoundedCornerShape(28.dp))
.padding(top = 2.dp) .padding(top = 2.dp)
) { ) {
val service = ServiceManager.getService()!!
val micModeValue = service.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE
}?.value?.get(0) ?: 0x00.toByte()
var selectedMode by remember { var selectedMode by remember {
mutableStateOf( mutableStateOf(
when (micModeValue) { when (micModeValue) {
@@ -114,22 +109,6 @@ fun MicrophoneSettings(hazeState: HazeState) {
} }
} }
LaunchedEffect(Unit) {
service.aacpManager.registerControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE,
listener
)
}
DisposableEffect(Unit) {
onDispose {
service.aacpManager.unregisterControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE,
listener
)
}
}
val density = LocalDensity.current val density = LocalDensity.current
val itemHeightPx = with(density) { 48.dp.toPx() } val itemHeightPx = with(density) { 48.dp.toPx() }
var parentHoveredIndex by remember { mutableStateOf<Int?>(null) } var parentHoveredIndex by remember { mutableStateOf<Int?>(null) }
@@ -194,10 +173,11 @@ fun MicrophoneSettings(hazeState: HazeState) {
options[2] -> 0x02 options[2] -> 0x02
else -> 0x00 else -> 0x00
} }
service.aacpManager.sendControlCommand( // service.aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value, // AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value,
byteArrayOf(byteValue.toByte()) // byteArrayOf(byteValue.toByte())
) // )
onMicModeValueChanged(byteValue.toByte())
} }
} }
parentHoveredIndex = null parentHoveredIndex = null
@@ -277,10 +257,7 @@ fun MicrophoneSettings(hazeState: HazeState) {
microphoneAlwaysLeftText -> 0x02 microphoneAlwaysLeftText -> 0x02
else -> 0x00 else -> 0x00
} }
service.aacpManager.sendControlCommand( onMicModeValueChanged(byteValue.toByte())
AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value,
byteArrayOf(byteValue.toByte())
)
}, },
hazeState = hazeState hazeState = hazeState
) )
@@ -288,10 +265,3 @@ fun MicrophoneSettings(hazeState: HazeState) {
} }
} }
} }
@ExperimentalHazeMaterialsApi
@Preview
@Composable
fun MicrophoneSettingsPreview() {
MicrophoneSettings(HazeState())
}
@@ -21,11 +21,6 @@
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.Spring import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.SpringSpec import androidx.compose.animation.core.SpringSpec
@@ -60,48 +55,28 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.imageResource import androidx.compose.ui.res.imageResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.constants.NoiseControlMode import me.kavishdevar.librepods.constants.NoiseControlMode
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.roundToInt import kotlin.math.roundToInt
@SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope") @SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope")
@Composable @Composable
fun NoiseControlSettings( fun NoiseControlSettings(
service: AirPodsService, showOffListeningMode: Boolean,
noiseControlModeValue: Int,
onNoiseControlModeChanged: (Int) -> Unit
) { ) {
val context = LocalContext.current
val offListeningModeConfigValue = service.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
}?.value?.takeIf { it.isNotEmpty() }?.get(0) != 2.toByte()
val offListeningMode = remember { mutableStateOf(offListeningModeConfigValue) }
val offListeningModeListener = object: AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
offListeningMode.value = controlCommand.value[0] != 2.toByte()
}
}
service.aacpManager.registerControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION,
offListeningModeListener
)
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8)
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
@@ -109,7 +84,6 @@ fun NoiseControlSettings(
val selectedBackground = if (isDarkTheme) Color(0xBF5C5A5F) else Color(0xFFFFFFFF) val selectedBackground = if (isDarkTheme) Color(0xBF5C5A5F) else Color(0xFFFFFFFF)
val noiseControlModeFromService = service.aacpManager.getControlCommandStatus(AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE)
val noiseControlMode = remember { mutableStateOf(NoiseControlMode.OFF) } val noiseControlMode = remember { mutableStateOf(NoiseControlMode.OFF) }
@@ -117,10 +91,11 @@ fun NoiseControlSettings(
val d2a = remember { mutableFloatStateOf(0f) } val d2a = remember { mutableFloatStateOf(0f) }
val d3a = remember { mutableFloatStateOf(0f) } val d3a = remember { mutableFloatStateOf(0f) }
// this function exists solely for the dividers, should get rid of it
fun onModeSelected(mode: NoiseControlMode, received: Boolean = false) { fun onModeSelected(mode: NoiseControlMode, received: Boolean = false) {
val previousMode = noiseControlMode.value val previousMode = noiseControlMode.value
val targetMode = if (!offListeningMode.value && mode == NoiseControlMode.OFF) { val targetMode = if (!showOffListeningMode && mode == NoiseControlMode.OFF) {
NoiseControlMode.TRANSPARENCY NoiseControlMode.TRANSPARENCY
} else { } else {
mode mode
@@ -128,9 +103,8 @@ fun NoiseControlSettings(
noiseControlMode.value = targetMode noiseControlMode.value = targetMode
if (!received && targetMode != previousMode) { if (!received && targetMode != previousMode) onNoiseControlModeChanged(targetMode.ordinal + 1)
service.aacpManager.sendControlCommand(identifier = AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value, value = targetMode.ordinal + 1)
}
when (noiseControlMode.value) { when (noiseControlMode.value) {
NoiseControlMode.NOISE_CANCELLATION -> { NoiseControlMode.NOISE_CANCELLATION -> {
@@ -157,42 +131,11 @@ fun NoiseControlSettings(
} }
if (noiseControlModeFromService != null) { val index = (noiseControlModeValue - 1).coerceIn(0, NoiseControlMode.entries.size - 1)
val value = noiseControlModeFromService.value noiseControlMode.value = NoiseControlMode.entries[index]
if (value.isNotEmpty()) {
val index = (value[0].toInt() - 1).coerceIn(0, NoiseControlMode.entries.size - 1)
noiseControlMode.value = NoiseControlMode.entries[index]
onModeSelected(noiseControlMode.value, received = true) onModeSelected(noiseControlMode.value, received = true)
}
}
val noiseControlReceiver = remember {
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == AirPodsNotifications.ANC_DATA) {
noiseControlMode.value = NoiseControlMode.entries.toTypedArray()[intent.getIntExtra("data", 3) - 1]
onModeSelected(noiseControlMode.value, true)
} else if (intent.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
try {
context.unregisterReceiver(this)
} catch (e: IllegalArgumentException) {
e.printStackTrace()
}
}
}
}
}
val noiseControlIntentFilter = IntentFilter().apply {
addAction(AirPodsNotifications.ANC_DATA)
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter, Context.RECEIVER_EXPORTED)
} else {
context.registerReceiver(noiseControlReceiver, noiseControlIntentFilter)
}
Box( Box(
modifier = Modifier modifier = Modifier
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)) .background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
@@ -207,14 +150,14 @@ fun NoiseControlSettings(
) )
) )
} }
@Suppress("COMPOSE_APPLIER_CALL_MISMATCH")
BoxWithConstraints( BoxWithConstraints(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 8.dp) .padding(vertical = 8.dp)
) { ) {
val density = LocalDensity.current val density = LocalDensity.current
val buttonCount = if (offListeningMode.value) 4 else 3 val buttonCount = if (showOffListeningMode) 4 else 3
val buttonWidth = maxWidth / buttonCount val buttonWidth = maxWidth / buttonCount
val isDragging = remember { mutableStateOf(false) } val isDragging = remember { mutableStateOf(false) }
@@ -222,10 +165,10 @@ fun NoiseControlSettings(
mutableFloatStateOf( mutableFloatStateOf(
with(density) { with(density) {
when(noiseControlMode.value) { when(noiseControlMode.value) {
NoiseControlMode.OFF -> if (offListeningMode.value) 0f else buttonWidth.toPx() NoiseControlMode.OFF -> if (showOffListeningMode) 0f else buttonWidth.toPx()
NoiseControlMode.TRANSPARENCY -> if (offListeningMode.value) buttonWidth.toPx() else 0f NoiseControlMode.TRANSPARENCY -> if (showOffListeningMode) buttonWidth.toPx() else 0f
NoiseControlMode.ADAPTIVE -> if (offListeningMode.value) (buttonWidth * 2).toPx() else buttonWidth.toPx() NoiseControlMode.ADAPTIVE -> if (showOffListeningMode) (buttonWidth * 2).toPx() else buttonWidth.toPx()
NoiseControlMode.NOISE_CANCELLATION -> if (offListeningMode.value) (buttonWidth * 3).toPx() else (buttonWidth * 2).toPx() NoiseControlMode.NOISE_CANCELLATION -> if (showOffListeningMode) (buttonWidth * 3).toPx() else (buttonWidth * 2).toPx()
} }
} }
) )
@@ -238,10 +181,10 @@ fun NoiseControlSettings(
) )
val targetOffset = buttonWidth * when(noiseControlMode.value) { val targetOffset = buttonWidth * when(noiseControlMode.value) {
NoiseControlMode.OFF -> if (offListeningMode.value) 0 else 1 NoiseControlMode.OFF -> if (showOffListeningMode) 0 else 1
NoiseControlMode.TRANSPARENCY -> if (offListeningMode.value) 1 else 0 NoiseControlMode.TRANSPARENCY -> if (showOffListeningMode) 1 else 0
NoiseControlMode.ADAPTIVE -> if (offListeningMode.value) 2 else 1 NoiseControlMode.ADAPTIVE -> if (showOffListeningMode) 2 else 1
NoiseControlMode.NOISE_CANCELLATION -> if (offListeningMode.value) 3 else 2 NoiseControlMode.NOISE_CANCELLATION -> if (showOffListeningMode) 3 else 2
} }
val animatedOffset by animateFloatAsState( val animatedOffset by animateFloatAsState(
@@ -264,7 +207,7 @@ fun NoiseControlSettings(
Row( Row(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
if (offListeningMode.value) { if (showOffListeningMode) {
NoiseControlButton( NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation), icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
onClick = { onModeSelected(NoiseControlMode.OFF) }, onClick = { onModeSelected(NoiseControlMode.OFF) },
@@ -337,13 +280,12 @@ fun NoiseControlSettings(
val position = dragOffset / with(density) { buttonWidth.toPx() } val position = dragOffset / with(density) { buttonWidth.toPx() }
val newIndex = position.roundToInt() val newIndex = position.roundToInt()
val newMode = when(newIndex) { val newMode = when(newIndex) {
0 -> if (offListeningMode.value) NoiseControlMode.OFF else NoiseControlMode.TRANSPARENCY 0 -> if (showOffListeningMode) NoiseControlMode.OFF else NoiseControlMode.TRANSPARENCY
1 -> if (offListeningMode.value) NoiseControlMode.TRANSPARENCY else NoiseControlMode.ADAPTIVE 1 -> if (showOffListeningMode) NoiseControlMode.TRANSPARENCY else NoiseControlMode.ADAPTIVE
2 -> if (offListeningMode.value) NoiseControlMode.ADAPTIVE else NoiseControlMode.NOISE_CANCELLATION 2 -> if (showOffListeningMode) NoiseControlMode.ADAPTIVE else NoiseControlMode.NOISE_CANCELLATION
3 -> NoiseControlMode.NOISE_CANCELLATION 3 -> NoiseControlMode.NOISE_CANCELLATION
else -> noiseControlMode.value // Keep current if index is invalid else -> noiseControlMode.value // Keep current if index is invalid
} }
// Call onModeSelected which now handles service call but not callback
onModeSelected(newMode) onModeSelected(newMode)
} }
) )
@@ -361,7 +303,7 @@ fun NoiseControlSettings(
.fillMaxWidth() .fillMaxWidth()
.zIndex(1f) .zIndex(1f)
) { ) {
if (offListeningMode.value) { if (showOffListeningMode) {
NoiseControlButton( NoiseControlButton(
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation), icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
onClick = { onModeSelected(NoiseControlMode.OFF) }, onClick = { onModeSelected(NoiseControlMode.OFF) },
@@ -420,7 +362,7 @@ fun NoiseControlSettings(
.fillMaxWidth() .fillMaxWidth()
.padding(top = 4.dp) .padding(top = 4.dp)
) { ) {
if (offListeningMode.value) { if (showOffListeningMode) {
Text( Text(
text = stringResource(R.string.off), text = stringResource(R.string.off),
style = TextStyle(fontSize = 12.sp, color = textColor), style = TextStyle(fontSize = 12.sp, color = textColor),
@@ -450,9 +392,3 @@ fun NoiseControlSettings(
} }
} }
} }
@Preview
@Composable
fun NoiseControlSettingsPreview() {
NoiseControlSettings(AirPodsService())
}
@@ -18,15 +18,11 @@
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables
import android.content.Context
import android.content.res.Configuration
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
@@ -35,13 +31,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavController import androidx.navigation.NavController
@@ -49,24 +43,22 @@ import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.constants.StemAction import me.kavishdevar.librepods.constants.StemAction
@Composable @Composable
fun PressAndHoldSettings(navController: NavController) { fun PressAndHoldSettings(
navController: NavController,
leftAction: StemAction,
rightAction: StemAction
) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val dividerColor = Color(0x40888888) val dividerColor = Color(0x40888888)
val context = LocalContext.current val leftActionText = when (leftAction) {
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
val leftAction = sharedPreferences.getString("left_long_press_action", StemAction.CYCLE_NOISE_CONTROL_MODES.name)
val rightAction = sharedPreferences.getString("right_long_press_action", StemAction.CYCLE_NOISE_CONTROL_MODES.name)
val leftActionText = when (StemAction.valueOf(leftAction ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) {
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control) StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant" StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
else -> "INVALID!!" else -> "INVALID!!"
} }
val rightActionText = when (StemAction.valueOf(rightAction ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) { val rightActionText = when (rightAction) {
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control) StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant" StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
else -> "INVALID!!" else -> "INVALID!!"
@@ -114,9 +106,3 @@ fun PressAndHoldSettings(navController: NavController) {
) )
} }
} }
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun PressAndHoldSettingsPreview() {
PressAndHoldSettings(navController = NavController(LocalContext.current))
}
@@ -55,7 +55,7 @@ import androidx.compose.ui.util.lerp
import com.kyant.backdrop.Backdrop import com.kyant.backdrop.Backdrop
import com.kyant.backdrop.drawBackdrop import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur import com.kyant.backdrop.effects.blur
import com.kyant.backdrop.effects.refraction import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.effects.vibrancy import com.kyant.backdrop.effects.vibrancy
import com.kyant.backdrop.highlight.Highlight import com.kyant.backdrop.highlight.Highlight
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -146,7 +146,12 @@ half4 main(float2 coord) {
effects = { effects = {
vibrancy() vibrancy()
blur(2f.dp.toPx()) blur(2f.dp.toPx())
refraction(12f.dp.toPx(), 24f.dp.toPx()) lens(
refractionHeight = 12f.dp.toPx(),
refractionAmount = 24f.dp.toPx(),
depthEffect = true,
chromaticAberration = true
)
}, },
layerBlock = { layerBlock = {
val width = size.width val width = size.width
@@ -63,8 +63,7 @@ import androidx.compose.ui.util.lerp
import com.kyant.backdrop.backdrops.LayerBackdrop import com.kyant.backdrop.backdrops.LayerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.effects.refractionWithDispersion
import com.kyant.backdrop.highlight.Highlight import com.kyant.backdrop.highlight.Highlight
import com.kyant.backdrop.shadow.Shadow import com.kyant.backdrop.shadow.Shadow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -78,13 +77,13 @@ import kotlin.math.tanh
@Composable @Composable
fun StyledIconButton( fun StyledIconButton(
onClick: () -> Unit, modifier: Modifier = Modifier,
icon: String, icon: String,
darkMode: Boolean,
tint: Color = Color.Unspecified, tint: Color = Color.Unspecified,
backdrop: LayerBackdrop = rememberLayerBackdrop(), backdrop: LayerBackdrop = rememberLayerBackdrop(),
modifier: Modifier = Modifier, onClick: () -> Unit
) { ) {
val darkMode = isSystemInDarkTheme()
val animationScope = rememberCoroutineScope() val animationScope = rememberCoroutineScope()
val progressAnimationSpec = spring(0.5f, 300f, 0.001f) val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold) val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
@@ -218,8 +217,12 @@ half4 main(float2 coord) {
} }
}, },
effects = { effects = {
refractionWithDispersion(6f.dp.toPx(), size.height / 2f) lens(
// blur(24f, TileMode.Decal) refractionHeight = 6f.dp.toPx(),
refractionAmount = size.height / 2f,
depthEffect = true,
chromaticAberration = true
)
}, },
) )
.pointerInput(animationScope) { .pointerInput(animationScope) {
@@ -61,7 +61,6 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.rememberHazeState import dev.chrisbanes.haze.rememberHazeState
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
@ExperimentalHazeMaterialsApi
@Composable @Composable
fun StyledScaffold( fun StyledScaffold(
title: String, title: String,
@@ -133,7 +132,6 @@ fun StyledScaffold(
} }
@ExperimentalHazeMaterialsApi
@Composable @Composable
fun StyledScaffold( fun StyledScaffold(
title: String, title: String,
@@ -150,7 +148,6 @@ fun StyledScaffold(
} }
} }
@ExperimentalHazeMaterialsApi
@Composable @Composable
fun StyledScaffold( fun StyledScaffold(
title: String, title: String,
@@ -48,7 +48,6 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
@@ -59,19 +58,10 @@ data class SelectItem(
val iconRes: Int? = null, val iconRes: Int? = null,
val selected: Boolean, val selected: Boolean,
val onClick: () -> Unit, val onClick: () -> Unit,
val visible: Boolean = true,
val enabled: Boolean = true val enabled: Boolean = true
) )
data class SelectItem2(
val name: String,
val description: String? = null,
val iconRes: Int? = null,
val selected: () -> Boolean,
val onClick: () -> Unit,
val enabled: Boolean = true
)
@Composable @Composable
fun StyledSelectList( fun StyledSelectList(
items: List<SelectItem>, items: List<SelectItem>,
@@ -87,18 +77,19 @@ fun StyledSelectList(
.background(backgroundColor, RoundedCornerShape(28.dp)), .background(backgroundColor, RoundedCornerShape(28.dp)),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
val visibleItems = items.filter { it.enabled } val visibleItems = items.filter { it.visible }
visibleItems.forEachIndexed { index, item -> visibleItems.forEachIndexed { index, item ->
val isFirst = index == 0 val isFirst = index == 0
val isLast = index == visibleItems.size - 1 val isLast = index == visibleItems.size - 1
val hasIcon = item.iconRes != null val hasIcon = item.iconRes != null
val shape = when { val shape = when {
isFirst && isLast -> RoundedCornerShape(28.dp)
isFirst -> RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp) isFirst -> RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)
isLast -> RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp) isLast -> RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)
else -> RoundedCornerShape(0.dp) else -> RoundedCornerShape(0.dp)
} }
var itemBackgroundColor by remember { mutableStateOf(backgroundColor) } var itemBackgroundColor by remember { mutableStateOf(if (item.enabled) backgroundColor else if (isDarkTheme) Color(0x40050505) else Color(0x40D9D9D9)) }
val animatedBackgroundColor by animateColorAsState(targetValue = itemBackgroundColor, animationSpec = tween(durationMillis = 500)) val animatedBackgroundColor by animateColorAsState(targetValue = itemBackgroundColor, animationSpec = tween(durationMillis = 500))
Row( Row(
@@ -108,10 +99,13 @@ fun StyledSelectList(
.pointerInput(Unit) { .pointerInput(Unit) {
detectTapGestures( detectTapGestures(
onPress = { onPress = {
itemBackgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9) if (item.enabled) {
tryAwaitRelease() itemBackgroundColor =
itemBackgroundColor = backgroundColor if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
item.onClick() tryAwaitRelease()
itemBackgroundColor = backgroundColor
item.onClick()
}
} }
) )
} }
@@ -121,7 +115,7 @@ fun StyledSelectList(
) { ) {
if (hasIcon) { if (hasIcon) {
Icon( Icon(
painter = painterResource(item.iconRes!!), painter = painterResource(item.iconRes),
contentDescription = "Icon", contentDescription = "Icon",
tint = Color(0xFF007AFF), tint = Color(0xFF007AFF),
modifier = Modifier modifier = Modifier
@@ -181,4 +175,4 @@ fun StyledSelectList(
} }
} }
} }
} }
@@ -18,6 +18,7 @@
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables
import android.annotation.SuppressLint
import android.content.res.Configuration import android.content.res.Configuration
import android.util.Log import android.util.Log
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
@@ -43,7 +44,6 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableFloatState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
@@ -81,7 +81,7 @@ import com.kyant.backdrop.backdrops.rememberCombinedBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur import com.kyant.backdrop.effects.blur
import com.kyant.backdrop.effects.refractionWithDispersion import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.highlight.Highlight import com.kyant.backdrop.highlight.Highlight
import com.kyant.backdrop.shadow.InnerShadow import com.kyant.backdrop.shadow.InnerShadow
import com.kyant.backdrop.shadow.Shadow import com.kyant.backdrop.shadow.Shadow
@@ -203,10 +203,11 @@ class MomentumAnimation(
} }
} }
@SuppressLint("UnrememberedMutableState")
@Composable @Composable
fun StyledSlider( fun StyledSlider(
label: String? = null, label: String? = null,
mutableFloatState: MutableFloatState, value: Float,
onValueChange: (Float) -> Unit, onValueChange: (Float) -> Unit,
valueRange: ClosedFloatingPointRange<Float>, valueRange: ClosedFloatingPointRange<Float>,
backdrop: Backdrop = rememberLayerBackdrop(), backdrop: Backdrop = rememberLayerBackdrop(),
@@ -217,23 +218,26 @@ fun StyledSlider(
startLabel: String? = null, startLabel: String? = null,
endLabel: String? = null, endLabel: String? = null,
independent: Boolean = false, independent: Boolean = false,
description: String? = null description: String? = null,
enabled: Boolean = true
) { ) {
val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val isLightTheme = !isSystemInDarkTheme() val isLightTheme = !isSystemInDarkTheme()
val accentColor =
if (isLightTheme) Color(0xFF0088FF)
else Color(0xFF0091FF)
val trackColor = val trackColor =
if (isLightTheme) Color(0xFF787878).copy(0.2f) if (isLightTheme) Color(0xFF787878).copy(0.2f)
else Color(0xFF787880).copy(0.36f) else Color(0xFF787880).copy(0.36f)
val accentColor =
if (enabled) {
if (isLightTheme) Color(0xFF0088FF)
else Color(0xFF0091FF)
} else {
trackColor
}
val labelTextColor = if (isLightTheme) Color.Black else Color.White val labelTextColor = if (isLightTheme) Color.Black else Color.White
val fraction by remember { val fraction by derivedStateOf {
derivedStateOf { ((value - valueRange.start) / (valueRange.endInclusive - valueRange.start))
((mutableFloatState.floatValue - valueRange.start) / (valueRange.endInclusive - valueRange.start)) .fastCoerceIn(0f, 1f)
.fastCoerceIn(0f, 1f)
}
} }
val sliderBackdrop = rememberLayerBackdrop() val sliderBackdrop = rememberLayerBackdrop()
@@ -427,71 +431,87 @@ fun StyledSlider(
) )
translationY = if (startLabel != null || endLabel != null) trackPositionState.floatValue + with(density) { 26.dp.toPx() } + size.height / 2f else trackPositionState.floatValue + with(density) { 8.dp.toPx() } translationY = if (startLabel != null || endLabel != null) trackPositionState.floatValue + with(density) { 26.dp.toPx() } + size.height / 2f else trackPositionState.floatValue + with(density) { 8.dp.toPx() }
} }
.draggable( .then(
rememberDraggableState { delta -> if (enabled) {
val trackWidth = trackWidthState.floatValue Modifier
if (trackWidth > 0f) { .draggable(
val targetFraction = fraction + delta / trackWidth rememberDraggableState { delta ->
val targetValue = val trackWidth = trackWidthState.floatValue
lerp(valueRange.start, valueRange.endInclusive, targetFraction) if (trackWidth > 0f) {
.fastCoerceIn(valueRange.start, valueRange.endInclusive) val targetFraction = fraction + delta / trackWidth
val snappedValue = if (snapPoints.isNotEmpty()) snapIfClose( val targetValue =
targetValue, lerp(
snapPoints, valueRange.start,
snapThreshold valueRange.endInclusive,
) else targetValue targetFraction
onValueChange(snappedValue) )
} .fastCoerceIn(
}, valueRange.start,
Orientation.Horizontal, valueRange.endInclusive
startDragImmediately = true, )
onDragStarted = { val snappedValue = if (snapPoints.isNotEmpty()) snapIfClose(
// Remove this block as momentumAnimation handles pressing targetValue,
}, snapPoints,
onDragStopped = { snapThreshold
// Remove this block as momentumAnimation handles pressing ) else targetValue
onValueChange((mutableFloatState.floatValue * 100).roundToInt() / 100f) onValueChange(snappedValue)
} }
) },
.then(momentumAnimation.modifier) Orientation.Horizontal,
.drawBackdrop( startDragImmediately = true,
rememberCombinedBackdrop(backdrop, sliderBackdrop), onDragStarted = {
{ RoundedCornerShape(28.dp) }, // Remove this block as momentumAnimation handles pressing
highlight = { },
val progress = momentumAnimation.progress onDragStopped = {
Highlight.Ambient.copy(alpha = progress) // Remove this block as momentumAnimation handles pressing
}, onValueChange((value * 100).roundToInt() / 100f)
shadow = { }
Shadow( )
radius = 4f.dp, .then(momentumAnimation.modifier)
color = Color.Black.copy(0.05f) .drawBackdrop(
) rememberCombinedBackdrop(backdrop, sliderBackdrop),
}, { RoundedCornerShape(28.dp) },
innerShadow = { highlight = {
val progress = momentumAnimation.progress val progress = momentumAnimation.progress
InnerShadow( Highlight.Ambient.copy(alpha = progress)
radius = 4f.dp * progress, },
alpha = progress shadow = {
) Shadow(
}, radius = 4f.dp,
layerBlock = { color = Color.Black.copy(0.05f)
scaleX = momentumAnimation.scaleX )
scaleY = momentumAnimation.scaleY },
val velocity = momentumAnimation.velocity / 5000f innerShadow = {
scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.15f, 0.15f) val progress = momentumAnimation.progress
scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(-0.15f, 0.15f) InnerShadow(
}, radius = 4f.dp * progress,
onDrawSurface = { alpha = progress
val progress = momentumAnimation.progress )
drawRect(Color.White.copy(alpha = 1f - progress)) },
}, layerBlock = {
effects = { scaleX = momentumAnimation.scaleX
val progress = momentumAnimation.progress scaleY = momentumAnimation.scaleY
blur(8f.dp.toPx() * (1f - progress)) val velocity = momentumAnimation.velocity / 5000f
refractionWithDispersion( scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.15f, 0.15f)
height = 6f.dp.toPx() * progress, scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(-0.15f, 0.15f)
amount = size.height / 2f * progress },
) onDrawSurface = {
val progress = momentumAnimation.progress
drawRect(Color.White.copy(alpha = 1f - progress))
},
effects = {
val progress = momentumAnimation.progress
blur(8f.dp.toPx() * (1f - progress))
lens(
refractionHeight = 6f.dp.toPx() * progress,
refractionAmount = size.height / 2f * progress,
depthEffect = true,
chromaticAberration = true
)
}
)
} else {
Modifier.background(trackColor, RoundedCornerShape(28.dp))
} }
) )
.size(40f.dp, 24f.dp) .size(40f.dp, 24f.dp)
@@ -566,12 +586,13 @@ fun StyledSliderPreview() {
.padding(16.dp) .padding(16.dp)
.fillMaxSize() .fillMaxSize()
) { ) {
Box ( Column (
Modifier.align(Alignment.Center) Modifier.align(Alignment.Center),
verticalArrangement = Arrangement.spacedBy(16.dp)
) )
{ {
StyledSlider( StyledSlider(
mutableFloatState = a, value = a.floatValue,
onValueChange = { onValueChange = {
a.floatValue = it a.floatValue = it
}, },
@@ -582,6 +603,19 @@ fun StyledSliderPreview() {
startIcon = "A", startIcon = "A",
endIcon = "B", endIcon = "B",
) )
StyledSlider(
value = a.floatValue,
onValueChange = {
a.floatValue = it
},
valueRange = 0f..2f,
snapPoints = listOf(1f),
snapThreshold = 0.1f,
independent = true,
startIcon = "A",
endIcon = "B",
enabled = false
)
} }
} }
} }
@@ -19,7 +19,7 @@
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables
import android.content.res.Configuration import android.content.res.Configuration
import androidx.compose.animation.Animatable import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
@@ -68,7 +68,7 @@ import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberCombinedBackdrop import com.kyant.backdrop.backdrops.rememberCombinedBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.refractionWithDispersion import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.highlight.Highlight import com.kyant.backdrop.highlight.Highlight
import com.kyant.backdrop.shadow.Shadow import com.kyant.backdrop.shadow.Shadow
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
@@ -100,22 +100,18 @@ fun StyledSwitch(
val density = LocalDensity.current val density = LocalDensity.current
val animationScope = rememberCoroutineScope() val animationScope = rememberCoroutineScope()
val progressAnimationSpec = spring(0.5f, 300f, 0.001f) val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
val colorAnimationSpec = tween<Color>(200, easing = FastOutSlowInEasing)
val progressAnimation = remember { Animatable(0f) } val progressAnimation = remember { Animatable(0f) }
val innerShadowLayer = rememberGraphicsLayer().apply { val innerShadowLayer = rememberGraphicsLayer().apply {
compositingStrategy = CompositingStrategy.Offscreen compositingStrategy = CompositingStrategy.Offscreen
} }
val animatedTrackColor = remember { Animatable(if (checked) onColor else offColor) } val targetColor = if (checked) onColor else offColor
val animatedTrackColor by animateColorAsState(targetColor)
val totalDrag = remember { mutableFloatStateOf(0f) } val totalDrag = remember { mutableFloatStateOf(0f) }
val tapThreshold = 10f val tapThreshold = 10f
val isFirstComposition = remember { mutableStateOf(true) } val isFirstComposition = remember { mutableStateOf(true) }
LaunchedEffect(checked) { LaunchedEffect(checked) {
if (!isFirstComposition.value) { if (!isFirstComposition.value) {
coroutineScope { coroutineScope {
launch {
val targetColor = if (checked) onColor else offColor
animatedTrackColor.animateTo(targetColor, colorAnimationSpec)
}
launch { launch {
val targetFrac = if (checked) 1f else 0f val targetFrac = if (checked) 1f else 0f
animatedFraction.animateTo(targetFrac, progressAnimationSpec) animatedFraction.animateTo(targetFrac, progressAnimationSpec)
@@ -140,7 +136,7 @@ fun StyledSwitch(
modifier = Modifier modifier = Modifier
.layerBackdrop(switchBackdrop) .layerBackdrop(switchBackdrop)
.clip(RoundedCornerShape(trackHeight / 2)) .clip(RoundedCornerShape(trackHeight / 2))
.background(animatedTrackColor.value) .background(animatedTrackColor)
.width(trackWidth) .width(trackWidth)
.height(trackHeight) .height(trackHeight)
.onSizeChanged { trackWidthPx.floatValue = it.width.toFloat() } .onSizeChanged { trackWidthPx.floatValue = it.width.toFloat() }
@@ -262,7 +258,12 @@ fun StyledSwitch(
drawRect(Color.White.copy(1f - progress)) drawRect(Color.White.copy(1f - progress))
}, },
effects = { effects = {
refractionWithDispersion(6f.dp.toPx(), size.height / 2f) lens(
refractionHeight = 6f.dp.toPx(),
refractionAmount = size.height / 2f,
depthEffect = true,
chromaticAberration = true
)
} }
) )
.width(thumbWidth) .width(thumbWidth)
@@ -20,8 +20,6 @@
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables
import android.content.SharedPreferences
import android.util.Log
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -39,18 +37,15 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
@@ -58,11 +53,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.edit
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@Composable @Composable
@@ -70,32 +61,27 @@ fun StyledToggle(
title: String? = null, title: String? = null,
label: String, label: String,
description: String? = null, description: String? = null,
checkedState: MutableState<Boolean> = remember { mutableStateOf(false) } , checked: Boolean = false,
sharedPreferenceKey: String? = null,
sharedPreferences: SharedPreferences? = null,
independent: Boolean = true, independent: Boolean = true,
enabled: Boolean = true, enabled: Boolean = true,
onCheckedChange: ((Boolean) -> Unit)? = null, onCheckedChange: (Boolean) -> Unit,
) { ) {
val currentChecked by rememberUpdatedState(checked)
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
var checked by checkedState
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) } var backgroundColor by remember {
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500)) mutableStateOf(
if (sharedPreferenceKey != null && sharedPreferences != null) { if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked) )
}
fun cb() {
if (sharedPreferences != null) {
if (sharedPreferenceKey == null) {
Log.e("StyledToggle", "SharedPreferenceKey is null but SharedPreferences is provided.")
return
}
sharedPreferences.edit { putBoolean(sharedPreferenceKey, checked) }
}
onCheckedChange?.invoke(checked)
} }
val animatedBackgroundColor by animateColorAsState(
targetValue = backgroundColor,
animationSpec = tween(durationMillis = 500)
)
if (independent) { if (independent) {
Column(modifier = Modifier.padding(vertical = 8.dp)) { Column(modifier = Modifier.padding(vertical = 8.dp)) {
if (title != null) { if (title != null) {
@@ -106,9 +92,15 @@ fun StyledToggle(
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f) color = textColor.copy(alpha = 0.6f)
), ),
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp) modifier = Modifier.padding(
start = 16.dp,
end = 16.dp,
top = 8.dp,
bottom = 4.dp
)
) )
} }
Box( Box(
modifier = Modifier modifier = Modifier
.background(animatedBackgroundColor, RoundedCornerShape(28.dp)) .background(animatedBackgroundColor, RoundedCornerShape(28.dp))
@@ -124,8 +116,7 @@ fun StyledToggle(
}, },
onTap = { onTap = {
if (enabled) { if (enabled) {
checked = !checked onCheckedChange(!currentChecked)
cb()
} }
} }
) )
@@ -148,24 +139,29 @@ fun StyledToggle(
color = textColor color = textColor
) )
) )
StyledSwitch( StyledSwitch(
checked = checked, checked = checked,
enabled = enabled, enabled = enabled,
onCheckedChange = { onCheckedChange = {
if (enabled) { if (enabled) {
checked = it onCheckedChange(it)
cb()
} }
} }
) )
} }
} }
if (description != null) { if (description != null) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Box( Box(
modifier = Modifier modifier = Modifier
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)) .background(
if (isDarkTheme) Color(0xFF000000)
else Color(0xFFF2F2F7)
)
) { ) {
Text( Text(
text = description, text = description,
@@ -181,6 +177,7 @@ fun StyledToggle(
} }
} else { } else {
val isPressed = remember { mutableStateOf(false) } val isPressed = remember { mutableStateOf(false) }
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -203,8 +200,7 @@ fun StyledToggle(
interactionSource = remember { MutableInteractionSource() } interactionSource = remember { MutableInteractionSource() }
) { ) {
if (enabled) { if (enabled) {
checked = !checked onCheckedChange(!currentChecked)
cb()
} }
}, },
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
@@ -223,7 +219,9 @@ fun StyledToggle(
color = textColor color = textColor
) )
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
if (description != null) { if (description != null) {
Text( Text(
text = description, text = description,
@@ -235,438 +233,13 @@ fun StyledToggle(
) )
} }
} }
StyledSwitch( StyledSwitch(
checked = checked, checked = checked,
enabled = enabled, enabled = enabled,
onCheckedChange = { onCheckedChange = {
if (enabled) { if (enabled) {
checked = it onCheckedChange(it)
cb()
}
}
)
}
}
}
@Composable
fun StyledToggle(
title: String? = null,
label: String,
description: String? = null,
controlCommandIdentifier: AACPManager.Companion.ControlCommandIdentifiers,
independent: Boolean = true,
enabled: Boolean = true,
sharedPreferenceKey: String? = null,
sharedPreferences: SharedPreferences? = null,
onCheckedChange: ((Boolean) -> Unit)? = null,
) {
val service = ServiceManager.getService() ?: return
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val checkedValue = service.aacpManager.controlCommandStatusList.find {
it.identifier == controlCommandIdentifier
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
var checked by remember { mutableStateOf(checkedValue == 1.toByte()) }
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
if (sharedPreferenceKey != null && sharedPreferences != null) {
checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked)
}
fun cb() {
service.aacpManager.sendControlCommand(identifier = controlCommandIdentifier.value, value = checked)
if (sharedPreferences != null) {
if (sharedPreferenceKey == null) {
Log.e("StyledToggle", "SharedPreferenceKey is null but SharedPreferences is provided.")
return
}
sharedPreferences.edit { putBoolean(sharedPreferenceKey, checked) }
}
onCheckedChange?.invoke(checked)
}
val listener = remember {
object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (controlCommand.identifier == controlCommandIdentifier.value) {
Log.d("StyledToggle", "Received control command for $label: ${controlCommand.value}")
checked = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0) == 1.toByte()
}
}
}
}
LaunchedEffect(Unit) {
service.aacpManager.registerControlCommandListener(controlCommandIdentifier, listener)
}
DisposableEffect(Unit) {
onDispose {
service.aacpManager.unregisterControlCommandListener(controlCommandIdentifier, listener)
}
}
if (independent) {
Column(modifier = Modifier.padding(vertical = 8.dp)) {
if (title != null) {
Text(
text = title,
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f)
),
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp)
)
}
Box(
modifier = Modifier
.background(animatedBackgroundColor, RoundedCornerShape(28.dp))
.padding(4.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
backgroundColor =
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColor =
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
},
onTap = {
if (enabled) {
checked = !checked
cb()
}
}
)
}
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(55.dp)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
modifier = Modifier.weight(1f),
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Normal,
color = textColor
)
)
StyledSwitch(
checked = checked,
enabled = enabled,
onCheckedChange = {
if (enabled) {
checked = it
cb()
}
}
)
}
}
if (description != null) {
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.padding(horizontal = 16.dp)
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
) {
Text(
text = description,
style = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
}
} else {
val isPressed = remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.background(
shape = RoundedCornerShape(28.dp),
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
)
.padding(16.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
isPressed.value = true
tryAwaitRelease()
isPressed.value = false
}
)
}
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
if (enabled) {
checked = !checked
cb()
}
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
) {
Text(
text = label,
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Normal,
color = textColor
)
)
Spacer(modifier = Modifier.height(4.dp))
if (description != null) {
Text(
text = description,
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
)
}
}
StyledSwitch(
checked = checked,
enabled = enabled,
onCheckedChange = {
if (enabled) {
checked = it
cb()
}
}
)
}
}
}
@Composable
fun StyledToggle(
title: String? = null,
label: String,
description: String? = null,
attHandle: ATTHandles,
independent: Boolean = true,
enabled: Boolean = true,
sharedPreferenceKey: String? = null,
sharedPreferences: SharedPreferences? = null,
onCheckedChange: ((Boolean) -> Unit)? = null,
) {
val attManager = ServiceManager.getService()?.attManager ?: return
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val checkedValue = try {
attManager.read(attHandle).getOrNull(0)?.toInt()
} catch (e: Exception) {
Log.w("StyledToggle", "Error reading initial value for $label: ${e.message}")
null
} ?: 0
var checked by remember { mutableStateOf(checkedValue !=0) }
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
attManager.enableNotifications(attHandle)
if (sharedPreferenceKey != null && sharedPreferences != null) {
checked = sharedPreferences.getBoolean(sharedPreferenceKey, checked)
}
fun cb() {
if (sharedPreferences != null) {
if (sharedPreferenceKey == null) {
Log.e("StyledToggle", "SharedPreferenceKey is null but SharedPreferences is provided.")
return
}
sharedPreferences.edit { putBoolean(sharedPreferenceKey, checked) }
}
onCheckedChange?.invoke(checked)
}
LaunchedEffect(checked) {
if (attManager.socket?.isConnected != true) return@LaunchedEffect
attManager.write(attHandle, if (checked) byteArrayOf(1) else byteArrayOf(0))
}
val listener = remember {
object : (ByteArray) -> Unit {
override fun invoke(value: ByteArray) {
if (value.isNotEmpty()) {
checked = value[0].toInt() != 0
Log.d("StyledToggle", "Updated from notification for $label: enabled=$checked")
} else {
Log.w("StyledToggle", "Empty value in notification for $label")
}
}
}
}
LaunchedEffect(Unit) {
attManager.registerListener(attHandle, listener)
}
DisposableEffect(Unit) {
onDispose {
attManager.unregisterListener(attHandle, listener)
}
}
if (independent) {
Column(modifier = Modifier.padding(vertical = 8.dp)) {
if (title != null) {
Text(
text = title,
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f)
),
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp)
)
}
Box(
modifier = Modifier
.background(animatedBackgroundColor, RoundedCornerShape(28.dp))
.padding(4.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
backgroundColor =
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColor =
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
},
onTap = {
if (enabled) {
checked = !checked
cb()
}
}
)
}
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(55.dp)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
modifier = Modifier.weight(1f),
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Normal,
color = textColor
)
)
StyledSwitch(
checked = checked,
enabled = enabled,
onCheckedChange = {
if (enabled) {
checked = it
cb()
}
}
)
}
}
if (description != null) {
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.padding(horizontal = 16.dp)
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
) {
Text(
text = description,
style = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
}
} else {
val isPressed = remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.background(
shape = RoundedCornerShape(28.dp),
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
)
.padding(16.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
isPressed.value = true
tryAwaitRelease()
isPressed.value = false
}
)
}
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
if (enabled) {
checked = !checked
cb()
}
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
) {
Text(
text = label,
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
if (description != null) {
Text(
text = description,
fontSize = 12.sp,
color = textColor.copy(0.6f),
lineHeight = 14.sp,
)
}
}
StyledSwitch(
checked = checked,
enabled = enabled,
onCheckedChange = {
if (enabled) {
checked = it
cb()
} }
} }
) )
@@ -677,11 +250,11 @@ fun StyledToggle(
@Preview @Preview
@Composable @Composable
fun StyledTogglePreview() { fun StyledTogglePreview() {
val context = LocalContext.current val checked = remember { mutableStateOf(false) }
val sharedPrefs = context.getSharedPreferences("preview", 0)
StyledToggle( StyledToggle(
label = "Example Toggle", label = "Example Toggle",
description = "This is an example description for the styled toggle.", description = "This is an example description for the styled toggle.",
sharedPreferences = sharedPrefs checked = checked.value,
onCheckedChange = { checked.value = !checked.value }
) )
} }
@@ -80,6 +80,8 @@ class AirPodsNotifications {
const val AIRPODS_DISCONNECTED = "me.kavishdevar.librepods.AIRPODS_DISCONNECTED" const val AIRPODS_DISCONNECTED = "me.kavishdevar.librepods.AIRPODS_DISCONNECTED"
const val AIRPODS_CONNECTION_DETECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTION_DETECTED" const val AIRPODS_CONNECTION_DETECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTION_DETECTED"
const val DISCONNECT_RECEIVERS = "me.kavishdevar.librepods.DISCONNECT_RECEIVERS" const val DISCONNECT_RECEIVERS = "me.kavishdevar.librepods.DISCONNECT_RECEIVERS"
const val EQ_DATA = "me.kavishdevar.librepods.EQ_DATA"
const val AIRPODS_INFORMATION_UPDATED = "me.kavishdevar.librepods.AIRPODS_INFORMATION_UPDATED"
} }
class EarDetection { class EarDetection {
@@ -0,0 +1,63 @@
/*
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.data
import me.kavishdevar.librepods.utils.AACPManager
class ControlCommandRepository(
private val aacpManager: AACPManager
) {
fun getValue(
identifier: AACPManager.Companion.ControlCommandIdentifiers
): ByteArray? {
return aacpManager.controlCommandStatusList
.find { it.identifier == identifier }
?.value
}
fun setValue(
id: AACPManager.Companion.ControlCommandIdentifiers,
value: ByteArray
) {
aacpManager.sendControlCommand(id.value, value)
}
fun observe(
identifier: AACPManager.Companion.ControlCommandIdentifiers,
onChange: (ByteArray) -> Unit
): AACPManager.ControlCommandListener {
val listener = object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
onChange(controlCommand.value)
}
}
aacpManager.registerControlCommandListener(identifier, listener)
return listener
}
fun remove(
identifier: AACPManager.Companion.ControlCommandIdentifiers,
listener: AACPManager.ControlCommandListener
) {
aacpManager.unregisterControlCommandListener(identifier, listener)
}
}
@@ -18,8 +18,8 @@
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.screens
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.util.Log
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
@@ -39,10 +39,8 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -69,74 +67,35 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope import me.kavishdevar.librepods.BuildConfig
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.R
import me.kavishdevar.librepods.composables.NavigationButton import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.composables.StyledDropdown import me.kavishdevar.librepods.composables.StyledDropdown
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider import me.kavishdevar.librepods.composables.StyledSlider
import me.kavishdevar.librepods.composables.StyledToggle import me.kavishdevar.librepods.composables.StyledToggle
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.Capability import me.kavishdevar.librepods.utils.Capability
// import me.kavishdevar.librepods.utils.RadareOffsetFinder import me.kavishdevar.librepods.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
private var phoneMediaDebounceJob: Job? = null //private var phoneMediaDebounceJob: Job? = null
private var toneVolumeDebounceJob: Job? = null //private var toneVolumeDebounceJob: Job? = null
private const val TAG = "AccessibilitySettings" //private const val TAG = "AccessibilitySettings"
@SuppressLint("DefaultLocale") @SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable @Composable
fun AccessibilitySettingsScreen(navController: NavController) { fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavController) {
val state by viewModel.uiState.collectAsState()
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
val isSdpOffsetAvailable = remember { mutableStateOf(false) } // always available rn, for testing without radare
// remember { mutableStateOf(RadareOffsetFinder.isSdpOffsetAvailable()) }
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491) val hearingAidEnabled = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(1)?.toInt() == 1 && state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(0)?.toInt() == 1
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
val capabilities = remember { ServiceManager.getService()?.airpodsInstance?.model?.capabilities ?: emptySet<Capability>() }
val hearingAidEnabled = remember { mutableStateOf(
aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }?.value?.getOrNull(1) == 0x01.toByte() &&
aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }?.value?.getOrNull(0) == 0x01.toByte()
) }
val hearingAidListener = remember {
object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value ||
controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value) {
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
hearingAidEnabled.value = (aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())
}
}
}
}
LaunchedEffect(Unit) {
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
}
DisposableEffect(Unit) {
onDispose {
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
}
}
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
@@ -153,170 +112,73 @@ fun AccessibilitySettingsScreen(navController: NavController) {
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Spacer(modifier = Modifier.height(spacerHeight)) Spacer(modifier = Modifier.height(spacerHeight))
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) } // val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
val phoneEQEnabled = remember { mutableStateOf(false) } // val phoneEQEnabled = remember { mutableStateOf(false) }
val mediaEQEnabled = remember { mutableStateOf(false) } // val mediaEQEnabled = remember { mutableStateOf(false) }
val pressSpeedOptions = mapOf( val pressSpeedOptions = mapOf(
0.toByte() to stringResource(R.string.default_option), 0.toByte() to stringResource(R.string.default_option),
1.toByte() to stringResource(R.string.slower), 1.toByte() to stringResource(R.string.slower),
2.toByte() to stringResource(R.string.slowest) 2.toByte() to stringResource(R.string.slowest)
) )
val selectedPressSpeedValue =
aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL }?.value?.takeIf { it.isNotEmpty() } val selectedPressSpeedValue = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL]?.getOrNull(0)
?.get(0)
var selectedPressSpeed by remember { var selectedPressSpeed by remember {
mutableStateOf( mutableStateOf(
pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0] pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0]
) )
} }
val selectedPressSpeedListener = object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL.value) {
val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
selectedPressSpeed = pressSpeedOptions[newValue] ?: pressSpeedOptions[0]
}
}
}
LaunchedEffect(Unit) {
aacpManager?.registerControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
selectedPressSpeedListener
)
}
DisposableEffect(Unit) {
onDispose {
aacpManager?.unregisterControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
selectedPressSpeedListener
)
}
}
val pressAndHoldDurationOptions = mapOf( val pressAndHoldDurationOptions = mapOf(
0.toByte() to stringResource(R.string.default_option), 0.toByte() to stringResource(R.string.default_option),
1.toByte() to stringResource(R.string.slower), 1.toByte() to stringResource(R.string.slower),
2.toByte() to stringResource(R.string.slowest) 2.toByte() to stringResource(R.string.slowest)
) )
val selectedPressAndHoldDurationValue =
aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL }?.value?.takeIf { it.isNotEmpty() } val selectedPressAndHoldDurationValue = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL]?.getOrNull(0)
?.get(0)
var selectedPressAndHoldDuration by remember { var selectedPressAndHoldDuration by remember {
mutableStateOf( mutableStateOf(
pressAndHoldDurationOptions[selectedPressAndHoldDurationValue] pressAndHoldDurationOptions[selectedPressAndHoldDurationValue]
?: pressAndHoldDurationOptions[0] ?: pressAndHoldDurationOptions[0]
) )
} }
val selectedPressAndHoldDurationListener = object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL.value) {
val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
selectedPressAndHoldDuration =
pressAndHoldDurationOptions[newValue] ?: pressAndHoldDurationOptions[0]
}
}
}
LaunchedEffect(Unit) {
aacpManager?.registerControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
selectedPressAndHoldDurationListener
)
}
DisposableEffect(Unit) {
onDispose {
aacpManager?.unregisterControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
selectedPressAndHoldDurationListener
)
}
}
val volumeSwipeSpeedOptions = mapOf( val volumeSwipeSpeedOptions = mapOf(
1.toByte() to stringResource(R.string.default_option), 1.toByte() to stringResource(R.string.default_option),
2.toByte() to stringResource(R.string.longer), 2.toByte() to stringResource(R.string.longer),
3.toByte() to stringResource(R.string.longest) 3.toByte() to stringResource(R.string.longest)
) )
val selectedVolumeSwipeSpeedValue = val selectedVolumeSwipeSpeedValue = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL]?.getOrNull(0)
aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL }?.value?.takeIf { it.isNotEmpty() }
?.get(0)
var selectedVolumeSwipeSpeed by remember { var selectedVolumeSwipeSpeed by remember {
mutableStateOf( mutableStateOf(
volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue] volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue]
?: volumeSwipeSpeedOptions[1] ?: volumeSwipeSpeedOptions[1]
) )
} }
val selectedVolumeSwipeSpeedListener = object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL.value) {
val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
selectedVolumeSwipeSpeed =
volumeSwipeSpeedOptions[newValue] ?: volumeSwipeSpeedOptions[1]
}
}
}
LaunchedEffect(Unit) {
aacpManager?.registerControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
selectedVolumeSwipeSpeedListener
)
}
DisposableEffect(Unit) {
onDispose {
aacpManager?.unregisterControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
selectedVolumeSwipeSpeedListener
)
}
}
LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) { // LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) {
phoneMediaDebounceJob?.cancel() // phoneMediaDebounceJob?.cancel()
phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch { // phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch {
delay(150) // delay(150)
val manager = ServiceManager.getService()?.aacpManager // val manager = ServiceManager.getService()?.aacpManager
if (manager == null) { // if (manager == null) {
Log.w(TAG, "Cannot write EQ: AACPManager not available") // Log.w(TAG, "Cannot write EQ: AACPManager not available")
return@launch // return@launch
} // }
try { // try {
val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte() // val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte()
val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte() // val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte()
Log.d( // Log.d(
TAG, // TAG,
"Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})" // "Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})"
) // )
manager.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte) // manager.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte)
} catch (e: Exception) { // } catch (e: Exception) {
Log.w(TAG, "Error sending phone/media EQ: ${e.message}") // Log.w(TAG, "Error sending phone/media EQ: ${e.message}")
} // }
} // }
} // }
val toneVolumeValue = remember { mutableFloatStateOf(
aacpManager?.controlCommandStatusList?.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME
}?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toFloat() ?: 75f
) }
LaunchedEffect(toneVolumeValue.floatValue) {
toneVolumeDebounceJob?.cancel()
toneVolumeDebounceJob = CoroutineScope(Dispatchers.IO).launch {
delay(150)
val manager = ServiceManager.getService()?.aacpManager
if (manager == null) {
Log.w(TAG, "Cannot write tone volume: AACPManager not available")
return@launch
}
try {
manager.sendControlCommand(
identifier = AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME.value,
value = byteArrayOf(toneVolumeValue.floatValue.toInt().toByte(), 0x50.toByte())
)
} catch (e: Exception) {
Log.w(TAG, "Error sending tone volume: ${e.message}")
}
}
}
DropdownMenuComponent( DropdownMenuComponent(
label = stringResource(R.string.press_speed), label = stringResource(R.string.press_speed),
@@ -325,8 +187,8 @@ fun AccessibilitySettingsScreen(navController: NavController) {
selectedOption = selectedPressSpeed?: stringResource(R.string.default_option), selectedOption = selectedPressSpeed?: stringResource(R.string.default_option),
onOptionSelected = { newValue -> onOptionSelected = { newValue ->
selectedPressSpeed = newValue selectedPressSpeed = newValue
aacpManager?.sendControlCommand( viewModel.setControlCommandByte(
identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL.value, identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
value = pressSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() value = pressSpeedOptions.filterValues { it == newValue }.keys.firstOrNull()
?: 0.toByte() ?: 0.toByte()
) )
@@ -343,8 +205,8 @@ fun AccessibilitySettingsScreen(navController: NavController) {
selectedOption = selectedPressAndHoldDuration?: stringResource(R.string.default_option), selectedOption = selectedPressAndHoldDuration?: stringResource(R.string.default_option),
onOptionSelected = { newValue -> onOptionSelected = { newValue ->
selectedPressAndHoldDuration = newValue selectedPressAndHoldDuration = newValue
aacpManager?.sendControlCommand( viewModel.setControlCommandByte(
identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL.value, identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
value = pressAndHoldDurationOptions.filterValues { it == newValue }.keys.firstOrNull() value = pressAndHoldDurationOptions.filterValues { it == newValue }.keys.firstOrNull()
?: 0.toByte() ?: 0.toByte()
) )
@@ -358,19 +220,21 @@ fun AccessibilitySettingsScreen(navController: NavController) {
title = stringResource(R.string.noise_control), title = stringResource(R.string.noise_control),
label = stringResource(R.string.noise_cancellation_single_airpod), label = stringResource(R.string.noise_cancellation_single_airpod),
description = stringResource(R.string.noise_cancellation_single_airpod_description), description = stringResource(R.string.noise_cancellation_single_airpod_description),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE,
independent = true, independent = true,
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE]?.getOrNull(0) == 0x01.toByte(),
onCheckedChange = { viewModel.setControlCommandBoolean(AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE, it) }
) )
if (capabilities.contains(Capability.LOUD_SOUND_REDUCTION)) { if (state.capabilities.contains(Capability.LOUD_SOUND_REDUCTION) && BuildConfig.FLAVOR == "xposed") {
StyledToggle( StyledToggle(
label = stringResource(R.string.loud_sound_reduction), label = stringResource(R.string.loud_sound_reduction),
description = stringResource(R.string.loud_sound_reduction_description), description = stringResource(R.string.loud_sound_reduction_description),
attHandle = ATTHandles.LOUD_SOUND_REDUCTION checked = viewModel.getATTCharacteristicValue(ATTHandles.LOUD_SOUND_REDUCTION)?.get(0) == 1.toByte(),
onCheckedChange = { viewModel.setATTCharacteristicValue(ATTHandles.LOUD_SOUND_REDUCTION, if (it) byteArrayOf(0x01) else byteArrayOf(0x00)) }
) )
} }
if (!hearingAidEnabled.value&& isSdpOffsetAvailable.value) { if (!hearingAidEnabled && BuildConfig.FLAVOR == "xposed") {
NavigationButton( NavigationButton(
to = "transparency_customization", to = "transparency_customization",
name = stringResource(R.string.customize_transparency_mode), name = stringResource(R.string.customize_transparency_mode),
@@ -378,12 +242,13 @@ fun AccessibilitySettingsScreen(navController: NavController) {
) )
} }
val toneVolumeValue = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME]?.getOrNull(0)?.toFloat() ?: 75f
StyledSlider( StyledSlider(
label = stringResource(R.string.tone_volume), label = stringResource(R.string.tone_volume),
description = stringResource(R.string.tone_volume_description), description = stringResource(R.string.tone_volume_description),
mutableFloatState = toneVolumeValue, value = toneVolumeValue,
onValueChange = { onValueChange = {
toneVolumeValue.floatValue = it viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME, byteArrayOf(it.toInt().toByte(), 0x50))
}, },
valueRange = 0f..100f, valueRange = 0f..100f,
snapPoints = listOf(75f), snapPoints = listOf(75f),
@@ -392,11 +257,13 @@ fun AccessibilitySettingsScreen(navController: NavController) {
independent = true independent = true
) )
if (capabilities.contains(Capability.SWIPE_FOR_VOLUME)) { if (state.capabilities.contains(Capability.SWIPE_FOR_VOLUME)) {
val volumeSwipeEnabled = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE]?.getOrNull(0)?.toInt() == 0x01
StyledToggle( StyledToggle(
label = stringResource(R.string.volume_control), label = stringResource(R.string.volume_control),
description = stringResource(R.string.volume_control_description), description = stringResource(R.string.volume_control_description),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE, checked = volumeSwipeEnabled,
onCheckedChange = { viewModel.setControlCommandBoolean(AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE, it) }
) )
DropdownMenuComponent( DropdownMenuComponent(
@@ -406,8 +273,8 @@ fun AccessibilitySettingsScreen(navController: NavController) {
selectedOption = selectedVolumeSwipeSpeed?: stringResource(R.string.default_option), selectedOption = selectedVolumeSwipeSpeed?: stringResource(R.string.default_option),
onOptionSelected = { newValue -> onOptionSelected = { newValue ->
selectedVolumeSwipeSpeed = newValue selectedVolumeSwipeSpeed = newValue
aacpManager?.sendControlCommand( viewModel.setControlCommandByte(
identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL.value, identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull()
?: 1.toByte() ?: 1.toByte()
) )
@@ -418,7 +285,7 @@ fun AccessibilitySettingsScreen(navController: NavController) {
) )
} }
if (!hearingAidEnabled.value&& isSdpOffsetAvailable.value) { // if (!hearingAidEnabled.value&& BuildConfig.FLAVOR == "xposed") {
// Text( // Text(
// text = stringResource(R.string.apply_eq_to), // text = stringResource(R.string.apply_eq_to),
// style = TextStyle( // style = TextStyle(
@@ -640,7 +507,7 @@ fun AccessibilitySettingsScreen(navController: NavController) {
// } // }
// } // }
// } // }
} // }
} }
} }
} }
@@ -18,84 +18,37 @@
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.screens
import android.annotation.SuppressLint
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider import me.kavishdevar.librepods.composables.StyledSlider
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi import me.kavishdevar.librepods.viewmodel.AirPodsViewModel
private var debounceJob: Job? = null
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable @Composable
fun AdaptiveStrengthScreen(navController: NavController) { fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel) {
val isDarkTheme = isSystemInDarkTheme() val state by viewModel.uiState.collectAsState()
val sliderValue = remember { mutableFloatStateOf(0f) }
val service = ServiceManager.getService()!!
LaunchedEffect(sliderValue) {
val sliderValueFromAACP = service.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
sliderValueFromAACP?.toFloat()?.let { sliderValue.floatValue = (100 - it) }
}
val listener = remember {
object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH.value) {
controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)?.toFloat()?.let {
sliderValue.floatValue = (100 - it)
}
}
}
}
}
DisposableEffect(Unit) {
service.aacpManager.registerControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH,
listener
)
onDispose {
service.aacpManager.unregisterControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH,
listener
)
}
}
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( StyledScaffold(
@@ -109,17 +62,26 @@ fun AdaptiveStrengthScreen(navController: NavController) {
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Spacer(modifier = Modifier.height(spacerHeight)) Spacer(modifier = Modifier.height(spacerHeight))
val sliderValue = remember {
mutableFloatStateOf(
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH]?.getOrNull(
0
)?.toFloat() ?: 50f
)
}
var job by remember { mutableStateOf<Job?>(null) }
val scope = rememberCoroutineScope()
StyledSlider( StyledSlider(
label = stringResource(R.string.customize_adaptive_audio), label = stringResource(R.string.customize_adaptive_audio),
mutableFloatState = sliderValue, value = sliderValue.floatValue,
onValueChange = { onValueChange = {
sliderValue.floatValue = it sliderValue.floatValue = it
debounceJob?.cancel() job?.cancel()
debounceJob = CoroutineScope(Dispatchers.Default).launch { job = scope.launch {
delay(300) delay(150)
service.aacpManager.sendControlCommand( viewModel.setControlCommandValue(
AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH.value, AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH,
(100 - it).toInt() byteArrayOf((100 - it).toInt().toByte())
) )
} }
}, },
@@ -20,16 +20,10 @@
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.screens
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Context.MODE_PRIVATE import android.content.Context.MODE_PRIVATE
import android.content.Context.RECEIVER_EXPORTED
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -46,10 +40,10 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -65,23 +59,19 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.highlight.Highlight import com.kyant.backdrop.highlight.Highlight
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.launch import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.AboutCard import me.kavishdevar.librepods.composables.AboutCard
import me.kavishdevar.librepods.composables.AudioSettings import me.kavishdevar.librepods.composables.AudioSettings
import me.kavishdevar.librepods.composables.BatteryView import me.kavishdevar.librepods.composables.BatteryView
import me.kavishdevar.librepods.composables.CallControlSettings import me.kavishdevar.librepods.composables.CallControlSettings
import me.kavishdevar.librepods.composables.ConfirmationDialog
import me.kavishdevar.librepods.composables.ConnectionSettings import me.kavishdevar.librepods.composables.ConnectionSettings
import me.kavishdevar.librepods.composables.HearingHealthSettings import me.kavishdevar.librepods.composables.HearingHealthSettings
import me.kavishdevar.librepods.composables.MicrophoneSettings import me.kavishdevar.librepods.composables.MicrophoneSettings
@@ -92,39 +82,33 @@ import me.kavishdevar.librepods.composables.StyledButton
import me.kavishdevar.librepods.composables.StyledIconButton import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledToggle import me.kavishdevar.librepods.composables.StyledToggle
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.AirPodsPro3
import me.kavishdevar.librepods.utils.Capability import me.kavishdevar.librepods.utils.Capability
// import me.kavishdevar.librepods.utils.RadareOffsetFinder import me.kavishdevar.librepods.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag") @SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
@Composable @Composable
fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService, fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavController) {
navController: NavController, isConnected: Boolean, isRemotelyConnected: Boolean) { val state by viewModel.uiState.collectAsState()
var isLocallyConnected by remember { mutableStateOf(isConnected) }
var isRemotelyConnected by remember { mutableStateOf(isRemotelyConnected) }
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE) val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
var device by remember { mutableStateOf(dev) }
var deviceName by remember { var deviceName by remember {
mutableStateOf( mutableStateOf(
TextFieldValue( TextFieldValue(
sharedPreferences.getString("name", device?.name ?: "AirPods Pro").toString() sharedPreferences.getString("name", state.deviceName).toString()
) )
) )
} }
LaunchedEffect(service) {
isLocallyConnected = service.isConnectedLocally
}
val nameChangeListener = remember { val nameChangeListener = remember {
SharedPreferences.OnSharedPreferenceChangeListener { _, key -> SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == "name") { if (key == "name") {
deviceName = TextFieldValue(sharedPreferences.getString("name", "AirPods Pro").toString()) deviceName =
TextFieldValue(sharedPreferences.getString("name", "AirPods Pro").toString())
} }
} }
} }
@@ -137,113 +121,28 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
} }
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val coroutineScope = rememberCoroutineScope()
fun handleRemoteConnection(connected: Boolean) { LaunchedEffect(Unit) {
isRemotelyConnected = connected viewModel.refreshInitialData()
} }
val context = LocalContext.current isSystemInDarkTheme()
val connectionReceiver = remember {
object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
"me.kavishdevar.librepods.AIRPODS_CONNECTED_REMOTELY" -> {
coroutineScope.launch {
handleRemoteConnection(true)
}
}
"me.kavishdevar.librepods.AIRPODS_DISCONNECTED_REMOTELY" -> {
coroutineScope.launch {
handleRemoteConnection(false)
}
}
AirPodsNotifications.AIRPODS_CONNECTED -> {
coroutineScope.launch {
isLocallyConnected = true
}
}
AirPodsNotifications.AIRPODS_DISCONNECTED -> {
coroutineScope.launch {
isLocallyConnected = false
}
}
AirPodsNotifications.DISCONNECT_RECEIVERS -> {
try {
context?.unregisterReceiver(this)
} catch (e: IllegalArgumentException) {
e.printStackTrace()
}
}
}
}
}
}
DisposableEffect(Unit) {
val filter = IntentFilter().apply {
addAction("me.kavishdevar.librepods.AIRPODS_CONNECTED_REMOTELY")
addAction("me.kavishdevar.librepods.AIRPODS_DISCONNECTED_REMOTELY")
addAction(AirPodsNotifications.AIRPODS_CONNECTED)
addAction(AirPodsNotifications.AIRPODS_DISCONNECTED)
addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(connectionReceiver, filter, RECEIVER_EXPORTED)
} else {
context.registerReceiver(connectionReceiver, filter)
}
onDispose {
try {
context.unregisterReceiver(connectionReceiver)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
LaunchedEffect(service) {
service.let {
it.sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply {
putParcelableArrayListExtra("data", ArrayList(it.getBattery()))
})
it.sendBroadcast(Intent(AirPodsNotifications.ANC_DATA).apply {
putExtra("data", it.getANC())
})
}
}
val darkMode = isSystemInDarkTheme()
val hazeStateS = remember { mutableStateOf(HazeState()) } val hazeStateS = remember { mutableStateOf(HazeState()) }
// val showDialog = remember { mutableStateOf(!sharedPreferences.getBoolean("donationDialogShown", false)) }
val showDialog = remember { mutableStateOf(false) }
StyledScaffold( StyledScaffold(
title = deviceName.text, title = deviceName.text, actionButtons = listOf(
actionButtons = listOf( { scaffoldBackdrop ->
{scaffoldBackdrop -> StyledIconButton(
StyledIconButton( onClick = { navController.navigate("app_settings") },
onClick = { navController.navigate("app_settings") }, icon = "􀍟",
icon = "􀍟", backdrop = scaffoldBackdrop
darkMode = darkMode, )
backdrop = scaffoldBackdrop }), snackbarHostState = snackbarHostState
)
}
),
snackbarHostState = snackbarHostState
) { spacerHeight, hazeState -> ) { spacerHeight, hazeState ->
hazeStateS.value = hazeState hazeStateS.value = hazeState
if (isLocallyConnected || isRemotelyConnected) {
val instance = service.airpodsInstance if (state.isLocallyConnected) {
if (instance == null) { val capabilities = state.capabilities
Text("Error: AirPods instance is null")
return@StyledScaffold
}
val capabilities = instance.model.capabilities
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -252,7 +151,11 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
) { ) {
item(key = "spacer_top") { Spacer(modifier = Modifier.height(spacerHeight)) } item(key = "spacer_top") { Spacer(modifier = Modifier.height(spacerHeight)) }
item(key = "battery") { item(key = "battery") {
BatteryView(service = service) BatteryView(
batteryList = state.battery,
budsRes = state.instance?.model?.budsRes ?: R.drawable.airpods_pro_2_case,
caseRes = state.instance?.model?.caseRes ?: R.drawable.airpods_pro_2_case
)
} }
item(key = "spacer_battery") { Spacer(modifier = Modifier.height(32.dp)) } item(key = "spacer_battery") { Spacer(modifier = Modifier.height(32.dp)) }
@@ -265,79 +168,261 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
independent = true independent = true
) )
} }
// val actAsAppleDeviceHookEnabled = RadareOffsetFinder.isSdpOffsetAvailable()
// if (actAsAppleDeviceHookEnabled) { val hasHearingAidCapability =
// item(key = "spacer_hearing_health") { Spacer(modifier = Modifier.height(32.dp)) } state.instance?.model?.capabilities?.contains(Capability.HEARING_AID) == true
// item(key = "hearing_health") { val hasPPECapability =
// HearingHealthSettings(navController = navController) state.instance?.model?.capabilities?.contains(Capability.PPE) == true
// }
// } if (hasHearingAidCapability || hasPPECapability) {
if (hasPPECapability || (BuildConfig.FLAVOR == "xposed" && hasHearingAidCapability)) item(
key = "spacer_hearing_health"
) { Spacer(modifier = Modifier.height(24.dp)) }
item(key = "hearing_health") {
HearingHealthSettings(
navController = navController,
hasPPECapability = hasPPECapability,
hasHearingAidCapability = hasHearingAidCapability,
isXposed = BuildConfig.FLAVOR == "xposed"
)
}
}
if (capabilities.contains(Capability.LISTENING_MODE)) { if (capabilities.contains(Capability.LISTENING_MODE)) {
item(key = "spacer_noise") { Spacer(modifier = Modifier.height(16.dp)) } item(key = "spacer_noise") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "noise_control") { NoiseControlSettings(service = service) } item(key = "noise_control") {
NoiseControlSettings(
showOffListeningMode = state.offListeningMode,
noiseControlModeValue = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE]?.getOrNull(
0
)?.toInt() ?: 3,
onNoiseControlModeChanged = {
viewModel.setControlCommandInt(
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE,
it
)
},
)
}
} }
if (capabilities.contains(Capability.STEM_CONFIG)) { if (capabilities.contains(Capability.STEM_CONFIG)) {
item(key = "spacer_press_hold") { Spacer(modifier = Modifier.height(16.dp)) } item(key = "spacer_press_hold") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "press_hold") { PressAndHoldSettings(navController = navController) } item(key = "press_hold") {
PressAndHoldSettings(
navController = navController,
leftAction = state.leftAction,
rightAction = state.rightAction
)
}
} }
item(key = "spacer_call") { Spacer(modifier = Modifier.height(16.dp)) } item(key = "spacer_call") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "call_control") { CallControlSettings(hazeState = hazeState) } item(key = "call_control") {
val flipped =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG]?.take(
2
)?.equals(byteArrayOf(0x00.toByte(), 0x02.toByte()))
CallControlSettings(
hazeState = hazeState,
flipped = flipped == true,
onCallControlValueChanged = {
viewModel.setControlCommandValue(
AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG,
if (it) byteArrayOf(0x00, 0x02) else byteArrayOf(0x00, 0x03)
)
})
}
if (capabilities.contains(Capability.STEM_CONFIG)) { if (capabilities.contains(Capability.STEM_CONFIG) && !BuildConfig.PLAY_BUILD) {
item(key = "spacer_camera") { Spacer(modifier = Modifier.height(16.dp)) } item(key = "spacer_camera") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "camera_control") { NavigationButton(to = "camera_control", name = stringResource(R.string.camera_remote), description = stringResource(R.string.camera_control_description), title = stringResource(R.string.camera_control), navController = navController) } item(key = "camera_control") {
NavigationButton(
to = "camera_control",
name = stringResource(R.string.camera_remote),
description = stringResource(R.string.camera_control_description),
title = stringResource(R.string.camera_control),
navController = navController
)
}
}
item(key = "upgrade_button") {
val context = LocalContext.current
val textColor = if (isSystemInDarkTheme()) Color.White else Color.Black
if (!state.isPremium) {
Spacer(modifier = Modifier.height(28.dp))
StyledButton(
onClick = {
viewModel.purchase(context)
},
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
tint = Color(0xFF916100)
) {
Text(
stringResource(R.string.unlock_all_features),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
),
)
}
Spacer(modifier = Modifier.height(16.dp))
}
} }
item(key = "spacer_audio") { Spacer(modifier = Modifier.height(16.dp)) } item(key = "spacer_audio") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "audio") { AudioSettings(navController = navController) } item(key = "audio") {
val model = state.instance?.model ?: AirPodsPro3()
val adaptiveVolumeCapability =
model.capabilities.contains(Capability.ADAPTIVE_VOLUME)
val conversationalAwarenessCapability =
model.capabilities.contains(Capability.CONVERSATION_AWARENESS)
val loudSoundReductionCapability =
model.capabilities.contains(Capability.LOUD_SOUND_REDUCTION)
val adaptiveAudioCapability =
model.capabilities.contains(Capability.ADAPTIVE_VOLUME)
val adaptiveVolumeChecked =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG]?.getOrNull(
0
) == 0x01.toByte()
val conversationalAwarenessChecked =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG]?.getOrNull(
0
) == 0x01.toByte()
val loudSoundReduction =
viewModel.getATTCharacteristicValue(ATTHandles.LOUD_SOUND_REDUCTION)
?.getOrNull(0) == 0x01.toByte()
val isXposed = BuildConfig.FLAVOR == "xposed"
AudioSettings(
navController = navController,
adaptiveVolumeCapability = adaptiveVolumeCapability,
conversationalAwarenessCapability = conversationalAwarenessCapability,
loudSoundReductionCapability = loudSoundReductionCapability,
adaptiveAudioCapability = adaptiveAudioCapability,
adaptiveVolumeChecked = adaptiveVolumeChecked,
onAdaptiveVolumeCheckedChange = { checked ->
viewModel.setControlCommandBoolean(
AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG,
checked
)
},
conversationalAwarenessChecked = conversationalAwarenessChecked && state.isPremium,
onConversationalAwarenessCheckedChange = { checked ->
viewModel.setControlCommandBoolean(
AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
checked
)
},
loudSoundReductionChecked = loudSoundReduction,
onLoudSoundReductionCheckedChange = {
viewModel.setATTCharacteristicValue(
ATTHandles.LOUD_SOUND_REDUCTION,
byteArrayOf(if (it) 0x01.toByte() else 0x00.toByte())
)
},
isXposed = isXposed,
isPremium = state.isPremium
)
}
item(key = "spacer_connection") { Spacer(modifier = Modifier.height(16.dp)) } item(key = "spacer_connection") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "connection") { ConnectionSettings() } item(key = "connection") {
ConnectionSettings(
automaticEarDetectionEnabled = state.automaticEarDetectionEnabled,
onAutomaticEarDetectionChanged = {
viewModel.setAutomaticEarDetectionEnabled(it)
},
automaticConnectionEnabled = state.automaticConnectionEnabled,
onAutomaticConnectionChanged = { viewModel.setAutomaticConnectionEnabled(it) }
)
}
item(key = "spacer_microphone") { Spacer(modifier = Modifier.height(16.dp)) } item(key = "spacer_microphone") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "microphone") { MicrophoneSettings(hazeState) } item(key = "microphone") {
val id = AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE
MicrophoneSettings(
hazeState = hazeState,
micModeValue = state.controlStates[id]?.getOrNull(0) ?: 0x00.toByte(),
onMicModeValueChanged = { viewModel.setControlCommandByte(id, it) })
}
if (capabilities.contains(Capability.SLEEP_DETECTION)) { if (capabilities.contains(Capability.SLEEP_DETECTION)) {
item(key = "spacer_sleep") { Spacer(modifier = Modifier.height(16.dp)) } item(key = "spacer_sleep") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "sleep_detection") { item(key = "sleep_detection") {
val id =
AACPManager.Companion.ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG
StyledToggle( StyledToggle(
label = stringResource(R.string.sleep_detection), label = stringResource(R.string.sleep_detection),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG checked = state.controlStates[id]?.getOrNull(0) == 0x01.toByte(),
onCheckedChange = {
viewModel.setControlCommandBoolean(id, it)
},
enabled = state.isPremium
) )
} }
} }
if (capabilities.contains(Capability.HEAD_GESTURES)) { if (capabilities.contains(Capability.HEAD_GESTURES)) {
item(key = "spacer_head_tracking") { Spacer(modifier = Modifier.height(16.dp)) } item(key = "spacer_head_tracking") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "head_tracking") { NavigationButton(to = "head_tracking", name = stringResource(R.string.head_gestures), navController = navController, currentState = if (sharedPreferences.getBoolean("head_gestures", false)) stringResource(R.string.on) else stringResource(R.string.off)) } item(key = "head_tracking") {
NavigationButton(
to = "head_tracking",
name = stringResource(R.string.head_gestures),
navController = navController,
currentState = if (sharedPreferences.getBoolean(
"head_gestures", false
)
) stringResource(R.string.on) else stringResource(R.string.off)
)
}
} }
item(key = "spacer_accessibility") { Spacer(modifier = Modifier.height(16.dp)) } item(key = "spacer_accessibility") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "accessibility") { NavigationButton(to = "accessibility", name = stringResource(R.string.accessibility), navController = navController) } item(key = "accessibility") {
NavigationButton(
to = "accessibility",
name = stringResource(R.string.accessibility),
navController = navController
)
}
if (capabilities.contains(Capability.LOUD_SOUND_REDUCTION)){ if (capabilities.contains(Capability.LOUD_SOUND_REDUCTION)) {
item(key = "spacer_off_listening") { Spacer(modifier = Modifier.height(16.dp)) } item(key = "spacer_off_listening") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "off_listening") { item(key = "off_listening") {
val id = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
StyledToggle( StyledToggle(
label = stringResource(R.string.off_listening_mode), label = stringResource(R.string.off_listening_mode),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION, description = stringResource(R.string.off_listening_mode_description),
description = stringResource(R.string.off_listening_mode_description) checked = state.controlStates[id]?.getOrNull(0) == 0x01.toByte(),
onCheckedChange = viewModel::setOffListeningMode
) )
} }
} }
item(key = "spacer_about") { Spacer(modifier = Modifier.height(32.dp)) } item(key = "spacer_about") { Spacer(modifier = Modifier.height(32.dp)) }
item(key = "about") { AboutCard(navController = navController) } item(key = "about") {
AboutCard(
navController = navController,
modelName = state.modelName,
actualModel = state.actualModel,
serialNumbers = state.serialNumbers,
version = state.version3,
)
}
item(key = "spacer_debug") { Spacer(modifier = Modifier.height(16.dp)) } // item(key = "spacer_debug") { Spacer(modifier = Modifier.height(16.dp)) }
item(key = "debug") { NavigationButton("debug", "Debug", navController) } // item(key = "debug") { NavigationButton("debug", "Debug", navController) }
item(key = "spacer_bottom") { Spacer(Modifier.height(24.dp)) } item(key = "spacer_bottom") { Spacer(Modifier.height(24.dp)) }
} }
} } else {
else {
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
Column( Column(
modifier = Modifier modifier = Modifier
@@ -348,23 +433,20 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
shape = { RoundedCornerShape(0.dp) }, shape = { RoundedCornerShape(0.dp) },
highlight = { highlight = {
Highlight.Ambient.copy(alpha = 0f) Highlight.Ambient.copy(alpha = 0f)
} },
) effects = {})
.hazeSource(hazeState) .hazeSource(hazeState)
.padding(horizontal = 8.dp), .padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
Text( Text(
text = stringResource(R.string.airpods_not_connected), text = stringResource(R.string.airpods_not_connected), style = TextStyle(
style = TextStyle(
fontSize = 24.sp, fontSize = 24.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = if (isSystemInDarkTheme()) Color.White else Color.Black, color = if (isSystemInDarkTheme()) Color.White else Color.Black,
fontFamily = FontFamily(Font(R.font.sf_pro)) fontFamily = FontFamily(Font(R.font.sf_pro))
), ), textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
) )
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
Text( Text(
@@ -379,34 +461,30 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))
StyledButton( // StyledButton(
onClick = { navController.navigate("troubleshooting") }, // onClick = { navController.navigate("troubleshooting") },
backdrop = backdrop, // backdrop = backdrop,
modifier = Modifier // modifier = Modifier
.fillMaxWidth(0.9f) // .fillMaxWidth(0.9f)
) { // ) {
Text( // Text(
text = stringResource(R.string.troubleshooting), // text = stringResource(R.string.troubleshooting),
style = TextStyle( // style = TextStyle(
fontSize = 16.sp, // fontSize = 16.sp,
fontWeight = FontWeight.Medium, // fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)), // fontFamily = FontFamily(Font(R.font.sf_pro)),
color = if (isSystemInDarkTheme()) Color.White else Color.Black // color = if (isSystemInDarkTheme()) Color.White else Color.Black
) // )
) // )
} // }
Spacer(Modifier.height(16.dp)) // Spacer(Modifier.height(16.dp))
StyledButton( StyledButton(
onClick = { onClick = {
service.reconnectFromSavedMac() viewModel.reconnectFromSavedMac()
}, }, backdrop = backdrop, modifier = Modifier.fillMaxWidth(0.9f)
backdrop = backdrop,
modifier = Modifier
.fillMaxWidth(0.9f)
) { ) {
Text( Text(
text = stringResource(R.string.reconnect_to_last_device), text = stringResource(R.string.reconnect_to_last_device), style = TextStyle(
style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)), fontFamily = FontFamily(Font(R.font.sf_pro)),
@@ -417,37 +495,18 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
} }
} }
} }
ConfirmationDialog(
showDialog = showDialog,
title = stringResource(R.string.support_librepods),
message = stringResource(R.string.support_dialog_description),
confirmText = stringResource(R.string.support_me) + " \uDBC0\uDEB5",
dismissText = stringResource(R.string.never_show_again),
onConfirm = {
val browserIntent = Intent(
Intent.ACTION_VIEW,
"https://github.com/sponsors/kavishdevar".toUri()
)
context.startActivity(browserIntent)
sharedPreferences.edit { putBoolean("donationDialogShown", true) }
},
onDismiss = {
sharedPreferences.edit { putBoolean("donationDialogShown", true) }
},
hazeState = hazeStateS.value,
)
} }
@Preview @Preview
@Composable @Composable
fun AirPodsSettingsScreenPreview() { fun AirPodsSettingsScreenPreview() {
Column ( Column(
modifier = Modifier.height(2000.dp) modifier = Modifier.height(2000.dp)
) { ) {
LibrePodsTheme ( LibrePodsTheme(
darkTheme = true darkTheme = true
) { ) {
AirPodsSettingsScreen(dev = null, service = AirPodsService(), navController = rememberNavController(), isConnected = true, isRemotelyConnected = false) // AirPodsSettingsScreen(dev = null, service = AirPodsService(), navController = rememberNavController(), isConnected = true, isRemotelyConnected = false)
} }
} }
} }
File diff suppressed because it is too large Load Diff
@@ -18,114 +18,79 @@
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.screens
import android.annotation.SuppressLint import android.accessibilityservice.AccessibilityServiceInfo
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.provider.Settings import android.provider.Settings
import android.view.accessibility.AccessibilityManager import android.view.accessibility.AccessibilityManager
import android.accessibilityservice.AccessibilityServiceInfo
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import androidx.core.content.edit
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import 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.R
import me.kavishdevar.librepods.composables.SelectItem import me.kavishdevar.librepods.composables.SelectItem
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSelectList import me.kavishdevar.librepods.composables.StyledSelectList
import me.kavishdevar.librepods.composables.StyledSlider
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.services.AppListenerService import me.kavishdevar.librepods.services.AppListenerService
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressType import me.kavishdevar.librepods.utils.AACPManager.Companion.StemPressType
import kotlin.io.encoding.ExperimentalEncodingApi import me.kavishdevar.librepods.viewmodel.AirPodsViewModel
private var debounceJob: Job? = null
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable @Composable
fun CameraControlScreen(navController: NavController) { fun CameraControlScreen(viewModel: AirPodsViewModel) {
val isDarkTheme = isSystemInDarkTheme()
val context = LocalContext.current val context = LocalContext.current
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE) val currentCameraAction by viewModel.cameraAction.collectAsState()
val service = ServiceManager.getService()!!
var currentCameraAction by remember {
mutableStateOf(
sharedPreferences.getString("camera_action", null)?.let { StemPressType.valueOf(it) }
)
}
fun isAppListenerServiceEnabled(context: Context): Boolean { fun isAppListenerServiceEnabled(context: Context): Boolean {
val am = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager val am = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
val enabledServices = am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK) val enabledServices =
am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK)
val serviceComponent = ComponentName(context, AppListenerService::class.java) val serviceComponent = ComponentName(context, AppListenerService::class.java)
return enabledServices.any { it.resolveInfo.serviceInfo.packageName == serviceComponent.packageName && it.resolveInfo.serviceInfo.name == serviceComponent.className } return enabledServices.any {
it.resolveInfo.serviceInfo.packageName == serviceComponent.packageName &&
it.resolveInfo.serviceInfo.name == serviceComponent.className
}
} }
val cameraOptions = listOf( fun handleSelection(action: StemPressType?) {
SelectItem( if (action != null && !isAppListenerServiceEnabled(context)) {
name = stringResource(R.string.off), context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
selected = currentCameraAction == null, } else {
onClick = { viewModel.setCameraAction(action)
sharedPreferences.edit { remove("camera_action") } }
currentCameraAction = null }
}
), val cameraOptions = remember(currentCameraAction) {
SelectItem( listOf(
name = stringResource(R.string.press_once), SelectItem(
selected = currentCameraAction == StemPressType.SINGLE_PRESS, name = "Off",
onClick = { selected = currentCameraAction == null,
if (!isAppListenerServiceEnabled(context)) { onClick = { handleSelection(null) }
context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) ),
} else { SelectItem(
sharedPreferences.edit { putString("camera_action", StemPressType.SINGLE_PRESS.name) } name = "Press once",
currentCameraAction = StemPressType.SINGLE_PRESS selected = currentCameraAction == StemPressType.SINGLE_PRESS,
} onClick = { handleSelection(StemPressType.SINGLE_PRESS) }
} ),
), SelectItem(
SelectItem( name = "Press and hold AirPods",
name = stringResource(R.string.press_and_hold_airpods), selected = currentCameraAction == StemPressType.LONG_PRESS,
selected = currentCameraAction == StemPressType.LONG_PRESS, onClick = { handleSelection(StemPressType.LONG_PRESS) }
onClick = { )
if (!isAppListenerServiceEnabled(context)) {
context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
} else {
sharedPreferences.edit { putString("camera_action", StemPressType.LONG_PRESS.name) }
currentCameraAction = StemPressType.LONG_PRESS
}
}
) )
) }
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
@@ -335,7 +335,6 @@ fun DebugScreen(navController: NavController) {
expandedItems.value = emptySet() expandedItems.value = emptySet()
}, },
icon = "􀈑", icon = "􀈑",
darkMode = isDarkTheme,
backdrop = scaffoldBackdrop backdrop = scaffoldBackdrop
) )
} }
@@ -23,10 +23,7 @@
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.screens
import android.content.Context
import android.os.Build
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
@@ -83,7 +80,6 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
@@ -100,6 +96,7 @@ import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledToggle import me.kavishdevar.librepods.composables.StyledToggle
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.HeadTracking import me.kavishdevar.librepods.utils.HeadTracking
import me.kavishdevar.librepods.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.cos import kotlin.math.cos
@@ -107,14 +104,14 @@ import kotlin.math.sin
import kotlin.random.Random import kotlin.random.Random
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@RequiresApi(Build.VERSION_CODES.Q)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
@Composable @Composable
fun HeadTrackingScreen() { fun HeadTrackingScreen(viewModel: AirPodsViewModel) {
val state by viewModel.uiState.collectAsState()
DisposableEffect(Unit) { DisposableEffect(Unit) {
ServiceManager.getService()?.startHeadTracking() viewModel.startHeadTracking()
onDispose { onDispose {
ServiceManager.getService()?.stopHeadTracking() viewModel.stopHeadTracking()
} }
} }
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
@@ -127,25 +124,22 @@ fun HeadTrackingScreen() {
title = stringResource(R.string.head_tracking), title = stringResource(R.string.head_tracking),
actionButtons = listOf( actionButtons = listOf(
{ scaffoldBackdrop -> { scaffoldBackdrop ->
var isActive by remember { mutableStateOf(ServiceManager.getService()?.isHeadTrackingActive == true) }
StyledIconButton( StyledIconButton(
onClick = { onClick = {
if (ServiceManager.getService()?.isHeadTrackingActive == false) { if (!state.headTrackingActive) {
ServiceManager.getService()?.startHeadTracking() viewModel.startHeadTracking()
Log.d("HeadTrackingScreen", "Head tracking started") Log.d("HeadTrackingScreen", "Head tracking started")
} else { } else {
ServiceManager.getService()?.stopHeadTracking() viewModel.stopHeadTracking()
Log.d("HeadTrackingScreen", "Head tracking stopped") Log.d("HeadTrackingScreen", "Head tracking stopped")
} }
}, },
icon = if (isActive) "􀊅" else "􀊃", icon = if (state.headTrackingActive) "􀊅" else "􀊃",
darkMode = isDarkTheme,
backdrop = scaffoldBackdrop backdrop = scaffoldBackdrop
) )
} }
), ),
) { spacerHeight, hazeState -> ) { spacerHeight, hazeState ->
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
var gestureText by remember { mutableStateOf("") } var gestureText by remember { mutableStateOf("") }
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
@@ -167,10 +161,37 @@ fun HeadTrackingScreen() {
.verticalScroll(scrollState) .verticalScroll(scrollState)
) { ) {
Spacer(modifier = Modifier.height(spacerHeight)) Spacer(modifier = Modifier.height(spacerHeight))
val context = LocalContext.current
if (!state.isPremium) {
StyledButton(
onClick = {
viewModel.purchase(context)
},
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
tint = Color(0xFF916100)
) {
Text(
stringResource(R.string.unlock_all_features),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
),
)
}
Spacer(modifier = Modifier.height(8.dp))
}
StyledToggle( StyledToggle(
label = "Head Gestures", label = "Head Gestures",
sharedPreferences = sharedPreferences, checked = state.headGesturesEnabled,
sharedPreferenceKey = "head_gestures", onCheckedChange = { viewModel.setHeadGesturesEnabled(it) },
enabled = state.isPremium
) )
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
@@ -739,11 +760,3 @@ private fun AccelerationPlot() {
} }
} }
} }
@ExperimentalHazeMaterialsApi
@RequiresApi(Build.VERSION_CODES.Q)
@Preview
@Composable
fun HeadTrackingScreenPreview() {
HeadTrackingScreen()
}
@@ -31,9 +31,10 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -41,7 +42,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
@@ -59,6 +59,7 @@ import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.HearingAidSettings import me.kavishdevar.librepods.utils.HearingAidSettings
import me.kavishdevar.librepods.utils.parseHearingAidSettingsResponse import me.kavishdevar.librepods.utils.parseHearingAidSettingsResponse
import me.kavishdevar.librepods.utils.sendHearingAidSettings import me.kavishdevar.librepods.utils.sendHearingAidSettings
import me.kavishdevar.librepods.viewmodel.AirPodsViewModel
import java.io.IOException import java.io.IOException
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@@ -69,13 +70,14 @@ private const val TAG = "HearingAidAdjustments"
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable @Composable
fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController) { fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
isSystemInDarkTheme() isSystemInDarkTheme()
val verticalScrollState = rememberScrollState() val verticalScrollState = rememberScrollState()
val hazeState = remember { HazeState() } val hazeState = remember { HazeState() }
val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available") val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available")
val aacpManager = remember { ServiceManager.getService()?.aacpManager } val state by viewModel.uiState.collectAsState()
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( StyledScaffold(
title = stringResource(R.string.adjustments) title = stringResource(R.string.adjustments)
@@ -125,25 +127,6 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
) )
} }
val hearingAidEnabled = remember {
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
mutableStateOf((aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte()))
}
val hearingAidListener = remember {
object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value ||
controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value) {
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
hearingAidEnabled.value = (aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())
}
}
}
}
val hearingAidATTListener = remember { val hearingAidATTListener = remember {
object : (ByteArray) -> Unit { object : (ByteArray) -> Unit {
override fun invoke(value: ByteArray) { override fun invoke(value: ByteArray) {
@@ -165,19 +148,6 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
} }
} }
LaunchedEffect(Unit) {
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
}
DisposableEffect(Unit) {
onDispose {
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
attManager.unregisterListener(ATTHandles.HEARING_AID, hearingAidATTListener)
}
}
LaunchedEffect(amplificationSliderValue.floatValue, balanceSliderValue.floatValue, toneSliderValue.floatValue, conversationBoostEnabled.value, ambientNoiseReductionSliderValue.floatValue, ownVoiceAmplification.floatValue, initialLoadComplete.value, initialReadSucceeded.value) { LaunchedEffect(amplificationSliderValue.floatValue, balanceSliderValue.floatValue, toneSliderValue.floatValue, conversationBoostEnabled.value, ambientNoiseReductionSliderValue.floatValue, ownVoiceAmplification.floatValue, initialLoadComplete.value, initialReadSucceeded.value) {
if (!initialLoadComplete.value) { if (!initialLoadComplete.value) {
Log.d(TAG, "Initial device load not complete - skipping send") Log.d(TAG, "Initial device load not complete - skipping send")
@@ -256,7 +226,7 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
StyledSlider( StyledSlider(
label = stringResource(R.string.amplification), label = stringResource(R.string.amplification),
valueRange = -1f..1f, valueRange = -1f..1f,
mutableFloatState = amplificationSliderValue, value = amplificationSliderValue.floatValue,
onValueChange = { onValueChange = {
amplificationSliderValue.floatValue = it amplificationSliderValue.floatValue = it
}, },
@@ -268,14 +238,15 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
StyledToggle( StyledToggle(
label = stringResource(R.string.swipe_to_control_amplification), label = stringResource(R.string.swipe_to_control_amplification),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE, checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE]?.getOrNull(0) == 0x01.toByte(),
onCheckedChange = { viewModel.setControlCommandBoolean(AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE, it) },
description = stringResource(R.string.swipe_amplification_description) description = stringResource(R.string.swipe_amplification_description)
) )
StyledSlider( StyledSlider(
label = stringResource(R.string.balance), label = stringResource(R.string.balance),
valueRange = -1f..1f, valueRange = -1f..1f,
mutableFloatState = balanceSliderValue, value = balanceSliderValue.floatValue,
onValueChange = { onValueChange = {
balanceSliderValue.floatValue = it balanceSliderValue.floatValue = it
}, },
@@ -288,7 +259,7 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
StyledSlider( StyledSlider(
label = stringResource(R.string.tone), label = stringResource(R.string.tone),
valueRange = -1f..1f, valueRange = -1f..1f,
mutableFloatState = toneSliderValue, value = toneSliderValue.floatValue,
onValueChange = { onValueChange = {
toneSliderValue.floatValue = it toneSliderValue.floatValue = it
}, },
@@ -300,7 +271,7 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
StyledSlider( StyledSlider(
label = stringResource(R.string.ambient_noise_reduction), label = stringResource(R.string.ambient_noise_reduction),
valueRange = 0f..1f, valueRange = 0f..1f,
mutableFloatState = ambientNoiseReductionSliderValue, value = ambientNoiseReductionSliderValue.floatValue,
onValueChange = { onValueChange = {
ambientNoiseReductionSliderValue.floatValue = it ambientNoiseReductionSliderValue.floatValue = it
}, },
@@ -311,7 +282,8 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
StyledToggle( StyledToggle(
label = stringResource(R.string.conversation_boost), label = stringResource(R.string.conversation_boost),
checkedState = conversationBoostEnabled, checked = conversationBoostEnabled.value,
onCheckedChange = { conversationBoostEnabled.value = it },
independent = true, independent = true,
description = stringResource(R.string.conversation_boost_description) description = stringResource(R.string.conversation_boost_description)
) )
@@ -37,8 +37,9 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -65,11 +66,11 @@ import me.kavishdevar.librepods.composables.ConfirmationDialog
import me.kavishdevar.librepods.composables.NavigationButton import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledToggle import me.kavishdevar.librepods.composables.StyledToggle
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse
import me.kavishdevar.librepods.utils.sendTransparencySettings import me.kavishdevar.librepods.utils.sendTransparencySettings
import me.kavishdevar.librepods.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
private const val TAG = "AccessibilitySettings" private const val TAG = "AccessibilitySettings"
@@ -78,23 +79,22 @@ private const val TAG = "AccessibilitySettings"
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable @Composable
fun HearingAidScreen(navController: NavController) { fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val verticalScrollState = rememberScrollState() val verticalScrollState = rememberScrollState()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val attManager = ServiceManager.getService()?.attManager ?: return
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
val showDialog = remember { mutableStateOf(false) } val showDialog = remember { mutableStateOf(false) }
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
val initialLoad = remember { mutableStateOf(true) } val initialLoad = remember { mutableStateOf(true) }
val state by viewModel.uiState.collectAsState()
val hearingAidEnabled = remember { val hearingAidEnabled = remember {
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID } val aidStatus = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG } val assistStatus = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG]
mutableStateOf((aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())) mutableStateOf((aidStatus?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.getOrNull(0) == 0x01.toByte()))
} }
val hazeStateS = remember { mutableStateOf(HazeState()) } // dont question this. i could possibly use something other than initializing it with an empty state and then replacing it with the the one provided by the scaffold val hazeStateS = remember { mutableStateOf(HazeState()) } // dont question this. i could possibly use something other than initializing it with an empty state and then replacing it with the the one provided by the scaffold
@@ -115,41 +115,16 @@ fun HearingAidScreen(navController: NavController) {
hazeStateS.value = hazeState hazeStateS.value = hazeState
Spacer(modifier = Modifier.height(spacerHeight)) Spacer(modifier = Modifier.height(spacerHeight))
val hearingAidListener = remember {
object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value ||
controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value) {
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
val assistStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }
hearingAidEnabled.value = (aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())
}
}
}
}
// val mediaAssistEnabled = remember { mutableStateOf(false) } // val mediaAssistEnabled = remember { mutableStateOf(false) }
// val adjustMediaEnabled = remember { mutableStateOf(false) } // val adjustMediaEnabled = remember { mutableStateOf(false) }
// val adjustPhoneEnabled = remember { mutableStateOf(false) } // val adjustPhoneEnabled = remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
}
DisposableEffect(Unit) {
onDispose {
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, hearingAidListener)
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, hearingAidListener)
}
}
LaunchedEffect(hearingAidEnabled.value) { LaunchedEffect(hearingAidEnabled.value) {
if (hearingAidEnabled.value && !initialLoad.value) { if (hearingAidEnabled.value && !initialLoad.value) {
showDialog.value = true showDialog.value = true
} else if (!hearingAidEnabled.value && !initialLoad.value) { } else if (!hearingAidEnabled.value && !initialLoad.value) {
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x02)) viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x02))
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value, 0x02.toByte()) viewModel.setControlCommandByte(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, 0x02.toByte())
hearingAidEnabled.value = false hearingAidEnabled.value = false
} }
initialLoad.value = false initialLoad.value = false
@@ -186,7 +161,8 @@ fun HearingAidScreen(navController: NavController) {
) { ) {
StyledToggle( StyledToggle(
label = stringResource(R.string.hearing_aid), label = stringResource(R.string.hearing_aid),
checkedState = hearingAidEnabled, checked = hearingAidEnabled.value,
onCheckedChange = { hearingAidEnabled.value = it },
independent = false independent = false
) )
HorizontalDivider( HorizontalDivider(
@@ -269,20 +245,24 @@ fun HearingAidScreen(navController: NavController) {
dismissText = "Cancel", dismissText = "Cancel",
onConfirm = { onConfirm = {
showDialog.value = false showDialog.value = false
val enrolled = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }?.value?.getOrNull(0) == 0x01.toByte() val enrolled = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(0) == 0x01.toByte()
if (!enrolled) { if (!enrolled) {
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x01)) viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x01))
} else { } else {
aacpManager.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x01)) viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x01))
} }
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value, 0x01.toByte()) viewModel.setControlCommandByte(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, 0x01.toByte())
hearingAidEnabled.value = true hearingAidEnabled.value = true
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
try { try {
val data = attManager.read(ATTHandles.TRANSPARENCY) val data = viewModel.getATTCharacteristicValue(ATTHandles.TRANSPARENCY) ?: byteArrayOf()
if (data.isEmpty()) {
Log.w(TAG, "read failed")
return@launch
}
val parsed = parseTransparencySettingsResponse(data) val parsed = parseTransparencySettingsResponse(data)
val disabledSettings = parsed.copy(enabled = false) val disabledSettings = parsed.copy(enabled = false)
sendTransparencySettings(attManager, disabledSettings) sendTransparencySettings(viewModel::setATTCharacteristicValue, disabledSettings)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error disabling transparency: ${e.message}") Log.e(TAG, "Error disabling transparency: ${e.message}")
} }
@@ -18,48 +18,31 @@
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.screens
import android.annotation.SuppressLint
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import me.kavishdevar.librepods.BuildConfig
import kotlinx.coroutines.Job
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledToggle import me.kavishdevar.librepods.composables.StyledToggle
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles import me.kavishdevar.librepods.utils.ATTHandles
import kotlin.io.encoding.ExperimentalEncodingApi import me.kavishdevar.librepods.viewmodel.AirPodsViewModel
private var debounceJob: Job? = null
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable @Composable
fun HearingProtectionScreen(navController: NavController) { fun HearingProtectionScreen(viewModel: AirPodsViewModel) {
val isDarkTheme = isSystemInDarkTheme()
val service = ServiceManager.getService()
if (service == null) return
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
val state by viewModel.uiState.collectAsState()
StyledScaffold( StyledScaffold(
title = stringResource(R.string.hearing_protection), title = stringResource(R.string.hearing_protection),
) { spacerHeight -> ) { spacerHeight ->
@@ -71,20 +54,36 @@ fun HearingProtectionScreen(navController: NavController) {
) { ) {
Spacer(modifier = Modifier.height(spacerHeight)) Spacer(modifier = Modifier.height(spacerHeight))
StyledToggle( if (BuildConfig.FLAVOR == "xposed") {
title = stringResource(R.string.environmental_noise), StyledToggle(
label = stringResource(R.string.loud_sound_reduction), title = stringResource(R.string.environmental_noise),
description = stringResource(R.string.loud_sound_reduction_description), label = stringResource(R.string.loud_sound_reduction),
attHandle = ATTHandles.LOUD_SOUND_REDUCTION description = stringResource(R.string.loud_sound_reduction_description),
) checked = viewModel.getATTCharacteristicValue(ATTHandles.LOUD_SOUND_REDUCTION)
?.get(0)?.toInt() == 1,
onCheckedChange = {
viewModel.setATTCharacteristicValue(
ATTHandles.LOUD_SOUND_REDUCTION,
byteArrayOf(if (it) 1.toByte() else 0.toByte())
)
}
// attHandle = ATTHandles.LOUD_SOUND_REDUCTION
)
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
}
StyledToggle( StyledToggle(
title = stringResource(R.string.workspace_use), title = stringResource(R.string.workspace_use),
label = stringResource(R.string.ppe), label = stringResource(R.string.ppe),
description = stringResource(R.string.workspace_use_description), description = stringResource(R.string.workspace_use_description),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG]?.getOrNull(
) 0
)?.toInt() == 1,
onCheckedChange = {
viewModel.setControlCommandBoolean(
AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG, it
)
})
} }
} }
} }
@@ -22,37 +22,25 @@ package me.kavishdevar.librepods.screens
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
@@ -61,59 +49,38 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.edit import androidx.core.content.edit
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.SelectItem import me.kavishdevar.librepods.composables.SelectItem
import me.kavishdevar.librepods.composables.StyledIconButton import me.kavishdevar.librepods.composables.StyledButton
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSelectList import me.kavishdevar.librepods.composables.StyledSelectList
import me.kavishdevar.librepods.constants.StemAction import me.kavishdevar.librepods.constants.StemAction
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.viewmodel.AirPodsViewModel
import kotlin.experimental.and import kotlin.experimental.and
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun RightDivider() {
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(start = 72.dp, end = 20.dp)
)
}
@Composable
fun RightDividerNoIcon() {
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(start = 20.dp, end = 20.dp)
)
}
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LongPress(navController: NavController, name: String) { fun LongPress(viewModel: AirPodsViewModel, name: String) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val modesByte = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find { val state by viewModel.uiState.collectAsState()
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
}?.value?.takeIf { it.isNotEmpty() }?.get(0) val modesByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]?.get(0) ?: 0
Log.d("PressAndHoldSettingsScreen", "Current modes state: ${modesByte.toString(2)}")
Log.d("PressAndHoldSettingsScreen", "Off mode: ${(modesByte and 0x01) != 0.toByte()}")
Log.d("PressAndHoldSettingsScreen", "Transparency mode: ${(modesByte and 0x04) != 0.toByte()}")
Log.d("PressAndHoldSettingsScreen", "Noise Cancellation mode: ${(modesByte and 0x02) != 0.toByte()}")
Log.d("PressAndHoldSettingsScreen", "Adaptive mode: ${(modesByte and 0x08) != 0.toByte()}")
if (modesByte != null) {
Log.d("PressAndHoldSettingsScreen", "Current modes state: ${modesByte.toString(2)}")
Log.d("PressAndHoldSettingsScreen", "Off mode: ${(modesByte and 0x01) != 0.toByte()}")
Log.d("PressAndHoldSettingsScreen", "Transparency mode: ${(modesByte and 0x04) != 0.toByte()}")
Log.d("PressAndHoldSettingsScreen", "Noise Cancellation mode: ${(modesByte and 0x02) != 0.toByte()}")
Log.d("PressAndHoldSettingsScreen", "Adaptive mode: ${(modesByte and 0x08) != 0.toByte()}")
}
val context = LocalContext.current val context = LocalContext.current
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE) val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
val prefKey = if (name.lowercase() == "left") "left_long_press_action" else "right_long_press_action" val prefKey = if (name.lowercase() == "left") "left_long_press_action" else "right_long_press_action"
@@ -124,9 +91,8 @@ fun LongPress(navController: NavController, name: String) {
StyledScaffold( StyledScaffold(
title = name title = name
) { spacerHeight -> ) { spacerHeight ->
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
Column ( Column (
modifier = Modifier modifier = Modifier
.layerBackdrop(backdrop) .layerBackdrop(backdrop)
.fillMaxSize() .fillMaxSize()
.padding(top = 8.dp) .padding(top = 8.dp)
@@ -148,11 +114,36 @@ fun LongPress(navController: NavController, name: String) {
onClick = { onClick = {
longPressAction = StemAction.DIGITAL_ASSISTANT longPressAction = StemAction.DIGITAL_ASSISTANT
sharedPreferences.edit { putString(prefKey, StemAction.DIGITAL_ASSISTANT.name) } sharedPreferences.edit { putString(prefKey, StemAction.DIGITAL_ASSISTANT.name) }
} },
enabled = state.isPremium
) )
) )
StyledSelectList(items = actionItems) StyledSelectList(items = actionItems)
if (!state.isPremium) {
Spacer(modifier = Modifier.height(24.dp))
StyledButton(
onClick = {
viewModel.purchase(context)
},
backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f,
tint = Color(0xFF916100)
) {
Text(
stringResource(R.string.unlock_all_features),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = textColor
),
)
}
Spacer(modifier = Modifier.height(8.dp))
}
if (longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES) { if (longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES) {
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
Text( Text(
@@ -176,10 +167,11 @@ fun LongPress(navController: NavController, name: String) {
val allowOff = offListeningModeValue == 1.toByte() val allowOff = offListeningModeValue == 1.toByte()
Log.d("PressAndHoldSettingsScreen", "Allow Off option: $allowOff") Log.d("PressAndHoldSettingsScreen", "Allow Off option: $allowOff")
val initialByte = ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find { val initialByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS ?.get(0)?.toInt()
}?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toInt() ?: sharedPreferences.getInt("long_press_byte", 0b0101) ?: sharedPreferences.getInt("long_press_byte", 0b0101)
var currentByte by remember { mutableStateOf(initialByte) }
var currentByte by remember { mutableIntStateOf(initialByte) }
val listeningModeItems = mutableListOf<SelectItem>() val listeningModeItems = mutableListOf<SelectItem>()
if (allowOff) { if (allowOff) {
@@ -197,8 +189,8 @@ fun LongPress(navController: NavController, name: String) {
} else { } else {
currentByte or bit currentByte or bit
} }
ServiceManager.getService()!!.aacpManager.sendControlCommand( viewModel.setControlCommandByte(
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value, AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS,
newValue.toByte() newValue.toByte()
) )
sharedPreferences.edit { sharedPreferences.edit {
@@ -223,8 +215,8 @@ fun LongPress(navController: NavController, name: String) {
} else { } else {
currentByte or bit currentByte or bit
} }
ServiceManager.getService()!!.aacpManager.sendControlCommand( viewModel.setControlCommandByte(
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value, AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS,
newValue.toByte() newValue.toByte()
) )
sharedPreferences.edit { sharedPreferences.edit {
@@ -246,8 +238,8 @@ fun LongPress(navController: NavController, name: String) {
} else { } else {
currentByte or bit currentByte or bit
} }
ServiceManager.getService()!!.aacpManager.sendControlCommand( viewModel.setControlCommandByte(
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value, AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS,
newValue.toByte() newValue.toByte()
) )
sharedPreferences.edit { sharedPreferences.edit {
@@ -269,8 +261,8 @@ fun LongPress(navController: NavController, name: String) {
} else { } else {
currentByte or bit currentByte or bit
} }
ServiceManager.getService()!!.aacpManager.sendControlCommand( viewModel.setControlCommandByte(
AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS.value, AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS,
newValue.toByte() newValue.toByte()
) )
sharedPreferences.edit { sharedPreferences.edit {
@@ -296,9 +288,7 @@ fun LongPress(navController: NavController, name: String) {
} }
} }
} }
Log.d("PressAndHoldSettingsScreen", "Current byte: ${ServiceManager.getService()!!.aacpManager.controlCommandStatusList.find { Log.d("PressAndHoldSettingsScreen", "Current byte: ${modesByte.toString(2)}")
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS
}?.value?.takeIf { it.isNotEmpty() }?.get(0)?.toString(2)}")
} }
fun countEnabledModes(byteValue: Int): Int { fun countEnabledModes(byteValue: Int): Int {
@@ -53,26 +53,22 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.edit import androidx.core.content.edit
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@Composable @Composable
fun RenameScreen(navController: NavController) { fun RenameScreen(viewModel: AirPodsViewModel) {
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE) val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
val isDarkTheme = isSystemInDarkTheme()
val name = remember { mutableStateOf(TextFieldValue(sharedPreferences.getString("name", "") ?: "")) } val name = remember { mutableStateOf(TextFieldValue(sharedPreferences.getString("name", "") ?: "")) }
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
@@ -115,7 +111,7 @@ fun RenameScreen(navController: NavController) {
onValueChange = { onValueChange = {
name.value = it name.value = it
sharedPreferences.edit {putString("name", it.text)} sharedPreferences.edit {putString("name", it.text)}
ServiceManager.getService()?.setName(it.text) viewModel.setName(it.text)
}, },
textStyle = TextStyle( textStyle = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
@@ -159,9 +155,3 @@ fun RenameScreen(navController: NavController) {
} }
} }
} }
@Preview
@Composable
fun RenameScreenPreview() {
RenameScreen(navController = NavController(LocalContext.current))
}
@@ -18,6 +18,7 @@
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.screens
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.util.Log import android.util.Log
import androidx.compose.foundation.background import androidx.compose.foundation.background
@@ -43,6 +44,8 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -58,23 +61,22 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider import me.kavishdevar.librepods.composables.StyledSlider
import me.kavishdevar.librepods.composables.StyledToggle import me.kavishdevar.librepods.composables.StyledToggle
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.ATTHandles import me.kavishdevar.librepods.utils.ATTHandles
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
import me.kavishdevar.librepods.utils.TransparencySettings import me.kavishdevar.librepods.utils.TransparencySettings
import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse
import me.kavishdevar.librepods.utils.sendTransparencySettings import me.kavishdevar.librepods.utils.sendTransparencySettings
import me.kavishdevar.librepods.viewmodel.AirPodsViewModel
import java.io.IOException import java.io.IOException
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@@ -84,14 +86,12 @@ private const val TAG = "TransparencySettings"
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable @Composable
fun TransparencySettingsScreen(navController: NavController) { fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val verticalScrollState = rememberScrollState() val verticalScrollState = rememberScrollState()
val attManager = ServiceManager.getService()?.attManager ?: return val attManager = ServiceManager.getService()?.attManager ?: return
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
val isSdpOffsetAvailable = remember { mutableStateOf(false) } // always available rn, for testing without radare
// remember { mutableStateOf(RadareOffsetFinder.isSdpOffsetAvailable()) }
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491) val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5) val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
@@ -99,6 +99,9 @@ fun TransparencySettingsScreen(navController: NavController) {
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
val state by viewModel.uiState.collectAsState()
StyledScaffold( StyledScaffold(
title = stringResource(R.string.customize_transparency_mode) title = stringResource(R.string.customize_transparency_mode)
){ spacerHeight, hazeState -> ){ spacerHeight, hazeState ->
@@ -205,7 +208,7 @@ fun TransparencySettingsScreen(navController: NavController) {
balance = balanceSliderValue.floatValue balance = balanceSliderValue.floatValue
) )
Log.d("TransparencySettings", "Updated settings: ${transparencySettings.value}") Log.d("TransparencySettings", "Updated settings: ${transparencySettings.value}")
sendTransparencySettings(attManager, transparencySettings.value) sendTransparencySettings(viewModel::setATTCharacteristicValue, transparencySettings.value)
} }
DisposableEffect(Unit) { DisposableEffect(Unit) {
@@ -222,18 +225,14 @@ fun TransparencySettingsScreen(navController: NavController) {
// If we have an AACP manager, prefer its EQ data to populate EQ controls first // If we have an AACP manager, prefer its EQ data to populate EQ controls first
try { try {
if (aacpManager != null) { Log.d(TAG, "Found AACPManager, reading cached EQ data")
Log.d(TAG, "Found AACPManager, reading cached EQ data") val aacpEQ = state.eqData
val aacpEQ = aacpManager.eqData if (aacpEQ.isNotEmpty()) {
if (aacpEQ.isNotEmpty()) { eq.value = aacpEQ.copyOf()
eq.value = aacpEQ.copyOf() phoneMediaEQ.value = aacpEQ.copyOf()
phoneMediaEQ.value = aacpEQ.copyOf() Log.d(TAG, "Populated EQ from AACPManager: ${aacpEQ.toList()}")
Log.d(TAG, "Populated EQ from AACPManager: ${aacpEQ.toList()}")
} else {
Log.d(TAG, "AACPManager EQ data empty")
}
} else { } else {
Log.d(TAG, "No AACPManager available") Log.d(TAG, "AACPManager EQ data empty")
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Error reading EQ from AACPManager: ${e.message}") Log.w(TAG, "Error reading EQ from AACPManager: ${e.message}")
@@ -277,18 +276,19 @@ fun TransparencySettingsScreen(navController: NavController) {
} }
// Only show transparency mode section if SDP offset is available // Only show transparency mode section if SDP offset is available
if (isSdpOffsetAvailable.value) { if (BuildConfig.FLAVOR == "xposed") {
StyledToggle( StyledToggle(
label = stringResource(R.string.transparency_mode), label = stringResource(R.string.transparency_mode),
checkedState = enabled, checked = enabled.value,
independent = true, independent = true,
description = stringResource(R.string.customize_transparency_mode_description) description = stringResource(R.string.customize_transparency_mode_description),
onCheckedChange = { enabled.value = it }
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
StyledSlider( StyledSlider(
label = stringResource(R.string.amplification), label = stringResource(R.string.amplification),
valueRange = -1f..1f, valueRange = -1f..1f,
mutableFloatState = amplificationSliderValue, value = amplificationSliderValue.floatValue,
onValueChange = { onValueChange = {
amplificationSliderValue.floatValue = it amplificationSliderValue.floatValue = it
}, },
@@ -300,7 +300,7 @@ fun TransparencySettingsScreen(navController: NavController) {
StyledSlider( StyledSlider(
label = stringResource(R.string.balance), label = stringResource(R.string.balance),
valueRange = -1f..1f, valueRange = -1f..1f,
mutableFloatState = balanceSliderValue, value = balanceSliderValue.floatValue,
onValueChange = { onValueChange = {
balanceSliderValue.floatValue = it balanceSliderValue.floatValue = it
}, },
@@ -313,7 +313,7 @@ fun TransparencySettingsScreen(navController: NavController) {
StyledSlider( StyledSlider(
label = stringResource(R.string.tone), label = stringResource(R.string.tone),
valueRange = -1f..1f, valueRange = -1f..1f,
mutableFloatState = toneSliderValue, value = toneSliderValue.floatValue,
onValueChange = { onValueChange = {
toneSliderValue.floatValue = it toneSliderValue.floatValue = it
}, },
@@ -325,7 +325,7 @@ fun TransparencySettingsScreen(navController: NavController) {
StyledSlider( StyledSlider(
label = stringResource(R.string.ambient_noise_reduction), label = stringResource(R.string.ambient_noise_reduction),
valueRange = 0f..1f, valueRange = 0f..1f,
mutableFloatState = ambientNoiseReductionSliderValue, value = ambientNoiseReductionSliderValue.floatValue,
onValueChange = { onValueChange = {
ambientNoiseReductionSliderValue.floatValue = it ambientNoiseReductionSliderValue.floatValue = it
}, },
@@ -336,14 +336,12 @@ fun TransparencySettingsScreen(navController: NavController) {
StyledToggle( StyledToggle(
label = stringResource(R.string.conversation_boost), label = stringResource(R.string.conversation_boost),
checkedState = conversationBoostEnabled, checked = conversationBoostEnabled.value,
independent = true, independent = true,
description = stringResource(R.string.conversation_boost_description) description = stringResource(R.string.conversation_boost_description),
onCheckedChange = { conversationBoostEnabled.value = it }
) )
}
// Only show transparency mode EQ section if SDP offset is available
if (isSdpOffsetAvailable.value) {
Text( Text(
text = stringResource(R.string.equalizer), text = stringResource(R.string.equalizer),
style = TextStyle( style = TextStyle(
@@ -55,7 +55,6 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
@@ -75,11 +74,8 @@ import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: MutableState<Job?> = mutableStateOf(null) private var debounceJob: MutableState<Job?> = mutableStateOf(null)
private const val TAG = "HearingAidAdjustments" private const val TAG = "HearingAidAdjustments"
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable @Composable
fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) { fun UpdateHearingTestScreen() {
val verticalScrollState = rememberScrollState() val verticalScrollState = rememberScrollState()
val attManager = ServiceManager.getService()?.attManager val attManager = ServiceManager.getService()?.attManager
if (attManager == null) { if (attManager == null) {
@@ -138,17 +134,17 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
HearingAidSettings( HearingAidSettings(
leftEQ = leftEQ.value, leftEQ = leftEQ.value,
rightEQ = rightEQ.value, rightEQ = rightEQ.value,
leftAmplification = leftAmplification.value, leftAmplification = leftAmplification.floatValue,
rightAmplification = rightAmplification.value, rightAmplification = rightAmplification.floatValue,
leftTone = tone.value, leftTone = tone.floatValue,
rightTone = tone.value, rightTone = tone.floatValue,
leftConversationBoost = conversationBoostEnabled.value, leftConversationBoost = conversationBoostEnabled.value,
rightConversationBoost = conversationBoostEnabled.value, rightConversationBoost = conversationBoostEnabled.value,
leftAmbientNoiseReduction = ambientNoiseReduction.value, leftAmbientNoiseReduction = ambientNoiseReduction.floatValue,
rightAmbientNoiseReduction = ambientNoiseReduction.value, rightAmbientNoiseReduction = ambientNoiseReduction.floatValue,
netAmplification = leftAmplification.value + rightAmplification.value / 2, netAmplification = leftAmplification.floatValue + rightAmplification.floatValue / 2,
balance = 0.5f + (rightAmplification.value - leftAmplification.value) / 2, balance = 0.5f + (rightAmplification.floatValue - leftAmplification.floatValue) / 2,
ownVoiceAmplification = ownVoiceAmplification.value ownVoiceAmplification = ownVoiceAmplification.floatValue
) )
) )
} }
@@ -161,11 +157,11 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
leftEQ.value = parsed.leftEQ.copyOf() leftEQ.value = parsed.leftEQ.copyOf()
rightEQ.value = parsed.rightEQ.copyOf() rightEQ.value = parsed.rightEQ.copyOf()
conversationBoostEnabled.value = parsed.leftConversationBoost conversationBoostEnabled.value = parsed.leftConversationBoost
tone.value = parsed.leftTone tone.floatValue = parsed.leftTone
ambientNoiseReduction.value = parsed.leftAmbientNoiseReduction ambientNoiseReduction.floatValue = parsed.leftAmbientNoiseReduction
ownVoiceAmplification.value = parsed.ownVoiceAmplification ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
leftAmplification.value = parsed.leftAmplification leftAmplification.floatValue = parsed.leftAmplification
rightAmplification.value = parsed.rightAmplification rightAmplification.floatValue = parsed.rightAmplification
Log.d(TAG, "Updated hearing aid settings from notification") Log.d(TAG, "Updated hearing aid settings from notification")
} else { } else {
Log.w(TAG, "Failed to parse hearing aid settings from notification") Log.w(TAG, "Failed to parse hearing aid settings from notification")
@@ -181,31 +177,45 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
} }
} }
LaunchedEffect(leftEQ.value, rightEQ.value, conversationBoostEnabled.value, initialLoadComplete.value, initialReadSucceeded.value, leftAmplification.value, rightAmplification.value, tone.value, ambientNoiseReduction.value, ownVoiceAmplification.value) { LaunchedEffect(
leftEQ.value,
rightEQ.value,
conversationBoostEnabled.value,
initialLoadComplete.value,
initialReadSucceeded.value,
leftAmplification.floatValue,
rightAmplification.floatValue,
tone.floatValue,
ambientNoiseReduction.floatValue,
ownVoiceAmplification.floatValue
) {
if (!initialLoadComplete.value) { if (!initialLoadComplete.value) {
Log.d(TAG, "Initial device load not complete - skipping send") Log.d(TAG, "Initial device load not complete - skipping send")
return@LaunchedEffect return@LaunchedEffect
} }
if (!initialReadSucceeded.value) { if (!initialReadSucceeded.value) {
Log.d(TAG, "Initial device read not successful yet - skipping send until read succeeds") Log.d(
TAG,
"Initial device read not successful yet - skipping send until read succeeds"
)
return@LaunchedEffect return@LaunchedEffect
} }
hearingAidSettings.value = HearingAidSettings( hearingAidSettings.value = HearingAidSettings(
leftEQ = leftEQ.value, leftEQ = leftEQ.value,
rightEQ = rightEQ.value, rightEQ = rightEQ.value,
leftAmplification = leftAmplification.value, leftAmplification = leftAmplification.floatValue,
rightAmplification = rightAmplification.value, rightAmplification = rightAmplification.floatValue,
leftTone = tone.value, leftTone = tone.floatValue,
rightTone = tone.value, rightTone = tone.floatValue,
leftConversationBoost = conversationBoostEnabled.value, leftConversationBoost = conversationBoostEnabled.value,
rightConversationBoost = conversationBoostEnabled.value, rightConversationBoost = conversationBoostEnabled.value,
leftAmbientNoiseReduction = ambientNoiseReduction.value, leftAmbientNoiseReduction = ambientNoiseReduction.floatValue,
rightAmbientNoiseReduction = ambientNoiseReduction.value, rightAmbientNoiseReduction = ambientNoiseReduction.floatValue,
netAmplification = leftAmplification.value + rightAmplification.value / 2, netAmplification = leftAmplification.floatValue + rightAmplification.floatValue / 2,
balance = 0.5f + (rightAmplification.value - leftAmplification.value) / 2, balance = 0.5f + (rightAmplification.floatValue - leftAmplification.floatValue) / 2,
ownVoiceAmplification = ownVoiceAmplification.value ownVoiceAmplification = ownVoiceAmplification.floatValue
) )
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}") Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob) sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob)
@@ -240,14 +250,17 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
leftEQ.value = parsedSettings.leftEQ.copyOf() leftEQ.value = parsedSettings.leftEQ.copyOf()
rightEQ.value = parsedSettings.rightEQ.copyOf() rightEQ.value = parsedSettings.rightEQ.copyOf()
conversationBoostEnabled.value = parsedSettings.leftConversationBoost conversationBoostEnabled.value = parsedSettings.leftConversationBoost
tone.value = parsedSettings.leftTone tone.floatValue = parsedSettings.leftTone
ambientNoiseReduction.value = parsedSettings.leftAmbientNoiseReduction ambientNoiseReduction.floatValue = parsedSettings.leftAmbientNoiseReduction
ownVoiceAmplification.value = parsedSettings.ownVoiceAmplification ownVoiceAmplification.floatValue = parsedSettings.ownVoiceAmplification
leftAmplification.value = parsedSettings.leftAmplification leftAmplification.floatValue = parsedSettings.leftAmplification
rightAmplification.value = parsedSettings.rightAmplification rightAmplification.floatValue = parsedSettings.rightAmplification
initialReadSucceeded.value = true initialReadSucceeded.value = true
} else { } else {
Log.d(TAG, "Failed to read/parse initial hearing aid settings after ${initialReadAttempts.intValue} attempts") Log.d(
TAG,
"Failed to read/parse initial hearing aid settings after ${initialReadAttempts.intValue} attempts"
)
} }
} catch (e: IOException) { } catch (e: IOException) {
e.printStackTrace() e.printStackTrace()
@@ -256,7 +269,8 @@ fun UpdateHearingTestScreen(@Suppress("unused") navController: NavController) {
} }
} }
val frequencies = listOf("250Hz", "500Hz", "1kHz", "2kHz", "3kHz", "4kHz", "6kHz", "8kHz") val frequencies =
listOf("250Hz", "500Hz", "1kHz", "2kHz", "3kHz", "4kHz", "6kHz", "8kHz")
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -19,22 +19,22 @@
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.screens
import androidx.compose.foundation.background import androidx.compose.foundation.background
import android.annotation.SuppressLint
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -45,36 +45,23 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Job
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledScaffold import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: Job? = null
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable @Composable
fun VersionScreen(navController: NavController) { fun VersionScreen(viewModel: AirPodsViewModel) {
val state by viewModel.uiState.collectAsState()
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val service = ServiceManager.getService()
if (service == null) return
val airpodsInstance = service.airpodsInstance
if (airpodsInstance == null) return
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( StyledScaffold(
title = stringResource(R.string.customize_adaptive_audio) title = stringResource(R.string.version)
) { spacerHeight -> ) { spacerHeight ->
Column( Column(
modifier = Modifier modifier = Modifier
@@ -120,7 +107,7 @@ fun VersionScreen(navController: NavController) {
) )
) )
Text( Text(
text = airpodsInstance.version1 ?: "N/A", text = state.version1,
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
color = textColor.copy(0.8f), color = textColor.copy(0.8f),
@@ -149,7 +136,7 @@ fun VersionScreen(navController: NavController) {
) )
) )
Text( Text(
text = airpodsInstance.version2 ?: "N/A", text = state.version2,
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
color = textColor.copy(0.8f), color = textColor.copy(0.8f),
@@ -178,7 +165,7 @@ fun VersionScreen(navController: NavController) {
) )
) )
Text( Text(
text = airpodsInstance.version3 ?: "N/A", text = state.version3,
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
color = textColor.copy(0.8f), color = textColor.copy(0.8f),
@@ -189,4 +176,4 @@ fun VersionScreen(navController: NavController) {
} }
} }
} }
} }
@@ -244,8 +244,10 @@ class AirPodsQSService : TileService() {
private fun getNextAncMode(): Int { private fun getNextAncMode(): Int {
val availableModes = getAvailableModes() val availableModes = getAvailableModes()
Log.d("AirPodsQSService", "availableModes: $availableModes, currentAncMode: $currentAncMode")
val currentIndex = availableModes.indexOf(currentAncMode) val currentIndex = availableModes.indexOf(currentAncMode)
val nextIndex = (currentIndex + 1) % availableModes.size val nextIndex = (currentIndex + 1) % availableModes.size
Log.d("AirPodsQSService", "nextIndex: $nextIndex")
return availableModes[nextIndex] return availableModes[nextIndex]
} }
File diff suppressed because it is too large Load Diff
@@ -61,4 +61,4 @@ fun LibrePodsTheme(
typography = Typography, typography = Typography,
content = content content = content
) )
} }
@@ -55,6 +55,7 @@ class AACPManager {
const val TIPI_3: Byte = 0x0C // Don't know this one const val TIPI_3: Byte = 0x0C // Don't know this one
const val SMART_ROUTING_RESP: Byte = 0x11 const val SMART_ROUTING_RESP: Byte = 0x11
const val SEND_CONNECTED_MAC: Byte = 0x14 const val SEND_CONNECTED_MAC: Byte = 0x14
const val AUDIO_SOURCE_2: Byte = 0x0C // seems redundant?
} }
private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00) private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00)
@@ -216,7 +217,7 @@ class AACPManager {
var audioSource: AudioSource? = null var audioSource: AudioSource? = null
private set private set
var eqData = FloatArray(8) { 0.0f } var eqData = FloatArray(8)
private set private set
var eqOnPhone: Boolean = false var eqOnPhone: Boolean = false
@@ -265,6 +266,7 @@ class AACPManager {
fun onConnectedDevicesReceived(connectedDevices: List<ConnectedDevice>) fun onConnectedDevicesReceived(connectedDevices: List<ConnectedDevice>)
fun onOwnershipToFalseRequest(sender: String, reasonReverseTapped: Boolean) fun onOwnershipToFalseRequest(sender: String, reasonReverseTapped: Boolean)
fun onShowNearbyUI(sender: String) fun onShowNearbyUI(sender: String)
fun onEQPacketReceived(eqData: FloatArray)
} }
fun parseStemPressResponse(data: ByteArray): Pair<StemPressType, StemPressBudType> { fun parseStemPressResponse(data: ByteArray): Pair<StemPressType, StemPressBudType> {
@@ -458,21 +460,27 @@ class AACPManager {
controlCommand.value.joinToString(" ") { "%02X".format(it) } controlCommand.value.joinToString(" ") { "%02X".format(it) }
}" }"
) )
Log.d(
TAG, "Control command list is now: ${ val controlCommandListText = try {
controlCommandStatusList.joinToString(", ") { it -> controlCommandStatusList.joinToString(", ") { it ->
"${it.identifier.name} (${it.identifier.value.toHexString()}) - ${ "${it.identifier.name} (${it.identifier.value.toHexString()}) - ${
it.value.joinToString( it.value.joinToString(
" " " "
) { "%02X".format(it) } ) { "%02X".format(it) }
}" }"
}
} catch (e: Exception) {
e.message
} }
}")
Log.d(
TAG, "Control command list is now: $controlCommandListText")
val controlCommandIdentifier = val controlCommandIdentifier =
ControlCommandIdentifiers.fromByte(controlCommand.identifier) ControlCommandIdentifiers.fromByte(controlCommand.identifier)
if (controlCommandIdentifier != null) { if (controlCommandIdentifier != null) {
controlCommandListeners[controlCommandIdentifier]?.forEach { listener -> controlCommandListeners[controlCommandIdentifier]?.forEach { listener ->
Log.d(TAG, "calling listener for ${controlCommandIdentifier.name}")
listener.onControlCommandReceived(controlCommand) listener.onControlCommandReceived(controlCommand)
} }
} else { } else {
@@ -585,7 +593,7 @@ class AACPManager {
eqOnMedia = (packet[10] == 0x01.toByte()) eqOnMedia = (packet[10] == 0x01.toByte())
eqOnPhone = (packet[11] == 0x01.toByte()) eqOnPhone = (packet[11] == 0x01.toByte())
// there are 4 eqs. i am not sure what those are for, maybe all 4 listening modes, or maybe phone+media left+right, but then there shouldn't be another flag for phone/media enabled. just directly the EQ... weird. // there are 4 eqs. i am not sure what those are for, maybe all 4 listening modes, or maybe phone+media left+right, but then there shouldn't be another flag for phone/media visible. just directly the EQ... weird.
// the EQs are little endian floats // the EQs are little endian floats
val eq1 = ByteBuffer.wrap(packet, 12, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer() 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 eq2 = ByteBuffer.wrap(packet, 44, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
@@ -594,11 +602,14 @@ class AACPManager {
// for now, taking just the first EQ // for now, taking just the first EQ
eqData = FloatArray(8) { i -> eq1.get(i) } eqData = FloatArray(8) { i -> eq1.get(i) }
Log.d(TAG, "EQ Data set to: ${eqData.toList()}, eqOnPhone: $eqOnPhone, eqOnMedia: $eqOnMedia") Log.d(TAG, "EQ Data set to: ${eqData.toList()}, eqOnPhone: $eqOnPhone, eqOnMedia: $eqOnMedia")
callback?.onEQPacketReceived(eqData)
} }
Opcodes.INFORMATION -> { Opcodes.INFORMATION -> {
Log.e(TAG, "Parsing Information Packet") Log.d(TAG, "Parsing Information Packet")
val information = parseInformationPacket(packet) val information = parseInformationPacket(packet)
callback?.onDeviceInformationReceived(information) callback?.onDeviceInformationReceived(information)
} }
@@ -1,289 +0,0 @@
/*
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/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.utils
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothServerSocket
import android.bluetooth.BluetoothSocket
import android.bluetooth.le.AdvertiseCallback
import android.bluetooth.le.AdvertiseData
import android.bluetooth.le.AdvertiseSettings
import android.bluetooth.le.BluetoothLeAdvertiser
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.os.ParcelUuid
import android.util.Log
import androidx.core.content.edit
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.services.ServiceManager
import java.io.IOException
import java.util.UUID
import kotlin.io.encoding.ExperimentalEncodingApi
enum class CrossDevicePackets(val packet: ByteArray) {
AIRPODS_CONNECTED(byteArrayOf(0x00, 0x01, 0x00, 0x01)),
AIRPODS_DISCONNECTED(byteArrayOf(0x00, 0x01, 0x00, 0x00)),
REQUEST_DISCONNECT(byteArrayOf(0x00, 0x02, 0x00, 0x00)),
REQUEST_BATTERY_BYTES(byteArrayOf(0x00, 0x02, 0x00, 0x01)),
REQUEST_ANC_BYTES(byteArrayOf(0x00, 0x02, 0x00, 0x02)),
REQUEST_CONNECTION_STATUS(byteArrayOf(0x00, 0x02, 0x00, 0x03)),
AIRPODS_DATA_HEADER(byteArrayOf(0x00, 0x04, 0x00, 0x01)),
}
object CrossDevice {
var initialized = false
private val uuid = UUID.fromString("1abbb9a4-10e4-4000-a75c-8953c5471342")
private var serverSocket: BluetoothServerSocket? = null
private var clientSocket: BluetoothSocket? = null
private lateinit var bluetoothAdapter: BluetoothAdapter
private lateinit var bluetoothLeAdvertiser: BluetoothLeAdvertiser
private const val MANUFACTURER_ID = 0x1234
private const val MANUFACTURER_DATA = "ALN_AirPods"
var isAvailable: Boolean = false // set to true when airpods are connected to another device
var batteryBytes: ByteArray = byteArrayOf()
var ancBytes: ByteArray = byteArrayOf()
private lateinit var sharedPreferences: SharedPreferences
private const val PACKET_LOG_KEY = "packet_log"
private var earDetectionStatus = listOf(false, false)
var disconnectionRequested = false
@SuppressLint("MissingPermission")
fun init(context: Context) {
CoroutineScope(Dispatchers.IO).launch {
Log.d("CrossDevice", "Initializing CrossDevice")
sharedPreferences = context.getSharedPreferences("packet_logs", Context.MODE_PRIVATE)
sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", false)}
this@CrossDevice.bluetoothAdapter = context.getSystemService(BluetoothManager::class.java).adapter
this@CrossDevice.bluetoothLeAdvertiser = bluetoothAdapter.bluetoothLeAdvertiser
// startAdvertising()
startServer()
initialized = true
}
}
@SuppressLint("MissingPermission")
private fun startServer() {
CoroutineScope(Dispatchers.IO).launch {
if (!bluetoothAdapter.isEnabled) return@launch
// serverSocket = bluetoothAdapter.listenUsingRfcommWithServiceRecord("ALNCrossDevice", uuid)
Log.d("CrossDevice", "Server started")
while (serverSocket != null) {
if (!bluetoothAdapter.isEnabled) {
serverSocket?.close()
break
}
if (clientSocket != null) {
try {
clientSocket!!.close()
} catch (e: IOException) {
e.printStackTrace()
}
}
try {
val socket = serverSocket!!.accept()
handleClientConnection(socket)
} catch (e: IOException) { }
}
}
}
@SuppressLint("MissingPermission", "unused")
private fun startAdvertising() {
CoroutineScope(Dispatchers.IO).launch {
val settings = AdvertiseSettings.Builder()
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
.setConnectable(true)
.build()
val data = AdvertiseData.Builder()
.setIncludeDeviceName(true)
.addManufacturerData(MANUFACTURER_ID, MANUFACTURER_DATA.toByteArray())
.addServiceUuid(ParcelUuid(uuid))
.build()
try {
bluetoothLeAdvertiser.startAdvertising(settings, data, advertiseCallback)
} catch (e: Exception) {
Log.e("CrossDevice", "Failed to start BLE Advertising: ${e.message}")
}
Log.d("CrossDevice", "BLE Advertising started")
}
}
private val advertiseCallback = object : AdvertiseCallback() {
override fun onStartSuccess(settingsInEffect: AdvertiseSettings) {
Log.d("CrossDevice", "BLE Advertising started successfully")
}
override fun onStartFailure(errorCode: Int) {
Log.e("CrossDevice", "BLE Advertising failed with error code: $errorCode")
}
}
fun setAirPodsConnected(connected: Boolean) {
if (connected) {
isAvailable = false
sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", false)}
clientSocket?.outputStream?.write(CrossDevicePackets.AIRPODS_CONNECTED.packet)
} else {
clientSocket?.outputStream?.write(CrossDevicePackets.AIRPODS_DISCONNECTED.packet)
// Reset state variables
isAvailable = true
}
}
fun sendReceivedPacket(packet: ByteArray) {
if (clientSocket == null || clientSocket!!.outputStream != null) {
return
}
clientSocket?.outputStream?.write(CrossDevicePackets.AIRPODS_DATA_HEADER.packet + packet)
}
private fun logPacket(packet: ByteArray, source: String) {
val packetHex = packet.joinToString(" ") { "%02X".format(it) }
val logEntry = "$source: $packetHex"
val logs = sharedPreferences.getStringSet(PACKET_LOG_KEY, mutableSetOf())?.toMutableSet() ?: mutableSetOf()
logs.add(logEntry)
sharedPreferences.edit { putStringSet(PACKET_LOG_KEY, logs)}
}
@SuppressLint("MissingPermission")
private fun handleClientConnection(socket: BluetoothSocket) {
Log.d("CrossDevice", "Client connected")
notifyAirPodsConnectedRemotely(ServiceManager.getService()?.applicationContext!!)
clientSocket = socket
val inputStream = socket.inputStream
val buffer = ByteArray(1024)
var bytes: Int
setAirPodsConnected(ServiceManager.getService()?.isConnectedLocally == true)
while (true) {
try {
bytes = inputStream.read(buffer)
} catch (e: IOException) {
e.printStackTrace()
notifyAirPodsDisconnectedRemotely(ServiceManager.getService()?.applicationContext!!)
val s = serverSocket?.accept()
if (s != null) {
handleClientConnection(s)
}
break
}
var packet = buffer.copyOf(bytes)
logPacket(packet, "Relay")
Log.d("CrossDevice", "Received packet: ${packet.joinToString("") { "%02x".format(it) }}")
if (bytes == -1) {
notifyAirPodsDisconnectedRemotely(ServiceManager.getService()?.applicationContext!!)
break
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet) || packet.contentEquals(CrossDevicePackets.REQUEST_DISCONNECT.packet + CrossDevicePackets.AIRPODS_DATA_HEADER.packet)) {
ServiceManager.getService()?.disconnectForCD()
disconnectionRequested = true
CoroutineScope(Dispatchers.IO).launch {
delay(1000)
disconnectionRequested = false
}
} else if (packet.contentEquals(CrossDevicePackets.AIRPODS_CONNECTED.packet)) {
isAvailable = true
sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", true)}
} else if (packet.contentEquals(CrossDevicePackets.AIRPODS_DISCONNECTED.packet)) {
isAvailable = false
sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", false)}
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_BATTERY_BYTES.packet)) {
Log.d("CrossDevice", "Received battery request, battery data: ${batteryBytes.joinToString("") { "%02x".format(it) }}")
sendRemotePacket(batteryBytes)
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_ANC_BYTES.packet)) {
Log.d("CrossDevice", "Received ANC request")
sendRemotePacket(ancBytes)
} else if (packet.contentEquals(CrossDevicePackets.REQUEST_CONNECTION_STATUS.packet)) {
Log.d("CrossDevice", "Received connection status request")
sendRemotePacket(if (ServiceManager.getService()?.isConnectedLocally == true) CrossDevicePackets.AIRPODS_CONNECTED.packet else CrossDevicePackets.AIRPODS_DISCONNECTED.packet)
} else {
if (packet.sliceArray(0..3).contentEquals(CrossDevicePackets.AIRPODS_DATA_HEADER.packet)) {
isAvailable = true
sharedPreferences.edit { putBoolean("CrossDeviceIsAvailable", true) }
if (packet.size % 2 == 0) {
val half = packet.size / 2
if (packet.sliceArray(0 until half).contentEquals(packet.sliceArray(half until packet.size))) {
Log.d("CrossDevice", "Duplicated packet, trimming")
packet = packet.sliceArray(0 until half)
}
}
var trimmedPacket = packet.drop(CrossDevicePackets.AIRPODS_DATA_HEADER.packet.size).toByteArray()
Log.d("CrossDevice", "Received relayed packet: ${trimmedPacket.joinToString("") { "%02x".format(it) }}")
if (ServiceManager.getService()?.isConnectedLocally == true) {
val packetInHex = trimmedPacket.joinToString("") { "%02x".format(it) }
// ServiceManager.getService()?.sendPacket(packetInHex)
} else if (ServiceManager.getService()?.batteryNotification?.isBatteryData(trimmedPacket) == true) {
batteryBytes = trimmedPacket
ServiceManager.getService()?.batteryNotification?.setBattery(trimmedPacket)
Log.d("CrossDevice", "Battery data: ${ServiceManager.getService()?.batteryNotification?.getBattery()[0]?.level}")
ServiceManager.getService()?.updateBattery()
ServiceManager.getService()?.sendBatteryBroadcast()
ServiceManager.getService()?.sendBatteryNotification()
} else if (ServiceManager.getService()?.ancNotification?.isANCData(trimmedPacket) == true) {
ServiceManager.getService()?.ancNotification?.setStatus(trimmedPacket)
ServiceManager.getService()?.sendANCBroadcast()
ServiceManager.getService()?.updateNoiseControlWidget()
ancBytes = trimmedPacket
} else if (ServiceManager.getService()?.earDetectionNotification?.isEarDetectionData(trimmedPacket) == true) {
Log.d("CrossDevice", "Ear detection data: ${trimmedPacket.joinToString("") { "%02x".format(it) }}")
ServiceManager.getService()?.earDetectionNotification?.setStatus(trimmedPacket)
val newEarDetectionStatus = listOf(
ServiceManager.getService()?.earDetectionNotification?.status?.get(0) == 0x00.toByte(),
ServiceManager.getService()?.earDetectionNotification?.status?.get(1) == 0x00.toByte()
)
if (earDetectionStatus == listOf(false, false) && newEarDetectionStatus.contains(true)) {
ServiceManager.getService()?.applicationContext?.sendBroadcast(
Intent("me.kavishdevar.librepods.cross_device_island")
)
}
earDetectionStatus = newEarDetectionStatus
}
}
}
}
}
fun sendRemotePacket(byteArray: ByteArray) {
if (clientSocket == null || clientSocket!!.outputStream == null) {
return
}
clientSocket?.outputStream?.write(byteArray)
clientSocket?.outputStream?.flush()
logPacket(byteArray, "Sent")
Log.d("CrossDevice", "Sent packet to remote device")
}
fun notifyAirPodsConnectedRemotely(context: Context) {
val intent = Intent("me.kavishdevar.librepods.AIRPODS_CONNECTED_REMOTELY")
context.sendBroadcast(intent)
}
fun notifyAirPodsDisconnectedRemotely(context: Context) {
val intent = Intent("me.kavishdevar.librepods.AIRPODS_DISCONNECTED_REMOTELY")
context.sendBroadcast(intent)
}
}
@@ -240,6 +240,7 @@ class IslandWindow(private val context: Context) {
FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.WRAP_CONTENT FrameLayout.LayoutParams.WRAP_CONTENT
) )
containerView.addView(islandView, containerParams) containerView.addView(islandView, containerParams)
params = WindowManager.LayoutParams( params = WindowManager.LayoutParams(
@@ -379,7 +380,11 @@ class IslandWindow(private val context: Context) {
videoView.start() videoView.start()
} }
windowManager.addView(containerView, params) try {
windowManager.addView(containerView, params)
} catch (e: Exception) {
e.printStackTrace()
}
islandView.post { islandView.post {
initialHeight = islandView.height initialHeight = islandView.height
@@ -139,7 +139,11 @@ class PopupWindow(
vid.start() vid.start()
} }
mWindowManager.addView(mView, mParams) try {
mWindowManager.addView(mView, mParams)
} catch (e: Exception) {
e.printStackTrace()
}
val displayMetrics = mView.context.resources.displayMetrics val displayMetrics = mView.context.resources.displayMetrics
val screenHeight = displayMetrics.heightPixels val screenHeight = displayMetrics.heightPixels
@@ -0,0 +1,49 @@
/*
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.utils
import android.os.Build
import me.kavishdevar.librepods.BuildConfig
fun isSupported(): Boolean {
if (BuildConfig.PLAY_BUILD) {
val isPixel = Build.MANUFACTURER.lowercase() == "google"
val isOppoOrOnePlus = Build.MANUFACTURER.lowercase() in listOf("oneplus", "oppo")
if (isPixel) {
when (Build.VERSION.SDK_INT) {
36 -> {
return Build.ID == "CP1A.260305.018" || Build.ID == "CP1A.260405.005"
}
37 -> {
return true
}
}
} else if (isOppoOrOnePlus) {
return true
}
}
return true
}
/*fun isSupported(): Boolean {
return true
}*/
@@ -139,7 +139,7 @@ fun parseTransparencySettingsResponse(data: ByteArray): TransparencySettings {
private var debounceJob: Job? = null private var debounceJob: Job? = null
fun sendTransparencySettings(attManager: ATTManager, transparencySettings: TransparencySettings) { fun sendTransparencySettings(writer: (ATTHandles, ByteArray) -> Unit, transparencySettings: TransparencySettings) {
debounceJob?.cancel() debounceJob?.cancel()
debounceJob = CoroutineScope(Dispatchers.IO).launch { debounceJob = CoroutineScope(Dispatchers.IO).launch {
delay(100) delay(100)
@@ -171,7 +171,7 @@ fun sendTransparencySettings(attManager: ATTManager, transparencySettings: Trans
} }
val data = buffer.array() val data = buffer.array()
attManager.write(ATTHandles.TRANSPARENCY, value = data) writer(ATTHandles.TRANSPARENCY, data)
} catch (e: IOException) { } catch (e: IOException) {
e.printStackTrace() e.printStackTrace()
} }
@@ -0,0 +1,417 @@
/*
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.viewmodel
import android.app.Activity
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
import android.util.Log
import androidx.core.content.edit
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.billing.BillingManager
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.constants.Battery
import me.kavishdevar.librepods.constants.StemAction
import me.kavishdevar.librepods.data.ControlCommandRepository
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.AACPManager.Companion.ControlCommandIdentifiers
import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.AirPodsInstance
import me.kavishdevar.librepods.utils.AirPodsModels
import me.kavishdevar.librepods.utils.Capability
@Suppress("ArrayInDataClass")
data class AirPodsUiState(
val deviceName: String,
val isLocallyConnected: Boolean = false,
val instance: AirPodsInstance? = null,
val capabilities: Set<Capability> = emptySet(),
val controlStates: Map<ControlCommandIdentifiers, ByteArray> = emptyMap(),
val offListeningMode: Boolean = true,
val battery: List<Battery> = emptyList(),
val ancMode: Int = 3,
val modelName: String = "",
val actualModel: String = "",
val serialNumbers: List<String> = emptyList(),
val version1: String = "",
val version2: String = "",
val version3: String = "",
val headTrackingActive: Boolean = false,
val headGesturesEnabled: Boolean = true,
val eqData: FloatArray = floatArrayOf(),
val automaticEarDetectionEnabled: Boolean = true,
val automaticConnectionEnabled: Boolean = true,
val leftAction: StemAction = StemAction.CYCLE_NOISE_CONTROL_MODES,
val rightAction: StemAction = StemAction.CYCLE_NOISE_CONTROL_MODES,
val isPremium: Boolean = false,
)
class AirPodsViewModel(
private val service: AirPodsService,
private val sharedPreferences: SharedPreferences,
private val controlRepo: ControlCommandRepository,
private val appContext: Context
) : ViewModel() {
private val _uiState = MutableStateFlow(AirPodsUiState(deviceName = sharedPreferences.getString("name", "AirPods Pro") ?: "AirPods Pro"))
val uiState: StateFlow<AirPodsUiState> = _uiState
private val listeners = mutableMapOf<
ControlCommandIdentifiers,
AACPManager.ControlCommandListener
>()
private lateinit var broadcastReceiver: BroadcastReceiver
private val _cameraAction = MutableStateFlow(
sharedPreferences.getString("camera_action", null)
?.let { value -> AACPManager.Companion.StemPressType.entries.find { it.name == value } }
)
val cameraAction: StateFlow<AACPManager.Companion.StemPressType?> = _cameraAction
fun setCameraAction(action: AACPManager.Companion.StemPressType?) {
sharedPreferences.edit {
if (action == null) remove("camera_action")
else putString("camera_action", action.name)
}
_cameraAction.value = action
}
init {
observeBroadcasts()
loadName()
loadInstance()
loadSharedPreferences()
setupControlObservers()
observeBilling()
}
override fun onCleared() {
listeners.forEach { (id, listener) ->
controlRepo.remove(id, listener)
}
appContext.unregisterReceiver(broadcastReceiver)
super.onCleared()
}
private fun loadName() {
val name = sharedPreferences.getString("name", "AirPods Pro")!!
_uiState.update { it.copy(deviceName = name) }
}
private fun observeBilling() {
viewModelScope.launch {
BillingManager.provider.isPremium.collect { premium ->
if (!premium) {
setControlCommandBoolean(ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG, false)
setHeadGesturesEnabled(false)
}
_uiState.update { it.copy(isPremium = premium) }
}
}
}
private fun observeBroadcasts() {
broadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
AirPodsNotifications.AIRPODS_CONNECTED -> {
_uiState.update {
it.copy(isLocallyConnected = true)
}
}
AirPodsNotifications.AIRPODS_DISCONNECTED -> {
_uiState.update {
it.copy(isLocallyConnected = false)
}
}
AirPodsNotifications.BATTERY_DATA -> {
val data = intent.getParcelableArrayListExtra("data", Battery::class.java)?.toList() ?: emptyList()
_uiState.update {
it.copy(battery = data)
}
}
AirPodsNotifications.EQ_DATA -> {
val data = intent.getFloatArrayExtra("eqData") ?: floatArrayOf()
_uiState.update {
it.copy(eqData = data)
}
}
AirPodsNotifications.AIRPODS_INFORMATION_UPDATED -> {
loadInstance()
}
}
}
}
val filter = IntentFilter().apply {
addAction(AirPodsNotifications.AIRPODS_CONNECTED)
addAction(AirPodsNotifications.AIRPODS_DISCONNECTED)
addAction(AirPodsNotifications.BATTERY_DATA)
addAction(AirPodsNotifications.EQ_DATA)
addAction(AirPodsNotifications.AIRPODS_INFORMATION_UPDATED)
}
appContext.registerReceiver(
broadcastReceiver,
filter,
Context.RECEIVER_NOT_EXPORTED
)
}
fun setControlCommandValue(
identifier: ControlCommandIdentifiers,
value: ByteArray
) {
controlRepo.setValue(identifier, value)
_uiState.update {
it.copy(
controlStates = it.controlStates + (identifier to value)
)
}
}
fun setControlCommandBoolean(
identifier: ControlCommandIdentifiers,
enabled: Boolean
) {
setControlCommandValue(
identifier,
if (enabled) byteArrayOf(0x01) else byteArrayOf(0x02)
)
}
fun setControlCommandInt(
identifier: ControlCommandIdentifiers,
value: Int
) {
setControlCommandValue(identifier, byteArrayOf(value.toByte()))
}
fun setControlCommandByte(
identifier: ControlCommandIdentifiers,
value: Byte
) {
setControlCommandValue(identifier, byteArrayOf(value))
}
fun observeControl(identifier: ControlCommandIdentifiers) {
val listener = controlRepo.observe(identifier) { value ->
_uiState.update { state ->
val current = state.controlStates[identifier]
if (current?.contentEquals(value) == true) return@update state
state.copy(
controlStates = state.controlStates + (identifier to value)
)
}
}
listeners[identifier] = listener
}
// I'm lazy, sorry.
fun setupControlObservers() {
val identifiersList = listOf(
ControlCommandIdentifiers.MIC_MODE,
ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
ControlCommandIdentifiers.LISTENING_MODE_CONFIGS,
ControlCommandIdentifiers.ONE_BUD_ANC_MODE,
ControlCommandIdentifiers.LISTENING_MODE,
ControlCommandIdentifiers.AUTO_ANSWER_MODE,
ControlCommandIdentifiers.CHIME_VOLUME,
ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG,
ControlCommandIdentifiers.VOLUME_SWIPE_MODE,
ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG,
ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
ControlCommandIdentifiers.HEARING_AID,
ControlCommandIdentifiers.AUTO_ANC_STRENGTH,
ControlCommandIdentifiers.HPS_GAIN_SWIPE,
ControlCommandIdentifiers.HEARING_ASSIST_CONFIG,
ControlCommandIdentifiers.ALLOW_OFF_OPTION,
ControlCommandIdentifiers.STEM_CONFIG,
ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG,
ControlCommandIdentifiers.ALLOW_AUTO_CONNECT,
ControlCommandIdentifiers.EAR_DETECTION_CONFIG,
ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG,
ControlCommandIdentifiers.OWNS_CONNECTION,
ControlCommandIdentifiers.PPE_TOGGLE_CONFIG,
)
for (identifier in identifiersList) {
observeControl(identifier)
}
}
fun refreshInitialData() {
service.let { service ->
_uiState.update {
it.copy(
isLocallyConnected = service.isConnected(),
battery = service.getBattery()
)
}
}
}
private fun loadSharedPreferences() {
val offListeningModeEnabled = sharedPreferences.getBoolean("off_listening_mode", true)
val automaticEarDetectionEnabled = sharedPreferences.getBoolean("automatic_ear_detection", true)
val automaticConnectionEnabled = sharedPreferences.getBoolean("automatic_connection_ctrl_cmd", true)
val headGesturesEnabled = sharedPreferences.getBoolean("head_gestures", true)
val leftAction = StemAction.valueOf(sharedPreferences.getString("left_long_press_action", "CYCLE_NOISE_CONTROL_MODES") ?: "CYCLE_NOISE_CONTROL_MODES")
val rightAction = StemAction.valueOf(sharedPreferences.getString("right_long_press_action", "CYCLE_NOISE_CONTROL_MODES") ?: "CYCLE_NOISE_CONTROL_MODES")
_uiState.update {
it.copy(
offListeningMode = offListeningModeEnabled,
automaticEarDetectionEnabled = automaticEarDetectionEnabled,
automaticConnectionEnabled = automaticConnectionEnabled,
headGesturesEnabled = headGesturesEnabled,
leftAction = leftAction,
rightAction = rightAction
)
}
}
fun setOffListeningMode(enabled: Boolean) {
sharedPreferences.edit { putBoolean("off_listening_mode", enabled) }
setControlCommandBoolean(ControlCommandIdentifiers.ALLOW_OFF_OPTION, enabled)
Log.d("AirPodsViewModel", "Hello???? $enabled")
_uiState.update {
it.copy(offListeningMode = enabled)
}
}
fun setHeadGesturesEnabled(enabled: Boolean) {
sharedPreferences.edit { putBoolean("head_gestures", enabled) }
_uiState.update {
it.copy(headGesturesEnabled = enabled)
}
}
private fun loadInstance() {
val instance = service.airpodsInstance ?: AirPodsInstance(
name = "AirPods",
model = AirPodsModels.getModelByModelNumber("A3049")!!,
actualModelNumber = "A3049",
aacpManager = service.aacpManager,
serialNumber = null,
leftSerialNumber = null,
rightSerialNumber = null,
version1 = null,
version2 = null,
version3 = null,
attManager = null
)
_uiState.update {
it.copy(
capabilities = instance.model.capabilities,
instance = instance,
modelName = instance.model.displayName,
actualModel = instance.actualModelNumber,
serialNumbers = listOf(instance.serialNumber ?: "", instance.leftSerialNumber ?: "", instance.rightSerialNumber ?: ""),
version1 = instance.version1 ?: "",
version2 = instance.version2 ?: "",
version3 = instance.version3 ?: ""
)
}
}
fun reconnectFromSavedMac() {
service.reconnectFromSavedMac()
}
fun setName(name: String) {
service.setName(name)
}
fun startHeadTracking() {
service.startHeadTracking()
_uiState.update { it.copy(headTrackingActive = true) }
}
fun stopHeadTracking() {
service.stopHeadTracking()
_uiState.update { it.copy(headTrackingActive = false) }
}
fun setATTCharacteristicValue(handle: ATTHandles, value: ByteArray) {
service.attManager?.write(handle, value)
}
fun getATTCharacteristicValue(handle: ATTHandles): ByteArray? {
return service.attManager?.read(handle)
}
fun setAutomaticEarDetectionEnabled(enabled: Boolean) {
sharedPreferences.edit { putBoolean("automatic_ear_detection", enabled) }
setControlCommandBoolean(ControlCommandIdentifiers.EAR_DETECTION_CONFIG, enabled)
_uiState.update {
it.copy(
automaticEarDetectionEnabled = enabled
)
}
}
fun setAutomaticConnectionEnabled(enabled: Boolean) {
sharedPreferences.edit { putBoolean("automatic_connection_ctrl_cmd", enabled) }
setControlCommandBoolean(ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG, enabled)
_uiState.update {
it.copy(
automaticConnectionEnabled = enabled
)
}
}
fun purchase(context: Context) {
BillingManager.provider.purchase(context as Activity)
}
}
@@ -0,0 +1,158 @@
package me.kavishdevar.librepods.viewmodel
import android.app.Activity
import android.app.Application
import android.content.Context
import androidx.core.content.edit
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.billing.BillingManager
import kotlin.math.roundToInt
data class AppSettingsUiState(
val showPhoneBatteryInWidget: Boolean = false,
val conversationalAwarenessPauseMusicEnabled: Boolean = false,
val relativeConversationalAwarenessVolumeEnabled: Boolean = true,
val disconnectWhenNotWearing: Boolean = false,
val takeoverWhenDisconnected: Boolean = false,
val takeoverWhenIdle: Boolean = false,
val takeoverWhenMusic: Boolean = false,
val takeoverWhenCall: Boolean = false,
val takeoverWhenRingingCall: Boolean = false,
val takeoverWhenMediaStart: Boolean = false,
val useAlternateHeadTrackingPackets: Boolean = true,
val conversationalAwarenessVolume: Float = 43f,
val showCameraDialog: Boolean = false,
val cameraPackageValue: String = "",
val cameraPackageError: String? = null,
val isPremium: Boolean = false
)
class AppSettingsViewModel(application: Application) : AndroidViewModel(application) {
private val sharedPreferences = application.getSharedPreferences("settings", Context.MODE_PRIVATE)
private val _uiState = MutableStateFlow(AppSettingsUiState())
val uiState = _uiState.asStateFlow()
init {
loadSettings()
observeBilling()
}
private fun observeBilling() {
viewModelScope.launch {
BillingManager.provider.isPremium.collect { premium ->
_uiState.update { it.copy(isPremium = premium) }
}
}
}
private fun loadSettings() {
_uiState.update { currentState ->
currentState.copy(
showPhoneBatteryInWidget = sharedPreferences.getBoolean("show_phone_battery_in_widget", false),
conversationalAwarenessPauseMusicEnabled = sharedPreferences.getBoolean("conversational_awareness_pause_music", false),
relativeConversationalAwarenessVolumeEnabled = sharedPreferences.getBoolean("relative_conversational_awareness_volume", true),
disconnectWhenNotWearing = sharedPreferences.getBoolean("disconnect_when_not_wearing", false),
takeoverWhenDisconnected = sharedPreferences.getBoolean("takeover_when_disconnected", false),
takeoverWhenIdle = sharedPreferences.getBoolean("takeover_when_idle", false),
takeoverWhenMusic = sharedPreferences.getBoolean("takeover_when_music", false),
takeoverWhenCall = sharedPreferences.getBoolean("takeover_when_call", false),
takeoverWhenRingingCall = sharedPreferences.getBoolean("takeover_when_ringing_call", false),
takeoverWhenMediaStart = sharedPreferences.getBoolean("takeover_when_media_start", false),
useAlternateHeadTrackingPackets = sharedPreferences.getBoolean("use_alternate_head_tracking_packets", true),
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 43).toFloat(),
cameraPackageValue = sharedPreferences.getString("custom_camera_package", "") ?: ""
)
}
}
fun setShowPhoneBatteryInWidget(enabled: Boolean) {
sharedPreferences.edit { putBoolean("show_phone_battery_in_widget", enabled) }
_uiState.update { it.copy(showPhoneBatteryInWidget = enabled) }
}
fun setConversationalAwarenessPauseMusicEnabled(enabled: Boolean) {
sharedPreferences.edit { putBoolean("conversational_awareness_pause_music", enabled) }
_uiState.update { it.copy(conversationalAwarenessPauseMusicEnabled = enabled) }
}
fun setRelativeConversationalAwarenessVolumeEnabled(enabled: Boolean) {
sharedPreferences.edit { putBoolean("relative_conversational_awareness_volume", enabled) }
_uiState.update { it.copy(relativeConversationalAwarenessVolumeEnabled = enabled) }
}
fun setDisconnectWhenNotWearing(enabled: Boolean) {
sharedPreferences.edit { putBoolean("disconnect_when_not_wearing", enabled) }
_uiState.update { it.copy(disconnectWhenNotWearing = enabled) }
}
fun setTakeoverWhenDisconnected(enabled: Boolean) {
sharedPreferences.edit { putBoolean("takeover_when_disconnected", enabled) }
_uiState.update { it.copy(takeoverWhenDisconnected = enabled) }
}
fun setTakeoverWhenIdle(enabled: Boolean) {
sharedPreferences.edit { putBoolean("takeover_when_idle", enabled) }
_uiState.update { it.copy(takeoverWhenIdle = enabled) }
}
fun setTakeoverWhenMusic(enabled: Boolean) {
sharedPreferences.edit { putBoolean("takeover_when_music", enabled) }
_uiState.update { it.copy(takeoverWhenMusic = enabled) }
}
fun setTakeoverWhenCall(enabled: Boolean) {
sharedPreferences.edit { putBoolean("takeover_when_call", enabled) }
_uiState.update { it.copy(takeoverWhenCall = enabled) }
}
fun setTakeoverWhenRingingCall(enabled: Boolean) {
sharedPreferences.edit { putBoolean("takeover_when_ringing_call", enabled) }
_uiState.update { it.copy(takeoverWhenRingingCall = enabled) }
}
fun setTakeoverWhenMediaStart(enabled: Boolean) {
sharedPreferences.edit { putBoolean("takeover_when_media_start", enabled) }
_uiState.update { it.copy(takeoverWhenMediaStart = enabled) }
}
fun setUseAlternateHeadTrackingPackets(enabled: Boolean) {
sharedPreferences.edit { putBoolean("use_alternate_head_tracking_packets", enabled) }
_uiState.update { it.copy(useAlternateHeadTrackingPackets = enabled) }
}
fun setConversationalAwarenessVolume(volume: Float) {
sharedPreferences.edit { putInt("conversational_awareness_volume", volume.roundToInt()) }
_uiState.update { it.copy(conversationalAwarenessVolume = volume) }
}
fun setShowCameraDialog(show: Boolean) {
_uiState.update { it.copy(showCameraDialog = show) }
}
fun setCameraPackageValue(value: String) {
_uiState.update { it.copy(cameraPackageValue = value) }
}
fun setCameraPackageError(error: String?) {
_uiState.update { it.copy(cameraPackageError = error) }
}
fun saveCameraPackage() {
if (_uiState.value.cameraPackageValue.isBlank()) {
sharedPreferences.edit { remove("custom_camera_package") }
} else {
sharedPreferences.edit { putString("custom_camera_package", _uiState.value.cameraPackageValue) }
}
setShowCameraDialog(false)
}
fun purchase(context: Context) {
BillingManager.provider.purchase(context as Activity)
}
}
+212 -216
View File
@@ -1,217 +1,213 @@
<resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation"> <resources xmlns:tools="http://schemas.android.com/tools" tools:ignore="MissingTranslation">
<string name="app_name" translatable="false">LibrePods</string> <string name="app_name" translatable="false">LibrePods</string>
<string name="app_description">Libera i tuoi AirPods dall'ecosistema Apple.</string> <string name="app_description">Libera i tuoi AirPods dall'ecosistema Apple.</string>
<string name="app_widget_description">Visualizza lo stato della batteria dei tuoi AirPods direttamente dalla schermata principale!</string> <string name="app_widget_description">Visualizza lo stato della batteria dei tuoi AirPods direttamente dalla schermata principale!</string>
<string name="accessibility">Accessibilità</string> <string name="accessibility">Accessibilità</string>
<string name="tone_volume">Volume Tono</string> <string name="tone_volume">Volume Tono</string>
<string name="tone_volume_description">Regola il volume del tono degli effetti sonori riprodotti dagli AirPods.</string> <string name="tone_volume_description">Regola il volume del tono degli effetti sonori riprodotti dagli AirPods.</string>
<string name="audio">Audio</string> <string name="audio">Audio</string>
<string name="adaptive_audio">Audio Adattivo</string> <string name="adaptive_audio">Audio Adattivo</string>
<string name="customize_adaptive_audio">Personalizza Audio Adattivo</string> <string name="customize_adaptive_audio">Personalizza Audio Adattivo</string>
<string name="adaptive_audio_description">L'audio adattivo risponde dinamicamente al tuo ambiente e cancella o permette i rumori esterni. Puoi personalizzare l'Audio Adattivo per permettere più o meno rumore.</string> <string name="adaptive_audio_description">L'audio adattivo risponde dinamicamente al tuo ambiente e cancella o permette i rumori esterni. Puoi personalizzare l'Audio Adattivo per permettere più o meno rumore.</string>
<string name="buds">Auricolari</string> <string name="buds">Auricolari</string>
<string name="case_alt">Custodia</string> <string name="case_alt">Custodia</string>
<string name="test">Test</string> <string name="test">Test</string>
<string name="name">Nome</string> <string name="name">Nome</string>
<string name="noise_control">Modalità di Ascolto</string> <string name="noise_control">Modalità di Ascolto</string>
<string name="off">Spento</string> <string name="off">Spento</string>
<string name="transparency">Trasparenza</string> <string name="transparency">Trasparenza</string>
<string name="adaptive">Adattivo</string> <string name="adaptive">Adattivo</string>
<string name="noise_cancellation">Cancellazione del Rumore</string> <string name="noise_cancellation">Cancellazione del Rumore</string>
<string name="press_and_hold_airpods">Premi e Tieni Premuto sugli AirPods</string> <string name="press_and_hold_airpods">Premi e Tieni Premuto sugli AirPods</string>
<string name="press_and_hold_noise_control_description">Premi e tieni premuto sullo stelo per alternare tra le modalità di ascolto selezionate.</string> <string name="press_and_hold_noise_control_description">Premi e tieni premuto sullo stelo per alternare tra le modalità di ascolto selezionate.</string>
<string name="head_gestures">Gesti della Testa</string> <string name="head_gestures">Gesti della Testa</string>
<string name="left">Sinistra</string> <string name="left">Sinistra</string>
<string name="right">Destra</string> <string name="right">Destra</string>
<string name="conversational_awareness">Consapevolezza Conversazionale</string> <string name="conversational_awareness">Consapevolezza Conversazionale</string>
<string name="conversational_awareness_description">Abbassa il volume dei contenuti multimediali e riduce il rumore di fondo quando inizi a parlare con altre persone.</string> <string name="conversational_awareness_description">Abbassa il volume dei contenuti multimediali e riduce il rumore di fondo quando inizi a parlare con altre persone.</string>
<string name="personalized_volume">Volume Personalizzato</string> <string name="personalized_volume">Volume Personalizzato</string>
<string name="personalized_volume_description">Regola il volume dei contenuti multimediali in risposta al tuo ambiente.</string> <string name="personalized_volume_description">Regola il volume dei contenuti multimediali in risposta al tuo ambiente.</string>
<string name="noise_cancellation_single_airpod">Cancellazione del Rumore con un Solo AirPod</string> <string name="noise_cancellation_single_airpod">Cancellazione del Rumore con un Solo AirPod</string>
<string name="noise_cancellation_single_airpod_description">Consenti agli AirPods di essere messi in modalità di cancellazione del rumore quando è presente un solo AirPod nell'orecchio.</string> <string name="noise_cancellation_single_airpod_description">Consenti agli AirPods di essere messi in modalità di cancellazione del rumore quando è presente un solo AirPod nell'orecchio.</string>
<string name="volume_control">Controllo Volume</string> <string name="volume_control">Controllo Volume</string>
<string name="volume_control_description">Regola il volume scorrendo verso l'alto o verso il basso sul sensore situato sullo stelo degli AirPods Pro.</string> <string name="volume_control_description">Regola il volume scorrendo verso l'alto o verso il basso sul sensore situato sullo stelo degli AirPods Pro.</string>
<string name="airpods_not_connected">AirPods non connessi</string> <string name="airpods_not_connected">AirPods non connessi</string>
<string name="airpods_not_connected_description">Si prega di connettere i tuoi AirPods per accedere alle impostazioni.</string> <string name="airpods_not_connected_description">Si prega di connettere i tuoi AirPods per accedere alle impostazioni.</string>
<string name="back">Indietro</string> <string name="back">Indietro</string>
<string name="app_settings">Personalizzazioni</string> <string name="app_settings">Personalizzazioni</string>
<string name="relative_conversational_awareness_volume">Volume relativo</string> <string name="relative_conversational_awareness_volume">Volume relativo</string>
<string name="relative_conversational_awareness_volume_description">Riduce a una percentuale del volume corrente invece del volume massimo.</string> <string name="relative_conversational_awareness_volume_description">Riduce a una percentuale del volume corrente invece del volume massimo.</string>
<string name="conversational_awareness_pause_music">Metti in Pausa la Musica</string> <string name="conversational_awareness_pause_music">Metti in Pausa la Musica</string>
<string name="conversational_awareness_pause_music_description">Quando inizi a parlare, la musica verrà messa in pausa.</string> <string name="conversational_awareness_pause_music_description">Quando inizi a parlare, la musica verrà messa in pausa.</string>
<string name="appwidget_text">ESEMPIO</string> <string name="appwidget_text">ESEMPIO</string>
<string name="add_widget">Aggiungi widget</string> <string name="add_widget">Aggiungi widget</string>
<string name="noise_control_widget_description">Controlla la Modalità di Controllo del Rumore direttamente dalla tua Schermata Principale.</string> <string name="noise_control_widget_description">Controlla la Modalità di Controllo del Rumore direttamente dalla tua Schermata Principale.</string>
<string name="island_connected_text">Connesso</string> <string name="island_connected_text">Connesso</string>
<string name="island_connected_remote_text">Connesso a Linux</string> <string name="island_connected_remote_text">Connesso a Linux</string>
<string name="island_taking_over_text">Connesso</string> <string name="island_taking_over_text">Connesso</string>
<string name="island_moved_to_remote_text">Spostato su Linux</string> <string name="island_moved_to_remote_text">Spostato su Linux</string>
<string name="island_moved_to_other_device_text">Spostato su %1$s</string> <string name="island_moved_to_other_device_text">Spostato su %1$s</string>
<string name="island_moved_to_other_device_reversed_text">Riconnetti dalla notifica</string> <string name="island_moved_to_other_device_reversed_text">Riconnetti dalla notifica</string>
<string name="head_tracking">Tracciamento della Testa</string> <string name="head_tracking">Tracciamento della Testa</string>
<string name="head_gestures_details">Annuisci per rispondere alle chiamate e scuoti la testa per rifiutarle.</string> <string name="head_gestures_details">Annuisci per rispondere alle chiamate e scuoti la testa per rifiutarle.</string>
<string name="general_settings_header">Generale</string> <string name="general_settings_header">Generale</string>
<string name="qs_click_behavior_title">Azione del Tile Impostazioni Rapide</string> <string name="qs_click_behavior_title">Azione del Tile Impostazioni Rapide</string>
<string name="qs_click_behavior_dialog_desc">Mostra la finestra di dialogo per il controllo del rumore al tocco.</string> <string name="qs_click_behavior_dialog_desc">Mostra la finestra di dialogo per il controllo del rumore al tocco.</string>
<string name="qs_click_behavior_cycle_desc">Alterna tra le modalità al tocco.</string> <string name="qs_click_behavior_cycle_desc">Alterna tra le modalità al tocco.</string>
<string name="developer_options_header">Sviluppatore</string> <string name="developer_options_header">Sviluppatore</string>
<string name="more_settings_title">Apri le Impostazioni degli AirPods</string> <string name="more_settings_title">Apri le Impostazioni degli AirPods</string>
<string name="more_settings_subtitle">Gestisci le funzionalità e le preferenze degli AirPods</string> <string name="more_settings_subtitle">Gestisci le funzionalità e le preferenze degli AirPods</string>
<string name="ear_detection">Rilevamento Automatico dell'Orecchio</string> <string name="ear_detection">Rilevamento Automatico dell'Orecchio</string>
<string name="auto_play">Riproduzione Automatica</string> <string name="auto_play">Riproduzione Automatica</string>
<string name="auto_pause">Pausa Automatica</string> <string name="auto_pause">Pausa Automatica</string>
<string name="troubleshooting">Risoluzione dei Problemi</string> <string name="troubleshooting">Risoluzione dei Problemi</string>
<string name="troubleshooting_description">Raccogli i log per diagnosticare i problemi con la connessione degli AirPods</string> <string name="troubleshooting_description">Raccogli i log per diagnosticare i problemi con la connessione degli AirPods</string>
<string name="collect_logs">Raccogli Log</string> <string name="collect_logs">Raccogli Log</string>
<string name="saved_logs">Log Salvati</string> <string name="saved_logs">Log Salvati</string>
<string name="no_logs_found">Nessun log salvato trovato</string> <string name="no_logs_found">Nessun log salvato trovato</string>
<string name="takeover_header">Preferenze di Connessione Automatica</string> <string name="takeover_header">Preferenze di Connessione Automatica</string>
<string name="takeover_airpods_state">Connetti ai tuoi AirPods quando il loro stato è:</string> <string name="takeover_airpods_state">Connetti ai tuoi AirPods quando il loro stato è:</string>
<string name="takeover_disconnected">Disconnesso</string> <string name="takeover_disconnected">Disconnesso</string>
<string name="takeover_disconnected_desc">Gli AirPods non sono connessi a un dispositivo</string> <string name="takeover_disconnected_desc">Gli AirPods non sono connessi a un dispositivo</string>
<string name="takeover_idle">Inattivo</string> <string name="takeover_idle">Inattivo</string>
<string name="takeover_idle_desc">Un dispositivo è connesso ai tuoi AirPods, ma non riproduce contenuti multimediali né è in chiamata</string> <string name="takeover_idle_desc">Un dispositivo è connesso ai tuoi AirPods, ma non riproduce contenuti multimediali né è in chiamata</string>
<string name="takeover_music">Riproduzione di contenuti multimediali</string> <string name="takeover_music">Riproduzione di contenuti multimediali</string>
<string name="takeover_music_desc">Un dispositivo sta riproducendo contenuti multimediali sui tuoi AirPods</string> <string name="takeover_music_desc">Un dispositivo sta riproducendo contenuti multimediali sui tuoi AirPods</string>
<string name="takeover_call">In chiamata</string> <string name="takeover_call">In chiamata</string>
<string name="takeover_call_desc">Un dispositivo è in chiamata con i tuoi AirPods</string> <string name="takeover_call_desc">Un dispositivo è in chiamata con i tuoi AirPods</string>
<string name="takeover_phone_state">Connetti agli AirPods quando il tuo telefono è:</string> <string name="takeover_phone_state">Connetti agli AirPods quando il tuo telefono è:</string>
<string name="takeover_ringing_call">Ricezione di una chiamata</string> <string name="takeover_ringing_call">Ricezione di una chiamata</string>
<string name="takeover_ringing_call_desc">Il tuo telefono inizia a squillare</string> <string name="takeover_ringing_call_desc">Il tuo telefono inizia a squillare</string>
<string name="takeover_media_start">Avvio della riproduzione di contenuti multimediali</string> <string name="takeover_media_start">Avvio della riproduzione di contenuti multimediali</string>
<string name="takeover_media_start_desc">Il tuo telefono inizia a riprodurre contenuti multimediali</string> <string name="takeover_media_start_desc">Il tuo telefono inizia a riprodurre contenuti multimediali</string>
<string name="undo">Annulla</string> <string name="undo">Annulla</string>
<string name="customize_transparency_mode_description">Puoi personalizzare la modalità Trasparenza per i tuoi AirPods Pro per aiutarti a sentire ciò che ti circonda.</string> <string name="customize_transparency_mode_description">Puoi personalizzare la modalità Trasparenza per i tuoi AirPods Pro per aiutarti a sentire ciò che ti circonda.</string>
<string name="loud_sound_reduction_description">La Riduzione dei Suoni Forti può ridurre attivamente la tua esposizione ai forti rumori ambientali quando in modalità Trasparenza e Adattiva. La Riduzione dei Suoni Forti non è attiva in modalità Spento.</string> <string name="loud_sound_reduction_description">La Riduzione dei Suoni Forti può ridurre attivamente la tua esposizione ai forti rumori ambientali quando in modalità Trasparenza e Adattiva. La Riduzione dei Suoni Forti non è attiva in modalità Spento.</string>
<string name="loud_sound_reduction">Riduzione dei Suoni Forti</string> <string name="loud_sound_reduction">Riduzione dei Suoni Forti</string>
<string name="call_controls">Controlli Chiamata</string> <string name="call_controls">Controlli Chiamata</string>
<string name="automatically_connect">Connetti automaticamente a questo dispositivo</string> <string name="automatically_connect">Connetti automaticamente a questo dispositivo</string>
<string name="automatically_connect_description">Quando abilitato, gli AirPods tenteranno di connettersi automaticamente a questo dispositivo. Altrimenti, tenteranno di connettersi automaticamente solo se sono stati connessi in precedenza.</string> <string name="automatically_connect_description">Quando abilitato, gli AirPods tenteranno di connettersi automaticamente a questo dispositivo. Altrimenti, tenteranno di connettersi automaticamente solo se sono stati connessi in precedenza.</string>
<string name="sleep_detection">Metti in pausa i contenuti multimediali quando ti addormenti</string> <string name="sleep_detection">Metti in pausa i contenuti multimediali quando ti addormenti</string>
<string name="off_listening_mode">Modalità Ascolto Disattivata</string> <string name="off_listening_mode">Modalità Ascolto Disattivata</string>
<string name="off_listening_mode_description">Quando questa opzione è attiva, le modalità di ascolto degli AirPods includeranno un'opzione "Spento". I livelli di suono forti non vengono ridotti quando la modalità di ascolto è impostata su "Spento".</string> <string name="off_listening_mode_description">Quando questa opzione è attiva, le modalità di ascolto degli AirPods includeranno un'opzione "Spento". I livelli di suono forti non vengono ridotti quando la modalità di ascolto è impostata su "Spento".</string>
<string name="microphone">Microfono</string> <string name="microphone">Microfono</string>
<string name="microphone_mode">Modalità Microfono</string> <string name="microphone_mode">Modalità Microfono</string>
<string name="microphone_automatic">Automatico</string> <string name="microphone_automatic">Automatico</string>
<string name="microphone_always_right">Sempre Destro</string> <string name="microphone_always_right">Sempre Destro</string>
<string name="microphone_always_left">Sempre Sinistro</string> <string name="microphone_always_left">Sempre Sinistro</string>
<string name="answer_call">Rispondi alla chiamata</string> <string name="answer_call">Rispondi alla chiamata</string>
<string name="mute_unmute">Silenzia/Riattiva</string> <string name="mute_unmute">Silenzia/Riattiva</string>
<string name="hang_up">Riaggancia</string> <string name="hang_up">Riaggancia</string>
<string name="press_once">Premi una Volta</string> <string name="press_once">Premi una Volta</string>
<string name="press_twice">Premi Due Volte</string> <string name="press_twice">Premi Due Volte</string>
<string name="hearing_aid">Apparecchio Acustico</string> <string name="hearing_aid">Apparecchio Acustico</string>
<string name="adjustments">Regolazioni</string> <string name="adjustments">Regolazioni</string>
<string name="swipe_to_control_amplification">Scorri per controllare l'amplificazione</string> <string name="swipe_to_control_amplification">Scorri per controllare l'amplificazione</string>
<string name="swipe_amplification_description">Quando sei in modalità Trasparenza e nessun contenuto multimediale è in riproduzione, scorri verso l'alto e verso il basso sui controlli Touch dei tuoi AirPods Pro per aumentare o diminuire l'amplificazione dei suoni ambientali.</string> <string name="swipe_amplification_description">Quando sei in modalità Trasparenza e nessun contenuto multimediale è in riproduzione, scorri verso l'alto e verso il basso sui controlli Touch dei tuoi AirPods Pro per aumentare o diminuire l'amplificazione dei suoni ambientali.</string>
<string name="transparency_mode">Modalità Trasparenza</string> <string name="transparency_mode">Modalità Trasparenza</string>
<string name="customize_transparency_mode">Personalizza la Modalità Trasparenza</string> <string name="customize_transparency_mode">Personalizza la Modalità Trasparenza</string>
<string name="press_speed">Velocità di Pressione</string> <string name="press_speed">Velocità di Pressione</string>
<string name="press_speed_description">Regola la velocità richiesta per premere due o tre volte sui tuoi AirPods.</string> <string name="press_speed_description">Regola la velocità richiesta per premere due o tre volte sui tuoi AirPods.</string>
<string name="press_and_hold_duration">Durata della Pressione Prolungata</string> <string name="press_and_hold_duration">Durata della Pressione Prolungata</string>
<string name="press_and_hold_duration_description">Regola la durata richiesta per premere e tenere premuto sui tuoi AirPods.</string> <string name="press_and_hold_duration_description">Regola la durata richiesta per premere e tenere premuto sui tuoi AirPods.</string>
<string name="volume_swipe_speed">Velocità di Scorrimento del Volume</string> <string name="volume_swipe_speed">Velocità di Scorrimento del Volume</string>
<string name="volume_swipe_speed_description">Per evitare regolazioni involontarie del volume, seleziona il tempo di attesa preferito tra gli scorrimenti.</string> <string name="volume_swipe_speed_description">Per evitare regolazioni involontarie del volume, seleziona il tempo di attesa preferito tra gli scorrimenti.</string>
<string name="equalizer">Equalizzatore</string> <string name="equalizer">Equalizzatore</string>
<string name="apply_eq_to">Applica EQ a</string> <string name="apply_eq_to">Applica EQ a</string>
<string name="phone">Telefono</string> <string name="phone">Telefono</string>
<string name="media">Media</string> <string name="media">Media</string>
<string name="band_label">Banda %d</string> <string name="band_label">Banda %d</string>
<string name="default_option">Predefinito</string> <string name="default_option">Predefinito</string>
<string name="slower">Più lento</string> <string name="slower">Più lento</string>
<string name="slowest">Il più lento</string> <string name="slowest">Il più lento</string>
<string name="longer">Più lungo</string> <string name="longer">Più lungo</string>
<string name="longest">Il più lungo</string> <string name="longest">Il più lungo</string>
<string name="darker">Più scuro</string> <string name="darker">Più scuro</string>
<string name="brighter">Più luminoso</string> <string name="brighter">Più luminoso</string>
<string name="less">Meno</string> <string name="less">Meno</string>
<string name="more">Di più</string> <string name="more">Di più</string>
<string name="amplification">Amplificazione</string> <string name="amplification">Amplificazione</string>
<string name="balance">Bilanciamento</string> <string name="balance">Bilanciamento</string>
<string name="tone">Tono</string> <string name="tone">Tono</string>
<string name="ambient_noise_reduction">Riduzione del Rumore Ambientale</string> <string name="ambient_noise_reduction">Riduzione del Rumore Ambientale</string>
<string name="conversation_boost">Potenziamento Conversazione</string> <string name="conversation_boost">Potenziamento Conversazione</string>
<string name="conversation_boost_description">Potenziamento Conversazione concentra i tuoi AirPods Pro sulla persona che parla di fronte a te, rendendo più facile sentire in una conversazione faccia a faccia.</string> <string name="conversation_boost_description">Potenziamento Conversazione concentra i tuoi AirPods Pro sulla persona che parla di fronte a te, rendendo più facile sentire in una conversazione faccia a faccia.</string>
<string name="hearing_aid_description">Gli AirPods possono utilizzare i risultati di un test dell'udito per apportare modifiche che migliorano la chiarezza delle voci e dei suoni intorno a te.\n\nApparecchio Acustico è destinato solo a persone con perdita dell'udito da lieve a moderata.</string> <string name="hearing_aid_description">Gli AirPods possono utilizzare i risultati di un test dell'udito per apportare modifiche che migliorano la chiarezza delle voci e dei suoni intorno a te.\n\nApparecchio Acustico è destinato solo a persone con perdita dell'udito da lieve a moderata.</string>
<string name="media_assist">Assistenza Media</string> <string name="media_assist">Assistenza Media</string>
<string name="media_assist_description">Gli AirPods Pro possono utilizzare i risultati di un test dell'udito per apportare modifiche che migliorano la chiarezza di musica, video e chiamate.</string> <string name="media_assist_description">Gli AirPods Pro possono utilizzare i risultati di un test dell'udito per apportare modifiche che migliorano la chiarezza di musica, video e chiamate.</string>
<string name="adjust_media">Regola Musica e Video</string> <string name="adjust_media">Regola Musica e Video</string>
<string name="adjust_calls">Regola Chiamate</string> <string name="adjust_calls">Regola Chiamate</string>
<string name="widget">Widget</string> <string name="widget">Widget</string>
<string name="show_phone_battery_in_widget">Mostra la batteria del telefono nel widget</string> <string name="show_phone_battery_in_widget">Mostra la batteria del telefono nel widget</string>
<string name="show_phone_battery_in_widget_description">Visualizza il livello della batteria del tuo telefono nel widget accanto alla batteria degli AirPods</string> <string name="show_phone_battery_in_widget_description">Visualizza il livello della batteria del tuo telefono nel widget accanto alla batteria degli AirPods</string>
<string name="conversational_awareness_volume">Volume Consapevolezza Conversazionale</string> <string name="conversational_awareness_volume">Volume Consapevolezza Conversazionale</string>
<string name="quick_settings_tile">Tile Impostazioni Rapide</string> <string name="quick_settings_tile">Tile Impostazioni Rapide</string>
<string name="open_dialog_for_controlling">Apri finestra di dialogo per il controllo</string> <string name="open_dialog_for_controlling">Apri finestra di dialogo per il controllo</string>
<string name="open_dialog_for_controlling_description">Se disabilitato, cliccando sul QS si scorrerà tra le modalità. Se abilitato, verrà mostrata una finestra di dialogo per controllare la modalità di controllo del rumore e la consapevolezza conversazionale.</string> <string name="open_dialog_for_controlling_description">Se disabilitato, cliccando sul QS si scorrerà tra le modalità. Se abilitato, verrà mostrata una finestra di dialogo per controllare la modalità di controllo del rumore e la consapevolezza conversazionale.</string>
<string name="disconnect_when_not_wearing">Disconnetti AirPods quando non indossati</string> <string name="disconnect_when_not_wearing">Disconnetti AirPods quando non indossati</string>
<string name="disconnect_when_not_wearing_description">Sarai ancora in grado di controllarli con l'app - questo disconnette solo l'audio.</string> <string name="disconnect_when_not_wearing_description">Sarai ancora in grado di controllarli con l'app - questo disconnette solo l'audio.</string>
<string name="advanced_options">Opzioni Avanzate</string> <string name="advanced_options">Opzioni Avanzate</string>
<string name="set_identity_resolving_key">Imposta Chiave di Risoluzione Identità (IRK)</string> <string name="set_identity_resolving_key">Imposta Chiave di Risoluzione Identità (IRK)</string>
<string name="set_identity_resolving_key_description">Imposta manualmente il valore IRK utilizzato per risolvere gli indirizzi casuali BLE</string> <string name="set_identity_resolving_key_description">Imposta manualmente il valore IRK utilizzato per risolvere gli indirizzi casuali BLE</string>
<string name="set_encryption_key">Imposta Chiave di Crittografia</string> <string name="set_encryption_key">Imposta Chiave di Crittografia</string>
<string name="set_encryption_key_description">Imposta manualmente il valore ENC_KEY utilizzato per decrittografare le pubblicità BLE</string> <string name="set_encryption_key_description">Imposta manualmente il valore ENC_KEY utilizzato per decrittografare le pubblicità BLE</string>
<string name="use_alternate_head_tracking_packets">Utilizza pacchetti alternativi di tracciamento della testa</string> <string name="use_alternate_head_tracking_packets">Utilizza pacchetti alternativi di tracciamento della testa</string>
<string name="use_alternate_head_tracking_packets_description">Abilita questo se il tracciamento della testa non funziona per te. Questo invia dati diversi agli AirPods per richiedere/interrompere i dati di tracciamento della testa.</string> <string name="use_alternate_head_tracking_packets_description">Abilita questo se il tracciamento della testa non funziona per te. Questo invia dati diversi agli AirPods per richiedere/interrompere i dati di tracciamento della testa.</string>
<string name="act_as_an_apple_device">Comportati come un dispositivo Apple</string> <string name="act_as_an_apple_device">Comportati come un dispositivo Apple</string>
<string name="act_as_an_apple_device_description">Abilita la connettività multi-dispositivo e le funzionalità di Accessibilità come la personalizzazione della modalità Trasparenza (amplificazione, tono, riduzione del rumore ambientale, potenziamento conversazione ed EQ)</string> <string name="act_as_an_apple_device_description">Abilita la connettività multi-dispositivo e le funzionalità di Accessibilità come la personalizzazione della modalità Trasparenza (amplificazione, tono, riduzione del rumore ambientale, potenziamento conversazione ed EQ)</string>
<string name="act_as_an_apple_device_warning">Potrebbe essere instabile!! Un massimo di due dispositivi possono essere connessi ai tuoi AirPods. Se li stai usando con un dispositivo Apple come un iPad o un Mac, connetti prima quel dispositivo e poi il tuo Android.</string> <string name="act_as_an_apple_device_warning">Potrebbe essere instabile!! Un massimo di due dispositivi possono essere connessi ai tuoi AirPods. Se li stai usando con un dispositivo Apple come un iPad o un Mac, connetti prima quel dispositivo e poi il tuo Android.</string>
<string name="reset_hook_offset">Reimposta Offset Hook</string> <string name="reset_hook_offset">Reimposta Offset Hook</string>
<string name="reset_hook_offset_description">Questo cancellerà l'offset hook corrente e richiederà di rifare la procedura di configurazione. Sei sicuro di voler continuare?</string> <string name="reset_hook_offset_description">Questo cancellerà l'offset hook corrente e richiederà di rifare la procedura di configurazione. Sei sicuro di voler continuare?</string>
<string name="reset">Reimposta</string> <string name="reset">Reimposta</string>
<string name="hook_offset_reset_success">Offset hook è stato resettato. Reindirizzamento alla configurazione...</string> <string name="hook_offset_reset_success">Offset hook è stato resettato. Reindirizzamento alla configurazione...</string>
<string name="hook_offset_reset_failure">Impossibile reimpostare l'offset hook</string> <string name="hook_offset_reset_failure">Impossibile reimpostare l'offset hook</string>
<string name="irk_set_success">IRK impostata correttamente</string> <string name="irk_set_success">IRK impostata correttamente</string>
<string name="encryption_key_set_success">Chiave di crittografia impostata correttamente</string> <string name="encryption_key_set_success">Chiave di crittografia impostata correttamente</string>
<string name="irk_hex_value">Valore Esadecimale IRK</string> <string name="irk_hex_value">Valore Esadecimale IRK</string>
<string name="enc_key_hex_value">Valore Esadecimale ENC_KEY</string> <string name="enc_key_hex_value">Valore Esadecimale ENC_KEY</string>
<string name="enter_irk_hex">Inserisci IRK di 16 byte come stringa esadecimale (32 caratteri):</string> <string name="enter_irk_hex">Inserisci IRK di 16 byte come stringa esadecimale (32 caratteri):</string>
<string name="enter_enc_key_hex">Inserisci ENC_KEY di 16 byte come stringa esadecimale (32 caratteri):</string> <string name="enter_enc_key_hex">Inserisci ENC_KEY di 16 byte come stringa esadecimale (32 caratteri):</string>
<string name="must_be_32_hex_chars">Devono essere esattamente 32 caratteri esadecimali</string> <string name="must_be_32_hex_chars">Devono essere esattamente 32 caratteri esadecimali</string>
<string name="error_converting_hex">Errore durante la conversione esadecimale:</string> <string name="error_converting_hex">Errore durante la conversione esadecimale:</string>
<string name="found_offset_restart_bluetooth">Offset trovato, riavviare il processo Bluetooth</string> <string name="found_offset_restart_bluetooth">Offset trovato, riavviare il processo Bluetooth</string>
<string name="digital_assistant">Assistente Digitale</string> <string name="digital_assistant">Assistente Digitale</string>
<string name="on">Attivo</string> <string name="on">Attivo</string>
<string name="camera_remote">Telecomando Fotocamera</string> <string name="camera_remote">Telecomando Fotocamera</string>
<string name="camera_control">Controllo Fotocamera</string> <string name="camera_control">Controllo Fotocamera</string>
<string name="camera_control_description">Scatta una foto, avvia o interrompi la registrazione e altro utilizzando Premere una Volta o Premere e Tenere Premuto. Quando si utilizzano gli AirPods per le azioni della fotocamera, se si seleziona Premere una Volta, i gesti di controllo dei media non saranno disponibili e, se si seleziona Premere e Tenere Premuto, la modalità di ascolto e i gesti dell'Assistente Digitale non saranno disponibili.</string> <string name="camera_control_description">Scatta una foto, avvia o interrompi la registrazione e altro utilizzando Premere una Volta o Premere e Tenere Premuto. Quando si utilizzano gli AirPods per le azioni della fotocamera, se si seleziona Premere una Volta, i gesti di controllo dei media non saranno disponibili e, se si seleziona Premere e Tenere Premuto, la modalità di ascolto e i gesti dell'Assistente Digitale non saranno disponibili.</string>
<string name="camera_control_app_description">Imposta un pacchetto app personalizzato per il rilevamento della fotocamera</string> <string name="camera_control_app_description">Imposta un pacchetto app personalizzato per il rilevamento della fotocamera</string>
<string name="set_custom_camera_package">Imposta Appid Fotocamera Personalizzata</string> <string name="set_custom_camera_package">Imposta Appid Fotocamera Personalizzata</string>
<string name="enter_custom_camera_package">Inserisci l'id dell'applicazione della fotocamera:</string> <string name="enter_custom_camera_package">Inserisci l'id dell'applicazione della fotocamera:</string>
<string name="custom_camera_package">Appid Fotocamera Personalizzata</string> <string name="custom_camera_package">Appid Fotocamera Personalizzata</string>
<string name="custom_camera_package_set_success">Appid fotocamera personalizzata impostata correttamente</string> <string name="custom_camera_package_set_success">Appid fotocamera personalizzata impostata correttamente</string>
<string name="app_listener_service_label">Ascoltatore fotocamera</string> <string name="app_listener_service_label">Ascoltatore fotocamera</string>
<string name="app_listener_service_description">Servizio di ascolto per LibrePods per rilevare quando la fotocamera è attiva per attivare il controllo della fotocamera sugli AirPods.</string> <string name="app_listener_service_description">Servizio di ascolto per LibrePods per rilevare quando la fotocamera è attiva per attivare il controllo della fotocamera sugli AirPods.</string>
<string name="open_source_licenses">Licenze Open Source</string> <string name="open_source_licenses">Licenze Open Source</string>
<string name="hearing_test">Aggiorna Test Uditivo</string> <string name="hearing_test">Aggiorna Test Uditivo</string>
<string name="update_hearing_test">Aggiorna Risultato Test Uditivo</string> <string name="update_hearing_test">Aggiorna Risultato Test Uditivo</string>
<string name="att_manager_is_null_try_reconnecting">ATT Manager è nullo, prova a riconnetterti.</string> <string name="att_manager_is_null_try_reconnecting">ATT Manager è nullo, prova a riconnetterti.</string>
<string name="permissions_required">Sono richieste le seguenti autorizzazioni per utilizzare l'app. Si prega di concederle per continuare.</string> <string name="permissions_required">Sono richieste le seguenti autorizzazioni per utilizzare l'app. Si prega di concederle per continuare.</string>
<string name="shake_your_head_or_nod">Scuoti la testa o annuisci!</string> <string name="shake_your_head_or_nod">Scuoti la testa o annuisci!</string>
<string name="root_access_required">Accesso Root Richiesto</string> <string name="root_access_required">Accesso Root Richiesto</string>
<string name="this_app_needs_root_access_to_hook_onto_the_bluetooth_library">Questa app ha bisogno dell'accesso root per agganciarsi alla libreria Bluetooth</string> <string name="this_app_needs_root_access_to_hook_onto_the_bluetooth_library">Questa app ha bisogno dell'accesso root per agganciarsi alla libreria Bluetooth</string>
<string name="root_access_denied">L'accesso root è stato negato. Si prega di concedere i permessi di root.</string> <string name="root_access_denied">L'accesso root è stato negato. Si prega di concedere i permessi di root.</string>
<string name="troubleshooting_steps">Passaggi per la Risoluzione dei Problemi</string> <string name="troubleshooting_steps">Passaggi per la Risoluzione dei Problemi</string>
<string name="hearing_test_value_instruction">Si prega di inserire i valori di perdita in dbHL</string> <string name="hearing_test_value_instruction">Si prega di inserire i valori di perdita in dbHL</string>
<string name="about">Informazioni</string> <string name="about">Informazioni</string>
<string name="model_name">Nome Modello</string> <string name="model_name">Nome Modello</string>
<string name="model_number">Numero Modello</string> <string name="model_number">Numero Modello</string>
<string name="serial_number">Numero di Serie</string> <string name="serial_number">Numero di Serie</string>
<string name="version">Versione</string> <string name="version">Versione</string>
<string name="hearing_health">Salute Uditiva</string> <string name="hearing_health">Salute Uditiva</string>
<string name="hearing_protection">Protezione dell'Udito</string> <string name="hearing_protection">Protezione dell'Udito</string>
<string name="workspace_use">Uso in Ambienti di Lavoro</string> <string name="workspace_use">Uso in Ambienti di Lavoro</string>
<string name="ppe">Protezione EN 352</string> <string name="ppe">Protezione EN 352</string>
<string name="workspace_use_description">La protezione EN 352 limita il livello massimo dei media a 82 dBA e soddisfa i requisiti applicabili dello standard EN 352 per la protezione individuale dell'udito.</string> <string name="workspace_use_description">La protezione EN 352 limita il livello massimo dei media a 82 dBA e soddisfa i requisiti applicabili dello standard EN 352 per la protezione individuale dell'udito.</string>
<string name="environmental_noise">Rumore Ambientale</string> <string name="environmental_noise">Rumore Ambientale</string>
<string name="reconnect_to_last_device">Riconnetti all'ultimo dispositivo connesso</string> <string name="reconnect_to_last_device">Riconnetti all'ultimo dispositivo connesso</string>
<string name="disconnect">Disconnetti</string> <string name="disconnect">Disconnetti</string>
<string name="support_me">Supportami</string> <string name="listening_mode_off_description">Disattiva la gestione del rumore</string>
<string name="never_show_again">Non mostrare più</string> <string name="listening_mode_transparency_description">Lascia entrare i suoni esterni</string>
<string name="support_dialog_description">Di recente ho perso il mio AirPod sinistro. Se hai trovato utile LibrePods, considera di supportarmi su GitHub Sponsors in modo che possa acquistare un sostituto e continuare a lavorare su questo progetto: anche una piccola somma fa molto. Grazie per il tuo supporto!</string> <string name="listening_mode_adaptive_description">Regola dinamicamente il rumore esterno</string>
<string name="support_librepods">Supporta LibrePods</string> <string name="listening_mode_noise_cancellation_description">Blocca i suoni esterni</string>
<string name="listening_mode_off_description">Disattiva la gestione del rumore</string>
<string name="listening_mode_transparency_description">Lascia entrare i suoni esterni</string>
<string name="listening_mode_adaptive_description">Regola dinamicamente il rumore esterno</string>
<string name="listening_mode_noise_cancellation_description">Blocca i suoni esterni</string>
</resources> </resources>
@@ -206,10 +206,6 @@
<string name="environmental_noise">Ruido ambiental</string> <string name="environmental_noise">Ruido ambiental</string>
<string name="reconnect_to_last_device">Reconectar al último dispositivo conectado</string> <string name="reconnect_to_last_device">Reconectar al último dispositivo conectado</string>
<string name="disconnect">Desconectar</string> <string name="disconnect">Desconectar</string>
<string name="support_me">Apóyame</string>
<string name="never_show_again">No volver a mostrar</string>
<string name="support_dialog_description">Hace poco perdí mi AirPod izquierdo. Si LibrePods te ha resultado útil, considera apoyarme en GitHub Sponsors para que pueda comprar un reemplazo y seguir trabajando en este proyecto; incluso una pequeña donación es de gran ayuda. ¡Gracias por tu apoyo!</string>
<string name="support_librepods">Apoya a LibrePods</string>
<string name="listening_mode_off_description">Desactiva la gestión del ruido</string> <string name="listening_mode_off_description">Desactiva la gestión del ruido</string>
<string name="listening_mode_transparency_description">Deja entrar los sonidos externos</string> <string name="listening_mode_transparency_description">Deja entrar los sonidos externos</string>
<string name="listening_mode_adaptive_description">Ajuste dinámico del ruido externo</string> <string name="listening_mode_adaptive_description">Ajuste dinámico del ruido externo</string>
@@ -206,10 +206,6 @@
<string name="environmental_noise">Bruit environnemental</string> <string name="environmental_noise">Bruit environnemental</string>
<string name="reconnect_to_last_device">Reconnecter au dernier appareil</string> <string name="reconnect_to_last_device">Reconnecter au dernier appareil</string>
<string name="disconnect">Déconnecter</string> <string name="disconnect">Déconnecter</string>
<string name="support_me">Soutenez-moi</string>
<string name="never_show_again">Ne plus afficher</string>
<string name="support_dialog_description">J\'ai récemment perdu mon AirPod gauche. Si LibrePods vous est utile, pensez à me soutenir sur GitHub Sponsors pour m\'aider à en racheter un et continuer ce projet — même un petit montant aide beaucoup. Merci pour votre soutien !</string>
<string name="support_librepods">Soutenir LibrePods</string>
<string name="listening_mode_off_description">Désactiver la gestion du bruit</string> <string name="listening_mode_off_description">Désactiver la gestion du bruit</string>
<string name="listening_mode_transparency_description">Laisser entrer les sons extérieurs</string> <string name="listening_mode_transparency_description">Laisser entrer les sons extérieurs</string>
<string name="listening_mode_adaptive_description">Ajuster dynamiquement les sons extérieurs</string> <string name="listening_mode_adaptive_description">Ajuster dynamiquement les sons extérieurs</string>
@@ -206,10 +206,6 @@
<string name="environmental_noise">Ruído Ambiental</string> <string name="environmental_noise">Ruído Ambiental</string>
<string name="reconnect_to_last_device">Reconectar ao último dispositivo conectado</string> <string name="reconnect_to_last_device">Reconectar ao último dispositivo conectado</string>
<string name="disconnect">Desconectar</string> <string name="disconnect">Desconectar</string>
<string name="support_me">Me Apoiar</string>
<string name="never_show_again">Nunca mostrar novamente</string>
<string name="support_dialog_description">Recentemente perdi meu AirPod esquerdo. Se você achou o LibrePods útil, considere me apoiar no GitHub Sponsors para que eu possa comprar uma substituição e continuar trabalhando neste projeto - mesmo uma pequena quantia faz muita diferença. Obrigado pelo seu apoio!</string>
<string name="support_librepods">Apoiar LibrePods</string>
<string name="listening_mode_off_description">Desativa o gerenciamento de ruído</string> <string name="listening_mode_off_description">Desativa o gerenciamento de ruído</string>
<string name="listening_mode_transparency_description">Permite sons externos</string> <string name="listening_mode_transparency_description">Permite sons externos</string>
<string name="listening_mode_adaptive_description">Ajusta dinamicamente o ruído externo</string> <string name="listening_mode_adaptive_description">Ajusta dinamicamente o ruído externo</string>
@@ -206,10 +206,6 @@
<string name="environmental_noise">Çevresel Gürültü</string> <string name="environmental_noise">Çevresel Gürültü</string>
<string name="reconnect_to_last_device">Son bağlanan cihaza yeniden bağlan</string> <string name="reconnect_to_last_device">Son bağlanan cihaza yeniden bağlan</string>
<string name="disconnect">Bağlantıyı Kes</string> <string name="disconnect">Bağlantıyı Kes</string>
<string name="support_me">Beni destekle</string>
<string name="never_show_again">Bir daha gösterme</string>
<string name="support_dialog_description">Yakın zamanda sol AirPod\'umu kaybettim. LibrePods\'u faydalı bulduysanız, bir yedek satın alıp bu proje üzerinde çalışmaya devam edebilmem için GitHub Sponsors\'ta beni desteklemeyi düşünün - küçük bir miktar bile çok işe yarar. Desteğiniz için teşekkürler!</string>
<string name="support_librepods">LibrePods\'u Destekle</string>
<string name="listening_mode_off_description">Gürültü yönetimini kapatır</string> <string name="listening_mode_off_description">Gürültü yönetimini kapatır</string>
<string name="listening_mode_transparency_description">Dış sesleri içeri alır</string> <string name="listening_mode_transparency_description">Dış sesleri içeri alır</string>
<string name="listening_mode_adaptive_description">Dış gürültüyü dinamik olarak ayarlar</string> <string name="listening_mode_adaptive_description">Dış gürültüyü dinamik olarak ayarlar</string>
@@ -206,10 +206,6 @@
<string name="environmental_noise">Навколишній Шум</string> <string name="environmental_noise">Навколишній Шум</string>
<string name="reconnect_to_last_device">Перепідключитися до останнього підключеного пристрою</string> <string name="reconnect_to_last_device">Перепідключитися до останнього підключеного пристрою</string>
<string name="disconnect">Відʼєднатися</string> <string name="disconnect">Відʼєднатися</string>
<string name="support_me">Підтримати мене</string>
<string name="never_show_again">Ніколи не показувати знову</string>
<string name="support_dialog_description">Нещодавно я втратив свій лівий AirPod. Якщо LibrePods виявилися корисними для вас, розгляньте можливість підтримати мене на GitHub Sponsors, щоб я міг купити заміну та продовжити роботу над цим проектом — навіть невелика допомога має велике значення. Дякую за вашу підтримку!</string>
<string name="support_librepods">Підтримати LibrePods</string>
<string name="listening_mode_off_description">Вимикає керування шумом</string> <string name="listening_mode_off_description">Вимикає керування шумом</string>
<string name="listening_mode_transparency_description">Пропускає зовнішні звуки</string> <string name="listening_mode_transparency_description">Пропускає зовнішні звуки</string>
<string name="listening_mode_adaptive_description">Динамічно налаштовує зовнішній шум</string> <string name="listening_mode_adaptive_description">Динамічно налаштовує зовнішній шум</string>
@@ -206,10 +206,6 @@
<string name="environmental_noise">Tiếng ồn môi trường</string> <string name="environmental_noise">Tiếng ồn môi trường</string>
<string name="reconnect_to_last_device">Kết nối lại với thiết bị được kết nối lần cuối</string> <string name="reconnect_to_last_device">Kết nối lại với thiết bị được kết nối lần cuối</string>
<string name="disconnect">Ngắt kết nối</string> <string name="disconnect">Ngắt kết nối</string>
<string name="support_me">Hỗ trợ tôi</string>
<string name="never_show_again">Không hiển thị lại</string>
<string name="support_dialog_description">Gần đây tôi bị mất tai bên trái của AirPod. Nếu bạn thấy LibrePods hữu ích, hãy cân nhắc hỗ trợ tôi trên GitHub Sponsors để tôi có thể mua cái thay thế và tiếp tục làm việc trên dự án này - ngay cả một khoản nhỏ cũng rất có ý nghĩa. Cảm ơn sự hỗ trợ của bạn!</string>
<string name="support_librepods">Hỗ trợ LibrePods</string>
<string name="listening_mode_off_description">Tắt quản lý tiếng ồn</string> <string name="listening_mode_off_description">Tắt quản lý tiếng ồn</string>
<string name="listening_mode_transparency_description">Cho phép âm thanh bên ngoài</string> <string name="listening_mode_transparency_description">Cho phép âm thanh bên ngoài</string>
<string name="listening_mode_adaptive_description">Điều chỉnh động tiếng ồn bên ngoài</string> <string name="listening_mode_adaptive_description">Điều chỉnh động tiếng ồn bên ngoài</string>
@@ -207,12 +207,8 @@
<string name="environmental_noise">环境噪音</string> <string name="environmental_noise">环境噪音</string>
<string name="reconnect_to_last_device">重新连接到上次连接的设备</string> <string name="reconnect_to_last_device">重新连接到上次连接的设备</string>
<string name="disconnect">断开连接</string> <string name="disconnect">断开连接</string>
<string name="support_me">支持我</string>
<string name="never_show_again">不再显示</string>
<string name="support_dialog_description">我最近丢了我的左耳 AirPod。如果你觉得 LibrePods 有用,请考虑在 GitHub Sponsors 上支持我,这样我就可以购买一个替换品并继续从事这个项目——即使是少量捐助也能发挥很大作用。感谢你的支持!</string>
<string name="support_librepods">支持 LibrePods</string>
<string name="listening_mode_off_description">关闭噪音管理</string> <string name="listening_mode_off_description">关闭噪音管理</string>
<string name="listening_mode_transparency_description">允许外部声音进入</string> <string name="listening_mode_transparency_description">允许外部声音进入</string>
<string name="listening_mode_adaptive_description">动态调整外部噪音</string> <string name="listening_mode_adaptive_description">动态调整外部噪音</string>
<string name="listening_mode_noise_cancellation_description">阻隔外部声音</string> <string name="listening_mode_noise_cancellation_description">阻隔外部声音</string>
</resources> </resources>
@@ -208,12 +208,8 @@
<string name="environmental_noise">環境噪音</string> <string name="environmental_noise">環境噪音</string>
<string name="reconnect_to_last_device">重新連接至上次連接的裝置</string> <string name="reconnect_to_last_device">重新連接至上次連接的裝置</string>
<string name="disconnect">中斷連線</string> <string name="disconnect">中斷連線</string>
<string name="support_me">贊助我</string>
<string name="never_show_again">不再顯示</string>
<string name="support_dialog_description">我最近弄丟了左耳的 AirPod。如果你覺得 LibrePods 很好用,請考慮在 GitHub Sponsors 上贊助我,讓我能買個替換品並繼續開發這個專案,一點點金額也能帶來很大的幫助。感謝你的支持!</string>
<string name="support_librepods">贊助 LibrePods</string>
<string name="listening_mode_off_description">關閉噪音管理</string> <string name="listening_mode_off_description">關閉噪音管理</string>
<string name="listening_mode_transparency_description">允許外部聲音</string> <string name="listening_mode_transparency_description">允許外部聲音</string>
<string name="listening_mode_adaptive_description">動態調整外部噪音</string> <string name="listening_mode_adaptive_description">動態調整外部噪音</string>
<string name="listening_mode_noise_cancellation_description">阻隔外部聲音</string> <string name="listening_mode_noise_cancellation_description">阻隔外部聲音</string>
</resources> </resources>
+1 -4
View File
@@ -206,12 +206,9 @@
<string name="environmental_noise">Environmental Noise</string> <string name="environmental_noise">Environmental Noise</string>
<string name="reconnect_to_last_device">Reconnect to last connected device</string> <string name="reconnect_to_last_device">Reconnect to last connected device</string>
<string name="disconnect">Disconnect</string> <string name="disconnect">Disconnect</string>
<string name="support_me">Support me</string>
<string name="never_show_again">Never show again</string>
<string name="support_dialog_description">I recently lost my left AirPod. If you\'ve found LibrePods useful, consider supporting me on GitHub Sponsors so I can buy a replacement and continue working on this project- even a little amount goes a long way. Thank you for your support!</string>
<string name="support_librepods">Support LibrePods</string>
<string name="listening_mode_off_description">Turns off noise management</string> <string name="listening_mode_off_description">Turns off noise management</string>
<string name="listening_mode_transparency_description">Lets in external sounds</string> <string name="listening_mode_transparency_description">Lets in external sounds</string>
<string name="listening_mode_adaptive_description">Dynamically adjust external noise</string> <string name="listening_mode_adaptive_description">Dynamically adjust external noise</string>
<string name="listening_mode_noise_cancellation_description">Blocks out external sounds</string> <string name="listening_mode_noise_cancellation_description">Blocks out external sounds</string>
<string name="unlock_all_features">Unlock all features</string>
</resources> </resources>
+2 -2
View File
@@ -1,7 +1,7 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins { plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.aboutLibraries) apply false alias(libs.plugins.aboutLibraries) apply false
} // alias(libs.plugins.hilt) apply false
}
+15 -2
View File
@@ -6,7 +6,7 @@
# http://www.gradle.org/docs/current/userguide/build_environment.html # http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process. # Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings. # The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 org.gradle.jvmargs=-Xmx8192m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode. # When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit # This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
@@ -22,4 +22,17 @@ kotlin.code.style=official
# thereby reducing the size of the R class for that library # thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
android.javaCompile.suppressSourceTargetDeprecationWarning=true android.javaCompile.suppressSourceTargetDeprecationWarning=true
org.gradle.caching=true
org.gradle.configuration-cache=true
#android.defaults.buildfeatures.resvalues=true
#android.sdk.defaultTargetSdkToCompileSdkIfUnset=false
#android.enableAppCompileTimeRClass=false
#android.usesSdkInManifest.disallowed=false
#android.uniquePackageNames=false
#android.dependency.useConstraints=true
#android.r8.strictFullModeForKeepRules=false
#android.r8.optimizedResourceShrinking=false
#android.builtInKotlin=false
#android.newDsl=false
+25 -19
View File
@@ -1,21 +1,22 @@
[versions] [versions]
accompanistPermissions = "0.36.0" accompanistPermissions = "0.37.3"
agp = "8.9.1" agp = "9.1.0"
kotlin = "2.1.10" kotlin = "2.3.20"
coreKtx = "1.17.0" coreKtx = "1.18.0"
lifecycleRuntimeKtx = "2.8.7" lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.10.1" activityCompose = "1.13.0"
composeBom = "2025.04.00" composeBom = "2026.03.01"
annotations = "26.0.2" annotations = "26.1.0"
navigationCompose = "2.8.9" navigationCompose = "2.9.7"
constraintlayout = "2.2.1" constraintlayout = "2.2.1"
haze = "1.6.10" haze = "1.7.2"
hazeMaterials = "1.6.10" hazeMaterials = "1.7.2"
dynamicanimation = "1.1.0" dynamicanimation = "1.1.0"
foundationLayout = "1.9.1" aboutLibraries = "14.0.1"
uiTooling = "1.9.1" materialIconsCore = "1.7.8"
ui = "1.9.2" backdrop = "2.0.0-alpha03"
aboutLibraries = "13.0.0-rc01" billing = "8.3.0"
hilt = "2.59.2"
[libraries] [libraries]
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" } accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" }
@@ -33,14 +34,19 @@ androidx-constraintlayout = { group = "androidx.constraintlayout", name = "const
haze = { group = "dev.chrisbanes.haze", name = "haze", version.ref = "haze" } haze = { group = "dev.chrisbanes.haze", name = "haze", version.ref = "haze" }
haze-materials = { group = "dev.chrisbanes.haze", name = "haze-materials", version.ref = "hazeMaterials" } haze-materials = { group = "dev.chrisbanes.haze", name = "haze-materials", version.ref = "hazeMaterials" }
androidx-dynamicanimation = { group = "androidx.dynamicanimation", name = "dynamicanimation", version.ref = "dynamicanimation" } androidx-dynamicanimation = { group = "androidx.dynamicanimation", name = "dynamicanimation", version.ref = "dynamicanimation" }
androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "foundationLayout" } androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout"}
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "uiTooling" } androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "ui" } androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
aboutlibraries = { group = "com.mikepenz", name = "aboutlibraries", version.ref = "aboutLibraries" } aboutlibraries = { group = "com.mikepenz", name = "aboutlibraries", version.ref = "aboutLibraries" }
aboutlibraries-compose-m3 = { group = "com.mikepenz", name = "aboutlibraries-compose-m3", version.ref = "aboutLibraries" } aboutlibraries-compose-m3 = { group = "com.mikepenz", name = "aboutlibraries-compose-m3", version.ref = "aboutLibraries" }
androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core", version.ref = "materialIconsCore" }
backdrop = { group = "io.github.kyant0", name = "backdrop", version.ref = "backdrop" }
billing = { group = "com.android.billingclient", name = "billing-ktx", version.ref = "billing" }
hilt = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
aboutLibraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "aboutLibraries" } aboutLibraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "aboutLibraries" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
+1 -1
View File
@@ -1,6 +1,6 @@
#Mon Oct 07 22:30:36 IST 2024 #Mon Oct 07 22:30:36 IST 2024
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists