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(