diff --git a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt index fcdbae9b..214e41e6 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/devicesettings/DeviceSettingsScreen.kt @@ -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, ) } } diff --git a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt index 35658a6a..1e940233 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt @@ -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() diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt index 984ee6b7..596b076b 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt @@ -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 = _state.asStateFlow() + private val engine = AapSessionEngine(profile, timeSource) - private val _keysReceived = MutableSharedFlow(extraBufferCapacity = 1) - val keysReceived: SharedFlow = _keysReceived.asSharedFlow() - - private val _stemPressEvents = MutableSharedFlow(extraBufferCapacity = 8, onBufferOverflow = BufferOverflow.DROP_OLDEST) - val stemPressEvents: SharedFlow = _stemPressEvents.asSharedFlow() + val state: StateFlow get() = engine.state + val keysReceived: SharedFlow get() = engine.keysReceived + val stemPressEvents: SharedFlow 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() - 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() - 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, AapSetting> = when (command) { - is AapCommand.SetAncMode -> return // Handled in send() - is AapCommand.SetConversationalAwareness -> { - val cur = baseState.setting() ?: return - AapSetting.ConversationalAwareness::class to cur.copy(enabled = command.enabled) - } - is AapCommand.SetNcWithOneAirPod -> { - val cur = baseState.setting() ?: return - AapSetting.NcWithOneAirPod::class to cur.copy(enabled = command.enabled) - } - is AapCommand.SetVolumeSwipe -> { - val cur = baseState.setting() ?: return - AapSetting.VolumeSwipe::class to cur.copy(enabled = command.enabled) - } - is AapCommand.SetPersonalizedVolume -> { - val cur = baseState.setting() ?: return - AapSetting.PersonalizedVolume::class to cur.copy(enabled = command.enabled) - } - is AapCommand.SetToneVolume -> { - baseState.setting() ?: return - AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = command.level) - } - is AapCommand.SetAdaptiveAudioNoise -> { - baseState.setting() ?: return - AapSetting.AdaptiveAudioNoise::class to AapSetting.AdaptiveAudioNoise(level = command.level) - } - is AapCommand.SetPressSpeed -> { - baseState.setting() ?: return - AapSetting.PressSpeed::class to AapSetting.PressSpeed(value = command.value) - } - is AapCommand.SetPressHoldDuration -> { - baseState.setting() ?: return - AapSetting.PressHoldDuration::class to AapSetting.PressHoldDuration(value = command.value) - } - is AapCommand.SetVolumeSwipeLength -> { - baseState.setting() ?: return - AapSetting.VolumeSwipeLength::class to AapSetting.VolumeSwipeLength(value = command.value) - } - is AapCommand.SetEndCallMuteMic -> { - baseState.setting() ?: 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\"" } ?: "" - 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() == 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() - 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()?.current - val ear = _state.value.setting() - // 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() - 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() - 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, 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() - 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 { - var start = 0 - while (start < payload.size) { - val b = payload[start].toInt() and 0xFF - if (b in 0x20..0x7E) break - start++ - } - if (start >= payload.size) return emptyList() - - val segments = mutableListOf() - var segIndex = 0 - var i = start - while (i < payload.size) { - while (i < payload.size && payload[i] == 0x00.toByte()) i++ - if (i >= payload.size) break - - val segStart = i - while (i < payload.size && payload[i] != 0x00.toByte()) i++ - val segBytes = payload.copyOfRange(segStart, i) - - val utf8: String? = try { - Charsets.UTF_8.newDecoder() - .onMalformedInput(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") } } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnectionManager.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnectionManager.kt index e02af01c..be3de978 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnectionManager.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnectionManager.kt @@ -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() diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapPodState.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapPodState.kt index 0c186bcf..b6c52217 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapPodState.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapPodState.kt @@ -15,6 +15,7 @@ data class AapPodState( val batteries: Map = emptyMap(), val lastMessageAt: Instant? = null, val pendingAncMode: AapSetting.AncMode.Value? = null, + val pendingSettingsCount: Int = 0, ) { inline fun 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 diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapSessionEngine.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapSessionEngine.kt new file mode 100644 index 00000000..f9daf6cd --- /dev/null +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapSessionEngine.kt @@ -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 = _state.asStateFlow() + + private val _keysReceived = MutableSharedFlow(extraBufferCapacity = 1) + val keysReceived: SharedFlow = _keysReceived.asSharedFlow() + + private val _stemPressEvents = + MutableSharedFlow(extraBufferCapacity = 8, onBufferOverflow = BufferOverflow.DROP_OLDEST) + val stemPressEvents: SharedFlow = _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() + if (earDetection != null && !earDetection.isEitherPodInEar) { + log(TAG) { "No pod in ear, queuing: ${command::class.simpleName}" } + val (optimistic, snapshot) = coordinator.enqueue(command, currentState) + optimistic?.let { _state.value = it } + _state.value = _state.value.copy( + pendingAncMode = snapshot.pendingAncMode, + pendingSettingsCount = snapshot.count, + ) + return@withLock + } + } + + // Immediate send path (pods in ear, or SetDeviceName) + if (command is AapCommand.SetAncMode) { + val snapshot = coordinator.removeFromQueue(AapCommand.SetAncMode::class) + val currentAnc = currentState.setting() + if (currentAnc != null) { + _state.value = + currentState.withSetting(AapSetting.AncMode::class, currentAnc.copy(current = command.mode)) + .copy(lastMessageAt = timeSource.now()) + } + _state.value = _state.value.copy( + pendingAncMode = snapshot.pendingAncMode, + pendingSettingsCount = snapshot.count, + ) + } + + val preSendDeviceInfo = currentState.deviceInfo + coordinator.optimisticUpdate(currentState, command)?.let { _state.value = it } + try { + wrappedSend(sendRaw, command) + } catch (e: Exception) { + if (command is AapCommand.SetDeviceName && preSendDeviceInfo != null) { + _state.value = _state.value.copy(deviceInfo = preSendDeviceInfo) + } + throw e + } + coordinator.startVerification( + command, + scope!!, + { _state.value }, + { cmd -> wrappedSend(sendRaw, cmd) }, + ::onVerificationOutcome + ) + } + + private suspend fun wrappedSend(sendRaw: suspend (AapCommand) -> Unit, command: AapCommand) { + sendRaw(command) + lastSentCommand = command + 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() + 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\"" } ?: "" + 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() == 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() + 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() + 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, AapSetting>>() + + if (trigger is AapSetting.AncMode && trigger.current == AapSetting.AncMode.Value.OFF) { + val current = _state.value.setting() + 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 { + var start = 0 + while (start < payload.size) { + val b = payload[start].toInt() and 0xFF + if (b in 0x20..0x7E) break + start++ + } + if (start >= payload.size) return emptyList() + + val segments = mutableListOf() + var segIndex = 0 + var i = start + while (i < payload.size) { + while (i < payload.size && payload[i] == 0x00.toByte()) i++ + if (i >= payload.size) break + + val segStart = i + while (i < payload.size && payload[i] != 0x00.toByte()) i++ + val segBytes = payload.copyOfRange(segStart, i) + + val utf8: String? = try { + Charsets.UTF_8.newDecoder() + .onMalformedInput(java.nio.charset.CodingErrorAction.REPORT) + .onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT) + .decode(java.nio.ByteBuffer.wrap(segBytes)) + .toString() + } catch (_: java.nio.charset.CharacterCodingException) { + null + } + val hex = segBytes.joinToString("") { "%02X".format(it) } + + segments.add(DeviceInfoSegment(segIndex, segStart, segBytes.size, utf8, hex)) + segIndex++ + } + return segments + } + + internal data class DeviceInfoSegment( + val index: Int, + val offset: Int, + val length: Int, + val utf8: String?, + val hex: String, + ) + } +} + +// ── 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]) } +} diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapSettingsCoordinator.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapSettingsCoordinator.kt new file mode 100644 index 00000000..e527a868 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapSettingsCoordinator.kt @@ -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, 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 { + 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): PendingSnapshot { + synchronized(pendingCommands) { pendingCommands.remove(commandClass) } + return snapshot() + } + + /** Return sorted pending commands (ANC first) and clear queue. */ + fun flush(): Pair, PendingSnapshot> { + val commands: List + 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, AapSetting> = when (command) { + is AapCommand.SetAncMode -> return null + is AapCommand.SetConversationalAwareness -> { + val cur = baseState.setting() ?: return null + AapSetting.ConversationalAwareness::class to cur.copy(enabled = command.enabled) + } + is AapCommand.SetNcWithOneAirPod -> { + val cur = baseState.setting() ?: return null + AapSetting.NcWithOneAirPod::class to cur.copy(enabled = command.enabled) + } + is AapCommand.SetVolumeSwipe -> { + val cur = baseState.setting() ?: return null + AapSetting.VolumeSwipe::class to cur.copy(enabled = command.enabled) + } + is AapCommand.SetPersonalizedVolume -> { + val cur = baseState.setting() ?: return null + AapSetting.PersonalizedVolume::class to cur.copy(enabled = command.enabled) + } + is AapCommand.SetToneVolume -> { + baseState.setting() ?: return null + AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = command.level) + } + is AapCommand.SetAdaptiveAudioNoise -> { + baseState.setting() ?: return null + AapSetting.AdaptiveAudioNoise::class to AapSetting.AdaptiveAudioNoise(level = command.level) + } + is AapCommand.SetPressSpeed -> { + baseState.setting() ?: return null + AapSetting.PressSpeed::class to AapSetting.PressSpeed(value = command.value) + } + is AapCommand.SetPressHoldDuration -> { + baseState.setting() ?: return null + AapSetting.PressHoldDuration::class to AapSetting.PressHoldDuration(value = command.value) + } + is AapCommand.SetVolumeSwipeLength -> { + baseState.setting() ?: return null + AapSetting.VolumeSwipeLength::class to AapSetting.VolumeSwipeLength(value = command.value) + } + is AapCommand.SetEndCallMuteMic -> { + baseState.setting() ?: 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()?.current == command.mode } + is AapCommand.SetAdaptiveAudioNoise -> { s -> s.setting()?.level == command.level } + is AapCommand.SetConversationalAwareness -> { s -> s.setting()?.enabled == command.enabled } + is AapCommand.SetToneVolume -> { s -> s.setting()?.level == command.level } + is AapCommand.SetPersonalizedVolume -> { s -> s.setting()?.enabled == command.enabled } + is AapCommand.SetVolumeSwipe -> { s -> s.setting()?.enabled == command.enabled } + is AapCommand.SetNcWithOneAirPod -> { s -> s.setting()?.enabled == command.enabled } + is AapCommand.SetPressSpeed -> { s -> s.setting()?.value == command.value } + is AapCommand.SetPressHoldDuration -> { s -> s.setting()?.value == command.value } + is AapCommand.SetVolumeSwipeLength -> { s -> s.setting()?.value == command.value } + is AapCommand.SetEndCallMuteMic -> { s -> + val cur = s.setting() + cur != null && cur.muteMic == command.muteMic && cur.endCall == command.endCall + } + is AapCommand.SetMicrophoneMode -> { s -> s.setting()?.mode == command.mode } + is AapCommand.SetEarDetectionEnabled -> { s -> s.setting()?.enabled == command.enabled } + is AapCommand.SetListeningModeCycle -> { s -> s.setting()?.modeMask == command.modeMask } + is AapCommand.SetAllowOffOption -> { s -> s.setting()?.enabled == command.enabled } + is AapCommand.SetStemConfig -> { s -> s.setting()?.claimedPressMask == command.claimedPressMask } + is AapCommand.SetSleepDetection -> { s -> s.setting()?.enabled == command.enabled } + is AapCommand.SetDeviceName -> null + } + + /** + * Start a delayed verification for a sent command. + * After 1s, checks if the device echoed the expected state. + * On mismatch: resend once, then report [VerificationOutcome.Rejected]. + * Cancels any previous verification (single-flight). + */ + fun startVerification( + command: AapCommand, + scope: CoroutineScope, + stateProvider: () -> AapPodState, + sendRaw: suspend (AapCommand) -> Unit, + onOutcome: (VerificationOutcome) -> Unit, + ) { + val check = verificationFor(command) ?: return + verificationJob?.cancel() + verificationJob = scope.launch { + delay(1000L) + if (check(stateProvider())) { + onOutcome(VerificationOutcome.Confirmed) + return@launch + } + + // Abort if pods left ear — firmware is doing its own thing + val ear = stateProvider().setting() + if (ear != null && !ear.isEitherPodInEar) { + log(TAG) { "Verification aborted for ${command::class.simpleName}: no pod in ear" } + return@launch + } + + log(TAG) { "Divergence detected for ${command::class.simpleName}, re-sending" } + try { + sendRaw(command) + } catch (e: Exception) { + log(TAG, ERROR) { "Verification resend failed: $e" } + return@launch + } + + // Second check after retry + delay(1000L) + if (check(stateProvider())) { + onOutcome(VerificationOutcome.Confirmed) + } else { + log(TAG) { "Rejected after retry: ${command::class.simpleName}" } + onOutcome(VerificationOutcome.Rejected(command)) + } + } + } + + companion object { + private val TAG = logTag("AAP", "Coordinator") + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f0992171..ae29f610 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -472,6 +472,7 @@ Adaptive Audio Noise How much environmental noise is allowed through Requires Adaptive noise control + Changes take effect when AirPods are in ear Press Speed How quickly you need to press for multi-press gestures Default diff --git a/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt index 5b2d64f7..4d319bb2 100644 --- a/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt +++ b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt @@ -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( diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapConnectionTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapConnectionTest.kt index aca9de8d..e899de00 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapConnectionTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapConnectionTest.kt @@ -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" diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapPodStateTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapPodStateTest.kt index 5f248f0f..7f2ea8e7 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapPodStateTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapPodStateTest.kt @@ -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 + } } diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapSettingsCoordinatorTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapSettingsCoordinatorTest.kt new file mode 100644 index 00000000..28327f2e --- /dev/null +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapSettingsCoordinatorTest.kt @@ -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 { + every { now() } returns Instant.ofEpochMilli(1000L) + every { currentTimeMillis() } returns 1000L + } + + private fun createCoordinator() = AapSettingsCoordinator(timeSource) + + private fun stateWithSetting(vararg settings: Pair, AapSetting>): AapPodState { + val map = settings.toMap() + return AapPodState(connectionState = AapPodState.ConnectionState.READY, settings = map) + } + + // ── Enqueue ───────────────────────────────────────────── + + @Nested + inner class EnqueueTests { + + @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()!!.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()!!.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() + commands[1].shouldBeInstanceOf() + } + + @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()!!.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()!!.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()!!.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() + } + + @Test + fun `resends on mismatch then confirms if retry succeeds`() = runTest(UnconfinedTestDispatcher()) { + val coord = createCoordinator() + var outcome: AapSettingsCoordinator.VerificationOutcome? = null + var sendCount = 0 + var currentLevel = 50 // Initially wrong + + coord.startVerification( + command = AapCommand.SetToneVolume(80), + scope = this, + stateProvider = { + stateWithSetting(AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = currentLevel)) + }, + sendRaw = { + sendCount++ + currentLevel = 80 // Simulate device accepting on retry + }, + onOutcome = { outcome = it }, + ) + + advanceTimeBy(1100L) // First check — mismatch, triggers resend + sendCount shouldBe 1 + advanceTimeBy(1100L) // Second check — matches now + outcome.shouldBeInstanceOf() + } + + @Test + fun `rejected after failed retry`() = runTest(UnconfinedTestDispatcher()) { + val coord = createCoordinator() + var outcome: AapSettingsCoordinator.VerificationOutcome? = null + + coord.startVerification( + command = AapCommand.SetToneVolume(80), + scope = this, + stateProvider = { + stateWithSetting(AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = 50)) // Always wrong + }, + sendRaw = { }, + onOutcome = { outcome = it }, + ) + + advanceTimeBy(2200L) // Both checks fail + outcome.shouldBeInstanceOf() + } + + @Test + fun `aborts when no pod in ear`() = runTest(UnconfinedTestDispatcher()) { + val coord = createCoordinator() + var outcome: AapSettingsCoordinator.VerificationOutcome? = null + var sendCount = 0 + + val state = AapPodState( + connectionState = AapPodState.ConnectionState.READY, + settings = mapOf( + AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = 50), + AapSetting.EarDetection::class to AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE, + secondaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE, + ), + ), + ) + + coord.startVerification( + command = AapCommand.SetToneVolume(80), + scope = this, + stateProvider = { state }, + sendRaw = { sendCount++ }, + onOutcome = { outcome = it }, + ) + + advanceTimeBy(2200L) + sendCount shouldBe 0 // No resend attempt + outcome.shouldBeNull() // No outcome — aborted silently + } + + @Test + fun `new startVerification cancels previous`() = runTest(UnconfinedTestDispatcher()) { + val coord = createCoordinator() + var firstOutcome: AapSettingsCoordinator.VerificationOutcome? = null + var secondOutcome: AapSettingsCoordinator.VerificationOutcome? = null + + val state = stateWithSetting( + AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = 80), + AapSetting.PressSpeed::class to AapSetting.PressSpeed(value = AapSetting.PressSpeed.Value.DEFAULT), + ) + + coord.startVerification( + command = AapCommand.SetToneVolume(80), + scope = this, + stateProvider = { state }, + sendRaw = { }, + onOutcome = { firstOutcome = it }, + ) + + // Start second verification before first completes + coord.startVerification( + command = AapCommand.SetPressSpeed(AapSetting.PressSpeed.Value.DEFAULT), + scope = this, + stateProvider = { state }, + sendRaw = { }, + onOutcome = { secondOutcome = it }, + ) + + advanceTimeBy(1100L) + firstOutcome.shouldBeNull() // Cancelled, never completed + secondOutcome.shouldBeInstanceOf() + } + } + + // ── Clear ─────────────────────────────────────────────── + + @Nested + inner class ClearTests { + + @Test + fun `clear empties queue and returns zero snapshot`() { + val coord = createCoordinator() + val state = stateWithSetting() + + coord.enqueue(AapCommand.SetAncMode(AapSetting.AncMode.Value.ON), state) + val snapshot = coord.clear() + + snapshot.count shouldBe 0 + snapshot.pendingAncMode.shouldBeNull() + } + + @Test + fun `clear cancels verification`() = runTest(UnconfinedTestDispatcher()) { + val coord = createCoordinator() + var outcome: AapSettingsCoordinator.VerificationOutcome? = null + + coord.startVerification( + command = AapCommand.SetToneVolume(80), + scope = this, + stateProvider = { + stateWithSetting(AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = 80)) + }, + sendRaw = { }, + onOutcome = { outcome = it }, + ) + + coord.clear() + advanceTimeBy(2000L) + outcome.shouldBeNull() // Cancelled, no outcome + } + } + + // ── Pending State ─────────────────────────────────────── + + @Nested + inner class PendingStateTests { + + @Test + fun `removeFromQueue returns updated snapshot`() { + val coord = createCoordinator() + val state = stateWithSetting( + AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = 50), + ) + + coord.enqueue(AapCommand.SetAncMode(AapSetting.AncMode.Value.ON), state) + coord.enqueue(AapCommand.SetToneVolume(80), state) + + val snapshot = coord.removeFromQueue(AapCommand.SetAncMode::class) + + snapshot.count shouldBe 1 + snapshot.pendingAncMode.shouldBeNull() + } + } +}