diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/AudioSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/AudioSettings.kt
index 5e7cf63..25a0c4e 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/AudioSettings.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/AudioSettings.kt
@@ -20,6 +20,7 @@
package me.kavishdevar.librepods.composables
+import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
@@ -27,7 +28,10 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
+import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
@@ -37,12 +41,32 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R
+import me.kavishdevar.librepods.services.ServiceManager
+import me.kavishdevar.librepods.utils.ATTManager
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun AudioSettings() {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
+ val attManager = ATTManager(ServiceManager.getService()?.device?: throw IllegalStateException("No device connected"))
+ DisposableEffect(attManager) {
+ onDispose {
+ try {
+ attManager.disconnect()
+ } catch (e: Exception) {
+ Log.w("AirPodsAudioSettings", "Error while disconnecting ATTManager: ${e.message}")
+ }
+ }
+ }
+ LaunchedEffect(Unit) {
+ Log.d("AirPodsAudioSettings", "Connecting to ATT...")
+ try {
+ attManager.connect()
+ } catch (e: Exception) {
+ Log.w("AirPodsAudioSettings", "Error while connecting ATTManager: ${e.message}")
+ }
+ }
Text(
text = stringResource(R.string.audio).uppercase(),
@@ -63,7 +87,29 @@ fun AudioSettings() {
.padding(top = 2.dp)
) {
+ PersonalizedVolumeSwitch()
+ HorizontalDivider(
+ thickness = 1.5.dp,
+ color = Color(0x40888888),
+ modifier = Modifier
+ .padding(start = 12.dp, end = 0.dp)
+ )
+
ConversationalAwarenessSwitch()
+ HorizontalDivider(
+ thickness = 1.5.dp,
+ color = Color(0x40888888),
+ modifier = Modifier
+ .padding(start = 12.dp, end = 0.dp)
+ )
+
+ LoudSoundReductionSwitch(attManager)
+ HorizontalDivider(
+ thickness = 1.5.dp,
+ color = Color(0x40888888),
+ modifier = Modifier
+ .padding(start = 12.dp, end = 0.dp)
+ )
Column(
modifier = Modifier
@@ -91,7 +137,6 @@ fun AudioSettings() {
color = textColor.copy(alpha = 0.6f)
)
)
-
AdaptiveStrengthSlider()
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/AutomaticConnectionSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/AutomaticConnectionSwitch.kt
new file mode 100644
index 0000000..994da45
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/AutomaticConnectionSwitch.kt
@@ -0,0 +1,182 @@
+/*
+ * LibrePods - AirPods liberated from Apple’s ecosystem
+ *
+ * Copyright (C) 2025 LibrePods contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License.
+ *
+ * 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 Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+@file:OptIn(ExperimentalEncodingApi::class)
+
+package me.kavishdevar.librepods.composables
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalContext
+import android.content.Context.MODE_PRIVATE
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import me.kavishdevar.librepods.R
+import me.kavishdevar.librepods.services.ServiceManager
+import me.kavishdevar.librepods.utils.AACPManager
+import kotlin.io.encoding.ExperimentalEncodingApi
+
+@Composable
+fun AutomaticConnectionSwitch() {
+ val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
+ val service = ServiceManager.getService()!!
+
+ val shared_preference_key = "automatic_connection_ctrl_cmd"
+
+ val automaticConnectionEnabledValue = service.aacpManager.controlCommandStatusList.find {
+ it.identifier == AACPManager.Companion.ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG
+ }?.value?.takeIf { it.isNotEmpty() }?.get(0)
+
+ var automaticConnectionEnabled by remember {
+ mutableStateOf(
+ if (automaticConnectionEnabledValue != null) {
+ automaticConnectionEnabledValue == 1.toByte()
+ } else {
+ sharedPreferences.getBoolean(shared_preference_key, false)
+ }
+ )
+ }
+
+ fun updateAutomaticConnection(enabled: Boolean) {
+ automaticConnectionEnabled = enabled
+ service.aacpManager.sendControlCommand(
+ AACPManager.Companion.ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG.value,
+ enabled
+ )
+ // todo: send other connected devices smartAudioRoutingDisabled or something, check packets again.
+
+ sharedPreferences.edit()
+ .putBoolean(shared_preference_key, enabled)
+ .apply()
+ }
+
+ val automaticConnectionListener = object: AACPManager.ControlCommandListener {
+ override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
+ if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG.value) {
+ val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
+ val enabled = newValue == 1.toByte()
+ automaticConnectionEnabled = enabled
+
+ sharedPreferences.edit()
+ .putBoolean(shared_preference_key, enabled)
+ .apply()
+ }
+ }
+ }
+
+ LaunchedEffect(Unit) {
+ service.aacpManager.registerControlCommandListener(
+ AACPManager.Companion.ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG,
+ automaticConnectionListener
+ )
+ }
+ DisposableEffect(Unit) {
+ onDispose {
+ service.aacpManager.unregisterControlCommandListener(
+ AACPManager.Companion.ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG,
+ automaticConnectionListener
+ )
+ }
+ }
+
+ val isDarkTheme = isSystemInDarkTheme()
+ val textColor = if (isDarkTheme) Color.White else Color.Black
+
+ val isPressed = remember { mutableStateOf(false) }
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(
+ shape = RoundedCornerShape(14.dp),
+ color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
+ )
+ .padding(horizontal = 12.dp, vertical = 12.dp)
+ .pointerInput(Unit) {
+ detectTapGestures(
+ onPress = {
+ isPressed.value = true
+ tryAwaitRelease()
+ isPressed.value = false
+ }
+ )
+ }
+ .clickable(
+ indication = null,
+ interactionSource = remember { MutableInteractionSource() }
+ ) {
+ updateAutomaticConnection(!automaticConnectionEnabled)
+ },
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .padding(end = 4.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.automatically_connect),
+ fontSize = 16.sp,
+ color = textColor
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = stringResource(R.string.automatically_connect_description),
+ fontSize = 12.sp,
+ color = textColor.copy(0.6f),
+ lineHeight = 14.sp,
+ )
+ }
+ StyledSwitch(
+ checked = automaticConnectionEnabled,
+ onCheckedChange = {
+ updateAutomaticConnection(it)
+ },
+ )
+ }
+}
+
+@Preview
+@Composable
+fun AutomaticConnectionSwitchPreview() {
+ AutomaticConnectionSwitch()
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/CallControlSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/CallControlSettings.kt
new file mode 100644
index 0000000..56bd43f
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/CallControlSettings.kt
@@ -0,0 +1,287 @@
+/*
+ * LibrePods - AirPods liberated from Apple’s ecosystem
+ *
+ * Copyright (C) 2025 LibrePods contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License.
+ *
+ * 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 Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+@file:OptIn(ExperimentalEncodingApi::class)
+
+package me.kavishdevar.librepods.composables
+
+import android.util.Log
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.KeyboardArrowDown
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import me.kavishdevar.librepods.R
+import me.kavishdevar.librepods.services.ServiceManager
+import me.kavishdevar.librepods.utils.AACPManager
+import kotlin.io.encoding.ExperimentalEncodingApi
+
+@Composable
+fun CallControlSettings() {
+ val isDarkTheme = isSystemInDarkTheme()
+ val textColor = if (isDarkTheme) Color.White else Color.Black
+ val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
+
+ Text(
+ text = stringResource(R.string.call_controls).uppercase(),
+ style = TextStyle(
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Light,
+ color = textColor.copy(alpha = 0.6f)
+ ),
+ modifier = Modifier.padding(8.dp, bottom = 2.dp)
+ )
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(backgroundColor, RoundedCornerShape(14.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)
+
+ var flipped by remember { mutableStateOf(callControlEnabledValue.contentEquals(byteArrayOf(0x00, 0x02))) }
+ var singlePressAction by remember { mutableStateOf(if (flipped) "Double Press" else "Single Press") }
+ var doublePressAction by remember { mutableStateOf(if (flipped) "Single Press" else "Double Press") }
+ var showSinglePressDropdown by remember { mutableStateOf(false) }
+ var showDoublePressDropdown 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) "Double Press" else "Single Press"
+ doublePressAction = if (newFlipped) "Single Press" else "Double Press"
+ 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) {
+ Log.d("CallControlSettings", "Call control flipped: $flipped")
+ }
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 12.dp, end = 12.dp)
+ .height(55.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "Answer call",
+ fontSize = 18.sp,
+ color = textColor,
+ modifier = Modifier.padding(bottom = 4.dp)
+ )
+ Text(
+ text = "Single Press",
+ fontSize = 18.sp,
+ color = textColor.copy(alpha = 0.6f)
+ )
+ }
+ HorizontalDivider(
+ thickness = 1.5.dp,
+ color = Color(0x40888888),
+ modifier = Modifier
+ .padding(start = 12.dp, end = 0.dp)
+ )
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 12.dp, end = 12.dp)
+ .height(55.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "Mute/Unmute",
+ fontSize = 18.sp,
+ color = textColor,
+ modifier = Modifier.padding(bottom = 4.dp)
+ )
+ Box {
+ Row(
+ modifier = Modifier.clickable { showSinglePressDropdown = true },
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = singlePressAction,
+ fontSize = 18.sp,
+ color = textColor.copy(alpha = 0.8f)
+ )
+ Icon(
+ Icons.Default.KeyboardArrowDown,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ tint = textColor.copy(alpha = 0.6f)
+ )
+ }
+ DropdownMenu(
+ expanded = showSinglePressDropdown,
+ onDismissRequest = { showSinglePressDropdown = false }
+ ) {
+ DropdownMenuItem(
+ text = { Text("Single Press") },
+ onClick = {
+ singlePressAction = "Single Press"
+ doublePressAction = "Double Press"
+ showSinglePressDropdown = false
+ service.aacpManager.sendControlCommand(0x24, byteArrayOf(0x00, 0x03))
+ }
+ )
+ DropdownMenuItem(
+ text = { Text("Double Press") },
+ onClick = {
+ singlePressAction = "Double Press"
+ doublePressAction = "Single Press"
+ showSinglePressDropdown = false
+ service.aacpManager.sendControlCommand(0x24, byteArrayOf(0x00, 0x02))
+ }
+ )
+ }
+ }
+ }
+ HorizontalDivider(
+ thickness = 1.5.dp,
+ color = Color(0x40888888),
+ modifier = Modifier
+ .padding(start = 12.dp, end = 0.dp)
+ )
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 12.dp, end = 12.dp)
+ .height(55.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "Hang Up",
+ fontSize = 18.sp,
+ color = textColor,
+ modifier = Modifier.padding(bottom = 4.dp)
+ )
+ Box {
+ Row(
+ modifier = Modifier.clickable { showDoublePressDropdown = true },
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = doublePressAction,
+ fontSize = 18.sp,
+ color = textColor.copy(alpha = 0.8f)
+ )
+ Icon(
+ Icons.Default.KeyboardArrowDown,
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ tint = textColor.copy(alpha = 0.6f)
+ )
+ }
+ DropdownMenu(
+ expanded = showDoublePressDropdown,
+ onDismissRequest = { showDoublePressDropdown = false }
+ ) {
+ DropdownMenuItem(
+ text = { Text("Single Press") },
+ onClick = {
+ doublePressAction = "Single Press"
+ singlePressAction = "Double Press"
+ showDoublePressDropdown = false
+ service.aacpManager.sendControlCommand(0x24, byteArrayOf(0x00, 0x02))
+ }
+ )
+ DropdownMenuItem(
+ text = { Text("Double Press") },
+ onClick = {
+ doublePressAction = "Double Press"
+ singlePressAction = "Single Press"
+ showDoublePressDropdown = false
+ service.aacpManager.sendControlCommand(0x24, byteArrayOf(0x00, 0x03))
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Preview
+@Composable
+fun CallControlSettingsPreview() {
+ CallControlSettings()
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/ConnectionSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/ConnectionSettings.kt
new file mode 100644
index 0000000..58ffa14
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/ConnectionSettings.kt
@@ -0,0 +1,76 @@
+/*
+ * LibrePods - AirPods liberated from Apple’s ecosystem
+ *
+ * Copyright (C) 2025 LibrePods contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License.
+ *
+ * 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 Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+@file:OptIn(ExperimentalEncodingApi::class)
+
+package me.kavishdevar.librepods.composables
+
+import android.util.Log
+import androidx.compose.foundation.background
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import me.kavishdevar.librepods.R
+import me.kavishdevar.librepods.services.ServiceManager
+import me.kavishdevar.librepods.utils.ATTManager
+import kotlin.io.encoding.ExperimentalEncodingApi
+
+@Composable
+fun ConnectionSettings() {
+ val isDarkTheme = isSystemInDarkTheme()
+ val textColor = if (isDarkTheme) Color.White else Color.Black
+ val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(backgroundColor, RoundedCornerShape(14.dp))
+ .padding(top = 2.dp)
+ ) {
+ EarDetectionSwitch()
+ HorizontalDivider(
+ thickness = 1.5.dp,
+ color = Color(0x40888888),
+ modifier = Modifier
+ .padding(start = 12.dp, end = 0.dp)
+ )
+
+ AutomaticConnectionSwitch()
+ }
+}
+
+@Preview
+@Composable
+fun ConnectionSettingsPreview() {
+ ConnectionSettings()
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/ConversationalAwarenessSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/ConversationalAwarenessSwitch.kt
index 46fa6f6..7492a62 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/ConversationalAwarenessSwitch.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/ConversationalAwarenessSwitch.kt
@@ -133,7 +133,7 @@ fun ConversationalAwarenessSwitch() {
.padding(end = 4.dp)
) {
Text(
- text = "Conversational Awareness",
+ text = stringResource(R.string.conversational_awareness),
fontSize = 16.sp,
color = textColor
)
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/CustomDropdown.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/CustomDropdown.kt
deleted file mode 100644
index a4d37b6..0000000
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/CustomDropdown.kt
+++ /dev/null
@@ -1,182 +0,0 @@
-/*
- * LibrePods - AirPods liberated from Apple’s ecosystem
- *
- * Copyright (C) 2025 LibrePods contributors
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as published
- * by the Free Software Foundation, either version 3 of the License.
- *
- * 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 Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see .
- */
-
-package me.kavishdevar.librepods.composables
-
-import androidx.compose.animation.core.Spring
-import androidx.compose.animation.core.animateDpAsState
-import androidx.compose.animation.core.animateFloatAsState
-import androidx.compose.animation.core.spring
-import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.interaction.MutableInteractionSource
-import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.widthIn
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.scale
-import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.layout.onGloballyPositioned
-import androidx.compose.ui.platform.LocalDensity
-import androidx.compose.ui.text.font.Font
-import androidx.compose.ui.text.font.FontFamily
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.IntOffset
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import androidx.compose.ui.window.Popup
-import androidx.compose.ui.window.PopupProperties
-import me.kavishdevar.librepods.R
-
-class DropdownItem(val name: String, val onSelect: () -> Unit) {
- fun select() {
- onSelect()
- }
-}
-
-@Composable
-fun CustomDropdown(name: String, description: String = "", items: List) {
- val isDarkTheme = isSystemInDarkTheme()
- val textColor = if (isDarkTheme) Color.White else Color.Black
- val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
- var expanded by remember { mutableStateOf(false) }
- var offset by remember { mutableStateOf(IntOffset.Zero) }
- var popupHeight by remember { mutableStateOf(0.dp) }
-
- val animatedHeight by animateDpAsState(
- targetValue = if (expanded) popupHeight else 0.dp,
- animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow)
- )
- val animatedScale by animateFloatAsState(
- targetValue = if (expanded) 1f else 0f,
- animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow)
- )
-
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .background(
- shape = RoundedCornerShape(14.dp),
- color = Color.Transparent
- )
- .padding(horizontal = 12.dp, vertical = 12.dp)
- .clickable(
- indication = null,
- interactionSource = remember { MutableInteractionSource() }
- ) {
- expanded = true
- }
- .onGloballyPositioned { coordinates ->
- val windowPosition = coordinates.localToWindow(Offset.Zero)
- offset = IntOffset(windowPosition.x.toInt(), windowPosition.y.toInt() + coordinates.size.height)
- },
- verticalAlignment = Alignment.CenterVertically
- ) {
- Column(
- modifier = Modifier
- .weight(1f)
- .padding(end = 4.dp)
- ) {
- Text(
- text = name,
- fontSize = 16.sp,
- color = textColor,
- maxLines = 1
- )
- if (description.isNotEmpty()) {
- Spacer(modifier = Modifier.height(4.dp))
- Text(
- text = description,
- fontSize = 12.sp,
- color = textColor.copy(0.6f),
- lineHeight = 14.sp,
- maxLines = 1
- )
- }
- }
- Text(
- text = "\uDBC0\uDD8F",
- fontSize = 16.sp,
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- color = textColor
- )
- }
-
- if (expanded) {
- Popup(
- alignment = Alignment.TopStart,
- offset = offset ,
- properties = PopupProperties(focusable = true),
- onDismissRequest = { expanded = false }
- ) {
- val density = LocalDensity.current
- Column(
- modifier = Modifier
- .background(backgroundColor, RoundedCornerShape(8.dp))
- .padding(8.dp)
- .widthIn(max = 50.dp)
- .height(animatedHeight)
- .scale(animatedScale)
- .onGloballyPositioned { coordinates ->
- popupHeight = with(density) { coordinates.size.height.toDp() }
- }
- ) {
- items.forEach { item ->
- Text(
- text = item.name,
- modifier = Modifier
- .fillMaxWidth()
- .clickable {
- item.select()
- expanded = false
- }
- .padding(8.dp),
- color = textColor
- )
- }
- }
- }
- }
-}
-
-@Preview
-@Composable
-fun CustomDropdownPreview() {
- CustomDropdown(
- name = "Volume Swipe Speed",
- items = listOf(
- DropdownItem("Always On") { },
- DropdownItem("Off") { },
- DropdownItem("Only when speaking") { }
- )
- )
-}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/EarDetectionSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/EarDetectionSwitch.kt
new file mode 100644
index 0000000..37ef7c8
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/EarDetectionSwitch.kt
@@ -0,0 +1,176 @@
+/*
+ * LibrePods - AirPods liberated from Apple’s ecosystem
+ *
+ * Copyright (C) 2025 LibrePods contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License.
+ *
+ * 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 Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+@file:OptIn(ExperimentalEncodingApi::class)
+
+package me.kavishdevar.librepods.composables
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import android.content.Context.MODE_PRIVATE
+import android.content.SharedPreferences
+import me.kavishdevar.librepods.R
+import me.kavishdevar.librepods.services.ServiceManager
+import me.kavishdevar.librepods.utils.AACPManager
+import kotlin.io.encoding.ExperimentalEncodingApi
+
+@Composable
+fun EarDetectionSwitch() {
+ val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
+ val service = ServiceManager.getService()!!
+
+ val shared_preference_key = "automatic_ear_detection"
+
+ val earDetectionEnabledValue = service.aacpManager.controlCommandStatusList.find {
+ it.identifier == AACPManager.Companion.ControlCommandIdentifiers.EAR_DETECTION_CONFIG
+ }?.value?.takeIf { it.isNotEmpty() }?.get(0)
+
+ var earDetectionEnabled by remember {
+ mutableStateOf(
+ if (earDetectionEnabledValue != null) {
+ earDetectionEnabledValue == 1.toByte()
+ } else {
+ sharedPreferences.getBoolean(shared_preference_key, false)
+ }
+ )
+ }
+
+ fun updateEarDetection(enabled: Boolean) {
+ earDetectionEnabled = enabled
+ service.aacpManager.sendControlCommand(
+ AACPManager.Companion.ControlCommandIdentifiers.EAR_DETECTION_CONFIG.value,
+ enabled
+ )
+ service.setEarDetection(enabled)
+
+ sharedPreferences.edit()
+ .putBoolean(shared_preference_key, enabled)
+ .apply()
+ }
+
+ val earDetectionListener = object: AACPManager.ControlCommandListener {
+ override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
+ if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.EAR_DETECTION_CONFIG.value) {
+ val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
+ val enabled = newValue == 1.toByte()
+ earDetectionEnabled = enabled
+
+ sharedPreferences.edit()
+ .putBoolean(shared_preference_key, enabled)
+ .apply()
+ }
+ }
+ }
+
+ LaunchedEffect(Unit) {
+ service.aacpManager.registerControlCommandListener(
+ AACPManager.Companion.ControlCommandIdentifiers.EAR_DETECTION_CONFIG,
+ earDetectionListener
+ )
+ }
+ DisposableEffect(Unit) {
+ onDispose {
+ service.aacpManager.unregisterControlCommandListener(
+ AACPManager.Companion.ControlCommandIdentifiers.EAR_DETECTION_CONFIG,
+ earDetectionListener
+ )
+ }
+ }
+
+ val isDarkTheme = isSystemInDarkTheme()
+ val textColor = if (isDarkTheme) Color.White else Color.Black
+
+ val isPressed = remember { mutableStateOf(false) }
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(
+ shape = RoundedCornerShape(14.dp),
+ color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
+ )
+ .padding(horizontal = 12.dp, vertical = 12.dp)
+ .pointerInput(Unit) {
+ detectTapGestures(
+ onPress = {
+ isPressed.value = true
+ tryAwaitRelease()
+ isPressed.value = false
+ }
+ )
+ }
+ .clickable(
+ indication = null,
+ interactionSource = remember { MutableInteractionSource() }
+ ) {
+ updateEarDetection(!earDetectionEnabled)
+ },
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .padding(end = 4.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.ear_detection),
+ fontSize = 16.sp,
+ color = textColor
+ )
+ }
+ StyledSwitch(
+ checked = earDetectionEnabled,
+ onCheckedChange = {
+ updateEarDetection(it)
+ }
+ )
+ }
+}
+
+@Preview
+@Composable
+fun EarDetectionSwitchPreview() {
+ EarDetectionSwitch()
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/PersonalizedVolumeSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/PersonalizedVolumeSwitch.kt
new file mode 100644
index 0000000..14bb876
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/PersonalizedVolumeSwitch.kt
@@ -0,0 +1,163 @@
+/*
+ * LibrePods - AirPods liberated from Apple’s ecosystem
+ *
+ * Copyright (C) 2025 LibrePods contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License.
+ *
+ * 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 Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+@file:OptIn(ExperimentalEncodingApi::class)
+
+package me.kavishdevar.librepods.composables
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import me.kavishdevar.librepods.R
+import me.kavishdevar.librepods.services.ServiceManager
+import me.kavishdevar.librepods.utils.AACPManager
+import kotlin.io.encoding.ExperimentalEncodingApi
+
+@Composable
+fun PersonalizedVolumeSwitch() {
+ val service = ServiceManager.getService()!!
+
+ val adaptiveVolumeEnabledValue = service.aacpManager.controlCommandStatusList.find {
+ it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG
+ }?.value?.takeIf { it.isNotEmpty() }?.get(0)
+
+ var adaptiveVolumeEnabled by remember {
+ mutableStateOf(
+ adaptiveVolumeEnabledValue == 1.toByte()
+ )
+ }
+
+ fun updatePersonalizedVolume(enabled: Boolean) {
+ adaptiveVolumeEnabled = enabled
+ service.aacpManager.sendControlCommand(
+ AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG.value,
+ enabled
+ )
+ }
+
+ val adaptiveVolumeListener = object: AACPManager.ControlCommandListener {
+ override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
+ if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG.value) {
+ val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
+ adaptiveVolumeEnabled = newValue == 1.toByte()
+ }
+ }
+ }
+
+ LaunchedEffect(Unit) {
+ service.aacpManager.registerControlCommandListener(
+ AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG,
+ adaptiveVolumeListener
+ )
+ }
+ DisposableEffect(Unit) {
+ onDispose {
+ service.aacpManager.unregisterControlCommandListener(
+ AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG,
+ adaptiveVolumeListener
+ )
+ }
+ }
+
+ val isDarkTheme = isSystemInDarkTheme()
+ val textColor = if (isDarkTheme) Color.White else Color.Black
+
+ val isPressed = remember { mutableStateOf(false) }
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(
+ shape = RoundedCornerShape(14.dp),
+ color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
+ )
+ .padding(horizontal = 12.dp, vertical = 12.dp)
+ .pointerInput(Unit) {
+ detectTapGestures(
+ onPress = {
+ isPressed.value = true
+ tryAwaitRelease()
+ isPressed.value = false
+ }
+ )
+ }
+ .clickable(
+ indication = null,
+ interactionSource = remember { MutableInteractionSource() }
+ ) {
+ updatePersonalizedVolume(!adaptiveVolumeEnabled)
+ },
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .padding(end = 4.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.personalized_volume),
+ fontSize = 16.sp,
+ color = textColor
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = stringResource(R.string.personalized_volume_description),
+ fontSize = 12.sp,
+ color = textColor.copy(0.6f),
+ lineHeight = 14.sp,
+ )
+ }
+ StyledSwitch(
+ checked = adaptiveVolumeEnabled,
+ onCheckedChange = {
+ updatePersonalizedVolume(it)
+ },
+ )
+ }
+}
+
+@Preview
+@Composable
+fun PersonalizedVolumeSwitchPreview() {
+ PersonalizedVolumeSwitch()
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt
index 9e587e3..d920461 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt
@@ -75,6 +75,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.PointerEventPass
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
@@ -102,6 +103,7 @@ import me.kavishdevar.librepods.composables.VolumeControlSwitch
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.ATTManager
import me.kavishdevar.librepods.utils.AACPManager
+import me.kavishdevar.librepods.utils.RadareOffsetFinder
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.ByteOrder
@@ -124,6 +126,9 @@ fun AccessibilitySettingsScreen() {
val attManager = ATTManager(ServiceManager.getService()?.device?: throw IllegalStateException("No device connected"))
// get the AACP manager if available (used for EQ read/write)
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
+ val context = LocalContext.current
+ val radareOffsetFinder = remember { RadareOffsetFinder(context) }
+ val isSdpOffsetAvailable = remember { mutableStateOf(RadareOffsetFinder.isSdpOffsetAvailable()) }
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
@@ -457,76 +462,80 @@ fun AccessibilitySettingsScreen() {
}
}
- AccessibilityToggle(
- text = "Transparency Mode",
- mutableState = enabled,
- independent = true
- )
- Text(
- text = stringResource(R.string.customize_transparency_mode_description),
- style = TextStyle(
- fontSize = 12.sp,
- color = textColor.copy(0.6f),
- lineHeight = 14.sp,
- ),
- modifier = Modifier
- .padding(horizontal = 2.dp)
- )
- Spacer(modifier = Modifier.height(4.dp))
- Text(
- text = "Customize Transparency Mode".uppercase(),
- style = TextStyle(
- fontSize = 14.sp,
- fontWeight = FontWeight.Light,
- color = textColor.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ),
- modifier = Modifier.padding(8.dp, bottom = 2.dp)
- )
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .background(backgroundColor, RoundedCornerShape(14.dp))
- .padding(8.dp)
- ) {
- AccessibilitySlider(
- label = "Amplification",
- valueRange = -1f..1f,
- value = amplificationSliderValue.floatValue,
- onValueChange = {
- amplificationSliderValue.floatValue = snapIfClose(it, listOf(-0.5f, -0.25f, 0f, 0.25f, 0.5f))
- },
- )
- AccessibilitySlider(
- label = "Balance",
- valueRange = -1f..1f,
- value = balanceSliderValue.floatValue,
- onValueChange = {
- balanceSliderValue.floatValue = snapIfClose(it, listOf(0f))
- },
- )
- AccessibilitySlider(
- label = "Tone",
- valueRange = -1f..1f,
- value = toneSliderValue.floatValue,
- onValueChange = {
- toneSliderValue.floatValue = snapIfClose(it, listOf(0f))
- },
- )
- AccessibilitySlider(
- label = "Ambient Noise Reduction",
- valueRange = 0f..1f,
- value = ambientNoiseReductionSliderValue.floatValue,
- onValueChange = {
- ambientNoiseReductionSliderValue.floatValue = snapIfClose(it, listOf(0.1f, 0.3f, 0.5f, 0.7f, 0.9f))
- },
- )
+ // Only show transparency mode section if SDP offset is available
+ if (isSdpOffsetAvailable.value) {
AccessibilityToggle(
- text = "Conversation Boost",
- mutableState = conversationBoostEnabled
+ text = "Transparency Mode",
+ mutableState = enabled,
+ independent = true
)
+ Text(
+ text = stringResource(R.string.customize_transparency_mode_description),
+ style = TextStyle(
+ fontSize = 12.sp,
+ color = textColor.copy(0.6f),
+ lineHeight = 14.sp,
+ ),
+ modifier = Modifier
+ .padding(horizontal = 2.dp)
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ text = "Customize Transparency Mode".uppercase(),
+ style = TextStyle(
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Light,
+ color = textColor.copy(alpha = 0.6f),
+ fontFamily = FontFamily(Font(R.font.sf_pro))
+ ),
+ modifier = Modifier.padding(8.dp, bottom = 2.dp)
+ )
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(backgroundColor, RoundedCornerShape(14.dp))
+ .padding(8.dp)
+ ) {
+ AccessibilitySlider(
+ label = "Amplification",
+ valueRange = -1f..1f,
+ value = amplificationSliderValue.floatValue,
+ onValueChange = {
+ amplificationSliderValue.floatValue = snapIfClose(it, listOf(-0.5f, -0.25f, 0f, 0.25f, 0.5f))
+ },
+ )
+ AccessibilitySlider(
+ label = "Balance",
+ valueRange = -1f..1f,
+ value = balanceSliderValue.floatValue,
+ onValueChange = {
+ balanceSliderValue.floatValue = snapIfClose(it, listOf(0f))
+ },
+ )
+ AccessibilitySlider(
+ label = "Tone",
+ valueRange = -1f..1f,
+ value = toneSliderValue.floatValue,
+ onValueChange = {
+ toneSliderValue.floatValue = snapIfClose(it, listOf(0f))
+ },
+ )
+ AccessibilitySlider(
+ label = "Ambient Noise Reduction",
+ valueRange = 0f..1f,
+ value = ambientNoiseReductionSliderValue.floatValue,
+ onValueChange = {
+ ambientNoiseReductionSliderValue.floatValue = snapIfClose(it, listOf(0.1f, 0.3f, 0.5f, 0.7f, 0.9f))
+ },
+ )
+ AccessibilityToggle(
+ text = "Conversation Boost",
+ mutableState = conversationBoostEnabled
+ )
+ }
+ Spacer(modifier = Modifier.height(2.dp))
}
- Spacer(modifier = Modifier.height(2.dp))
+
Text(
text = "AUDIO",
style = TextStyle(
@@ -604,101 +613,105 @@ fun AccessibilitySettingsScreen() {
}
Spacer(modifier = Modifier.height(2.dp))
- Text(
- text = "Equalizer".uppercase(),
- style = TextStyle(
- fontSize = 14.sp,
- fontWeight = FontWeight.Light,
- color = textColor.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ),
- modifier = Modifier.padding(8.dp, bottom = 2.dp)
- )
+ // Only show transparency mode EQ section if SDP offset is available
+ if (isSdpOffsetAvailable.value) {
+ Text(
+ text = "Equalizer".uppercase(),
+ style = TextStyle(
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Light,
+ color = textColor.copy(alpha = 0.6f),
+ fontFamily = FontFamily(Font(R.font.sf_pro))
+ ),
+ modifier = Modifier.padding(8.dp, bottom = 2.dp)
+ )
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .background(backgroundColor, RoundedCornerShape(14.dp))
- .padding(16.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.SpaceBetween
- ) {
- for (i in 0 until 8) {
- val eqValue = remember(eq.value[i]) { mutableFloatStateOf(eq.value[i]) }
- Row(
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier
- .fillMaxWidth()
- .height(38.dp)
- ) {
- Text(
- text = String.format("%.2f", eqValue.floatValue),
- fontSize = 12.sp,
- color = textColor,
- modifier = Modifier.padding(bottom = 4.dp)
- )
-
- Slider(
- value = eqValue.floatValue,
- onValueChange = { newVal ->
- eqValue.floatValue = newVal
- val newEQ = eq.value.copyOf()
- newEQ[i] = eqValue.floatValue
- eq.value = newEQ
- },
- valueRange = 0f..100f,
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(backgroundColor, RoundedCornerShape(14.dp))
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.SpaceBetween
+ ) {
+ for (i in 0 until 8) {
+ val eqValue = remember(eq.value[i]) { mutableFloatStateOf(eq.value[i]) }
+ Row(
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
- .fillMaxWidth(0.9f)
- .height(36.dp),
- colors = SliderDefaults.colors(
- thumbColor = thumbColor,
- activeTrackColor = activeTrackColor,
- inactiveTrackColor = trackColor
- ),
- thumb = {
- Box(
- modifier = Modifier
- .size(24.dp)
- .shadow(4.dp, CircleShape)
- .background(thumbColor, CircleShape)
- )
- },
- track = {
- Box (
- modifier = Modifier
- .fillMaxWidth()
- .height(12.dp),
- contentAlignment = Alignment.CenterStart
- )
- {
+ .fillMaxWidth()
+ .height(38.dp)
+ ) {
+ Text(
+ text = String.format("%.2f", eqValue.floatValue),
+ fontSize = 12.sp,
+ color = textColor,
+ modifier = Modifier.padding(bottom = 4.dp)
+ )
+
+ Slider(
+ value = eqValue.floatValue,
+ onValueChange = { newVal ->
+ eqValue.floatValue = newVal
+ val newEQ = eq.value.copyOf()
+ newEQ[i] = eqValue.floatValue
+ eq.value = newEQ
+ },
+ valueRange = 0f..100f,
+ modifier = Modifier
+ .fillMaxWidth(0.9f)
+ .height(36.dp),
+ colors = SliderDefaults.colors(
+ thumbColor = thumbColor,
+ activeTrackColor = activeTrackColor,
+ inactiveTrackColor = trackColor
+ ),
+ thumb = {
Box(
+ modifier = Modifier
+ .size(24.dp)
+ .shadow(4.dp, CircleShape)
+ .background(thumbColor, CircleShape)
+ )
+ },
+ track = {
+ Box (
modifier = Modifier
.fillMaxWidth()
- .height(4.dp)
- .background(trackColor, RoundedCornerShape(4.dp))
- )
- Box(
- modifier = Modifier
- .fillMaxWidth(eqValue.floatValue / 100f)
- .height(4.dp)
- .background(activeTrackColor, RoundedCornerShape(4.dp))
+ .height(12.dp),
+ contentAlignment = Alignment.CenterStart
)
+ {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(4.dp)
+ .background(trackColor, RoundedCornerShape(4.dp))
+ )
+ Box(
+ modifier = Modifier
+ .fillMaxWidth(eqValue.floatValue / 100f)
+ .height(4.dp)
+ .background(activeTrackColor, RoundedCornerShape(4.dp))
+ )
+ }
}
- }
- )
+ )
- Text(
- text = "Band ${i + 1}",
- fontSize = 12.sp,
- color = textColor,
- modifier = Modifier.padding(top = 4.dp)
- )
+ Text(
+ text = "Band ${i + 1}",
+ fontSize = 12.sp,
+ color = textColor,
+ modifier = Modifier.padding(top = 4.dp)
+ )
+ }
}
}
+
+ Spacer(modifier = Modifier.height(16.dp))
}
- Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Apply EQ to".uppercase(),
style = TextStyle(
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt
index 545f6fb..e4c1e65 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt
@@ -94,6 +94,8 @@ import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.CustomDevice
import me.kavishdevar.librepods.composables.AudioSettings
import me.kavishdevar.librepods.composables.BatteryView
+import me.kavishdevar.librepods.composables.CallControlSettings
+import me.kavishdevar.librepods.composables.ConnectionSettings
import me.kavishdevar.librepods.composables.IndependentToggle
import me.kavishdevar.librepods.composables.NameField
import me.kavishdevar.librepods.composables.NavigationButton
@@ -353,11 +355,35 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
)
}
- // Only show L2CAP-dependent features when not in BLE-only mode
if (!bleOnlyMode) {
Spacer(modifier = Modifier.height(32.dp))
NoiseControlSettings(service = service)
+ Spacer(modifier = Modifier.height(16.dp))
+ CallControlSettings()
+
+ // camera control goes here, airpods side is done, i just need to figure out how to listen to app open/close events
+
+ Spacer(modifier = Modifier.height(16.dp))
+ PressAndHoldSettings(navController = navController)
+
+ Spacer(modifier = Modifier.height(16.dp))
+ AudioSettings()
+
+ Spacer(modifier = Modifier.height(16.dp))
+ ConnectionSettings()
+
+ // microphone settings
+
+ Spacer(modifier = Modifier.height(16.dp))
+ IndependentToggle(
+ name = stringResource(R.string.sleep_detection),
+ service = service,
+ sharedPreferences = sharedPreferences,
+ default = false,
+ controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG
+ )
+
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.head_gestures).uppercase(),
@@ -369,43 +395,36 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
),
modifier = Modifier.padding(8.dp, bottom = 2.dp)
)
-
Spacer(modifier = Modifier.height(2.dp))
NavigationButton(to = "head_tracking", "Head Tracking", navController)
- Spacer(modifier = Modifier.height(16.dp))
- PressAndHoldSettings(navController = navController)
-
- Spacer(modifier = Modifier.height(16.dp))
- AudioSettings()
-
- Spacer(modifier = Modifier.height(16.dp))
- IndependentToggle(
- name = "Off Listening Mode",
- service = service,
- sharedPreferences = sharedPreferences,
- default = false,
- controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
- )
- }
-
- Spacer(modifier = Modifier.height(16.dp))
- IndependentToggle(
- name = "Automatic Ear Detection",
- service = service,
- functionName = "setEarDetection",
- sharedPreferences = sharedPreferences,
- default = true,
- )
-
- // Only show debug when not in BLE-only mode
- if (!bleOnlyMode) {
Spacer(modifier = Modifier.height(16.dp))
NavigationButton(to = "", "Accessibility", navController = navController, onClick = {
val intent = Intent(context, CustomDevice::class.java)
context.startActivity(intent)
})
+ Spacer(modifier = Modifier.height(16.dp))
+ IndependentToggle(
+ name = stringResource(R.string.off_listening_mode),
+ service = service,
+ sharedPreferences = sharedPreferences,
+ default = false,
+ controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
+ )
+ Text(
+ text = stringResource(R.string.off_listening_mode_description),
+ style = TextStyle(
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Light,
+ color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
+ fontFamily = FontFamily(Font(R.font.sf_pro))
+ ),
+ modifier = Modifier.padding(8.dp, top = 0.dp)
+ )
+
+ // an about card- everything but the version number is unknown - will add later if i find out
+
Spacer(modifier = Modifier.height(16.dp))
NavigationButton("debug", "Debug", navController)
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt
index 02e9993..6200348 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt
@@ -114,6 +114,10 @@ class AACPManager {
HEARING_ASSIST_CONFIG(0x33),
ALLOW_OFF_OPTION(0x34),
STEM_CONFIG(0x39),
+ SLEEP_DETECTION_CONFIG(0x35),
+ ALLOW_AUTO_CONNECT(0x36), // not sure what this does, AUTOMATIC_CONNECTION is the only one used, but this is newer... so ¯\_(ツ)_/¯
+ EAR_DETECTION_CONFIG(0x0A),
+ AUTOMATIC_CONNECTION_CONFIG(0x20),
OWNS_CONNECTION(0x06);
companion object {
@@ -594,6 +598,8 @@ class AACPManager {
fun createRequestNotificationPacket(): ByteArray {
val opcode = byteArrayOf(Opcodes.REQUEST_NOTIFICATIONS, 0x00)
val data = byteArrayOf(0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte(), 0xFF.toByte())
+ // note to self #1: third byte is 0xfd when ear detection is disabled
+ // note to self #2: this can be sent any time, not just at the start of the aacp connection
return opcode + data
}
diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml
index 8674767..8ebffb3 100644
--- a/android/app/src/main/res/values-zh-rCN/strings.xml
+++ b/android/app/src/main/res/values-zh-rCN/strings.xml
@@ -21,7 +21,6 @@
头部手势
左耳
右耳
- 根据环境调整媒体音量
对话感知
当你开始与他人交谈时,会降低媒体音量并减少背景噪音。
个性化音量
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 3d84861..fc1043d 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -21,7 +21,6 @@
Head Gestures
Left
Right
- Adjusts the volume of media in response to your environment
Conversational Awareness
Lowers media volume and reduces background noise when you start speaking to other people.
Personalized Volume
@@ -85,4 +84,10 @@
You can customize Transparency mode for your AirPods Pro to help you hear what\'s around you.
AirPods Pro automatically reduce your exposure to loud environmental noises when in Transparency and Adaptive mode.
Loud Sound Reduction
+ Call Controls
+ Connect to this device automatically
+ When enabled, AirPods will try to connect to this device automatically. Else, they will try to autoconnect only when last connected.
+ Pause media when falling asleep
+ Off Listening Mode
+ When this is on, AirPods listening modes will include an Off option. Loud sound levels are not reduced when the listening mode is set to Off.