android: add ui for hearing stuff

mostly copied from the transparency settings, which are now updated to match ios <26 ui
This commit is contained in:
Kavish Devar
2025-09-22 00:59:39 +05:30
parent 3ace0e1831
commit fe69082e11
8 changed files with 1535 additions and 163 deletions

View File

@@ -112,6 +112,8 @@ import me.kavishdevar.librepods.screens.AirPodsSettingsScreen
import me.kavishdevar.librepods.screens.AppSettingsScreen
import me.kavishdevar.librepods.screens.DebugScreen
import me.kavishdevar.librepods.screens.HeadTrackingScreen
import me.kavishdevar.librepods.screens.HearingAidScreen
import me.kavishdevar.librepods.screens.HearingAidAdjustmentsScreen
import me.kavishdevar.librepods.screens.LongPress
import me.kavishdevar.librepods.screens.Onboarding
import me.kavishdevar.librepods.screens.RenameScreen
@@ -380,6 +382,12 @@ fun Main() {
composable("onboarding") {
Onboarding(navController, context)
}
composable("hearing_aid") {
HearingAidScreen(navController)
}
composable("hearing_aid_adjustments") {
HearingAidAdjustmentsScreen(navController)
}
}
}

View File

@@ -49,10 +49,11 @@ import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AccessibilitySlider(
label: String,
label: String? = null,
value: Float,
onValueChange: (Float) -> Unit,
valueRange: ClosedFloatingPointRange<Float>
valueRange: ClosedFloatingPointRange<Float>,
widthFrac: Float = 1f
) {
val isDarkTheme = isSystemInDarkTheme()
@@ -62,18 +63,20 @@ fun AccessibilitySlider(
val labelTextColor = if (isDarkTheme) Color.White else Color.Black
Column(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth(widthFrac),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = label,
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = labelTextColor,
fontFamily = androidx.compose.ui.text.font.FontFamily(Font(R.font.sf_pro))
if (label != null) {
Text(
text = label,
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = labelTextColor,
fontFamily = androidx.compose.ui.text.font.FontFamily(Font(R.font.sf_pro))
)
)
)
}
Slider(
value = value,

View File

@@ -27,7 +27,10 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Arrangement
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
@@ -45,17 +48,22 @@ 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.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
import androidx.core.content.edit
import android.util.Log
@Composable
fun IndependentToggle(name: String, service: AirPodsService? = null, functionName: String? = null, sharedPreferences: SharedPreferences, default: Boolean = false, controlCommandIdentifier: AACPManager.Companion.ControlCommandIdentifiers? = null) {
fun IndependentToggle(name: String, service: AirPodsService? = null, functionName: String? = null, sharedPreferences: SharedPreferences, default: Boolean = false, controlCommandIdentifier: AACPManager.Companion.ControlCommandIdentifiers? = null, description: String? = null) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val snakeCasedName =
@@ -109,39 +117,57 @@ fun IndependentToggle(name: String, service: AirPodsService? = null, functionNam
}
}
}
Box (
Column (
modifier = Modifier
.padding(vertical = 8.dp)
.background(animatedBackgroundColor, RoundedCornerShape(14.dp))
.pointerInput(Unit) {
detectTapGestures(
onPress = {
backgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
},
onTap = {
checked = !checked
cb()
}
)
},
)
{
Row(
.padding(vertical = 8.dp),
) {
Box (
modifier = Modifier
.fillMaxWidth()
.height(55.dp)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(text = name, modifier = Modifier.weight(1f), fontSize = 16.sp, color = textColor)
StyledSwitch(
checked = checked,
onCheckedChange = {
checked = it
cb()
.background(animatedBackgroundColor, RoundedCornerShape(14.dp))
.pointerInput(Unit) {
detectTapGestures(
onPress = {
backgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
},
onTap = {
checked = !checked
cb()
}
)
},
)
{
Row(
modifier = Modifier
.fillMaxWidth()
.height(55.dp)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(text = name, modifier = Modifier.weight(1f), fontSize = 16.sp, color = textColor)
StyledSwitch(
checked = checked,
onCheckedChange = {
checked = it
cb()
},
)
}
}
if (description != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = 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(horizontal = 8.dp)
)
}
}

View File

@@ -111,9 +111,9 @@ import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.io.encoding.ExperimentalEncodingApi
var debounceJob: Job? = null
var phoneMediaDebounceJob: Job? = null
const val TAG = "AccessibilitySettings"
private var debounceJob: Job? = null
private var phoneMediaDebounceJob: Job? = null
private const val TAG = "AccessibilitySettings"
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@@ -150,7 +150,7 @@ fun AccessibilitySettingsScreen() {
CenterAlignedTopAppBar(
title = {
Text(
text = "Accessibility Settings",
text = stringResource(R.string.accessibility),
style = TextStyle(
fontSize = 20.sp,
fontWeight = FontWeight.Medium,
@@ -452,79 +452,232 @@ fun AccessibilitySettingsScreen() {
// Only show transparency mode section if SDP offset is available
if (isSdpOffsetAvailable.value) {
AccessibilityToggle(
text = "Transparency Mode",
text = stringResource(R.string.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)
independent = true,
description = stringResource(R.string.customize_transparency_mode_description)
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Customize Transparency Mode".uppercase(),
text = stringResource(R.string.amplification).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)
modifier = Modifier.padding(8.dp, bottom = 0.dp)
)
Column(
Box(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(14.dp))
.padding(8.dp)
.padding(horizontal = 8.dp, vertical = 0.dp)
.height(55.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
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxSize()
) {
Text(
text = "􀊥",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
color = labelTextColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(start = 4.dp)
)
AccessibilitySlider(
valueRange = -1f..1f,
value = amplificationSliderValue.floatValue,
onValueChange = {
amplificationSliderValue.floatValue = snapIfClose(it, listOf(-0.5f, -0.25f, 0f, 0.25f, 0.5f))
},
widthFrac = 0.90f,
)
Text(
text = "􀊩",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
color = labelTextColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(end = 4.dp)
)
}
}
Spacer(modifier = Modifier.height(2.dp))
Text(
text = stringResource(R.string.balance).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 = 0.dp)
)
Box(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(14.dp))
.padding(horizontal = 8.dp, vertical = 0.dp)
) {
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = stringResource(R.string.left),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
color = labelTextColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = stringResource(R.string.right),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
color = labelTextColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
AccessibilitySlider(
valueRange = -1f..1f,
value = balanceSliderValue.floatValue,
onValueChange = {
balanceSliderValue.floatValue = snapIfClose(it, listOf(0f))
},
)
}
}
Text(
text = stringResource(R.string.tone).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 = 0.dp)
)
Box(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(14.dp))
.padding(horizontal = 8.dp, vertical = 0.dp)
) {
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = stringResource(R.string.darker),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
color = labelTextColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = stringResource(R.string.brighter),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
color = labelTextColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
AccessibilitySlider(
valueRange = -1f..1f,
value = toneSliderValue.floatValue,
onValueChange = {
toneSliderValue.floatValue = snapIfClose(it, listOf(0f))
},
)
}
}
Text(
text = stringResource(R.string.ambient_noise_reduction).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 = 0.dp)
)
Box(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(14.dp))
.padding(horizontal = 8.dp, vertical = 0.dp)
) {
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = stringResource(R.string.less),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
color = labelTextColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = stringResource(R.string.more),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
color = labelTextColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
AccessibilitySlider(
valueRange = 0f..1f,
value = ambientNoiseReductionSliderValue.floatValue,
onValueChange = {
ambientNoiseReductionSliderValue.floatValue = snapIfClose(it, listOf(0.1f, 0.3f, 0.5f, 0.7f, 0.9f))
},
)
}
}
AccessibilityToggle(
text = stringResource(R.string.conversation_boost),
mutableState = conversationBoostEnabled,
independent = true,
description = stringResource(R.string.conversation_boost_description)
)
}
Text(
text = "AUDIO",
text = stringResource(R.string.audio).uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
@@ -542,7 +695,7 @@ fun AccessibilitySettingsScreen() {
verticalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Tone Volume",
text = stringResource(R.string.tone_volume),
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
@@ -559,8 +712,8 @@ fun AccessibilitySettingsScreen() {
LoudSoundReductionSwitch()
DropdownMenuComponent(
label = "Press Speed",
options = pressSpeedOptions.values.toList(),
label = stringResource(R.string.press_speed),
options = listOf(stringResource(R.string.default_option), stringResource(R.string.slower), stringResource(R.string.slowest)),
selectedOption = selectedPressSpeed.toString(),
onOptionSelected = { newValue ->
selectedPressSpeed = newValue
@@ -572,8 +725,8 @@ fun AccessibilitySettingsScreen() {
textColor = textColor
)
DropdownMenuComponent(
label = "Press and Hold Duration",
options = pressAndHoldDurationOptions.values.toList(),
label = stringResource(R.string.press_and_hold_duration),
options = listOf(stringResource(R.string.default_option), stringResource(R.string.slower), stringResource(R.string.slowest)),
selectedOption = selectedPressAndHoldDuration.toString(),
onOptionSelected = { newValue ->
selectedPressAndHoldDuration = newValue
@@ -585,8 +738,8 @@ fun AccessibilitySettingsScreen() {
textColor = textColor
)
DropdownMenuComponent(
label = "Volume Swipe Speed",
options = volumeSwipeSpeedOptions.values.toList(),
label = stringResource(R.string.volume_swipe_speed),
options = listOf(stringResource(R.string.default_option), stringResource(R.string.longer), stringResource(R.string.longest)),
selectedOption = selectedVolumeSwipeSpeed.toString(),
onOptionSelected = { newValue ->
selectedVolumeSwipeSpeed = newValue
@@ -603,7 +756,7 @@ fun AccessibilitySettingsScreen() {
// Only show transparency mode EQ section if SDP offset is available
if (isSdpOffsetAvailable.value) {
Text(
text = "Equalizer".uppercase(),
text = stringResource(R.string.equalizer).uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
@@ -687,7 +840,7 @@ fun AccessibilitySettingsScreen() {
)
Text(
text = "Band ${i + 1}",
text = stringResource(R.string.band_label, i + 1),
fontSize = 12.sp,
color = textColor,
modifier = Modifier.padding(top = 4.dp)
@@ -700,7 +853,7 @@ fun AccessibilitySettingsScreen() {
}
Text(
text = "Apply EQ to".uppercase(),
text = stringResource(R.string.apply_eq_to).uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
@@ -740,7 +893,7 @@ fun AccessibilitySettingsScreen() {
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Phone",
stringResource(R.string.phone),
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro)),
@@ -791,7 +944,7 @@ fun AccessibilitySettingsScreen() {
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Media",
stringResource(R.string.media),
fontSize = 16.sp,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro)),
@@ -888,7 +1041,7 @@ fun AccessibilitySettingsScreen() {
)
Text(
text = "Band ${i + 1}",
text = stringResource(R.string.band_label, i + 1),
fontSize = 12.sp,
color = textColor,
modifier = Modifier.padding(top = 4.dp)
@@ -902,57 +1055,75 @@ fun AccessibilitySettingsScreen() {
@Composable
fun AccessibilityToggle(text: String, mutableState: MutableState<Boolean>, independent: Boolean = false) {
fun AccessibilityToggle(text: String, mutableState: MutableState<Boolean>, independent: Boolean = false, description: String? = null) {
val isDarkTheme = isSystemInDarkTheme()
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
val textColor = if (isDarkTheme) Color.White else Color.Black
val boxPaddings = if (independent) 2.dp else 4.dp
val cornerShape = if (independent) RoundedCornerShape(14.dp) else RoundedCornerShape(0.dp)
Box (
Column(
modifier = Modifier
.padding(vertical = boxPaddings)
.background(animatedBackgroundColor, cornerShape)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
backgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
},
onTap = {
mutableState.value = !mutableState.value
}
)
},
)
{
val rowHeight = if (independent) 55.dp else 50.dp
val rowPadding = if (independent) 12.dp else 4.dp
Row(
.padding(vertical = 8.dp)
) {
Box (
modifier = Modifier
.fillMaxWidth()
.height(rowHeight)
.padding(horizontal = rowPadding),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = text,
modifier = Modifier.weight(1f),
fontSize = 16.sp,
color = textColor
)
StyledSwitch(
checked = mutableState.value,
onCheckedChange = {
mutableState.value = it
.background(animatedBackgroundColor, cornerShape)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
backgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
},
onTap = {
mutableState.value = !mutableState.value
}
)
},
)
{
val rowHeight = if (independent) 55.dp else 50.dp
val rowPadding = if (independent) 12.dp else 4.dp
Row(
modifier = Modifier
.fillMaxWidth()
.height(rowHeight)
.padding(horizontal = rowPadding),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = text,
modifier = Modifier.weight(1f),
fontSize = 16.sp,
color = textColor
)
StyledSwitch(
checked = mutableState.value,
onCheckedChange = {
mutableState.value = it
},
)
}
}
if (description != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = 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(horizontal = 8.dp)
)
}
}
}
data class TransparencySettings (
private data class TransparencySettings (
val enabled: Boolean,
val leftEQ: FloatArray,
val rightEQ: FloatArray,
@@ -1134,7 +1305,7 @@ private fun snapIfClose(value: Float, points: List<Float>, threshold: Float = 0.
}
@Composable
fun DropdownMenuComponent(
private fun DropdownMenuComponent(
label: String,
options: List<String>,
selectedOption: String,

View File

@@ -358,6 +358,9 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
if (!bleOnlyMode) {
Spacer(modifier = Modifier.height(32.dp))
NavigationButton(to = "hearing_aid", stringResource(R.string.hearing_aid), navController)
Spacer(modifier = Modifier.height(16.dp))
NoiseControlSettings(service = service)
Spacer(modifier = Modifier.height(16.dp))
@@ -401,17 +404,8 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
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)
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION,
description = stringResource(R.string.off_listening_mode_description)
)
// an about card- everything but the version number is unknown - will add later if i find out

View File

@@ -0,0 +1,799 @@
/*
* 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 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 <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.screens
import android.annotation.SuppressLint
import android.content.Context
import android.util.Log
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
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.mutableFloatStateOf
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.drawBehind
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow
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
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import dev.chrisbanes.haze.HazeEffectScope
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.AccessibilitySlider
import me.kavishdevar.librepods.composables.IndependentToggle
import me.kavishdevar.librepods.composables.LoudSoundReductionSwitch
import me.kavishdevar.librepods.composables.SinglePodANCSwitch
import me.kavishdevar.librepods.composables.StyledSwitch
import me.kavishdevar.librepods.composables.ToneVolumeSlider
import me.kavishdevar.librepods.composables.VolumeControlSwitch
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.ATTManager
import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.RadareOffsetFinder
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: Job? = null
private var phoneMediaDebounceJob: Job? = null
private const val TAG = "AccessibilitySettings"
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
fun HearingAidAdjustmentsScreen(navController: NavController) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val verticalScrollState = rememberScrollState()
val hazeState = remember { HazeState() }
val snackbarHostState = remember { SnackbarHostState() }
val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available")
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
val context = LocalContext.current
val radareOffsetFinder = remember { RadareOffsetFinder(context) }
val isSdpOffsetAvailable = remember { mutableStateOf(RadareOffsetFinder.isSdpOffsetAvailable()) }
val service = ServiceManager.getService()
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
val labelTextColor = if (isDarkTheme) Color.White else Color.Black
Scaffold(
containerColor = if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7),
topBar = {
val darkMode = isSystemInDarkTheme()
val mDensity = remember { mutableFloatStateOf(1f) }
CenterAlignedTopAppBar(
title = {
Text(
text = stringResource(R.string.adjustments),
style = TextStyle(
fontSize = 20.sp,
fontWeight = FontWeight.Medium,
color = if (darkMode) Color.White else Color.Black,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
},
modifier = Modifier
.hazeEffect(
state = hazeState,
style = CupertinoMaterials.thick(),
block = fun HazeEffectScope.() {
alpha = if (verticalScrollState.value > 60.dp.value * mDensity.floatValue) 1f else 0f
})
.drawBehind {
mDensity.floatValue = density
val strokeWidth = 0.7.dp.value * density
val y = size.height - strokeWidth / 2
if (verticalScrollState.value > 60.dp.value * density) {
drawLine(
if (darkMode) Color.DarkGray else Color.LightGray,
Offset(0f, y),
Offset(size.width, y),
strokeWidth
)
}
},
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(containerColor = Color.Transparent)
)
},
snackbarHost = { SnackbarHost(snackbarHostState) }
) { paddingValues ->
Column(
modifier = Modifier
.hazeSource(hazeState)
.fillMaxSize()
.padding(paddingValues)
.padding(horizontal = 16.dp)
.verticalScroll(verticalScrollState),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val enabled = remember { mutableStateOf(false) }
val amplificationSliderValue = remember { mutableFloatStateOf(0.5f) }
val balanceSliderValue = remember { mutableFloatStateOf(0.5f) }
val toneSliderValue = remember { mutableFloatStateOf(0.5f) }
val ambientNoiseReductionSliderValue = remember { mutableFloatStateOf(0.0f) }
val conversationBoostEnabled = remember { mutableStateOf(false) }
val eq = remember { mutableStateOf(FloatArray(8)) }
val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
val phoneEQEnabled = remember { mutableStateOf(false) }
val mediaEQEnabled = remember { mutableStateOf(false) }
val initialLoadComplete = remember { mutableStateOf(false) }
val initialReadSucceeded = remember { mutableStateOf(false) }
val initialReadAttempts = remember { mutableStateOf(0) }
val HearingAidSettings = remember {
mutableStateOf(
HearingAidSettings(
enabled = enabled.value,
leftEQ = eq.value,
rightEQ = eq.value,
leftAmplification = amplificationSliderValue.floatValue + (0.5f - balanceSliderValue.floatValue) * amplificationSliderValue.floatValue * 2,
rightAmplification = amplificationSliderValue.floatValue + (balanceSliderValue.floatValue - 0.5f) * amplificationSliderValue.floatValue * 2,
leftTone = toneSliderValue.floatValue,
rightTone = toneSliderValue.floatValue,
leftConversationBoost = conversationBoostEnabled.value,
rightConversationBoost = conversationBoostEnabled.value,
leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
netAmplification = amplificationSliderValue.floatValue,
balance = balanceSliderValue.floatValue
)
)
}
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())
}
}
}
}
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(enabled.value, amplificationSliderValue.floatValue, balanceSliderValue.floatValue, toneSliderValue.floatValue, conversationBoostEnabled.value, ambientNoiseReductionSliderValue.floatValue, eq.value, initialLoadComplete.value, initialReadSucceeded.value) {
if (!initialLoadComplete.value) {
Log.d(TAG, "Initial device load not complete - skipping send")
return@LaunchedEffect
}
if (!initialReadSucceeded.value) {
Log.d(TAG, "Initial device read not successful yet - skipping send until read succeeds")
return@LaunchedEffect
}
HearingAidSettings.value = HearingAidSettings(
enabled = enabled.value,
leftEQ = eq.value,
rightEQ = eq.value,
leftAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue < 0) -balanceSliderValue.floatValue else 0f,
rightAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue > 0) balanceSliderValue.floatValue else 0f,
leftTone = toneSliderValue.floatValue,
rightTone = toneSliderValue.floatValue,
leftConversationBoost = conversationBoostEnabled.value,
rightConversationBoost = conversationBoostEnabled.value,
leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
netAmplification = amplificationSliderValue.floatValue,
balance = balanceSliderValue.floatValue
)
Log.d("HearingAidSettings", "Updated settings: ${HearingAidSettings.value}")
// sendHearingAidSettings(attManager, HearingAidSettings.value)
}
DisposableEffect(Unit) {
onDispose {
// attManager.unregisterListener(ATTHandles.TRANSPARENCY, transparencyListener)
}
}
LaunchedEffect(Unit) {
Log.d(TAG, "Connecting to ATT...")
try {
// attManager.enableNotifications(ATTHandles.TRANSPARENCY)
// attManager.registerListener(ATTHandles.TRANSPARENCY, transparencyListener)
try {
if (aacpManager != null) {
Log.d(TAG, "Found AACPManager, reading cached EQ data")
val aacpEQ = aacpManager.eqData
if (aacpEQ.isNotEmpty()) {
eq.value = aacpEQ.copyOf()
phoneMediaEQ.value = aacpEQ.copyOf()
phoneEQEnabled.value = aacpManager.eqOnPhone
mediaEQEnabled.value = aacpManager.eqOnMedia
Log.d(TAG, "Populated EQ from AACPManager: ${aacpEQ.toList()}")
} else {
Log.d(TAG, "AACPManager EQ data empty")
}
} else {
Log.d(TAG, "No AACPManager available")
}
} catch (e: Exception) {
Log.w(TAG, "Error reading EQ from AACPManager: ${e.message}")
}
/*
var parsedSettings: HearingAidSettings? = null
for (attempt in 1..3) {
initialReadAttempts.value = attempt
try {
val data = attManager.read(ATTHandles.TRANSPARENCY)
parsedSettings = parseHearingAidSettingsResponse(data = data)
if (parsedSettings != null) {
Log.d(TAG, "Parsed settings on attempt $attempt")
break
} else {
Log.d(TAG, "Parsing returned null on attempt $attempt")
}
} catch (e: Exception) {
Log.w(TAG, "Read attempt $attempt failed: ${e.message}")
}
delay(200)
}
if (parsedSettings != null) {
Log.d(TAG, "Initial transparency settings: $parsedSettings")
enabled.value = parsedSettings.enabled
amplificationSliderValue.floatValue = parsedSettings.netAmplification
balanceSliderValue.floatValue = parsedSettings.balance
toneSliderValue.floatValue = parsedSettings.leftTone
ambientNoiseReductionSliderValue.floatValue = parsedSettings.leftAmbientNoiseReduction
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
eq.value = parsedSettings.leftEQ.copyOf()
initialReadSucceeded.value = true
} else {
Log.d(TAG, "Failed to read/parse initial transparency settings after ${initialReadAttempts.value} attempts")
}
*/
} catch (e: IOException) {
e.printStackTrace()
} finally {
initialLoadComplete.value = true
}
}
LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) {
phoneMediaDebounceJob?.cancel()
phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch {
delay(150)
val manager = ServiceManager.getService()?.aacpManager
if (manager == null) {
Log.w(TAG, "Cannot write EQ: AACPManager not available")
return@launch
}
try {
val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte()
val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte()
Log.d(TAG, "Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})")
manager.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte)
} catch (e: Exception) {
Log.w(TAG, "Error sending phone/media EQ: ${e.message}")
}
}
}
val isDarkThemeLocal = isSystemInDarkTheme()
var backgroundColorHA by remember { mutableStateOf(if (isDarkThemeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColorHA by animateColorAsState(targetValue = backgroundColorHA, animationSpec = tween(durationMillis = 500))
Text(
text = stringResource(R.string.amplification).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 = 0.dp)
)
Box(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(14.dp))
.padding(horizontal = 8.dp, vertical = 0.dp)
.height(55.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxSize()
) {
Text(
text = "􀊥",
style = TextStyle(
fontSize = 18.sp,
fontWeight = FontWeight.Normal,
color = labelTextColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(start = 4.dp)
)
AccessibilitySlider(
valueRange = -1f..1f,
value = amplificationSliderValue.floatValue,
onValueChange = {
amplificationSliderValue.floatValue = snapIfClose(it, listOf(-0.5f, -0.25f, 0f, 0.25f, 0.5f))
},
widthFrac = 0.90f
)
Text(
text = "􀊩",
style = TextStyle(
fontSize = 18.sp,
fontWeight = FontWeight.Normal,
color = labelTextColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(end = 4.dp)
)
}
}
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
IndependentToggle(
name = stringResource(R.string.swipe_to_control_amplification),
service = service,
sharedPreferences = sharedPreferences,
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE,
description = stringResource(R.string.swipe_amplification_description)
)
Text(
text = stringResource(R.string.balance).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 = 0.dp)
)
Box(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(14.dp))
.padding(horizontal = 8.dp, vertical = 0.dp)
) {
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = stringResource(R.string.left),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
color = labelTextColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = stringResource(R.string.right),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
color = labelTextColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
AccessibilitySlider(
valueRange = -1f..1f,
value = balanceSliderValue.floatValue,
onValueChange = {
balanceSliderValue.floatValue = snapIfClose(it, listOf(0f))
},
)
}
}
Text(
text = stringResource(R.string.tone).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 = 0.dp)
)
Box(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(14.dp))
.padding(horizontal = 8.dp, vertical = 0.dp)
) {
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = stringResource(R.string.darker),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
color = labelTextColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = stringResource(R.string.brighter),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
color = labelTextColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
AccessibilitySlider(
valueRange = -1f..1f,
value = toneSliderValue.floatValue,
onValueChange = {
toneSliderValue.floatValue = snapIfClose(it, listOf(0f))
},
)
}
}
Text(
text = stringResource(R.string.ambient_noise_reduction).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 = 0.dp)
)
Box(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(14.dp))
.padding(horizontal = 8.dp, vertical = 0.dp)
) {
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = stringResource(R.string.less),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
color = labelTextColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
Text(
text = stringResource(R.string.more),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
color = labelTextColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
AccessibilitySlider(
valueRange = 0f..1f,
value = ambientNoiseReductionSliderValue.floatValue,
onValueChange = {
ambientNoiseReductionSliderValue.floatValue = snapIfClose(it, listOf(0.1f, 0.3f, 0.5f, 0.7f, 0.9f))
},
)
}
}
AccessibilityToggle(
text = stringResource(R.string.conversation_boost),
mutableState = conversationBoostEnabled,
independent = true,
description = stringResource(R.string.conversation_boost_description)
)
}
}
}
private data class HearingAidSettings(
val enabled: Boolean,
val leftEQ: FloatArray,
val rightEQ: FloatArray,
val leftAmplification: Float,
val rightAmplification: Float,
val leftTone: Float,
val rightTone: Float,
val leftConversationBoost: Boolean,
val rightConversationBoost: Boolean,
val leftAmbientNoiseReduction: Float,
val rightAmbientNoiseReduction: Float,
val netAmplification: Float,
val balance: Float
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as HearingAidSettings
if (enabled != other.enabled) return false
if (leftAmplification != other.leftAmplification) return false
if (rightAmplification != other.rightAmplification) return false
if (leftTone != other.leftTone) return false
if (rightTone != other.rightTone) return false
if (leftConversationBoost != other.leftConversationBoost) return false
if (rightConversationBoost != other.rightConversationBoost) return false
if (leftAmbientNoiseReduction != other.leftAmbientNoiseReduction) return false
if (rightAmbientNoiseReduction != other.rightAmbientNoiseReduction) return false
if (!leftEQ.contentEquals(other.leftEQ)) return false
if (!rightEQ.contentEquals(other.rightEQ)) return false
return true
}
override fun hashCode(): Int {
var result = enabled.hashCode()
result = 31 * result + leftAmplification.hashCode()
result = 31 * result + rightAmplification.hashCode()
result = 31 * result + leftTone.hashCode()
result = 31 * result + rightTone.hashCode()
result = 31 * result + leftConversationBoost.hashCode()
result = 31 * result + rightConversationBoost.hashCode()
result = 31 * result + leftAmbientNoiseReduction.hashCode()
result = 31 * result + rightAmbientNoiseReduction.hashCode()
result = 31 * result + leftEQ.contentHashCode()
result = 31 * result + rightEQ.contentHashCode()
return result
}
}
private fun parseHearingAidSettingsResponse(data: ByteArray): HearingAidSettings? {
val settingsData = data.copyOfRange(1, data.size)
val buffer = ByteBuffer.wrap(settingsData).order(ByteOrder.LITTLE_ENDIAN)
val enabled = buffer.float
Log.d(TAG, "Parsed enabled: $enabled")
val leftEQ = FloatArray(8)
for (i in 0..7) {
leftEQ[i] = buffer.float
Log.d(TAG, "Parsed left EQ${i+1}: ${leftEQ[i]}")
}
val leftAmplification = buffer.float
Log.d(TAG, "Parsed left amplification: $leftAmplification")
val leftTone = buffer.float
Log.d(TAG, "Parsed left tone: $leftTone")
val leftConvFloat = buffer.float
val leftConversationBoost = leftConvFloat > 0.5f
Log.d(TAG, "Parsed left conversation boost: $leftConvFloat ($leftConversationBoost)")
val leftAmbientNoiseReduction = buffer.float
Log.d(TAG, "Parsed left ambient noise reduction: $leftAmbientNoiseReduction")
val rightEQ = FloatArray(8)
for (i in 0..7) {
rightEQ[i] = buffer.float
Log.d(TAG, "Parsed right EQ${i+1}: $rightEQ[i]")
}
val rightAmplification = buffer.float
Log.d(TAG, "Parsed right amplification: $rightAmplification")
val rightTone = buffer.float
Log.d(TAG, "Parsed right tone: $rightTone")
val rightConvFloat = buffer.float
val rightConversationBoost = rightConvFloat > 0.5f
Log.d(TAG, "Parsed right conversation boost: $rightConvFloat ($rightConversationBoost)")
val rightAmbientNoiseReduction = buffer.float
Log.d(TAG, "Parsed right ambient noise reduction: $rightAmbientNoiseReduction")
Log.d(TAG, "Settings parsed successfully")
val avg = (leftAmplification + rightAmplification) / 2
val amplification = avg.coerceIn(-1f, 1f)
val diff = rightAmplification - leftAmplification
val balance = diff.coerceIn(-1f, 1f)
return HearingAidSettings(
enabled = enabled > 0.5f,
leftEQ = leftEQ,
rightEQ = rightEQ,
leftAmplification = leftAmplification,
rightAmplification = rightAmplification,
leftTone = leftTone,
rightTone = rightTone,
leftConversationBoost = leftConversationBoost,
rightConversationBoost = rightConversationBoost,
leftAmbientNoiseReduction = leftAmbientNoiseReduction,
rightAmbientNoiseReduction = rightAmbientNoiseReduction,
netAmplification = amplification,
balance = balance
)
}
private fun sendHearingAidSettings(
attManager: ATTManager,
HearingAidSettings: HearingAidSettings
) {
debounceJob?.cancel()
debounceJob = CoroutineScope(Dispatchers.IO).launch {
delay(100)
try {
val buffer = ByteBuffer.allocate(100).order(ByteOrder.LITTLE_ENDIAN)
Log.d(TAG,
"Sending settings: $HearingAidSettings"
)
buffer.putFloat(if (HearingAidSettings.enabled) 1.0f else 0.0f)
for (eq in HearingAidSettings.leftEQ) {
buffer.putFloat(eq)
}
buffer.putFloat(HearingAidSettings.leftAmplification)
buffer.putFloat(HearingAidSettings.leftTone)
buffer.putFloat(if (HearingAidSettings.leftConversationBoost) 1.0f else 0.0f)
buffer.putFloat(HearingAidSettings.leftAmbientNoiseReduction)
for (eq in HearingAidSettings.rightEQ) {
buffer.putFloat(eq)
}
buffer.putFloat(HearingAidSettings.rightAmplification)
buffer.putFloat(HearingAidSettings.rightTone)
buffer.putFloat(if (HearingAidSettings.rightConversationBoost) 1.0f else 0.0f)
buffer.putFloat(HearingAidSettings.rightAmbientNoiseReduction)
val data = buffer.array()
attManager.write(
ATTHandles.TRANSPARENCY,
value = data
)
} catch (e: IOException) {
e.printStackTrace()
}
}
}
private fun sendPhoneMediaEQ(aacpManager: me.kavishdevar.librepods.utils.AACPManager?, eq: FloatArray, phoneEnabled: Boolean, mediaEnabled: Boolean) {
phoneMediaDebounceJob?.cancel()
phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch {
delay(100)
try {
if (aacpManager == null) {
Log.w(TAG, "AACPManger is null; cannot send phone/media EQ")
return@launch
}
val phoneByte = if (phoneEnabled) 0x01.toByte() else 0x02.toByte()
val mediaByte = if (mediaEnabled) 0x01.toByte() else 0x02.toByte()
aacpManager.sendPhoneMediaEQ(eq, phoneByte, mediaByte)
} catch (e: Exception) {
Log.w(TAG, "Error in sendPhoneMediaEQ: ${e.message}")
}
}
}
private fun snapIfClose(value: Float, points: List<Float>, threshold: Float = 0.05f): Float {
val nearest = points.minByOrNull { kotlin.math.abs(it - value) } ?: value
return if (kotlin.math.abs(nearest - value) <= threshold) nearest else value
}

