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.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -66,6 +67,31 @@ fun AdaptiveStrengthSlider() {
sliderValueFromAACP?.toFloat()?.let { sliderValue.floatValue = (100 - it) } 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 isDarkTheme = isSystemInDarkTheme()
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFFD9D9D9) val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFFD9D9D9)
@@ -81,11 +107,11 @@ fun AdaptiveStrengthSlider() {
Slider( Slider(
value = sliderValue.floatValue, value = sliderValue.floatValue,
onValueChange = { onValueChange = {
sliderValue.floatValue = it sliderValue.floatValue = snapIfClose(it, listOf(0f, 50f, 100f))
}, },
valueRange = 0f..100f, valueRange = 0f..100f,
onValueChangeFinished = { onValueChangeFinished = {
sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat() sliderValue.floatValue = snapIfClose(sliderValue.floatValue.roundToInt().toFloat(), listOf(0f, 50f, 100f))
service.aacpManager.sendControlCommand( service.aacpManager.sendControlCommand(
identifier = AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH.value, identifier = AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH.value,
value = (100 - sliderValue.floatValue).toInt() value = (100 - sliderValue.floatValue).toInt()
@@ -156,3 +182,8 @@ fun AdaptiveStrengthSlider() {
fun AdaptiveStrengthSliderPreview() { fun AdaptiveStrengthSliderPreview() {
AdaptiveStrengthSlider() 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.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue 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 isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black 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.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -51,6 +52,7 @@ import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
import androidx.core.content.edit import androidx.core.content.edit
import android.util.Log
@Composable @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) {
@@ -86,6 +88,27 @@ fun IndependentToggle(name: String, service: AirPodsService? = null, functionNam
LaunchedEffect(sharedPreferences) { LaunchedEffect(sharedPreferences) {
checked = sharedPreferences.getBoolean(snakeCasedName, true) 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 ( Box (
modifier = Modifier modifier = Modifier
.padding(vertical = 8.dp) .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.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -63,6 +64,7 @@ fun LoudSoundReductionSwitch(attManager: ATTManager) {
while (attManager.socket?.isConnected != true) { while (attManager.socket?.isConnected != true) {
delay(100) delay(100)
} }
attManager.enableNotifications(0x1b)
var parsed = false var parsed = false
for (attempt in 1..3) { for (attempt in 1..3) {
@@ -91,6 +93,29 @@ fun LoudSoundReductionSwitch(attManager: ATTManager) {
attManager.write(0x1b, if (loudSoundReductionEnabled) byteArrayOf(1) else byteArrayOf(0)) 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 isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black 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.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -60,6 +62,22 @@ fun SinglePodANCSwitch() {
singleANCEnabledValue == 1.toByte() 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) { fun updateSingleEnabled(enabled: Boolean) {
singleANCEnabled = enabled singleANCEnabled = enabled

View File

@@ -37,6 +37,8 @@ import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -66,6 +68,24 @@ fun ToneVolumeSlider() {
val sliderValue = remember { mutableFloatStateOf( val sliderValue = remember { mutableFloatStateOf(
sliderValueFromAACP?.toFloat() ?: -1f 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}") Log.d("ToneVolumeSlider", "Slider value: ${sliderValue.floatValue}")
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
@@ -94,11 +114,11 @@ fun ToneVolumeSlider() {
Slider( Slider(
value = sliderValue.floatValue, value = sliderValue.floatValue,
onValueChange = { onValueChange = {
sliderValue.floatValue = it sliderValue.floatValue = snapIfClose(it, listOf(100f))
}, },
valueRange = 0f..100f, valueRange = 0f..125f,
onValueChangeFinished = { onValueChangeFinished = {
sliderValue.floatValue = sliderValue.floatValue.roundToInt().toFloat() sliderValue.floatValue = snapIfClose(sliderValue.floatValue.roundToInt().toFloat(), listOf(100f))
service.aacpManager.sendControlCommand( service.aacpManager.sendControlCommand(
identifier = AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME.value, identifier = AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME.value,
value = byteArrayOf(sliderValue.floatValue.toInt().toByte(), value = byteArrayOf(sliderValue.floatValue.toInt().toByte(),
@@ -163,3 +183,8 @@ fun ToneVolumeSlider() {
fun ToneVolumeSliderPreview() { fun ToneVolumeSliderPreview() {
ToneVolumeSlider() 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.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -60,6 +62,22 @@ fun VolumeControlSwitch() {
volumeControlEnabledValue == 1.toByte() 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) { fun updateVolumeControlEnabled(enabled: Boolean) {
volumeControlEnabled = enabled volumeControlEnabled = enabled
service.aacpManager.sendControlCommand( service.aacpManager.sendControlCommand(

View File

@@ -23,6 +23,7 @@ import android.util.Log
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -42,6 +43,8 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Scaffold 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.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
@@ -93,6 +97,7 @@ import me.kavishdevar.librepods.composables.ToneVolumeSlider
import me.kavishdevar.librepods.composables.VolumeControlSwitch import me.kavishdevar.librepods.composables.VolumeControlSwitch
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.ATTManager import me.kavishdevar.librepods.utils.ATTManager
import me.kavishdevar.librepods.utils.AACPManager
import java.io.IOException import java.io.IOException
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder 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) { LaunchedEffect(enabled.value, amplificationSliderValue.floatValue, balanceSliderValue.floatValue, toneSliderValue.floatValue, conversationBoostEnabled.value, ambientNoiseReductionSliderValue.floatValue, eq.value, initialLoadComplete.value, initialReadSucceeded.value) {
if (!initialLoadComplete.value) { if (!initialLoadComplete.value) {
Log.d(TAG, "Initial device load not complete - skipping send") Log.d(TAG, "Initial device load not complete - skipping send")
@@ -239,8 +336,8 @@ fun AccessibilitySettingsScreen() {
enabled = enabled.value, enabled = enabled.value,
leftEQ = eq.value, leftEQ = eq.value,
rightEQ = eq.value, rightEQ = eq.value,
leftAmplification = amplificationSliderValue.floatValue + (0.5f - balanceSliderValue.floatValue) * amplificationSliderValue.floatValue * 2, leftAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue < 0) -balanceSliderValue.floatValue else 0f,
rightAmplification = amplificationSliderValue.floatValue + (balanceSliderValue.floatValue - 0.5f) * amplificationSliderValue.floatValue * 2, rightAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue > 0) balanceSliderValue.floatValue else 0f,
leftTone = toneSliderValue.floatValue, leftTone = toneSliderValue.floatValue,
rightTone = toneSliderValue.floatValue, rightTone = toneSliderValue.floatValue,
leftConversationBoost = conversationBoostEnabled.value, leftConversationBoost = conversationBoostEnabled.value,
@@ -254,6 +351,12 @@ fun AccessibilitySettingsScreen() {
sendTransparencySettings(attManager, transparencySettings.value) sendTransparencySettings(attManager, transparencySettings.value)
} }
DisposableEffect(Unit) {
onDispose {
attManager.unregisterListener(0x18, transparencyListener)
}
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
Log.d(TAG, "Connecting to ATT...") Log.d(TAG, "Connecting to ATT...")
try { try {
@@ -261,6 +364,10 @@ fun AccessibilitySettingsScreen() {
while (attManager.socket?.isConnected != true) { while (attManager.socket?.isConnected != true) {
delay(100) delay(100)
} }
attManager.enableNotifications(0x18)
attManager.registerListener(0x18, transparencyListener)
// If we have an AACP manager, prefer its EQ data to populate EQ controls first // If we have an AACP manager, prefer its EQ data to populate EQ controls first
try { try {
if (aacpManager != null) { if (aacpManager != null) {
@@ -375,26 +482,26 @@ fun AccessibilitySettingsScreen() {
) { ) {
AccessibilitySlider( AccessibilitySlider(
label = "Amplification", label = "Amplification",
valueRange = 0f..1f, valueRange = -1f..1f,
value = amplificationSliderValue.floatValue, value = amplificationSliderValue.floatValue,
onValueChange = { onValueChange = {
amplificationSliderValue.floatValue = it amplificationSliderValue.floatValue = snapIfClose(it, listOf(-0.5f, -0.25f, 0f, 0.25f, 0.5f))
}, },
) )
AccessibilitySlider( AccessibilitySlider(
label = "Balance", label = "Balance",
valueRange = 0f..1f, valueRange = -1f..1f,
value = balanceSliderValue.floatValue, value = balanceSliderValue.floatValue,
onValueChange = { onValueChange = {
balanceSliderValue.floatValue = it balanceSliderValue.floatValue = snapIfClose(it, listOf(0f))
}, },
) )
AccessibilitySlider( AccessibilitySlider(
label = "Tone", label = "Tone",
valueRange = 0f..1f, valueRange = -1f..1f,
value = toneSliderValue.floatValue, value = toneSliderValue.floatValue,
onValueChange = { onValueChange = {
toneSliderValue.floatValue = it toneSliderValue.floatValue = snapIfClose(it, listOf(0f))
}, },
) )
AccessibilitySlider( AccessibilitySlider(
@@ -402,7 +509,7 @@ fun AccessibilitySettingsScreen() {
valueRange = 0f..1f, valueRange = 0f..1f,
value = ambientNoiseReductionSliderValue.floatValue, value = ambientNoiseReductionSliderValue.floatValue,
onValueChange = { onValueChange = {
ambientNoiseReductionSliderValue.floatValue = it ambientNoiseReductionSliderValue.floatValue = snapIfClose(it, listOf(0.1f, 0.3f, 0.5f, 0.7f, 0.9f))
}, },
) )
AccessibilityToggle( AccessibilityToggle(
@@ -445,6 +552,46 @@ fun AccessibilitySettingsScreen() {
SinglePodANCSwitch() SinglePodANCSwitch()
VolumeControlSwitch() VolumeControlSwitch()
LoudSoundReductionSwitch(attManager) 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)) Spacer(modifier = Modifier.height(2.dp))
@@ -515,13 +662,13 @@ fun AccessibilitySettingsScreen() {
color = textColor.copy(alpha = 0.6f), color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)) fontFamily = FontFamily(Font(R.font.sf_pro))
), ),
modifier = Modifier.padding(8.dp, bottom = 2.dp) modifier = Modifier.padding(8.dp, bottom = 0.dp)
) )
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(14.dp)) .background(backgroundColor, RoundedCornerShape(14.dp))
.padding(top = 0.dp, bottom = 12.dp) .padding(vertical = 0.dp)
) { ) {
val darkModeLocal = isSystemInDarkTheme() 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") Log.d(TAG, "Settings parsed successfully")
val avg = (leftAmplification + rightAmplification) / 2 val avg = (leftAmplification + rightAmplification) / 2
val amplification = avg.coerceIn(0f, 1f) val amplification = avg.coerceIn(-1f, 1f)
val diff = rightAmplification - leftAmplification val diff = rightAmplification - leftAmplification
val balance = if (avg == 0f) { val balance = diff.coerceIn(-1f, 1f)
0.5f
} else {
(0.5f + diff / (2 * avg)).coerceIn(0f, 1f)
}
return TransparencySettings( return TransparencySettings(
enabled = enabled > 0.5f, 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 kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.CustomDevice import me.kavishdevar.librepods.CustomDevice
import me.kavishdevar.librepods.composables.AccessibilitySettings
import me.kavishdevar.librepods.composables.AudioSettings import me.kavishdevar.librepods.composables.AudioSettings
import me.kavishdevar.librepods.composables.BatteryView import me.kavishdevar.librepods.composables.BatteryView
import me.kavishdevar.librepods.composables.IndependentToggle import me.kavishdevar.librepods.composables.IndependentToggle

View File

@@ -272,6 +272,13 @@ class AACPManager {
controlCommandListeners.getOrPut(identifier) { mutableListOf() }.add(callback) controlCommandListeners.getOrPut(identifier) { mutableListOf() }.add(callback)
} }
fun unregisterControlCommandListener(
identifier: ControlCommandIdentifiers,
callback: ControlCommandListener
) {
controlCommandListeners[identifier]?.remove(callback)
}
private var callback: PacketCallback? = null private var callback: PacketCallback? = null
fun setPacketCallback(callback: PacketCallback) { 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 { fun createEqualizerDataPacket(eqData: FloatArray, eqOnPhone: Boolean, eqOnMedia: Boolean): ByteArray {
val opcode = byteArrayOf(Opcodes.EQ_DATA, 0x00) val opcode = byteArrayOf(Opcodes.EQ_DATA, 0x00)
val identifier = byteArrayOf(0x84.toByte(), 0x00) val identifier = byteArrayOf(0x84.toByte(), 0x00)
@@ -1120,6 +1120,9 @@ class AACPManager {
val payload = buffer.array() val payload = buffer.array()
val packet = header + payload val packet = header + payload
sendPacket(packet) sendPacket(packet)
this.eqData = eq.copyOf()
this.eqOnPhone = phone == 0x01.toByte()
this.eqOnMedia = media == 0x01.toByte()
} }
fun parseAudioSourceResponse(data: ByteArray): Pair<String, AudioSourceType> { fun parseAudioSourceResponse(data: ByteArray): Pair<String, AudioSourceType> {

View File

@@ -5,9 +5,14 @@ import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket import android.bluetooth.BluetoothSocket
import android.os.ParcelUuid import android.os.ParcelUuid
import android.util.Log import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.lsposed.hiddenapibypass.HiddenApiBypass import org.lsposed.hiddenapibypass.HiddenApiBypass
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
class ATTManager(private val device: BluetoothDevice) { class ATTManager(private val device: BluetoothDevice) {
companion object { companion object {
@@ -15,11 +20,17 @@ class ATTManager(private val device: BluetoothDevice) {
private const val OPCODE_READ_REQUEST: Byte = 0x0A private const val OPCODE_READ_REQUEST: Byte = 0x0A
private const val OPCODE_WRITE_REQUEST: Byte = 0x12 private const val OPCODE_WRITE_REQUEST: Byte = 0x12
private const val OPCODE_HANDLE_VALUE_NTF: Byte = 0x1B
} }
var socket: BluetoothSocket? = null var socket: BluetoothSocket? = null
private var input: InputStream? = null private var input: InputStream? = null
private var output: OutputStream? = 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") @SuppressLint("MissingPermission")
fun connect() { fun connect() {
@@ -31,22 +42,63 @@ class ATTManager(private val device: BluetoothDevice) {
input = socket!!.inputStream input = socket!!.inputStream
output = socket!!.outputStream output = socket!!.outputStream
Log.d(TAG, "Connected to ATT") 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() { fun disconnect() {
try { try {
notificationJob?.cancel()
socket?.close() socket?.close()
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Error closing socket: ${e.message}") 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 { fun read(handle: Int): ByteArray {
val lsb = (handle and 0xFF).toByte() val lsb = (handle and 0xFF).toByte()
val msb = ((handle shr 8) and 0xFF).toByte() val msb = ((handle shr 8) and 0xFF).toByte()
val pdu = byteArrayOf(OPCODE_READ_REQUEST, lsb, msb) val pdu = byteArrayOf(OPCODE_READ_REQUEST, lsb, msb)
writeRaw(pdu) writeRaw(pdu)
return readRaw() // wait for response placed into responses queue by the reader coroutine
return readResponse()
} }
fun write(handle: Int, value: ByteArray) { 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 msb = ((handle shr 8) and 0xFF).toByte()
val pdu = byteArrayOf(OPCODE_WRITE_REQUEST, lsb, msb) + value val pdu = byteArrayOf(OPCODE_WRITE_REQUEST, lsb, msb) + value
writeRaw(pdu) 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) { 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) }}") 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 inp = input ?: throw IllegalStateException("Not connected")
val buffer = ByteArray(512) val buffer = ByteArray(512)
val len = inp.read(buffer) val len = inp.read(buffer)
if (len <= 0) throw IllegalStateException("No data read from ATT socket") if (len <= 0) throw IllegalStateException("No data read from ATT socket")
val data = buffer.copyOfRange(0, len) val data = buffer.copyOfRange(0, len)
Log.wtf(TAG, "Read ${data.size} bytes from ATT") 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 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 { private fun createBluetoothSocket(device: BluetoothDevice, uuid: ParcelUuid): BluetoothSocket {
val type = 3 // L2CAP val type = 3 // L2CAP
val constructorSpecs = listOf( val constructorSpecs = listOf(