From 175f8e36b53e829150bdf74541b454380c31c523 Mon Sep 17 00:00:00 2001 From: darken Date: Tue, 7 Apr 2026 18:44:32 +0200 Subject: [PATCH] fix(aap): Address device settings review feedback - Show connected device MACs in correct byte order (remove stale reversal) - Rename 'Volume Swipe Length' setting to 'Volume Swipe Wait Time' - Hide 'Charging Sounds' toggle: real case tones go over ATT and the actual effect of this AAP setting is unknown. Decode kept internally so AAP freshness signal still refreshes. - Fix duplicate commands on End Call/Mute Mic radio buttons: use Modifier.selectable with Role.RadioButton, short-circuit when already selected, and wrap options in selectableGroup for TalkBack. --- .../ui/devicesettings/DeviceSettingsScreen.kt | 81 +++++++++---------- .../devicesettings/DeviceSettingsViewModel.kt | 2 - .../eu/darken/capod/monitor/core/PodDevice.kt | 3 - .../darken/capod/pods/core/apple/PodModel.kt | 6 -- .../pods/core/apple/aap/AapConnection.kt | 3 - .../core/apple/aap/protocol/AapCommand.kt | 1 - .../aap/protocol/DefaultAapDeviceProfile.kt | 10 ++- app/src/main/res/values/strings.xml | 6 +- .../DefaultAapDeviceProfileNewSettingsTest.kt | 45 ++++++++--- 9 files changed, 78 insertions(+), 79 deletions(-) diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt index d833518c..ae8cb8f5 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt @@ -2,6 +2,8 @@ package eu.darken.capod.main.ui.devicesettings import androidx.compose.foundation.Canvas import androidx.compose.foundation.clickable +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -21,7 +23,6 @@ import androidx.compose.material.icons.twotone.Headphones import androidx.compose.material.icons.twotone.Hearing import androidx.compose.material.icons.twotone.Mic import androidx.compose.material.icons.twotone.Nightlight -import androidx.compose.material.icons.twotone.NotificationsActive import androidx.compose.material.icons.twotone.Speed import androidx.compose.material.icons.twotone.Stars import androidx.compose.material.icons.twotone.Swipe @@ -54,6 +55,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -111,7 +113,6 @@ fun DeviceSettingsScreenHost( onListeningModeCycleChange = { vm.setListeningModeCycle(it) }, onAllowOffOptionChange = { vm.setAllowOffOption(it) }, onSleepDetectionChange = { vm.setSleepDetection(it) }, - onInCaseToneChange = { vm.setInCaseTone(it) }, onDeviceNameChange = { vm.setDeviceName(it) }, onStemActionsClick = { vm.navToStemConfig() }, ) @@ -138,7 +139,6 @@ fun DeviceSettingsScreen( onListeningModeCycleChange: (Int) -> Unit = {}, onAllowOffOptionChange: (Boolean) -> Unit = {}, onSleepDetectionChange: (Boolean) -> Unit = {}, - onInCaseToneChange: (Boolean) -> Unit = {}, onDeviceNameChange: (String) -> Unit = {}, onStemActionsClick: () -> Unit = {}, ) { @@ -460,22 +460,6 @@ fun DeviceSettingsScreen( } } - if (features.hasInCaseTone) { - val inCaseTone = device.inCaseTone - ?: AapSetting.InCaseTone(enabled = true) - item("in_case_tone") { - ProGatedSwitchItem( - icon = Icons.TwoTone.NotificationsActive, - title = stringResource(R.string.device_settings_in_case_tone_label), - subtitle = stringResource(R.string.device_settings_in_case_tone_description), - checked = inCaseTone.enabled, - onCheckedChange = onInCaseToneChange, - enabled = enabled, - isPro = isPro, - ) - } - } - // ── Connections ─────────────────────────────── val connectedDevices = device.connectedDevices if (connectedDevices != null && connectedDevices.devices.isNotEmpty()) { @@ -745,31 +729,33 @@ private fun EndCallMuteMicControl( Spacer(modifier = Modifier.height(8.dp)) - CallControlOption( - title = stringResource(R.string.device_settings_end_call_mute_mic_option_a_title), - subtitle = stringResource(R.string.device_settings_end_call_mute_mic_option_a_subtitle), - selected = optionASelected, - enabled = enabled, - onClick = { - onChange( - AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS, - AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS, - ) - }, - ) + Column(modifier = Modifier.selectableGroup()) { + CallControlOption( + title = stringResource(R.string.device_settings_end_call_mute_mic_option_a_title), + subtitle = stringResource(R.string.device_settings_end_call_mute_mic_option_a_subtitle), + selected = optionASelected, + enabled = enabled, + onClick = { + onChange( + AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS, + AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS, + ) + }, + ) - CallControlOption( - title = stringResource(R.string.device_settings_end_call_mute_mic_option_b_title), - subtitle = stringResource(R.string.device_settings_end_call_mute_mic_option_b_subtitle), - selected = !optionASelected, - enabled = enabled, - onClick = { - onChange( - AapSetting.EndCallMuteMic.MuteMicMode.SINGLE_PRESS, - AapSetting.EndCallMuteMic.EndCallMode.DOUBLE_PRESS, - ) - }, - ) + CallControlOption( + title = stringResource(R.string.device_settings_end_call_mute_mic_option_b_title), + subtitle = stringResource(R.string.device_settings_end_call_mute_mic_option_b_subtitle), + selected = !optionASelected, + enabled = enabled, + onClick = { + onChange( + AapSetting.EndCallMuteMic.MuteMicMode.SINGLE_PRESS, + AapSetting.EndCallMuteMic.EndCallMode.DOUBLE_PRESS, + ) + }, + ) + } } } @@ -785,12 +771,17 @@ private fun CallControlOption( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .clickable(enabled = enabled, onClick = onClick) + .selectable( + selected = selected, + enabled = enabled, + role = Role.RadioButton, + onClick = { if (!selected) onClick() }, + ) .padding(vertical = 4.dp), ) { RadioButton( selected = selected, - onClick = onClick, + onClick = null, enabled = enabled, ) Column(modifier = Modifier.padding(start = 4.dp)) { diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt index 002c2f5d..365ea888 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsViewModel.kt @@ -126,8 +126,6 @@ class DeviceSettingsViewModel @Inject constructor( fun setSleepDetection(enabled: Boolean) = sendProGated(AapCommand.SetSleepDetection(enabled)) - fun setInCaseTone(enabled: Boolean) = sendProGated(AapCommand.SetInCaseTone(enabled)) - fun setDeviceName(name: String) = send(AapCommand.SetDeviceName(name)) fun navToStemConfig() = launch { diff --git a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt index 9464c2be..396e9a20 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt @@ -275,9 +275,6 @@ data class PodDevice( val sleepDetection: AapSetting.SleepDetection? get() = aap?.setting() - val inCaseTone: AapSetting.InCaseTone? - get() = aap?.setting() - val connectedDevices: AapSetting.ConnectedDevices? get() = aap?.setting() diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/PodModel.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/PodModel.kt index 1bf68c02..41ed9ba2 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/PodModel.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/PodModel.kt @@ -68,7 +68,6 @@ enum class PodModel( hasMicrophoneMode = true, hasEarDetectionToggle = true, hasSleepDetection = true, - hasInCaseTone = true, ), modelNumbers = setOf("A3050", "A3053", "A3054"), // earphones leftPodIconRes = R.drawable.device_airpods_gen3_left, @@ -100,7 +99,6 @@ enum class PodModel( hasAllowOffOption = true, hasStemConfig = true, hasSleepDetection = true, - hasInCaseTone = true, needsInitExt = true, ), modelNumbers = setOf("A3055", "A3056", "A3057"), // earphones @@ -160,7 +158,6 @@ enum class PodModel( hasAllowOffOption = true, hasStemConfig = true, hasSleepDetection = true, - hasInCaseTone = true, needsInitExt = true, ), modelNumbers = setOf("A2698", "A2699", "A2931"), // earphones @@ -195,7 +192,6 @@ enum class PodModel( hasAllowOffOption = true, hasStemConfig = true, hasSleepDetection = true, - hasInCaseTone = true, needsInitExt = true, ), modelNumbers = setOf("A3047", "A3048", "A3049"), // earphones @@ -230,7 +226,6 @@ enum class PodModel( hasAllowOffOption = true, hasStemConfig = true, hasSleepDetection = true, - hasInCaseTone = true, needsInitExt = true, ), modelNumbers = setOf("A3063", "A3064", "A3065"), // earphones @@ -531,7 +526,6 @@ enum class PodModel( val hasAllowOffOption: Boolean = false, val hasStemConfig: Boolean = false, val hasSleepDetection: Boolean = false, - val hasInCaseTone: Boolean = false, // Protocol val needsInitExt: Boolean = false, ) diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt index 59135394..6a469801 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt @@ -240,9 +240,6 @@ internal class AapConnection( is AapCommand.SetSleepDetection -> { AapSetting.SleepDetection::class to AapSetting.SleepDetection(enabled = command.enabled) } - is AapCommand.SetInCaseTone -> { - AapSetting.InCaseTone::class to AapSetting.InCaseTone(enabled = command.enabled) - } is AapCommand.SetDeviceName -> return // No optimistic state — name comes from deviceInfo } _state.value = baseState.withSetting(updated.first, updated.second).copy(lastMessageAt = Instant.now()) diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapCommand.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapCommand.kt index 71d00b48..198a5bab 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapCommand.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapCommand.kt @@ -22,6 +22,5 @@ sealed class AapCommand { data class SetAllowOffOption(val enabled: Boolean) : AapCommand() data class SetStemConfig(val claimedPressMask: Int) : AapCommand() data class SetSleepDetection(val enabled: Boolean) : AapCommand() - data class SetInCaseTone(val enabled: Boolean) : AapCommand() data class SetDeviceName(val name: String) : AapCommand() } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/DefaultAapDeviceProfile.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/DefaultAapDeviceProfile.kt index a2810000..14e9de85 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/DefaultAapDeviceProfile.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/DefaultAapDeviceProfile.kt @@ -40,6 +40,11 @@ class DefaultAapDeviceProfile( const val SETTING_MICROPHONE_MODE = 0x01 const val SETTING_EAR_DETECTION_ENABLED = 0x0A const val SETTING_LISTENING_MODE_CYCLE = 0x1A + // Kept decoded internally (never exposed in UI): the device pushes 0x31 frames and dropping the + // decode would fall through to "Unhandled message" logging and prevent lastMessageAt refresh, + // which AAP freshness / boost logic in PodDevice.computeAapBoost depends on. + // Originally labeled "Charging Sounds" but the real case tones are controlled over ATT, not here; + // the actual effect of this setting is unknown, so we don't expose or write it. const val SETTING_IN_CASE_TONE = 0x31 const val SETTING_ALLOW_OFF_OPTION = 0x34 const val SETTING_SLEEP_DETECTION = 0x35 @@ -104,7 +109,6 @@ class DefaultAapDeviceProfile( is AapCommand.SetAllowOffOption -> buildSettingsMessage(SETTING_ALLOW_OFF_OPTION, encodeAppleBool(command.enabled)) is AapCommand.SetStemConfig -> buildSettingsMessage(SETTING_STEM_CONFIG, command.claimedPressMask and 0x0F) is AapCommand.SetSleepDetection -> buildSettingsMessage(SETTING_SLEEP_DETECTION, encodeAppleBool(command.enabled)) - is AapCommand.SetInCaseTone -> buildSettingsMessage(SETTING_IN_CASE_TONE, encodeAppleBool(command.enabled)) is AapCommand.SetDeviceName -> buildRenameMessage(command.name) } @@ -143,7 +147,7 @@ class DefaultAapDeviceProfile( var offset = 3 for (i in 0 until count) { if (offset + 8 > message.payload.size) break - val mac = (0 until 6).map { "%02X".format(message.payload[offset + 5 - it]) }.joinToString(":") + val mac = (0 until 6).map { "%02X".format(message.payload[offset + it]) }.joinToString(":") val type = message.payload[offset + 6].toInt() and 0xFF devices.add(AapSetting.ConnectedDevices.ConnectedDevice(mac, type)) offset += 8 @@ -154,7 +158,7 @@ class DefaultAapDeviceProfile( // Audio source tracking (push-only from device) if (message.commandType == CMD_AUDIO_SOURCE) { if (message.payload.size < 7) return null - val mac = (0 until 6).map { "%02X".format(message.payload[5 - it]) }.joinToString(":") + val mac = (0 until 6).map { "%02X".format(message.payload[it]) }.joinToString(":") val typeValue = message.payload[6].toInt() and 0xFF val type = when (typeValue) { 0x01 -> AapSetting.AudioSource.AudioSourceType.CALL diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c2c5e087..793b0d09 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -456,8 +456,8 @@ Shortest Volume Swipe Swipe to adjust volume - Volume Swipe Length - How far to swipe on the stem to change volume + Volume Swipe Wait Time + To prevent unintended volume adjustments, choose how long to wait between swipes. Default Longer Longest @@ -488,8 +488,6 @@ Show Off as an option when cycling noise control modes Sleep Detection Automatically pause audio when you fall asleep - Charging Sounds - Play a sound when connected to a charger Rename Device name Rename diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileNewSettingsTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileNewSettingsTest.kt index 59dfdd08..76ef5270 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileNewSettingsTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/devices/DefaultAapDeviceProfileNewSettingsTest.kt @@ -118,11 +118,11 @@ class DefaultAapDeviceProfileNewSettingsTest : BaseAapSessionTest() { } // ── In-Case Tone (0x31) ───────────────────────────────── + // Decode path is kept internally even though the setting is no longer exposed in the UI. + // See DefaultAapDeviceProfile.SETTING_IN_CASE_TONE for rationale. @Nested inner class InCaseToneTests { - @Test fun `encode enabled`() { profile.encodeCommand(AapCommand.SetInCaseTone(true))[7] shouldBe 0x01.toByte() } - @Test fun `encode disabled`() { profile.encodeCommand(AapCommand.SetInCaseTone(false))[7] shouldBe 0x02.toByte() } @Test fun `decode enabled`() { decodeSetting(settingsMessage(0x31, 0x01)).enabled shouldBe true } @Test fun `decode disabled`() { decodeSetting(settingsMessage(0x31, 0x02)).enabled shouldBe false } @Test fun `decode unknown returns null`() { profile.decodeSetting(settingsMessage(0x31, 0x00)).shouldBeNull() } @@ -213,13 +213,20 @@ class DefaultAapDeviceProfileNewSettingsTest : BaseAapSessionTest() { @Nested inner class ConnectedDevicesTests { @Test fun `decode single device`() { - // payload: 2 unknown bytes, count=1, then 6-byte MAC reversed + 2 flags - val msg = aapMessage("04 00 04 00 2E 00 00 00 01 06 05 04 03 02 01 00 00") + // payload: 2 unknown bytes, count=1, then 6-byte MAC + 2 flags + val msg = aapMessage("04 00 04 00 2E 00 00 00 01 01 02 03 04 05 06 00 00") val cd = decodeSetting(msg) cd.devices.size shouldBe 1 cd.devices[0].mac shouldBe "01:02:03:04:05:06" } + @Test fun `decode high bytes formats as upper-case hex`() { + // Use MAC with high bytes (>=0x80) to lock in %02X formatting and catch sign-extension regressions. + val msg = aapMessage("04 00 04 00 2E 00 00 00 01 AA BB CC DD EE FF 00 00") + val cd = decodeSetting(msg) + cd.devices[0].mac shouldBe "AA:BB:CC:DD:EE:FF" + } + @Test fun `decode empty list`() { val msg = aapMessage("04 00 04 00 2E 00 00 00 00") val cd = decodeSetting(msg) @@ -236,26 +243,43 @@ class DefaultAapDeviceProfileNewSettingsTest : BaseAapSessionTest() { @Nested inner class AudioSourceTests { @Test fun `decode media source`() { - val msg = aapMessage("04 00 04 00 0E 00 06 05 04 03 02 01 02") + val msg = aapMessage("04 00 04 00 0E 00 01 02 03 04 05 06 02") val as_ = decodeSetting(msg) as_.sourceMac shouldBe "01:02:03:04:05:06" as_.type shouldBe AapSetting.AudioSource.AudioSourceType.MEDIA } @Test fun `decode call source`() { - val msg = aapMessage("04 00 04 00 0E 00 06 05 04 03 02 01 01") + val msg = aapMessage("04 00 04 00 0E 00 01 02 03 04 05 06 01") val as_ = decodeSetting(msg) + as_.sourceMac shouldBe "01:02:03:04:05:06" as_.type shouldBe AapSetting.AudioSource.AudioSourceType.CALL } @Test fun `decode unknown type maps to NONE`() { - val msg = aapMessage("04 00 04 00 0E 00 06 05 04 03 02 01 00") + val msg = aapMessage("04 00 04 00 0E 00 01 02 03 04 05 06 00") val as_ = decodeSetting(msg) + as_.sourceMac shouldBe "01:02:03:04:05:06" as_.type shouldBe AapSetting.AudioSource.AudioSourceType.NONE } @Test fun `payload too short returns null`() { - profile.decodeSetting(aapMessage("04 00 04 00 0E 00 06 05 04 03 02 01")).shouldBeNull() + profile.decodeSetting(aapMessage("04 00 04 00 0E 00 01 02 03 04 05 06")).shouldBeNull() + } + + @Test fun `decode high bytes formats as upper-case hex`() { + val msg = aapMessage("04 00 04 00 0E 00 AA BB CC DD EE FF 02") + val as_ = decodeSetting(msg) + as_.sourceMac shouldBe "AA:BB:CC:DD:EE:FF" + } + + @Test fun `ConnectedDevices and AudioSource decode MAC identically`() { + // DeviceSettingsScreen matches audioSource.sourceMac == device.mac, so both paths must canonicalize the same way. + val connectedMsg = aapMessage("04 00 04 00 2E 00 00 00 01 11 22 33 44 55 66 00 00") + val audioMsg = aapMessage("04 00 04 00 0E 00 11 22 33 44 55 66 02") + val connected = decodeSetting(connectedMsg) + val audio = decodeSetting(audioMsg) + connected.devices[0].mac shouldBe audio.sourceMac } } @@ -271,7 +295,6 @@ class DefaultAapDeviceProfileNewSettingsTest : BaseAapSessionTest() { f.hasAllowOffOption shouldBe true f.hasStemConfig shouldBe true f.hasSleepDetection shouldBe true - f.hasInCaseTone shouldBe true } @Test fun `Pro 1 has mic and ear detection but no stem config`() { @@ -282,17 +305,15 @@ class DefaultAapDeviceProfileNewSettingsTest : BaseAapSessionTest() { f.hasAllowOffOption shouldBe true f.hasStemConfig shouldBe false f.hasSleepDetection shouldBe false - f.hasInCaseTone shouldBe false } - @Test fun `Gen 4 has mic, ear detection, sleep, in-case but no stem config`() { + @Test fun `Gen 4 has mic, ear detection, sleep but no stem config`() { val f = PodModel.AIRPODS_GEN4.features f.hasMicrophoneMode shouldBe true f.hasEarDetectionToggle shouldBe true f.hasListeningModeCycle shouldBe false f.hasStemConfig shouldBe false f.hasSleepDetection shouldBe true - f.hasInCaseTone shouldBe true } @Test fun `Max has ear detection toggle only`() {