feat(aap): Unified command queue with ear-detection gating and session engine extraction

Queue all setting commands when no pod is in ear, flush when a pod goes in. Enable the adaptive noise slider when ADAPTIVE mode is pending. Show an info box when settings changes are pending.

Extract AapSessionEngine (state, send path, message processing, inference) and AapSettingsCoordinator (queue, optimistic updates, verification) from AapConnection, reducing it from 773 to 173 lines.
This commit is contained in:
darken
2026-04-16 10:32:21 +02:00
committed by Matthias Urhahn
parent 59b4c404aa
commit e5821de4dd
12 changed files with 1313 additions and 549 deletions
@@ -504,6 +504,14 @@ fun DeviceSettingsScreen(
// Settings — only show when AAP is connected
if (features != null && device.isAapConnected) {
if (device.hasPendingSettings == true) {
item("pending_info") {
SettingsInfoBox(
text = stringResource(R.string.device_settings_pending_info),
)
}
}
// ── Noise Control ────────────────────────────
val ancMode = device.ancMode
val adaptiveNoise = device.adaptiveAudioNoise
@@ -586,7 +594,8 @@ fun DeviceSettingsScreen(
level = adaptiveNoise.level,
onLevelChange = onAdaptiveAudioNoiseChange,
enabled = enabled,
isAdaptiveMode = ancMode.current == AapSetting.AncMode.Value.ADAPTIVE,
isAdaptiveMode = ancMode.current == AapSetting.AncMode.Value.ADAPTIVE
|| device.pendingAncMode == AapSetting.AncMode.Value.ADAPTIVE,
)
}
}
@@ -267,6 +267,9 @@ data class PodDevice(
val pendingAncMode: AapSetting.AncMode.Value?
get() = aap?.pendingAncMode
val hasPendingSettings: Boolean?
get() = aap?.hasPendingSettings
val conversationalAwareness: AapSetting.ConversationalAwareness?
get() = aap?.setting()
@@ -10,38 +10,27 @@ import eu.darken.capod.common.debug.logging.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import kotlin.reflect.KClass
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
import eu.darken.capod.pods.core.apple.aap.protocol.AapFramer
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 kotlinx.coroutines.channels.BufferOverflow
import java.time.Instant
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.charset.CharacterCodingException
import java.nio.charset.CodingErrorAction
/**
* Manages a single AAP L2CAP connection to a device.
* Thin socket wrapper — all session logic lives in [AapSessionEngine].
* Internal — not exposed outside [AapConnectionManager].
*/
@SuppressLint("MissingPermission")
@@ -53,51 +42,35 @@ internal class AapConnection(
private val psm: Int = 0x1001,
) {
private val _state = MutableStateFlow(AapPodState())
val state: StateFlow<AapPodState> = _state.asStateFlow()
private val engine = AapSessionEngine(profile, timeSource)
private val _keysReceived = MutableSharedFlow<KeyExchangeResult>(extraBufferCapacity = 1)
val keysReceived: SharedFlow<KeyExchangeResult> = _keysReceived.asSharedFlow()
private val _stemPressEvents = MutableSharedFlow<StemPressEvent>(extraBufferCapacity = 8, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val stemPressEvents: SharedFlow<StemPressEvent> = _stemPressEvents.asSharedFlow()
val state: StateFlow<AapPodState> get() = engine.state
val keysReceived: SharedFlow<KeyExchangeResult> get() = engine.keysReceived
val stemPressEvents: SharedFlow<StemPressEvent> get() = engine.stemPressEvents
private var socket: BluetoothSocket? = null
private var readerJob: Job? = null
private val writeMutex = Mutex()
private val sendMutex = Mutex()
private val framer = AapFramer()
private var pendingAncMode: AapSetting.AncMode.Value? = null
private var ancDebounceJob: Job? = null
private var connectionScope: CoroutineScope? = null
private var lastAncCommandSentAt: Long = 0L
private var lastCommandedAncMode: AapSetting.AncMode.Value? = null
private var ancResendJob: Job? = null
/** True after one automatic resend — prevents further retries for the same user action. */
private var ancResendAttempted: Boolean = false
private var lastSentCommand: AapCommand? = null
private var lastSentAt: Long = 0L
/**
* Opens the L2CAP socket, sends the handshake, and launches the read loop.
* Returns after the handshake is sent — the read loop runs in [scope] independently.
*/
suspend fun connect(scope: CoroutineScope) = withContext(Dispatchers.IO) {
if (_state.value.connectionState != AapPodState.ConnectionState.DISCONNECTED) {
throw IllegalStateException("connect() called in state ${_state.value.connectionState}")
if (state.value.connectionState != AapPodState.ConnectionState.DISCONNECTED) {
throw IllegalStateException("connect() called in state ${state.value.connectionState}")
}
connectionScope = scope
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.CONNECTING)
engine.start(scope)
try {
val sock = socketFactory.createSocket(device, psm)
sock.connect()
socket = sock
log(TAG) { "Connected to ${device.address}" }
log(TAG, INFO) { "Connected to ${device.address}" }
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.HANDSHAKING)
engine.onHandshakeSent()
// Send handshake
val handshake = profile.encodeHandshake()
@@ -129,147 +102,22 @@ internal class AapConnection(
} catch (e: Exception) {
log(TAG, ERROR) { "Connection failed: $e" }
cleanupSocket()
_state.value = AapPodState(connectionState = AapPodState.ConnectionState.DISCONNECTED)
engine.reset()
throw e
}
}
suspend fun disconnect() = withContext(Dispatchers.IO) {
log(TAG) { "Disconnecting" }
log(TAG, INFO) { "Disconnecting" }
readerJob?.cancel()
readerJob = null
ancDebounceJob?.cancel()
ancDebounceJob = null
ancResendJob?.cancel()
ancResendJob = null
ancResendAttempted = false
pendingAncMode = null
lastCommandedAncMode = null
connectionScope = null
engine.reset()
cleanupSocket()
framer.reset()
_state.value = AapPodState(connectionState = AapPodState.ConnectionState.DISCONNECTED)
}
suspend fun send(command: AapCommand) = sendMutex.withLock {
val currentState = _state.value
if (currentState.connectionState != AapPodState.ConnectionState.READY) {
throw IllegalStateException("Cannot send command in state ${currentState.connectionState}")
}
if (command is AapCommand.SetAncMode) {
lastCommandedAncMode = command.mode
ancResendJob?.cancel()
ancResendJob = null
ancResendAttempted = false
val earDetection = currentState.setting<AapSetting.EarDetection>()
if (earDetection != null && !earDetection.isEitherPodInEar) {
log(TAG) { "No pod in ear, queuing ANC mode: ${command.mode}" }
pendingAncMode = command.mode
_state.value = currentState.copy(pendingAncMode = command.mode)
return@withLock
}
pendingAncMode = null
// Optimistically update UI — don't wait for device echo
val currentAnc = currentState.setting<AapSetting.AncMode>()
if (currentAnc != null) {
_state.value = currentState
.withSetting(AapSetting.AncMode::class, currentAnc.copy(current = command.mode))
.copy(pendingAncMode = null, lastMessageAt = timeSource.now())
} else {
_state.value = currentState.copy(pendingAncMode = null)
}
}
val preSendDeviceInfo = currentState.deviceInfo
applyOptimisticUpdate(currentState, command)
try {
sendRaw(command)
} catch (e: Exception) {
// Rollback is scoped to rename — the name is the one user-visible writable field
// that has no device echo to correct an incorrect optimistic update, so a failed
// send would otherwise leave the UI permanently lying about the device name.
if (command is AapCommand.SetDeviceName && preSendDeviceInfo != null) {
_state.value = _state.value.copy(deviceInfo = preSendDeviceInfo)
}
throw e
}
}
/**
* Optimistically update state for non-ANC settings so the UI reflects the change immediately.
* ANC is handled separately in [send] due to ear-detection gating.
* If the setting hasn't been reported by the device yet, skip (UI wouldn't show the toggle).
*/
private fun applyOptimisticUpdate(baseState: AapPodState, command: AapCommand) {
val updated: Pair<KClass<out AapSetting>, AapSetting> = when (command) {
is AapCommand.SetAncMode -> return // Handled in send()
is AapCommand.SetConversationalAwareness -> {
val cur = baseState.setting<AapSetting.ConversationalAwareness>() ?: return
AapSetting.ConversationalAwareness::class to cur.copy(enabled = command.enabled)
}
is AapCommand.SetNcWithOneAirPod -> {
val cur = baseState.setting<AapSetting.NcWithOneAirPod>() ?: return
AapSetting.NcWithOneAirPod::class to cur.copy(enabled = command.enabled)
}
is AapCommand.SetVolumeSwipe -> {
val cur = baseState.setting<AapSetting.VolumeSwipe>() ?: return
AapSetting.VolumeSwipe::class to cur.copy(enabled = command.enabled)
}
is AapCommand.SetPersonalizedVolume -> {
val cur = baseState.setting<AapSetting.PersonalizedVolume>() ?: return
AapSetting.PersonalizedVolume::class to cur.copy(enabled = command.enabled)
}
is AapCommand.SetToneVolume -> {
baseState.setting<AapSetting.ToneVolume>() ?: return
AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = command.level)
}
is AapCommand.SetAdaptiveAudioNoise -> {
baseState.setting<AapSetting.AdaptiveAudioNoise>() ?: return
AapSetting.AdaptiveAudioNoise::class to AapSetting.AdaptiveAudioNoise(level = command.level)
}
is AapCommand.SetPressSpeed -> {
baseState.setting<AapSetting.PressSpeed>() ?: return
AapSetting.PressSpeed::class to AapSetting.PressSpeed(value = command.value)
}
is AapCommand.SetPressHoldDuration -> {
baseState.setting<AapSetting.PressHoldDuration>() ?: return
AapSetting.PressHoldDuration::class to AapSetting.PressHoldDuration(value = command.value)
}
is AapCommand.SetVolumeSwipeLength -> {
baseState.setting<AapSetting.VolumeSwipeLength>() ?: return
AapSetting.VolumeSwipeLength::class to AapSetting.VolumeSwipeLength(value = command.value)
}
is AapCommand.SetEndCallMuteMic -> {
baseState.setting<AapSetting.EndCallMuteMic>() ?: return
AapSetting.EndCallMuteMic::class to AapSetting.EndCallMuteMic(muteMic = command.muteMic, endCall = command.endCall)
}
is AapCommand.SetMicrophoneMode -> {
AapSetting.MicrophoneMode::class to AapSetting.MicrophoneMode(mode = command.mode)
}
is AapCommand.SetEarDetectionEnabled -> {
AapSetting.EarDetectionEnabled::class to AapSetting.EarDetectionEnabled(enabled = command.enabled)
}
is AapCommand.SetListeningModeCycle -> {
AapSetting.ListeningModeCycle::class to AapSetting.ListeningModeCycle(modeMask = command.modeMask)
}
is AapCommand.SetAllowOffOption -> {
AapSetting.AllowOffOption::class to AapSetting.AllowOffOption(enabled = command.enabled)
}
is AapCommand.SetStemConfig -> {
AapSetting.StemConfig::class to AapSetting.StemConfig(claimedPressMask = command.claimedPressMask)
}
is AapCommand.SetSleepDetection -> {
AapSetting.SleepDetection::class to AapSetting.SleepDetection(enabled = command.enabled)
}
is AapCommand.SetDeviceName -> {
val currentInfo = baseState.deviceInfo ?: return
_state.value = baseState
.copy(deviceInfo = currentInfo.copy(name = command.name), lastMessageAt = timeSource.now())
return
}
}
_state.value = baseState.withSetting(updated.first, updated.second).copy(lastMessageAt = timeSource.now())
suspend fun send(command: AapCommand) {
engine.send(command, ::sendRaw)
}
private suspend fun sendRaw(command: AapCommand) {
@@ -279,10 +127,6 @@ internal class AapConnection(
val sock = socket ?: throw IOException("Socket is null")
sock.outputStream.write(bytes)
sock.outputStream.flush()
val now = timeSource.currentTimeMillis()
if (command is AapCommand.SetAncMode) lastAncCommandSentAt = now
lastSentCommand = command
lastSentAt = now
val hex = bytes.joinToString(" ") { "%02X".format(it) }
log(TAG, VERBOSE) { "SEND cmd=$command len=${bytes.size} raw=$hex" }
}
@@ -291,7 +135,6 @@ internal class AapConnection(
private suspend fun readLoop(sock: BluetoothSocket) = withContext(Dispatchers.IO) {
val buf = ByteArray(2048)
var handshakeResponseReceived = false
try {
while (isActive) {
@@ -305,289 +148,14 @@ internal class AapConnection(
val raw = buf.copyOfRange(0, len)
val message = AapMessage.Companion.parse(raw)
if (message != null) {
processMessage(message)
if (!handshakeResponseReceived && message.commandType != 0x0009) {
handshakeResponseReceived = true
}
}
// Transition to READY after processing first batch of messages
if (handshakeResponseReceived && _state.value.connectionState == AapPodState.ConnectionState.HANDSHAKING) {
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.READY)
log(TAG) { "Connection READY" }
engine.processMessage(message)
}
}
} catch (e: IOException) {
if (isActive) log(TAG, ERROR) { "Read error: $e" }
} finally {
ancDebounceJob?.cancel()
ancResendJob?.cancel()
ancResendAttempted = false
pendingAncMode = null
lastCommandedAncMode = null
engine.reset()
cleanupSocket()
_state.value = _state.value.copy(
connectionState = AapPodState.ConnectionState.DISCONNECTED,
pendingAncMode = null,
)
}
}
private fun processMessage(message: AapMessage) {
val hex = message.raw.joinToString(" ") { "%02X".format(it) }
log(TAG, VERBOSE) { "MSG cmd=0x${"%04X".format(message.commandType)} len=${message.raw.size} raw=$hex" }
// Issue #173: diagnostic dump of the 0x1D INFORMATION packet so testers with engraved AirPods
// can share a debug recording that reveals where (or whether) the engraving message lives.
// Runs unconditionally for 0x1D — not gated on decodeDeviceInfo success, since an engraving-
// shaped packet may not match the strict production decoder's expectations.
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"
}
val rendered = seg.utf8?.let { "\"$it\"" } ?: "<non-utf8>"
log(TAG, INFO) { "DeviceInfoDump #173: [${seg.index}] off=${seg.offset} len=${seg.length} ($label) $rendered hex=${seg.hex}" }
}
}
// Try stem press event (transient — emitted via SharedFlow, not stored in state)
profile.decodeStemPress(message)?.let { event ->
_stemPressEvents.tryEmit(event)
log(TAG) { "Stem press: ${event.pressType} ${event.bud}" }
return
}
// Try battery
profile.decodeBattery(message)?.let { batteries ->
// Filter DISCONNECTED entries (e.g. case reports 0% DISCONNECTED when pods are removed).
// Merge with existing state so previously-known values are preserved for absent slots.
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
}
// Try 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
}
// Try 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
}
// Try setting update (merge into existing state)
profile.decodeSetting(message)?.let { (key, value) ->
val previous = _state.value.settings[key]
// Debounce device-initiated ANC mode changes (firmware cycles modes on ear transitions).
// Skip debounce for: first ANC mode (initial setup), echoes after our own command.
if (value is AapSetting.AncMode) {
val isFirstAncMode = _state.value.setting<AapSetting.AncMode>() == null
val sinceLastCommand = timeSource.currentTimeMillis() - lastAncCommandSentAt
if (isFirstAncMode || sinceLastCommand <= 3000L) {
ancDebounceJob?.cancel()
_state.value = _state.value.withSetting(key, value).copy(lastMessageAt = timeSource.now())
log(TAG) { "Setting: ${key.simpleName} = $value [was: $previous]" }
applyInferences(value)
// After our command, firmware may cycle through modes before settling.
// Schedule a verification: if settled mode != commanded mode, re-send once.
// Capped at one retry per user action — if the device rejects the mode
// (e.g. OFF without AllowOffOption), stop instead of looping.
lastCommandedAncMode?.let { commanded ->
ancResendJob?.cancel()
if (ancResendAttempted) {
// Already retried once — accept the device's answer as final.
val current = (value as AapSetting.AncMode).current
if (current != commanded) {
log(TAG) { "ANC mode rejected: commanded=$commanded settled=$current, giving up after retry" }
// OFF specifically requires AllowOffOption — rejection means it's disabled
if (commanded == AapSetting.AncMode.Value.OFF) {
val prev = _state.value.setting<AapSetting.AllowOffOption>()
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)" }
}
}
}
lastCommandedAncMode = null
} else {
ancResendJob = connectionScope?.launch {
delay(1000L)
val current = _state.value.setting<AapSetting.AncMode>()?.current
val ear = _state.value.setting<AapSetting.EarDetection>()
// Abort if pods moved to case/disconnected — firmware is doing its own thing
if (ear != null && !ear.isEitherPodInEar) {
log(TAG) { "ANC resend aborted: no pod in ear (ear=$ear)" }
lastCommandedAncMode = null
return@launch
}
if (current != null && current != commanded) {
log(TAG) { "ANC mode diverged: commanded=$commanded settled=$current, re-sending" }
ancResendAttempted = true
sendRaw(AapCommand.SetAncMode(commanded))
} else {
lastCommandedAncMode = null
}
}
}
}
} else {
ancDebounceJob?.cancel()
ancDebounceJob = connectionScope?.launch {
delay(1500L)
_state.value = _state.value.withSetting(key, value).copy(lastMessageAt = timeSource.now())
log(TAG) { "Setting (debounced): ${key.simpleName} = $value [was: $previous]" }
applyInferences(value)
}
}
return
}
// Detect ear detection role swap — clear stale PrimaryPod until 0x0008 refreshes it
val clearPrimaryPod = value is AapSetting.EarDetection && run {
val prev = _state.value.setting<AapSetting.EarDetection>()
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]" }
applyInferences(value)
// Flush queued ANC command when a pod goes in ear
if (value is AapSetting.EarDetection && value.isEitherPodInEar) {
pendingAncMode?.let { mode ->
pendingAncMode = null
// Optimistic update — show target mode immediately, don't wait for device echo
val currentAnc = _state.value.setting<AapSetting.AncMode>()
if (currentAnc != null) {
_state.value = _state.value
.withSetting(AapSetting.AncMode::class, currentAnc.copy(current = mode))
.copy(pendingAncMode = null, lastMessageAt = timeSource.now())
} else {
_state.value = _state.value.copy(pendingAncMode = null)
}
log(TAG) { "Pod in ear, sending queued ANC mode: $mode" }
connectionScope?.launch { sendRaw(AapCommand.SetAncMode(mode)) }
}
}
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"
if (message.commandType == 0x0009 && message.payload.size >= 2) {
val settingId = message.payload[0].toInt() and 0xFF
val value = message.payload[1].toInt() and 0xFF
val boolHint = appleBoolHint(value)
val tailHex = if (message.payload.size > 2) {
message.payload.copyOfRange(2, message.payload.size).joinToString(" ") { "%02X".format(it) }
} else {
""
}
log(TAG, INFO) {
buildString {
append("Unhandled setting id=0x${"%02X".format(settingId)} ")
append("value=0x${"%02X".format(value)}")
boolHint?.let { append(" appleBool=$it") }
append(" payload=${message.payload.size}B")
if (tailHex.isNotEmpty()) append(" tail=[$tailHex]")
append(" sinceLastSend=${sinceSend}ms lastSend=$lastSend")
}
}
return
}
if (message.commandType == 0x000C && message.payload.size >= 6) {
val macRaw = formatMac(message.payload, reverse = false)
val macReversed = formatMac(message.payload, reverse = true)
val tailHex = if (message.payload.size > 6) {
message.payload.copyOfRange(6, message.payload.size).joinToString(" ") { "%02X".format(it) }
} else {
""
}
_state.value = _state.value.copy(lastMessageAt = timeSource.now())
log(TAG, VERBOSE) {
buildString {
append("Known cmd=0x000C")
append(" payload=${message.payload.size}B")
append(" macRaw=$macRaw macReversed=$macReversed")
if (tailHex.isNotEmpty()) append(" tail=[$tailHex]")
append(" sinceLastSend=${sinceSend}ms lastSend=$lastSend")
}
}
return
}
// Known non-settings commands: log at VERBOSE, refresh lastMessageAt
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"
}
return
}
log(TAG, INFO) {
"Unhandled cmd=0x${"%04X".format(message.commandType)} payload=${message.payload.size}B [$payloadHex] sinceLastSend=${sinceSend}ms lastSend=$lastSend"
}
}
/**
* Infer settings that the device never pushes but whose state can be deduced from other signals.
* Called after every setting update. Only SETS inferred values — never clears them based on absence.
*/
private fun applyInferences(trigger: AapSetting) {
val inferred = mutableListOf<Pair<KClass<out AapSetting>, AapSetting>>()
// AncMode=OFF is only possible when AllowOffOption is enabled — the device rejects
// SetAncMode(OFF) otherwise. If we see OFF in the burst or after a mode change,
// AllowOffOption must be true.
if (trigger is AapSetting.AncMode && trigger.current == AapSetting.AncMode.Value.OFF) {
val current = _state.value.setting<AapSetting.AllowOffOption>()
if (current == null || !current.enabled) {
inferred += AapSetting.AllowOffOption::class to AapSetting.AllowOffOption(enabled = true)
}
}
for ((key, value) in inferred) {
_state.value = _state.value.withSetting(key, value)
log(TAG) { "Inferred: ${key.simpleName} = $value (from ${trigger::class.simpleName})" }
}
}
@@ -600,99 +168,6 @@ internal class AapConnection(
}
companion object {
private val TAG = logTag("AapConnection")
// Non-settings commands observed in real sessions. Logged at VERBOSE instead of INFO
// to reduce noise, but still fully logged with payload hex for debug log analysis.
private val KNOWN_NON_SETTINGS_COMMANDS = setOf(
0x0000, // Handshake acknowledgment
0x0002, // Capability/feature table (H2+ only)
0x0017, // HID/service descriptors
0x002B, // Session metadata / event history
0x004E, // Unknown (all-zero payload)
0x0052, // ANC mode change status/rejection
0x0055, // Audio/session state
0x0057, // Connection lifecycle
)
private fun appleBoolHint(wireValue: Int): Boolean? = when (wireValue) {
0x01 -> true
0x02 -> false
else -> null
}
private fun formatMac(bytes: ByteArray, reverse: Boolean): String {
val indices = if (reverse) (5 downTo 0) else (0..5)
return indices.joinToString(":") { "%02X".format(bytes[it]) }
}
/**
* 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<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(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(segBytes))
.toString()
} catch (_: CharacterCodingException) {
null
}
val hex = segBytes.joinToString("") { "%02X".format(it) }
segments.add(
DeviceInfoSegment(
index = segIndex,
offset = segStart,
length = segBytes.size,
utf8 = utf8,
hex = hex,
)
)
segIndex++
}
return segments
}
internal data class DeviceInfoSegment(
val index: Int,
val offset: Int,
val length: Int,
val utf8: String?,
val hex: String,
)
private val TAG = logTag("AAP", "Connection")
}
}
@@ -41,7 +41,7 @@ class AapConnectionManager @Inject constructor(
private val timeSource: TimeSource,
) {
companion object {
private val TAG = logTag("AapConnectionMgr")
private val TAG = logTag("AAP", "Manager")
}
private val mutex = Mutex()
@@ -15,6 +15,7 @@ data class AapPodState(
val batteries: Map<BatteryType, Battery> = emptyMap(),
val lastMessageAt: Instant? = null,
val pendingAncMode: AapSetting.AncMode.Value? = null,
val pendingSettingsCount: Int = 0,
) {
inline fun <reified T : AapSetting> setting(): T? = settings[T::class] as? T
@@ -32,6 +33,9 @@ data class AapPodState(
val isEitherPodInEar: Boolean?
get() = aapEarDetection?.isEitherPodInEar
val hasPendingSettings: Boolean
get() = pendingSettingsCount > 0
// Battery — from AAP command 0x04, 1% granularity
val batteryLeft: Float? get() = batteries[BatteryType.LEFT]?.percent
val batteryRight: Float? get() = batteries[BatteryType.RIGHT]?.percent
@@ -0,0 +1,502 @@
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.Logging.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
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.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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.reflect.KClass
/**
* Owns all AAP session state and decision-making. [AapConnection] is a thin socket wrapper
* that delegates to this engine for send-path logic and incoming message processing.
*/
internal class AapSessionEngine(
private val profile: AapDeviceProfile,
private val timeSource: TimeSource,
) {
// ── State ───────────────────────────────────────────────
private val _state = MutableStateFlow(AapPodState())
val state: StateFlow<AapPodState> = _state.asStateFlow()
private val _keysReceived = MutableSharedFlow<KeyExchangeResult>(extraBufferCapacity = 1)
val keysReceived: SharedFlow<KeyExchangeResult> = _keysReceived.asSharedFlow()
private val _stemPressEvents =
MutableSharedFlow<StemPressEvent>(extraBufferCapacity = 8, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val stemPressEvents: SharedFlow<StemPressEvent> = _stemPressEvents.asSharedFlow()
private val coordinator = AapSettingsCoordinator(timeSource)
private val sendMutex = Mutex()
private var scope: CoroutineScope? = null
private var ancDebounceJob: Job? = null
private var lastSentCommand: AapCommand? = null
private var lastSentAt: Long = 0L
private var handshakeResponseReceived: Boolean = false
/** Stored reference to the socket write callback — set on each [send] / flush call. */
private var activeSendRaw: (suspend (AapCommand) -> Unit)? = null
// ── 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)
}
/** Called after handshake bytes are sent on the socket. */
fun onHandshakeSent() {
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.HANDSHAKING)
handshakeResponseReceived = false
}
/** Idempotent reset: cancel all jobs, clear queue, reset state to DISCONNECTED. */
fun reset() {
ancDebounceJob?.cancel()
ancDebounceJob = null
coordinator.clear()
scope = null
lastSentCommand = null
lastSentAt = 0L
activeSendRaw = null
handshakeResponseReceived = false
_state.value = AapPodState(connectionState = AapPodState.ConnectionState.DISCONNECTED)
}
// ── 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<AapSetting.EarDetection>()
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<AapSetting.AncMode>()
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
lastSentAt = timeSource.currentTimeMillis()
}
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) {
val prev = _state.value.setting<AapSetting.AllowOffOption>()
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)" }
}
}
}
}
// ── Message processing ──────────────────────────────────
fun processMessage(message: AapMessage) {
val hex = message.raw.joinToString(" ") { "%02X".format(it) }
log(TAG, VERBOSE) { "MSG cmd=0x${"%04X".format(message.commandType)} len=${message.raw.size} raw=$hex" }
// 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"
}
val rendered = seg.utf8?.let { "\"$it\"" } ?: "<non-utf8>"
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) {
val isFirstAncMode = _state.value.setting<AapSetting.AncMode>() == null
val recentAncSend =
lastSentCommand is AapCommand.SetAncMode && (timeSource.currentTimeMillis() - lastSentAt) <= 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]" }
applyInferences(value)
} 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]" }
applyInferences(value)
}
}
return
}
// Ear detection role swap
val clearPrimaryPod = value is AapSetting.EarDetection && run {
val prev = _state.value.setting<AapSetting.EarDetection>()
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]" }
applyInferences(value)
// 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,
)
if (commands.isNotEmpty()) {
val ancCmd = commands.firstOrNull { it is AapCommand.SetAncMode } as? AapCommand.SetAncMode
if (ancCmd != null) {
val currentAnc = _state.value.setting<AapSetting.AncMode>()
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
}
}
commands.lastOrNull()?.let {
coordinator.startVerification(
it,
this,
{ _state.value },
{ cmd -> wrappedSend(sendFn, cmd) },
::onVerificationOutcome
)
}
}
}
}
// HANDSHAKING → READY transition
if (!handshakeResponseReceived && _state.value.connectionState == AapPodState.ConnectionState.HANDSHAKING) {
handshakeResponseReceived = true
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.READY)
log(TAG) { "Connection READY" }
}
return
}
// Track handshake response for non-setting messages too
if (!handshakeResponseReceived && message.commandType != 0x0009 && _state.value.connectionState == AapPodState.ConnectionState.HANDSHAKING) {
handshakeResponseReceived = true
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.READY)
log(TAG) { "Connection READY" }
}
val payloadHex = message.payload.joinToString(" ") { "%02X".format(it) }
val sinceSend = if (lastSentAt == 0L) -1L else timeSource.currentTimeMillis() - lastSentAt
val lastSend = lastSentCommand?.let { it::class.simpleName } ?: "none"
if (message.commandType == 0x0009 && 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) }
} else {
""
}
log(TAG, INFO) {
buildString {
append("Unhandled setting id=0x${"%02X".format(settingId)} ")
append("value=0x${"%02X".format(value)}")
boolHint?.let { append(" appleBool=$it") }
append(" payload=${message.payload.size}B")
if (tailHex.isNotEmpty()) append(" tail=[$tailHex]")
append(" sinceLastSend=${sinceSend}ms lastSend=$lastSend")
}
}
return
}
if (message.commandType == 0x000C && 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) {
message.payload.copyOfRange(6, message.payload.size).joinToString(" ") { "%02X".format(it) }
} else {
""
}
_state.value = _state.value.copy(lastMessageAt = timeSource.now())
log(TAG, VERBOSE) {
buildString {
append("Known cmd=0x000C")
append(" payload=${message.payload.size}B")
append(" macRaw=$macRaw macReversed=$macReversed")
if (tailHex.isNotEmpty()) append(" tail=[$tailHex]")
append(" sinceLastSend=${sinceSend}ms lastSend=$lastSend")
}
}
return
}
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"
}
return
}
log(TAG, INFO) {
"Unhandled cmd=0x${"%04X".format(message.commandType)} payload=${message.payload.size}B [$payloadHex] sinceLastSend=${sinceSend}ms lastSend=$lastSend"
}
}
// ── Inference ───────────────────────────────────────────
private fun applyInferences(trigger: AapSetting) {
val inferred = mutableListOf<Pair<KClass<out AapSetting>, AapSetting>>()
if (trigger is AapSetting.AncMode && trigger.current == AapSetting.AncMode.Value.OFF) {
val current = _state.value.setting<AapSetting.AllowOffOption>()
if (current == null || !current.enabled) {
inferred += AapSetting.AllowOffOption::class to AapSetting.AllowOffOption(enabled = true)
}
}
for ((key, value) in inferred) {
_state.value = _state.value.withSetting(key, value)
log(TAG) { "Inferred: ${key.simpleName} = $value (from ${trigger::class.simpleName})" }
}
}
companion object {
private val TAG = logTag("AAP", "Engine")
private val KNOWN_NON_SETTINGS_COMMANDS = setOf(
0x0000, 0x0002, 0x0017, 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<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.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,
)
}
}
// ── Extension helpers ───────────────────────────────────
/** Apple wire-format boolean: 0x01 = true, 0x02 = false. */
private fun Int.appleBoolHint(): Boolean? = when (this) {
0x01 -> true
0x02 -> false
else -> null
}
/** Format 6-byte MAC address, optionally reversed. */
private fun ByteArray.formatMac(reverse: Boolean): String {
val indices = if (reverse) (5 downTo 0) else (0..5)
return indices.joinToString(":") { "%02X".format(this[it]) }
}
@@ -0,0 +1,249 @@
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.
*/
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()
}
// Keyed by command class — newer commands of the same type overwrite.
private val pendingCommands = linkedMapOf<KClass<out AapCommand>, AapCommand>()
private var verificationJob: Job? = null
// ── 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> {
if (command is AapCommand.SetAncMode && command.mode != AapSetting.AncMode.Value.ADAPTIVE) {
synchronized(pendingCommands) { pendingCommands.remove(AapCommand.SetAdaptiveAudioNoise::class) }
}
synchronized(pendingCommands) { pendingCommands[command::class] = command }
val optimistic = if (command !is AapCommand.SetAncMode) {
optimisticUpdate(currentState, command)
} else {
null
}
return optimistic to snapshot()
}
/** Remove a specific command class from the queue. */
fun removeFromQueue(commandClass: KClass<out AapCommand>): PendingSnapshot {
synchronized(pendingCommands) { pendingCommands.remove(commandClass) }
return snapshot()
}
/** 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()
}
val sorted = commands.sortedBy { if (it is AapCommand.SetAncMode) 0 else 1 }
return sorted to snapshot()
}
/** Cancel verification, clear queue. */
fun clear(): PendingSnapshot {
verificationJob?.cancel()
verificationJob = null
synchronized(pendingCommands) { pendingCommands.clear() }
return snapshot()
}
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
is AapCommand.SetConversationalAwareness -> {
val cur = baseState.setting<AapSetting.ConversationalAwareness>() ?: return null
AapSetting.ConversationalAwareness::class to cur.copy(enabled = command.enabled)
}
is AapCommand.SetNcWithOneAirPod -> {
val cur = baseState.setting<AapSetting.NcWithOneAirPod>() ?: return null
AapSetting.NcWithOneAirPod::class to cur.copy(enabled = command.enabled)
}
is AapCommand.SetVolumeSwipe -> {
val cur = baseState.setting<AapSetting.VolumeSwipe>() ?: return null
AapSetting.VolumeSwipe::class to cur.copy(enabled = command.enabled)
}
is AapCommand.SetPersonalizedVolume -> {
val cur = baseState.setting<AapSetting.PersonalizedVolume>() ?: return null
AapSetting.PersonalizedVolume::class to cur.copy(enabled = command.enabled)
}
is AapCommand.SetToneVolume -> {
baseState.setting<AapSetting.ToneVolume>() ?: return null
AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = command.level)
}
is AapCommand.SetAdaptiveAudioNoise -> {
baseState.setting<AapSetting.AdaptiveAudioNoise>() ?: return null
AapSetting.AdaptiveAudioNoise::class to AapSetting.AdaptiveAudioNoise(level = command.level)
}
is AapCommand.SetPressSpeed -> {
baseState.setting<AapSetting.PressSpeed>() ?: return null
AapSetting.PressSpeed::class to AapSetting.PressSpeed(value = command.value)
}
is AapCommand.SetPressHoldDuration -> {
baseState.setting<AapSetting.PressHoldDuration>() ?: return null
AapSetting.PressHoldDuration::class to AapSetting.PressHoldDuration(value = command.value)
}
is AapCommand.SetVolumeSwipeLength -> {
baseState.setting<AapSetting.VolumeSwipeLength>() ?: return null
AapSetting.VolumeSwipeLength::class to AapSetting.VolumeSwipeLength(value = command.value)
}
is AapCommand.SetEndCallMuteMic -> {
baseState.setting<AapSetting.EndCallMuteMic>() ?: return null
AapSetting.EndCallMuteMic::class to AapSetting.EndCallMuteMic(muteMic = command.muteMic, endCall = command.endCall)
}
is AapCommand.SetMicrophoneMode -> {
AapSetting.MicrophoneMode::class to AapSetting.MicrophoneMode(mode = command.mode)
}
is AapCommand.SetEarDetectionEnabled -> {
AapSetting.EarDetectionEnabled::class to AapSetting.EarDetectionEnabled(enabled = command.enabled)
}
is AapCommand.SetListeningModeCycle -> {
AapSetting.ListeningModeCycle::class to AapSetting.ListeningModeCycle(modeMask = command.modeMask)
}
is AapCommand.SetAllowOffOption -> {
AapSetting.AllowOffOption::class to AapSetting.AllowOffOption(enabled = command.enabled)
}
is AapCommand.SetStemConfig -> {
AapSetting.StemConfig::class to AapSetting.StemConfig(claimedPressMask = command.claimedPressMask)
}
is AapCommand.SetSleepDetection -> {
AapSetting.SleepDetection::class to AapSetting.SleepDetection(enabled = command.enabled)
}
is AapCommand.SetDeviceName -> {
val currentInfo = baseState.deviceInfo ?: return null
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 }
is AapCommand.SetConversationalAwareness -> { s -> s.setting<AapSetting.ConversationalAwareness>()?.enabled == command.enabled }
is AapCommand.SetToneVolume -> { s -> s.setting<AapSetting.ToneVolume>()?.level == command.level }
is AapCommand.SetPersonalizedVolume -> { s -> s.setting<AapSetting.PersonalizedVolume>()?.enabled == command.enabled }
is AapCommand.SetVolumeSwipe -> { s -> s.setting<AapSetting.VolumeSwipe>()?.enabled == command.enabled }
is AapCommand.SetNcWithOneAirPod -> { s -> s.setting<AapSetting.NcWithOneAirPod>()?.enabled == command.enabled }
is AapCommand.SetPressSpeed -> { s -> s.setting<AapSetting.PressSpeed>()?.value == command.value }
is AapCommand.SetPressHoldDuration -> { s -> s.setting<AapSetting.PressHoldDuration>()?.value == command.value }
is AapCommand.SetVolumeSwipeLength -> { s -> s.setting<AapSetting.VolumeSwipeLength>()?.value == command.value }
is AapCommand.SetEndCallMuteMic -> { s ->
val cur = s.setting<AapSetting.EndCallMuteMic>()
cur != null && cur.muteMic == command.muteMic && cur.endCall == command.endCall
}
is AapCommand.SetMicrophoneMode -> { s -> s.setting<AapSetting.MicrophoneMode>()?.mode == command.mode }
is AapCommand.SetEarDetectionEnabled -> { s -> s.setting<AapSetting.EarDetectionEnabled>()?.enabled == command.enabled }
is AapCommand.SetListeningModeCycle -> { s -> s.setting<AapSetting.ListeningModeCycle>()?.modeMask == command.modeMask }
is AapCommand.SetAllowOffOption -> { s -> s.setting<AapSetting.AllowOffOption>()?.enabled == command.enabled }
is AapCommand.SetStemConfig -> { s -> s.setting<AapSetting.StemConfig>()?.claimedPressMask == command.claimedPressMask }
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")
}
}
+1
View File
@@ -472,6 +472,7 @@
<string name="device_settings_adaptive_noise_label">Adaptive Audio Noise</string>
<string name="device_settings_adaptive_noise_description">How much environmental noise is allowed through</string>
<string name="device_settings_adaptive_noise_requires_adaptive">Requires Adaptive noise control</string>
<string name="device_settings_pending_info">Changes take effect when AirPods are in ear</string>
<string name="device_settings_press_speed_label">Press Speed</string>
<string name="device_settings_press_speed_description">How quickly you need to press for multi-press gestures</string>
<string name="device_settings_press_speed_default">Default</string>
@@ -465,6 +465,31 @@ class PodDeviceTest : BaseTest() {
device.pendingAncMode.shouldBeNull()
}
// ── Pending Settings ────────────────────────────────────
@Test
fun `hasPendingSettings exposed from AAP state`() {
val aap = AapPodState(
connectionState = AapPodState.ConnectionState.READY,
pendingSettingsCount = 2,
)
val device = PodDevice(profileId = null, ble = mockDualPod(), aap = aap)
device.hasPendingSettings shouldBe true
}
@Test
fun `hasPendingSettings null when no AAP`() {
val device = PodDevice(profileId = null, ble = mockDualPod(), aap = null)
device.hasPendingSettings.shouldBeNull()
}
@Test
fun `hasPendingSettings false when no pending`() {
val aap = AapPodState(connectionState = AapPodState.ConnectionState.READY)
val device = PodDevice(profileId = null, ble = mockDualPod(), aap = aap)
device.hasPendingSettings shouldBe false
}
@Test
fun `icon and label properties delegate to BLE`() {
val device = PodDevice(
@@ -10,7 +10,7 @@ import org.junit.jupiter.api.Test
import testhelpers.BaseTest
/**
* Tests for the issue #173 diagnostic helper [AapConnection.describeDeviceInfoSegments].
* Tests for the issue #173 diagnostic helper [AapSessionEngine.describeDeviceInfoSegments].
*
* The helper must:
* - Work on real captured 0x1D payloads (matches production decoder on ASCII slots).
@@ -50,7 +50,7 @@ class AapConnectionTest : BaseTest() {
"""
)
val segments = AapConnection.describeDeviceInfoSegments(payload)
val segments = AapSessionEngine.describeDeviceInfoSegments(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 = AapConnection.describeDeviceInfoSegments(payload)
val segments = AapSessionEngine.describeDeviceInfoSegments(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 = AapConnection.describeDeviceInfoSegments(payload)
val segments = AapSessionEngine.describeDeviceInfoSegments(payload)
segments.size shouldBe 2
segments[0].utf8 shouldBe "Name"
segments[1].utf8 shouldBe "Model"
@@ -279,4 +279,28 @@ class AapPodStateTest : BaseTest() {
val state = AapPodState().copy(pendingAncMode = AapSetting.AncMode.Value.ADAPTIVE)
state.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
}
// ── Pending Settings Count ──────────────────────────────
@Test
fun `pendingSettingsCount defaults to 0`() {
AapPodState().pendingSettingsCount shouldBe 0
}
@Test
fun `pendingSettingsCount preserved in copy`() {
val state = AapPodState().copy(pendingSettingsCount = 3)
state.pendingSettingsCount shouldBe 3
}
@Test
fun `hasPendingSettings false when count is 0`() {
AapPodState().hasPendingSettings shouldBe false
}
@Test
fun `hasPendingSettings true when pendingSettingsCount greater than 0`() {
val state = AapPodState().copy(pendingSettingsCount = 2)
state.hasPendingSettings shouldBe true
}
}
@@ -0,0 +1,472 @@
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.AapDeviceInfo
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
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
class AapSettingsCoordinatorTest : BaseTest() {
private val timeSource = mockk<TimeSource> {
every { now() } returns Instant.ofEpochMilli(1000L)
every { currentTimeMillis() } returns 1000L
}
private fun createCoordinator() = AapSettingsCoordinator(timeSource)
private fun stateWithSetting(vararg settings: Pair<kotlin.reflect.KClass<out AapSetting>, AapSetting>): AapPodState {
val map = settings.toMap()
return AapPodState(connectionState = AapPodState.ConnectionState.READY, settings = map)
}
// ── Enqueue ─────────────────────────────────────────────
@Nested
inner class EnqueueTests {
@Test
fun `enqueue returns snapshot with correct count`() {
val coord = createCoordinator()
val state = stateWithSetting(
AapSetting.ConversationalAwareness::class to AapSetting.ConversationalAwareness(enabled = false),
)
val (_, snapshot) = coord.enqueue(AapCommand.SetConversationalAwareness(true), state)
snapshot.count shouldBe 1
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)
optimistic.shouldBeNull() // ANC uses pendingAncMode, not optimistic update
snapshot.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
snapshot.count shouldBe 1
}
@Test
fun `enqueue same command class overwrites previous`() {
val coord = createCoordinator()
val state = stateWithSetting(
AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = 50),
)
coord.enqueue(AapCommand.SetToneVolume(60), state)
val (optimistic, snapshot) = coord.enqueue(AapCommand.SetToneVolume(80), state)
snapshot.count shouldBe 1 // Not 2
optimistic.shouldNotBeNull()
optimistic.setting<AapSetting.ToneVolume>()!!.level shouldBe 80
}
@Test
fun `switching away from ADAPTIVE removes queued SetAdaptiveAudioNoise`() {
val coord = createCoordinator()
val state = stateWithSetting(
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)
snapshot.count shouldBe 1 // Only ANC, noise removed
snapshot.pendingAncMode shouldBe AapSetting.AncMode.Value.ON
}
@Test
fun `switching to ADAPTIVE keeps queued SetAdaptiveAudioNoise`() {
val coord = createCoordinator()
val state = stateWithSetting(
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)
snapshot.count shouldBe 2
}
@Test
fun `enqueue returns optimistic state for non-ANC commands`() {
val coord = createCoordinator()
val state = stateWithSetting(
AapSetting.VolumeSwipe::class to AapSetting.VolumeSwipe(enabled = false),
)
val (optimistic, _) = coord.enqueue(AapCommand.SetVolumeSwipe(true), state)
optimistic.shouldNotBeNull()
optimistic.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()
commands.shouldBeEmpty()
snapshot.count shouldBe 0
snapshot.pendingAncMode.shouldBeNull()
}
@Test
fun `flush sorts ANC mode first`() {
val coord = createCoordinator()
val state = stateWithSetting(
AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = 50),
)
coord.enqueue(AapCommand.SetToneVolume(80), state)
coord.enqueue(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE), state)
val (commands, _) = coord.flush()
commands shouldHaveSize 2
commands[0].shouldBeInstanceOf<AapCommand.SetAncMode>()
commands[1].shouldBeInstanceOf<AapCommand.SetToneVolume>()
}
@Test
fun `flush clears queue`() {
val coord = createCoordinator()
val state = stateWithSetting()
coord.enqueue(AapCommand.SetAncMode(AapSetting.AncMode.Value.ON), state)
coord.flush()
val (commands, snapshot) = coord.flush()
commands.shouldBeEmpty()
snapshot.count shouldBe 0
}
}
// ── Optimistic Update ───────────────────────────────────
@Nested
inner class OptimisticUpdateTests {
@Test
fun `ANC mode returns null`() {
val coord = createCoordinator()
val state = stateWithSetting()
coord.optimisticUpdate(state, AapCommand.SetAncMode(AapSetting.AncMode.Value.ON)).shouldBeNull()
}
@Test
fun `returns null when setting not yet in state`() {
val coord = createCoordinator()
val state = stateWithSetting() // No ToneVolume in state
coord.optimisticUpdate(state, AapCommand.SetToneVolume(80)).shouldBeNull()
}
@Test
fun `boolean toggle returns correct state`() {
val coord = createCoordinator()
val state = stateWithSetting(
AapSetting.PersonalizedVolume::class to AapSetting.PersonalizedVolume(enabled = false),
)
val result = coord.optimisticUpdate(state, AapCommand.SetPersonalizedVolume(true))
result.shouldNotBeNull()
result.setting<AapSetting.PersonalizedVolume>()!!.enabled shouldBe true
}
@Test
fun `slider value returns correct state`() {
val coord = createCoordinator()
val state = stateWithSetting(
AapSetting.AdaptiveAudioNoise::class to AapSetting.AdaptiveAudioNoise(level = 30),
)
val result = coord.optimisticUpdate(state, AapCommand.SetAdaptiveAudioNoise(70))
result.shouldNotBeNull()
result.setting<AapSetting.AdaptiveAudioNoise>()!!.level shouldBe 70
}
@Test
fun `SetDeviceName updates deviceInfo`() {
val coord = createCoordinator()
val state = AapPodState(
connectionState = AapPodState.ConnectionState.READY,
deviceInfo = AapDeviceInfo(
name = "Old Name",
modelNumber = "A2084",
manufacturer = "Apple",
serialNumber = "ABC123",
firmwareVersion = "1.0.0",
),
)
val result = coord.optimisticUpdate(state, AapCommand.SetDeviceName("New Name"))
result.shouldNotBeNull()
result.deviceInfo!!.name shouldBe "New Name"
}
@Test
fun `does not mutate input state`() {
val coord = createCoordinator()
val state = stateWithSetting(
AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = 50),
)
coord.optimisticUpdate(state, AapCommand.SetToneVolume(80))
state.setting<AapSetting.ToneVolume>()!!.level shouldBe 50
}
}
// ── Verification ────────────────────────────────────────
@Nested
inner class VerificationTests {
@Test
fun `verificationFor returns null for SetDeviceName`() {
val coord = createCoordinator()
coord.verificationFor(AapCommand.SetDeviceName("test")).shouldBeNull()
}
@Test
fun `verificationFor returns correct check for ANC mode`() {
val coord = createCoordinator()
val check = coord.verificationFor(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE))!!
val matching = stateWithSetting(
AapSetting.AncMode::class to AapSetting.AncMode(
current = AapSetting.AncMode.Value.ADAPTIVE,
supported = listOf(AapSetting.AncMode.Value.ADAPTIVE),
),
)
val mismatched = stateWithSetting(
AapSetting.AncMode::class to AapSetting.AncMode(
current = AapSetting.AncMode.Value.ON,
supported = listOf(AapSetting.AncMode.Value.ON),
),
)
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()
}
}
}