fix(reaction): Stop case popup flicker when one AirPod is out of case

When one pod is out of the case, the out-of-case pod broadcasts bit4-only
frames whose lid byte is stale and decodes to a phantom OPEN even while the
case is shut, interleaved ~1:1 with the correct in-case-pod (bit6) frames.
The derived lid state flapped OPEN<->CLOSED, so the case-open popup re-popped
~0.5s after closing and sometimes lingered. Verified on AirPods Pro 1 (issue
log) and Pro 3 (live BLE capture).

- Trust the lid bit only from in-case-pod (bit6) or both-in-case (bit2) frames;
  bit4-only frames now decode to UNKNOWN, and the real state is recovered from a
  recent in-case broadcast (matches LibrePods).
- getLatestCaseLidState recovers from history within a 2s age window instead of a
  fixed frame count, so a missed CLOSED can't keep a stale OPEN.
- Don't refresh the show-cooldown on a non-CLOSED hide, so a transient UNKNOWN
  can't suppress a genuine re-open.
- Add a freshness backstop: dismiss the popup if a fresh OPEN broadcast stops
  arriving (device left BLE range while open).
- Key popup/auto-connect de-duplication on the derived lid state, not just raw
  advertisement bytes, since the effective lid can change while bytes don't.

Closes #598
This commit is contained in:
Matthias Urhahn
2026-06-08 08:05:50 +02:00
committed by Matthias Urhahn
parent 81e2bc1dba
commit 4bab7c61a9
6 changed files with 270 additions and 20 deletions
@@ -7,6 +7,7 @@ import eu.darken.capod.pods.core.apple.ble.devices.airpods.HasStateDetectionAirP
import eu.darken.capod.pods.core.apple.ble.history.KnownDevice
import eu.darken.capod.pods.core.apple.ble.protocol.ProximityPayload
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import java.time.Instant
@@ -250,13 +251,34 @@ class DualApplePodsTest : BaseBlePodsTest() {
@Test
fun `test AirPodDevice - case lid uses status derived case context`() {
directAirPodsPro(status = 0x10, rawCaseLidState = 0x51).caseLidState shouldBe DualApplePods.LidState.OPEN
// bit4 only = one pod in case, broadcast by the OUT-of-case pod. Its lid byte is stale and
// decodes to a phantom OPEN even when the case is shut, so it must report UNKNOWN (#598).
directAirPodsPro(status = 0x10, rawCaseLidState = 0x51).caseLidState shouldBe DualApplePods.LidState.UNKNOWN
// bit2 = both pods in case → lid byte is trustworthy.
directAirPodsPro(status = 0x04, rawCaseLidState = 0x5A).caseLidState shouldBe DualApplePods.LidState.CLOSED
// bit6 = this (broadcasting) pod is in the case → lid byte is trustworthy.
directAirPodsPro(status = 0x40, rawCaseLidState = 0x51).caseLidState shouldBe DualApplePods.LidState.OPEN
// No case context at all → NOT_IN_CASE.
directAirPodsPro(status = 0x2B, rawCaseLidState = 0x11).caseLidState shouldBe DualApplePods.LidState.NOT_IN_CASE
directAirPodsPro(status = 0x20, rawCaseLidState = 0x5A).caseLidState shouldBe DualApplePods.LidState.NOT_IN_CASE
}
@Test
fun `case lid - out-of-case pod frame reports UNKNOWN, not a phantom OPEN (issue 598)`() {
// Real captures while the case is physically shut with one pod removed. The in-case pod
// reports CLOSED; the out-of-case (bit4-only) pod carries a stale lid byte that the old
// decoder turned into a phantom OPEN. It must now be UNKNOWN.
// AirPods Pro 3:
directAirPodsPro(status = 0x73, rawCaseLidState = 0x39).caseLidState shouldBe DualApplePods.LidState.CLOSED
directAirPodsPro(status = 0x13, rawCaseLidState = 0x11).caseLidState shouldBe DualApplePods.LidState.UNKNOWN
// AirPods Pro 1:
directAirPodsPro(status = 0x53, rawCaseLidState = 0x39).caseLidState shouldBe DualApplePods.LidState.CLOSED
directAirPodsPro(status = 0x33, rawCaseLidState = 0x02).caseLidState shouldBe DualApplePods.LidState.UNKNOWN
// Both pods in the case (bit2) stays trustworthy even without bit6 (Pro 3 0x15, Pro 1 0x04).
directAirPodsPro(status = 0x15, rawCaseLidState = 0x31).caseLidState shouldBe DualApplePods.LidState.OPEN
directAirPodsPro(status = 0x04, rawCaseLidState = 0x31).caseLidState shouldBe DualApplePods.LidState.OPEN
}
private fun knownDeviceOf(vararg pods: AirPodsPro): KnownDevice {
val id = pods.first().identifier
return KnownDevice(
@@ -315,6 +337,47 @@ class DualApplePodsTest : BaseBlePodsTest() {
result shouldBe DualApplePods.LidState.NOT_IN_CASE
}
@Test
fun `getLatestCaseLidState - phantom out-of-case frame recovers CLOSED from in-case history (issue 598)`() {
val sharedId = BlePodSnapshot.Id()
// In-case pod reports the real, shut lid; out-of-case pod's bit4-only frame is UNKNOWN.
val inCaseClosed = directAirPodsPro(status = 0x73, rawCaseLidState = 0x39).copy(identifier = sharedId)
val outOfCasePhantom = directAirPodsPro(status = 0x13, rawCaseLidState = 0x11).copy(identifier = sharedId)
val known = knownDeviceOf(inCaseClosed, outOfCasePhantom)
val result = with(testFactory) { known.getLatestCaseLidState(outOfCasePhantom) }
// Must recover CLOSED from the in-case broadcast, not surface the phantom OPEN.
result shouldBe DualApplePods.LidState.CLOSED
}
@Test
fun `getLatestCaseLidState - only out-of-case frames never report a phantom OPEN (issue 598)`() {
val sharedId = BlePodSnapshot.Id()
val phantom1 = directAirPodsPro(status = 0x13, rawCaseLidState = 0x11).copy(identifier = sharedId)
val phantom2 = directAirPodsPro(status = 0x13, rawCaseLidState = 0x11).copy(identifier = sharedId)
val known = knownDeviceOf(phantom1, phantom2)
val result = with(testFactory) { known.getLatestCaseLidState(phantom2) }
// No authoritative reading anywhere → fall back to a coarse signal, never a guessed OPEN.
result shouldBe DualApplePods.LidState.NOT_IN_CASE
}
@Test
fun `case lid - first-seen out-of-case frame does not leak a phantom OPEN through the factory (issue 598)`() = runTest {
// Going through the real factory, a bit4-only out-of-case frame must never surface a phantom
// OPEN. The exact non-OPEN value depends on whether the device already has history
// (UNKNOWN when first-seen, NOT_IN_CASE once a single phantom frame is in history) — both are
// acceptable; what matters is that it is never OPEN.
create<DualApplePods>(
hex = "07 19 01 0E 20 13 AA B5 11 00 00 E0 0C A7 8A 60 4B D3 7D F4 60 4F 2C 73 E9 A7 F4",
address = "AA:BB:CC:DD:EE:01",
) {
caseLidState shouldNotBe DualApplePods.LidState.OPEN
}
}
@Test
fun `test AirPodDevice - connection state`() = runTest {
// Disconnected
@@ -1,7 +1,10 @@
package eu.darken.capod.reaction.core.popup
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.ble.devices.DualApplePods
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
@@ -189,4 +192,103 @@ class PopUpReactionLogicTest : BaseTest() {
).shouldShow shouldBe false
}
}
@Nested
inner class CooldownGuardTests {
private val timeSource = TestTimeSource(wallNow = Instant.parse("2026-01-01T00:00:00Z"))
private val reaction = PopUpReaction(
deviceMonitor = mockk(relaxed = true),
bluetoothManager = mockk(relaxed = true),
timeSource = timeSource,
)
@Test
fun `UNKNOWN hide does not refresh the show cooldown (issue 598)`() {
// Show on OPEN → cooldown stamped now.
reaction.throttleCasePopUps(device(DualApplePods.LidState.OPEN))
.shouldBeInstanceOf<PopUpReaction.Event.PopupShow>()
// Long past the 10s cooldown, a transient out-of-case frame hides the popup...
timeSource.advanceBy(Duration.ofSeconds(11))
reaction.throttleCasePopUps(device(DualApplePods.LidState.UNKNOWN))
.shouldBeInstanceOf<PopUpReaction.Event.PopupHide>()
// ...and must NOT have refreshed the cooldown: a genuine OPEN 1s later still shows.
timeSource.advanceBy(Duration.ofSeconds(1))
reaction.throttleCasePopUps(device(DualApplePods.LidState.OPEN))
.shouldBeInstanceOf<PopUpReaction.Event.PopupShow>()
}
@Test
fun `OPEN within cooldown is still throttled`() {
reaction.throttleCasePopUps(device(DualApplePods.LidState.OPEN))
.shouldBeInstanceOf<PopUpReaction.Event.PopupShow>()
timeSource.advanceBy(Duration.ofSeconds(5))
reaction.throttleCasePopUps(device(DualApplePods.LidState.OPEN)) shouldBe null
}
private fun device(lid: DualApplePods.LidState?) = mockDevice(lid = lid)
}
@Nested
inner class StaleCloseTests {
private val timeSource = TestTimeSource(wallNow = Instant.parse("2026-01-01T00:00:00Z"))
private val reaction = PopUpReaction(
deviceMonitor = mockk(relaxed = true),
bluetoothManager = mockk(relaxed = true),
timeSource = timeSource,
)
@Test
fun `fresh OPEN broadcast keeps the popup`() {
reaction.isCaseOpenBroadcastFresh(mockDevice(DualApplePods.LidState.OPEN, lastSeen = timeSource.now())) shouldBe true
}
@Test
fun `OPEN broadcast older than the timeout does not keep the popup`() {
val stale = timeSource.now().minus(Duration.ofSeconds(10))
reaction.isCaseOpenBroadcastFresh(mockDevice(DualApplePods.LidState.OPEN, lastSeen = stale)) shouldBe false
}
@Test
fun `CLOSED lid is not kept open`() {
reaction.isCaseOpenBroadcastFresh(mockDevice(DualApplePods.LidState.CLOSED, lastSeen = timeSource.now())) shouldBe false
}
@Test
fun `null lid (dropped to cache, out of range) is not kept open`() {
reaction.isCaseOpenBroadcastFresh(mockDevice(lid = null, lastSeen = timeSource.now())) shouldBe false
}
@Test
fun `ineligible device is not kept open`() {
reaction.isCaseOpenBroadcastFresh(
mockDevice(DualApplePods.LidState.OPEN, eligible = false, lastSeen = timeSource.now())
) shouldBe false
}
@Test
fun `null device is not kept open`() {
reaction.isCaseOpenBroadcastFresh(null) shouldBe false
}
}
private fun mockDevice(
lid: DualApplePods.LidState?,
eligible: Boolean = true,
lastSeen: Instant = Instant.parse("2026-01-01T00:00:00Z"),
profile: String? = "profile-1",
): PodDevice = mockk(relaxed = true) {
every { caseLidState } returns lid
every { reactions } returns mockk(relaxed = true) {
every { showPopUpOnCaseOpen } returns eligible
}
// isCaseOpenBroadcastFresh reads BLE freshness via device.ble?.seenLastAt.
every { ble } returns mockk(relaxed = true) {
every { seenLastAt } returns lastSeen
}
every { profileId } returns profile
}
}