mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
fix(reaction): Ignore CA terminals from pod removal/insertion re-key
This commit is contained in:
+57
-5
@@ -12,6 +12,8 @@ import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.primaryDevice
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.ConversationAwarenessEvent
|
||||
import eu.darken.capod.profiles.core.ReactionConfig
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -58,6 +60,12 @@ import kotlin.time.Duration.Companion.seconds
|
||||
* armed wind-down fuse and re-arms the long backstop, so media stays paused/ducked through the whole
|
||||
* conversation. (Treating `5` as a stop was the premature-resume + stuck bug.)
|
||||
*
|
||||
* Removing/inserting a pod makes the firmware re-key CA — it emits a terminal then a fresh onset
|
||||
* around the physical change even though the conversation continues. A terminal arriving within
|
||||
* [EAR_TRANSITION_WINDOW] of an AAP ear-detection change is therefore treated as a re-key artifact:
|
||||
* it defers to the wind-down fuse instead of disengaging, so a brief pod removal mid-conversation
|
||||
* neither strands a pause nor causes a duck→restore→re-duck volume blip.
|
||||
*
|
||||
* State is a single global slot (media volume / playback is system-wide, not per-device) guarded by
|
||||
* a [Mutex] — events, AAP-state-removal, the stale timer, and monitor completion all mutate it.
|
||||
* Volume ducks are additionally reverted on owner-disconnect and monitor completion so a dropped
|
||||
@@ -89,11 +97,17 @@ class ConversationReaction @Inject constructor(
|
||||
private var staleJob: Job? = null
|
||||
private var idCounter = 0L
|
||||
|
||||
// Per-owner AAP ear-detection tracking, so a CA terminal that lands right after a pod
|
||||
// removal/insertion can be recognised as a re-key artifact rather than a real conversation end.
|
||||
private val lastEarDetection = mutableMapOf<BluetoothAddress, AapSetting.EarDetection?>()
|
||||
private val earTransitionAt = mutableMapOf<BluetoothAddress, Long>()
|
||||
|
||||
fun monitor(): Flow<Unit> = merge(
|
||||
aapManager.conversationalAwarenessEvents.onEach { (address, event) -> onEvent(address, event) },
|
||||
// Reverts a stranded duck when the owning device leaves the AAP state map for any reason
|
||||
// (intentional or not) — disconnectEvents only fires for unintentional drops.
|
||||
aapManager.allStates.onEach { states -> onActiveDevicesChanged(states.keys) },
|
||||
// Tracks ear-detection transitions (for terminal-artifact suppression) and reverts a stranded
|
||||
// duck when the owning device leaves the AAP state map for any reason (intentional or not) —
|
||||
// disconnectEvents only fires for unintentional drops.
|
||||
aapManager.allStates.onEach { states -> onStatesUpdated(states) },
|
||||
)
|
||||
.map { }
|
||||
// Service stop / scope cancellation: undo any active duck so we don't leave volume lowered.
|
||||
@@ -204,6 +218,17 @@ class ConversationReaction @Inject constructor(
|
||||
log(TAG) { "STOP from $address ignored — owner is ${current.owner}" }
|
||||
return
|
||||
}
|
||||
// Pulling/inserting a pod makes the firmware tear down and re-key CA — it emits a terminal
|
||||
// (8,9) then a fresh onset (1,2) around the physical change, even though the conversation
|
||||
// is still going. So a terminal that lands right after an ear-detection transition is not
|
||||
// a real end: defer to the short wind-down fuse instead of disengaging. A genuine re-onset
|
||||
// cancels it (no resume/restore churn — fixes the PAUSE strand and the LOWER_VOLUME blip on
|
||||
// removal); if the conversation really ended, the fuse disengages within seconds.
|
||||
if (isRecentEarTransition(address)) {
|
||||
armDisengageTimer(current, WIND_DOWN_TIMEOUT)
|
||||
log(TAG) { "STOP from $address during ear transition — deferring to fuse, not disengaging" }
|
||||
return@withLock
|
||||
}
|
||||
clearActive()
|
||||
// An explicit terminal frame is positive evidence CA ended now → resume regardless of how
|
||||
// long ago we engaged. The age guard is only for inferred (stale-backstop) ends.
|
||||
@@ -245,9 +270,22 @@ class ConversationReaction @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun onActiveDevicesChanged(addresses: Set<BluetoothAddress>) = mutex.withLock {
|
||||
private suspend fun onStatesUpdated(states: Map<BluetoothAddress, AapPodState>) = mutex.withLock {
|
||||
// Record when each device's AAP ear-detection last changed (pod removed/inserted/cased).
|
||||
// The very first observation of a device only seeds the baseline — it is not a transition.
|
||||
val now = timeSource.elapsedRealtime()
|
||||
for ((address, state) in states) {
|
||||
val ear = state.aapEarDetection
|
||||
if (lastEarDetection.containsKey(address) && lastEarDetection[address] != ear) {
|
||||
earTransitionAt[address] = now
|
||||
}
|
||||
lastEarDetection[address] = ear
|
||||
}
|
||||
lastEarDetection.keys.retainAll(states.keys)
|
||||
earTransitionAt.keys.retainAll(states.keys)
|
||||
|
||||
val current = active ?: return
|
||||
if (current.owner !in addresses) {
|
||||
if (current.owner !in states.keys) {
|
||||
clearActive()
|
||||
revert(current, "owner ${current.owner} gone")
|
||||
}
|
||||
@@ -310,6 +348,12 @@ class ConversationReaction @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Must be called under [mutex]. True if [address] had an AAP ear-detection change very recently. */
|
||||
private fun isRecentEarTransition(address: BluetoothAddress): Boolean {
|
||||
val at = earTransitionAt[address] ?: return false
|
||||
return (timeSource.elapsedRealtime() - at).milliseconds <= EAR_TRANSITION_WINDOW
|
||||
}
|
||||
|
||||
private fun nextId(): Long = ++idCounter
|
||||
|
||||
companion object {
|
||||
@@ -343,5 +387,13 @@ class ConversationReaction @Inject constructor(
|
||||
* resumes regardless of age — those carry real/recent evidence the conversation just ended.
|
||||
*/
|
||||
private val PAUSE_RESUME_WINDOW = 2.minutes
|
||||
|
||||
/**
|
||||
* A CA terminal landing within this window of an AAP ear-detection change is treated as a pod
|
||||
* removal/insertion re-key artifact (firmware emits 8,9 then 1,2 around the physical change),
|
||||
* not a real end. The artifact terminal arrives ~40ms after the ear change; a real end is
|
||||
* seconds away. Kept well under that gap so genuine terminals aren't deferred to the fuse.
|
||||
*/
|
||||
private val EAR_TRANSITION_WINDOW = 2.seconds
|
||||
}
|
||||
}
|
||||
|
||||
+82
@@ -6,6 +6,7 @@ import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
|
||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.ConversationAwarenessEvent
|
||||
import eu.darken.capod.profiles.core.ReactionConfig
|
||||
import io.mockk.coEvery
|
||||
@@ -31,6 +32,9 @@ class ConversationReactionTest : BaseTest() {
|
||||
private val primaryAddress: BluetoothAddress = "AA:BB:CC:DD:EE:FF"
|
||||
private val otherAddress: BluetoothAddress = "11:22:33:44:55:66"
|
||||
|
||||
private val inEar = AapSetting.EarDetection.PodPlacement.IN_EAR
|
||||
private val outOfEar = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR
|
||||
|
||||
// Mirrors of ConversationReaction's private timing constants. STALE_TIMEOUT: long backstop
|
||||
// while only START frames were seen. WIND_DOWN_TIMEOUT: short fuse armed by a HOLD frame
|
||||
// (wind-down begun, terminal imminent — but sometimes dropped, #608).
|
||||
@@ -95,6 +99,16 @@ class ConversationReactionTest : BaseTest() {
|
||||
runCurrent()
|
||||
}
|
||||
|
||||
/** Simulates an AAP ear-detection transition (pod removed/inserted) for the primary device. */
|
||||
private fun TestScope.changeEarDetection(primary: AapSetting.EarDetection.PodPlacement, secondary: AapSetting.EarDetection.PodPlacement) {
|
||||
statesFlow.value = mapOf(
|
||||
primaryAddress to mockk(relaxed = true) {
|
||||
every { aapEarDetection } returns AapSetting.EarDetection(primary, secondary)
|
||||
},
|
||||
)
|
||||
runCurrent()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `LOWER_VOLUME start ducks, stop restores`() = runTest(UnconfinedTestDispatcher()) {
|
||||
val job = launchReaction()
|
||||
@@ -540,6 +554,74 @@ class ConversationReactionTest : BaseTest() {
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PAUSE pod removal mid-conversation does not strand media paused`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
// Pulling a pod re-keys CA (terminal then fresh onset) while the conversation continues.
|
||||
// The terminal lands right after the ear-detection change → defer to the fuse, the re-onset
|
||||
// cancels it, media stays paused and the session alive. The real end later resumes.
|
||||
devicesFlow.value = listOf(mockPodDevice(primaryAddress, ConversationAction.PAUSE))
|
||||
val job = launchReaction()
|
||||
|
||||
emit(primaryAddress, ConversationAwarenessEvent.START)
|
||||
coVerify(exactly = 1) { mediaControl.sendPause(false) }
|
||||
|
||||
changeEarDetection(inEar, outOfEar) // pod pulled
|
||||
emit(primaryAddress, ConversationAwarenessEvent.STOP) // re-key terminal
|
||||
emit(primaryAddress, ConversationAwarenessEvent.START) // re-onset, still talking
|
||||
advanceTimeBy(windDownTimeoutMs * 2) // fuse would have fired — but was cancelled
|
||||
runCurrent()
|
||||
coVerify(exactly = 0) { mediaControl.sendPlay() } // still paused, not stranded-resumed early
|
||||
|
||||
timeSource.advanceBy(java.time.Duration.ofSeconds(3)) // past EAR_TRANSITION_WINDOW
|
||||
advanceTimeBy(3_000)
|
||||
runCurrent()
|
||||
emit(primaryAddress, ConversationAwarenessEvent.STOP) // genuine end, no recent ear change
|
||||
coVerify(exactly = 1) { mediaControl.sendPlay() } // resumes
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `LOWER_VOLUME pod removal mid-conversation does not blip the volume`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
// The re-key terminal must not restore (which the immediate re-onset would then re-duck —
|
||||
// an audible 5→10→5 jump). Defer to the fuse; the re-onset keeps it ducked.
|
||||
val job = launchReaction() // default LOWER_VOLUME
|
||||
|
||||
emit(primaryAddress, ConversationAwarenessEvent.START)
|
||||
verify(exactly = 1) { mediaControl.duckMusicVolume(any()) }
|
||||
|
||||
changeEarDetection(inEar, outOfEar) // pod pulled
|
||||
emit(primaryAddress, ConversationAwarenessEvent.STOP) // re-key terminal
|
||||
verify(exactly = 0) { mediaControl.restoreMusicVolume(any()) } // no restore → no blip
|
||||
|
||||
emit(primaryAddress, ConversationAwarenessEvent.START) // re-onset
|
||||
verify(exactly = 1) { mediaControl.duckMusicVolume(any()) } // not re-ducked (keep-alive only)
|
||||
verify(exactly = 0) { mediaControl.restoreMusicVolume(any()) }
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `terminal during ear transition still disengages via the fuse if no re-onset follows`() =
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
// Removed a pod AND the conversation actually ended (no re-onset): the deferred terminal's
|
||||
// fuse must still disengage within seconds rather than waiting for the long backstop.
|
||||
devicesFlow.value = listOf(mockPodDevice(primaryAddress, ConversationAction.PAUSE))
|
||||
val job = launchReaction()
|
||||
|
||||
emit(primaryAddress, ConversationAwarenessEvent.START)
|
||||
coVerify(exactly = 1) { mediaControl.sendPause(false) }
|
||||
|
||||
changeEarDetection(inEar, outOfEar)
|
||||
emit(primaryAddress, ConversationAwarenessEvent.STOP) // deferred to fuse
|
||||
coVerify(exactly = 0) { mediaControl.sendPlay() }
|
||||
|
||||
advanceTimeBy(windDownTimeoutMs + 500)
|
||||
runCurrent()
|
||||
coVerify(exactly = 1) { mediaControl.sendPlay() } // fuse disengaged
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `STOP from a non-owner does not disengage the active owner`() = runTest(UnconfinedTestDispatcher()) {
|
||||
devicesFlow.value = listOf(mockPodDevice(primaryAddress, ConversationAction.PAUSE))
|
||||
|
||||
Reference in New Issue
Block a user