View File

@@ -0,0 +1,341 @@
/*
* 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 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 <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.screens
import android.annotation.SuppressLint
import android.content.Context
import android.util.Log
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
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.mutableFloatStateOf
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.drawBehind
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow
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
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import dev.chrisbanes.haze.HazeEffectScope
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.AccessibilitySlider
import me.kavishdevar.librepods.composables.LoudSoundReductionSwitch
import me.kavishdevar.librepods.composables.SinglePodANCSwitch
import me.kavishdevar.librepods.composables.StyledSwitch
import me.kavishdevar.librepods.composables.ToneVolumeSlider
import me.kavishdevar.librepods.composables.VolumeControlSwitch
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.ATTManager
import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.RadareOffsetFinder
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: Job? = null
private var phoneMediaDebounceJob: Job? = null
private const val TAG = "AccessibilitySettings"
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
fun HearingAidScreen(navController: NavController) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val verticalScrollState = rememberScrollState()
val hazeState = remember { HazeState() }
val snackbarHostState = remember { SnackbarHostState() }
val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available")
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
val context = LocalContext.current
val radareOffsetFinder = remember { RadareOffsetFinder(context) }
val isSdpOffsetAvailable = remember { mutableStateOf(RadareOffsetFinder.isSdpOffsetAvailable()) }
val service = ServiceManager.getService()
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
val labelTextColor = if (isDarkTheme) Color.White else Color.Black
Scaffold(
containerColor = if (isSystemInDarkTheme()) Color(
0xFF000000
) else Color(
0xFFF2F2F7
),
topBar = {
val darkMode = isSystemInDarkTheme()
val mDensity = remember { mutableFloatStateOf(1f) }
CenterAlignedTopAppBar(
title = {
Text(
text = stringResource(R.string.hearing_aid),
style = TextStyle(
fontSize = 20.sp,
fontWeight = FontWeight.Medium,
color = if (darkMode) Color.White else Color.Black,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
},
modifier = Modifier
.hazeEffect(
state = hazeState,
style = CupertinoMaterials.thick(),
block = fun HazeEffectScope.() {
alpha =
if (verticalScrollState.value > 60.dp.value * mDensity.floatValue) 1f else 0f
})
.drawBehind {
mDensity.floatValue = density
val strokeWidth = 0.7.dp.value * density
val y = size.height - strokeWidth / 2
if (verticalScrollState.value > 60.dp.value * density) {
drawLine(
if (darkMode) Color.DarkGray else Color.LightGray,
Offset(0f, y),
Offset(size.width, y),
strokeWidth
)
}
},
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent
)
)
},
snackbarHost = { SnackbarHost(snackbarHostState) }
) { paddingValues ->
Column(
modifier = Modifier
.hazeSource(hazeState)
.fillMaxSize()
.padding(paddingValues)
.padding(horizontal = 16.dp)
.verticalScroll(verticalScrollState),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
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())
}
}
}
}
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)
}
}
fun onChange(value: Boolean) {
if (value) {
// Enable and enroll if not enrolled
val enrolled = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }?.value?.getOrNull(0) == 0x01.toByte()
if (!enrolled) {
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x01)) // Enroll and enable
} else {
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x01)) // Enable
}
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value, 0x01.toByte()) // Enable assist
} else {
// Disable both, keep enrolled
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x02)) // Disable
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value, 0x02.toByte()) // Disable assist
}
hearingAidEnabled.value = value
}
Text(
text = stringResource(R.string.hearing_aid).uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f)
),
modifier = Modifier.padding(8.dp, bottom = 2.dp)
)
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(14.dp))
) {
val isDarkThemeLocal = isSystemInDarkTheme()
var backgroundColorHA by remember { mutableStateOf(if (isDarkThemeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColorHA by animateColorAsState(targetValue = backgroundColorHA, animationSpec = tween(durationMillis = 500))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
backgroundColorHA = if (isDarkThemeLocal) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColorHA = if (isDarkThemeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
},
onTap = {
onChange(value = !hearingAidEnabled.value)
}
)
},
verticalAlignment = Alignment.CenterVertically
) {
Text(text = stringResource(R.string.hearing_aid), modifier = Modifier.weight(1f), fontSize = 16.sp, color = textColor)
StyledSwitch(
checked = hearingAidEnabled.value,
onCheckedChange = {
onChange(value = it)
},
)
}
HorizontalDivider(
thickness = 1.5.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(start = 12.dp, end = 0.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { navController.navigate("hearing_aid_adjustments") }
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.adjustments),
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.weight(1f))
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = textColor
)
}
}
Text(
text = stringResource(R.string.hearing_aid_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(horizontal = 8.dp)
)
}
}
}

View File

@@ -100,4 +100,34 @@
<string name="hang_up">Hang Up</string>
<string name="press_once">Press Once</string>
<string name="press_twice">Press Twice</string>
<string name="hearing_aid">Hearing Aid</string>
<string name="adjustments">Adjustments</string>
<string name="swipe_to_control_amplification">Swipe to control amplification</string>
<string name="swipe_amplification_description">When in Transparency and no media is playing, swipe up and down on the Touch controls of your AirPods Pro to increase or decrease the amplification of environmental sounds.</string>
<string name="transparency_mode">Transparency Mode</string>
<string name="customize_transparency_mode">Customize Transparency Mode</string>
<string name="press_speed">Press Speed</string>
<string name="press_and_hold_duration">Press and Hold Duration</string>
<string name="volume_swipe_speed">Volume Swipe Speed</string>
<string name="equalizer">Equalizer</string>
<string name="apply_eq_to">Apply EQ to</string>
<string name="phone">Phone</string>
<string name="media">Media</string>
<string name="band_label">Band %d</string>
<string name="default_option">Default</string>
<string name="slower">Slower</string>
<string name="slowest">Slowest</string>
<string name="longer">Longer</string>
<string name="longest">Longest</string>
<string name="darker">Darker</string>
<string name="brighter">Brighter</string>
<string name="less">Less</string>
<string name="more">More</string>
<string name="amplification">Amplification</string>
<string name="balance">Balance</string>
<string name="tone">Tone</string>
<string name="ambient_noise_reduction">Ambient Noise Reduction</string>
<string name="conversation_boost">Conversation Boost</string>
<string name="conversation_boost_description">Conversation Boost focuses your AirPods Pro on the person talking in front of you, making it easier to hear in a face-to-face conversation.</string>
<string name="hearing_aid_description">AirPods can use the results of a hearing test to make adjustments that improve the clarity of voices and sounds around you. \n\n Hearing Aid is only intended for people with perceived mild to moderate hearing loss.</string>
</resources>