mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-16 19:26:12 -04:00
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:
@@ -53,7 +53,16 @@ class PlayPause @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Cache persistence can update battery timestamps without changing any reaction-relevant state.
|
// 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() }
|
.distinctUntilChangedBy { it?.toPlayPauseMonitorKey() }
|
||||||
|
.onEach { device ->
|
||||||
|
log(TAG, VERBOSE) { "Post-distinct: profileId=${device?.profileId}" }
|
||||||
|
}
|
||||||
.withPrevious()
|
.withPrevious()
|
||||||
.filter { (previous, current) ->
|
.filter { (previous, current) ->
|
||||||
if (previous == null || current == null) return@filter false
|
if (previous == null || current == null) return@filter false
|
||||||
@@ -81,17 +90,16 @@ class PlayPause @Inject constructor(
|
|||||||
previous!!.hasEarDetection && previous.hasDualPods &&
|
previous!!.hasEarDetection && previous.hasDualPods &&
|
||||||
current.hasEarDetection && current.hasDualPods -> {
|
current.hasEarDetection && current.hasDualPods -> {
|
||||||
// Dual pod devices (AirPods, AirPods Pro, etc.)
|
// 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) {
|
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 -> {
|
previous.hasEarDetection && current.hasEarDetection -> {
|
||||||
@@ -342,6 +350,24 @@ class PlayPause @Inject constructor(
|
|||||||
rightInEar = null,
|
rightInEar = null,
|
||||||
isWorn = worn
|
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 leftInEar: Boolean?,
|
||||||
val rightInEar: Boolean?,
|
val rightInEar: Boolean?,
|
||||||
val isBeingWorn: Boolean?,
|
val isBeingWorn: Boolean?,
|
||||||
|
val isEitherPodInEar: Boolean?,
|
||||||
val hasAapEarDetection: Boolean,
|
val hasAapEarDetection: Boolean,
|
||||||
val hasBleSnapshot: Boolean,
|
val hasBleSnapshot: Boolean,
|
||||||
)
|
)
|
||||||
@@ -391,10 +418,29 @@ class PlayPause @Inject constructor(
|
|||||||
leftInEar = isLeftInEar,
|
leftInEar = isLeftInEar,
|
||||||
rightInEar = isRightInEar,
|
rightInEar = isRightInEar,
|
||||||
isBeingWorn = isBeingWorn,
|
isBeingWorn = isBeingWorn,
|
||||||
|
isEitherPodInEar = isEitherPodInEar,
|
||||||
hasAapEarDetection = aap?.aapEarDetection != null,
|
hasAapEarDetection = aap?.aapEarDetection != null,
|
||||||
hasBleSnapshot = ble != 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 {
|
companion object {
|
||||||
private val TAG = logTag("Reaction", "PlayPause")
|
private val TAG = logTag("Reaction", "PlayPause")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -810,6 +810,79 @@ class PodDeviceTest : BaseTest() {
|
|||||||
device.rssiQuality shouldBe 1.0f
|
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
|
@Test
|
||||||
fun `rssiQuality - BLE value wins over AAP READY`() {
|
fun `rssiQuality - BLE value wins over AAP READY`() {
|
||||||
val ble = mockk<DualApplePods>(relaxed = true) {
|
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
|
@Nested
|
||||||
inner class EarDetectionStateTests {
|
inner class EarDetectionStateTests {
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user