diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/components/CompactBatterySummary.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/components/CompactBatterySummary.kt new file mode 100644 index 00000000..25306fc3 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/components/CompactBatterySummary.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnectionManager.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnectionManager.kt index be3de978..28fbbff0 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnectionManager.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnectionManager.kt @@ -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 diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapAncController.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapAncController.kt similarity index 94% rename from app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapAncController.kt rename to app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapAncController.kt index 532cdb22..963145d9 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapAncController.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapAncController.kt @@ -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() - timerActions += planAllowOffInferenceTimer(podState, updatedRuntime) + timerActions.plusAssign(planAllowOffInferenceTimer(podState, updatedRuntime)) val isFirstAncMode = podState.setting() == 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( diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapConnection.kt similarity index 89% rename from app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt rename to app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapConnection.kt index 4b064392..e53fd8e2 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapConnection.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapConnection.kt @@ -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") } -} +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapDeviceInfoDiagnostics.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapDeviceInfoDiagnostics.kt similarity index 79% rename from app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapDeviceInfoDiagnostics.kt rename to app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapDeviceInfoDiagnostics.kt index fefe9e08..dda1286c 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapDeviceInfoDiagnostics.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapDeviceInfoDiagnostics.kt @@ -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) } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapInboundInterpreter.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapInboundInterpreter.kt similarity index 93% rename from app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapInboundInterpreter.kt rename to app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapInboundInterpreter.kt index f55985f0..a8dd11aa 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapInboundInterpreter.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapInboundInterpreter.kt @@ -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 diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapOutboundController.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapOutboundController.kt similarity index 98% rename from app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapOutboundController.kt rename to app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapOutboundController.kt index dd06d537..7feaaf12 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapOutboundController.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapOutboundController.kt @@ -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 diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapSessionEngine.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngine.kt similarity index 99% rename from app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapSessionEngine.kt rename to app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngine.kt index 2cc00102..90b51cfe 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapSessionEngine.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngine.kt @@ -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 diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapSettingsCoordinator.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSettingsCoordinator.kt similarity index 98% rename from app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapSettingsCoordinator.kt rename to app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSettingsCoordinator.kt index 1be1e5ea..88c7f45c 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/AapSettingsCoordinator.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/AapSettingsCoordinator.kt @@ -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() ?: return null AapSetting.ConversationalAwareness::class to cur.copy(enabled = command.enabled) } + is AapCommand.SetNcWithOneAirPod -> { val cur = baseState.setting() ?: return null AapSetting.NcWithOneAirPod::class to cur.copy(enabled = command.enabled) } + is AapCommand.SetVolumeSwipe -> { val cur = baseState.setting() ?: return null AapSetting.VolumeSwipe::class to cur.copy(enabled = command.enabled) } + is AapCommand.SetPersonalizedVolume -> { val cur = baseState.setting() ?: return null AapSetting.PersonalizedVolume::class to cur.copy(enabled = command.enabled) } + is AapCommand.SetToneVolume -> { baseState.setting() ?: return null AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = command.level) } + is AapCommand.SetAdaptiveAudioNoise -> { baseState.setting() ?: return null AapSetting.AdaptiveAudioNoise::class to AapSetting.AdaptiveAudioNoise(level = command.level) } + is AapCommand.SetPressSpeed -> { baseState.setting() ?: return null AapSetting.PressSpeed::class to AapSetting.PressSpeed(value = command.value) } + is AapCommand.SetPressHoldDuration -> { baseState.setting() ?: return null AapSetting.PressHoldDuration::class to AapSetting.PressHoldDuration(value = command.value) } + is AapCommand.SetVolumeSwipeLength -> { baseState.setting() ?: return null AapSetting.VolumeSwipeLength::class to AapSetting.VolumeSwipeLength(value = command.value) } + is AapCommand.SetEndCallMuteMic -> { baseState.setting() ?: return null AapSetting.EndCallMuteMic::class to AapSetting.EndCallMuteMic( @@ -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() cur != null && cur.muteMic == command.muteMic && cur.endCall == command.endCall } + is AapCommand.SetMicrophoneMode -> { s -> s.setting()?.mode == command.mode } is AapCommand.SetEarDetectionEnabled -> { s -> s.setting()?.enabled == command.enabled } is AapCommand.SetListeningModeCycle -> { s -> s.setting()?.modeMask == command.modeMask } @@ -186,4 +204,4 @@ internal class AapSettingsCoordinator( is AapCommand.SetSleepDetection -> { s -> s.setting()?.enabled == command.enabled } is AapCommand.SetDeviceName -> null } -} +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/HidTracker.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/HidTracker.kt similarity index 98% rename from app/src/main/java/eu/darken/capod/pods/core/apple/aap/HidTracker.kt rename to app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/HidTracker.kt index 2c4e119b..34711fb3 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/HidTracker.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/engine/HidTracker.kt @@ -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 } } -} +} \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapMessage.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapMessage.kt index 621b1c38..62765a04 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapMessage.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/aap/protocol/AapMessage.kt @@ -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? { diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapAncControllerTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapAncControllerTest.kt similarity index 95% rename from app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapAncControllerTest.kt rename to app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapAncControllerTest.kt index 54c23fce..e515a9aa 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapAncControllerTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapAncControllerTest.kt @@ -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, AapSetting>): AapPodState = + private fun podStateWith(vararg settings: Pair, AapSetting>): AapPodState = AapPodState( connectionState = AapPodState.ConnectionState.READY, settings = settings.toMap(), @@ -149,4 +151,4 @@ class AapAncControllerTest : BaseTest() { decision.podState.setting()!!.enabled shouldBe false decision.timerActions shouldBe listOf(EngineTimerAction.Cancel(EngineTimerKey.AllowOffInference)) } -} +} \ No newline at end of file diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapDeviceInfoDiagnosticsTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapDeviceInfoDiagnosticsTest.kt similarity index 90% rename from app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapDeviceInfoDiagnosticsTest.kt rename to app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapDeviceInfoDiagnosticsTest.kt index 270372de..7abe99da 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapDeviceInfoDiagnosticsTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapDeviceInfoDiagnosticsTest.kt @@ -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" } -} +} \ No newline at end of file diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapSessionEngineTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngineTest.kt similarity index 67% rename from app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapSessionEngineTest.kt rename to app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngineTest.kt index 6fd944f1..0a47765c 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapSessionEngineTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSessionEngineTest.kt @@ -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, 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, 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 to AapSetting.EarDetection( + primaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE, + secondaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE, + ) + engine.processMessage(dummyMessage()) + + nextSetting = AapSetting.AncMode::class as KClass to ancSetting + engine.processMessage(dummyMessage()) + + nextSetting = + AapSetting.ConversationalAwareness::class as KClass to + AapSetting.ConversationalAwareness(enabled = false) + engine.processMessage(dummyMessage()) + + val sentCommands = mutableListOf() + 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 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 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()!!.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 to AapSetting.EarDetection( - primaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE, - secondaryPod = AapSetting.EarDetection.PodPlacement.IN_CASE, - ) - engine.processMessage(dummyMessage()) - - nextSetting = AapSetting.AncMode::class as KClass to ancSetting - engine.processMessage(dummyMessage()) - - nextSetting = - AapSetting.ConversationalAwareness::class as KClass to - AapSetting.ConversationalAwareness(enabled = false) - engine.processMessage(dummyMessage()) - - val sentCommands = mutableListOf() - 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 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 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()!!.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, 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, 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().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().shouldBeNull() + + nextSetting = settingPair( + AapSetting.AncMode( + current = AapSetting.AncMode.Value.ADAPTIVE, + supported = supportedModes, + ) + ) + engine.processMessage(dummyMessage()) + advanceTimeBy(1600L) + engine.state.value.setting().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().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().shouldBeNull() - - nextSetting = settingPair(AapSetting.AncMode( - current = AapSetting.AncMode.Value.ADAPTIVE, - supported = supportedModes, - )) - engine.processMessage(dummyMessage()) - advanceTimeBy(1600L) - engine.state.value.setting().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().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, 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, 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().shouldBeNull() + engine.state.value.setting()!!.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().shouldBeNull() - engine.state.value.setting()!!.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() 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, 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, 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() + engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)) { sentCommands += it } + + engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE + engine.state.value.setting()!!.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()!!.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() - engine.send(AapCommand.SetAncMode(AapSetting.AncMode.Value.ADAPTIVE)) { sentCommands += it } - - engine.state.value.pendingAncMode shouldBe AapSetting.AncMode.Value.ADAPTIVE - engine.state.value.setting()!!.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()!!.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 to ancSetting.copy(current = AapSetting.AncMode.Value.TRANSPARENCY)) + every { decodeSetting(any()) } returns (AapSetting.AncMode::class as KClass 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()!!.current shouldBe AapSetting.AncMode.Value.ON // Second message: unsolicited change — should be debounced - every { profile.decodeSetting(any()) } returns (AapSetting.AncMode::class as KClass to ancSetting.copy(current = AapSetting.AncMode.Value.TRANSPARENCY)) + every { profile.decodeSetting(any()) } returns (AapSetting.AncMode::class as KClass 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() } } -} +} \ No newline at end of file diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapSettingsCoordinatorTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSettingsCoordinatorTest.kt similarity index 94% rename from app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapSettingsCoordinatorTest.kt rename to app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSettingsCoordinatorTest.kt index 917df67a..ee822bce 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/AapSettingsCoordinatorTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/AapSettingsCoordinatorTest.kt @@ -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 } } -} +} \ No newline at end of file diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/HidTrackerTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/HidTrackerTest.kt similarity index 90% rename from app/src/test/java/eu/darken/capod/pods/core/apple/aap/HidTrackerTest.kt rename to app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/HidTrackerTest.kt index a38d0913..7262b75c 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/aap/HidTrackerTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/aap/engine/HidTrackerTest.kt @@ -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() + result.shouldBeInstanceOf() 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() + result.shouldBeInstanceOf() 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() + result.shouldBeInstanceOf() 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() + result.shouldBeInstanceOf() 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() + result.shouldBeInstanceOf() } @Test fun `empty payload is Other`() { val result = HidTracker.classify(ByteArray(0)) - result.shouldBeInstanceOf() + result.shouldBeInstanceOf() } @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() + result.shouldBeInstanceOf() } } @@ -211,4 +210,4 @@ class HidTrackerTest : BaseTest() { logs.shouldBeEmpty() } } -} +} \ No newline at end of file