Refactor packages

This commit is contained in:
darken
2026-04-17 13:02:14 +02:00
committed by Matthias Urhahn
parent f141ce3f2a
commit 74e48e79eb
16 changed files with 568 additions and 270 deletions
@@ -0,0 +1,219 @@
package eu.darken.capod.main.ui.overview.cards.components
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.twotone.BatteryUnknown
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.ble.formatBatteryPercent
@Composable
fun CompactBatterySummary(
device: PodDevice,
modifier: Modifier = Modifier,
) {
val hasAnyBattery = device.batteryLeft != null
|| device.batteryRight != null
|| device.batteryHeadset != null
|| device.batteryCase != null
Surface(
modifier = modifier
.fillMaxWidth()
.padding(top = 8.dp)
.then(if (!device.isLive) Modifier.alpha(0.7f) else Modifier),
shape = RoundedCornerShape(12.dp),
tonalElevation = 4.dp,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
when {
!hasAnyBattery -> EmptyBatteryRow()
device.hasDualPods -> DualPodsRow(device)
else -> SinglePodRow(device)
}
}
}
}
@Composable
private fun RowScope.DualPodsRow(device: PodDevice) {
MiniPodRing(
iconRes = device.leftPodIcon,
percent = device.batteryLeft,
)
Spacer(modifier = Modifier.width(16.dp))
MiniPodRing(
iconRes = device.rightPodIcon,
percent = device.batteryRight,
)
if (device.hasCase && device.batteryCase != null) {
Spacer(modifier = Modifier.weight(1f))
MiniCaseCluster(device = device)
}
}
@Composable
private fun RowScope.SinglePodRow(device: PodDevice) {
Spacer(modifier = Modifier.weight(1f))
MiniPodRing(
iconRes = null,
percent = device.batteryHeadset,
)
if (device.hasCase && device.batteryCase != null) {
Spacer(modifier = Modifier.weight(1f))
MiniCaseCluster(device = device)
}
Spacer(modifier = Modifier.weight(1f))
}
@Composable
private fun RowScope.EmptyBatteryRow() {
Spacer(modifier = Modifier.weight(1f))
Icon(
imageVector = Icons.AutoMirrored.TwoTone.BatteryUnknown,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.battery_unavailable_label),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.weight(1f))
}
@Composable
private fun MiniPodRing(
iconRes: Int?,
percent: Float?,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val clamped = percent?.coerceIn(0f, 1f)
val animatedProgress by animateFloatAsState(
targetValue = clamped ?: 0f,
animationSpec = tween(600, easing = FastOutSlowInEasing),
label = "miniGaugeProgress",
)
val ringColor = when {
clamped == null -> MaterialTheme.colorScheme.surfaceVariant
clamped > 0.30f -> MaterialTheme.colorScheme.primary
clamped >= 0.15f -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.error
}
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(28.dp),
) {
CircularProgressIndicator(
progress = { 1f },
modifier = Modifier.size(28.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
strokeWidth = 3.dp,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
strokeCap = StrokeCap.Round,
)
if (clamped != null) {
CircularProgressIndicator(
progress = { animatedProgress },
modifier = Modifier.size(28.dp),
color = ringColor,
strokeWidth = 3.dp,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
strokeCap = StrokeCap.Round,
)
}
if (iconRes != null) {
Image(
painter = painterResource(iconRes),
contentDescription = null,
modifier = Modifier.size(16.dp),
)
}
}
Spacer(modifier = Modifier.width(6.dp))
Text(
text = formatBatteryPercent(context, percent),
style = MaterialTheme.typography.titleSmall,
color = if (percent != null) {
MaterialTheme.colorScheme.onSurface
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
@Composable
private fun MiniCaseCluster(
device: PodDevice,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
) {
Image(
painter = painterResource(device.caseIcon),
contentDescription = null,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(6.dp))
BatteryCapsule(
percent = device.batteryCase ?: -1f,
modifier = Modifier
.width(36.dp)
.height(6.dp),
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = formatBatteryPercent(context, device.batteryCase),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
@@ -3,11 +3,12 @@ package eu.darken.capod.pods.core.apple.aap
import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothDevice
import eu.darken.capod.common.TimeSource import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.bluetooth.BluetoothAddress import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.bluetooth.l2cap.L2capSocketFactory import eu.darken.capod.common.bluetooth.l2cap.L2capSocketFactory
import eu.darken.capod.common.coroutine.AppScope import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.apple.PodModel import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.engine.AapConnection
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand 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.AapDeviceProfile
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
@@ -78,7 +79,7 @@ class AapConnectionManager @Inject constructor(
// Clear stale intentional-disconnect flag from previous connection lifecycle // Clear stale intentional-disconnect flag from previous connection lifecycle
intentionalDisconnects.remove(address) intentionalDisconnects.remove(address)
val profile = AapDeviceProfile.Companion.forModel(model) val profile = AapDeviceProfile.forModel(model)
val connection = AapConnection(device, profile, socketFactory, timeSource = timeSource) val connection = AapConnection(device, profile, socketFactory, timeSource = timeSource)
connections[address] = connection connections[address] = connection
@@ -1,5 +1,6 @@
package eu.darken.capod.pods.core.apple.aap package eu.darken.capod.pods.core.apple.aap.engine
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import java.time.Instant import java.time.Instant
import kotlin.reflect.KClass import kotlin.reflect.KClass
@@ -35,11 +36,11 @@ internal class AapAncController {
val previous = podState.settings[key] val previous = podState.settings[key]
val updatedRuntime = runtimeState.copy(latestObservedAncMode = value) val updatedRuntime = runtimeState.copy(latestObservedAncMode = value)
val timerActions = mutableListOf<EngineTimerAction>() val timerActions = mutableListOf<EngineTimerAction>()
timerActions += planAllowOffInferenceTimer(podState, updatedRuntime) timerActions.plusAssign(planAllowOffInferenceTimer(podState, updatedRuntime))
val isFirstAncMode = podState.setting<AapSetting.AncMode>() == null val isFirstAncMode = podState.setting<AapSetting.AncMode>() == null
return if (isFirstAncMode || isRecentAncSend) { return if (isFirstAncMode || isRecentAncSend) {
timerActions += EngineTimerAction.Cancel(EngineTimerKey.AncDebounce) timerActions.plusAssign(EngineTimerAction.Cancel(EngineTimerKey.AncDebounce))
AncDecision( AncDecision(
podState = applyAncSetting(podState, key, value, now, isRecentAncSend), podState = applyAncSetting(podState, key, value, now, isRecentAncSend),
runtimeState = updatedRuntime.copy(pendingDebouncedAnc = null), runtimeState = updatedRuntime.copy(pendingDebouncedAnc = null),
@@ -47,7 +48,7 @@ internal class AapAncController {
logs = listOf("Setting: ${key.simpleName} = $value [was: $previous]"), logs = listOf("Setting: ${key.simpleName} = $value [was: $previous]"),
) )
} else { } else {
timerActions += EngineTimerAction.Start(EngineTimerKey.AncDebounce, 1500L) timerActions.plusAssign(EngineTimerAction.Start(EngineTimerKey.AncDebounce, 1500L))
AncDecision( AncDecision(
podState = podState, podState = podState,
runtimeState = updatedRuntime.copy( runtimeState = updatedRuntime.copy(
@@ -1,15 +1,14 @@
package eu.darken.capod.pods.core.apple.aap package eu.darken.capod.pods.core.apple.aap.engine
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket import android.bluetooth.BluetoothSocket
import eu.darken.capod.common.TimeSource import eu.darken.capod.common.TimeSource
import eu.darken.capod.common.bluetooth.l2cap.L2capSocketFactory import eu.darken.capod.common.bluetooth.l2cap.L2capSocketFactory
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR import eu.darken.capod.common.debug.logging.Logging
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.log
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand 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.AapDeviceProfile
import eu.darken.capod.pods.core.apple.aap.protocol.AapFramer import eu.darken.capod.pods.core.apple.aap.protocol.AapFramer
@@ -31,7 +30,7 @@ import java.io.IOException
/** /**
* Manages a single AAP L2CAP connection to a device. * Manages a single AAP L2CAP connection to a device.
* Thin socket wrapper all session logic lives in [AapSessionEngine]. * Thin socket wrapper all session logic lives in [AapSessionEngine].
* Internal not exposed outside [AapConnectionManager]. * Internal not exposed outside [eu.darken.capod.pods.core.apple.aap.AapConnectionManager].
*/ */
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
internal class AapConnection( internal class AapConnection(
@@ -67,7 +66,7 @@ internal class AapConnection(
val sock = socketFactory.createSocket(device, PSM) val sock = socketFactory.createSocket(device, PSM)
sock.connect() sock.connect()
socket = sock socket = sock
log(TAG, INFO) { "Connected to ${device.address}" } log(TAG, Logging.Priority.INFO) { "Connected to ${device.address}" }
engine.onHandshakeSent() engine.onHandshakeSent()
@@ -99,7 +98,7 @@ internal class AapConnection(
// Launch read loop in the provided scope — connect() returns immediately // Launch read loop in the provided scope — connect() returns immediately
readerJob = scope.launch(Dispatchers.IO) { readLoop(sock) } readerJob = scope.launch(Dispatchers.IO) { readLoop(sock) }
} catch (e: Exception) { } catch (e: Exception) {
log(TAG, ERROR) { "Connection failed: $e" } log(TAG, Logging.Priority.ERROR) { "Connection failed: $e" }
cleanupSocket() cleanupSocket()
engine.reset() engine.reset()
throw e throw e
@@ -107,7 +106,7 @@ internal class AapConnection(
} }
suspend fun disconnect() = withContext(Dispatchers.IO) { suspend fun disconnect() = withContext(Dispatchers.IO) {
log(TAG, INFO) { "Disconnecting" } log(TAG, Logging.Priority.INFO) { "Disconnecting" }
readerJob?.cancel() readerJob?.cancel()
readerJob = null readerJob = null
engine.reset() engine.reset()
@@ -127,7 +126,7 @@ internal class AapConnection(
sock.outputStream.write(bytes) sock.outputStream.write(bytes)
sock.outputStream.flush() sock.outputStream.flush()
val hex = bytes.joinToString(" ") { "%02X".format(it) } val hex = bytes.joinToString(" ") { "%02X".format(it) }
log(TAG, VERBOSE) { "SEND cmd=$command len=${bytes.size} raw=$hex" } log(TAG, Logging.Priority.VERBOSE) { "SEND cmd=$command len=${bytes.size} raw=$hex" }
} }
} }
} }
@@ -145,13 +144,13 @@ internal class AapConnection(
// L2CAP SEQPACKET: each read() returns exactly one complete message // L2CAP SEQPACKET: each read() returns exactly one complete message
val raw = buf.copyOfRange(0, len) val raw = buf.copyOfRange(0, len)
val message = AapMessage.Companion.parse(raw) val message = AapMessage.parse(raw)
if (message != null) { if (message != null) {
engine.processMessage(message) engine.processMessage(message)
} }
} }
} catch (e: IOException) { } catch (e: IOException) {
if (isActive) log(TAG, ERROR) { "Read error: $e" } if (isActive) log(TAG, Logging.Priority.ERROR) { "Read error: $e" }
} finally { } finally {
engine.reset() engine.reset()
cleanupSocket() cleanupSocket()
@@ -170,4 +169,4 @@ internal class AapConnection(
private const val PSM = 0x1001 private const val PSM = 0x1001
private val TAG = logTag("AAP", "Connection") private val TAG = logTag("AAP", "Connection")
} }
} }
@@ -1,4 +1,7 @@
package eu.darken.capod.pods.core.apple.aap package eu.darken.capod.pods.core.apple.aap.engine
import java.nio.ByteBuffer
import java.nio.charset.CodingErrorAction
/** /**
* Diagnostic-only NUL-delimited segmentation of a 0x1D INFORMATION payload, used for * Diagnostic-only NUL-delimited segmentation of a 0x1D INFORMATION payload, used for
@@ -28,11 +31,11 @@ internal object AapDeviceInfoDiagnostics {
val utf8: String? = try { val utf8: String? = try {
Charsets.UTF_8.newDecoder() Charsets.UTF_8.newDecoder()
.onMalformedInput(java.nio.charset.CodingErrorAction.REPORT) .onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT) .onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(java.nio.ByteBuffer.wrap(segBytes)) .decode(ByteBuffer.wrap(segBytes))
.toString() .toString()
} catch (_: java.nio.charset.CharacterCodingException) { } catch (_: CharacterCodingException) {
null null
} }
val hex = segBytes.joinToString("") { "%02X".format(it) } val hex = segBytes.joinToString("") { "%02X".format(it) }
@@ -1,5 +1,6 @@
package eu.darken.capod.pods.core.apple.aap package eu.darken.capod.pods.core.apple.aap.engine
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceProfile
import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage
@@ -1,6 +1,7 @@
package eu.darken.capod.pods.core.apple.aap package eu.darken.capod.pods.core.apple.aap.engine
import eu.darken.capod.common.TimeSource import eu.darken.capod.common.TimeSource
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
@@ -1,4 +1,4 @@
package eu.darken.capod.pods.core.apple.aap package eu.darken.capod.pods.core.apple.aap.engine
import eu.darken.capod.common.TimeSource 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.ERROR
@@ -6,6 +6,7 @@ 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.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand 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.AapDeviceProfile
import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage
@@ -1,6 +1,7 @@
package eu.darken.capod.pods.core.apple.aap package eu.darken.capod.pods.core.apple.aap.engine
import eu.darken.capod.common.TimeSource import eu.darken.capod.common.TimeSource
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import kotlin.reflect.KClass import kotlin.reflect.KClass
@@ -95,38 +96,47 @@ internal class AapSettingsCoordinator(
val cur = baseState.setting<AapSetting.ConversationalAwareness>() ?: return null val cur = baseState.setting<AapSetting.ConversationalAwareness>() ?: return null
AapSetting.ConversationalAwareness::class to cur.copy(enabled = command.enabled) AapSetting.ConversationalAwareness::class to cur.copy(enabled = command.enabled)
} }
is AapCommand.SetNcWithOneAirPod -> { is AapCommand.SetNcWithOneAirPod -> {
val cur = baseState.setting<AapSetting.NcWithOneAirPod>() ?: return null val cur = baseState.setting<AapSetting.NcWithOneAirPod>() ?: return null
AapSetting.NcWithOneAirPod::class to cur.copy(enabled = command.enabled) AapSetting.NcWithOneAirPod::class to cur.copy(enabled = command.enabled)
} }
is AapCommand.SetVolumeSwipe -> { is AapCommand.SetVolumeSwipe -> {
val cur = baseState.setting<AapSetting.VolumeSwipe>() ?: return null val cur = baseState.setting<AapSetting.VolumeSwipe>() ?: return null
AapSetting.VolumeSwipe::class to cur.copy(enabled = command.enabled) AapSetting.VolumeSwipe::class to cur.copy(enabled = command.enabled)
} }
is AapCommand.SetPersonalizedVolume -> { is AapCommand.SetPersonalizedVolume -> {
val cur = baseState.setting<AapSetting.PersonalizedVolume>() ?: return null val cur = baseState.setting<AapSetting.PersonalizedVolume>() ?: return null
AapSetting.PersonalizedVolume::class to cur.copy(enabled = command.enabled) AapSetting.PersonalizedVolume::class to cur.copy(enabled = command.enabled)
} }
is AapCommand.SetToneVolume -> { is AapCommand.SetToneVolume -> {
baseState.setting<AapSetting.ToneVolume>() ?: return null baseState.setting<AapSetting.ToneVolume>() ?: return null
AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = command.level) AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = command.level)
} }
is AapCommand.SetAdaptiveAudioNoise -> { is AapCommand.SetAdaptiveAudioNoise -> {
baseState.setting<AapSetting.AdaptiveAudioNoise>() ?: return null baseState.setting<AapSetting.AdaptiveAudioNoise>() ?: return null
AapSetting.AdaptiveAudioNoise::class to AapSetting.AdaptiveAudioNoise(level = command.level) AapSetting.AdaptiveAudioNoise::class to AapSetting.AdaptiveAudioNoise(level = command.level)
} }
is AapCommand.SetPressSpeed -> { is AapCommand.SetPressSpeed -> {
baseState.setting<AapSetting.PressSpeed>() ?: return null baseState.setting<AapSetting.PressSpeed>() ?: return null
AapSetting.PressSpeed::class to AapSetting.PressSpeed(value = command.value) AapSetting.PressSpeed::class to AapSetting.PressSpeed(value = command.value)
} }
is AapCommand.SetPressHoldDuration -> { is AapCommand.SetPressHoldDuration -> {
baseState.setting<AapSetting.PressHoldDuration>() ?: return null baseState.setting<AapSetting.PressHoldDuration>() ?: return null
AapSetting.PressHoldDuration::class to AapSetting.PressHoldDuration(value = command.value) AapSetting.PressHoldDuration::class to AapSetting.PressHoldDuration(value = command.value)
} }
is AapCommand.SetVolumeSwipeLength -> { is AapCommand.SetVolumeSwipeLength -> {
baseState.setting<AapSetting.VolumeSwipeLength>() ?: return null baseState.setting<AapSetting.VolumeSwipeLength>() ?: return null
AapSetting.VolumeSwipeLength::class to AapSetting.VolumeSwipeLength(value = command.value) AapSetting.VolumeSwipeLength::class to AapSetting.VolumeSwipeLength(value = command.value)
} }
is AapCommand.SetEndCallMuteMic -> { is AapCommand.SetEndCallMuteMic -> {
baseState.setting<AapSetting.EndCallMuteMic>() ?: return null baseState.setting<AapSetting.EndCallMuteMic>() ?: return null
AapSetting.EndCallMuteMic::class to AapSetting.EndCallMuteMic( AapSetting.EndCallMuteMic::class to AapSetting.EndCallMuteMic(
@@ -134,24 +144,31 @@ internal class AapSettingsCoordinator(
endCall = command.endCall, endCall = command.endCall,
) )
} }
is AapCommand.SetMicrophoneMode -> { is AapCommand.SetMicrophoneMode -> {
AapSetting.MicrophoneMode::class to AapSetting.MicrophoneMode(mode = command.mode) AapSetting.MicrophoneMode::class to AapSetting.MicrophoneMode(mode = command.mode)
} }
is AapCommand.SetEarDetectionEnabled -> { is AapCommand.SetEarDetectionEnabled -> {
AapSetting.EarDetectionEnabled::class to AapSetting.EarDetectionEnabled(enabled = command.enabled) AapSetting.EarDetectionEnabled::class to AapSetting.EarDetectionEnabled(enabled = command.enabled)
} }
is AapCommand.SetListeningModeCycle -> { is AapCommand.SetListeningModeCycle -> {
AapSetting.ListeningModeCycle::class to AapSetting.ListeningModeCycle(modeMask = command.modeMask) AapSetting.ListeningModeCycle::class to AapSetting.ListeningModeCycle(modeMask = command.modeMask)
} }
is AapCommand.SetAllowOffOption -> { is AapCommand.SetAllowOffOption -> {
AapSetting.AllowOffOption::class to AapSetting.AllowOffOption(enabled = command.enabled) AapSetting.AllowOffOption::class to AapSetting.AllowOffOption(enabled = command.enabled)
} }
is AapCommand.SetStemConfig -> { is AapCommand.SetStemConfig -> {
AapSetting.StemConfig::class to AapSetting.StemConfig(claimedPressMask = command.claimedPressMask) AapSetting.StemConfig::class to AapSetting.StemConfig(claimedPressMask = command.claimedPressMask)
} }
is AapCommand.SetSleepDetection -> { is AapCommand.SetSleepDetection -> {
AapSetting.SleepDetection::class to AapSetting.SleepDetection(enabled = command.enabled) AapSetting.SleepDetection::class to AapSetting.SleepDetection(enabled = command.enabled)
} }
is AapCommand.SetDeviceName -> { is AapCommand.SetDeviceName -> {
val currentInfo = baseState.deviceInfo ?: return null val currentInfo = baseState.deviceInfo ?: return null
return baseState.copy( return baseState.copy(
@@ -178,6 +195,7 @@ internal class AapSettingsCoordinator(
val cur = s.setting<AapSetting.EndCallMuteMic>() val cur = s.setting<AapSetting.EndCallMuteMic>()
cur != null && cur.muteMic == command.muteMic && cur.endCall == command.endCall cur != null && cur.muteMic == command.muteMic && cur.endCall == command.endCall
} }
is AapCommand.SetMicrophoneMode -> { s -> s.setting<AapSetting.MicrophoneMode>()?.mode == command.mode } is AapCommand.SetMicrophoneMode -> { s -> s.setting<AapSetting.MicrophoneMode>()?.mode == command.mode }
is AapCommand.SetEarDetectionEnabled -> { s -> s.setting<AapSetting.EarDetectionEnabled>()?.enabled == command.enabled } is AapCommand.SetEarDetectionEnabled -> { s -> s.setting<AapSetting.EarDetectionEnabled>()?.enabled == command.enabled }
is AapCommand.SetListeningModeCycle -> { s -> s.setting<AapSetting.ListeningModeCycle>()?.modeMask == command.modeMask } is AapCommand.SetListeningModeCycle -> { s -> s.setting<AapSetting.ListeningModeCycle>()?.modeMask == command.modeMask }
@@ -186,4 +204,4 @@ internal class AapSettingsCoordinator(
is AapCommand.SetSleepDetection -> { s -> s.setting<AapSetting.SleepDetection>()?.enabled == command.enabled } is AapCommand.SetSleepDetection -> { s -> s.setting<AapSetting.SleepDetection>()?.enabled == command.enabled }
is AapCommand.SetDeviceName -> null is AapCommand.SetDeviceName -> null
} }
} }
@@ -1,4 +1,4 @@
package eu.darken.capod.pods.core.apple.aap package eu.darken.capod.pods.core.apple.aap.engine
/** /**
* Batches cmd 0x0017 HID descriptor frames and emits structured summaries. * Batches cmd 0x0017 HID descriptor frames and emits structured summaries.
@@ -21,6 +21,7 @@ internal class HidTracker(private val log: (String) -> Unit) {
val names = type.services.joinToString(", ") val names = type.services.joinToString(", ")
log("HID: services=[$names] (${payload.size}B)") log("HID: services=[$names] (${payload.size}B)")
} }
is HidFrameType.Descriptor -> { is HidFrameType.Descriptor -> {
if (bulkCount == 0) { if (bulkCount == 0) {
bulkPhase = type.phase bulkPhase = type.phase
@@ -35,10 +36,12 @@ internal class HidTracker(private val log: (String) -> Unit) {
bulkCount = 1 bulkCount = 1
} }
} }
is HidFrameType.Terminator -> { is HidFrameType.Terminator -> {
flush() flush()
log("HID: terminator (${type.payloadSize}B)") log("HID: terminator (${type.payloadSize}B)")
} }
is HidFrameType.Other -> { is HidFrameType.Other -> {
flush() flush()
val hex = payload.joinToString(" ") { "%02X".format(it) } val hex = payload.joinToString(" ") { "%02X".format(it) }
@@ -111,4 +114,4 @@ internal class HidTracker(private val log: (String) -> Unit) {
return HidFrameType.Other return HidFrameType.Other
} }
} }
} }
@@ -19,7 +19,7 @@ data class AapMessage(
companion object { companion object {
/** /**
* Parse a complete AAP message from raw bytes. * Parse a complete AAP message from raw bytes.
* AAP messages have the format: [4-byte header] [2-byte command type] [payload...] * AAP messages have the format: [4-byte header] [2-byte command type] [payload]
* Minimum message size is 6 bytes (header + command type with no payload). * Minimum message size is 6 bytes (header + command type with no payload).
*/ */
fun parse(raw: ByteArray): AapMessage? { fun parse(raw: ByteArray): AapMessage? {
@@ -1,11 +1,13 @@
package eu.darken.capod.pods.core.apple.aap package eu.darken.capod.pods.core.apple.aap.engine
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import testhelpers.BaseTest import testhelpers.BaseTest
import java.time.Instant import java.time.Instant
import kotlin.reflect.KClass
class AapAncControllerTest : BaseTest() { class AapAncControllerTest : BaseTest() {
@@ -17,7 +19,7 @@ class AapAncControllerTest : BaseTest() {
AapSetting.AncMode.Value.ADAPTIVE, AapSetting.AncMode.Value.ADAPTIVE,
) )
private fun podStateWith(vararg settings: Pair<kotlin.reflect.KClass<out AapSetting>, AapSetting>): AapPodState = private fun podStateWith(vararg settings: Pair<KClass<out AapSetting>, AapSetting>): AapPodState =
AapPodState( AapPodState(
connectionState = AapPodState.ConnectionState.READY, connectionState = AapPodState.ConnectionState.READY,
settings = settings.toMap(), settings = settings.toMap(),
@@ -149,4 +151,4 @@ class AapAncControllerTest : BaseTest() {
decision.podState.setting<AapSetting.AllowOffOption>()!!.enabled shouldBe false decision.podState.setting<AapSetting.AllowOffOption>()!!.enabled shouldBe false
decision.timerActions shouldBe listOf(EngineTimerAction.Cancel(EngineTimerKey.AllowOffInference)) decision.timerActions shouldBe listOf(EngineTimerAction.Cancel(EngineTimerKey.AllowOffInference))
} }
} }
@@ -1,4 +1,4 @@
package eu.darken.capod.pods.core.apple.aap package eu.darken.capod.pods.core.apple.aap.engine
import eu.darken.capod.pods.core.apple.PodModel import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage
@@ -15,7 +15,7 @@ import testhelpers.BaseTest
* The helper must: * The helper must:
* - Work on real captured 0x1D payloads (matches production decoder on ASCII slots). * - Work on real captured 0x1D payloads (matches production decoder on ASCII slots).
* - Decode non-ASCII UTF-8 (emoji, non-Latin script) the production parser drops these. * - Decode non-ASCII UTF-8 (emoji, non-Latin script) the production parser drops these.
* - Always emit segments for any payload shape, even when [DefaultAapDeviceProfile.decodeDeviceInfo] * - Always emit segments for any payload shape, even when [eu.darken.capod.pods.core.apple.aap.protocol.DefaultAapDeviceProfile.decodeDeviceInfo]
* would return null (malformed / unknown-shaped packets). * would return null (malformed / unknown-shaped packets).
*/ */
class AapDeviceInfoDiagnosticsTest : BaseTest() { class AapDeviceInfoDiagnosticsTest : BaseTest() {
@@ -78,8 +78,8 @@ class AapDeviceInfoDiagnosticsTest : BaseTest() {
// so any emoji / non-Latin engraving is silently skipped. The diagnostic helper must not. // so any emoji / non-Latin engraving is silently skipped. The diagnostic helper must not.
val engraving = "My AirPods 🌈" val engraving = "My AirPods 🌈"
val payload = hex("02 DF 00 04 00") + val payload = hex("02 DF 00 04 00") +
engraving.toByteArray(Charsets.UTF_8) + byteArrayOf(0x00) + engraving.toByteArray(Charsets.UTF_8) + byteArrayOf(0x00) +
"A3048".toByteArray(Charsets.UTF_8) + byteArrayOf(0x00) "A3048".toByteArray(Charsets.UTF_8) + byteArrayOf(0x00)
val segments = AapDeviceInfoDiagnostics.describeSegments(payload) val segments = AapDeviceInfoDiagnostics.describeSegments(payload)
@@ -94,8 +94,8 @@ class AapDeviceInfoDiagnosticsTest : BaseTest() {
// diagnostic dump must still surface whatever segments it finds so maintainers can inspect // diagnostic dump must still surface whatever segments it finds so maintainers can inspect
// unexpected-shape packets from an engraved device. // unexpected-shape packets from an engraved device.
val payload = hex("02 DF 00 04 00") + val payload = hex("02 DF 00 04 00") +
"Name".toByteArray(Charsets.UTF_8) + byteArrayOf(0x00) + "Name".toByteArray(Charsets.UTF_8) + byteArrayOf(0x00) +
"Model".toByteArray(Charsets.UTF_8) + byteArrayOf(0x00) "Model".toByteArray(Charsets.UTF_8) + byteArrayOf(0x00)
val fullMessageBytes = hex("04 00 04 00 1D 00") + payload val fullMessageBytes = hex("04 00 04 00 1D 00") + payload
val message = AapMessage.parse(fullMessageBytes)!! val message = AapMessage.parse(fullMessageBytes)!!
@@ -106,4 +106,4 @@ class AapDeviceInfoDiagnosticsTest : BaseTest() {
segments[0].utf8 shouldBe "Name" segments[0].utf8 shouldBe "Name"
segments[1].utf8 shouldBe "Model" segments[1].utf8 shouldBe "Model"
} }
} }
@@ -1,11 +1,11 @@
package eu.darken.capod.pods.core.apple.aap package eu.darken.capod.pods.core.apple.aap.engine
import eu.darken.capod.common.TimeSource import eu.darken.capod.common.TimeSource
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand 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.AapDeviceProfile
import eu.darken.capod.pods.core.apple.aap.protocol.AapMessage 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.AapSetting
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent import eu.darken.capod.pods.core.apple.aap.protocol.StemPressEvent
import io.kotest.matchers.collections.shouldBeEmpty import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.nulls.shouldBeNull
@@ -131,22 +131,28 @@ class AapSessionEngineTest : BaseTest() {
engine.start(this as TestScope) engine.start(this as TestScope)
engine.onHandshakeSent() engine.onHandshakeSent()
nextSetting = settingPair(AapSetting.EarDetection( nextSetting = settingPair(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR, AapSetting.EarDetection(
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR, primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
)) secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
)
)
engine.processMessage(dummyMessage(commandType = 0x0002)) engine.processMessage(dummyMessage(commandType = 0x0002))
nextSetting = settingPair(AapSetting.AncMode( nextSetting = settingPair(
current = AapSetting.AncMode.Value.ON, AapSetting.AncMode(
supported = supportedModes, current = AapSetting.AncMode.Value.ON,
)) supported = supportedModes,
)
)
engine.processMessage(dummyMessage()) engine.processMessage(dummyMessage())
nextSetting = settingPair(AapSetting.AncMode( nextSetting = settingPair(
current = AapSetting.AncMode.Value.TRANSPARENCY, AapSetting.AncMode(
supported = supportedModes, current = AapSetting.AncMode.Value.TRANSPARENCY,
)) supported = supportedModes,
)
)
engine.processMessage(dummyMessage()) engine.processMessage(dummyMessage())
engine.reset() engine.reset()
@@ -243,67 +249,68 @@ class AapSessionEngineTest : BaseTest() {
} }
@Test @Test
fun `flush with ANC plus later setting keeps ANC recent and verifies ANC first`() = runTest(UnconfinedTestDispatcher()) { fun `flush with ANC plus later setting keeps ANC recent and verifies ANC first`() =
val ancSetting = AapSetting.AncMode( runTest(UnconfinedTestDispatcher()) {
current = AapSetting.AncMode.Value.ON, val ancSetting = AapSetting.AncMode(
supported = listOf(AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.ADAPTIVE), current = AapSetting.AncMode.Value.ON,
) supported = listOf(AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.ADAPTIVE),
var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null )
val profile = mockProfile { var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null
every { decodeSetting(any()) } answers { nextSetting } val profile = mockProfile {
every { decodeSetting(any()) } answers { nextSetting }
}
val engine = AapSessionEngine(profile, timeSource)
engine.startReady(this as TestScope)
nextSetting = AapSetting.EarDetection::class as KClass<out AapSetting> to AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE,
secondaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE,
)
engine.processMessage(dummyMessage())
nextSetting = AapSetting.AncMode::class as KClass<out AapSetting> to ancSetting
engine.processMessage(dummyMessage())
nextSetting =
AapSetting.ConversationalAwareness::class as KClass<out AapSetting> to
AapSetting.ConversationalAwareness(enabled = false)
engine.processMessage(dummyMessage())
val sentCommands = mutableListOf<AapCommand>()
val sendRaw: suspend (AapCommand) -> Unit = { sentCommands += it }
engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE), sendRaw)
engine.send(AapCommand.SetConversationalAwareness(true), sendRaw)
engine.state.value.pendingSettingsCount shouldBe 2
engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
nextSetting = AapSetting.EarDetection::class as KClass<out AapSetting> to AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
)
engine.processMessage(dummyMessage())
runCurrent()
sentCommands.size shouldBe 2
sentCommands[0] shouldBe AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)
sentCommands[1] shouldBe AapCommand.SetConversationalAwareness(true)
engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
nextSetting = AapSetting.AncMode::class as KClass<out AapSetting> to
ancSetting.copy(current = AapSetting.AncMode.Value.ON)
engine.processMessage(dummyMessage())
// This would still be ADAPTIVE if the mixed flush path lost the ANC send marker and debounced.
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ON
engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
advanceTimeBy(1100L)
sentCommands.size shouldBe 3
sentCommands[2] shouldBe AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)
engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
} }
val engine = AapSessionEngine(profile, timeSource)
engine.startReady(this as TestScope)
nextSetting = AapSetting.EarDetection::class as KClass<out AapSetting> to AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE,
secondaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE,
)
engine.processMessage(dummyMessage())
nextSetting = AapSetting.AncMode::class as KClass<out AapSetting> to ancSetting
engine.processMessage(dummyMessage())
nextSetting =
AapSetting.ConversationalAwareness::class as KClass<out AapSetting> to
AapSetting.ConversationalAwareness(enabled = false)
engine.processMessage(dummyMessage())
val sentCommands = mutableListOf<AapCommand>()
val sendRaw: suspend (AapCommand) -> Unit = { sentCommands += it }
engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE), sendRaw)
engine.send(AapCommand.SetConversationalAwareness(true), sendRaw)
engine.state.value.pendingSettingsCount shouldBe 2
engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
nextSetting = AapSetting.EarDetection::class as KClass<out AapSetting> to AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
)
engine.processMessage(dummyMessage())
runCurrent()
sentCommands.size shouldBe 2
sentCommands[0] shouldBe AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)
sentCommands[1] shouldBe AapCommand.SetConversationalAwareness(true)
engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
nextSetting = AapSetting.AncMode::class as KClass<out AapSetting> to
ancSetting.copy(current = AapSetting.AncMode.Value.ON)
engine.processMessage(dummyMessage())
// This would still be ADAPTIVE if the mixed flush path lost the ANC send marker and debounced.
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ON
engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
advanceTimeBy(1100L)
sentCommands.size shouldBe 3
sentCommands[2] shouldBe AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)
engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
}
} }
// ── Message processing — state merge ──────────────────── // ── Message processing — state merge ────────────────────
@@ -402,43 +409,50 @@ class AapSessionEngineTest : BaseTest() {
inner class InferenceTests { inner class InferenceTests {
@Test @Test
fun `startup OFF while no pod is in ear does not infer AllowOffOption true`() = runTest(UnconfinedTestDispatcher()) { fun `startup OFF while no pod is in ear does not infer AllowOffOption true`() =
val supportedModes = listOf( runTest(UnconfinedTestDispatcher()) {
AapSetting.AncMode.Value.OFF, val supportedModes = listOf(
AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.OFF,
AapSetting.AncMode.Value.ADAPTIVE, AapSetting.AncMode.Value.ON,
) AapSetting.AncMode.Value.ADAPTIVE,
var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null )
val profile = mockProfile { var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null
every { decodeSetting(any()) } answers { nextSetting } val profile = mockProfile {
every { decodeSetting(any()) } answers { nextSetting }
}
val engine = AapSessionEngine(profile, timeSource)
engine.start(this as TestScope)
engine.onHandshakeSent()
nextSetting = settingPair(
AapSetting.AncMode(
current = AapSetting.AncMode.Value.OFF,
supported = supportedModes,
)
)
engine.processMessage(dummyMessage(commandType = 0x0002))
engine.state.value.setting<AapSetting.AllowOffOption>().shouldBeNull()
nextSetting = settingPair(
AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE,
secondaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE,
)
)
engine.processMessage(dummyMessage())
advanceTimeBy(1600L)
engine.state.value.setting<AapSetting.AllowOffOption>().shouldBeNull()
nextSetting = settingPair(
AapSetting.AncMode(
current = AapSetting.AncMode.Value.ADAPTIVE,
supported = supportedModes,
)
)
engine.processMessage(dummyMessage())
advanceTimeBy(1600L)
engine.state.value.setting<AapSetting.AllowOffOption>().shouldBeNull()
} }
val engine = AapSessionEngine(profile, timeSource)
engine.start(this as TestScope)
engine.onHandshakeSent()
nextSetting = settingPair(AapSetting.AncMode(
current = AapSetting.AncMode.Value.OFF,
supported = supportedModes,
))
engine.processMessage(dummyMessage(commandType = 0x0002))
engine.state.value.setting<AapSetting.AllowOffOption>().shouldBeNull()
nextSetting = settingPair(AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE,
secondaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE,
))
engine.processMessage(dummyMessage())
advanceTimeBy(1600L)
engine.state.value.setting<AapSetting.AllowOffOption>().shouldBeNull()
nextSetting = settingPair(AapSetting.AncMode(
current = AapSetting.AncMode.Value.ADAPTIVE,
supported = supportedModes,
))
engine.processMessage(dummyMessage())
advanceTimeBy(1600L)
engine.state.value.setting<AapSetting.AllowOffOption>().shouldBeNull()
}
@Test @Test
fun `stable in-ear OFF infers AllowOffOption true after delay`() = runTest(UnconfinedTestDispatcher()) { fun `stable in-ear OFF infers AllowOffOption true after delay`() = runTest(UnconfinedTestDispatcher()) {
@@ -455,16 +469,20 @@ class AapSessionEngineTest : BaseTest() {
engine.start(this as TestScope) engine.start(this as TestScope)
engine.onHandshakeSent() engine.onHandshakeSent()
nextSetting = settingPair(AapSetting.EarDetection( nextSetting = settingPair(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR, AapSetting.EarDetection(
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR, primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
)) secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
)
)
engine.processMessage(dummyMessage(commandType = 0x0002)) engine.processMessage(dummyMessage(commandType = 0x0002))
nextSetting = settingPair(AapSetting.AncMode( nextSetting = settingPair(
current = AapSetting.AncMode.Value.OFF, AapSetting.AncMode(
supported = supportedModes, current = AapSetting.AncMode.Value.OFF,
)) supported = supportedModes,
)
)
engine.processMessage(dummyMessage()) engine.processMessage(dummyMessage())
engine.state.value.setting<AapSetting.AllowOffOption>().shouldBeNull() engine.state.value.setting<AapSetting.AllowOffOption>().shouldBeNull()
@@ -473,43 +491,50 @@ class AapSessionEngineTest : BaseTest() {
} }
@Test @Test
fun `later non-OFF ANC update cancels pending AllowOffOption true inference`() = runTest(UnconfinedTestDispatcher()) { fun `later non-OFF ANC update cancels pending AllowOffOption true inference`() =
val supportedModes = listOf( runTest(UnconfinedTestDispatcher()) {
AapSetting.AncMode.Value.OFF, val supportedModes = listOf(
AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.OFF,
AapSetting.AncMode.Value.ADAPTIVE, AapSetting.AncMode.Value.ON,
) AapSetting.AncMode.Value.ADAPTIVE,
var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null )
val profile = mockProfile { var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null
every { decodeSetting(any()) } answers { nextSetting } val profile = mockProfile {
every { decodeSetting(any()) } answers { nextSetting }
}
val engine = AapSessionEngine(profile, timeSource)
engine.start(this as TestScope)
engine.onHandshakeSent()
nextSetting = settingPair(
AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
)
)
engine.processMessage(dummyMessage(commandType = 0x0002))
nextSetting = settingPair(
AapSetting.AncMode(
current = AapSetting.AncMode.Value.OFF,
supported = supportedModes,
)
)
engine.processMessage(dummyMessage())
advanceTimeBy(500L)
nextSetting = settingPair(
AapSetting.AncMode(
current = AapSetting.AncMode.Value.ADAPTIVE,
supported = supportedModes,
)
)
engine.processMessage(dummyMessage())
advanceTimeBy(1600L)
engine.state.value.setting<AapSetting.AllowOffOption>().shouldBeNull()
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ADAPTIVE
} }
val engine = AapSessionEngine(profile, timeSource)
engine.start(this as TestScope)
engine.onHandshakeSent()
nextSetting = settingPair(AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
))
engine.processMessage(dummyMessage(commandType = 0x0002))
nextSetting = settingPair(AapSetting.AncMode(
current = AapSetting.AncMode.Value.OFF,
supported = supportedModes,
))
engine.processMessage(dummyMessage())
advanceTimeBy(500L)
nextSetting = settingPair(AapSetting.AncMode(
current = AapSetting.AncMode.Value.ADAPTIVE,
supported = supportedModes,
))
engine.processMessage(dummyMessage())
advanceTimeBy(1600L)
engine.state.value.setting<AapSetting.AllowOffOption>().shouldBeNull()
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ADAPTIVE
}
@Test @Test
fun `rejected OFF command infers AllowOffOption false`() = runTest(UnconfinedTestDispatcher()) { fun `rejected OFF command infers AllowOffOption false`() = runTest(UnconfinedTestDispatcher()) {
@@ -525,16 +550,20 @@ class AapSessionEngineTest : BaseTest() {
val engine = AapSessionEngine(profile, timeSource) val engine = AapSessionEngine(profile, timeSource)
engine.startReady(this as TestScope) engine.startReady(this as TestScope)
nextSetting = settingPair(AapSetting.EarDetection( nextSetting = settingPair(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR, AapSetting.EarDetection(
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR, primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
)) secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
)
)
engine.processMessage(dummyMessage()) engine.processMessage(dummyMessage())
nextSetting = settingPair(AapSetting.AncMode( nextSetting = settingPair(
current = AapSetting.AncMode.Value.ADAPTIVE, AapSetting.AncMode(
supported = supportedModes, current = AapSetting.AncMode.Value.ADAPTIVE,
)) supported = supportedModes,
)
)
engine.processMessage(dummyMessage()) engine.processMessage(dummyMessage())
nextSetting = settingPair(AapSetting.AllowOffOption(enabled = true)) nextSetting = settingPair(AapSetting.AllowOffOption(enabled = true))
@@ -543,10 +572,12 @@ class AapSessionEngineTest : BaseTest() {
val sentCommands = mutableListOf<AapCommand>() val sentCommands = mutableListOf<AapCommand>()
engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF)) { sentCommands += it } engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF)) { sentCommands += it }
nextSetting = settingPair(AapSetting.AncMode( nextSetting = settingPair(
current = AapSetting.AncMode.Value.ADAPTIVE, AapSetting.AncMode(
supported = supportedModes, current = AapSetting.AncMode.Value.ADAPTIVE,
)) supported = supportedModes,
)
)
engine.processMessage(dummyMessage()) engine.processMessage(dummyMessage())
advanceTimeBy(2100L) advanceTimeBy(2100L)
@@ -561,47 +592,54 @@ class AapSessionEngineTest : BaseTest() {
@Test @Test
fun `matching ANC echo clears pending mode without optimistic current overwrite`() = runTest(UnconfinedTestDispatcher()) { fun `matching ANC echo clears pending mode without optimistic current overwrite`() =
val supportedModes = listOf( runTest(UnconfinedTestDispatcher()) {
AapSetting.AncMode.Value.OFF, val supportedModes = listOf(
AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.OFF,
AapSetting.AncMode.Value.ADAPTIVE, AapSetting.AncMode.Value.ON,
) AapSetting.AncMode.Value.ADAPTIVE,
var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null )
val profile = mockProfile { var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null
every { decodeSetting(any()) } answers { nextSetting } val profile = mockProfile {
every { decodeSetting(any()) } answers { nextSetting }
}
val engine = AapSessionEngine(profile, timeSource)
engine.startReady(this as TestScope)
nextSetting = settingPair(
AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
)
)
engine.processMessage(dummyMessage())
nextSetting = settingPair(
AapSetting.AncMode(
current = AapSetting.AncMode.Value.ON,
supported = supportedModes,
)
)
engine.processMessage(dummyMessage())
val sentCommands = mutableListOf<AapCommand>()
engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)) { sentCommands += it }
engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ON
nextSetting = settingPair(
AapSetting.AncMode(
current = AapSetting.AncMode.Value.ADAPTIVE,
supported = supportedModes,
)
)
engine.processMessage(dummyMessage())
engine.state.value.pendingAncMode.shouldBeNull()
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ADAPTIVE
sentCommands shouldBe listOf(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE))
} }
val engine = AapSessionEngine(profile, timeSource)
engine.startReady(this as TestScope)
nextSetting = settingPair(AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
))
engine.processMessage(dummyMessage())
nextSetting = settingPair(AapSetting.AncMode(
current = AapSetting.AncMode.Value.ON,
supported = supportedModes,
))
engine.processMessage(dummyMessage())
val sentCommands = mutableListOf<AapCommand>()
engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)) { sentCommands += it }
engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ON
nextSetting = settingPair(AapSetting.AncMode(
current = AapSetting.AncMode.Value.ADAPTIVE,
supported = supportedModes,
))
engine.processMessage(dummyMessage())
engine.state.value.pendingAncMode.shouldBeNull()
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ADAPTIVE
sentCommands shouldBe listOf(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE))
}
// ── ANC Debounce ──────────────────────────────────────── // ── ANC Debounce ────────────────────────────────────────
@@ -645,7 +683,9 @@ fun `matching ANC echo clears pending mode without optimistic current overwrite`
every { decodeBattery(any()) } returns null every { decodeBattery(any()) } returns null
every { decodePrivateKeyResponse(any()) } returns null every { decodePrivateKeyResponse(any()) } returns null
every { decodeDeviceInfo(any()) } returns null every { decodeDeviceInfo(any()) } returns null
every { decodeSetting(any()) } returns (AapSetting.AncMode::class as KClass<out AapSetting> to ancSetting.copy(current = AapSetting.AncMode.Value.TRANSPARENCY)) every { decodeSetting(any()) } returns (AapSetting.AncMode::class as KClass<out AapSetting> to ancSetting.copy(
current = AapSetting.AncMode.Value.TRANSPARENCY
))
} }
val engine = AapSessionEngine(profile, timeSource) val engine = AapSessionEngine(profile, timeSource)
engine.start(this as TestScope) engine.start(this as TestScope)
@@ -658,7 +698,9 @@ fun `matching ANC echo clears pending mode without optimistic current overwrite`
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ON engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ON
// Second message: unsolicited change — should be debounced // Second message: unsolicited change — should be debounced
every { profile.decodeSetting(any()) } returns (AapSetting.AncMode::class as KClass<out AapSetting> to ancSetting.copy(current = AapSetting.AncMode.Value.TRANSPARENCY)) every { profile.decodeSetting(any()) } returns (AapSetting.AncMode::class as KClass<out AapSetting> to ancSetting.copy(
current = AapSetting.AncMode.Value.TRANSPARENCY
))
engine.processMessage(dummyMessage()) engine.processMessage(dummyMessage())
// Not yet applied (debounced) // Not yet applied (debounced)
@@ -731,7 +773,10 @@ fun `matching ANC echo clears pending mode without optimistic current overwrite`
@Test @Test
fun `0x0017 does not run through decode pipeline`() = runTest(UnconfinedTestDispatcher()) { fun `0x0017 does not run through decode pipeline`() = runTest(UnconfinedTestDispatcher()) {
val profile = mockProfile { val profile = mockProfile {
every { decodeStemPress(any()) } returns StemPressEvent(StemPressEvent.PressType.SINGLE, StemPressEvent.Bud.LEFT) every { decodeStemPress(any()) } returns StemPressEvent(
StemPressEvent.PressType.SINGLE,
StemPressEvent.Bud.LEFT
)
} }
val engine = createEngine(profile) val engine = createEngine(profile)
engine.startReady(this as TestScope) engine.startReady(this as TestScope)
@@ -750,4 +795,4 @@ fun `matching ANC echo clears pending mode without optimistic current overwrite`
collector.cancel() collector.cancel()
} }
} }
} }
@@ -1,6 +1,7 @@
package eu.darken.capod.pods.core.apple.aap package eu.darken.capod.pods.core.apple.aap.engine
import eu.darken.capod.common.TimeSource import eu.darken.capod.common.TimeSource
import eu.darken.capod.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand 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.AapDeviceInfo
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
@@ -52,7 +53,8 @@ class AapSettingsCoordinatorTest : BaseTest() {
fun `enqueue ANC returns pendingAncMode in snapshot`() { fun `enqueue ANC returns pendingAncMode in snapshot`() {
val coord = createCoordinator() val coord = createCoordinator()
val result = coord.enqueue(emptyList(), AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE), stateWithSetting()) val result =
coord.enqueue(emptyList(), AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE), stateWithSetting())
result.optimisticState.shouldBeNull() result.optimisticState.shouldBeNull()
result.snapshot.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE result.snapshot.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
@@ -96,7 +98,8 @@ class AapSettingsCoordinatorTest : BaseTest() {
) )
val first = coord.enqueue(emptyList(), AapCommand.SetAdaptiveAudioNoise(70), state) val first = coord.enqueue(emptyList(), AapCommand.SetAdaptiveAudioNoise(70), state)
val second = coord.enqueue(first.pendingCommands, AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE), state) val second =
coord.enqueue(first.pendingCommands, AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE), state)
second.snapshot.count shouldBe 2 second.snapshot.count shouldBe 2
} }
@@ -133,7 +136,8 @@ class AapSettingsCoordinatorTest : BaseTest() {
) )
val first = coord.enqueue(emptyList(), AapCommand.SetToneVolume(80), state) val first = coord.enqueue(emptyList(), AapCommand.SetToneVolume(80), state)
val second = coord.enqueue(first.pendingCommands, AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE), state) val second =
coord.enqueue(first.pendingCommands, AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE), state)
val result = coord.flush(second.pendingCommands) val result = coord.flush(second.pendingCommands)
result.commands shouldHaveSize 2 result.commands shouldHaveSize 2
@@ -150,7 +154,8 @@ class AapSettingsCoordinatorTest : BaseTest() {
) )
val first = coord.enqueue(emptyList(), AapCommand.SetToneVolume(80), state) val first = coord.enqueue(emptyList(), AapCommand.SetToneVolume(80), state)
val second = coord.enqueue(first.pendingCommands, AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF), state) val second =
coord.enqueue(first.pendingCommands, AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF), state)
val third = coord.enqueue(second.pendingCommands, AapCommand.SetAllowOffOption(true), state) val third = coord.enqueue(second.pendingCommands, AapCommand.SetAllowOffOption(true), state)
val result = coord.flush(third.pendingCommands) val result = coord.flush(third.pendingCommands)
@@ -296,4 +301,4 @@ class AapSettingsCoordinatorTest : BaseTest() {
check(mismatched) shouldBe false check(mismatched) shouldBe false
} }
} }
} }
@@ -1,6 +1,5 @@
package eu.darken.capod.pods.core.apple.aap package eu.darken.capod.pods.core.apple.aap.engine
import eu.darken.capod.pods.core.apple.aap.HidTracker.HidFrameType
import io.kotest.matchers.collections.shouldBeEmpty import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.collections.shouldContainExactly import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldBe
@@ -23,11 +22,11 @@ class HidTrackerTest : BaseTest() {
fun `service directory frame from Pro 3 capture`() { fun `service directory frame from Pro 3 capture`() {
val payload = hexToBytes( val payload = hexToBytes(
"FE 00 00 06 41 50 00 00 00 80 00 00 41 4F 50 00 00 80 00 00 " + "FE 00 00 06 41 50 00 00 00 80 00 00 41 4F 50 00 00 80 00 00 " +
"52 54 50 00 00 80 00 00 42 54 4D 00 00 80 00 00 " + "52 54 50 00 00 80 00 00 42 54 4D 00 00 80 00 00 " +
"44 53 50 31 00 80 00 00 44 53 50 32 00 80 00 00" "44 53 50 31 00 80 00 00 44 53 50 32 00 80 00 00"
) )
val result = HidTracker.classify(payload) val result = HidTracker.classify(payload)
result.shouldBeInstanceOf<HidFrameType.ServiceDirectory>() result.shouldBeInstanceOf<HidTracker.HidFrameType.ServiceDirectory>()
result.services.shouldContainExactly("AP", "AOP", "RTP", "BTM", "DSP1", "DSP2") result.services.shouldContainExactly("AP", "AOP", "RTP", "BTM", "DSP1", "DSP2")
} }
@@ -36,7 +35,7 @@ class HidTrackerTest : BaseTest() {
val fill = ByteArray(65) { 0xFF.toByte() } val fill = ByteArray(65) { 0xFF.toByte() }
val payload = hexToBytes("00 04 00 00 44 00 01 A1 81") + fill val payload = hexToBytes("00 04 00 00 44 00 01 A1 81") + fill
val result = HidTracker.classify(payload) val result = HidTracker.classify(payload)
result.shouldBeInstanceOf<HidFrameType.Descriptor>() result.shouldBeInstanceOf<HidTracker.HidFrameType.Descriptor>()
result.phase shouldBe 0x81 result.phase shouldBe 0x81
result.fill shouldBe 0xFF result.fill shouldBe 0xFF
} }
@@ -46,7 +45,7 @@ class HidTrackerTest : BaseTest() {
val fill = ByteArray(65) { 0xEF.toByte() } val fill = ByteArray(65) { 0xEF.toByte() }
val payload = hexToBytes("00 04 00 00 44 00 01 C3 02") + fill val payload = hexToBytes("00 04 00 00 44 00 01 C3 02") + fill
val result = HidTracker.classify(payload) val result = HidTracker.classify(payload)
result.shouldBeInstanceOf<HidFrameType.Descriptor>() result.shouldBeInstanceOf<HidTracker.HidFrameType.Descriptor>()
result.phase shouldBe 0x02 result.phase shouldBe 0x02
result.fill shouldBe 0xEF result.fill shouldBe 0xEF
} }
@@ -55,7 +54,7 @@ class HidTrackerTest : BaseTest() {
fun `terminator frame`() { fun `terminator frame`() {
val payload = hexToBytes("00 04 00 00 01 00 FF") val payload = hexToBytes("00 04 00 00 01 00 FF")
val result = HidTracker.classify(payload) val result = HidTracker.classify(payload)
result.shouldBeInstanceOf<HidFrameType.Terminator>() result.shouldBeInstanceOf<HidTracker.HidFrameType.Terminator>()
result.payloadSize shouldBe 7 result.payloadSize shouldBe 7
} }
@@ -63,20 +62,20 @@ class HidTrackerTest : BaseTest() {
fun `short frame ending in FF but not 7 bytes is Other`() { fun `short frame ending in FF but not 7 bytes is Other`() {
val payload = hexToBytes("00 04 00 FF") val payload = hexToBytes("00 04 00 FF")
val result = HidTracker.classify(payload) val result = HidTracker.classify(payload)
result.shouldBeInstanceOf<HidFrameType.Other>() result.shouldBeInstanceOf<HidTracker.HidFrameType.Other>()
} }
@Test @Test
fun `empty payload is Other`() { fun `empty payload is Other`() {
val result = HidTracker.classify(ByteArray(0)) val result = HidTracker.classify(ByteArray(0))
result.shouldBeInstanceOf<HidFrameType.Other>() result.shouldBeInstanceOf<HidTracker.HidFrameType.Other>()
} }
@Test @Test
fun `random payload is Other`() { fun `random payload is Other`() {
val payload = hexToBytes("AB CD EF 01 02 03 04 05 06 07 08") val payload = hexToBytes("AB CD EF 01 02 03 04 05 06 07 08")
val result = HidTracker.classify(payload) val result = HidTracker.classify(payload)
result.shouldBeInstanceOf<HidFrameType.Other>() result.shouldBeInstanceOf<HidTracker.HidFrameType.Other>()
} }
} }
@@ -211,4 +210,4 @@ class HidTrackerTest : BaseTest() {
logs.shouldBeEmpty() logs.shouldBeEmpty()
} }
} }
} }