diff --git a/app/src/main/java/eu/darken/capod/reaction/core/conversation/ConversationReaction.kt b/app/src/main/java/eu/darken/capod/reaction/core/conversation/ConversationReaction.kt index 0fff7b50..e8178e4b 100644 --- a/app/src/main/java/eu/darken/capod/reaction/core/conversation/ConversationReaction.kt +++ b/app/src/main/java/eu/darken/capod/reaction/core/conversation/ConversationReaction.kt @@ -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() + private val earTransitionAt = mutableMapOf() + fun monitor(): Flow = 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) = mutex.withLock { + private suspend fun onStatesUpdated(states: Map) = 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 } } diff --git a/app/src/test/java/eu/darken/capod/reaction/core/conversation/ConversationReactionTest.kt b/app/src/test/java/eu/darken/capod/reaction/core/conversation/ConversationReactionTest.kt index 1cda15f7..bba2de61 100644 --- a/app/src/test/java/eu/darken/capod/reaction/core/conversation/ConversationReactionTest.kt +++ b/app/src/test/java/eu/darken/capod/reaction/core/conversation/ConversationReactionTest.kt @@ -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))