mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-15 02:36:12 -04:00
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:
committed by
Matthias Urhahn
parent
81e2bc1dba
commit
4bab7c61a9
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user