fix(aap): Fix flush ANC debounce, HANDSHAKING→READY regression, and flush ordering

Track lastAncSentAt separately so non-ANC commands during flush don't break the ANC echo debounce window. Prioritize ANC for post-flush verification.

Move HANDSHAKING→READY transition to top of processMessage so decoded battery, stem press, and device info messages also trigger it.

Sort flush: AllowOffOption before AncMode before others, preventing device rejection when enabling OFF mode.
This commit is contained in:
darken
2026-04-16 10:32:21 +02:00
committed by Matthias Urhahn
parent e5821de4dd
commit 4ba59636c0
5 changed files with 501 additions and 21 deletions
@@ -38,8 +38,7 @@ internal class AapConnection(
private val device: BluetoothDevice,
private val profile: AapDeviceProfile,
private val socketFactory: L2capSocketFactory,
private val timeSource: TimeSource,
private val psm: Int = 0x1001,
timeSource: TimeSource,
) {
private val engine = AapSessionEngine(profile, timeSource)
@@ -65,7 +64,7 @@ internal class AapConnection(
engine.start(scope)
try {
val sock = socketFactory.createSocket(device, psm)
val sock = socketFactory.createSocket(device, PSM)
sock.connect()
socket = sock
log(TAG, INFO) { "Connected to ${device.address}" }
@@ -168,6 +167,7 @@ internal class AapConnection(
}
companion object {
private const val PSM = 0x1001
private val TAG = logTag("AAP", "Connection")
}
}
@@ -55,6 +55,8 @@ internal class AapSessionEngine(
private var ancDebounceJob: Job? = null
private var lastSentCommand: AapCommand? = null
private var lastSentAt: Long = 0L
/** Separate tracking for ANC sends — not overwritten by non-ANC commands during flush. */
private var lastAncSentAt: Long = 0L
private var handshakeResponseReceived: Boolean = false
/** Stored reference to the socket write callback — set on each [send] / flush call. */
@@ -82,6 +84,7 @@ internal class AapSessionEngine(
scope = null
lastSentCommand = null
lastSentAt = 0L
lastAncSentAt = 0L
activeSendRaw = null
handshakeResponseReceived = false
_state.value = AapPodState(connectionState = AapPodState.ConnectionState.DISCONNECTED)
@@ -148,7 +151,9 @@ internal class AapSessionEngine(
private suspend fun wrappedSend(sendRaw: suspend (AapCommand) -> Unit, command: AapCommand) {
sendRaw(command)
lastSentCommand = command
lastSentAt = timeSource.currentTimeMillis()
val now = timeSource.currentTimeMillis()
lastSentAt = now
if (command is AapCommand.SetAncMode) lastAncSentAt = now
}
private fun onVerificationOutcome(outcome: AapSettingsCoordinator.VerificationOutcome) {
@@ -173,6 +178,16 @@ internal class AapSessionEngine(
val hex = message.raw.joinToString(" ") { "%02X".format(it) }
log(TAG, VERBOSE) { "MSG cmd=0x${"%04X".format(message.commandType)} len=${message.raw.size} raw=$hex" }
// HANDSHAKING → READY: first non-settings-echo message means the device is talking.
// Must happen before any early returns so decoded messages (battery, stem, etc.) also trigger it.
if (!handshakeResponseReceived && message.commandType != 0x0009
&& _state.value.connectionState == AapPodState.ConnectionState.HANDSHAKING
) {
handshakeResponseReceived = true
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.READY)
log(TAG) { "Connection READY" }
}
// Issue #173 diagnostic dump
if (message.commandType == 0x001D) {
val segments = describeDeviceInfoSegments(message.payload)
@@ -241,8 +256,7 @@ internal class AapSessionEngine(
// ANC debounce
if (value is AapSetting.AncMode) {
val isFirstAncMode = _state.value.setting<AapSetting.AncMode>() == null
val recentAncSend =
lastSentCommand is AapCommand.SetAncMode && (timeSource.currentTimeMillis() - lastSentAt) <= 3000L
val recentAncSend = lastAncSentAt > 0L && (timeSource.currentTimeMillis() - lastAncSentAt) <= 3000L
if (isFirstAncMode || recentAncSend) {
ancDebounceJob?.cancel()
_state.value = _state.value.withSetting(key, value).copy(lastMessageAt = timeSource.now())
@@ -309,7 +323,10 @@ internal class AapSessionEngine(
break
}
}
commands.lastOrNull()?.let {
// Verify ANC mode if it was in the batch (most important for divergence detection).
// Fall back to verifying the last command if no ANC was flushed.
val toVerify = commands.firstOrNull { it is AapCommand.SetAncMode } ?: commands.lastOrNull()
toVerify?.let {
coordinator.startVerification(
it,
this,
@@ -322,22 +339,9 @@ internal class AapSessionEngine(
}
}
// HANDSHAKING → READY transition
if (!handshakeResponseReceived && _state.value.connectionState == AapPodState.ConnectionState.HANDSHAKING) {
handshakeResponseReceived = true
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.READY)
log(TAG) { "Connection READY" }
}
return
}
// Track handshake response for non-setting messages too
if (!handshakeResponseReceived && message.commandType != 0x0009 && _state.value.connectionState == AapPodState.ConnectionState.HANDSHAKING) {
handshakeResponseReceived = true
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.READY)
log(TAG) { "Connection READY" }
}
val payloadHex = message.payload.joinToString(" ") { "%02X".format(it) }
val sinceSend = if (lastSentAt == 0L) -1L else timeSource.currentTimeMillis() - lastSentAt
val lastSend = lastSentCommand?.let { it::class.simpleName } ?: "none"
@@ -73,7 +73,16 @@ internal class AapSettingsCoordinator(
commands = pendingCommands.values.toList()
pendingCommands.clear()
}
val sorted = commands.sortedBy { if (it is AapCommand.SetAncMode) 0 else 1 }
// Dependency-aware ordering:
// AllowOffOption must precede AncMode (device rejects OFF without it)
// AncMode must precede mode-dependent settings (e.g. AdaptiveAudioNoise)
val sorted = commands.sortedBy {
when (it) {
is AapCommand.SetAllowOffOption -> 0
is AapCommand.SetAncMode -> 1
else -> 2
}
}
return sorted to snapshot()
}
@@ -0,0 +1,448 @@
package eu.darken.capod.pods.core.apple.aap
import eu.darken.capod.common.TimeSource
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import java.time.Instant
import kotlin.reflect.KClass
class AapSessionEngineTest : BaseTest() {
private val timeSource = mockk<TimeSource> {
every { now() } returns Instant.ofEpochMilli(1000L)
every { currentTimeMillis() } returns 1000L
}
private fun dummyMessage(commandType: Int = 0x0009): AapMessage {
val header = byteArrayOf(0x04, 0x00, 0x02, 0x00)
val cmdBytes = byteArrayOf((commandType and 0xFF).toByte(), ((commandType shr 8) and 0xFF).toByte())
val raw = header + cmdBytes
return AapMessage(raw = raw, commandType = commandType, payload = ByteArray(0))
}
/** Build a profile mock with all decode methods stubbed. Does NOT mock encodeCommand (sealed class). */
private fun mockProfile(block: AapDeviceProfile.() -> Unit = {}): AapDeviceProfile = mockk {
every { decodeStemPress(any()) } returns null
every { decodeBattery(any()) } returns null
every { decodePrivateKeyResponse(any()) } returns null
every { decodeDeviceInfo(any()) } returns null
every { decodeSetting(any()) } returns null
every { encodeHandshake() } returns ByteArray(10)
every { encodeNotificationEnable() } returns emptyList()
every { encodeInitExt() } returns ByteArray(10)
every { encodePrivateKeyRequest() } returns null
block()
}
private fun createEngine(
profile: AapDeviceProfile = mockProfile(),
): AapSessionEngine = AapSessionEngine(profile, timeSource)
private fun AapSessionEngine.startReady(scope: TestScope) {
start(scope)
onHandshakeSent()
// Non-0x0009 message triggers HANDSHAKING → READY
processMessage(dummyMessage(commandType = 0x0002))
}
// ── Lifecycle ───────────────────────────────────────────
@Nested
inner class LifecycleTests {
@Test
fun `start sets CONNECTING`() {
val engine = createEngine()
val scope = TestScope(UnconfinedTestDispatcher())
engine.start(scope)
engine.state.value.connectionState shouldBe AapPodState.ConnectionState.CONNECTING
}
@Test
fun `onHandshakeSent sets HANDSHAKING`() {
val engine = createEngine()
val scope = TestScope(UnconfinedTestDispatcher())
engine.start(scope)
engine.onHandshakeSent()
engine.state.value.connectionState shouldBe AapPodState.ConnectionState.HANDSHAKING
}
@Test
fun `reset clears to DISCONNECTED`() {
val engine = createEngine()
val scope = TestScope(UnconfinedTestDispatcher())
engine.start(scope)
engine.onHandshakeSent()
engine.reset()
engine.state.value.connectionState shouldBe AapPodState.ConnectionState.DISCONNECTED
}
@Test
fun `reset is idempotent`() {
val engine = createEngine()
engine.reset()
engine.reset()
engine.state.value.connectionState shouldBe AapPodState.ConnectionState.DISCONNECTED
}
@Test
fun `first non-0x0009 message transitions HANDSHAKING to READY`() {
val engine = createEngine()
val scope = TestScope(UnconfinedTestDispatcher())
engine.start(scope)
engine.onHandshakeSent()
engine.state.value.connectionState shouldBe AapPodState.ConnectionState.HANDSHAKING
engine.processMessage(dummyMessage(commandType = 0x0002))
engine.state.value.connectionState shouldBe AapPodState.ConnectionState.READY
}
@Test
fun `0x0009 message does NOT trigger READY transition`() {
val engine = createEngine()
val scope = TestScope(UnconfinedTestDispatcher())
engine.start(scope)
engine.onHandshakeSent()
engine.processMessage(dummyMessage(commandType = 0x0009))
engine.state.value.connectionState shouldBe AapPodState.ConnectionState.HANDSHAKING
}
}
// ── Send path ───────────────────────────────────────────
@Nested
inner class SendPathTests {
@Test
fun `send queues when no pod in ear`() = runTest(UnconfinedTestDispatcher()) {
val engine = createEngine()
engine.startReady(this as TestScope)
// Put ear detection showing pods in case
val earState = engine.state.value.withSetting(
AapSetting.EarDetection::class,
AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE,
secondaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE,
),
)
// We need to set the state with ear detection — do it by processing a setting message
val profile = mockProfile {
every { decodeSetting(any()) } returns (AapSetting.EarDetection::class as KClass<out AapSetting> to AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE,
secondaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE,
))
}
val engineWithEar = AapSessionEngine(profile, timeSource)
engineWithEar.startReady(this as TestScope)
// Force ear detection into state via processMessage
engineWithEar.processMessage(dummyMessage())
var sendCount = 0
engineWithEar.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)) { sendCount++ }
sendCount shouldBe 0 // Not sent, queued
engineWithEar.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
engineWithEar.state.value.pendingSettingsCount shouldBe 1
}
@Test
fun `send immediate when pod in ear`() = runTest(UnconfinedTestDispatcher()) {
val profile = mockProfile {
every { decodeSetting(any()) } returns (AapSetting.EarDetection::class as KClass<out AapSetting> to AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
))
}
val engine = AapSessionEngine(profile, timeSource)
engine.startReady(this as TestScope)
engine.processMessage(dummyMessage()) // Set ear detection
var sentCommands = mutableListOf<AapCommand>()
engine.send(AapCommand.SetConversationalAwareness(true)) { sentCommands.add(it) }
sentCommands.size shouldBe 1
engine.state.value.pendingSettingsCount shouldBe 0
}
@Test
fun `flush with ANC plus later setting keeps ANC recent and verifies ANC first`() = runTest(UnconfinedTestDispatcher()) {
val ancSetting = AapSetting.AncMode(
current = AapSetting.AncMode.Value.ON,
supported = listOf(AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.ADAPTIVE),
)
var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null
val profile = mockProfile {
every { decodeSetting(any()) } answers { nextSetting }
}
val engine = AapSessionEngine(profile, timeSource)
engine.startReady(this as TestScope)
nextSetting = AapSetting.EarDetection::class as KClass<out AapSetting> to AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE,
secondaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE,
)
engine.processMessage(dummyMessage())
nextSetting = AapSetting.AncMode::class as KClass<out AapSetting> to ancSetting
engine.processMessage(dummyMessage())
nextSetting =
AapSetting.ConversationalAwareness::class as KClass<out AapSetting> to
AapSetting.ConversationalAwareness(enabled = false)
engine.processMessage(dummyMessage())
val sentCommands = mutableListOf<AapCommand>()
val sendRaw: suspend (AapCommand) -> Unit = { sentCommands += it }
engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE), sendRaw)
engine.send(AapCommand.SetConversationalAwareness(true), sendRaw)
engine.state.value.pendingSettingsCount shouldBe 2
engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
nextSetting = AapSetting.EarDetection::class as KClass<out AapSetting> to AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
)
engine.processMessage(dummyMessage())
runCurrent()
sentCommands.size shouldBe 2
sentCommands[0] shouldBe AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)
sentCommands[1] shouldBe AapCommand.SetConversationalAwareness(true)
nextSetting = AapSetting.AncMode::class as KClass<out AapSetting> to
ancSetting.copy(current = AapSetting.AncMode.Value.ON)
engine.processMessage(dummyMessage())
// This would still be ADAPTIVE if the mixed flush path lost the ANC send marker and debounced.
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ON
advanceTimeBy(1100L)
sentCommands.size shouldBe 3
sentCommands[2] shouldBe AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)
}
}
// ── Message processing — state merge ────────────────────
@Nested
inner class MessageProcessingTests {
@Test
fun `battery decode merges into state and filters DISCONNECTED`() {
val profile = mockProfile {
every { decodeStemPress(any()) } returns null
every { decodeBattery(any()) } returns mapOf(
AapPodState.BatteryType.LEFT to AapPodState.Battery(AapPodState.BatteryType.LEFT, 0.85f, AapPodState.ChargingState.NOT_CHARGING),
AapPodState.BatteryType.CASE to AapPodState.Battery(AapPodState.BatteryType.CASE, 0f, AapPodState.ChargingState.DISCONNECTED),
)
}
val engine = AapSessionEngine(profile, timeSource)
val scope = TestScope(UnconfinedTestDispatcher())
engine.start(scope)
engine.onHandshakeSent()
engine.processMessage(dummyMessage())
engine.state.value.batteryLeft shouldBe 0.85f
engine.state.value.batteryCase.shouldBeNull() // DISCONNECTED filtered
}
@Test
fun `decoded battery message transitions HANDSHAKING to READY`() {
val profile = mockProfile {
every { decodeBattery(any()) } returns mapOf(
AapPodState.BatteryType.LEFT to AapPodState.Battery(
AapPodState.BatteryType.LEFT,
0.85f,
AapPodState.ChargingState.NOT_CHARGING,
),
)
}
val engine = AapSessionEngine(profile, timeSource)
val scope = TestScope(UnconfinedTestDispatcher())
engine.start(scope)
engine.onHandshakeSent()
engine.processMessage(dummyMessage(commandType = 0x0002))
engine.state.value.connectionState shouldBe AapPodState.ConnectionState.READY
engine.state.value.batteryLeft shouldBe 0.85f
}
@Test
fun `setting decode merges into state`() {
val profile = mockProfile {
every { decodeStemPress(any()) } returns null
every { decodeBattery(any()) } returns null
every { decodePrivateKeyResponse(any()) } returns null
every { decodeDeviceInfo(any()) } returns null
every { decodeSetting(any()) } returns (AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = 75))
}
val engine = AapSessionEngine(profile, timeSource)
val scope = TestScope(UnconfinedTestDispatcher())
engine.start(scope)
engine.onHandshakeSent()
engine.processMessage(dummyMessage(commandType = 0x0002)) // triggers READY + processes setting
engine.state.value.setting<AapSetting.ToneVolume>()!!.level shouldBe 75
}
@Test
fun `stem press emits event`() = runTest(UnconfinedTestDispatcher()) {
val profile = mockProfile {
every { decodeStemPress(any()) } returns StemPressEvent(
pressType = StemPressEvent.PressType.SINGLE,
bud = StemPressEvent.Bud.LEFT,
)
}
val engine = AapSessionEngine(profile, timeSource)
engine.start(this as TestScope)
var emitted: StemPressEvent? = null
val job = launch {
emitted = engine.stemPressEvents.first()
}
engine.processMessage(dummyMessage())
job.join()
emitted.shouldNotBeNull()
emitted!!.pressType shouldBe StemPressEvent.PressType.SINGLE
}
}
// ── Inference ───────────────────────────────────────────
@Nested
inner class InferenceTests {
@Test
fun `AncMode OFF infers AllowOffOption true`() {
val profile = mockProfile {
every { decodeStemPress(any()) } returns null
every { decodeBattery(any()) } returns null
every { decodePrivateKeyResponse(any()) } returns null
every { decodeDeviceInfo(any()) } returns null
every { decodeSetting(any()) } returns (AapSetting.AncMode::class to AapSetting.AncMode(
current = AapSetting.AncMode.Value.OFF,
supported = listOf(AapSetting.AncMode.Value.OFF, AapSetting.AncMode.Value.ON),
))
}
val engine = AapSessionEngine(profile, timeSource)
val scope = TestScope(UnconfinedTestDispatcher())
engine.start(scope)
engine.onHandshakeSent()
// First ANC mode = no debounce, applied immediately
engine.processMessage(dummyMessage())
engine.state.value.setting<AapSetting.AllowOffOption>()?.enabled shouldBe true
}
}
// ── ANC Debounce ────────────────────────────────────────
@Nested
inner class AncDebounceTests {
@Test
fun `first ANC mode applied immediately without debounce`() {
val profile = mockProfile {
every { decodeStemPress(any()) } returns null
every { decodeBattery(any()) } returns null
every { decodePrivateKeyResponse(any()) } returns null
every { decodeDeviceInfo(any()) } returns null
every { decodeSetting(any()) } returns (AapSetting.AncMode::class to AapSetting.AncMode(
current = AapSetting.AncMode.Value.ON,
supported = listOf(AapSetting.AncMode.Value.ON),
))
}
val engine = AapSessionEngine(profile, timeSource)
val scope = TestScope(UnconfinedTestDispatcher())
engine.start(scope)
engine.onHandshakeSent()
engine.processMessage(dummyMessage())
// Applied immediately (first ANC mode, no debounce)
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ON
}
@Test
fun `unsolicited ANC mode change is debounced`() = runTest(UnconfinedTestDispatcher()) {
// Set up with an existing ANC mode and no recent send
every { timeSource.currentTimeMillis() } returns 10000L // Well past any send
val ancSetting = AapSetting.AncMode(
current = AapSetting.AncMode.Value.ON,
supported = listOf(AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.TRANSPARENCY),
)
val profile = mockProfile {
every { decodeStemPress(any()) } returns null
every { decodeBattery(any()) } returns null
every { decodePrivateKeyResponse(any()) } returns null
every { decodeDeviceInfo(any()) } returns null
every { decodeSetting(any()) } returns (AapSetting.AncMode::class as KClass<out AapSetting> to ancSetting.copy(current = AapSetting.AncMode.Value.TRANSPARENCY))
}
val engine = AapSessionEngine(profile, timeSource)
engine.start(this as TestScope)
engine.onHandshakeSent()
// First message: set initial ANC mode (no debounce for first)
every { profile.decodeSetting(any()) } returns (AapSetting.AncMode::class as KClass<out AapSetting> to ancSetting)
engine.processMessage(dummyMessage(commandType = 0x0002)) // triggers READY + first ANC
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ON
// Second message: unsolicited change — should be debounced
every { profile.decodeSetting(any()) } returns (AapSetting.AncMode::class as KClass<out AapSetting> to ancSetting.copy(current = AapSetting.AncMode.Value.TRANSPARENCY))
engine.processMessage(dummyMessage())
// Not yet applied (debounced)
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ON
// After 1500ms debounce
advanceTimeBy(1600L)
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.TRANSPARENCY
}
}
}
@@ -154,6 +154,25 @@ class AapSettingsCoordinatorTest : BaseTest() {
commands[1].shouldBeInstanceOf<AapCommand.SetToneVolume>()
}
@Test
fun `flush sorts AllowOffOption before AncMode before others`() {
val coord = createCoordinator()
val state = stateWithSetting(
AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = 50),
)
coord.enqueue(AapCommand.SetToneVolume(80), state)
coord.enqueue(AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF), state)
coord.enqueue(AapCommand.SetAllowOffOption(true), state)
val (commands, _) = coord.flush()
commands shouldHaveSize 3
commands[0].shouldBeInstanceOf<AapCommand.SetAllowOffOption>()
commands[1].shouldBeInstanceOf<AapCommand.SetAncMode>()
commands[2].shouldBeInstanceOf<AapCommand.SetToneVolume>()
}
@Test
fun `flush clears queue`() {
val coord = createCoordinator()