mirror of
https://github.com/kavishdevar/librepods.git
synced 2026-02-01 07:39:11 +00:00
android: bring back some accessiblity settings and add listeners for all config
This commit is contained in:
@@ -1,218 +0,0 @@
|
||||
/*
|
||||
* LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
*
|
||||
* Copyright (C) 2025 LibrePods contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user