mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
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:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user