android: bring back some accessiblity settings and add listeners for all config

This commit is contained in:
Kavish Devar
2025-09-19 13:10:59 +05:30
parent 93328d281e
commit 65d074efe0
12 changed files with 476 additions and 253 deletions

View File

@@ -1,218 +0,0 @@
/*
* LibrePods - AirPods liberated from Apples ecosystem
*
* Copyright (C) 2025 LibrePods contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU 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/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
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.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
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.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 AccessibilitySettings() {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val service = ServiceManager.getService()!!
Text(
text = stringResource(R.string.accessibility).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)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp)
) {
Text(
text = stringResource(R.string.tone_volume),
modifier = Modifier
.padding(end = 8.dp, bottom = 2.dp, start = 2.dp)
.fillMaxWidth(),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = textColor
)
)
ToneVolumeSlider()
}
val pressSpeedOptions = mapOf(
0.toByte() to "Default",
1.toByte() to "Slower",
2.toByte() to "Slowest"
)
val selectedPressSpeedValue = service.aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL }?.value?.takeIf { it.isNotEmpty() }?.get(0)
var selectedPressSpeed by remember { mutableStateOf(pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0]) }
DropdownMenuComponent(
label = "Press Speed",
options = pressSpeedOptions.values.toList(),
selectedOption = selectedPressSpeed.toString(),
onOptionSelected = { newValue ->
selectedPressSpeed = newValue
service.aacpManager.sendControlCommand(
identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL.value,
value = pressSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() ?: 0.toByte()
)
},
textColor = textColor
)
val pressAndHoldDurationOptions = mapOf(
0.toByte() to "Default",
1.toByte() to "Slower",
2.toByte() to "Slowest"
)
val selectedPressAndHoldDurationValue = service.aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL }?.value?.takeIf { it.isNotEmpty() }?.get(0)
var selectedPressAndHoldDuration by remember { mutableStateOf(pressAndHoldDurationOptions[selectedPressAndHoldDurationValue] ?: pressAndHoldDurationOptions[0]) }
DropdownMenuComponent(
label = "Press and Hold Duration",
options = pressAndHoldDurationOptions.values.toList(),
selectedOption = selectedPressAndHoldDuration.toString(),
onOptionSelected = { newValue ->
selectedPressAndHoldDuration = newValue
service.aacpManager.sendControlCommand(
identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL.value,
value = pressAndHoldDurationOptions.filterValues { it == newValue }.keys.firstOrNull() ?: 0.toByte()
)
},
textColor = textColor
)
val volumeSwipeSpeedOptions = mapOf(
1.toByte() to "Default",
2.toByte() to "Longer",
3.toByte() to "Longest"
)
val selectedVolumeSwipeSpeedValue = service.aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL }?.value?.takeIf { it.isNotEmpty() }?.get(0)
var selectedVolumeSwipeSpeed by remember { mutableStateOf(volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue] ?: volumeSwipeSpeedOptions[1]) }
DropdownMenuComponent(
label = "Volume Swipe Speed",
options = volumeSwipeSpeedOptions.values.toList(),
selectedOption = selectedVolumeSwipeSpeed.toString(),
onOptionSelected = { newValue ->
selectedVolumeSwipeSpeed = newValue
service.aacpManager.sendControlCommand(
identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL.value,
value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() ?: 1.toByte()
)
},
textColor = textColor
)
}
}
@Composable
fun DropdownMenuComponent(
label: String,
options: List<String>,
selectedOption: String,
onOptionSelected: (String) -> Unit,
textColor: Color
) {
var expanded by remember { mutableStateOf(false) }
Column (
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
) {
Text(
text = label,
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = textColor
)
)
Box(
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = true }
.padding(8.dp)
) {
Text(
text = selectedOption,
modifier = Modifier.padding(16.dp),
color = textColor
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
options.forEach { option ->
DropdownMenuItem(
onClick = {
onOptionSelected(option)
expanded = false
},
text = { Text(text = option) }
)
}
}
}
}
@Preview
@Composable
fun AccessibilitySettingsPreview() {
AccessibilitySettings()
}

View File

@@ -38,6 +38,7 @@ import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@@ -66,6 +67,31 @@ fun AdaptiveStrengthSlider() {
sliderValueFromAACP?.toFloat()?.let { sliderValue.floatValue = (100 - it) }
}
val listener = remember {
object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH.value) {
controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)?.toFloat()?.let {
sliderValue.floatValue = (100 - it)
}
}
}
}
}
DisposableEffect(Unit) {
service.aacpManager.registerControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH,
listener
)
onDispose {
service.aacpManager.unregisterControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH,
listener
)
}
}
val isDarkTheme = isSystemInDarkTheme()
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFFD9D9D9)
@@ -81,11 +107,11 @@ fun AdaptiveStrengthSlider() {
Slider(
value = sliderValue.floatValue,
onValueChange = {
sliderValue.floatValue = it
sliderValue.floatValue = snapIfClose(it, listOf(0f, 50f, 100f))
},
valueRange = 0f..100f,
onValueChangeFinished = {
sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat()
sliderValue.floatValue = snapIfClose(sliderValue.floatValue.roundToInt().toFloat(), listOf(0f, 50f, 100f))
service.aacpManager.sendControlCommand(
identifier = AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH.value,
value = (100 - sliderValue.floatValue).toInt()
@@ -156,3 +182,8 @@ fun AdaptiveStrengthSlider() {
fun AdaptiveStrengthSliderPreview() {
AdaptiveStrengthSlider()
}
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

@@ -34,7 +34,9 @@ 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
@@ -71,6 +73,30 @@ fun ConversationalAwarenessSwitch() {
)
}
val conversationalAwarenessListener = object: AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG.value) {
val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
conversationalAwarenessEnabled = newValue == 1.toByte()
}
}
}
LaunchedEffect(Unit) {
service.aacpManager.registerControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
conversationalAwarenessListener
)
}
DisposableEffect(Unit) {
onDispose {
service.aacpManager.unregisterControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
conversationalAwarenessListener
)
}
}
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black

View File

@@ -34,6 +34,7 @@ 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.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -51,6 +52,7 @@ import me.kavishdevar.librepods.services.AirPodsService
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) {
@@ -86,6 +88,27 @@ fun IndependentToggle(name: String, service: AirPodsService? = null, functionNam
LaunchedEffect(sharedPreferences) {
checked = sharedPreferences.getBoolean(snakeCasedName, true)
}
if (controlCommandIdentifier != null) {
val listener = remember {
object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (controlCommand.identifier == controlCommandIdentifier.value) {
Log.d("IndependentToggle", "Received control command for $name: ${controlCommand.value}")
checked = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0) == 1.toByte()
}
}
}
}
LaunchedEffect(Unit) {
service?.aacpManager?.registerControlCommandListener(controlCommandIdentifier, listener)
}
DisposableEffect(Unit) {
onDispose {
service?.aacpManager?.unregisterControlCommandListener(controlCommandIdentifier, listener)
}
}
}
Box (
modifier = Modifier
.padding(vertical = 8.dp)

View File

@@ -35,6 +35,7 @@ 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.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -63,6 +64,7 @@ fun LoudSoundReductionSwitch(attManager: ATTManager) {
while (attManager.socket?.isConnected != true) {
delay(100)
}
attManager.enableNotifications(0x1b)
var parsed = false
for (attempt in 1..3) {
@@ -91,6 +93,29 @@ fun LoudSoundReductionSwitch(attManager: ATTManager) {
attManager.write(0x1b, if (loudSoundReductionEnabled) byteArrayOf(1) else byteArrayOf(0))
}
val loudSoundListener = remember {
object : (ByteArray) -> Unit {
override fun invoke(value: ByteArray) {
if (value.isNotEmpty()) {
loudSoundReductionEnabled = value[0].toInt() != 0
Log.d("LoudSoundReduction", "Updated from notification: enabled=$loudSoundReductionEnabled")
} else {
Log.w("LoudSoundReduction", "Empty value in notification")
}
}
}
}
LaunchedEffect(Unit) {
attManager.registerListener(0x1b, loudSoundListener)
}
DisposableEffect(Unit) {
onDispose {
attManager.unregisterListener(0x1b, loudSoundListener)
}
}
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black

View File

@@ -34,6 +34,8 @@ 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.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -60,6 +62,22 @@ fun SinglePodANCSwitch() {
singleANCEnabledValue == 1.toByte()
)
}
val listener = object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE.value) {
val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
singleANCEnabled = newValue == 1.toByte()
}
}
}
LaunchedEffect(Unit) {
service.aacpManager.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE, listener)
}
DisposableEffect(Unit) {
onDispose {
service.aacpManager.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE, listener)
}
}
fun updateSingleEnabled(enabled: Boolean) {
singleANCEnabled = enabled

View File

@@ -37,6 +37,8 @@ import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@@ -66,6 +68,24 @@ fun ToneVolumeSlider() {
val sliderValue = remember { mutableFloatStateOf(
sliderValueFromAACP?.toFloat() ?: -1f
) }
val listener = object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME.value) {
val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)?.toFloat()
if (newValue != null) {
sliderValue.floatValue = newValue
}
}
}
}
LaunchedEffect(Unit) {
service.aacpManager.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME, listener)
}
DisposableEffect(Unit) {
onDispose {
service.aacpManager.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME, listener)
}
}
Log.d("ToneVolumeSlider", "Slider value: ${sliderValue.floatValue}")
val isDarkTheme = isSystemInDarkTheme()
@@ -94,11 +114,11 @@ fun ToneVolumeSlider() {
Slider(
value = sliderValue.floatValue,
onValueChange = {
sliderValue.floatValue = it
sliderValue.floatValue = snapIfClose(it, listOf(100f))
},
valueRange = 0f..100f,
valueRange = 0f..125f,
onValueChangeFinished = {
sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat()
sliderValue.floatValue = snapIfClose(sliderValue.floatValue.roundToInt().toFloat(), listOf(100f))
service.aacpManager.sendControlCommand(
identifier = AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME.value,
value = byteArrayOf(sliderValue.floatValue.toInt().toByte(),
@@ -163,3 +183,8 @@ fun ToneVolumeSlider() {
fun ToneVolumeSliderPreview() {
ToneVolumeSlider()
}
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

@@ -34,6 +34,8 @@ 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.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -60,6 +62,22 @@ fun VolumeControlSwitch() {
volumeControlEnabledValue == 1.toByte()
)
}
val listener = object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE.value) {
val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
volumeControlEnabled = newValue == 1.toByte()
}
}
}
LaunchedEffect(Unit) {
service.aacpManager.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE, listener)
}
DisposableEffect(Unit) {
onDispose {
service.aacpManager.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE, listener)
}
}
fun updateVolumeControlEnabled(enabled: Boolean) {
volumeControlEnabled = enabled
service.aacpManager.sendControlCommand(

View File

@@ -23,6 +23,7 @@ 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
@@ -42,6 +43,8 @@ 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.HorizontalDivider
import androidx.compose.material3.Scaffold
@@ -67,6 +70,7 @@ import androidx.compose.ui.draw.scale
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.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
@@ -93,6 +97,7 @@ 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.AACPManager
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.ByteOrder
@@ -224,6 +229,98 @@ fun AccessibilitySettingsScreen() {
)
}
val transparencyListener = remember {
object : (ByteArray) -> Unit {
override fun invoke(value: ByteArray) {
val parsed = parseTransparencySettingsResponse(value)
if (parsed != null) {
enabled.value = parsed.enabled
amplificationSliderValue.floatValue = parsed.netAmplification
balanceSliderValue.floatValue = parsed.balance
toneSliderValue.floatValue = parsed.leftTone
ambientNoiseReductionSliderValue.floatValue = parsed.leftAmbientNoiseReduction
conversationBoostEnabled.value = parsed.leftConversationBoost
eq.value = parsed.leftEQ.copyOf()
Log.d(TAG, "Updated transparency settings from notification")
} else {
Log.w(TAG, "Failed to parse transparency settings from notification")
}
}
}
}
val pressSpeedOptions = mapOf(
0.toByte() to "Default",
1.toByte() to "Slower",
2.toByte() to "Slowest"
)
val selectedPressSpeedValue = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL }?.value?.takeIf { it.isNotEmpty() }?.get(0)
var selectedPressSpeed by remember { mutableStateOf(pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0]) }
val selectedPressSpeedListener = object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL.value) {
val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
selectedPressSpeed = pressSpeedOptions[newValue] ?: pressSpeedOptions[0]
}
}
}
LaunchedEffect(Unit) {
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL, selectedPressSpeedListener)
}
DisposableEffect(Unit) {
onDispose {
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL, selectedPressSpeedListener)
}
}
val pressAndHoldDurationOptions = mapOf(
0.toByte() to "Default",
1.toByte() to "Slower",
2.toByte() to "Slowest"
)
val selectedPressAndHoldDurationValue = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL }?.value?.takeIf { it.isNotEmpty() }?.get(0)
var selectedPressAndHoldDuration by remember { mutableStateOf(pressAndHoldDurationOptions[selectedPressAndHoldDurationValue] ?: pressAndHoldDurationOptions[0]) }
val selectedPressAndHoldDurationListener = object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL.value) {
val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
selectedPressAndHoldDuration = pressAndHoldDurationOptions[newValue] ?: pressAndHoldDurationOptions[0]
}
}
}
LaunchedEffect(Unit) {
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL, selectedPressAndHoldDurationListener)
}
DisposableEffect(Unit) {
onDispose {
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL, selectedPressAndHoldDurationListener)
}
}
val volumeSwipeSpeedOptions = mapOf(
1.toByte() to "Default",
2.toByte() to "Longer",
3.toByte() to "Longest"
)
val selectedVolumeSwipeSpeedValue = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL }?.value?.takeIf { it.isNotEmpty() }?.get(0)
var selectedVolumeSwipeSpeed by remember { mutableStateOf(volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue] ?: volumeSwipeSpeedOptions[1]) }
val selectedVolumeSwipeSpeedListener = object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL.value) {
val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
selectedVolumeSwipeSpeed = volumeSwipeSpeedOptions[newValue] ?: volumeSwipeSpeedOptions[1]
}
}
}
LaunchedEffect(Unit) {
aacpManager?.registerControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL, selectedVolumeSwipeSpeedListener)
}
DisposableEffect(Unit) {
onDispose {
aacpManager?.unregisterControlCommandListener(AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL, selectedVolumeSwipeSpeedListener)
}
}
LaunchedEffect(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")
@@ -239,8 +336,8 @@ fun AccessibilitySettingsScreen() {
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,
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,
@@ -254,6 +351,12 @@ fun AccessibilitySettingsScreen() {
sendTransparencySettings(attManager, transparencySettings.value)
}
DisposableEffect(Unit) {
onDispose {
attManager.unregisterListener(0x18, transparencyListener)
}
}
LaunchedEffect(Unit) {
Log.d(TAG, "Connecting to ATT...")
try {
@@ -261,6 +364,10 @@ fun AccessibilitySettingsScreen() {
while (attManager.socket?.isConnected != true) {
delay(100)
}
attManager.enableNotifications(0x18)
attManager.registerListener(0x18, transparencyListener)
// If we have an AACP manager, prefer its EQ data to populate EQ controls first
try {
if (aacpManager != null) {
@@ -375,26 +482,26 @@ fun AccessibilitySettingsScreen() {
) {
AccessibilitySlider(
label = "Amplification",
valueRange = 0f..1f,
valueRange = -1f..1f,
value = amplificationSliderValue.floatValue,
onValueChange = {
amplificationSliderValue.floatValue = it
amplificationSliderValue.floatValue = snapIfClose(it, listOf(-0.5f, -0.25f, 0f, 0.25f, 0.5f))
},
)
AccessibilitySlider(
label = "Balance",
valueRange = 0f..1f,
valueRange = -1f..1f,
value = balanceSliderValue.floatValue,
onValueChange = {
balanceSliderValue.floatValue = it
balanceSliderValue.floatValue = snapIfClose(it, listOf(0f))
},
)
AccessibilitySlider(
label = "Tone",
valueRange = 0f..1f,
valueRange = -1f..1f,
value = toneSliderValue.floatValue,
onValueChange = {
toneSliderValue.floatValue = it
toneSliderValue.floatValue = snapIfClose(it, listOf(0f))
},
)
AccessibilitySlider(
@@ -402,7 +509,7 @@ fun AccessibilitySettingsScreen() {
valueRange = 0f..1f,
value = ambientNoiseReductionSliderValue.floatValue,
onValueChange = {
ambientNoiseReductionSliderValue.floatValue = it
ambientNoiseReductionSliderValue.floatValue = snapIfClose(it, listOf(0.1f, 0.3f, 0.5f, 0.7f, 0.9f))
},
)
AccessibilityToggle(
@@ -445,6 +552,46 @@ fun AccessibilitySettingsScreen() {
SinglePodANCSwitch()
VolumeControlSwitch()
LoudSoundReductionSwitch(attManager)
DropdownMenuComponent(
label = "Press Speed",
options = pressSpeedOptions.values.toList(),
selectedOption = selectedPressSpeed.toString(),
onOptionSelected = { newValue ->
selectedPressSpeed = newValue
aacpManager?.sendControlCommand(
identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL.value,
value = pressSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() ?: 0.toByte()
)
},
textColor = textColor
)
DropdownMenuComponent(
label = "Press and Hold Duration",
options = pressAndHoldDurationOptions.values.toList(),
selectedOption = selectedPressAndHoldDuration.toString(),
onOptionSelected = { newValue ->
selectedPressAndHoldDuration = newValue
aacpManager?.sendControlCommand(
identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL.value,
value = pressAndHoldDurationOptions.filterValues { it == newValue }.keys.firstOrNull() ?: 0.toByte()
)
},
textColor = textColor
)
DropdownMenuComponent(
label = "Volume Swipe Speed",
options = volumeSwipeSpeedOptions.values.toList(),
selectedOption = selectedVolumeSwipeSpeed.toString(),
onOptionSelected = { newValue ->
selectedVolumeSwipeSpeed = newValue
aacpManager?.sendControlCommand(
identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL.value,
value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() ?: 1.toByte()
)
},
textColor = textColor
)
}
Spacer(modifier = Modifier.height(2.dp))
@@ -515,13 +662,13 @@ fun AccessibilitySettingsScreen() {
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(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(14.dp))
.padding(top = 0.dp, bottom = 12.dp)
.padding(vertical = 0.dp)
) {
val darkModeLocal = isSystemInDarkTheme()
@@ -666,7 +813,6 @@ fun AccessibilitySettingsScreen() {
}
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
@@ -816,13 +962,9 @@ private fun parseTransparencySettingsResponse(data: ByteArray): TransparencySett
Log.d(TAG, "Settings parsed successfully")
val avg = (leftAmplification + rightAmplification) / 2
val amplification = avg.coerceIn(0f, 1f)
val amplification = avg.coerceIn(-1f, 1f)
val diff = rightAmplification - leftAmplification
val balance = if (avg == 0f) {
0.5f
} else {
(0.5f + diff / (2 * avg)).coerceIn(0f, 1f)
}
val balance = diff.coerceIn(-1f, 1f)
return TransparencySettings(
enabled = enabled > 0.5f,
@@ -902,3 +1044,61 @@ private fun sendPhoneMediaEQ(aacpManager: me.kavishdevar.librepods.utils.AACPMan
}
}
}
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
}
@Composable
fun DropdownMenuComponent(
label: String,
options: List<String>,
selectedOption: String,
onOptionSelected: (String) -> Unit,
textColor: Color
) {
var expanded by remember { mutableStateOf(false) }
Column (
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
) {
Text(
text = label,
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = textColor
)
)
Box(
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = true }
.padding(8.dp)
) {
Text(
text = selectedOption,
modifier = Modifier.padding(16.dp),
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
options.forEach { option ->
DropdownMenuItem(
onClick = {
onOptionSelected(option)
expanded = false
},
text = { Text(text = option) }
)
}
}
}
}

View File

@@ -92,7 +92,6 @@ import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.CustomDevice
import me.kavishdevar.librepods.composables.AccessibilitySettings
import me.kavishdevar.librepods.composables.AudioSettings
import me.kavishdevar.librepods.composables.BatteryView
import me.kavishdevar.librepods.composables.IndependentToggle

View File

@@ -272,6 +272,13 @@ class AACPManager {
controlCommandListeners.getOrPut(identifier) { mutableListOf() }.add(callback)
}
fun unregisterControlCommandListener(
identifier: ControlCommandIdentifiers,
callback: ControlCommandListener
) {
controlCommandListeners[identifier]?.remove(callback)
}
private var callback: PacketCallback? = null
fun setPacketCallback(callback: PacketCallback) {
@@ -558,13 +565,6 @@ class AACPManager {
}
}
fun sendEqualizerData(eqData: FloatArray, eqOnPhone: Boolean, eqOnMedia: Boolean): Boolean {
if (eqData.size != 8) {
throw IllegalArgumentException("EQ data must be 8 floats")
}
return sendDataPacket(createEqualizerDataPacket(eqData, eqOnPhone, eqOnMedia))
}
fun createEqualizerDataPacket(eqData: FloatArray, eqOnPhone: Boolean, eqOnMedia: Boolean): ByteArray {
val opcode = byteArrayOf(Opcodes.EQ_DATA, 0x00)
val identifier = byteArrayOf(0x84.toByte(), 0x00)
@@ -1120,6 +1120,9 @@ class AACPManager {
val payload = buffer.array()
val packet = header + payload
sendPacket(packet)
this.eqData = eq.copyOf()
this.eqOnPhone = phone == 0x01.toByte()
this.eqOnMedia = media == 0x01.toByte()
}
fun parseAudioSourceResponse(data: ByteArray): Pair<String, AudioSourceType> {

View File

@@ -5,9 +5,14 @@ import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket
import android.os.ParcelUuid
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.lsposed.hiddenapibypass.HiddenApiBypass
import java.io.InputStream
import java.io.OutputStream
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
class ATTManager(private val device: BluetoothDevice) {
companion object {
@@ -15,11 +20,17 @@ class ATTManager(private val device: BluetoothDevice) {
private const val OPCODE_READ_REQUEST: Byte = 0x0A
private const val OPCODE_WRITE_REQUEST: Byte = 0x12
private const val OPCODE_HANDLE_VALUE_NTF: Byte = 0x1B
}
var socket: BluetoothSocket? = null
private var input: InputStream? = null
private var output: OutputStream? = null
private val listeners = mutableMapOf<Int, MutableList<(ByteArray) -> Unit>>()
private var notificationJob: kotlinx.coroutines.Job? = null
// queue for non-notification PDUs (responses to requests)
private val responses = LinkedBlockingQueue<ByteArray>()
@SuppressLint("MissingPermission")
fun connect() {
@@ -31,22 +42,63 @@ class ATTManager(private val device: BluetoothDevice) {
input = socket!!.inputStream
output = socket!!.outputStream
Log.d(TAG, "Connected to ATT")
notificationJob = CoroutineScope(Dispatchers.IO).launch {
while (socket?.isConnected == true) {
try {
val pdu = readPDU()
if (pdu.isNotEmpty() && pdu[0] == OPCODE_HANDLE_VALUE_NTF) {
// notification -> dispatch to listeners
val handle = (pdu[1].toInt() and 0xFF) or ((pdu[2].toInt() and 0xFF) shl 8)
val value = pdu.copyOfRange(2, pdu.size)
listeners[handle]?.forEach { listener ->
try {
listener(value)
Log.d(TAG, "Dispatched notification for handle $handle to listener, with value ${value.joinToString(" ") { String.format("%02X", it) }}")
} catch (e: Exception) {
Log.w(TAG, "Error in listener for handle $handle: ${e.message}")
}
}
} else {
// not a notification -> treat as a response for pending request(s)
responses.put(pdu)
}
} catch (e: Exception) {
Log.w(TAG, "Error reading notification/response: ${e.message}")
if (socket?.isConnected != true) break
}
}
}
}
fun disconnect() {
try {
notificationJob?.cancel()
socket?.close()
} catch (e: Exception) {
Log.w(TAG, "Error closing socket: ${e.message}")
}
}
fun registerListener(handle: Int, listener: (ByteArray) -> Unit) {
listeners.getOrPut(handle) { mutableListOf() }.add(listener)
}
fun unregisterListener(handle: Int, listener: (ByteArray) -> Unit) {
listeners[handle]?.remove(listener)
}
fun enableNotifications(handle: Int) {
write(handle + 1, byteArrayOf(0x01, 0x00))
}
fun read(handle: Int): ByteArray {
val lsb = (handle and 0xFF).toByte()
val msb = ((handle shr 8) and 0xFF).toByte()
val pdu = byteArrayOf(OPCODE_READ_REQUEST, lsb, msb)
writeRaw(pdu)
return readRaw()
// wait for response placed into responses queue by the reader coroutine
return readResponse()
}
fun write(handle: Int, value: ByteArray) {
@@ -54,7 +106,12 @@ class ATTManager(private val device: BluetoothDevice) {
val msb = ((handle shr 8) and 0xFF).toByte()
val pdu = byteArrayOf(OPCODE_WRITE_REQUEST, lsb, msb) + value
writeRaw(pdu)
readRaw() // usually a Write Response (0x13)
// usually a Write Response (0x13) will arrive; wait for it (but discard return)
try {
readResponse()
} catch (e: Exception) {
Log.w(TAG, "No write response received: ${e.message}")
}
}
private fun writeRaw(pdu: ByteArray) {
@@ -63,17 +120,33 @@ class ATTManager(private val device: BluetoothDevice) {
Log.d(TAG, "writeRaw: ${pdu.joinToString(" ") { String.format("%02X", it) }}")
}
private fun readRaw(): ByteArray {
// rename / specialize: read raw PDU directly from input stream (blocking)
private fun readPDU(): ByteArray {
val inp = input ?: throw IllegalStateException("Not connected")
val buffer = ByteArray(512)
val len = inp.read(buffer)
if (len <= 0) throw IllegalStateException("No data read from ATT socket")
val data = buffer.copyOfRange(0, len)
Log.wtf(TAG, "Read ${data.size} bytes from ATT")
Log.d(TAG, "readRaw: ${data.joinToString(" ") { String.format("%02X", it) }}")
Log.d(TAG, "readPDU: ${data.joinToString(" ") { String.format("%02X", it) }}")
return data
}
// wait for a response PDU produced by the background reader
private fun readResponse(timeoutMs: Long = 2000): ByteArray {
try {
val resp = responses.poll(timeoutMs, TimeUnit.MILLISECONDS)
if (resp == null) {
throw IllegalStateException("No response read from ATT socket within $timeoutMs ms")
}
Log.d(TAG, "readResponse: ${resp.joinToString(" ") { String.format("%02X", it) }}")
return resp
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
throw IllegalStateException("Interrupted while waiting for ATT response", e)
}
}
private fun createBluetoothSocket(device: BluetoothDevice, uuid: ParcelUuid): BluetoothSocket {
val type = 3 // L2CAP
val constructorSpecs = listOf(