From f141ce3f2a29cdfb207e637ffc376f6833d80cc9 Mon Sep 17 00:00:00 2001 From: darken Date: Thu, 16 Apr 2026 09:43:57 +0200 Subject: [PATCH] refactor(aap): Extract controllers and centralize timer lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split AapSessionEngine into dedicated controllers (AapAncController, AapOutboundController), a typed inbound decoder (AapInboundInterpreter), a HID frame batcher (HidTracker), and a device-info diagnostics helper (AapDeviceInfoDiagnostics). Each controller returns typed decisions carrying state, timer actions, and logs instead of mutating engine state via callbacks. AapSettingsCoordinator is now stateless — pending queue and verification state live in engine runtime state. All coroutine timer Jobs live in the engine's timerJobs map keyed by EngineTimerKey, with cancelAllTimers() on reset to prevent forgotten cancellations. Engine event dispatch is split: suspend path for user-initiated sends (errors propagate to caller), non-suspend for sync events (timer fires, inbound updates). --- .../pods/core/apple/aap/AapAncController.kt | 163 ++++ .../apple/aap/AapDeviceInfoDiagnostics.kt | 53 ++ .../core/apple/aap/AapInboundInterpreter.kt | 32 + .../core/apple/aap/AapOutboundController.kt | 176 ++++ .../pods/core/apple/aap/AapSessionEngine.kt | 899 ++++++++---------- .../core/apple/aap/AapSettingsCoordinator.kt | 191 ++-- .../capod/pods/core/apple/aap/HidTracker.kt | 114 +++ .../core/apple/aap/AapAncControllerTest.kt | 152 +++ ...est.kt => AapDeviceInfoDiagnosticsTest.kt} | 10 +- .../core/apple/aap/AapSessionEngineTest.kt | 87 ++ .../apple/aap/AapSettingsCoordinatorTest.kt | 324 ++----- 11 files changed, 1300 insertions(+), 901 deletions(-) create mode 100644 app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapAncController.kt create mode 100644 app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapDeviceInfoDiagnostics.kt create mode 100644 app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapInboundInterpreter.kt create mode 100644 app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapOutboundController.kt create mode 100644 app/src/main/java/eu/darken/capod/pods/core/apple/aap/HidTracker.kt create mode 100644 app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapAncControllerTest.kt rename app/src/test/java/eu/darken/capod/pods/core/apple/aap/{AapConnectionTest.kt => AapDeviceInfoDiagnosticsTest.kt} (92%) diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapAncController.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapAncController.kt new file mode 100644 index 00000000..532cdb22 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapAncController.kt @@ -0,0 +1,163 @@ +package eu.darken.capod.pods.core.apple.aap + +import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting +import java.time.Instant +import kotlin.reflect.KClass + +internal data class AncRuntimeState( + val latestObservedAncMode: AapSetting.AncMode? = null, + val pendingDebouncedAnc: PendingDebouncedAnc? = null, +) + +internal data class PendingDebouncedAnc( + val key: KClass, + val value: AapSetting.AncMode, + val previous: AapSetting?, +) + +internal data class AncDecision( + val podState: AapPodState, + val runtimeState: AncRuntimeState, + val timerActions: List = emptyList(), + val logs: List = emptyList(), +) + +internal class AapAncController { + + fun onAncSetting( + podState: AapPodState, + runtimeState: AncRuntimeState, + key: KClass, + value: AapSetting.AncMode, + isRecentAncSend: Boolean, + now: Instant, + ): AncDecision { + val previous = podState.settings[key] + val updatedRuntime = runtimeState.copy(latestObservedAncMode = value) + val timerActions = mutableListOf() + timerActions += planAllowOffInferenceTimer(podState, updatedRuntime) + + val isFirstAncMode = podState.setting() == null + return if (isFirstAncMode || isRecentAncSend) { + timerActions += EngineTimerAction.Cancel(EngineTimerKey.AncDebounce) + AncDecision( + podState = applyAncSetting(podState, key, value, now, isRecentAncSend), + runtimeState = updatedRuntime.copy(pendingDebouncedAnc = null), + timerActions = timerActions, + logs = listOf("Setting: ${key.simpleName} = $value [was: $previous]"), + ) + } else { + timerActions += EngineTimerAction.Start(EngineTimerKey.AncDebounce, 1500L) + AncDecision( + podState = podState, + runtimeState = updatedRuntime.copy( + pendingDebouncedAnc = PendingDebouncedAnc(key = key, value = value, previous = previous), + ), + timerActions = timerActions, + ) + } + } + + fun onEarDetectionUpdated( + podState: AapPodState, + runtimeState: AncRuntimeState, + ): AncDecision = AncDecision( + podState = podState, + runtimeState = runtimeState, + timerActions = planAllowOffInferenceTimer(podState, runtimeState), + ) + + fun onAncDebounceTimerFired( + podState: AapPodState, + runtimeState: AncRuntimeState, + now: Instant, + isRecentAncSend: Boolean, + ): AncDecision { + val pending = runtimeState.pendingDebouncedAnc ?: return AncDecision(podState, runtimeState) + return AncDecision( + podState = applyAncSetting(podState, pending.key, pending.value, now, isRecentAncSend), + runtimeState = runtimeState.copy(pendingDebouncedAnc = null), + logs = listOf("Setting: ${pending.key.simpleName} = ${pending.value} [was: ${pending.previous}]"), + ) + } + + fun onAllowOffInferenceTimerFired( + podState: AapPodState, + runtimeState: AncRuntimeState, + ): AncDecision { + val latestAncMode = runtimeState.latestObservedAncMode ?: podState.setting() + val latestEarDetection = podState.setting() + val latestAllowOffOption = podState.setting() + if (latestAncMode?.current == AapSetting.AncMode.Value.OFF && + latestEarDetection?.isEitherPodInEar == true && + latestAllowOffOption?.enabled != true + ) { + return AncDecision( + podState = podState.withSetting( + AapSetting.AllowOffOption::class, + AapSetting.AllowOffOption(enabled = true), + ), + runtimeState = runtimeState, + logs = listOf( + "Inferred: AllowOffOption = AllowOffOption(enabled=true) (from stable in-ear AncMode=OFF)", + ), + ) + } + return AncDecision(podState, runtimeState) + } + + fun onOffRejected( + podState: AapPodState, + runtimeState: AncRuntimeState, + ): AncDecision { + val prev = podState.setting() + if (prev == null || prev.enabled) { + return AncDecision( + podState = podState.withSetting( + AapSetting.AllowOffOption::class, + AapSetting.AllowOffOption(enabled = false), + ), + runtimeState = runtimeState, + timerActions = listOf(EngineTimerAction.Cancel(EngineTimerKey.AllowOffInference)), + logs = listOf("Inferred: AllowOffOption = false (OFF mode rejected by device)"), + ) + } + return AncDecision( + podState = podState, + runtimeState = runtimeState, + timerActions = listOf(EngineTimerAction.Cancel(EngineTimerKey.AllowOffInference)), + ) + } + + private fun applyAncSetting( + podState: AapPodState, + key: KClass, + value: AapSetting.AncMode, + now: Instant, + isRecentAncSend: Boolean, + ): AapPodState { + val updated = podState.withSetting(key, value).copy(lastMessageAt = now) + return if (isRecentAncSend && updated.pendingAncMode == value.current) { + updated.copy(pendingAncMode = null) + } else { + updated + } + } + + private fun planAllowOffInferenceTimer( + podState: AapPodState, + runtimeState: AncRuntimeState, + ): List { + val observedAncMode = runtimeState.latestObservedAncMode ?: podState.setting() + val earDetection = podState.setting() + val allowOffOption = podState.setting() + return if (observedAncMode?.current == AapSetting.AncMode.Value.OFF && + earDetection?.isEitherPodInEar == true && + allowOffOption?.enabled != true + ) { + listOf(EngineTimerAction.Start(EngineTimerKey.AllowOffInference, 1500L)) + } else { + listOf(EngineTimerAction.Cancel(EngineTimerKey.AllowOffInference)) + } + } +} diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapDeviceInfoDiagnostics.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapDeviceInfoDiagnostics.kt new file mode 100644 index 00000000..fefe9e08 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapDeviceInfoDiagnostics.kt @@ -0,0 +1,53 @@ +package eu.darken.capod.pods.core.apple.aap + +/** + * Diagnostic-only NUL-delimited segmentation of a 0x1D INFORMATION payload, used for + * issue #173 engraving discovery logging. + */ +internal object AapDeviceInfoDiagnostics { + + fun describeSegments(payload: ByteArray): List { + var start = 0 + while (start < payload.size) { + val b = payload[start].toInt() and 0xFF + if (b in 0x20..0x7E) break + start++ + } + if (start >= payload.size) return emptyList() + + val segments = mutableListOf() + var segIndex = 0 + var i = start + while (i < payload.size) { + while (i < payload.size && payload[i] == 0x00.toByte()) i++ + if (i >= payload.size) break + + val segStart = i + while (i < payload.size && payload[i] != 0x00.toByte()) i++ + val segBytes = payload.copyOfRange(segStart, i) + + val utf8: String? = try { + Charsets.UTF_8.newDecoder() + .onMalformedInput(java.nio.charset.CodingErrorAction.REPORT) + .onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT) + .decode(java.nio.ByteBuffer.wrap(segBytes)) + .toString() + } catch (_: java.nio.charset.CharacterCodingException) { + null + } + val hex = segBytes.joinToString("") { "%02X".format(it) } + + segments += DeviceInfoSegment(segIndex, segStart, segBytes.size, utf8, hex) + segIndex++ + } + return segments + } +} + +internal data class DeviceInfoSegment( + val index: Int, + val offset: Int, + val length: Int, + val utf8: String?, + val hex: String, +) diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapInboundInterpreter.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapInboundInterpreter.kt new file mode 100644 index 00000000..f55985f0 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapInboundInterpreter.kt @@ -0,0 +1,32 @@ +package eu.darken.capod.pods.core.apple.aap + +import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo +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 kotlin.reflect.KClass + +internal sealed interface AapInboundUpdate { + data class StemPress(val event: StemPressEvent) : AapInboundUpdate + data class Battery(val batteries: Map) : AapInboundUpdate + data class PrivateKeys(val result: KeyExchangeResult) : AapInboundUpdate + data class DeviceInfo(val info: AapDeviceInfo) : AapInboundUpdate + data class Setting(val key: KClass, val value: AapSetting) : AapInboundUpdate +} + +internal class AapInboundInterpreter( + private val profile: AapDeviceProfile, +) { + fun decode(message: AapMessage): AapInboundUpdate? { + profile.decodeStemPress(message)?.let { return AapInboundUpdate.StemPress(it) } + profile.decodeBattery(message)?.let { return AapInboundUpdate.Battery(it) } + profile.decodePrivateKeyResponse(message)?.let { return AapInboundUpdate.PrivateKeys(it) } + profile.decodeDeviceInfo(message)?.let { return AapInboundUpdate.DeviceInfo(it) } + profile.decodeSetting(message)?.let { (key, value) -> + return AapInboundUpdate.Setting(key, value) + } + return null + } +} diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapOutboundController.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapOutboundController.kt new file mode 100644 index 00000000..dd06d537 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapOutboundController.kt @@ -0,0 +1,176 @@ +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.AapSetting + +internal data class VerificationState( + val command: AapCommand, + val attempt: Int = 0, +) + +internal data class OutboundRuntimeState( + val pendingCommands: List = emptyList(), + val verification: VerificationState? = null, +) + +internal data class OutboundDecision( + val podState: AapPodState, + val runtimeState: OutboundRuntimeState, + val commandsToSend: List = emptyList(), + val timerActions: List = emptyList(), + val logs: List = emptyList(), + val rejectedCommand: AapCommand? = null, +) + +internal class AapOutboundController( + timeSource: TimeSource, +) { + + private val coordinator = AapSettingsCoordinator(timeSource) + + fun onCommandRequested( + podState: AapPodState, + runtimeState: OutboundRuntimeState, + command: AapCommand, + ): OutboundDecision { + if (command !is AapCommand.SetDeviceName) { + val earDetection = podState.setting() + if (earDetection != null && !earDetection.isEitherPodInEar) { + val result = coordinator.enqueue(runtimeState.pendingCommands, command, podState) + return OutboundDecision( + podState = applyPendingSnapshot( + result.optimisticState ?: podState, + result.snapshot, + if (command is AapCommand.SetAncMode) command.mode else result.snapshot.pendingAncMode, + ), + runtimeState = runtimeState.copy(pendingCommands = result.pendingCommands), + logs = listOf("No pod in ear, queuing: ${command::class.simpleName}"), + ) + } + } + + var updatedPodState = podState + var updatedRuntimeState = runtimeState + if (command is AapCommand.SetAncMode) { + val result = coordinator.removeFromQueue(runtimeState.pendingCommands, AapCommand.SetAncMode::class) + updatedPodState = applyPendingSnapshot(updatedPodState, result.snapshot, command.mode) + updatedRuntimeState = updatedRuntimeState.copy(pendingCommands = result.pendingCommands) + } + + coordinator.optimisticUpdate(updatedPodState, command)?.let { updatedPodState = it } + + val verificationCheck = coordinator.verificationFor(command) + return OutboundDecision( + podState = updatedPodState, + runtimeState = updatedRuntimeState.copy( + verification = verificationCheck?.let { VerificationState(command = command, attempt = 0) } + ?: updatedRuntimeState.verification, + ), + commandsToSend = listOf(command), + timerActions = if (verificationCheck != null) { + listOf(EngineTimerAction.Start(EngineTimerKey.Verification, 1000L)) + } else { + emptyList() + }, + ) + } + + fun onEarDetectionInEar( + podState: AapPodState, + runtimeState: OutboundRuntimeState, + ): OutboundDecision { + val result = coordinator.flush(runtimeState.pendingCommands) + if (result.commands.isEmpty()) return OutboundDecision(podState, runtimeState.copy(pendingCommands = result.pendingCommands)) + + val ancCommand = result.commands.firstOrNull { it is AapCommand.SetAncMode } as? AapCommand.SetAncMode + val updatedPodState = applyPendingSnapshot( + podState, + result.snapshot, + ancCommand?.mode ?: podState.pendingAncMode, + ) + val toVerify = result.commands.firstOrNull { it is AapCommand.SetAncMode } ?: result.commands.lastOrNull() + val verificationCheck = toVerify?.let { coordinator.verificationFor(it) } + return OutboundDecision( + podState = updatedPodState, + runtimeState = runtimeState.copy( + pendingCommands = result.pendingCommands, + verification = if (verificationCheck != null) { + VerificationState(command = checkNotNull(toVerify), attempt = 0) + } else { + runtimeState.verification + }, + ), + commandsToSend = result.commands, + timerActions = if (verificationCheck != null) { + listOf(EngineTimerAction.Start(EngineTimerKey.Verification, 1000L)) + } else { + emptyList() + }, + logs = listOf("Pod in ear, flushing ${result.commands.size} queued commands"), + ) + } + + fun onVerificationTimerFired( + podState: AapPodState, + runtimeState: OutboundRuntimeState, + ): OutboundDecision { + val verification = runtimeState.verification ?: return OutboundDecision(podState, runtimeState) + val check = coordinator.verificationFor(verification.command) + ?: return OutboundDecision( + podState = podState, + runtimeState = runtimeState.copy(verification = null), + ) + + if (check(podState)) { + return OutboundDecision( + podState = clearPendingForCommand(podState, verification.command), + runtimeState = runtimeState.copy(verification = null), + ) + } + + val ear = podState.setting() + if (ear != null && !ear.isEitherPodInEar) { + return OutboundDecision( + podState = podState, + runtimeState = runtimeState.copy(verification = null), + logs = listOf("Verification aborted for ${verification.command::class.simpleName}: no pod in ear"), + ) + } + + if (verification.attempt == 0) { + return OutboundDecision( + podState = podState, + runtimeState = runtimeState.copy(verification = verification.copy(attempt = 1)), + commandsToSend = listOf(verification.command), + timerActions = listOf(EngineTimerAction.Start(EngineTimerKey.Verification, 1000L)), + logs = listOf("Divergence detected for ${verification.command::class.simpleName}, re-sending"), + ) + } + + return OutboundDecision( + podState = clearPendingForCommand(podState, verification.command), + runtimeState = runtimeState.copy(verification = null), + logs = listOf("Rejected after retry: ${verification.command::class.simpleName}"), + rejectedCommand = verification.command, + ) + } + + private fun clearPendingForCommand( + podState: AapPodState, + command: AapCommand, + ): AapPodState = if (command is AapCommand.SetAncMode && podState.pendingAncMode == command.mode) { + podState.copy(pendingAncMode = null) + } else { + podState + } + + private fun applyPendingSnapshot( + podState: AapPodState, + snapshot: AapSettingsCoordinator.PendingSnapshot, + ancPendingMode: AapSetting.AncMode.Value?, + ): AapPodState = podState.copy( + pendingAncMode = ancPendingMode, + pendingSettingsCount = snapshot.count, + ) +} diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapSessionEngine.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapSessionEngine.kt index 7c1a6123..2cc00102 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapSessionEngine.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapSessionEngine.kt @@ -32,12 +32,10 @@ import kotlin.reflect.KClass * that delegates to this engine for send-path logic and incoming message processing. */ internal class AapSessionEngine( - private val profile: AapDeviceProfile, + profile: AapDeviceProfile, private val timeSource: TimeSource, ) { - // ── State ─────────────────────────────────────────────── - private val _state = MutableStateFlow(AapPodState()) val state: StateFlow = _state.asStateFlow() @@ -48,333 +46,399 @@ internal class AapSessionEngine( MutableSharedFlow(extraBufferCapacity = 8, onBufferOverflow = BufferOverflow.DROP_OLDEST) val stemPressEvents: SharedFlow = _stemPressEvents.asSharedFlow() - private val coordinator = AapSettingsCoordinator(timeSource) private val hidTracker = HidTracker { msg -> log(TAG) { msg } } + private val inboundInterpreter = AapInboundInterpreter(profile) + private val ancController = AapAncController() + private val outboundController = AapOutboundController(timeSource) + private val sendMutex = Mutex() + private val timerJobs = mutableMapOf() private var scope: CoroutineScope? = null - private var ancDebounceJob: Job? = null - private var allowOffInferenceJob: 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 - private var latestObservedAncMode: AapSetting.AncMode? = null - - /** Stored reference to the socket write callback — set on each [send] / flush call. */ private var activeSendRaw: (suspend (AapCommand) -> Unit)? = null + private var runtimeState = EngineRuntimeState() - // ── Lifecycle ─────────────────────────────────────────── - - /** Begin a session: set scope, transition to CONNECTING. */ fun start(scope: CoroutineScope) { - this.scope = scope - _state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.CONNECTING) + dispatch(AapEngineEvent.SessionStarted(scope)) } - /** Called after handshake bytes are sent on the socket. */ fun onHandshakeSent() { - _state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.HANDSHAKING) - handshakeResponseReceived = false + dispatch(AapEngineEvent.HandshakeSent) } - /** Idempotent reset: cancel all jobs, clear queue, reset state to DISCONNECTED. */ fun reset() { - ancDebounceJob?.cancel() - ancDebounceJob = null - cancelAllowOffOptionInference() - coordinator.clear() - hidTracker.flush() - hidTracker.reset() - scope = null - lastSentCommand = null - lastSentAt = 0L - lastAncSentAt = 0L - latestObservedAncMode = null - activeSendRaw = null - handshakeResponseReceived = false - _state.value = AapPodState(connectionState = AapPodState.ConnectionState.DISCONNECTED) + dispatch(AapEngineEvent.ResetRequested) } - // ── Send path ─────────────────────────────────────────── - - suspend fun send(command: AapCommand, sendRaw: suspend (AapCommand) -> Unit) = sendMutex.withLock { - activeSendRaw = sendRaw - val currentState = _state.value - if (currentState.connectionState != AapPodState.ConnectionState.READY) { - throw IllegalStateException("Cannot send command in state ${currentState.connectionState}") - } - - // Ear-detection gating — defer all setting commands except SetDeviceName - if (command !is AapCommand.SetDeviceName) { - val earDetection = currentState.setting() - if (earDetection != null && !earDetection.isEitherPodInEar) { - log(TAG) { "No pod in ear, queuing: ${command::class.simpleName}" } - val (optimistic, snapshot) = coordinator.enqueue(command, currentState) - optimistic?.let { _state.value = it } - _state.value = _state.value.copy( - pendingAncMode = snapshot.pendingAncMode, - pendingSettingsCount = snapshot.count, - ) - return@withLock - } - } - - // Immediate send path (pods in ear, or SetDeviceName) - if (command is AapCommand.SetAncMode) { - val snapshot = coordinator.removeFromQueue(AapCommand.SetAncMode::class) - val currentAnc = currentState.setting() - if (currentAnc != null) { - _state.value = - currentState.withSetting(AapSetting.AncMode::class, currentAnc.copy(current = command.mode)) - .copy(lastMessageAt = timeSource.now()) - } - _state.value = _state.value.copy( - pendingAncMode = snapshot.pendingAncMode, - pendingSettingsCount = snapshot.count, - ) - } - - val preSendDeviceInfo = currentState.deviceInfo - coordinator.optimisticUpdate(currentState, command)?.let { _state.value = it } - try { - wrappedSend(sendRaw, command) - } catch (e: Exception) { - if (command is AapCommand.SetDeviceName && preSendDeviceInfo != null) { - _state.value = _state.value.copy(deviceInfo = preSendDeviceInfo) - } - throw e - } - coordinator.startVerification( - command, - scope!!, - { _state.value }, - { cmd -> wrappedSend(sendRaw, cmd) }, - ::onVerificationOutcome - ) - } - - private suspend fun wrappedSend(sendRaw: suspend (AapCommand) -> Unit, command: AapCommand) { - sendRaw(command) - lastSentCommand = command - val now = timeSource.currentTimeMillis() - lastSentAt = now - if (command is AapCommand.SetAncMode) lastAncSentAt = now - } - - private fun onVerificationOutcome(outcome: AapSettingsCoordinator.VerificationOutcome) { - if (outcome is AapSettingsCoordinator.VerificationOutcome.Rejected) { - val command = outcome.command - if (command is AapCommand.SetAncMode && command.mode == AapSetting.AncMode.Value.OFF) { - cancelAllowOffOptionInference() - val prev = _state.value.setting() - if (prev == null || prev.enabled) { - _state.value = _state.value.withSetting( - AapSetting.AllowOffOption::class, - AapSetting.AllowOffOption(enabled = false), - ) - log(TAG) { "Inferred: AllowOffOption = false (OFF mode rejected by device)" } - } - } + suspend fun send(command: AapCommand, sendRaw: suspend (AapCommand) -> Unit) { + scope ?: throw IllegalStateException("Cannot send command without an active session scope") + sendMutex.withLock { + activeSendRaw = sendRaw + val decision = outboundController.onCommandRequested(_state.value, runtimeState.outbound, command) + applyOutboundDecisionInline(decision) } } - // ── Message processing ────────────────────────────────── - fun processMessage(message: AapMessage) { - // Suppress per-frame raw hex for 0x0017 — the HidTracker emits structured summaries instead. - // For all other commands, log at VERBOSE as before. + dispatch(AapEngineEvent.MessageReceived(message)) + } + + private fun dispatch(event: AapEngineEvent) { + when (event) { + is AapEngineEvent.SessionStarted -> { + scope = event.scope + runtimeState = runtimeState.copy(handshakeResponseReceived = false) + _state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.CONNECTING) + } + + AapEngineEvent.HandshakeSent -> { + runtimeState = runtimeState.copy(handshakeResponseReceived = false) + _state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.HANDSHAKING) + } + + AapEngineEvent.ResetRequested -> { + cancelAllTimers() + hidTracker.flush() + hidTracker.reset() + scope = null + activeSendRaw = null + runtimeState = EngineRuntimeState() + _state.value = AapPodState(connectionState = AapPodState.ConnectionState.DISCONNECTED) + } + + is AapEngineEvent.MessageReceived -> handleMessageReceived(event.message) + is AapEngineEvent.InboundUpdateDecoded -> handleInboundUpdate(event.update) + is AapEngineEvent.TimerFired -> handleTimerFired(event.key) + } + } + + private fun handleMessageReceived(message: AapMessage) { if (message.commandType != CMD_HID_DESCRIPTOR) { val hex = message.raw.joinToString(" ") { "%02X".format(it) } - log(TAG, VERBOSE) { "MSG cmd=0x${"%04X".format(message.commandType)} len=${message.raw.size} raw=$hex" } - // Flush any pending HID batch summary before processing a non-HID message. + log(TAG, VERBOSE) { + "MSG cmd=0x${"%04X".format(message.commandType)} len=${message.raw.size} raw=$hex" + } hidTracker.flush() } - // 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 + if (!runtimeState.handshakeResponseReceived && + message.commandType != CMD_SETTING && + _state.value.connectionState == AapPodState.ConnectionState.HANDSHAKING ) { - handshakeResponseReceived = true + runtimeState = runtimeState.copy(handshakeResponseReceived = true) _state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.READY) log(TAG) { "Connection READY" } } - // Fast-path for HID descriptor frames (cmd 0x0017). During case transitions, 800+ frames - // arrive in ~20 seconds. Handle them before any profile.decode*() calls to avoid 800 wasted - // decode attempts. The HidTracker batches bulk frames and emits structured summaries. if (message.commandType == CMD_HID_DESCRIPTOR) { hidTracker.consume(message.payload) _state.value = _state.value.copy(lastMessageAt = timeSource.now()) return } - // Issue #173 diagnostic dump - if (message.commandType == 0x001D) { - val segments = describeDeviceInfoSegments(message.payload) - log(TAG, INFO) { "DeviceInfoDump #173: payload=${message.payload.size} bytes, segments=${segments.size}" } - segments.forEach { seg -> - val label = when (seg.index) { - 0 -> "name" - 1 -> "modelNumber" - 2 -> "manufacturer" - 3 -> "serialNumber" - 4 -> "firmwareVersion" - 5 -> "firmwareVersionDup" - 6 -> "protocolVersion" - 7 -> "updaterAppId" - 8 -> "leftEarbudSerial" - 9 -> "rightEarbudSerial" - 10 -> "buildNumber" - 11 -> "encryptedBlob" - 12 -> "timestamp" - else -> "unknown" + if (message.commandType == CMD_DEVICE_INFO) { + logDeviceInfoDiagnostics(message.payload) + } + + val update = inboundInterpreter.decode(message) + if (update != null) { + dispatch(AapEngineEvent.InboundUpdateDecoded(update)) + } else { + logUnhandledMessage(message) + } + } + + private fun handleInboundUpdate(update: AapInboundUpdate) { + when (update) { + is AapInboundUpdate.StemPress -> { + _stemPressEvents.tryEmit(update.event) + log(TAG) { "Stem press: ${update.event.pressType} ${update.event.bud}" } + } + + is AapInboundUpdate.Battery -> { + val valid = update.batteries.filterValues { + it.charging != AapPodState.ChargingState.DISCONNECTED } - val rendered = seg.utf8?.let { "\"$it\"" } ?: "" - log( TAG, INFO ) { - "DeviceInfoDump #173: [${seg.index}] off=${seg.offset} len=${seg.length} ($label) $rendered hex=${seg.hex}" - } - } - } - - // Stem press (transient event, not stored in state) - profile.decodeStemPress(message)?.let { event -> - _stemPressEvents.tryEmit(event) - log(TAG) { "Stem press: ${event.pressType} ${event.bud}" } - return - } - - // Battery - profile.decodeBattery(message)?.let { batteries -> - val valid = batteries.filterValues { it.charging != AapPodState.ChargingState.DISCONNECTED } - _state.value = _state.value.copy( - batteries = _state.value.batteries + valid, - lastMessageAt = timeSource.now(), - ) - log(TAG) { "Battery update: ${batteries.entries.map { "${it.key}=${(it.value.percent * 100).toInt()}% ${it.value.charging}" }}" } - return - } - - // Private key response - profile.decodePrivateKeyResponse(message)?.let { keys -> - log(TAG) { "Private keys received: IRK=${keys.irk != null}, ENC=${keys.encKey != null}" } - _state.value = _state.value.copy(lastMessageAt = timeSource.now()) - _keysReceived.tryEmit(keys) - return - } - - // Device info - profile.decodeDeviceInfo(message)?.let { info -> - _state.value = _state.value.copy(deviceInfo = info, lastMessageAt = timeSource.now()) - log(TAG) { "Device info: ${info.name} (${info.modelNumber})" } - return - } - - // Setting update - profile.decodeSetting(message)?.let { (key, value) -> - val previous = _state.value.settings[key] - - // ANC debounce - if (value is AapSetting.AncMode) { - latestObservedAncMode = value - syncAllowOffOptionInference() - val isFirstAncMode = _state.value.setting() == null - val recentAncSend = lastAncSentAt > 0L && (timeSource.currentTimeMillis() - lastAncSentAt) <= 3000L - if (isFirstAncMode || recentAncSend) { - ancDebounceJob?.cancel() - _state.value = _state.value.withSetting(key, value).copy(lastMessageAt = timeSource.now()) - log(TAG) { "Setting: ${key.simpleName} = $value [was: $previous]" } - } else { - ancDebounceJob?.cancel() - ancDebounceJob = scope?.launch { - delay(1500L) - _state.value = _state.value.withSetting(key, value).copy(lastMessageAt = timeSource.now()) - log(TAG) { "Setting (debounced): ${key.simpleName} = $value [was: $previous]" } - } - } - return - } - - // Ear detection role swap - val clearPrimaryPod = value is AapSetting.EarDetection && run { - val prev = _state.value.setting() - prev != null && prev.primaryPod == value.secondaryPod && prev.secondaryPod == value.primaryPod - } - - var newState = _state.value.withSetting(key, value).copy(lastMessageAt = timeSource.now()) - if (clearPrimaryPod) { - newState = newState.copy(settings = newState.settings - AapSetting.PrimaryPod::class) - } - _state.value = newState - log(TAG) { "Setting: ${key.simpleName} = $value${if (clearPrimaryPod) " (swap, PrimaryPod cleared)" else ""} [was: $previous]" } - if (value is AapSetting.EarDetection) syncAllowOffOptionInference() - - // Flush queued commands when pod goes in ear - if (value is AapSetting.EarDetection && value.isEitherPodInEar) { - val (commands, snapshot) = coordinator.flush() _state.value = _state.value.copy( - pendingAncMode = snapshot.pendingAncMode, - pendingSettingsCount = snapshot.count, + batteries = _state.value.batteries + valid, + lastMessageAt = timeSource.now(), ) - - if (commands.isNotEmpty()) { - val ancCmd = commands.firstOrNull { it is AapCommand.SetAncMode } as? AapCommand.SetAncMode - if (ancCmd != null) { - val currentAnc = _state.value.setting() - if (currentAnc != null) { - _state.value = _state.value.withSetting( - AapSetting.AncMode::class, - currentAnc.copy(current = ancCmd.mode) - ).copy(lastMessageAt = timeSource.now()) - } - } - - log(TAG) { "Pod in ear, flushing ${commands.size} queued commands" } - val sendFn = activeSendRaw - scope?.launch { - if (sendFn == null) { - log(TAG, ERROR) { "No sendRaw available for flush" } - return@launch - } - for (cmd in commands) { - try { - wrappedSend(sendFn, cmd) - } catch (e: Exception) { - log(TAG, ERROR) { "Flush failed at ${cmd::class.simpleName}: $e" } - break - } - } - // 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, - { _state.value }, - { cmd -> wrappedSend(sendFn, cmd) }, - ::onVerificationOutcome - ) - } - } + log(TAG) { + "Battery update: ${update.batteries.entries.map { "${it.key}=${(it.value.percent * 100).toInt()}% ${it.value.charging}" }}" } } + is AapInboundUpdate.PrivateKeys -> { + log(TAG) { + "Private keys received: IRK=${update.result.irk != null}, ENC=${update.result.encKey != null}" + } + _state.value = _state.value.copy(lastMessageAt = timeSource.now()) + _keysReceived.tryEmit(update.result) + } + + is AapInboundUpdate.DeviceInfo -> { + _state.value = _state.value.copy(deviceInfo = update.info, lastMessageAt = timeSource.now()) + log(TAG) { "Device info: ${update.info.name} (${update.info.modelNumber})" } + } + + is AapInboundUpdate.Setting -> handleSettingUpdate(update.key, update.value) + } + } + + private fun handleSettingUpdate(key: KClass, value: AapSetting) { + if (value is AapSetting.AncMode) { + val decision = ancController.onAncSetting( + podState = _state.value, + runtimeState = runtimeState.anc, + key = key, + value = value, + isRecentAncSend = isRecentAncSend(), + now = timeSource.now(), + ) + applyAncDecision(decision) return } - 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" + val previous = _state.value.settings[key] + val clearPrimaryPod = value is AapSetting.EarDetection && run { + val prev = _state.value.setting() + prev != null && prev.primaryPod == value.secondaryPod && prev.secondaryPod == value.primaryPod + } - if (message.commandType == 0x0009 && message.payload.size >= 2) { + var newState = _state.value.withSetting(key, value).copy(lastMessageAt = timeSource.now()) + if (clearPrimaryPod) { + newState = newState.copy(settings = newState.settings - AapSetting.PrimaryPod::class) + } + _state.value = newState + log(TAG) { + "Setting: ${key.simpleName} = $value${if (clearPrimaryPod) " (swap, PrimaryPod cleared)" else ""} [was: $previous]" + } + + if (value is AapSetting.EarDetection) { + applyAncDecision(ancController.onEarDetectionUpdated(_state.value, runtimeState.anc)) + if (value.isEitherPodInEar) { + applyOutboundDecisionAsync( + outboundController.onEarDetectionInEar(_state.value, runtimeState.outbound), + ) + } + } + } + + private fun handleTimerFired(key: EngineTimerKey) { + when (key) { + EngineTimerKey.AncDebounce -> { + val decision = ancController.onAncDebounceTimerFired( + podState = _state.value, + runtimeState = runtimeState.anc, + now = timeSource.now(), + isRecentAncSend = isRecentAncSend(), + ) + applyAncDecision(decision) + } + + EngineTimerKey.AllowOffInference -> { + applyAncDecision(ancController.onAllowOffInferenceTimerFired(_state.value, runtimeState.anc)) + } + + EngineTimerKey.Verification -> { + applyOutboundDecisionAsync( + outboundController.onVerificationTimerFired(_state.value, runtimeState.outbound), + ) + } + } + } + + private fun applyAncDecision(decision: AncDecision) { + _state.value = decision.podState + runtimeState = runtimeState.copy(anc = decision.runtimeState) + decision.logs.forEach { log(TAG) { it } } + applyTimerActions(decision.timerActions) + } + + /** + * Apply the non-send side of a decision (state, runtime, logs) and prepare the send context. + * + * Returns `null` when there's nothing to send — either the decision carries no commands, or + * no transport is currently available. In both "nothing to send" paths, the decision's timer + * actions and rejection handling are applied before returning, so callers only need to worry + * about the send path itself. + */ + private fun applyDecisionStateAndPrepareSend(decision: OutboundDecision): OutboundSendContext? { + val previousState = _state.value + val previousVerification = runtimeState.outbound.verification + + _state.value = decision.podState + runtimeState = runtimeState.copy(outbound = decision.runtimeState) + decision.logs.forEach { log(TAG) { it } } + + if (decision.commandsToSend.isEmpty()) { + applyTimerActions(decision.timerActions) + handleRejectedCommand(decision.rejectedCommand) + return null + } + + val sendFn = activeSendRaw + if (sendFn == null) { + log(TAG, ERROR) { "No sendRaw available for outbound send" } + restoreVerification(previousVerification) + return null + } + + return OutboundSendContext(sendFn, previousState, previousVerification, decision) + } + + private fun restoreVerification(previousVerification: VerificationState?) { + runtimeState = runtimeState.copy(outbound = runtimeState.outbound.copy(verification = previousVerification)) + } + + /** User-initiated send: runs in the caller's coroutine, errors propagate back to the caller. */ + private suspend fun applyOutboundDecisionInline(decision: OutboundDecision) { + val ctx = applyDecisionStateAndPrepareSend(decision) ?: return + try { + sendCommands(ctx.sendFn, ctx.decision.commandsToSend, ctx.previousState) + applyTimerActions(ctx.decision.timerActions) + handleRejectedCommand(ctx.decision.rejectedCommand) + } catch (e: Exception) { + restoreVerification(ctx.previousVerification) + throw e + } + } + + /** Engine-initiated send (timer fired, ear-detection flush): launched on the engine's scope. */ + private fun applyOutboundDecisionAsync(decision: OutboundDecision) { + val ctx = applyDecisionStateAndPrepareSend(decision) ?: return + val currentScope = scope + if (currentScope == null) { + log(TAG, ERROR) { "No scope available for outbound async send" } + restoreVerification(ctx.previousVerification) + return + } + currentScope.launch { + try { + sendCommands(ctx.sendFn, ctx.decision.commandsToSend, ctx.previousState) + applyTimerActions(ctx.decision.timerActions) + handleRejectedCommand(ctx.decision.rejectedCommand) + } catch (_: Exception) { + restoreVerification(ctx.previousVerification) + } + } + } + + private suspend fun sendCommands( + sendFn: suspend (AapCommand) -> Unit, + commands: List, + previousState: AapPodState, + ) { + commands.forEach { command -> + try { + wrappedSend(sendFn, command) + } catch (e: Exception) { + if (command is AapCommand.SetDeviceName && previousState.deviceInfo != null) { + _state.value = _state.value.copy(deviceInfo = previousState.deviceInfo) + } + if (commands.size == 1) { + throw e + } + log(TAG, ERROR) { "Flush failed at ${command::class.simpleName}: $e" } + throw e + } + } + } + + private suspend fun wrappedSend(sendFn: suspend (AapCommand) -> Unit, command: AapCommand) { + sendFn(command) + val now = timeSource.currentTimeMillis() + runtimeState = runtimeState.copy( + lastSentCommand = command, + lastSentAtMs = now, + lastAncSentAtMs = if (command is AapCommand.SetAncMode) now else runtimeState.lastAncSentAtMs, + ) + } + + private fun handleRejectedCommand(command: AapCommand?) { + if (command is AapCommand.SetAncMode && command.mode == AapSetting.AncMode.Value.OFF) { + applyAncDecision(ancController.onOffRejected(_state.value, runtimeState.anc)) + } + } + + private fun scheduleTimer(key: EngineTimerKey, delayMs: Long) { + val currentScope = scope ?: return + timerJobs.remove(key)?.cancel() + timerJobs[key] = currentScope.launch { + delay(delayMs) + timerJobs.remove(key) + dispatch(AapEngineEvent.TimerFired(key)) + } + } + + private fun cancelTimer(key: EngineTimerKey) { + timerJobs.remove(key)?.cancel() + } + + private fun cancelAllTimers() { + timerJobs.values.forEach { it.cancel() } + timerJobs.clear() + } + + private fun applyTimerActions(actions: List) { + actions.forEach { action -> + when (action) { + is EngineTimerAction.Start -> scheduleTimer(action.key, action.delayMs) + is EngineTimerAction.Cancel -> cancelTimer(action.key) + } + } + } + + private fun isRecentAncSend(): Boolean = + runtimeState.lastAncSentAtMs > 0L && (timeSource.currentTimeMillis() - runtimeState.lastAncSentAtMs) <= 3000L + + private fun currentSendDebugInfo(): SendDebugInfo { + val sinceLastSend = + if (runtimeState.lastSentAtMs == 0L) -1L else timeSource.currentTimeMillis() - runtimeState.lastSentAtMs + val lastSend = runtimeState.lastSentCommand?.let { it::class.simpleName } ?: "none" + return SendDebugInfo(sinceLastSend = sinceLastSend, lastSend = lastSend) + } + + private fun logDeviceInfoDiagnostics(payload: ByteArray) { + val segments = AapDeviceInfoDiagnostics.describeSegments(payload) + log(TAG, INFO) { + "DeviceInfoDump #173: payload=${payload.size} bytes, segments=${segments.size}" + } + segments.forEach { seg -> + val label = when (seg.index) { + 0 -> "name" + 1 -> "modelNumber" + 2 -> "manufacturer" + 3 -> "serialNumber" + 4 -> "firmwareVersion" + 5 -> "firmwareVersionDup" + 6 -> "protocolVersion" + 7 -> "updaterAppId" + 8 -> "leftEarbudSerial" + 9 -> "rightEarbudSerial" + 10 -> "buildNumber" + 11 -> "encryptedBlob" + 12 -> "timestamp" + else -> "unknown" + } + val rendered = seg.utf8?.let { "\"$it\"" } ?: "" + log(TAG, INFO) { + "DeviceInfoDump #173: [${seg.index}] off=${seg.offset} len=${seg.length} ($label) $rendered hex=${seg.hex}" + } + } + } + + private fun logUnhandledMessage(message: AapMessage) { + val payloadHex = message.payload.joinToString(" ") { "%02X".format(it) } + val sendInfo = currentSendDebugInfo() + + if (message.commandType == CMD_SETTING && message.payload.size >= 2) { val settingId = message.payload[0].toInt() and 0xFF val value = message.payload[1].toInt() and 0xFF val boolHint = value.appleBoolHint() val tailHex = if (message.payload.size > 2) { - message.payload.copyOfRange(2, message.payload.size).joinToString(" ") { "%02X".format(it) } + message.payload.copyOfRange(2, message.payload.size) + .joinToString(" ") { "%02X".format(it) } } else { "" } @@ -385,13 +449,13 @@ internal class AapSessionEngine( boolHint?.let { append(" appleBool=$it") } append(" payload=${message.payload.size}B") if (tailHex.isNotEmpty()) append(" tail=[$tailHex]") - append(" sinceLastSend=${sinceSend}ms lastSend=$lastSend") + append(" sinceLastSend=${sendInfo.sinceLastSend}ms lastSend=${sendInfo.lastSend}") } } return } - if (message.commandType == 0x000C && message.payload.size >= 6) { + if (message.commandType == CMD_CONNECTED_DEVICE && message.payload.size >= 6) { val macRaw = message.payload.formatMac(reverse = false) val macReversed = message.payload.formatMac(reverse = true) val tailHex = if (message.payload.size > 6) { @@ -406,7 +470,7 @@ internal class AapSessionEngine( append(" payload=${message.payload.size}B") append(" macRaw=$macRaw macReversed=$macReversed") if (tailHex.isNotEmpty()) append(" tail=[$tailHex]") - append(" sinceLastSend=${sinceSend}ms lastSend=$lastSend") + append(" sinceLastSend=${sendInfo.sinceLastSend}ms lastSend=${sendInfo.lastSend}") } } return @@ -415,254 +479,73 @@ internal class AapSessionEngine( if (message.commandType in KNOWN_NON_SETTINGS_COMMANDS) { _state.value = _state.value.copy(lastMessageAt = timeSource.now()) log(TAG, VERBOSE) { - "Known cmd=0x${"%04X".format(message.commandType)} payload=${message.payload.size}B [$payloadHex] sinceLastSend=${sinceSend}ms lastSend=$lastSend" + "Known cmd=0x${"%04X".format(message.commandType)} payload=${message.payload.size}B [$payloadHex] sinceLastSend=${sendInfo.sinceLastSend}ms lastSend=${sendInfo.lastSend}" } return } log(TAG, INFO) { - "Unhandled cmd=0x${"%04X".format(message.commandType)} payload=${message.payload.size}B [$payloadHex] sinceLastSend=${sinceSend}ms lastSend=$lastSend" + "Unhandled cmd=0x${"%04X".format(message.commandType)} payload=${message.payload.size}B [$payloadHex] sinceLastSend=${sendInfo.sinceLastSend}ms lastSend=${sendInfo.lastSend}" } } - // ── Inference ─────────────────────────────────────────── - - private fun syncAllowOffOptionInference() { - cancelAllowOffOptionInference() - - val observedAncMode = latestObservedAncMode ?: _state.value.setting() ?: return - val earDetection = _state.value.setting() ?: return - val allowOffOption = _state.value.setting() - if (observedAncMode.current != AapSetting.AncMode.Value.OFF) return - if (!earDetection.isEitherPodInEar) return - if (allowOffOption?.enabled == true) return - - val inferenceScope = scope ?: return - allowOffInferenceJob = inferenceScope.launch { - delay(1500L) - - val latestAncMode = latestObservedAncMode ?: _state.value.setting() - val latestEarDetection = _state.value.setting() - val latestAllowOffOption = _state.value.setting() - if (latestAncMode?.current == AapSetting.AncMode.Value.OFF && - latestEarDetection?.isEitherPodInEar == true && - latestAllowOffOption?.enabled != true - ) { - _state.value = _state.value.withSetting( - AapSetting.AllowOffOption::class, - AapSetting.AllowOffOption(enabled = true), - ) - log(TAG) { - "Inferred: AllowOffOption = AllowOffOption(enabled=true) (from stable in-ear AncMode=OFF)" - } - } - allowOffInferenceJob = null - } - } - - private fun cancelAllowOffOptionInference() { - allowOffInferenceJob?.cancel() - allowOffInferenceJob = null - } - companion object { private val TAG = logTag("AAP", "Engine") + private const val CMD_SETTING = 0x0009 + private const val CMD_CONNECTED_DEVICE = 0x000C + private const val CMD_DEVICE_INFO = 0x001D private const val CMD_HID_DESCRIPTOR = 0x0017 private val KNOWN_NON_SETTINGS_COMMANDS = setOf( 0x0000, 0x0002, 0x002B, 0x004E, 0x0052, 0x0055, 0x0057, ) - - /** - * Diagnostic-only NUL-delimited segmentation of a 0x1D INFORMATION payload, used for the - * issue #173 engraving discovery logging. Not part of the production decode path — the - * production parser [eu.darken.capod.pods.core.apple.aap.protocol.DefaultAapDeviceProfile] - * intentionally stays ASCII-only to preserve existing wire-format assumptions. - * - * Skips binary header bytes until the first printable ASCII byte (mirroring the production - * decoder's start condition — all known captures begin the real data with the device name, - * which is always ASCII like "AirPods Pro"), then splits on NUL bytes. Each non-empty chunk - * becomes one [DeviceInfoSegment]. UTF-8 decode is attempted strictly; if a chunk contains - * invalid UTF-8 (e.g. the post-serials encrypted blob), [DeviceInfoSegment.utf8] is null - * and the caller falls back to [DeviceInfoSegment.hex]. - * - * Offsets are relative to the supplied payload (post message-header), matching what the - * caller holds in [AapMessage.payload]. - */ - internal fun describeDeviceInfoSegments(payload: ByteArray): List { - var start = 0 - while (start < payload.size) { - val b = payload[start].toInt() and 0xFF - if (b in 0x20..0x7E) break - start++ - } - if (start >= payload.size) return emptyList() - - val segments = mutableListOf() - var segIndex = 0 - var i = start - while (i < payload.size) { - while (i < payload.size && payload[i] == 0x00.toByte()) i++ - if (i >= payload.size) break - - val segStart = i - while (i < payload.size && payload[i] != 0x00.toByte()) i++ - val segBytes = payload.copyOfRange(segStart, i) - - val utf8: String? = try { - Charsets.UTF_8.newDecoder() - .onMalformedInput(java.nio.charset.CodingErrorAction.REPORT) - .onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT) - .decode(java.nio.ByteBuffer.wrap(segBytes)) - .toString() - } catch (_: java.nio.charset.CharacterCodingException) { - null - } - val hex = segBytes.joinToString("") { "%02X".format(it) } - - segments.add(DeviceInfoSegment(segIndex, segStart, segBytes.size, utf8, hex)) - segIndex++ - } - return segments - } - - internal data class DeviceInfoSegment( - val index: Int, - val offset: Int, - val length: Int, - val utf8: String?, - val hex: String, - ) } } -// ── HID descriptor frame tracker ─────────────────────── - /** - * Batches cmd 0x0017 HID descriptor frames and emits structured summaries. - * - * During case transitions AirPods send 800+ descriptor frames in ~20 seconds. - * Instead of logging each one, this tracker classifies each frame and batches - * consecutive bulk descriptor frames by (phase, fill), emitting a single summary - * line per batch. + * Synchronous engine events. User-initiated sends are intentionally not modeled here — they + * require a suspending transport callback and are handled directly in [AapSessionEngine.send]. */ -internal class HidTracker(private val log: (String) -> Unit) { - - private var bulkCount = 0 - private var bulkPhase: Int = -1 - private var bulkFill: Int = -1 - - fun consume(payload: ByteArray) { - when (val type = classify(payload)) { - is HidFrameType.ServiceDirectory -> { - flush() - val names = type.services.joinToString(", ") - log("HID: services=[$names] (${payload.size}B)") - } - - is HidFrameType.Descriptor -> { - if (type.phase != bulkPhase || type.fill != bulkFill) { - flush() - bulkPhase = type.phase - bulkFill = type.fill - } - bulkCount++ - } - - is HidFrameType.Terminator -> { - flush() - log("HID: terminator (${payload.size}B)") - } - - is HidFrameType.Other -> { - flush() - val hex = payload.joinToString(" ") { "%02X".format(it) } - log("HID: unknown (${payload.size}B) [$hex]") - } - } - } - - fun flush() { - if (bulkCount > 0) { - log("HID: $bulkCount descriptor frames phase=0x${"%02X".format(bulkPhase)} fill=0x${"%02X".format(bulkFill)}") - bulkCount = 0 - bulkPhase = -1 - bulkFill = -1 - } - } - - fun reset() { - bulkCount = 0 - bulkPhase = -1 - bulkFill = -1 - } - - internal sealed class HidFrameType { - data class ServiceDirectory(val services: List) : HidFrameType() - data class Descriptor(val phase: Int, val fill: Int) : HidFrameType() - data class Terminator(val payloadSize: Int) : HidFrameType() - data class Other(val payloadSize: Int) : HidFrameType() - } - - internal companion object { - private val TERMINATOR = byteArrayOf(0x00, 0x04, 0x00, 0x00, 0x01, 0x00, 0xFF.toByte()) - private val DESCRIPTOR_PREFIX = byteArrayOf(0x00, 0x04, 0x00, 0x00, 0x44, 0x00, 0x01) - - internal fun classify(payload: ByteArray): HidFrameType { - if (payload.size == 7 && payload.contentEquals(TERMINATOR)) { - return HidFrameType.Terminator(payload.size) - } - - if (payload.size >= 10 && payload.startsWith(DESCRIPTOR_PREFIX)) { - val phase = payload[8].toInt() and 0xFF - val fill = payload[9].toInt() and 0xFF - return HidFrameType.Descriptor(phase, fill) - } - - if (payload.isNotEmpty() && (payload[0].toInt() and 0xFF) == 0xFE) { - val services = parseServiceNames(payload) - return HidFrameType.ServiceDirectory(services) - } - - return HidFrameType.Other(payload.size) - } - - /** - * Extract service names from a directory frame by scanning for runs of printable - * ASCII (0x20-0x7E) of length >= 2, starting from byte 4. The count at byte 3 - * is compared to the extracted names and a warning is logged on mismatch. - */ - private fun parseServiceNames(payload: ByteArray): List { - if (payload.size < 5) return emptyList() - - val names = mutableListOf() - var i = 4 - while (i < payload.size) { - val b = payload[i].toInt() and 0xFF - if (b in 0x20..0x7E) { - val start = i - while (i < payload.size && (payload[i].toInt() and 0xFF) in 0x20..0x7E) i++ - if (i - start >= 2) { - names.add(String(payload, start, i - start, Charsets.US_ASCII)) - } - } else { - i++ - } - } - return names - } - } +internal sealed interface AapEngineEvent { + data class SessionStarted(val scope: CoroutineScope) : AapEngineEvent + data object HandshakeSent : AapEngineEvent + data object ResetRequested : AapEngineEvent + data class MessageReceived(val message: AapMessage) : AapEngineEvent + data class InboundUpdateDecoded(val update: AapInboundUpdate) : AapEngineEvent + data class TimerFired(val key: EngineTimerKey) : AapEngineEvent } -private fun ByteArray.startsWith(prefix: ByteArray): Boolean { - if (size < prefix.size) return false - for (i in prefix.indices) { - if (this[i] != prefix[i]) return false - } - return true +internal data class EngineRuntimeState( + val handshakeResponseReceived: Boolean = false, + val lastSentCommand: AapCommand? = null, + val lastSentAtMs: Long = 0L, + val lastAncSentAtMs: Long = 0L, + val outbound: OutboundRuntimeState = OutboundRuntimeState(), + val anc: AncRuntimeState = AncRuntimeState(), +) + +internal sealed interface EngineTimerKey { + data object AncDebounce : EngineTimerKey + data object AllowOffInference : EngineTimerKey + data object Verification : EngineTimerKey } -// ── Extension helpers ─────────────────────────────────── +internal sealed interface EngineTimerAction { + data class Start(val key: EngineTimerKey, val delayMs: Long) : EngineTimerAction + data class Cancel(val key: EngineTimerKey) : EngineTimerAction +} + +private data class OutboundSendContext( + val sendFn: suspend (AapCommand) -> Unit, + val previousState: AapPodState, + val previousVerification: VerificationState?, + val decision: OutboundDecision, +) + +private data class SendDebugInfo( + val sinceLastSend: Long, + val lastSend: String, +) /** Apple wire-format boolean: 0x01 = true, 0x02 = false. */ private fun Int.appleBoolHint(): Boolean? = when (this) { diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapSettingsCoordinator.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapSettingsCoordinator.kt index e954fa96..1be1e5ea 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapSettingsCoordinator.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapSettingsCoordinator.kt @@ -1,111 +1,93 @@ package eu.darken.capod.pods.core.apple.aap import eu.darken.capod.common.TimeSource -import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR -import eu.darken.capod.common.debug.logging.log -import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting import kotlin.reflect.KClass -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch /** - * Coordinates deferred outbound settings and post-send verification. - * Does not own transport — [AapConnection] keeps socket and state mutation responsibility. - * - * All setting commands (except [AapCommand.SetDeviceName]) can be deferred here when no pod - * is in ear, then flushed as a batch when a pod goes in. + * Pure helper for queue ordering, optimistic state updates, and verification predicates. + * Runtime ownership (timers, retries, pending queue state) lives in [AapSessionEngine]. */ internal class AapSettingsCoordinator( private val timeSource: TimeSource, ) { - /** Snapshot of pending state — returned from every mutating method. */ data class PendingSnapshot( val pendingAncMode: AapSetting.AncMode.Value?, val count: Int, ) - /** Verification result — caller handles domain inference (e.g. AllowOffOption). */ - sealed class VerificationOutcome { - data object Confirmed : VerificationOutcome() - data class Rejected(val command: AapCommand) : VerificationOutcome() - } + data class QueueResult( + val pendingCommands: List, + val optimisticState: AapPodState?, + val snapshot: PendingSnapshot, + ) - // Keyed by command class — newer commands of the same type overwrite. - private val pendingCommands = linkedMapOf, AapCommand>() - private var verificationJob: Job? = null + data class FlushResult( + val commands: List, + val pendingCommands: List, + val snapshot: PendingSnapshot, + ) - // ── Queue operations ──────────────────────────────────── - - /** - * Queue a command. Returns (optimistic state update or null, pending snapshot). - * ANC uses [PendingSnapshot.pendingAncMode] for display, so no optimistic setting update. - * Handles stale dependency: removes [AapCommand.SetAdaptiveAudioNoise] when ANC leaves ADAPTIVE. - */ - fun enqueue(command: AapCommand, currentState: AapPodState): Pair { + fun enqueue( + pendingCommands: List, + command: AapCommand, + currentState: AapPodState, + ): QueueResult { + var updated = pendingCommands if (command is AapCommand.SetAncMode && command.mode != AapSetting.AncMode.Value.ADAPTIVE) { - synchronized(pendingCommands) { pendingCommands.remove(AapCommand.SetAdaptiveAudioNoise::class) } + updated = updated.filterNot { it is AapCommand.SetAdaptiveAudioNoise } } - synchronized(pendingCommands) { pendingCommands[command::class] = command } + updated = updated.filterNot { it::class == command::class } + command - val optimistic = if (command !is AapCommand.SetAncMode) { - optimisticUpdate(currentState, command) - } else { - null - } - return optimistic to snapshot() + val optimistic = if (command !is AapCommand.SetAncMode) optimisticUpdate(currentState, command) else null + return QueueResult( + pendingCommands = updated, + optimisticState = optimistic, + snapshot = snapshot(updated), + ) } - /** Remove a specific command class from the queue. */ - fun removeFromQueue(commandClass: KClass): PendingSnapshot { - synchronized(pendingCommands) { pendingCommands.remove(commandClass) } - return snapshot() + fun removeFromQueue( + pendingCommands: List, + commandClass: KClass, + ): QueueResult { + val updated = pendingCommands.filterNot { it::class == commandClass } + return QueueResult( + pendingCommands = updated, + optimisticState = null, + snapshot = snapshot(updated), + ) } - /** Return sorted pending commands (ANC first) and clear queue. */ - fun flush(): Pair, PendingSnapshot> { - val commands: List - synchronized(pendingCommands) { - commands = pendingCommands.values.toList() - pendingCommands.clear() - } - // 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 { + fun flush(pendingCommands: List): FlushResult { + val sorted = pendingCommands.sortedBy { when (it) { is AapCommand.SetAllowOffOption -> 0 is AapCommand.SetAncMode -> 1 else -> 2 } } - return sorted to snapshot() + return FlushResult( + commands = sorted, + pendingCommands = emptyList(), + snapshot = snapshot(emptyList()), + ) } - /** Cancel verification, clear queue. */ - fun clear(): PendingSnapshot { - verificationJob?.cancel() - verificationJob = null - synchronized(pendingCommands) { pendingCommands.clear() } - return snapshot() + fun clear(): QueueResult = QueueResult( + pendingCommands = emptyList(), + optimisticState = null, + snapshot = snapshot(emptyList()), + ) + + fun snapshot(pendingCommands: List): PendingSnapshot { + val ancPending = pendingCommands.firstOrNull { it is AapCommand.SetAncMode } + ?.let { (it as AapCommand.SetAncMode).mode } + return PendingSnapshot(pendingAncMode = ancPending, count = pendingCommands.size) } - private fun snapshot(): PendingSnapshot { - val map = synchronized(pendingCommands) { pendingCommands.toMap() } - val ancPending = (map[AapCommand.SetAncMode::class] as? AapCommand.SetAncMode)?.mode - return PendingSnapshot(pendingAncMode = ancPending, count = map.size) - } - - // ── Optimistic update ─────────────────────────────────── - - /** - * Pure: compute the optimistic state for a command. - * Returns null when no update applies (ANC mode — uses pendingAncMode, or setting not yet reported). - */ fun optimisticUpdate(baseState: AapPodState, command: AapCommand): AapPodState? { val updated: Pair, AapSetting> = when (command) { is AapCommand.SetAncMode -> return null @@ -147,7 +129,10 @@ internal class AapSettingsCoordinator( } is AapCommand.SetEndCallMuteMic -> { baseState.setting() ?: return null - AapSetting.EndCallMuteMic::class to AapSetting.EndCallMuteMic(muteMic = command.muteMic, endCall = command.endCall) + AapSetting.EndCallMuteMic::class to AapSetting.EndCallMuteMic( + muteMic = command.muteMic, + endCall = command.endCall, + ) } is AapCommand.SetMicrophoneMode -> { AapSetting.MicrophoneMode::class to AapSetting.MicrophoneMode(mode = command.mode) @@ -169,17 +154,15 @@ internal class AapSettingsCoordinator( } is AapCommand.SetDeviceName -> { val currentInfo = baseState.deviceInfo ?: return null - return baseState.copy(deviceInfo = currentInfo.copy(name = command.name), lastMessageAt = timeSource.now()) + return baseState.copy( + deviceInfo = currentInfo.copy(name = command.name), + lastMessageAt = timeSource.now(), + ) } } return baseState.withSetting(updated.first, updated.second).copy(lastMessageAt = timeSource.now()) } - // ── Verification ──────────────────────────────────────── - - /** - * Pure: return an echo-verification check lambda for a command, or null if no verification. - */ fun verificationFor(command: AapCommand): ((AapPodState) -> Boolean)? = when (command) { is AapCommand.SetAncMode -> { s -> s.setting()?.current == command.mode } is AapCommand.SetAdaptiveAudioNoise -> { s -> s.setting()?.level == command.level } @@ -203,56 +186,4 @@ internal class AapSettingsCoordinator( is AapCommand.SetSleepDetection -> { s -> s.setting()?.enabled == command.enabled } is AapCommand.SetDeviceName -> null } - - /** - * Start a delayed verification for a sent command. - * After 1s, checks if the device echoed the expected state. - * On mismatch: resend once, then report [VerificationOutcome.Rejected]. - * Cancels any previous verification (single-flight). - */ - fun startVerification( - command: AapCommand, - scope: CoroutineScope, - stateProvider: () -> AapPodState, - sendRaw: suspend (AapCommand) -> Unit, - onOutcome: (VerificationOutcome) -> Unit, - ) { - val check = verificationFor(command) ?: return - verificationJob?.cancel() - verificationJob = scope.launch { - delay(1000L) - if (check(stateProvider())) { - onOutcome(VerificationOutcome.Confirmed) - return@launch - } - - // Abort if pods left ear — firmware is doing its own thing - val ear = stateProvider().setting() - if (ear != null && !ear.isEitherPodInEar) { - log(TAG) { "Verification aborted for ${command::class.simpleName}: no pod in ear" } - return@launch - } - - log(TAG) { "Divergence detected for ${command::class.simpleName}, re-sending" } - try { - sendRaw(command) - } catch (e: Exception) { - log(TAG, ERROR) { "Verification resend failed: $e" } - return@launch - } - - // Second check after retry - delay(1000L) - if (check(stateProvider())) { - onOutcome(VerificationOutcome.Confirmed) - } else { - log(TAG) { "Rejected after retry: ${command::class.simpleName}" } - onOutcome(VerificationOutcome.Rejected(command)) - } - } - } - - companion object { - private val TAG = logTag("AAP", "Coordinator") - } } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/HidTracker.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/HidTracker.kt new file mode 100644 index 00000000..2c4e119b --- /dev/null +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/HidTracker.kt @@ -0,0 +1,114 @@ +package eu.darken.capod.pods.core.apple.aap + +/** + * Batches cmd 0x0017 HID descriptor frames and emits structured summaries. + * + * During case transitions AirPods send 800+ descriptor frames in ~20 seconds. + * Instead of logging each one, this tracker classifies each frame and batches + * consecutive bulk descriptor frames by (phase, fill), emitting a single summary + * line per batch. + */ +internal class HidTracker(private val log: (String) -> Unit) { + + private var bulkCount = 0 + private var bulkPhase: Int = -1 + private var bulkFill: Int = -1 + + fun consume(payload: ByteArray) { + when (val type = classify(payload)) { + is HidFrameType.ServiceDirectory -> { + flush() + val names = type.services.joinToString(", ") + log("HID: services=[$names] (${payload.size}B)") + } + is HidFrameType.Descriptor -> { + if (bulkCount == 0) { + bulkPhase = type.phase + bulkFill = type.fill + bulkCount = 1 + } else if (bulkPhase == type.phase && bulkFill == type.fill) { + bulkCount++ + } else { + flush() + bulkPhase = type.phase + bulkFill = type.fill + bulkCount = 1 + } + } + is HidFrameType.Terminator -> { + flush() + log("HID: terminator (${type.payloadSize}B)") + } + is HidFrameType.Other -> { + flush() + val hex = payload.joinToString(" ") { "%02X".format(it) } + log("HID: unknown (${payload.size}B) [$hex]") + } + } + } + + fun flush() { + if (bulkCount == 0) return + log( + "HID: $bulkCount descriptor frames phase=0x${"%02X".format(bulkPhase)} fill=0x${"%02X".format(bulkFill)}" + ) + bulkCount = 0 + bulkPhase = -1 + bulkFill = -1 + } + + fun reset() { + bulkCount = 0 + bulkPhase = -1 + bulkFill = -1 + } + + sealed class HidFrameType { + data class ServiceDirectory(val services: List) : HidFrameType() + data class Descriptor(val phase: Int, val fill: Int) : HidFrameType() + data class Terminator(val payloadSize: Int) : HidFrameType() + data object Other : HidFrameType() + } + + companion object { + internal fun classify(payload: ByteArray): HidFrameType { + // Service directory frame — starts with FE 00 00 and contains repeated + // [len=4? ascii service name + 4B flags] blocks. For logging, extract names. + if (payload.size >= 4 && (payload[0].toInt() and 0xFF) == 0xFE) { + val services = mutableListOf() + var i = 4 + while (i + 7 < payload.size) { + val rawName = payload.copyOfRange(i, i + 4) + val name = rawName + .takeWhile { it != 0.toByte() } + .toByteArray() + .toString(Charsets.US_ASCII) + if (name.isNotBlank()) services += name + i += 8 + } + return HidFrameType.ServiceDirectory(services) + } + + // Bulk descriptor frames observed as: + // 00 04 00 00 44 00 01 A1 81 FF FF ... + // 00 04 00 00 44 00 01 C3 02 EF EF ... + if (payload.size >= 10 && + payload[0] == 0x00.toByte() && + payload[1] == 0x04.toByte() && + payload[4] == 0x44.toByte() && + payload[5] == 0x00.toByte() + ) { + val phase = payload[8].toInt() and 0xFF + val fill = payload[9].toInt() and 0xFF + return HidFrameType.Descriptor(phase = phase, fill = fill) + } + + // Small terminator frame observed as exactly 7 bytes ending in FF. + if (payload.size == 7 && payload.last() == 0xFF.toByte()) { + return HidFrameType.Terminator(payload.size) + } + + return HidFrameType.Other + } + } +} diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapAncControllerTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapAncControllerTest.kt new file mode 100644 index 00000000..54c23fce --- /dev/null +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapAncControllerTest.kt @@ -0,0 +1,152 @@ +package eu.darken.capod.pods.core.apple.aap + +import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import testhelpers.BaseTest +import java.time.Instant + +class AapAncControllerTest : BaseTest() { + + private val controller = AapAncController() + private val now = Instant.ofEpochMilli(1000L) + private val supportedModes = listOf( + AapSetting.AncMode.Value.OFF, + AapSetting.AncMode.Value.ON, + AapSetting.AncMode.Value.ADAPTIVE, + ) + + private fun podStateWith(vararg settings: Pair, AapSetting>): AapPodState = + AapPodState( + connectionState = AapPodState.ConnectionState.READY, + settings = settings.toMap(), + ) + + @Test + fun `first in-ear OFF applies immediately and schedules AllowOff inference`() { + val podState = podStateWith( + AapSetting.EarDetection::class to AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR, + secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR, + ), + ) + + val decision = controller.onAncSetting( + podState = podState, + runtimeState = AncRuntimeState(), + key = AapSetting.AncMode::class, + value = AapSetting.AncMode( + current = AapSetting.AncMode.Value.OFF, + supported = supportedModes, + ), + isRecentAncSend = false, + now = now, + ) + + decision.podState.setting()!!.current shouldBe AapSetting.AncMode.Value.OFF + decision.runtimeState.pendingDebouncedAnc.shouldBeNull() + decision.timerActions shouldBe listOf( + EngineTimerAction.Start(EngineTimerKey.AllowOffInference, 1500L), + EngineTimerAction.Cancel(EngineTimerKey.AncDebounce), + ) + } + + @Test + fun `in-case OFF does not schedule AllowOff inference`() { + val podState = podStateWith( + AapSetting.EarDetection::class to AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE, + secondaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE, + ), + ) + + val decision = controller.onAncSetting( + podState = podState, + runtimeState = AncRuntimeState(), + key = AapSetting.AncMode::class, + value = AapSetting.AncMode( + current = AapSetting.AncMode.Value.OFF, + supported = supportedModes, + ), + isRecentAncSend = false, + now = now, + ) + + decision.timerActions shouldBe listOf( + EngineTimerAction.Cancel(EngineTimerKey.AllowOffInference), + EngineTimerAction.Cancel(EngineTimerKey.AncDebounce), + ) + } + + @Test + fun `unsolicited ANC change is deferred through debounce state`() { + val podState = podStateWith( + AapSetting.EarDetection::class to AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR, + secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR, + ), + AapSetting.AncMode::class to AapSetting.AncMode( + current = AapSetting.AncMode.Value.ON, + supported = supportedModes, + ), + ) + + val decision = controller.onAncSetting( + podState = podState, + runtimeState = AncRuntimeState(), + key = AapSetting.AncMode::class, + value = AapSetting.AncMode( + current = AapSetting.AncMode.Value.ADAPTIVE, + supported = supportedModes, + ), + isRecentAncSend = false, + now = now, + ) + + decision.podState.setting()!!.current shouldBe AapSetting.AncMode.Value.ON + decision.runtimeState.pendingDebouncedAnc!!.value.current shouldBe AapSetting.AncMode.Value.ADAPTIVE + decision.timerActions shouldBe listOf( + EngineTimerAction.Cancel(EngineTimerKey.AllowOffInference), + EngineTimerAction.Start(EngineTimerKey.AncDebounce, 1500L), + ) + } + + @Test + fun `AllowOff timer infers true from stable in-ear OFF`() { + val podState = podStateWith( + AapSetting.EarDetection::class to AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR, + secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR, + ), + AapSetting.AncMode::class to AapSetting.AncMode( + current = AapSetting.AncMode.Value.OFF, + supported = supportedModes, + ), + ) + + val decision = controller.onAllowOffInferenceTimerFired( + podState = podState, + runtimeState = AncRuntimeState( + latestObservedAncMode = AapSetting.AncMode( + current = AapSetting.AncMode.Value.OFF, + supported = supportedModes, + ), + ), + ) + + decision.podState.setting()!!.enabled shouldBe true + } + + @Test + fun `OFF rejection forces AllowOff false and cancels inference timer`() { + val podState = podStateWith( + AapSetting.AllowOffOption::class to AapSetting.AllowOffOption(enabled = true), + ) + + val decision = controller.onOffRejected(podState, AncRuntimeState()) + + decision.podState.setting()!!.enabled shouldBe false + decision.timerActions shouldBe listOf(EngineTimerAction.Cancel(EngineTimerKey.AllowOffInference)) + } +} diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapConnectionTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapDeviceInfoDiagnosticsTest.kt similarity index 92% rename from app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapConnectionTest.kt rename to app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapDeviceInfoDiagnosticsTest.kt index e899de00..270372de 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapConnectionTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapDeviceInfoDiagnosticsTest.kt @@ -10,7 +10,7 @@ import org.junit.jupiter.api.Test import testhelpers.BaseTest /** - * Tests for the issue #173 diagnostic helper [AapSessionEngine.describeDeviceInfoSegments]. + * Tests for the issue #173 diagnostic helper [AapDeviceInfoDiagnostics.describeSegments]. * * The helper must: * - Work on real captured 0x1D payloads (matches production decoder on ASCII slots). @@ -18,7 +18,7 @@ import testhelpers.BaseTest * - Always emit segments for any payload shape, even when [DefaultAapDeviceProfile.decodeDeviceInfo] * would return null (malformed / unknown-shaped packets). */ -class AapConnectionTest : BaseTest() { +class AapDeviceInfoDiagnosticsTest : BaseTest() { private fun hex(s: String): ByteArray = s .replace("\n", " ") @@ -50,7 +50,7 @@ class AapConnectionTest : BaseTest() { """ ) - val segments = AapSessionEngine.describeDeviceInfoSegments(payload) + val segments = AapDeviceInfoDiagnostics.describeSegments(payload) // The first five segments mirror the production decoder's name/modelNumber/manufacturer/ // serialNumber/firmwareVersion slots. @@ -81,7 +81,7 @@ class AapConnectionTest : BaseTest() { engraving.toByteArray(Charsets.UTF_8) + byteArrayOf(0x00) + "A3048".toByteArray(Charsets.UTF_8) + byteArrayOf(0x00) - val segments = AapSessionEngine.describeDeviceInfoSegments(payload) + val segments = AapDeviceInfoDiagnostics.describeSegments(payload) segments.size shouldBe 2 segments[0].utf8 shouldBe engraving @@ -101,7 +101,7 @@ class AapConnectionTest : BaseTest() { DefaultAapDeviceProfile(PodModel.AIRPODS_PRO2_USBC).decodeDeviceInfo(message).shouldBeNull() - val segments = AapSessionEngine.describeDeviceInfoSegments(payload) + val segments = AapDeviceInfoDiagnostics.describeSegments(payload) segments.size shouldBe 2 segments[0].utf8 shouldBe "Name" segments[1].utf8 shouldBe "Model" diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapSessionEngineTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapSessionEngineTest.kt index f9bd0e05..6fd944f1 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapSessionEngineTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapSessionEngineTest.kt @@ -117,6 +117,45 @@ class AapSessionEngineTest : BaseTest() { engine.state.value.connectionState shouldBe AapPodState.ConnectionState.DISCONNECTED } + @Test + fun `reset cancels pending runtime timers`() = runTest(UnconfinedTestDispatcher()) { + val supportedModes = listOf( + AapSetting.AncMode.Value.ON, + AapSetting.AncMode.Value.TRANSPARENCY, + ) + var nextSetting: Pair, AapSetting>? = null + val profile = mockProfile { + every { decodeSetting(any()) } answers { nextSetting } + } + val engine = AapSessionEngine(profile, timeSource) + engine.start(this as TestScope) + engine.onHandshakeSent() + + nextSetting = settingPair(AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR, + secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR, + )) + engine.processMessage(dummyMessage(commandType = 0x0002)) + + nextSetting = settingPair(AapSetting.AncMode( + current = AapSetting.AncMode.Value.ON, + supported = supportedModes, + )) + engine.processMessage(dummyMessage()) + + nextSetting = settingPair(AapSetting.AncMode( + current = AapSetting.AncMode.Value.TRANSPARENCY, + supported = supportedModes, + )) + engine.processMessage(dummyMessage()) + + engine.reset() + advanceTimeBy(1600L) + + engine.state.value.connectionState shouldBe AapPodState.ConnectionState.DISCONNECTED + engine.state.value.setting().shouldBeNull() + } + @Test fun `first non-0x0009 message transitions HANDSHAKING to READY`() { val engine = createEngine() @@ -249,6 +288,7 @@ class AapSessionEngineTest : BaseTest() { sentCommands.size shouldBe 2 sentCommands[0] shouldBe AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE) sentCommands[1] shouldBe AapCommand.SetConversationalAwareness(true) + engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE nextSetting = AapSetting.AncMode::class as KClass to ancSetting.copy(current = AapSetting.AncMode.Value.ON) @@ -256,11 +296,13 @@ class AapSessionEngineTest : BaseTest() { // This would still be ADAPTIVE if the mixed flush path lost the ANC send marker and debounced. engine.state.value.setting()!!.current shouldBe AapSetting.AncMode.Value.ON + engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE advanceTimeBy(1100L) sentCommands.size shouldBe 3 sentCommands[2] shouldBe AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE) + engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE } } @@ -508,6 +550,7 @@ class AapSessionEngineTest : BaseTest() { engine.processMessage(dummyMessage()) advanceTimeBy(2100L) + engine.state.value.pendingAncMode.shouldBeNull() engine.state.value.setting()?.enabled shouldBe false sentCommands shouldBe listOf( AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF), @@ -516,6 +559,50 @@ class AapSessionEngineTest : BaseTest() { } } + +@Test +fun `matching ANC echo clears pending mode without optimistic current overwrite`() = runTest(UnconfinedTestDispatcher()) { + val supportedModes = listOf( + AapSetting.AncMode.Value.OFF, + AapSetting.AncMode.Value.ON, + AapSetting.AncMode.Value.ADAPTIVE, + ) + var nextSetting: Pair, AapSetting>? = null + val profile = mockProfile { + every { decodeSetting(any()) } answers { nextSetting } + } + val engine = AapSessionEngine(profile, timeSource) + engine.startReady(this as TestScope) + + nextSetting = settingPair(AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR, + secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR, + )) + engine.processMessage(dummyMessage()) + + nextSetting = settingPair(AapSetting.AncMode( + current = AapSetting.AncMode.Value.ON, + supported = supportedModes, + )) + engine.processMessage(dummyMessage()) + + val sentCommands = mutableListOf() + engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)) { sentCommands += it } + + engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE + engine.state.value.setting()!!.current shouldBe AapSetting.AncMode.Value.ON + + nextSetting = settingPair(AapSetting.AncMode( + current = AapSetting.AncMode.Value.ADAPTIVE, + supported = supportedModes, + )) + engine.processMessage(dummyMessage()) + + engine.state.value.pendingAncMode.shouldBeNull() + engine.state.value.setting()!!.current shouldBe AapSetting.AncMode.Value.ADAPTIVE + sentCommands shouldBe listOf(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)) +} + // ── ANC Debounce ──────────────────────────────────────── @Nested diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapSettingsCoordinatorTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapSettingsCoordinatorTest.kt index 66ab9a65..917df67a 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapSettingsCoordinatorTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapSettingsCoordinatorTest.kt @@ -12,14 +12,11 @@ import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeInstanceOf import io.mockk.every import io.mockk.mockk -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.advanceTimeBy -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 AapSettingsCoordinatorTest : BaseTest() { @@ -30,15 +27,13 @@ class AapSettingsCoordinatorTest : BaseTest() { private fun createCoordinator() = AapSettingsCoordinator(timeSource) - private fun stateWithSetting(vararg settings: Pair, AapSetting>): AapPodState { + private fun stateWithSetting(vararg settings: Pair, AapSetting>): AapPodState { val map = settings.toMap() return AapPodState(connectionState = AapPodState.ConnectionState.READY, settings = map) } - // ── Enqueue ───────────────────────────────────────────── - @Nested - inner class EnqueueTests { + inner class QueueTests { @Test fun `enqueue returns snapshot with correct count`() { @@ -47,22 +42,21 @@ class AapSettingsCoordinatorTest : BaseTest() { AapSetting.ConversationalAwareness::class to AapSetting.ConversationalAwareness(enabled = false), ) - val (_, snapshot) = coord.enqueue(AapCommand.SetConversationalAwareness(true), state) + val result = coord.enqueue(emptyList(), AapCommand.SetConversationalAwareness(true), state) - snapshot.count shouldBe 1 - snapshot.pendingAncMode.shouldBeNull() + result.snapshot.count shouldBe 1 + result.snapshot.pendingAncMode.shouldBeNull() } @Test fun `enqueue ANC returns pendingAncMode in snapshot`() { val coord = createCoordinator() - val state = stateWithSetting() - val (optimistic, snapshot) = coord.enqueue(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE), state) + val result = coord.enqueue(emptyList(), AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE), stateWithSetting()) - optimistic.shouldBeNull() // ANC uses pendingAncMode, not optimistic update - snapshot.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE - snapshot.count shouldBe 1 + result.optimisticState.shouldBeNull() + result.snapshot.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE + result.snapshot.count shouldBe 1 } @Test @@ -72,12 +66,12 @@ class AapSettingsCoordinatorTest : BaseTest() { AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = 50), ) - coord.enqueue(AapCommand.SetToneVolume(60), state) - val (optimistic, snapshot) = coord.enqueue(AapCommand.SetToneVolume(80), state) + val first = coord.enqueue(emptyList(), AapCommand.SetToneVolume(60), state) + val second = coord.enqueue(first.pendingCommands, AapCommand.SetToneVolume(80), state) - snapshot.count shouldBe 1 // Not 2 - optimistic.shouldNotBeNull() - optimistic.setting()!!.level shouldBe 80 + second.snapshot.count shouldBe 1 + second.optimisticState.shouldNotBeNull() + second.optimisticState!!.setting()!!.level shouldBe 80 } @Test @@ -87,11 +81,11 @@ class AapSettingsCoordinatorTest : BaseTest() { AapSetting.AdaptiveAudioNoise::class to AapSetting.AdaptiveAudioNoise(level = 50), ) - coord.enqueue(AapCommand.SetAdaptiveAudioNoise(70), state) - val (_, snapshot) = coord.enqueue(AapCommand.SetAncMode(AapSetting.AncMode.Value.ON), state) + val first = coord.enqueue(emptyList(), AapCommand.SetAdaptiveAudioNoise(70), state) + val second = coord.enqueue(first.pendingCommands, AapCommand.SetAncMode(AapSetting.AncMode.Value.ON), state) - snapshot.count shouldBe 1 // Only ANC, noise removed - snapshot.pendingAncMode shouldBe AapSetting.AncMode.Value.ON + second.snapshot.count shouldBe 1 + second.snapshot.pendingAncMode shouldBe AapSetting.AncMode.Value.ON } @Test @@ -101,10 +95,10 @@ class AapSettingsCoordinatorTest : BaseTest() { AapSetting.AdaptiveAudioNoise::class to AapSetting.AdaptiveAudioNoise(level = 50), ) - coord.enqueue(AapCommand.SetAdaptiveAudioNoise(70), state) - val (_, snapshot) = coord.enqueue(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE), state) + val first = coord.enqueue(emptyList(), AapCommand.SetAdaptiveAudioNoise(70), state) + val second = coord.enqueue(first.pendingCommands, AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE), state) - snapshot.count shouldBe 2 + second.snapshot.count shouldBe 2 } @Test @@ -114,27 +108,21 @@ class AapSettingsCoordinatorTest : BaseTest() { AapSetting.VolumeSwipe::class to AapSetting.VolumeSwipe(enabled = false), ) - val (optimistic, _) = coord.enqueue(AapCommand.SetVolumeSwipe(true), state) + val result = coord.enqueue(emptyList(), AapCommand.SetVolumeSwipe(true), state) - optimistic.shouldNotBeNull() - optimistic.setting()!!.enabled shouldBe true + result.optimisticState.shouldNotBeNull() + result.optimisticState!!.setting()!!.enabled shouldBe true } - } - - // ── Flush ─────────────────────────────────────────────── - - @Nested - inner class FlushTests { @Test fun `flush empty queue returns empty list and zero snapshot`() { val coord = createCoordinator() - val (commands, snapshot) = coord.flush() + val result = coord.flush(emptyList()) - commands.shouldBeEmpty() - snapshot.count shouldBe 0 - snapshot.pendingAncMode.shouldBeNull() + result.commands.shouldBeEmpty() + result.snapshot.count shouldBe 0 + result.snapshot.pendingAncMode.shouldBeNull() } @Test @@ -144,14 +132,14 @@ class AapSettingsCoordinatorTest : BaseTest() { AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = 50), ) - coord.enqueue(AapCommand.SetToneVolume(80), state) - coord.enqueue(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE), state) + val first = coord.enqueue(emptyList(), AapCommand.SetToneVolume(80), state) + val second = coord.enqueue(first.pendingCommands, AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE), state) + val result = coord.flush(second.pendingCommands) - val (commands, _) = coord.flush() - - commands shouldHaveSize 2 - commands[0].shouldBeInstanceOf() - commands[1].shouldBeInstanceOf() + result.commands shouldHaveSize 2 + result.commands[0].shouldBeInstanceOf() + result.commands[1].shouldBeInstanceOf() + result.pendingCommands.shouldBeEmpty() } @Test @@ -161,34 +149,44 @@ class AapSettingsCoordinatorTest : BaseTest() { 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 first = coord.enqueue(emptyList(), AapCommand.SetToneVolume(80), state) + val second = coord.enqueue(first.pendingCommands, AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF), state) + val third = coord.enqueue(second.pendingCommands, AapCommand.SetAllowOffOption(true), state) + val result = coord.flush(third.pendingCommands) - val (commands, _) = coord.flush() - - commands shouldHaveSize 3 - commands[0].shouldBeInstanceOf() - commands[1].shouldBeInstanceOf() - commands[2].shouldBeInstanceOf() + result.commands shouldHaveSize 3 + result.commands[0].shouldBeInstanceOf() + result.commands[1].shouldBeInstanceOf() + result.commands[2].shouldBeInstanceOf() } @Test - fun `flush clears queue`() { + fun `removeFromQueue returns updated snapshot`() { val coord = createCoordinator() - val state = stateWithSetting() + val state = stateWithSetting( + AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = 50), + ) - coord.enqueue(AapCommand.SetAncMode(AapSetting.AncMode.Value.ON), state) - coord.flush() + val first = coord.enqueue(emptyList(), AapCommand.SetAncMode(AapSetting.AncMode.Value.ON), state) + val second = coord.enqueue(first.pendingCommands, AapCommand.SetToneVolume(80), state) + val result = coord.removeFromQueue(second.pendingCommands, AapCommand.SetAncMode::class) - val (commands, snapshot) = coord.flush() - commands.shouldBeEmpty() - snapshot.count shouldBe 0 + result.snapshot.count shouldBe 1 + result.snapshot.pendingAncMode.shouldBeNull() + } + + @Test + fun `clear returns empty snapshot`() { + val coord = createCoordinator() + + val result = coord.clear() + + result.snapshot.count shouldBe 0 + result.snapshot.pendingAncMode.shouldBeNull() + result.pendingCommands.shouldBeEmpty() } } - // ── Optimistic Update ─────────────────────────────────── - @Nested inner class OptimisticUpdateTests { @@ -203,7 +201,7 @@ class AapSettingsCoordinatorTest : BaseTest() { @Test fun `returns null when setting not yet in state`() { val coord = createCoordinator() - val state = stateWithSetting() // No ToneVolume in state + val state = stateWithSetting() coord.optimisticUpdate(state, AapCommand.SetToneVolume(80)).shouldBeNull() } @@ -267,10 +265,8 @@ class AapSettingsCoordinatorTest : BaseTest() { } } - // ── Verification ──────────────────────────────────────── - @Nested - inner class VerificationTests { + inner class VerificationPredicateTests { @Test fun `verificationFor returns null for SetDeviceName`() { @@ -299,193 +295,5 @@ class AapSettingsCoordinatorTest : BaseTest() { check(matching) shouldBe true check(mismatched) shouldBe false } - - @Test - fun `confirmed when state matches after delay`() = runTest(UnconfinedTestDispatcher()) { - val coord = createCoordinator() - var outcome: AapSettingsCoordinator.VerificationOutcome? = null - val state = stateWithSetting( - AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = 80), - ) - - coord.startVerification( - command = AapCommand.SetToneVolume(80), - scope = this, - stateProvider = { state }, - sendRaw = { }, - onOutcome = { outcome = it }, - ) - - advanceTimeBy(1100L) - outcome.shouldBeInstanceOf() - } - - @Test - fun `resends on mismatch then confirms if retry succeeds`() = runTest(UnconfinedTestDispatcher()) { - val coord = createCoordinator() - var outcome: AapSettingsCoordinator.VerificationOutcome? = null - var sendCount = 0 - var currentLevel = 50 // Initially wrong - - coord.startVerification( - command = AapCommand.SetToneVolume(80), - scope = this, - stateProvider = { - stateWithSetting(AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = currentLevel)) - }, - sendRaw = { - sendCount++ - currentLevel = 80 // Simulate device accepting on retry - }, - onOutcome = { outcome = it }, - ) - - advanceTimeBy(1100L) // First check — mismatch, triggers resend - sendCount shouldBe 1 - advanceTimeBy(1100L) // Second check — matches now - outcome.shouldBeInstanceOf() - } - - @Test - fun `rejected after failed retry`() = runTest(UnconfinedTestDispatcher()) { - val coord = createCoordinator() - var outcome: AapSettingsCoordinator.VerificationOutcome? = null - - coord.startVerification( - command = AapCommand.SetToneVolume(80), - scope = this, - stateProvider = { - stateWithSetting(AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = 50)) // Always wrong - }, - sendRaw = { }, - onOutcome = { outcome = it }, - ) - - advanceTimeBy(2200L) // Both checks fail - outcome.shouldBeInstanceOf() - } - - @Test - fun `aborts when no pod in ear`() = runTest(UnconfinedTestDispatcher()) { - val coord = createCoordinator() - var outcome: AapSettingsCoordinator.VerificationOutcome? = null - var sendCount = 0 - - val state = AapPodState( - connectionState = AapPodState.ConnectionState.READY, - settings = mapOf( - AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = 50), - AapSetting.EarDetection::class to AapSetting.EarDetection( - primaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE, - secondaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE, - ), - ), - ) - - coord.startVerification( - command = AapCommand.SetToneVolume(80), - scope = this, - stateProvider = { state }, - sendRaw = { sendCount++ }, - onOutcome = { outcome = it }, - ) - - advanceTimeBy(2200L) - sendCount shouldBe 0 // No resend attempt - outcome.shouldBeNull() // No outcome — aborted silently - } - - @Test - fun `new startVerification cancels previous`() = runTest(UnconfinedTestDispatcher()) { - val coord = createCoordinator() - var firstOutcome: AapSettingsCoordinator.VerificationOutcome? = null - var secondOutcome: AapSettingsCoordinator.VerificationOutcome? = null - - val state = stateWithSetting( - AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = 80), - AapSetting.PressSpeed::class to AapSetting.PressSpeed(value = AapSetting.PressSpeed.Value.DEFAULT), - ) - - coord.startVerification( - command = AapCommand.SetToneVolume(80), - scope = this, - stateProvider = { state }, - sendRaw = { }, - onOutcome = { firstOutcome = it }, - ) - - // Start second verification before first completes - coord.startVerification( - command = AapCommand.SetPressSpeed(AapSetting.PressSpeed.Value.DEFAULT), - scope = this, - stateProvider = { state }, - sendRaw = { }, - onOutcome = { secondOutcome = it }, - ) - - advanceTimeBy(1100L) - firstOutcome.shouldBeNull() // Cancelled, never completed - secondOutcome.shouldBeInstanceOf() - } - } - - // ── Clear ─────────────────────────────────────────────── - - @Nested - inner class ClearTests { - - @Test - fun `clear empties queue and returns zero snapshot`() { - val coord = createCoordinator() - val state = stateWithSetting() - - coord.enqueue(AapCommand.SetAncMode(AapSetting.AncMode.Value.ON), state) - val snapshot = coord.clear() - - snapshot.count shouldBe 0 - snapshot.pendingAncMode.shouldBeNull() - } - - @Test - fun `clear cancels verification`() = runTest(UnconfinedTestDispatcher()) { - val coord = createCoordinator() - var outcome: AapSettingsCoordinator.VerificationOutcome? = null - - coord.startVerification( - command = AapCommand.SetToneVolume(80), - scope = this, - stateProvider = { - stateWithSetting(AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = 80)) - }, - sendRaw = { }, - onOutcome = { outcome = it }, - ) - - coord.clear() - advanceTimeBy(2000L) - outcome.shouldBeNull() // Cancelled, no outcome - } - } - - // ── Pending State ─────────────────────────────────────── - - @Nested - inner class PendingStateTests { - - @Test - fun `removeFromQueue returns updated snapshot`() { - val coord = createCoordinator() - val state = stateWithSetting( - AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = 50), - ) - - coord.enqueue(AapCommand.SetAncMode(AapSetting.AncMode.Value.ON), state) - coord.enqueue(AapCommand.SetToneVolume(80), state) - - val snapshot = coord.removeFromQueue(AapCommand.SetAncMode::class) - - snapshot.count shouldBe 1 - snapshot.pendingAncMode.shouldBeNull() - } } }