mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
Refactor packages
This commit is contained in:
+219
@@ -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
|
||||
|
||||
|
||||
+5
-4
@@ -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(
|
||||
+11
-12
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-5
@@ -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) }
|
||||
+2
-1
@@ -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
|
||||
+2
-1
@@ -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
|
||||
|
||||
+2
-1
@@ -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
|
||||
+20
-2
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -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? {
|
||||
|
||||
+5
-3
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+255
-210
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-6
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-12
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user