fix(reaction): Fix autoplay/autopause for AAP-based ear detection

PlayPause coerced null per-side ear values to false, making all ear states invisible when resolvedPrimaryPod was unknown. Fall back to AAP aggregate state (isBeingWorn/isEitherPodInEar) when per-side mapping is unavailable, keeping PodDevice.isLeftInEar/isRightInEar truthful for UI consumers. Add isEitherPodInEar to PlayPauseMonitorKey for proper dedup. Add diagnostic logging at the distinctUntilChangedBy boundary.
This commit is contained in:
darken
2026-04-14 11:28:41 +02:00
committed by Matthias Urhahn
parent 200e32f67a
commit d4a355c356
3 changed files with 240 additions and 9 deletions
@@ -810,6 +810,79 @@ class PodDeviceTest : BaseTest() {
device.rssiQuality shouldBe 1.0f
}
// --- AAP aggregate ear detection (null per-side) tests ---
@Test
fun `isLeftInEar null when AAP ear detection present but no primaryPod and no BLE`() {
val aap = AapPodState(
connectionState = AapPodState.ConnectionState.READY,
settings = mapOf(
AapSetting.EarDetection::class to AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
),
),
)
val device = PodDevice(profileId = "p1", ble = null, aap = aap, profileModel = PodModel.AIRPODS_PRO3)
// Per-side is null because resolvedPrimaryPod is null (no AAP PrimaryPod, no BLE)
device.isLeftInEar.shouldBeNull()
device.isRightInEar.shouldBeNull()
}
@Test
fun `isBeingWorn works without resolvedPrimaryPod`() {
val aapBothIn = AapPodState(
connectionState = AapPodState.ConnectionState.READY,
settings = mapOf(
AapSetting.EarDetection::class to AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
secondaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
),
),
)
val deviceBothIn = PodDevice(profileId = "p1", ble = null, aap = aapBothIn, profileModel = PodModel.AIRPODS_PRO3)
deviceBothIn.isBeingWorn shouldBe true
val aapOneOut = AapPodState(
connectionState = AapPodState.ConnectionState.READY,
settings = mapOf(
AapSetting.EarDetection::class to AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
secondaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
),
),
)
val deviceOneOut = PodDevice(profileId = "p1", ble = null, aap = aapOneOut, profileModel = PodModel.AIRPODS_PRO3)
deviceOneOut.isBeingWorn shouldBe false
}
@Test
fun `isEitherPodInEar works without resolvedPrimaryPod`() {
val aapOneIn = AapPodState(
connectionState = AapPodState.ConnectionState.READY,
settings = mapOf(
AapSetting.EarDetection::class to AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
secondaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
),
),
)
val device = PodDevice(profileId = "p1", ble = null, aap = aapOneIn, profileModel = PodModel.AIRPODS_PRO3)
device.isEitherPodInEar shouldBe true
val aapNoneIn = AapPodState(
connectionState = AapPodState.ConnectionState.READY,
settings = mapOf(
AapSetting.EarDetection::class to AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
),
),
)
val deviceNone = PodDevice(profileId = "p1", ble = null, aap = aapNoneIn, profileModel = PodModel.AIRPODS_PRO3)
deviceNone.isEitherPodInEar shouldBe false
}
@Test
fun `rssiQuality - BLE value wins over AAP READY`() {
val ble = mockk<DualApplePods>(relaxed = true) {
@@ -610,6 +610,118 @@ class PlayPauseLogicTest : BaseTest() {
}
}
@Nested
inner class AapAggregateTests {
@Test
fun `fromAapAggregate - both in ear`() {
val state = EarDetectionState.fromAapAggregate(isBeingWorn = true, isEitherPodInEar = true)
state.bothInEar shouldBe true
state.eitherInEar shouldBe true
state.podCount shouldBe 2
}
@Test
fun `fromAapAggregate - one in ear`() {
val state = EarDetectionState.fromAapAggregate(isBeingWorn = false, isEitherPodInEar = true)
state.bothInEar shouldBe false
state.eitherInEar shouldBe true
state.podCount shouldBe 1
}
@Test
fun `fromAapAggregate - none in ear`() {
val state = EarDetectionState.fromAapAggregate(isBeingWorn = false, isEitherPodInEar = false)
state.bothInEar shouldBe false
state.eitherInEar shouldBe false
state.podCount shouldBe 0
}
@Test
fun `aggregate normal mode - both to none - should pause`() {
val previous = EarDetectionState.fromAapAggregate(isBeingWorn = true, isEitherPodInEar = true)
val current = EarDetectionState.fromAapAggregate(isBeingWorn = false, isEitherPodInEar = false)
val decision = playPause.evaluatePlayPauseAction(
previous = previous, current = current,
onePodMode = false, isCurrentlyPlaying = true,
)
decision.shouldPause shouldBe true
}
@Test
fun `aggregate normal mode - none to both - should play`() {
val previous = EarDetectionState.fromAapAggregate(isBeingWorn = false, isEitherPodInEar = false)
val current = EarDetectionState.fromAapAggregate(isBeingWorn = true, isEitherPodInEar = true)
val decision = playPause.evaluatePlayPauseAction(
previous = previous, current = current,
onePodMode = false, isCurrentlyPlaying = false,
)
decision.shouldPlay shouldBe true
}
@Test
fun `aggregate normal mode - both to one - should pause`() {
val previous = EarDetectionState.fromAapAggregate(isBeingWorn = true, isEitherPodInEar = true)
val current = EarDetectionState.fromAapAggregate(isBeingWorn = false, isEitherPodInEar = true)
val decision = playPause.evaluatePlayPauseAction(
previous = previous, current = current,
onePodMode = false, isCurrentlyPlaying = true,
)
decision.shouldPause shouldBe true
}
@Test
fun `aggregate one-pod mode - none to one - should play`() {
val previous = EarDetectionState.fromAapAggregate(isBeingWorn = false, isEitherPodInEar = false)
val current = EarDetectionState.fromAapAggregate(isBeingWorn = false, isEitherPodInEar = true)
val decision = playPause.evaluatePlayPauseAction(
previous = previous, current = current,
onePodMode = true, isCurrentlyPlaying = false,
)
decision.shouldPlay shouldBe true
}
@Test
fun `aggregate one-pod mode - one to none - should pause`() {
val previous = EarDetectionState.fromAapAggregate(isBeingWorn = false, isEitherPodInEar = true)
val current = EarDetectionState.fromAapAggregate(isBeingWorn = false, isEitherPodInEar = false)
val decision = playPause.evaluatePlayPauseAction(
previous = previous, current = current,
onePodMode = true, isCurrentlyPlaying = true,
)
decision.shouldPause shouldBe true
}
@Test
fun `aggregate one-pod mode - one to both - should play`() {
val previous = EarDetectionState.fromAapAggregate(isBeingWorn = false, isEitherPodInEar = true)
val current = EarDetectionState.fromAapAggregate(isBeingWorn = true, isEitherPodInEar = true)
val decision = playPause.evaluatePlayPauseAction(
previous = previous, current = current,
onePodMode = true, isCurrentlyPlaying = false,
)
decision.shouldPlay shouldBe true
}
@Test
fun `aggregate one-pod mode - both to one - should pause`() {
val previous = EarDetectionState.fromAapAggregate(isBeingWorn = true, isEitherPodInEar = true)
val current = EarDetectionState.fromAapAggregate(isBeingWorn = false, isEitherPodInEar = true)
val decision = playPause.evaluatePlayPauseAction(
previous = previous, current = current,
onePodMode = true, isCurrentlyPlaying = true,
)
decision.shouldPause shouldBe true
}
}
@Nested
inner class EarDetectionStateTests {