refactor(aap): Extract controllers and centralize timer lifecycle

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).
This commit is contained in:
darken
2026-04-16 10:32:21 +02:00
committed by Matthias Urhahn
parent 14da81d231
commit f141ce3f2a
11 changed files with 1300 additions and 901 deletions
@@ -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<out AapSetting>,
val value: AapSetting.AncMode,
val previous: AapSetting?,
)
internal data class AncDecision(
val podState: AapPodState,
val runtimeState: AncRuntimeState,
val timerActions: List<EngineTimerAction> = emptyList(),
val logs: List<String> = emptyList(),
)
internal class AapAncController {
fun onAncSetting(
podState: AapPodState,
runtimeState: AncRuntimeState,
key: KClass<out AapSetting>,
value: AapSetting.AncMode,
isRecentAncSend: Boolean,
now: Instant,
): AncDecision {
val previous = podState.settings[key]
val updatedRuntime = runtimeState.copy(latestObservedAncMode = value)
val timerActions = mutableListOf<EngineTimerAction>()
timerActions += planAllowOffInferenceTimer(podState, updatedRuntime)
val isFirstAncMode = podState.setting<AapSetting.AncMode>() == 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<AapSetting.AncMode>()
val latestEarDetection = podState.setting<AapSetting.EarDetection>()
val latestAllowOffOption = podState.setting<AapSetting.AllowOffOption>()
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<AapSetting.AllowOffOption>()
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<out AapSetting>,
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<EngineTimerAction> {
val observedAncMode = runtimeState.latestObservedAncMode ?: podState.setting<AapSetting.AncMode>()
val earDetection = podState.setting<AapSetting.EarDetection>()
val allowOffOption = podState.setting<AapSetting.AllowOffOption>()
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))
}
}
}
@@ -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<DeviceInfoSegment> {
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<DeviceInfoSegment>()
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,
)
@@ -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<AapPodState.BatteryType, AapPodState.Battery>) : AapInboundUpdate
data class PrivateKeys(val result: KeyExchangeResult) : AapInboundUpdate
data class DeviceInfo(val info: AapDeviceInfo) : AapInboundUpdate
data class Setting(val key: KClass<out AapSetting>, 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
}
}
@@ -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<AapCommand> = emptyList(),
val verification: VerificationState? = null,
)
internal data class OutboundDecision(
val podState: AapPodState,
val runtimeState: OutboundRuntimeState,
val commandsToSend: List<AapCommand> = emptyList(),
val timerActions: List<EngineTimerAction> = emptyList(),
val logs: List<String> = 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<AapSetting.EarDetection>()
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<AapSetting.EarDetection>()
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,
)
}
File diff suppressed because it is too large Load Diff
@@ -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<AapCommand>,
val optimisticState: AapPodState?,
val snapshot: PendingSnapshot,
)
// Keyed by command class — newer commands of the same type overwrite.
private val pendingCommands = linkedMapOf<KClass<out AapCommand>, AapCommand>()
private var verificationJob: Job? = null
data class FlushResult(
val commands: List<AapCommand>,
val pendingCommands: List<AapCommand>,
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<AapPodState?, PendingSnapshot> {
fun enqueue(
pendingCommands: List<AapCommand>,
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<out AapCommand>): PendingSnapshot {
synchronized(pendingCommands) { pendingCommands.remove(commandClass) }
return snapshot()
fun removeFromQueue(
pendingCommands: List<AapCommand>,
commandClass: KClass<out AapCommand>,
): 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<List<AapCommand>, PendingSnapshot> {
val commands: List<AapCommand>
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<AapCommand>): 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<AapCommand>): 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<KClass<out AapSetting>, AapSetting> = when (command) {
is AapCommand.SetAncMode -> return null
@@ -147,7 +129,10 @@ internal class AapSettingsCoordinator(
}
is AapCommand.SetEndCallMuteMic -> {
baseState.setting<AapSetting.EndCallMuteMic>() ?: 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<AapSetting.AncMode>()?.current == command.mode }
is AapCommand.SetAdaptiveAudioNoise -> { s -> s.setting<AapSetting.AdaptiveAudioNoise>()?.level == command.level }
@@ -203,56 +186,4 @@ internal class AapSettingsCoordinator(
is AapCommand.SetSleepDetection -> { s -> s.setting<AapSetting.SleepDetection>()?.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<AapSetting.EarDetection>()
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")
}
}
@@ -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<String>) : 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<String>()
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
}
}
}
@@ -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<kotlin.reflect.KClass<out AapSetting>, 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<AapSetting.AncMode>()!!.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<AapSetting.AncMode>()!!.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<AapSetting.AllowOffOption>()!!.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<AapSetting.AllowOffOption>()!!.enabled shouldBe false
decision.timerActions shouldBe listOf(EngineTimerAction.Cancel(EngineTimerKey.AllowOffInference))
}
}
@@ -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"
@@ -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<KClass<out AapSetting>, 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<AapSetting.AncMode>().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<out AapSetting> 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<AapSetting.AncMode>()!!.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<AapSetting.AllowOffOption>()?.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<KClass<out AapSetting>, 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<AapCommand>()
engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)) { sentCommands += it }
engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
engine.state.value.setting<AapSetting.AncMode>()!!.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<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ADAPTIVE
sentCommands shouldBe listOf(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE))
}
// ── ANC Debounce ────────────────────────────────────────
@Nested
@@ -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<kotlin.reflect.KClass<out AapSetting>, AapSetting>): AapPodState {
private fun stateWithSetting(vararg settings: Pair<KClass<out AapSetting>, 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<AapSetting.ToneVolume>()!!.level shouldBe 80
second.snapshot.count shouldBe 1
second.optimisticState.shouldNotBeNull()
second.optimisticState!!.setting<AapSetting.ToneVolume>()!!.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<AapSetting.VolumeSwipe>()!!.enabled shouldBe true
result.optimisticState.shouldNotBeNull()
result.optimisticState!!.setting<AapSetting.VolumeSwipe>()!!.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<AapCommand.SetAncMode>()
commands[1].shouldBeInstanceOf<AapCommand.SetToneVolume>()
result.commands shouldHaveSize 2
result.commands[0].shouldBeInstanceOf<AapCommand.SetAncMode>()
result.commands[1].shouldBeInstanceOf<AapCommand.SetToneVolume>()
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<AapCommand.SetAllowOffOption>()
commands[1].shouldBeInstanceOf<AapCommand.SetAncMode>()
commands[2].shouldBeInstanceOf<AapCommand.SetToneVolume>()
result.commands shouldHaveSize 3
result.commands[0].shouldBeInstanceOf<AapCommand.SetAllowOffOption>()
result.commands[1].shouldBeInstanceOf<AapCommand.SetAncMode>()
result.commands[2].shouldBeInstanceOf<AapCommand.SetToneVolume>()
}
@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<AapSettingsCoordinator.VerificationOutcome.Confirmed>()
}
@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<AapSettingsCoordinator.VerificationOutcome.Confirmed>()
}
@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<AapSettingsCoordinator.VerificationOutcome.Rejected>()
}
@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<AapSettingsCoordinator.VerificationOutcome.Confirmed>()
}
}
// ── 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()
}
}
}