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`() {