From 65d074efe01b155a8d14e831564c453bcd7cd3d6 Mon Sep 17 00:00:00 2001 From: Kavish Devar Date: Fri, 19 Sep 2025 13:10:59 +0530 Subject: [PATCH] android: bring back some accessiblity settings and add listeners for all config --- .../composables/AccessibilitySettings.kt | 218 ---------------- .../composables/AdaptiveStrengthSlider.kt | 35 ++- .../ConversationalAwarenessSwitch.kt | 26 ++ .../composables/IndependentToggle.kt | 23 ++ .../composables/LoudSoundReductionSwitch.kt | 25 ++ .../composables/SinglePodANCSwitch.kt | 18 ++ .../librepods/composables/ToneVolumeSlider.kt | 31 ++- .../composables/VolumeControlSwitch.kt | 18 ++ .../screens/AccessibilitySettingsScreen.kt | 236 ++++++++++++++++-- .../screens/AirPodsSettingsScreen.kt | 1 - .../librepods/utils/AACPManager.kt | 17 +- .../kavishdevar/librepods/utils/ATTManager.kt | 81 +++++- 12 files changed, 476 insertions(+), 253 deletions(-) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/AccessibilitySettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/AccessibilitySettings.kt index ac870f2..e69de29 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/AccessibilitySettings.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/AccessibilitySettings.kt @@ -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 . - */ - -@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, - 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() -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/AdaptiveStrengthSlider.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/AdaptiveStrengthSlider.kt index ad6dc8d..e60bea9 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/AdaptiveStrengthSlider.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/AdaptiveStrengthSlider.kt @@ -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, 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 +} \ No newline at end of file diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/ConversationalAwarenessSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/ConversationalAwarenessSwitch.kt index 668bdad..46fa6f6 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/ConversationalAwarenessSwitch.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/ConversationalAwarenessSwitch.kt @@ -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 diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/IndependentToggle.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/IndependentToggle.kt index 2cb6e46..f40364b 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/IndependentToggle.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/IndependentToggle.kt @@ -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) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/LoudSoundReductionSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/LoudSoundReductionSwitch.kt index c1cec37..1d0ad9d 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/LoudSoundReductionSwitch.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/LoudSoundReductionSwitch.kt @@ -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 diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/SinglePodANCSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/SinglePodANCSwitch.kt index 370be0d..4818bf8 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/SinglePodANCSwitch.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/SinglePodANCSwitch.kt @@ -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 diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/ToneVolumeSlider.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/ToneVolumeSlider.kt index c9db361..07546ab 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/ToneVolumeSlider.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/ToneVolumeSlider.kt @@ -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, 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 +} \ No newline at end of file diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/VolumeControlSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/VolumeControlSwitch.kt index 41bc9cc..1c8b622 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/composables/VolumeControlSwitch.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/VolumeControlSwitch.kt @@ -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( diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt index 4b65d7b..73693f4 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt @@ -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, 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, + 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) } + ) + } + } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt index 99df81f..545f6fb 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt @@ -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 diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt index 764e368..f704c9a 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt @@ -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 { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/ATTManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/ATTManager.kt index 2939c33..72857bb 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/ATTManager.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/ATTManager.kt @@ -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 Unit>>() + private var notificationJob: kotlinx.coroutines.Job? = null + + // queue for non-notification PDUs (responses to requests) + private val responses = LinkedBlockingQueue() @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(