fix: Correct PodModel feature flags for ear detection and call controls

Sync flags with what the BLE classes actually report and what iOS exposes:

- AirPods Gen 1/2/3: enable hasEarDetection (already parsed via DualApplePods) and hasEarDetectionToggle

- AirPods Gen 3, Pro 1: enable hasEndCallMuteMic (force-sensor stems)

- Powerbeats Pro, Beats Fit Pro: enable hasEarDetectionToggle (iOS exposes it)

- Beats Solo Pro, Studio 3: drop hasEarDetection (over-ear, BLE class is bare SingleApplePods)

- FAKE_AIRPODS_GEN1/2/3: enable hasEarDetection to match HasEarDetectionDual

- Generalize microphone mode description from 'AirPod' to 'earbud'

Tests rewritten as exhaustive set assertions plus implication invariants.
This commit is contained in:
darken
2026-05-01 20:08:29 +02:00
committed by Matthias Urhahn
parent e1c0a702dd
commit fc98db09a2
4 changed files with 397 additions and 60 deletions
@@ -1,5 +1,6 @@
package eu.darken.capod.pods.core.apple
import io.kotest.assertions.withClue
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
@@ -7,79 +8,364 @@ import testhelpers.BaseTest
class ModelFeaturesTest : BaseTest() {
@Test
fun `AirPods Pro 3 has all features`() {
val f = PodModel.AIRPODS_PRO3.features
f.hasDualPods shouldBe true
f.hasCase shouldBe true
f.hasEarDetection shouldBe true
f.hasAncControl shouldBe true
fun `feature flags match supported model matrix`() {
featureExpectations.forEach { expectation ->
withClue(expectation.name) {
modelsWith(expectation.predicate) shouldBe expectation.models
}
}
}
@Test
fun `AirPods Gen 1 has dual pods and case but no ear detection or ANC`() {
val f = PodModel.AIRPODS_GEN1.features
f.hasDualPods shouldBe true
f.hasCase shouldBe true
f.hasEarDetection shouldBe false
f.hasAncControl shouldBe false
}
@Test
fun `AirPods Max is single device with ANC but no dual pods or case`() {
val f = PodModel.AIRPODS_MAX.features
f.hasDualPods shouldBe false
f.hasCase shouldBe false
f.hasEarDetection shouldBe true
f.hasAncControl shouldBe true
}
@Test
fun `Beats Solo 3 has no features`() {
val f = PodModel.BEATS_SOLO_3.features
f.hasDualPods shouldBe false
f.hasCase shouldBe false
f.hasEarDetection shouldBe false
f.hasAncControl shouldBe false
}
@Test
fun `PowerBeats Pro has dual pods, case, ear detection but no ANC`() {
val f = PodModel.POWERBEATS_PRO.features
f.hasDualPods shouldBe true
f.hasCase shouldBe true
f.hasEarDetection shouldBe true
f.hasAncControl shouldBe false
fun `models without ear detection match intentional matrix`() {
modelsWithout { it.hasEarDetection } shouldBe setOf(
PodModel.BEATS_FLEX,
PodModel.BEATS_SOLO_3,
PodModel.BEATS_SOLO_PRO,
PodModel.BEATS_SOLO_4,
PodModel.BEATS_SOLO_BUDS,
PodModel.BEATS_STUDIO_3,
PodModel.BEATS_STUDIO_BUDS,
PodModel.BEATS_STUDIO_BUDS_PLUS,
PodModel.BEATS_STUDIO_PRO,
PodModel.BEATS_X,
PodModel.POWERBEATS_3,
PodModel.POWERBEATS_4,
PodModel.UNKNOWN,
)
}
@Test
fun `UNKNOWN model has no features`() {
val f = PodModel.UNKNOWN.features
f.hasDualPods shouldBe false
f.hasCase shouldBe false
f.hasEarDetection shouldBe false
f.hasAncControl shouldBe false
PodModel.UNKNOWN.features shouldBe PodModel.Features()
}
@Test
fun `all ANC-capable dual-pod models have ear detection except Studio Buds`() {
val noEarDetectionAncModels = setOf(
PodModel.BEATS_STUDIO_BUDS,
PodModel.BEATS_STUDIO_BUDS_PLUS,
)
fun `ear detection toggle implies ear detection`() {
PodModel.entries
.filter { it.features.hasAncControl && it.features.hasDualPods }
.filter { it !in noEarDetectionAncModels }
.filter { it.features.hasEarDetectionToggle }
.forEach { model ->
model.features.hasEarDetection shouldBe true
withClue(model.name) {
model.features.hasEarDetection shouldBe true
}
}
}
@Test
fun `all models with case also have dual pods`() {
fun `sleep detection implies ear detection`() {
PodModel.entries
.filter { it.features.hasSleepDetection }
.forEach { model ->
withClue(model.name) {
model.features.hasEarDetection shouldBe true
}
}
}
@Test
fun `case implies dual pods`() {
PodModel.entries
.filter { it.features.hasCase }
.forEach { model ->
model.features.hasDualPods shouldBe true
withClue(model.name) {
model.features.hasDualPods shouldBe true
}
}
}
}
@Test
fun `adaptive ANC implies ANC control`() {
PodModel.entries
.filter { it.features.hasAdaptiveAnc }
.forEach { model ->
withClue(model.name) {
model.features.hasAncControl shouldBe true
}
}
}
@Test
fun `adaptive audio noise implies adaptive ANC`() {
PodModel.entries
.filter { it.features.hasAdaptiveAudioNoise }
.forEach { model ->
withClue(model.name) {
model.features.hasAdaptiveAnc shouldBe true
}
}
}
@Test
fun `listening mode cycle implies ANC control`() {
PodModel.entries
.filter { it.features.hasListeningModeCycle }
.forEach { model ->
withClue(model.name) {
model.features.hasAncControl shouldBe true
}
}
}
@Test
fun `allow off option implies listening mode cycle`() {
PodModel.entries
.filter { it.features.hasAllowOffOption }
.forEach { model ->
withClue(model.name) {
model.features.hasListeningModeCycle shouldBe true
}
}
}
private fun modelsWith(predicate: (PodModel.Features) -> Boolean): Set<PodModel> = PodModel.entries
.filter { predicate(it.features) }
.toSet()
private fun modelsWithout(predicate: (PodModel.Features) -> Boolean): Set<PodModel> = PodModel.entries
.filterNot { predicate(it.features) }
.toSet()
private data class FeatureExpectation(
val name: String,
val predicate: (PodModel.Features) -> Boolean,
val models: Set<PodModel>,
)
private fun feature(
name: String,
predicate: (PodModel.Features) -> Boolean,
models: Set<PodModel>,
) = FeatureExpectation(name, predicate, models)
private val dualPodModels = setOf(
PodModel.AIRPODS_GEN1,
PodModel.AIRPODS_GEN2,
PodModel.AIRPODS_GEN3,
PodModel.AIRPODS_GEN4,
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.BEATS_SOLO_BUDS,
PodModel.BEATS_STUDIO_BUDS,
PodModel.BEATS_STUDIO_BUDS_PLUS,
PodModel.POWERBEATS_PRO,
PodModel.POWERBEATS_PRO2,
PodModel.BEATS_FIT_PRO,
PodModel.FAKE_AIRPODS_GEN1,
PodModel.FAKE_AIRPODS_GEN2,
PodModel.FAKE_AIRPODS_GEN3,
PodModel.FAKE_AIRPODS_PRO,
PodModel.FAKE_AIRPODS_PRO2,
)
private val earDetectionModels = setOf(
PodModel.AIRPODS_GEN1,
PodModel.AIRPODS_GEN2,
PodModel.AIRPODS_GEN3,
PodModel.AIRPODS_GEN4,
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX,
PodModel.AIRPODS_MAX_USBC,
PodModel.AIRPODS_MAX2,
PodModel.POWERBEATS_PRO,
PodModel.POWERBEATS_PRO2,
PodModel.BEATS_FIT_PRO,
PodModel.FAKE_AIRPODS_GEN1,
PodModel.FAKE_AIRPODS_GEN2,
PodModel.FAKE_AIRPODS_GEN3,
PodModel.FAKE_AIRPODS_PRO,
PodModel.FAKE_AIRPODS_PRO2,
)
private val ancControlModels = setOf(
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX,
PodModel.AIRPODS_MAX_USBC,
PodModel.AIRPODS_MAX2,
PodModel.BEATS_SOLO_PRO,
PodModel.BEATS_STUDIO_3,
PodModel.BEATS_STUDIO_BUDS,
PodModel.BEATS_STUDIO_BUDS_PLUS,
PodModel.BEATS_STUDIO_PRO,
PodModel.POWERBEATS_PRO2,
PodModel.BEATS_FIT_PRO,
PodModel.FAKE_AIRPODS_PRO,
PodModel.FAKE_AIRPODS_PRO2,
)
private val adaptiveAncModels = setOf(
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX2,
)
private val conversationAwarenessModels = setOf(
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX2,
)
private val ncOneAirpodModels = setOf(
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
)
private val pressSpeedModels = setOf(
PodModel.AIRPODS_GEN3,
PodModel.AIRPODS_GEN4,
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX,
PodModel.AIRPODS_MAX_USBC,
PodModel.AIRPODS_MAX2,
)
private val volumeSwipeModels = setOf(
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
)
private val personalizedVolumeModels = setOf(
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX2,
)
private val toneVolumeModels = setOf(
PodModel.AIRPODS_GEN3,
PodModel.AIRPODS_GEN4,
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX,
PodModel.AIRPODS_MAX_USBC,
PodModel.AIRPODS_MAX2,
)
private val endCallMuteMicModels = setOf(
PodModel.AIRPODS_GEN3,
PodModel.AIRPODS_GEN4,
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
)
private val adaptiveAudioNoiseModels = setOf(
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX2,
)
private val microphoneModeModels = setOf(
PodModel.AIRPODS_GEN1,
PodModel.AIRPODS_GEN2,
PodModel.AIRPODS_GEN3,
PodModel.AIRPODS_GEN4,
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.POWERBEATS_PRO2,
PodModel.BEATS_FIT_PRO,
)
private val earDetectionToggleModels = setOf(
PodModel.AIRPODS_GEN1,
PodModel.AIRPODS_GEN2,
PodModel.AIRPODS_GEN3,
PodModel.AIRPODS_GEN4,
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX,
PodModel.AIRPODS_MAX_USBC,
PodModel.AIRPODS_MAX2,
PodModel.POWERBEATS_PRO,
PodModel.POWERBEATS_PRO2,
PodModel.BEATS_FIT_PRO,
)
private val listeningModeCycleModels = setOf(
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.AIRPODS_MAX,
PodModel.AIRPODS_MAX_USBC,
PodModel.AIRPODS_MAX2,
)
private val stemConfigModels = setOf(
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
)
private val sleepDetectionModels = setOf(
PodModel.AIRPODS_GEN4,
PodModel.AIRPODS_GEN4_ANC,
PodModel.AIRPODS_PRO2,
PodModel.AIRPODS_PRO2_USBC,
PodModel.AIRPODS_PRO3,
PodModel.POWERBEATS_PRO2,
)
private val featureExpectations = listOf(
feature("hasDualPods", { it.hasDualPods }, dualPodModels),
feature("hasCase", { it.hasCase }, dualPodModels),
feature("hasEarDetection", { it.hasEarDetection }, earDetectionModels),
feature("hasAncControl", { it.hasAncControl }, ancControlModels),
feature("hasAdaptiveAnc", { it.hasAdaptiveAnc }, adaptiveAncModels),
feature("hasConversationAwareness", { it.hasConversationAwareness }, conversationAwarenessModels),
feature("hasNcOneAirpod", { it.hasNcOneAirpod }, ncOneAirpodModels),
feature("hasPressSpeed", { it.hasPressSpeed }, pressSpeedModels),
feature("hasPressHoldDuration", { it.hasPressHoldDuration }, pressSpeedModels),
feature("hasVolumeSwipe", { it.hasVolumeSwipe }, volumeSwipeModels),
feature("hasVolumeSwipeLength", { it.hasVolumeSwipeLength }, volumeSwipeModels),
feature("hasPersonalizedVolume", { it.hasPersonalizedVolume }, personalizedVolumeModels),
feature("hasToneVolume", { it.hasToneVolume }, toneVolumeModels),
feature("hasEndCallMuteMic", { it.hasEndCallMuteMic }, endCallMuteMicModels),
feature("hasAdaptiveAudioNoise", { it.hasAdaptiveAudioNoise }, adaptiveAudioNoiseModels),
feature("hasMicrophoneMode", { it.hasMicrophoneMode }, microphoneModeModels),
feature("hasEarDetectionToggle", { it.hasEarDetectionToggle }, earDetectionToggleModels),
feature("hasListeningModeCycle", { it.hasListeningModeCycle }, listeningModeCycleModels),
feature("hasAllowOffOption", { it.hasAllowOffOption }, listeningModeCycleModels),
feature("hasStemConfig", { it.hasStemConfig }, stemConfigModels),
feature("hasSleepDetection", { it.hasSleepDetection }, sleepDetectionModels),
feature("hasDynamicEndOfCharge", { it.hasDynamicEndOfCharge }, setOf(PodModel.AIRPODS_PRO3)),
)
}
@@ -337,6 +337,7 @@ class DefaultAapDeviceProfileNewSettingsTest : BaseAapSessionTest() {
f.hasPressSpeed shouldBe true
f.hasPressHoldDuration shouldBe true
f.hasToneVolume shouldBe true
f.hasEndCallMuteMic shouldBe true
f.hasListeningModeCycle shouldBe false
f.hasStemConfig shouldBe false
f.hasSleepDetection shouldBe true
@@ -351,14 +352,31 @@ class DefaultAapDeviceProfileNewSettingsTest : BaseAapSessionTest() {
f.hasStemConfig shouldBe false
}
@Test fun `Gen 1 has mic mode only`() {
@Test fun `Gen 1 has mic mode and ear detection toggle`() {
val f = PodModel.AIRPODS_GEN1.features
f.hasMicrophoneMode shouldBe true
f.hasEarDetectionToggle shouldBe false
f.hasEarDetection shouldBe true
f.hasEarDetectionToggle shouldBe true
f.hasListeningModeCycle shouldBe false
f.hasStemConfig shouldBe false
}
@Test fun `Gen 2 and Gen 3 expose ear detection toggle`() {
val gen2 = PodModel.AIRPODS_GEN2.features
gen2.hasMicrophoneMode shouldBe true
gen2.hasEarDetection shouldBe true
gen2.hasEarDetectionToggle shouldBe true
val gen3 = PodModel.AIRPODS_GEN3.features
gen3.hasMicrophoneMode shouldBe true
gen3.hasEarDetection shouldBe true
gen3.hasEarDetectionToggle shouldBe true
gen3.hasPressSpeed shouldBe true
gen3.hasPressHoldDuration shouldBe true
gen3.hasToneVolume shouldBe true
gen3.hasEndCallMuteMic shouldBe true
}
@Test fun `Max 2 has H2 features`() {
val f = PodModel.AIRPODS_MAX2.features
f.hasAdaptiveAnc shouldBe true
@@ -382,6 +400,18 @@ class DefaultAapDeviceProfileNewSettingsTest : BaseAapSessionTest() {
f.hasEarDetectionToggle shouldBe true
f.hasAncControl shouldBe true
f.hasEarDetection shouldBe true
f.hasMicrophoneMode shouldBe true
}
@Test fun `Powerbeats Pro and Beats Fit Pro expose ear detection toggle`() {
val powerbeatsPro = PodModel.POWERBEATS_PRO.features
powerbeatsPro.hasEarDetection shouldBe true
powerbeatsPro.hasEarDetectionToggle shouldBe true
val beatsFitPro = PodModel.BEATS_FIT_PRO.features
beatsFitPro.hasEarDetection shouldBe true
beatsFitPro.hasEarDetectionToggle shouldBe true
beatsFitPro.hasMicrophoneMode shouldBe true
}
}
@@ -413,6 +443,14 @@ class DefaultAapDeviceProfileNewSettingsTest : BaseAapSessionTest() {
}
}
@Test fun `earDetectionToggle implies earDetection`() {
for (model in PodModel.entries) {
if (model.features.hasEarDetectionToggle) {
model.features.hasEarDetection shouldBe true
}
}
}
@Test fun `adaptiveAudioNoise implies adaptiveAnc`() {
for (model in PodModel.entries) {
if (model.features.hasAdaptiveAudioNoise) {