From d4a355c356390936ecccd60fe48d17014751cab4 Mon Sep 17 00:00:00 2001 From: darken Date: Tue, 14 Apr 2026 08:04:42 +0200 Subject: [PATCH] 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. --- .../reaction/core/playpause/PlayPause.kt | 64 ++++++++-- .../capod/monitor/core/PodDeviceTest.kt | 73 ++++++++++++ .../core/playpause/PlayPauseLogicTest.kt | 112 ++++++++++++++++++ 3 files changed, 240 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/eu/darken/capod/reaction/core/playpause/PlayPause.kt b/app/src/main/java/eu/darken/capod/reaction/core/playpause/PlayPause.kt index 330ee7c5..239cd70a 100644 --- a/app/src/main/java/eu/darken/capod/reaction/core/playpause/PlayPause.kt +++ b/app/src/main/java/eu/darken/capod/reaction/core/playpause/PlayPause.kt @@ -53,7 +53,16 @@ class PlayPause @Inject constructor( } } // Cache persistence can update battery timestamps without changing any reaction-relevant state. + .onEach { device -> + log(TAG, VERBOSE) { + val key = device?.toPlayPauseMonitorKey() + "Pre-distinct: key=$key" + } + } .distinctUntilChangedBy { it?.toPlayPauseMonitorKey() } + .onEach { device -> + log(TAG, VERBOSE) { "Post-distinct: profileId=${device?.profileId}" } + } .withPrevious() .filter { (previous, current) -> if (previous == null || current == null) return@filter false @@ -81,17 +90,16 @@ class PlayPause @Inject constructor( previous!!.hasEarDetection && previous.hasDualPods && current.hasEarDetection && current.hasDualPods -> { // Dual pod devices (AirPods, AirPods Pro, etc.) + // Per-side values may be null when AAP ear detection is present but + // resolvedPrimaryPod is unknown (cmd 0x0008 not received or cleared + // by role swap). Fall back to aggregate AAP state in that case. + prevState = previous.toEarDetectionState() + currState = current.toEarDetectionState() log(TAG, VERBOSE) { - "Dual-pod device: left=${current.isLeftInEar}, right=${current.isRightInEar}" + "Dual-pod device: left=${current.isLeftInEar}, right=${current.isRightInEar}, " + + "worn=${current.isBeingWorn}, either=${current.isEitherPodInEar}, " + + "aapEar=${current.aap?.aapEarDetection != null}, state=$currState" } - prevState = EarDetectionState.fromDualPod( - left = previous.isLeftInEar ?: false, - right = previous.isRightInEar ?: false, - ) - currState = EarDetectionState.fromDualPod( - left = current.isLeftInEar ?: false, - right = current.isRightInEar ?: false, - ) } previous.hasEarDetection && current.hasEarDetection -> { @@ -342,6 +350,24 @@ class PlayPause @Inject constructor( rightInEar = null, isWorn = worn ) + + /** + * Derives ear detection state from AAP aggregate values when per-side + * (left/right) mapping is unavailable (e.g. resolvedPrimaryPod is null). + * + * Uses [isBeingWorn] (both pods in ear) and [isEitherPodInEar] (at least + * one pod in ear) to reconstruct a pod count suitable for both normal mode + * (bothInEar triggers play) and one-pod mode (podCount delta triggers play/pause). + * + * The left/right assignment is synthetic but deterministic — transitions are + * always detected. UI-facing code should use [PodDevice.isLeftInEar] / + * [PodDevice.isRightInEar] which stay null when side mapping is unknown. + */ + fun fromAapAggregate(isBeingWorn: Boolean, isEitherPodInEar: Boolean) = when { + isBeingWorn -> EarDetectionState(leftInEar = true, rightInEar = true, isWorn = true) + isEitherPodInEar -> EarDetectionState(leftInEar = true, rightInEar = false, isWorn = false) + else -> EarDetectionState(leftInEar = false, rightInEar = false, isWorn = false) + } } } @@ -376,6 +402,7 @@ class PlayPause @Inject constructor( val leftInEar: Boolean?, val rightInEar: Boolean?, val isBeingWorn: Boolean?, + val isEitherPodInEar: Boolean?, val hasAapEarDetection: Boolean, val hasBleSnapshot: Boolean, ) @@ -391,10 +418,29 @@ class PlayPause @Inject constructor( leftInEar = isLeftInEar, rightInEar = isRightInEar, isBeingWorn = isBeingWorn, + isEitherPodInEar = isEitherPodInEar, hasAapEarDetection = aap?.aapEarDetection != null, hasBleSnapshot = ble != null, ) + /** + * Converts a dual-pod [PodDevice] to [EarDetectionState] for reaction evaluation. + * Prefers per-side values (left/right) when available, falls back to AAP aggregate + * state (isBeingWorn/isEitherPodInEar) when per-side mapping is unknown. + */ + private fun PodDevice.toEarDetectionState(): EarDetectionState { + val left = isLeftInEar + val right = isRightInEar + if (left != null && right != null) { + return EarDetectionState.fromDualPod(left = left, right = right) + } + // Per-side unavailable (resolvedPrimaryPod is null) — use aggregate AAP state. + return EarDetectionState.fromAapAggregate( + isBeingWorn = isBeingWorn ?: false, + isEitherPodInEar = isEitherPodInEar ?: false, + ) + } + companion object { private val TAG = logTag("Reaction", "PlayPause") } diff --git a/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt index b1a1c382..5b2d64f7 100644 --- a/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt +++ b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt @@ -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(relaxed = true) { diff --git a/app/src/test/java/eu/darken/capod/reaction/core/playpause/PlayPauseLogicTest.kt b/app/src/test/java/eu/darken/capod/reaction/core/playpause/PlayPauseLogicTest.kt index 7d1fd6a7..fcdb740e 100644 --- a/app/src/test/java/eu/darken/capod/reaction/core/playpause/PlayPauseLogicTest.kt +++ b/app/src/test/java/eu/darken/capod/reaction/core/playpause/PlayPauseLogicTest.kt @@ -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 {