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.
This commit is contained in:
darken
2026-04-07 18:53:54 +02:00
committed by Matthias Urhahn
parent a102b03261
commit 175f8e36b5
9 changed files with 78 additions and 79 deletions
@@ -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)) {
@@ -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 {
@@ -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()
@@ -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,
)
@@ -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())
@@ -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()
}
@@ -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
+2 -4
View File
@@ -456,8 +456,8 @@
<string name="device_settings_press_hold_shortest">Shortest</string>
<string name="device_settings_volume_swipe_label">Volume Swipe</string>
<string name="device_settings_volume_swipe_description">Swipe to adjust volume</string>
<string name="device_settings_volume_swipe_length_label">Volume Swipe Length</string>
<string name="device_settings_volume_swipe_length_description">How far to swipe on the stem to change volume</string>
<string name="device_settings_volume_swipe_length_label">Volume Swipe Wait Time</string>
<string name="device_settings_volume_swipe_length_description">To prevent unintended volume adjustments, choose how long to wait between swipes.</string>
<string name="device_settings_volume_swipe_length_default">Default</string>
<string name="device_settings_volume_swipe_length_longer">Longer</string>
<string name="device_settings_volume_swipe_length_longest">Longest</string>
@@ -488,8 +488,6 @@
<string name="device_settings_allow_off_description">Show Off as an option when cycling noise control modes</string>
<string name="device_settings_sleep_detection_label">Sleep Detection</string>
<string name="device_settings_sleep_detection_description">Automatically pause audio when you fall asleep</string>
<string name="device_settings_in_case_tone_label">Charging Sounds</string>
<string name="device_settings_in_case_tone_description">Play a sound when connected to a charger</string>
<string name="device_settings_rename_label">Rename</string>
<string name="device_settings_rename_hint">Device name</string>
<string name="device_settings_rename_confirm">Rename</string>
@@ -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<AapSetting.InCaseTone>(settingsMessage(0x31, 0x01)).enabled shouldBe true }
@Test fun `decode disabled`() { decodeSetting<AapSetting.InCaseTone>(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<AapSetting.ConnectedDevices>(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<AapSetting.ConnectedDevices>(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<AapSetting.ConnectedDevices>(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<AapSetting.AudioSource>(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<AapSetting.AudioSource>(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<AapSetting.AudioSource>(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<AapSetting.AudioSource>(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<AapSetting.ConnectedDevices>(connectedMsg)
val audio = decodeSetting<AapSetting.AudioSource>(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`() {