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 eu.darken.capod.common.TimeSource
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.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.aap.engine.AapConnection
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.KeyExchangeResult
@@ -78,7 +79,7 @@ class AapConnectionManager @Inject constructor(
// Clear stale intentional-disconnect flag from previous connection lifecycle
intentionalDisconnects.remove(address)
val profile = AapDeviceProfile.Companion.forModel(model)
val profile = AapDeviceProfile.forModel(model)
val connection = AapConnection(device, profile, socketFactory, timeSource = timeSource)
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 java.time.Instant
import kotlin.reflect.KClass
@@ -35,11 +36,11 @@ internal class AapAncController {
val previous = podState.settings[key]
val updatedRuntime = runtimeState.copy(latestObservedAncMode = value)
val timerActions = mutableListOf<EngineTimerAction>()
timerActions += planAllowOffInferenceTimer(podState, updatedRuntime)
timerActions.plusAssign(planAllowOffInferenceTimer(podState, updatedRuntime))
val isFirstAncMode = podState.setting<AapSetting.AncMode>() == null
return if (isFirstAncMode || isRecentAncSend) {
timerActions += EngineTimerAction.Cancel(EngineTimerKey.AncDebounce)
timerActions.plusAssign(EngineTimerAction.Cancel(EngineTimerKey.AncDebounce))
AncDecision(
podState = applyAncSetting(podState, key, value, now, isRecentAncSend),
runtimeState = updatedRuntime.copy(pendingDebouncedAnc = null),
@@ -47,7 +48,7 @@ internal class AapAncController {
logs = listOf("Setting: ${key.simpleName} = $value [was: $previous]"),
)
} else {
timerActions += EngineTimerAction.Start(EngineTimerKey.AncDebounce, 1500L)
timerActions.plusAssign(EngineTimerAction.Start(EngineTimerKey.AncDebounce, 1500L))
AncDecision(
podState = podState,
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.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket
import eu.darken.capod.common.TimeSource
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.Priority.INFO
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.Logging
import eu.darken.capod.common.debug.logging.log
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.AapDeviceProfile
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.
* 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")
internal class AapConnection(
@@ -67,7 +66,7 @@ internal class AapConnection(
val sock = socketFactory.createSocket(device, PSM)
sock.connect()
socket = sock
log(TAG, INFO) { "Connected to ${device.address}" }
log(TAG, Logging.Priority.INFO) { "Connected to ${device.address}" }
engine.onHandshakeSent()
@@ -99,7 +98,7 @@ internal class AapConnection(
// Launch read loop in the provided scope — connect() returns immediately
readerJob = scope.launch(Dispatchers.IO) { readLoop(sock) }
} catch (e: Exception) {
log(TAG, ERROR) { "Connection failed: $e" }
log(TAG, Logging.Priority.ERROR) { "Connection failed: $e" }
cleanupSocket()
engine.reset()
throw e
@@ -107,7 +106,7 @@ internal class AapConnection(
}
suspend fun disconnect() = withContext(Dispatchers.IO) {
log(TAG, INFO) { "Disconnecting" }
log(TAG, Logging.Priority.INFO) { "Disconnecting" }
readerJob?.cancel()
readerJob = null
engine.reset()
@@ -127,7 +126,7 @@ internal class AapConnection(
sock.outputStream.write(bytes)
sock.outputStream.flush()
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
val raw = buf.copyOfRange(0, len)
val message = AapMessage.Companion.parse(raw)
val message = AapMessage.parse(raw)
if (message != null) {
engine.processMessage(message)
}
}
} catch (e: IOException) {
if (isActive) log(TAG, ERROR) { "Read error: $e" }
if (isActive) log(TAG, Logging.Priority.ERROR) { "Read error: $e" }
} finally {
engine.reset()
cleanupSocket()
@@ -170,4 +169,4 @@ internal class AapConnection(
private const val PSM = 0x1001
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
@@ -28,11 +31,11 @@ internal object AapDeviceInfoDiagnostics {
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))
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(segBytes))
.toString()
} catch (_: java.nio.charset.CharacterCodingException) {
} catch (_: CharacterCodingException) {
null
}
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.AapDeviceProfile
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.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
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.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.log
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.AapDeviceProfile
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.pods.core.apple.aap.AapPodState
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
import eu.darken.capod.pods.core.apple.aap.protocol.AapSetting
import kotlin.reflect.KClass
@@ -95,38 +96,47 @@ internal class AapSettingsCoordinator(
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(
@@ -134,24 +144,31 @@ internal class AapSettingsCoordinator(
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(
@@ -178,6 +195,7 @@ internal class AapSettingsCoordinator(
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 }
@@ -186,4 +204,4 @@ internal class AapSettingsCoordinator(
is AapCommand.SetSleepDetection -> { s -> s.setting<AapSetting.SleepDetection>()?.enabled == command.enabled }
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.
@@ -21,6 +21,7 @@ internal class HidTracker(private val log: (String) -> Unit) {
val names = type.services.joinToString(", ")
log("HID: services=[$names] (${payload.size}B)")
}
is HidFrameType.Descriptor -> {
if (bulkCount == 0) {
bulkPhase = type.phase
@@ -35,10 +36,12 @@ internal class HidTracker(private val log: (String) -> Unit) {
bulkCount = 1
}
}
is HidFrameType.Terminator -> {
flush()
log("HID: terminator (${type.payloadSize}B)")
}
is HidFrameType.Other -> {
flush()
val hex = payload.joinToString(" ") { "%02X".format(it) }
@@ -111,4 +114,4 @@ internal class HidTracker(private val log: (String) -> Unit) {
return HidFrameType.Other
}
}
}
}
@@ -19,7 +19,7 @@ data class AapMessage(
companion object {
/**
* 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).
*/
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 io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
import java.time.Instant
import kotlin.reflect.KClass
class AapAncControllerTest : BaseTest() {
@@ -17,7 +19,7 @@ class AapAncControllerTest : BaseTest() {
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(
connectionState = AapPodState.ConnectionState.READY,
settings = settings.toMap(),
@@ -149,4 +151,4 @@ class AapAncControllerTest : BaseTest() {
decision.podState.setting<AapSetting.AllowOffOption>()!!.enabled shouldBe false
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.aap.protocol.AapMessage
@@ -15,7 +15,7 @@ import testhelpers.BaseTest
* The helper must:
* - 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.
* - 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).
*/
class AapDeviceInfoDiagnosticsTest : BaseTest() {
@@ -78,8 +78,8 @@ class AapDeviceInfoDiagnosticsTest : BaseTest() {
// so any emoji / non-Latin engraving is silently skipped. The diagnostic helper must not.
val engraving = "My AirPods 🌈"
val payload = hex("02 DF 00 04 00") +
engraving.toByteArray(Charsets.UTF_8) + byteArrayOf(0x00) +
"A3048".toByteArray(Charsets.UTF_8) + byteArrayOf(0x00)
engraving.toByteArray(Charsets.UTF_8) + byteArrayOf(0x00) +
"A3048".toByteArray(Charsets.UTF_8) + byteArrayOf(0x00)
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
// unexpected-shape packets from an engraved device.
val payload = hex("02 DF 00 04 00") +
"Name".toByteArray(Charsets.UTF_8) + byteArrayOf(0x00) +
"Model".toByteArray(Charsets.UTF_8) + byteArrayOf(0x00)
"Name".toByteArray(Charsets.UTF_8) + byteArrayOf(0x00) +
"Model".toByteArray(Charsets.UTF_8) + byteArrayOf(0x00)
val fullMessageBytes = hex("04 00 04 00 1D 00") + payload
val message = AapMessage.parse(fullMessageBytes)!!
@@ -106,4 +106,4 @@ class AapDeviceInfoDiagnosticsTest : BaseTest() {
segments[0].utf8 shouldBe "Name"
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.pods.core.apple.aap.AapPodState
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 io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.nulls.shouldBeNull
@@ -131,22 +131,28 @@ class AapSessionEngineTest : BaseTest() {
engine.start(this as TestScope)
engine.onHandshakeSent()
nextSetting = settingPair(AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
))
nextSetting = settingPair(
AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
)
)
engine.processMessage(dummyMessage(commandType = 0x0002))
nextSetting = settingPair(AapSetting.AncMode(
current = AapSetting.AncMode.Value.ON,
supported = supportedModes,
))
nextSetting = settingPair(
AapSetting.AncMode(
current = AapSetting.AncMode.Value.ON,
supported = supportedModes,
)
)
engine.processMessage(dummyMessage())
nextSetting = settingPair(AapSetting.AncMode(
current = AapSetting.AncMode.Value.TRANSPARENCY,
supported = supportedModes,
))
nextSetting = settingPair(
AapSetting.AncMode(
current = AapSetting.AncMode.Value.TRANSPARENCY,
supported = supportedModes,
)
)
engine.processMessage(dummyMessage())
engine.reset()
@@ -243,67 +249,68 @@ class AapSessionEngineTest : BaseTest() {
}
@Test
fun `flush with ANC plus later setting keeps ANC recent and verifies ANC first`() = runTest(UnconfinedTestDispatcher()) {
val ancSetting = AapSetting.AncMode(
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 {
every { decodeSetting(any()) } answers { nextSetting }
fun `flush with ANC plus later setting keeps ANC recent and verifies ANC first`() =
runTest(UnconfinedTestDispatcher()) {
val ancSetting = AapSetting.AncMode(
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 {
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 ────────────────────
@@ -402,43 +409,50 @@ class AapSessionEngineTest : BaseTest() {
inner class InferenceTests {
@Test
fun `startup OFF while no pod is in ear does not infer AllowOffOption true`() = runTest(UnconfinedTestDispatcher()) {
val supportedModes = listOf(
AapSetting.AncMode.Value.OFF,
AapSetting.AncMode.Value.ON,
AapSetting.AncMode.Value.ADAPTIVE,
)
var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null
val profile = mockProfile {
every { decodeSetting(any()) } answers { nextSetting }
fun `startup OFF while no pod is in ear does not infer AllowOffOption true`() =
runTest(UnconfinedTestDispatcher()) {
val supportedModes = listOf(
AapSetting.AncMode.Value.OFF,
AapSetting.AncMode.Value.ON,
AapSetting.AncMode.Value.ADAPTIVE,
)
var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null
val profile = mockProfile {
every { decodeSetting(any()) } answers { nextSetting }
}
val engine = AapSessionEngine(profile, timeSource)
engine.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
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.onHandshakeSent()
nextSetting = settingPair(AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
))
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,
))
nextSetting = settingPair(
AapSetting.AncMode(
current = AapSetting.AncMode.Value.OFF,
supported = supportedModes,
)
)
engine.processMessage(dummyMessage())
engine.state.value.setting<AapSetting.AllowOffOption>().shouldBeNull()
@@ -473,43 +491,50 @@ class AapSessionEngineTest : BaseTest() {
}
@Test
fun `later non-OFF ANC update cancels pending AllowOffOption true inference`() = runTest(UnconfinedTestDispatcher()) {
val supportedModes = listOf(
AapSetting.AncMode.Value.OFF,
AapSetting.AncMode.Value.ON,
AapSetting.AncMode.Value.ADAPTIVE,
)
var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null
val profile = mockProfile {
every { decodeSetting(any()) } answers { nextSetting }
fun `later non-OFF ANC update cancels pending AllowOffOption true inference`() =
runTest(UnconfinedTestDispatcher()) {
val supportedModes = listOf(
AapSetting.AncMode.Value.OFF,
AapSetting.AncMode.Value.ON,
AapSetting.AncMode.Value.ADAPTIVE,
)
var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null
val profile = mockProfile {
every { decodeSetting(any()) } answers { nextSetting }
}
val engine = AapSessionEngine(profile, timeSource)
engine.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
fun `rejected OFF command infers AllowOffOption false`() = runTest(UnconfinedTestDispatcher()) {
@@ -525,16 +550,20 @@ class AapSessionEngineTest : BaseTest() {
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,
))
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.ADAPTIVE,
supported = supportedModes,
))
nextSetting = settingPair(
AapSetting.AncMode(
current = AapSetting.AncMode.Value.ADAPTIVE,
supported = supportedModes,
)
)
engine.processMessage(dummyMessage())
nextSetting = settingPair(AapSetting.AllowOffOption(enabled = true))
@@ -543,10 +572,12 @@ class AapSessionEngineTest : BaseTest() {
val sentCommands = mutableListOf<AapCommand>()
engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.OFF)) { sentCommands += it }
nextSetting = settingPair(AapSetting.AncMode(
current = AapSetting.AncMode.Value.ADAPTIVE,
supported = supportedModes,
))
nextSetting = settingPair(
AapSetting.AncMode(
current = AapSetting.AncMode.Value.ADAPTIVE,
supported = supportedModes,
)
)
engine.processMessage(dummyMessage())
advanceTimeBy(2100L)
@@ -561,47 +592,54 @@ class AapSessionEngineTest : BaseTest() {
@Test
fun `matching ANC echo clears pending mode without optimistic current overwrite`() = runTest(UnconfinedTestDispatcher()) {
val supportedModes = listOf(
AapSetting.AncMode.Value.OFF,
AapSetting.AncMode.Value.ON,
AapSetting.AncMode.Value.ADAPTIVE,
)
var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null
val profile = mockProfile {
every { decodeSetting(any()) } answers { nextSetting }
fun `matching ANC echo clears pending mode without optimistic current overwrite`() =
runTest(UnconfinedTestDispatcher()) {
val supportedModes = listOf(
AapSetting.AncMode.Value.OFF,
AapSetting.AncMode.Value.ON,
AapSetting.AncMode.Value.ADAPTIVE,
)
var nextSetting: Pair<KClass<out AapSetting>, AapSetting>? = null
val profile = mockProfile {
every { decodeSetting(any()) } answers { nextSetting }
}
val engine = AapSessionEngine(profile, timeSource)
engine.startReady(this as TestScope)
nextSetting = settingPair(
AapSetting.EarDetection(
primaryPod = AapSetting.EarDetection.PodPlacement.IN_EAR,
secondaryPod = AapSetting.EarDetection.PodPlacement.NOT_IN_EAR,
)
)
engine.processMessage(dummyMessage())
nextSetting = settingPair(
AapSetting.AncMode(
current = AapSetting.AncMode.Value.ON,
supported = supportedModes,
)
)
engine.processMessage(dummyMessage())
val sentCommands = mutableListOf<AapCommand>()
engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)) { sentCommands += it }
engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ON
nextSetting = settingPair(
AapSetting.AncMode(
current = AapSetting.AncMode.Value.ADAPTIVE,
supported = supportedModes,
)
)
engine.processMessage(dummyMessage())
engine.state.value.pendingAncMode.shouldBeNull()
engine.state.value.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.ADAPTIVE
sentCommands shouldBe listOf(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE))
}
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 ────────────────────────────────────────
@@ -645,7 +683,9 @@ fun `matching ANC echo clears pending mode without optimistic current overwrite`
every { decodeBattery(any()) } returns null
every { decodePrivateKeyResponse(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)
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
// 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())
// Not yet applied (debounced)
@@ -731,7 +773,10 @@ fun `matching ANC echo clears pending mode without optimistic current overwrite`
@Test
fun `0x0017 does not run through decode pipeline`() = runTest(UnconfinedTestDispatcher()) {
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)
engine.startReady(this as TestScope)
@@ -750,4 +795,4 @@ fun `matching ANC echo clears pending mode without optimistic current overwrite`
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.pods.core.apple.aap.AapPodState
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
@@ -52,7 +53,8 @@ class AapSettingsCoordinatorTest : BaseTest() {
fun `enqueue ANC returns pendingAncMode in snapshot`() {
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.snapshot.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE
@@ -96,7 +98,8 @@ class AapSettingsCoordinatorTest : BaseTest() {
)
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
}
@@ -133,7 +136,8 @@ class AapSettingsCoordinatorTest : BaseTest() {
)
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)
result.commands shouldHaveSize 2
@@ -150,7 +154,8 @@ class AapSettingsCoordinatorTest : BaseTest() {
)
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 result = coord.flush(third.pendingCommands)
@@ -296,4 +301,4 @@ class AapSettingsCoordinatorTest : BaseTest() {
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.shouldContainExactly
import io.kotest.matchers.shouldBe
@@ -23,11 +22,11 @@ class HidTrackerTest : BaseTest() {
fun `service directory frame from Pro 3 capture`() {
val payload = hexToBytes(
"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 " +
"44 53 50 31 00 80 00 00 44 53 50 32 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"
)
val result = HidTracker.classify(payload)
result.shouldBeInstanceOf<HidFrameType.ServiceDirectory>()
result.shouldBeInstanceOf<HidTracker.HidFrameType.ServiceDirectory>()
result.services.shouldContainExactly("AP", "AOP", "RTP", "BTM", "DSP1", "DSP2")
}
@@ -36,7 +35,7 @@ class HidTrackerTest : BaseTest() {
val fill = ByteArray(65) { 0xFF.toByte() }
val payload = hexToBytes("00 04 00 00 44 00 01 A1 81") + fill
val result = HidTracker.classify(payload)
result.shouldBeInstanceOf<HidFrameType.Descriptor>()
result.shouldBeInstanceOf<HidTracker.HidFrameType.Descriptor>()
result.phase shouldBe 0x81
result.fill shouldBe 0xFF
}
@@ -46,7 +45,7 @@ class HidTrackerTest : BaseTest() {
val fill = ByteArray(65) { 0xEF.toByte() }
val payload = hexToBytes("00 04 00 00 44 00 01 C3 02") + fill
val result = HidTracker.classify(payload)
result.shouldBeInstanceOf<HidFrameType.Descriptor>()
result.shouldBeInstanceOf<HidTracker.HidFrameType.Descriptor>()
result.phase shouldBe 0x02
result.fill shouldBe 0xEF
}
@@ -55,7 +54,7 @@ class HidTrackerTest : BaseTest() {
fun `terminator frame`() {
val payload = hexToBytes("00 04 00 00 01 00 FF")
val result = HidTracker.classify(payload)
result.shouldBeInstanceOf<HidFrameType.Terminator>()
result.shouldBeInstanceOf<HidTracker.HidFrameType.Terminator>()
result.payloadSize shouldBe 7
}
@@ -63,20 +62,20 @@ class HidTrackerTest : BaseTest() {
fun `short frame ending in FF but not 7 bytes is Other`() {
val payload = hexToBytes("00 04 00 FF")
val result = HidTracker.classify(payload)
result.shouldBeInstanceOf<HidFrameType.Other>()
result.shouldBeInstanceOf<HidTracker.HidFrameType.Other>()
}
@Test
fun `empty payload is Other`() {
val result = HidTracker.classify(ByteArray(0))
result.shouldBeInstanceOf<HidFrameType.Other>()
result.shouldBeInstanceOf<HidTracker.HidFrameType.Other>()
}
@Test
fun `random payload is Other`() {
val payload = hexToBytes("AB CD EF 01 02 03 04 05 06 07 08")
val result = HidTracker.classify(payload)
result.shouldBeInstanceOf<HidFrameType.Other>()
result.shouldBeInstanceOf<HidTracker.HidFrameType.Other>()
}
}
@@ -211,4 +210,4 @@ class HidTrackerTest : BaseTest() {
logs.shouldBeEmpty()
}
}
}
}