mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
feat: Add AAP L2CAP protocol support for reading and writing AirPods settings
Full integration of the Apple Accessory Protocol (AAP) over L2CAP, enabling direct communication with AirPods for 1% battery granularity, ANC mode control, Conversation Awareness toggle, and private key exchange for BLE encrypted battery.
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
package eu.darken.capod.common.bluetooth.l2cap
|
||||
|
||||
import android.util.Log
|
||||
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 java.lang.invoke.MethodHandles
|
||||
import java.lang.invoke.MethodType
|
||||
import java.lang.reflect.Method
|
||||
@@ -20,7 +22,7 @@ import java.lang.reflect.Method
|
||||
*/
|
||||
object HiddenApiBypass {
|
||||
|
||||
private const val TAG = "HiddenApiBypass"
|
||||
private val TAG = logTag("HiddenApiBypass")
|
||||
|
||||
private val exemptedPrefixes = mutableSetOf<String>()
|
||||
|
||||
@@ -114,7 +116,7 @@ object HiddenApiBypass {
|
||||
}
|
||||
stubMethod.invoke(runtime, allPrefixes as Any)
|
||||
exemptionsSet = true
|
||||
Log.d(TAG, "setHiddenApiExemptions OK: ${allPrefixes.contentToString()}")
|
||||
log(TAG, VERBOSE) { "setHiddenApiExemptions OK: ${allPrefixes.contentToString()}" }
|
||||
}
|
||||
|
||||
if (runtime != null && exemptionsSet) break
|
||||
|
||||
@@ -3,7 +3,9 @@ package eu.darken.capod.common.bluetooth.l2cap
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothSocket
|
||||
import android.util.Log
|
||||
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 java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -18,7 +20,7 @@ import javax.inject.Singleton
|
||||
@SuppressLint("MissingPermission")
|
||||
class L2capSocketFactory @Inject constructor() {
|
||||
|
||||
private val TAG = "L2capSocketFactory"
|
||||
private val TAG = logTag("L2capSocketFactory")
|
||||
private val TYPE_L2CAP = 3
|
||||
|
||||
/**
|
||||
@@ -36,12 +38,12 @@ class L2capSocketFactory @Inject constructor() {
|
||||
|
||||
// Strategy 1: Public API via BluetoothSocketSettings (API 37+)
|
||||
tryPublicApi(device, psm)?.let { socket ->
|
||||
Log.d(TAG, "Socket created via public BluetoothSocketSettings API")
|
||||
log(TAG, VERBOSE) { "Socket created via public BluetoothSocketSettings API" }
|
||||
return socket
|
||||
}
|
||||
|
||||
// Strategy 2: Hidden API via reflection + bypass
|
||||
Log.d(TAG, "Public API unavailable, using hidden API bypass")
|
||||
log(TAG, VERBOSE) { "Public API unavailable, using hidden API bypass" }
|
||||
return createViaHiddenApi(device, psm)
|
||||
}
|
||||
|
||||
@@ -60,18 +62,18 @@ class L2capSocketFactory @Inject constructor() {
|
||||
val createMethod = BluetoothDevice::class.java.getMethod("createUsingSocketSettings", settingsClass)
|
||||
createMethod.invoke(device, settings) as BluetoothSocket
|
||||
} catch (e: ClassNotFoundException) {
|
||||
Log.d(TAG, "BluetoothSocketSettings not available (pre-API 37)")
|
||||
log(TAG, VERBOSE) { "BluetoothSocketSettings not available (pre-API 37)" }
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
val cause = if (e is java.lang.reflect.InvocationTargetException) e.cause ?: e else e
|
||||
when (cause) {
|
||||
is IllegalArgumentException -> {
|
||||
Log.d(TAG, "BluetoothSocketSettings does not support TYPE_L2CAP: ${cause.message}")
|
||||
log(TAG, VERBOSE) { "BluetoothSocketSettings does not support TYPE_L2CAP: ${cause.message}" }
|
||||
null
|
||||
}
|
||||
is SecurityException -> throw cause
|
||||
else -> {
|
||||
Log.d(TAG, "BluetoothSocketSettings failed: ${cause::class.simpleName}: ${cause.message}")
|
||||
log(TAG, VERBOSE) { "BluetoothSocketSettings failed: ${cause::class.simpleName}: ${cause.message}" }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ import eu.darken.capod.main.ui.overview.cards.UnknownPodDeviceCard
|
||||
import eu.darken.capod.main.ui.overview.cards.UnmatchedDevicesCard
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.pods.core.PodModel
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting
|
||||
import java.time.Instant
|
||||
|
||||
@Composable
|
||||
@@ -127,6 +128,8 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
|
||||
onSettings = { vm.goToSettings() },
|
||||
onUpgrade = { vm.onUpgrade() },
|
||||
onToggleUnmatched = { vm.toggleUnmatchedDevices() },
|
||||
onAncModeChange = { device, mode -> vm.setAncMode(device, mode) },
|
||||
onConversationAwarenessChange = { device, enabled -> vm.setConversationalAwareness(device, enabled) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -138,6 +141,8 @@ fun OverviewScreen(
|
||||
onSettings: () -> Unit,
|
||||
onUpgrade: () -> Unit,
|
||||
onToggleUnmatched: () -> Unit,
|
||||
onAncModeChange: (PodDevice, AapSetting.AncMode.Value) -> Unit = { _, _ -> },
|
||||
onConversationAwarenessChange: (PodDevice, Boolean) -> Unit = { _, _ -> },
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -222,7 +227,13 @@ fun OverviewScreen(
|
||||
items = state.profiledDevices,
|
||||
key = { it.identifier.hashCode() },
|
||||
) { device ->
|
||||
PodDeviceCard(device = device, showDebug = state.isDebugMode, now = state.now)
|
||||
PodDeviceCard(
|
||||
device = device,
|
||||
showDebug = state.isDebugMode,
|
||||
now = state.now,
|
||||
onAncModeChange = { mode -> onAncModeChange(device, mode) },
|
||||
onConversationAwarenessChange = { enabled -> onConversationAwarenessChange(device, enabled) },
|
||||
)
|
||||
}
|
||||
|
||||
// 5. Monitoring active card
|
||||
@@ -247,7 +258,13 @@ fun OverviewScreen(
|
||||
items = state.unmatchedDevices,
|
||||
key = { "unmatched_${it.identifier.hashCode()}" },
|
||||
) { device ->
|
||||
PodDeviceCard(device = device, showDebug = state.isDebugMode, now = state.now)
|
||||
PodDeviceCard(
|
||||
device = device,
|
||||
showDebug = state.isDebugMode,
|
||||
now = state.now,
|
||||
onAncModeChange = { mode -> onAncModeChange(device, mode) },
|
||||
onConversationAwarenessChange = { enabled -> onConversationAwarenessChange(device, enabled) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -257,10 +274,20 @@ fun OverviewScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PodDeviceCard(device: PodDevice, showDebug: Boolean, now: Instant) {
|
||||
private fun PodDeviceCard(
|
||||
device: PodDevice,
|
||||
showDebug: Boolean,
|
||||
now: Instant,
|
||||
onAncModeChange: (AapSetting.AncMode.Value) -> Unit,
|
||||
onConversationAwarenessChange: (Boolean) -> Unit = {},
|
||||
) {
|
||||
when {
|
||||
device.hasDualPods -> DualPodsCard(device = device, showDebug = showDebug, now = now)
|
||||
device.model != PodModel.UNKNOWN -> SinglePodsCard(device = device, showDebug = showDebug, now = now)
|
||||
device.hasDualPods -> DualPodsCard(
|
||||
device = device, showDebug = showDebug, now = now,
|
||||
onAncModeChange = onAncModeChange,
|
||||
onConversationAwarenessChange = onConversationAwarenessChange,
|
||||
)
|
||||
device.model != PodModel.UNKNOWN -> SinglePodsCard(device = device, showDebug = showDebug, now = now, onAncModeChange = onAncModeChange)
|
||||
else -> UnknownPodDeviceCard(device = device, showDebug = showDebug, now = now)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ import eu.darken.capod.main.core.PermissionTool
|
||||
import eu.darken.capod.monitor.core.DeviceMonitor
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.monitor.core.worker.MonitorControl
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapCommand
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapConnectionManager
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting
|
||||
import eu.darken.capod.profiles.core.DeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -46,6 +49,7 @@ class OverviewViewModel @Inject constructor(
|
||||
private val upgradeRepo: UpgradeRepo,
|
||||
private val bluetoothManager: BluetoothManager2,
|
||||
private val profilesRepo: DeviceProfilesRepo,
|
||||
private val aapManager: AapConnectionManager,
|
||||
) : ViewModel4(dispatcherProvider) {
|
||||
|
||||
val requestPermissionEvent = SingleEventFlow<Permission>()
|
||||
@@ -158,6 +162,30 @@ class OverviewViewModel @Inject constructor(
|
||||
requestPermissionEvent.tryEmit(permission)
|
||||
}
|
||||
|
||||
fun setAncMode(device: PodDevice, mode: AapSetting.AncMode.Value) {
|
||||
val address = device.address ?: return
|
||||
launch {
|
||||
try {
|
||||
aapManager.sendCommand(address, AapCommand.SetAncMode(mode))
|
||||
log(TAG) { "ANC mode set to $mode for $address" }
|
||||
} catch (e: Exception) {
|
||||
log(TAG) { "Failed to set ANC mode: ${e.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setConversationalAwareness(device: PodDevice, enabled: Boolean) {
|
||||
val address = device.address ?: return
|
||||
launch {
|
||||
try {
|
||||
aapManager.sendCommand(address, AapCommand.SetConversationalAwareness(enabled))
|
||||
log(TAG) { "Conversation awareness set to $enabled for $address" }
|
||||
} catch (e: Exception) {
|
||||
log(TAG) { "Failed to set conversation awareness: ${e.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Overview", "VM")
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ import eu.darken.capod.pods.core.HasStateDetection
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
||||
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting
|
||||
import eu.darken.capod.pods.core.formatBatteryPercent
|
||||
import eu.darken.capod.pods.core.toBatteryFloat
|
||||
import eu.darken.capod.pods.core.toBatteryOrNull
|
||||
@@ -64,6 +65,8 @@ fun DualPodsCard(
|
||||
device: PodDevice,
|
||||
showDebug: Boolean,
|
||||
now: Instant,
|
||||
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null,
|
||||
onConversationAwarenessChange: ((Boolean) -> Unit)? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -211,6 +214,27 @@ fun DualPodsCard(
|
||||
)
|
||||
}
|
||||
|
||||
// ANC mode selector
|
||||
val ancMode = device.ancMode
|
||||
if (device.isAapConnected && device.hasAncControl && ancMode != null) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
AncModeSelector(
|
||||
currentMode = ancMode.current,
|
||||
supportedModes = ancMode.supported,
|
||||
onModeSelected = { onAncModeChange?.invoke(it) },
|
||||
)
|
||||
}
|
||||
|
||||
// Conversation awareness toggle
|
||||
val convAwareness = device.conversationalAwareness
|
||||
if (device.isAapConnected && device.model.features.hasConversationAwareness && convAwareness != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
ConversationAwarenessToggle(
|
||||
enabled = convAwareness.enabled,
|
||||
onToggle = { onConversationAwarenessChange?.invoke(it) },
|
||||
)
|
||||
}
|
||||
|
||||
// Debug info
|
||||
if (showDebug) {
|
||||
DebugSection(rawDataHex = device.rawDataHex)
|
||||
|
||||
@@ -26,7 +26,11 @@ import androidx.compose.material.icons.twotone.KeyboardVoice
|
||||
import androidx.compose.material.icons.twotone.SettingsInputAntenna
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -34,7 +38,11 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting
|
||||
|
||||
private val CapsuleShape = RoundedCornerShape(6.dp)
|
||||
|
||||
@@ -193,3 +201,53 @@ fun DebugSection(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AncModeSelector(
|
||||
currentMode: AapSetting.AncMode.Value,
|
||||
supportedModes: List<AapSetting.AncMode.Value>,
|
||||
onModeSelected: (AapSetting.AncMode.Value) -> Unit,
|
||||
) {
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
supportedModes.forEachIndexed { index, mode ->
|
||||
SegmentedButton(
|
||||
selected = mode == currentMode,
|
||||
onClick = { onModeSelected(mode) },
|
||||
shape = SegmentedButtonDefaults.itemShape(index, supportedModes.size),
|
||||
label = {
|
||||
Text(
|
||||
text = when (mode) {
|
||||
AapSetting.AncMode.Value.OFF -> stringResource(R.string.anc_mode_off)
|
||||
AapSetting.AncMode.Value.ON -> stringResource(R.string.anc_mode_on)
|
||||
AapSetting.AncMode.Value.TRANSPARENCY -> stringResource(R.string.anc_mode_transparency)
|
||||
AapSetting.AncMode.Value.ADAPTIVE -> stringResource(R.string.anc_mode_adaptive)
|
||||
},
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConversationAwarenessToggle(
|
||||
enabled: Boolean,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.conversation_awareness_label),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Switch(
|
||||
checked = enabled,
|
||||
onCheckedChange = onToggle,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ import eu.darken.capod.monitor.core.firstSeenFormatted
|
||||
import eu.darken.capod.monitor.core.getSignalQuality
|
||||
import eu.darken.capod.monitor.core.lastSeenFormatted
|
||||
import eu.darken.capod.pods.core.apple.ApplePods
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting
|
||||
import eu.darken.capod.pods.core.formatBatteryPercent
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
@@ -59,6 +60,7 @@ fun SinglePodsCard(
|
||||
device: PodDevice,
|
||||
showDebug: Boolean,
|
||||
now: Instant,
|
||||
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
@@ -224,6 +226,17 @@ fun SinglePodsCard(
|
||||
}
|
||||
}
|
||||
|
||||
// ANC mode selector
|
||||
val ancMode = device.ancMode
|
||||
if (device.isAapConnected && device.hasAncControl && ancMode != null) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
AncModeSelector(
|
||||
currentMode = ancMode.current,
|
||||
supportedModes = ancMode.supported,
|
||||
onModeSelected = { onAncModeChange?.invoke(it) },
|
||||
)
|
||||
}
|
||||
|
||||
// Debug info
|
||||
if (showDebug) {
|
||||
DebugSection(rawDataHex = device.rawDataHex)
|
||||
|
||||
@@ -23,9 +23,12 @@ class DeviceMonitor @Inject constructor(
|
||||
val devices: Flow<List<PodDevice>> = blePodMonitor.devices
|
||||
.combine(aapManager.allStates) { pods, aapStates ->
|
||||
pods.map { pod ->
|
||||
// AAP connections are keyed by bonded BR/EDR address (from profile),
|
||||
// BLE scans use rotating RPAs. Bridge via the profile's bonded address.
|
||||
val bondedAddress = pod.meta?.profile?.address
|
||||
PodDevice(
|
||||
ble = pod,
|
||||
aap = aapStates[pod.address],
|
||||
aap = bondedAddress?.let { aapStates[it] },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -33,7 +36,8 @@ class DeviceMonitor @Inject constructor(
|
||||
suspend fun getDeviceForProfile(profileId: String): PodDevice? {
|
||||
log(TAG) { "getDeviceForProfile(profileId=$profileId)" }
|
||||
val bleDevice = blePodMonitor.getDeviceForProfile(profileId) ?: return null
|
||||
val aapState = aapManager.allStates.firstOrNull()?.get(bleDevice.address)
|
||||
val bondedAddress = bleDevice.meta?.profile?.address
|
||||
val aapState = bondedAddress?.let { aapManager.allStates.firstOrNull()?.get(it) }
|
||||
return PodDevice(ble = bleDevice, aap = aapState)
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,10 @@ data class PodDevice(
|
||||
) {
|
||||
// Identity
|
||||
val model: PodModel get() = ble?.model ?: PodModel.UNKNOWN
|
||||
val address: BluetoothAddress? get() = ble?.address
|
||||
/** Bonded BR/EDR address (from profile). Used for AAP commands. */
|
||||
val address: BluetoothAddress? get() = ble?.meta?.profile?.address
|
||||
/** BLE scan address (RPA, rotates). */
|
||||
val bleAddress: BluetoothAddress? get() = ble?.address
|
||||
val identifier: BlePodSnapshot.Id? get() = ble?.identifier
|
||||
val meta: BlePodSnapshot.Meta? get() = ble?.meta
|
||||
|
||||
@@ -45,31 +48,31 @@ data class PodDevice(
|
||||
val signalQuality: Float get() = ble?.signalQuality ?: 0f
|
||||
val rssi: Int get() = ble?.rssi ?: 0
|
||||
|
||||
// Battery — best available source
|
||||
// Battery — AAP preferred, BLE fallback
|
||||
val batteryLeft: Float?
|
||||
get() = (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent
|
||||
get() = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent
|
||||
|
||||
val batteryRight: Float?
|
||||
get() = (ble as? DualBlePodSnapshot)?.batteryRightPodPercent
|
||||
get() = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent
|
||||
|
||||
val batteryCase: Float?
|
||||
get() = (ble as? HasCase)?.batteryCasePercent
|
||||
get() = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent
|
||||
|
||||
val batteryHeadset: Float?
|
||||
get() = (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent
|
||||
get() = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent
|
||||
|
||||
// Charging
|
||||
// Charging — AAP preferred, BLE fallback
|
||||
val isLeftPodCharging: Boolean?
|
||||
get() = (ble as? HasChargeDetectionDual)?.isLeftPodCharging
|
||||
get() = aap?.isLeftCharging ?: (ble as? HasChargeDetectionDual)?.isLeftPodCharging
|
||||
|
||||
val isRightPodCharging: Boolean?
|
||||
get() = (ble as? HasChargeDetectionDual)?.isRightPodCharging
|
||||
get() = aap?.isRightCharging ?: (ble as? HasChargeDetectionDual)?.isRightPodCharging
|
||||
|
||||
val isCaseCharging: Boolean?
|
||||
get() = (ble as? HasCase)?.isCaseCharging
|
||||
get() = aap?.isCaseCharging ?: (ble as? HasCase)?.isCaseCharging
|
||||
|
||||
val isHeadsetBeingCharged: Boolean?
|
||||
get() = (ble as? HasChargeDetection)?.isHeadsetBeingCharged
|
||||
get() = aap?.isHeadsetCharging ?: (ble as? HasChargeDetection)?.isHeadsetBeingCharged
|
||||
|
||||
// Ear detection
|
||||
val isLeftInEar: Boolean?
|
||||
@@ -115,5 +118,23 @@ data class PodDevice(
|
||||
val ancMode: AapSetting.AncMode?
|
||||
get() = aap?.setting()
|
||||
|
||||
val conversationalAwareness: AapSetting.ConversationalAwareness?
|
||||
get() = aap?.setting()
|
||||
|
||||
val toneVolume: AapSetting.ToneVolume?
|
||||
get() = aap?.setting()
|
||||
|
||||
val personalizedVolume: AapSetting.PersonalizedVolume?
|
||||
get() = aap?.setting()
|
||||
|
||||
val volumeSwipe: AapSetting.VolumeSwipe?
|
||||
get() = aap?.setting()
|
||||
|
||||
val ncWithOneAirPod: AapSetting.NcWithOneAirPod?
|
||||
get() = aap?.setting()
|
||||
|
||||
val adaptiveAudioNoise: AapSetting.AdaptiveAudioNoise?
|
||||
get() = aap?.setting()
|
||||
|
||||
val isAapConnected: Boolean get() = aap != null
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ import eu.darken.capod.monitor.core.primaryDevice
|
||||
import eu.darken.capod.monitor.ui.MonitorNotifications
|
||||
import eu.darken.capod.profiles.core.DeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import eu.darken.capod.reaction.core.aap.AapAutoConnect
|
||||
import eu.darken.capod.reaction.core.aap.AapKeyPersister
|
||||
import eu.darken.capod.reaction.core.autoconnect.AutoConnect
|
||||
import eu.darken.capod.reaction.core.playpause.PlayPause
|
||||
import eu.darken.capod.reaction.core.popup.PopUpReaction
|
||||
@@ -72,6 +74,8 @@ class MonitorService : Service() {
|
||||
@Inject lateinit var popUpReaction: PopUpReaction
|
||||
@Inject lateinit var popUpWindow: PopUpWindow
|
||||
@Inject lateinit var profilesRepo: DeviceProfilesRepo
|
||||
@Inject lateinit var aapAutoConnect: AapAutoConnect
|
||||
@Inject lateinit var aapKeyPersister: AapKeyPersister
|
||||
|
||||
private val monitorScope = MonitorCoroutineScope()
|
||||
private var monitoringJob: Job? = null
|
||||
@@ -287,6 +291,16 @@ class MonitorService : Service() {
|
||||
.catch { log(TAG, WARN) { "autoConnect failed:\n${it.asLog()}" } }
|
||||
.launchIn(monitorScope)
|
||||
|
||||
aapAutoConnect.monitor()
|
||||
.setupCommonEventHandlers(TAG) { "aapAutoConnect" }
|
||||
.catch { log(TAG, WARN) { "aapAutoConnect failed:\n${it.asLog()}" } }
|
||||
.launchIn(monitorScope)
|
||||
|
||||
aapKeyPersister.monitor()
|
||||
.setupCommonEventHandlers(TAG) { "aapKeyPersister" }
|
||||
.catch { log(TAG, WARN) { "aapKeyPersister failed:\n${it.asLog()}" } }
|
||||
.launchIn(monitorScope)
|
||||
|
||||
log(TAG, VERBOSE) { "Monitor job is active" }
|
||||
monitorJob.join()
|
||||
log(TAG, VERBOSE) { "Monitor job quit" }
|
||||
|
||||
@@ -29,31 +29,82 @@ enum class PodModel(
|
||||
),
|
||||
@SerialName("airpods.gen4.anc") AIRPODS_GEN4_ANC(
|
||||
"AirPods (Gen 4 ANC)", R.drawable.device_airpods_gen4anc_both,
|
||||
Features(hasDualPods = true, hasCase = true, hasEarDetection = true, hasAncControl = true),
|
||||
Features(
|
||||
hasDualPods = true, hasCase = true, hasEarDetection = true,
|
||||
hasAncControl = true, hasAdaptiveAnc = true,
|
||||
hasConversationAwareness = true, hasNcOneAirpod = true,
|
||||
hasPressSpeed = true, hasPressHoldDuration = true,
|
||||
hasVolumeSwipe = true, hasVolumeSwipeLength = true,
|
||||
hasPersonalizedVolume = true, hasToneVolume = true,
|
||||
hasEndCallMuteMic = true, hasAdaptiveAudioNoise = true,
|
||||
needsInitExt = true,
|
||||
),
|
||||
),
|
||||
@SerialName("airpods.pro") AIRPODS_PRO(
|
||||
"AirPods Pro", R.drawable.device_airpods_pro2_both,
|
||||
Features(hasDualPods = true, hasCase = true, hasEarDetection = true, hasAncControl = true),
|
||||
Features(
|
||||
hasDualPods = true, hasCase = true, hasEarDetection = true,
|
||||
hasAncControl = true,
|
||||
hasNcOneAirpod = true,
|
||||
hasPressSpeed = true, hasPressHoldDuration = true,
|
||||
hasVolumeSwipe = true, hasVolumeSwipeLength = true,
|
||||
hasToneVolume = true,
|
||||
),
|
||||
),
|
||||
@SerialName("airpods.pro2") AIRPODS_PRO2(
|
||||
"AirPods Pro 2", R.drawable.device_airpods_pro2_both,
|
||||
Features(hasDualPods = true, hasCase = true, hasEarDetection = true, hasAncControl = true),
|
||||
Features(
|
||||
hasDualPods = true, hasCase = true, hasEarDetection = true,
|
||||
hasAncControl = true, hasAdaptiveAnc = true,
|
||||
hasConversationAwareness = true, hasNcOneAirpod = true,
|
||||
hasPressSpeed = true, hasPressHoldDuration = true,
|
||||
hasVolumeSwipe = true, hasVolumeSwipeLength = true,
|
||||
hasPersonalizedVolume = true, hasToneVolume = true,
|
||||
hasEndCallMuteMic = true, hasAdaptiveAudioNoise = true,
|
||||
needsInitExt = true,
|
||||
),
|
||||
),
|
||||
@SerialName("airpods.pro2.usbc") AIRPODS_PRO2_USBC(
|
||||
"AirPods Pro 2 USB-C", R.drawable.device_airpods_pro2_both,
|
||||
Features(hasDualPods = true, hasCase = true, hasEarDetection = true, hasAncControl = true),
|
||||
Features(
|
||||
hasDualPods = true, hasCase = true, hasEarDetection = true,
|
||||
hasAncControl = true, hasAdaptiveAnc = true,
|
||||
hasConversationAwareness = true, hasNcOneAirpod = true,
|
||||
hasPressSpeed = true, hasPressHoldDuration = true,
|
||||
hasVolumeSwipe = true, hasVolumeSwipeLength = true,
|
||||
hasPersonalizedVolume = true, hasToneVolume = true,
|
||||
hasEndCallMuteMic = true, hasAdaptiveAudioNoise = true,
|
||||
needsInitExt = true,
|
||||
),
|
||||
),
|
||||
@SerialName("airpods.pro3") AIRPODS_PRO3(
|
||||
"AirPods Pro 3", R.drawable.device_airpods_pro2_both,
|
||||
Features(hasDualPods = true, hasCase = true, hasEarDetection = true, hasAncControl = true),
|
||||
Features(
|
||||
hasDualPods = true, hasCase = true, hasEarDetection = true,
|
||||
hasAncControl = true, hasAdaptiveAnc = true,
|
||||
hasConversationAwareness = true, hasNcOneAirpod = true,
|
||||
hasPressSpeed = true, hasPressHoldDuration = true,
|
||||
hasVolumeSwipe = true, hasVolumeSwipeLength = true,
|
||||
hasPersonalizedVolume = true, hasToneVolume = true,
|
||||
hasEndCallMuteMic = true, hasAdaptiveAudioNoise = true,
|
||||
needsInitExt = true,
|
||||
),
|
||||
),
|
||||
@SerialName("airpods.max") AIRPODS_MAX(
|
||||
"AirPods Max", R.drawable.device_airpods_max,
|
||||
Features(hasAncControl = true),
|
||||
Features(
|
||||
hasAncControl = true,
|
||||
hasPressSpeed = true, hasPressHoldDuration = true,
|
||||
hasToneVolume = true,
|
||||
),
|
||||
),
|
||||
@SerialName("airpods.max.usbc") AIRPODS_MAX_USBC(
|
||||
"AirPods Max USB-C", R.drawable.device_airpods_max,
|
||||
Features(hasAncControl = true),
|
||||
Features(
|
||||
hasAncControl = true,
|
||||
hasPressSpeed = true, hasPressHoldDuration = true,
|
||||
hasToneVolume = true,
|
||||
),
|
||||
),
|
||||
@SerialName("beats.flex") BEATS_FLEX(
|
||||
"Beats Flex", R.drawable.device_beats_earbuds,
|
||||
@@ -61,10 +112,33 @@ enum class PodModel(
|
||||
@SerialName("beats.solo.3") BEATS_SOLO_3(
|
||||
"Beats Solo 3", R.drawable.device_beats_headphones,
|
||||
),
|
||||
@SerialName("beats.solo.pro") BEATS_SOLO_PRO(
|
||||
"Beats Solo Pro", R.drawable.device_beats_headphones,
|
||||
Features(hasAncControl = true),
|
||||
),
|
||||
@SerialName("beats.solo.4") BEATS_SOLO_4(
|
||||
"Beats Solo 4", R.drawable.device_beats_headphones,
|
||||
),
|
||||
@SerialName("beats.solo.buds") BEATS_SOLO_BUDS(
|
||||
"Beats Solo Buds", R.drawable.device_beats_earbuds,
|
||||
Features(hasDualPods = true, hasCase = true),
|
||||
),
|
||||
@SerialName("beats.studio.3") BEATS_STUDIO_3(
|
||||
"Beats Studio 3", R.drawable.device_beats_studio3,
|
||||
Features(hasAncControl = true),
|
||||
),
|
||||
@SerialName("beats.studio.buds") BEATS_STUDIO_BUDS(
|
||||
"Beats Studio Buds", R.drawable.device_beats_earbuds,
|
||||
Features(hasDualPods = true, hasCase = true, hasEarDetection = true, hasAncControl = true),
|
||||
),
|
||||
@SerialName("beats.studio.buds.plus") BEATS_STUDIO_BUDS_PLUS(
|
||||
"Beats Studio Buds+", R.drawable.device_beats_earbuds,
|
||||
Features(hasDualPods = true, hasCase = true, hasEarDetection = true, hasAncControl = true),
|
||||
),
|
||||
@SerialName("beats.studio.pro") BEATS_STUDIO_PRO(
|
||||
"Beats Studio Pro", R.drawable.device_beats_headphones,
|
||||
Features(hasAncControl = true),
|
||||
),
|
||||
@SerialName("beats.x") BEATS_X(
|
||||
"Beats X", R.drawable.device_beats_x,
|
||||
),
|
||||
@@ -111,9 +185,25 @@ enum class PodModel(
|
||||
);
|
||||
|
||||
data class Features(
|
||||
// Physical form
|
||||
val hasDualPods: Boolean = false,
|
||||
val hasCase: Boolean = false,
|
||||
val hasEarDetection: Boolean = false,
|
||||
// ANC
|
||||
val hasAncControl: Boolean = false,
|
||||
val hasAdaptiveAnc: Boolean = false,
|
||||
// AAP settings
|
||||
val hasConversationAwareness: Boolean = false,
|
||||
val hasNcOneAirpod: Boolean = false,
|
||||
val hasPressSpeed: Boolean = false,
|
||||
val hasPressHoldDuration: Boolean = false,
|
||||
val hasVolumeSwipe: Boolean = false,
|
||||
val hasVolumeSwipeLength: Boolean = false,
|
||||
val hasPersonalizedVolume: Boolean = false,
|
||||
val hasToneVolume: Boolean = false,
|
||||
val hasEndCallMuteMic: Boolean = false,
|
||||
val hasAdaptiveAudioNoise: Boolean = false,
|
||||
// Protocol
|
||||
val needsInitExt: Boolean = false,
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,15 @@ package eu.darken.capod.pods.core.apple.protocol.aap
|
||||
* The [AapDeviceProfile] encodes these into the device-specific wire format.
|
||||
*/
|
||||
sealed class AapCommand {
|
||||
data class SetAncMode(val mode: AncModeValue) : AapCommand()
|
||||
data class SetAncMode(val mode: AapSetting.AncMode.Value) : AapCommand()
|
||||
data class SetConversationalAwareness(val enabled: Boolean) : AapCommand()
|
||||
data class SetPressSpeed(val value: AapSetting.PressSpeed.Value) : AapCommand()
|
||||
data class SetPressHoldDuration(val value: AapSetting.PressHoldDuration.Value) : AapCommand()
|
||||
data class SetNcWithOneAirPod(val enabled: Boolean) : AapCommand()
|
||||
data class SetToneVolume(val level: Int) : AapCommand()
|
||||
data class SetVolumeSwipeLength(val value: AapSetting.VolumeSwipeLength.Value) : AapCommand()
|
||||
data class SetEndCallMuteMic(val muteMic: AapSetting.EndCallMuteMic.MuteMicMode, val endCall: AapSetting.EndCallMuteMic.EndCallMode) : AapCommand()
|
||||
data class SetVolumeSwipe(val enabled: Boolean) : AapCommand()
|
||||
data class SetPersonalizedVolume(val enabled: Boolean) : AapCommand()
|
||||
data class SetAdaptiveAudioNoise(val level: Int) : AapCommand()
|
||||
}
|
||||
|
||||
@@ -3,13 +3,20 @@ package eu.darken.capod.pods.core.apple.protocol.aap
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothSocket
|
||||
import android.util.Log
|
||||
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.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -30,63 +37,88 @@ internal class AapConnection(
|
||||
private val psm: Int = 0x1001,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "AapConnection"
|
||||
private val TAG = logTag("AapConnection")
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow(AapPodState())
|
||||
val state: StateFlow<AapPodState> = _state.asStateFlow()
|
||||
|
||||
private val _keysReceived = MutableSharedFlow<KeyExchangeResult>(extraBufferCapacity = 1)
|
||||
val keysReceived: SharedFlow<KeyExchangeResult> = _keysReceived.asSharedFlow()
|
||||
|
||||
private var socket: BluetoothSocket? = null
|
||||
private var readerJob: Job? = null
|
||||
private val writeMutex = Mutex()
|
||||
private val framer = AapFramer()
|
||||
|
||||
suspend fun connect() = withContext(Dispatchers.IO) {
|
||||
if (_state.value.connectionState != AapConnectionState.DISCONNECTED) {
|
||||
Log.w(TAG, "connect() called in state ${_state.value.connectionState}")
|
||||
return@withContext
|
||||
/**
|
||||
* Opens the L2CAP socket, sends the handshake, and launches the read loop.
|
||||
* Returns after the handshake is sent — the read loop runs in [scope] independently.
|
||||
*/
|
||||
suspend fun connect(scope: CoroutineScope) = withContext(Dispatchers.IO) {
|
||||
if (_state.value.connectionState != AapPodState.ConnectionState.DISCONNECTED) {
|
||||
throw IllegalStateException("connect() called in state ${_state.value.connectionState}")
|
||||
}
|
||||
|
||||
_state.value = _state.value.copy(connectionState = AapConnectionState.CONNECTING)
|
||||
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.CONNECTING)
|
||||
|
||||
try {
|
||||
val sock = socketFactory.createSocket(device, psm)
|
||||
sock.connect()
|
||||
socket = sock
|
||||
Log.d(TAG, "Connected to ${device.address}")
|
||||
log(TAG) { "Connected to ${device.address}" }
|
||||
|
||||
_state.value = _state.value.copy(connectionState = AapConnectionState.HANDSHAKING)
|
||||
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.HANDSHAKING)
|
||||
|
||||
// Send handshake
|
||||
val handshake = profile.encodeHandshake()
|
||||
sock.outputStream.write(handshake)
|
||||
sock.outputStream.flush()
|
||||
Log.d(TAG, "Handshake sent")
|
||||
log(TAG) { "Handshake sent" }
|
||||
|
||||
// Start read loop
|
||||
coroutineScope {
|
||||
readerJob = launch { readLoop(sock) }
|
||||
// Send notification enable packets — tells device to push battery/settings updates
|
||||
for (packet in profile.encodeNotificationEnable()) {
|
||||
sock.outputStream.write(packet)
|
||||
sock.outputStream.flush()
|
||||
}
|
||||
log(TAG) { "Notification enable sent" }
|
||||
|
||||
// Send InitExt for models that need it (Pro 2/3/USB-C, AP4 ANC)
|
||||
profile.encodeInitExt()?.let { initExt ->
|
||||
sock.outputStream.write(initExt)
|
||||
sock.outputStream.flush()
|
||||
log(TAG) { "InitExt sent" }
|
||||
}
|
||||
|
||||
// Request private keys (IRK + ENC) for BLE encrypted battery
|
||||
profile.encodePrivateKeyRequest()?.let { keyReq ->
|
||||
sock.outputStream.write(keyReq)
|
||||
sock.outputStream.flush()
|
||||
log(TAG) { "Private key request sent" }
|
||||
}
|
||||
|
||||
// Launch read loop in the provided scope — connect() returns immediately
|
||||
readerJob = scope.launch(Dispatchers.IO) { readLoop(sock) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Connection failed", e)
|
||||
log(TAG, ERROR) { "Connection failed: $e" }
|
||||
cleanupSocket()
|
||||
_state.value = AapPodState(connectionState = AapConnectionState.DISCONNECTED)
|
||||
_state.value = AapPodState(connectionState = AapPodState.ConnectionState.DISCONNECTED)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun disconnect() = withContext(Dispatchers.IO) {
|
||||
Log.d(TAG, "Disconnecting")
|
||||
log(TAG) { "Disconnecting" }
|
||||
readerJob?.cancel()
|
||||
readerJob = null
|
||||
cleanupSocket()
|
||||
framer.reset()
|
||||
_state.value = AapPodState(connectionState = AapConnectionState.DISCONNECTED)
|
||||
_state.value = AapPodState(connectionState = AapPodState.ConnectionState.DISCONNECTED)
|
||||
}
|
||||
|
||||
suspend fun send(command: AapCommand) {
|
||||
val currentState = _state.value
|
||||
if (currentState.connectionState != AapConnectionState.READY) {
|
||||
if (currentState.connectionState != AapPodState.ConnectionState.READY) {
|
||||
throw IllegalStateException("Cannot send command in state ${currentState.connectionState}")
|
||||
}
|
||||
|
||||
@@ -96,7 +128,7 @@ internal class AapConnection(
|
||||
val sock = socket ?: throw IOException("Socket is null")
|
||||
sock.outputStream.write(bytes)
|
||||
sock.outputStream.flush()
|
||||
Log.d(TAG, "Sent command: $command (${bytes.size} bytes)")
|
||||
log(TAG) { "Sent command: $command (${bytes.size} bytes)" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,45 +141,67 @@ internal class AapConnection(
|
||||
while (isActive) {
|
||||
val len = sock.inputStream.read(buf)
|
||||
if (len == -1) {
|
||||
Log.d(TAG, "Stream closed by remote")
|
||||
log(TAG) { "Stream closed by remote" }
|
||||
break
|
||||
}
|
||||
|
||||
val messages = framer.consume(buf, 0, len)
|
||||
for (message in messages) {
|
||||
// L2CAP SEQPACKET: each read() returns exactly one complete message
|
||||
val raw = buf.copyOfRange(0, len)
|
||||
val message = AapMessage.parse(raw)
|
||||
if (message != null) {
|
||||
processMessage(message)
|
||||
|
||||
if (!handshakeResponseReceived && message.commandType != 0x0009) {
|
||||
handshakeResponseReceived = true
|
||||
}
|
||||
}
|
||||
|
||||
// Transition to READY after processing first batch of messages
|
||||
if (handshakeResponseReceived && _state.value.connectionState == AapConnectionState.HANDSHAKING) {
|
||||
_state.value = _state.value.copy(connectionState = AapConnectionState.READY)
|
||||
Log.d(TAG, "Connection READY")
|
||||
if (handshakeResponseReceived && _state.value.connectionState == AapPodState.ConnectionState.HANDSHAKING) {
|
||||
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.READY)
|
||||
log(TAG) { "Connection READY" }
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
if (isActive) Log.e(TAG, "Read error", e)
|
||||
if (isActive) log(TAG, ERROR) { "Read error: $e" }
|
||||
} finally {
|
||||
cleanupSocket()
|
||||
_state.value = _state.value.copy(connectionState = AapConnectionState.DISCONNECTED)
|
||||
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.DISCONNECTED)
|
||||
}
|
||||
}
|
||||
|
||||
private fun processMessage(message: AapMessage) {
|
||||
val hex = message.raw.joinToString(" ") { "%02X".format(it) }
|
||||
log(TAG, VERBOSE) { "MSG cmd=0x${"%04X".format(message.commandType)} len=${message.raw.size} raw=$hex" }
|
||||
|
||||
// Try battery
|
||||
profile.decodeBattery(message)?.let { batteries ->
|
||||
_state.value = _state.value.copy(batteries = batteries)
|
||||
log(TAG) { "Battery update: ${batteries.entries.map { "${it.key}=${(it.value.percent * 100).toInt()}% ${it.value.charging}" }}" }
|
||||
return
|
||||
}
|
||||
|
||||
// Try private key response
|
||||
profile.decodePrivateKeyResponse(message)?.let { keys ->
|
||||
log(TAG) { "Private keys received: IRK=${keys.irk != null}, ENC=${keys.encKey != null}" }
|
||||
_keysReceived.tryEmit(keys)
|
||||
return
|
||||
}
|
||||
|
||||
// Try device info
|
||||
profile.decodeDeviceInfo(message)?.let { info ->
|
||||
_state.value = _state.value.copy(deviceInfo = info)
|
||||
Log.d(TAG, "Device info: ${info.name} (${info.modelNumber})")
|
||||
log(TAG) { "Device info: ${info.name} (${info.modelNumber})" }
|
||||
return
|
||||
}
|
||||
|
||||
// Try setting update (merge into existing state)
|
||||
profile.decodeSetting(message)?.let { (key, value) ->
|
||||
_state.value = _state.value.withSetting(key, value)
|
||||
log(TAG) { "Setting: ${key.simpleName} = $value" }
|
||||
return
|
||||
}
|
||||
|
||||
log(TAG) { "Unhandled message: cmd=0x${"%04X".format(message.commandType)} payload=${message.payload.size}B" }
|
||||
}
|
||||
|
||||
private fun cleanupSocket() {
|
||||
|
||||
+77
-19
@@ -1,15 +1,26 @@
|
||||
package eu.darken.capod.pods.core.apple.protocol.aap
|
||||
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.util.Log
|
||||
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.pods.core.PodModel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -21,57 +32,104 @@ import javax.inject.Singleton
|
||||
@Singleton
|
||||
class AapConnectionManager @Inject constructor(
|
||||
private val socketFactory: L2capSocketFactory,
|
||||
@AppScope private val scope: CoroutineScope,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "AapConnectionMgr"
|
||||
private val TAG = logTag("AapConnectionMgr")
|
||||
}
|
||||
|
||||
private val mutex = Mutex()
|
||||
private val connections = mutableMapOf<BluetoothAddress, AapConnection>()
|
||||
private val connectionJobs = mutableMapOf<BluetoothAddress, Job>()
|
||||
private val intentionalDisconnects = mutableSetOf<BluetoothAddress>()
|
||||
|
||||
private val _allStates = MutableStateFlow<Map<BluetoothAddress, AapPodState>>(emptyMap())
|
||||
val allStates: StateFlow<Map<BluetoothAddress, AapPodState>> = _allStates.asStateFlow()
|
||||
|
||||
fun deviceState(address: BluetoothAddress): Flow<AapPodState?> =
|
||||
_allStates.map { it[address] }
|
||||
private val _disconnectEvents = MutableSharedFlow<BluetoothAddress>(extraBufferCapacity = 16)
|
||||
val disconnectEvents: SharedFlow<BluetoothAddress> = _disconnectEvents.asSharedFlow()
|
||||
|
||||
/** Emits when a connection receives private keys (IRK/ENC) from the device. */
|
||||
private val _keysReceived = MutableSharedFlow<Pair<BluetoothAddress, KeyExchangeResult>>(extraBufferCapacity = 16)
|
||||
val keysReceived: SharedFlow<Pair<BluetoothAddress, KeyExchangeResult>> = _keysReceived.asSharedFlow()
|
||||
|
||||
fun deviceState(address: BluetoothAddress) = _allStates.map { it[address] }
|
||||
|
||||
suspend fun connect(
|
||||
address: BluetoothAddress,
|
||||
device: BluetoothDevice,
|
||||
model: PodModel,
|
||||
) {
|
||||
) = mutex.withLock {
|
||||
if (connections.containsKey(address)) {
|
||||
Log.d(TAG, "Already connected to $address")
|
||||
log(TAG) { "Already connected to $address" }
|
||||
return
|
||||
}
|
||||
|
||||
// Clear stale intentional-disconnect flag from previous connection lifecycle
|
||||
intentionalDisconnects.remove(address)
|
||||
|
||||
val profile = AapDeviceProfile.forModel(model)
|
||||
val connection = AapConnection(device, profile, socketFactory)
|
||||
connections[address] = connection
|
||||
|
||||
try {
|
||||
connection.connect()
|
||||
connection.connect(scope)
|
||||
} catch (e: Exception) {
|
||||
connections.remove(address)
|
||||
throw e
|
||||
}
|
||||
|
||||
// Observe connection state and propagate to allStates
|
||||
// Note: in production this would use a coroutine scope to collect the flow
|
||||
updateStates()
|
||||
// Collect connection state and propagate live updates to allStates.
|
||||
// The coroutine cancels itself after handling DISCONNECTED,
|
||||
// which also cancels the child key-forwarding coroutine.
|
||||
connectionJobs[address] = scope.launch {
|
||||
// Forward private keys from this connection (child coroutine)
|
||||
launch {
|
||||
connection.keysReceived.collect { keys ->
|
||||
_keysReceived.tryEmit(address to keys)
|
||||
}
|
||||
}
|
||||
|
||||
connection.state.collect { podState ->
|
||||
if (podState.connectionState == AapPodState.ConnectionState.DISCONNECTED) {
|
||||
log(TAG) { "Connection to $address disconnected" }
|
||||
val wasIntentional = mutex.withLock {
|
||||
connections.remove(address)
|
||||
connectionJobs.remove(address)
|
||||
connection.disconnect()
|
||||
_allStates.update { it - address }
|
||||
val intentional = address in intentionalDisconnects
|
||||
intentionalDisconnects.remove(address)
|
||||
intentional
|
||||
}
|
||||
|
||||
if (!wasIntentional) {
|
||||
_disconnectEvents.tryEmit(address)
|
||||
}
|
||||
|
||||
// End this collector coroutine (also cancels child key-forwarding coroutine)
|
||||
cancel()
|
||||
} else {
|
||||
_allStates.update { it + (address to podState) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun disconnect(address: BluetoothAddress) {
|
||||
val connection = connections.remove(address) ?: return
|
||||
suspend fun disconnect(address: BluetoothAddress) = mutex.withLock {
|
||||
intentionalDisconnects.add(address)
|
||||
val connection = connections.remove(address) ?: run {
|
||||
intentionalDisconnects.remove(address)
|
||||
return
|
||||
}
|
||||
connectionJobs.remove(address)?.cancel()
|
||||
connection.disconnect()
|
||||
updateStates()
|
||||
_allStates.update { it - address }
|
||||
}
|
||||
|
||||
suspend fun sendCommand(address: BluetoothAddress, command: AapCommand) {
|
||||
val connection = connections[address]
|
||||
val connection = mutex.withLock { connections[address] }
|
||||
?: throw IllegalStateException("No connection for $address")
|
||||
connection.send(command)
|
||||
}
|
||||
|
||||
private fun updateStates() {
|
||||
_allStates.value = connections.mapValues { (_, conn) -> conn.state.value }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,12 @@ interface AapDeviceProfile {
|
||||
*/
|
||||
fun decodeSetting(message: AapMessage): Pair<KClass<out AapSetting>, AapSetting>?
|
||||
|
||||
/**
|
||||
* Decode a battery notification (command 0x04).
|
||||
* Returns null if the message is not a battery update.
|
||||
*/
|
||||
fun decodeBattery(message: AapMessage): Map<AapPodState.BatteryType, AapPodState.Battery>?
|
||||
|
||||
/**
|
||||
* Decode a device info message (typically command type 0x001D).
|
||||
*/
|
||||
@@ -35,7 +41,32 @@ interface AapDeviceProfile {
|
||||
*/
|
||||
fun encodeHandshake(): ByteArray
|
||||
|
||||
/**
|
||||
* Encode notification enable packets. These tell the device to push
|
||||
* battery, settings, and other event notifications.
|
||||
* Sent after the handshake, before the read loop starts.
|
||||
*/
|
||||
fun encodeNotificationEnable(): List<ByteArray>
|
||||
|
||||
/**
|
||||
* Encode the extended init packet (0x4D) for models that require it
|
||||
* (e.g., Pro 2/3/USB-C, AirPods 4 ANC). Returns null if not needed.
|
||||
*/
|
||||
fun encodeInitExt(): ByteArray?
|
||||
|
||||
/**
|
||||
* Encode a private key request (command 0x30).
|
||||
* Returns null if the model doesn't support key exchange.
|
||||
*/
|
||||
fun encodePrivateKeyRequest(): ByteArray?
|
||||
|
||||
/**
|
||||
* Decode a private key response (command 0x31).
|
||||
* Returns null if the message is not a key response.
|
||||
*/
|
||||
fun decodePrivateKeyResponse(message: AapMessage): KeyExchangeResult?
|
||||
|
||||
companion object {
|
||||
fun forModel(model: PodModel): AapDeviceProfile = DefaultAapDeviceProfile()
|
||||
fun forModel(model: PodModel): AapDeviceProfile = DefaultAapDeviceProfile(model)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,19 +6,70 @@ import kotlin.reflect.KClass
|
||||
* Pure data representing the current state of an AAP connection. No connection handle.
|
||||
*/
|
||||
data class AapPodState(
|
||||
val connectionState: AapConnectionState = AapConnectionState.DISCONNECTED,
|
||||
val connectionState: ConnectionState = ConnectionState.DISCONNECTED,
|
||||
val deviceInfo: AapDeviceInfo? = null,
|
||||
val settings: Map<KClass<out AapSetting>, AapSetting> = emptyMap(),
|
||||
val batteries: Map<BatteryType, Battery> = emptyMap(),
|
||||
) {
|
||||
inline fun <reified T : AapSetting> setting(): T? = settings[T::class] as? T
|
||||
|
||||
fun withSetting(key: KClass<out AapSetting>, value: AapSetting): AapPodState =
|
||||
copy(settings = settings + (key to value))
|
||||
}
|
||||
|
||||
enum class AapConnectionState {
|
||||
DISCONNECTED,
|
||||
CONNECTING,
|
||||
HANDSHAKING,
|
||||
READY,
|
||||
// Battery — from AAP command 0x04, 1% granularity
|
||||
val batteryLeft: Float? get() = batteries[BatteryType.LEFT]?.percent
|
||||
val batteryRight: Float? get() = batteries[BatteryType.RIGHT]?.percent
|
||||
val batteryCase: Float? get() = batteries[BatteryType.CASE]?.percent
|
||||
val batteryHeadset: Float? get() = batteries[BatteryType.SINGLE]?.percent
|
||||
|
||||
// Charging state from AAP battery
|
||||
val isLeftCharging: Boolean?
|
||||
get() = batteries[BatteryType.LEFT]?.let { it.charging == ChargingState.CHARGING || it.charging == ChargingState.CHARGING_OPTIMIZED }
|
||||
val isRightCharging: Boolean?
|
||||
get() = batteries[BatteryType.RIGHT]?.let { it.charging == ChargingState.CHARGING || it.charging == ChargingState.CHARGING_OPTIMIZED }
|
||||
val isCaseCharging: Boolean?
|
||||
get() = batteries[BatteryType.CASE]?.let { it.charging == ChargingState.CHARGING || it.charging == ChargingState.CHARGING_OPTIMIZED }
|
||||
val isHeadsetCharging: Boolean?
|
||||
get() = batteries[BatteryType.SINGLE]?.let { it.charging == ChargingState.CHARGING || it.charging == ChargingState.CHARGING_OPTIMIZED }
|
||||
|
||||
enum class ConnectionState {
|
||||
DISCONNECTED,
|
||||
CONNECTING,
|
||||
HANDSHAKING,
|
||||
READY,
|
||||
}
|
||||
|
||||
/** Battery entry from an AAP battery notification (command 0x04). */
|
||||
data class Battery(
|
||||
val type: BatteryType,
|
||||
val percent: Float,
|
||||
val charging: ChargingState,
|
||||
)
|
||||
|
||||
enum class BatteryType(val wireValue: Int) {
|
||||
SINGLE(0x01),
|
||||
RIGHT(0x02),
|
||||
LEFT(0x04),
|
||||
CASE(0x08),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromWire(value: Int): BatteryType? = entries.firstOrNull { it.wireValue == value }
|
||||
}
|
||||
}
|
||||
|
||||
enum class ChargingState(val wireValue: Int) {
|
||||
UNDEFINED(0x00),
|
||||
CHARGING(0x01),
|
||||
NOT_CHARGING(0x02),
|
||||
DISCONNECTED(0x04),
|
||||
// TODO: Observed on AirPods Pro 3 in charging case. Possibly Apple's "Optimized Battery Charging" limit.
|
||||
// Verify behavior with Pro 2 and Pro 1 to confirm whether this is model-specific or firmware-specific.
|
||||
CHARGING_OPTIMIZED(0x05),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromWire(value: Int): ChargingState = entries.firstOrNull { it.wireValue == value } ?: UNDEFINED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,97 @@ package eu.darken.capod.pods.core.apple.protocol.aap
|
||||
sealed class AapSetting {
|
||||
|
||||
data class AncMode(
|
||||
val current: AncModeValue,
|
||||
val supported: List<AncModeValue>,
|
||||
) : AapSetting()
|
||||
val current: Value,
|
||||
val supported: List<Value>,
|
||||
) : AapSetting() {
|
||||
enum class Value {
|
||||
OFF, ON, TRANSPARENCY, ADAPTIVE,
|
||||
}
|
||||
}
|
||||
|
||||
data class ConversationalAwareness(
|
||||
val enabled: Boolean,
|
||||
) : AapSetting()
|
||||
|
||||
data class PressSpeed(
|
||||
val value: Value,
|
||||
) : AapSetting() {
|
||||
enum class Value(val wireValue: Int) {
|
||||
DEFAULT(0x00), SLOWER(0x01), SLOWEST(0x02);
|
||||
|
||||
companion object {
|
||||
fun fromWire(value: Int): Value? = entries.firstOrNull { it.wireValue == value }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class PressHoldDuration(
|
||||
val value: Value,
|
||||
) : AapSetting() {
|
||||
enum class Value(val wireValue: Int) {
|
||||
DEFAULT(0x00), SHORTER(0x01), SHORTEST(0x02);
|
||||
|
||||
companion object {
|
||||
fun fromWire(value: Int): Value? = entries.firstOrNull { it.wireValue == value }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class NcWithOneAirPod(
|
||||
val enabled: Boolean,
|
||||
) : AapSetting()
|
||||
|
||||
data class ToneVolume(
|
||||
val level: Int,
|
||||
) : AapSetting()
|
||||
|
||||
data class VolumeSwipeLength(
|
||||
val value: Value,
|
||||
) : AapSetting() {
|
||||
enum class Value(val wireValue: Int) {
|
||||
DEFAULT(0x00), LONGER(0x01), LONGEST(0x02);
|
||||
|
||||
companion object {
|
||||
fun fromWire(value: Int): Value? = entries.firstOrNull { it.wireValue == value }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class EndCallMuteMic(
|
||||
val muteMic: MuteMicMode,
|
||||
val endCall: EndCallMode,
|
||||
) : AapSetting() {
|
||||
enum class MuteMicMode(val wireValue: Int) {
|
||||
SINGLE_PRESS(0x23), DOUBLE_PRESS(0x22);
|
||||
|
||||
companion object {
|
||||
fun fromWire(value: Int): MuteMicMode? = entries.firstOrNull { it.wireValue == value }
|
||||
}
|
||||
}
|
||||
|
||||
enum class EndCallMode(val wireValue: Int) {
|
||||
DOUBLE_PRESS(0x02), SINGLE_PRESS(0x03);
|
||||
|
||||
companion object {
|
||||
fun fromWire(value: Int): EndCallMode? = entries.firstOrNull { it.wireValue == value }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class VolumeSwipe(
|
||||
val enabled: Boolean,
|
||||
) : AapSetting()
|
||||
|
||||
data class PersonalizedVolume(
|
||||
val enabled: Boolean,
|
||||
) : AapSetting()
|
||||
|
||||
data class AdaptiveAudioNoise(
|
||||
val level: Int,
|
||||
) : AapSetting()
|
||||
|
||||
/** Push-only from device — reports speaking detection state (command 0x4B). */
|
||||
data class ConversationalAwarenessState(
|
||||
val speaking: Boolean,
|
||||
) : AapSetting()
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
package eu.darken.capod.pods.core.apple.protocol.aap
|
||||
|
||||
enum class AncModeValue {
|
||||
OFF,
|
||||
ON,
|
||||
TRANSPARENCY,
|
||||
ADAPTIVE,
|
||||
}
|
||||
+228
-37
@@ -1,32 +1,54 @@
|
||||
package eu.darken.capod.pods.core.apple.protocol.aap
|
||||
|
||||
import eu.darken.capod.pods.core.PodModel
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
/**
|
||||
* Default AAP device profile covering the known protocol from MagicPodsCore + PoC captures.
|
||||
* Handles the common wire format used by AirPods Pro 2, Pro 3, and similar H2/H3 chip devices.
|
||||
* Default AAP device profile covering the known protocol from MagicPodsCore + LibrePods research.
|
||||
* Handles the common wire format used by all Apple/Beats devices over L2CAP PSM 0x1001.
|
||||
*
|
||||
* When model-specific differences are discovered, subclass and override the relevant methods.
|
||||
* Model-specific behavior (supported ANC modes, InitExt, feature gating) is driven by
|
||||
* [PodModel.features] — no subclassing needed.
|
||||
*/
|
||||
class DefaultAapDeviceProfile : AapDeviceProfile {
|
||||
class DefaultAapDeviceProfile(
|
||||
private val model: PodModel = PodModel.UNKNOWN,
|
||||
) : AapDeviceProfile {
|
||||
|
||||
companion object {
|
||||
// AAP command types (bytes 4-5 of the message, little-endian)
|
||||
const val CMD_SETTINGS = 0x0009
|
||||
const val CMD_BATTERY = 0x0004
|
||||
const val CMD_DEVICE_INFO = 0x001D
|
||||
const val CMD_PRIVATE_KEYS_RESPONSE = 0x0031
|
||||
const val CMD_CONVERSATION_AWARENESS_STATE = 0x004B
|
||||
|
||||
// Setting IDs (first byte of settings command payload)
|
||||
const val SETTING_ANC_MODE = 0x0D
|
||||
const val SETTING_CONVERSATIONAL_AWARENESS = 0x18
|
||||
const val SETTING_PRESS_SPEED = 0x17
|
||||
const val SETTING_PRESS_HOLD_DURATION = 0x18
|
||||
const val SETTING_NC_ONE_AIRPOD = 0x1B
|
||||
const val SETTING_TONE_VOLUME = 0x1F
|
||||
const val SETTING_VOLUME_SWIPE_LENGTH = 0x23
|
||||
const val SETTING_END_CALL_MUTE_MIC = 0x24
|
||||
const val SETTING_VOLUME_SWIPE = 0x25
|
||||
const val SETTING_PERSONALIZED_VOLUME = 0x26
|
||||
const val SETTING_CONVERSATIONAL_AWARENESS = 0x28
|
||||
const val SETTING_ADAPTIVE_AUDIO_NOISE = 0x2E
|
||||
|
||||
// ANC mode wire values
|
||||
const val ANC_WIRE_OFF = 0x01
|
||||
const val ANC_WIRE_ON = 0x02
|
||||
const val ANC_WIRE_TRANSPARENCY = 0x03
|
||||
const val ANC_WIRE_ADAPTIVE = 0x04
|
||||
}
|
||||
|
||||
// Default supported ANC modes
|
||||
val DEFAULT_ANC_MODES = listOf(AncModeValue.ON, AncModeValue.TRANSPARENCY, AncModeValue.ADAPTIVE)
|
||||
private val supportedAncModes: List<AapSetting.AncMode.Value> by lazy {
|
||||
val features = model.features
|
||||
when {
|
||||
!features.hasAncControl -> emptyList()
|
||||
features.hasAdaptiveAnc -> listOf(AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.TRANSPARENCY, AapSetting.AncMode.Value.ADAPTIVE)
|
||||
else -> listOf(AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.TRANSPARENCY)
|
||||
}
|
||||
}
|
||||
|
||||
override fun encodeHandshake(): ByteArray = byteArrayOf(
|
||||
@@ -34,18 +56,42 @@ class DefaultAapDeviceProfile : AapDeviceProfile {
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
|
||||
)
|
||||
|
||||
override fun encodeCommand(command: AapCommand): ByteArray = when (command) {
|
||||
is AapCommand.SetAncMode -> buildSettingsMessage(
|
||||
SETTING_ANC_MODE,
|
||||
encodeAncMode(command.mode)
|
||||
)
|
||||
is AapCommand.SetConversationalAwareness -> buildSettingsMessage(
|
||||
SETTING_CONVERSATIONAL_AWARENESS,
|
||||
if (command.enabled) 0x01 else 0x00
|
||||
override fun encodeNotificationEnable(): List<ByteArray> = listOf(
|
||||
byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x0f, 0x00, 0xff.toByte(), 0xff.toByte(), 0xef.toByte(), 0xff.toByte()),
|
||||
byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x0f, 0x00, 0xff.toByte(), 0xff.toByte(), 0xff.toByte(), 0xff.toByte()),
|
||||
)
|
||||
|
||||
override fun encodeInitExt(): ByteArray? {
|
||||
if (!model.features.needsInitExt) return null
|
||||
return byteArrayOf(
|
||||
0x04, 0x00, 0x04, 0x00, 0x4d, 0x00, 0x0e, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
)
|
||||
}
|
||||
|
||||
override fun encodeCommand(command: AapCommand): ByteArray = when (command) {
|
||||
is AapCommand.SetAncMode -> buildSettingsMessage(SETTING_ANC_MODE, encodeAncMode(command.mode))
|
||||
is AapCommand.SetConversationalAwareness -> buildSettingsMessage(SETTING_CONVERSATIONAL_AWARENESS, encodeAppleBool(command.enabled))
|
||||
is AapCommand.SetPressSpeed -> buildSettingsMessage(SETTING_PRESS_SPEED, command.value.wireValue)
|
||||
is AapCommand.SetPressHoldDuration -> buildSettingsMessage(SETTING_PRESS_HOLD_DURATION, command.value.wireValue)
|
||||
is AapCommand.SetNcWithOneAirPod -> buildSettingsMessage(SETTING_NC_ONE_AIRPOD, encodeAppleBool(command.enabled))
|
||||
is AapCommand.SetToneVolume -> buildSettingsMessage(SETTING_TONE_VOLUME, command.level.coerceIn(0x0F, 0x64))
|
||||
is AapCommand.SetVolumeSwipeLength -> buildSettingsMessage(SETTING_VOLUME_SWIPE_LENGTH, command.value.wireValue)
|
||||
is AapCommand.SetVolumeSwipe -> buildSettingsMessage(SETTING_VOLUME_SWIPE, encodeAppleBool(command.enabled))
|
||||
is AapCommand.SetPersonalizedVolume -> buildSettingsMessage(SETTING_PERSONALIZED_VOLUME, encodeAppleBool(command.enabled))
|
||||
is AapCommand.SetAdaptiveAudioNoise -> buildSettingsMessage(SETTING_ADAPTIVE_AUDIO_NOISE, command.level.coerceIn(0, 100))
|
||||
is AapCommand.SetEndCallMuteMic -> buildEndCallMuteMicMessage(command.muteMic, command.endCall)
|
||||
}
|
||||
|
||||
override fun decodeSetting(message: AapMessage): Pair<KClass<out AapSetting>, AapSetting>? {
|
||||
// Conversation Awareness State is a separate command type (push-only)
|
||||
if (message.commandType == CMD_CONVERSATION_AWARENESS_STATE) {
|
||||
if (message.payload.isEmpty()) return null
|
||||
val value = message.payload[0].toInt() and 0xFF
|
||||
val speaking = value == 0x01
|
||||
return AapSetting.ConversationalAwarenessState::class to AapSetting.ConversationalAwarenessState(speaking)
|
||||
}
|
||||
|
||||
if (message.commandType != CMD_SETTINGS) return null
|
||||
if (message.payload.size < 2) return null
|
||||
|
||||
@@ -55,20 +101,123 @@ class DefaultAapDeviceProfile : AapDeviceProfile {
|
||||
return when (settingId) {
|
||||
SETTING_ANC_MODE -> {
|
||||
val mode = decodeAncMode(value) ?: return null
|
||||
AapSetting.AncMode::class to AapSetting.AncMode(
|
||||
current = mode,
|
||||
supported = DEFAULT_ANC_MODES,
|
||||
)
|
||||
AapSetting.AncMode::class to AapSetting.AncMode(current = mode, supported = supportedAncModes)
|
||||
}
|
||||
SETTING_CONVERSATIONAL_AWARENESS -> {
|
||||
AapSetting.ConversationalAwareness::class to AapSetting.ConversationalAwareness(
|
||||
enabled = value != 0,
|
||||
)
|
||||
val enabled = decodeAppleBool(value) ?: return null
|
||||
AapSetting.ConversationalAwareness::class to AapSetting.ConversationalAwareness(enabled)
|
||||
}
|
||||
SETTING_PRESS_SPEED -> {
|
||||
val speed = AapSetting.PressSpeed.Value.fromWire(value) ?: return null
|
||||
AapSetting.PressSpeed::class to AapSetting.PressSpeed(speed)
|
||||
}
|
||||
SETTING_PRESS_HOLD_DURATION -> {
|
||||
val duration = AapSetting.PressHoldDuration.Value.fromWire(value) ?: return null
|
||||
AapSetting.PressHoldDuration::class to AapSetting.PressHoldDuration(duration)
|
||||
}
|
||||
SETTING_NC_ONE_AIRPOD -> {
|
||||
val enabled = decodeAppleBool(value) ?: return null
|
||||
AapSetting.NcWithOneAirPod::class to AapSetting.NcWithOneAirPod(enabled)
|
||||
}
|
||||
SETTING_TONE_VOLUME -> {
|
||||
AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = value)
|
||||
}
|
||||
SETTING_VOLUME_SWIPE_LENGTH -> {
|
||||
val length = AapSetting.VolumeSwipeLength.Value.fromWire(value) ?: return null
|
||||
AapSetting.VolumeSwipeLength::class to AapSetting.VolumeSwipeLength(length)
|
||||
}
|
||||
SETTING_END_CALL_MUTE_MIC -> {
|
||||
decodeEndCallMuteMic(message.payload)
|
||||
}
|
||||
SETTING_VOLUME_SWIPE -> {
|
||||
val enabled = decodeAppleBool(value) ?: return null
|
||||
AapSetting.VolumeSwipe::class to AapSetting.VolumeSwipe(enabled)
|
||||
}
|
||||
SETTING_PERSONALIZED_VOLUME -> {
|
||||
val enabled = decodeAppleBool(value) ?: return null
|
||||
AapSetting.PersonalizedVolume::class to AapSetting.PersonalizedVolume(enabled)
|
||||
}
|
||||
SETTING_ADAPTIVE_AUDIO_NOISE -> {
|
||||
AapSetting.AdaptiveAudioNoise::class to AapSetting.AdaptiveAudioNoise(level = value)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
override fun decodeBattery(message: AapMessage): Map<AapPodState.BatteryType, AapPodState.Battery>? {
|
||||
if (message.commandType != CMD_BATTERY) return null
|
||||
val payload = message.payload
|
||||
if (payload.isEmpty()) return null
|
||||
|
||||
val count = payload[0].toInt() and 0xFF
|
||||
if (count == 0) return emptyMap()
|
||||
|
||||
// Each battery entry is 5 bytes starting at offset 1
|
||||
val result = mutableMapOf<AapPodState.BatteryType, AapPodState.Battery>()
|
||||
var offset = 1
|
||||
for (i in 0 until count) {
|
||||
if (offset + 5 > payload.size) break
|
||||
|
||||
val type = AapPodState.BatteryType.fromWire(payload[offset].toInt() and 0xFF) ?: run {
|
||||
offset += 5
|
||||
continue
|
||||
}
|
||||
// payload[offset+1] is unknown/reserved
|
||||
val percent = payload[offset + 2].toInt() and 0xFF
|
||||
val charging = AapPodState.ChargingState.fromWire(payload[offset + 3].toInt() and 0xFF)
|
||||
// payload[offset+4] is unknown/reserved
|
||||
offset += 5
|
||||
|
||||
// Values above 100 are not valid battery percentages.
|
||||
// Known cases: 127 (0x7F) = fake reading after case close, 255 (0xFF) = disconnected.
|
||||
if (percent > 100) continue
|
||||
|
||||
result[type] = AapPodState.Battery(
|
||||
type = type,
|
||||
percent = percent / 100f,
|
||||
charging = charging,
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
override fun encodePrivateKeyRequest(): ByteArray = byteArrayOf(
|
||||
0x04, 0x00, 0x04, 0x00, 0x30, 0x00, 0x05, 0x00
|
||||
)
|
||||
|
||||
override fun decodePrivateKeyResponse(message: AapMessage): KeyExchangeResult? {
|
||||
if (message.commandType != CMD_PRIVATE_KEYS_RESPONSE) return null
|
||||
val payload = message.payload
|
||||
if (payload.isEmpty()) return null
|
||||
|
||||
val keyCount = payload[0].toInt() and 0xFF
|
||||
var irk: ByteArray? = null
|
||||
var encKey: ByteArray? = null
|
||||
var offset = 1
|
||||
|
||||
for (i in 0 until keyCount) {
|
||||
// Each entry: keyType(1), unknown(1), keyLength(1), unknown(1), keyData(keyLength)
|
||||
if (offset + 4 > payload.size) break
|
||||
val keyType = payload[offset].toInt() and 0xFF
|
||||
// offset+1 is unknown
|
||||
val keyLength = payload[offset + 2].toInt() and 0xFF
|
||||
// offset+3 is unknown
|
||||
offset += 4
|
||||
|
||||
if (offset + keyLength > payload.size) break
|
||||
val keyData = payload.copyOfRange(offset, offset + keyLength)
|
||||
offset += keyLength
|
||||
|
||||
when (keyType) {
|
||||
0x01 -> if (keyLength == 16) irk = keyData // IRK
|
||||
0x04 -> if (keyLength == 16) encKey = keyData // ENC
|
||||
}
|
||||
}
|
||||
|
||||
return if (irk != null || encKey != null) KeyExchangeResult(irk, encKey) else null
|
||||
}
|
||||
|
||||
override fun decodeDeviceInfo(message: AapMessage): AapDeviceInfo? {
|
||||
if (message.commandType != CMD_DEVICE_INFO) return null
|
||||
if (message.payload.size < 10) return null
|
||||
@@ -87,18 +236,28 @@ class DefaultAapDeviceProfile : AapDeviceProfile {
|
||||
)
|
||||
}
|
||||
|
||||
protected fun encodeAncMode(mode: AncModeValue): Int = when (mode) {
|
||||
AncModeValue.OFF -> ANC_WIRE_OFF
|
||||
AncModeValue.ON -> ANC_WIRE_ON
|
||||
AncModeValue.TRANSPARENCY -> ANC_WIRE_TRANSPARENCY
|
||||
AncModeValue.ADAPTIVE -> ANC_WIRE_ADAPTIVE
|
||||
protected fun encodeAncMode(mode: AapSetting.AncMode.Value): Int = when (mode) {
|
||||
AapSetting.AncMode.Value.OFF -> ANC_WIRE_OFF
|
||||
AapSetting.AncMode.Value.ON -> ANC_WIRE_ON
|
||||
AapSetting.AncMode.Value.TRANSPARENCY -> ANC_WIRE_TRANSPARENCY
|
||||
AapSetting.AncMode.Value.ADAPTIVE -> ANC_WIRE_ADAPTIVE
|
||||
}
|
||||
|
||||
protected fun decodeAncMode(wireValue: Int): AncModeValue? = when (wireValue) {
|
||||
ANC_WIRE_OFF -> AncModeValue.OFF
|
||||
ANC_WIRE_ON -> AncModeValue.ON
|
||||
ANC_WIRE_TRANSPARENCY -> AncModeValue.TRANSPARENCY
|
||||
ANC_WIRE_ADAPTIVE -> AncModeValue.ADAPTIVE
|
||||
protected fun decodeAncMode(wireValue: Int): AapSetting.AncMode.Value? = when (wireValue) {
|
||||
ANC_WIRE_OFF -> AapSetting.AncMode.Value.OFF
|
||||
ANC_WIRE_ON -> AapSetting.AncMode.Value.ON
|
||||
ANC_WIRE_TRANSPARENCY -> AapSetting.AncMode.Value.TRANSPARENCY
|
||||
ANC_WIRE_ADAPTIVE -> AapSetting.AncMode.Value.ADAPTIVE
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** Standard Apple boolean encoding: 0x01=on, 0x02=off. */
|
||||
private fun encodeAppleBool(enabled: Boolean): Int = if (enabled) 0x01 else 0x02
|
||||
|
||||
/** Decode Apple boolean: 0x01=true, 0x02=false, anything else=null (unknown). */
|
||||
private fun decodeAppleBool(wireValue: Int): Boolean? = when (wireValue) {
|
||||
0x01 -> true
|
||||
0x02 -> false
|
||||
else -> null
|
||||
}
|
||||
|
||||
@@ -109,17 +268,49 @@ class DefaultAapDeviceProfile : AapDeviceProfile {
|
||||
0x00, 0x00, 0x00,
|
||||
)
|
||||
|
||||
/** EndCallMuteMic uses a special 2-byte format: [0x24] [0x21] [muteMic] [endCall] [0x00] */
|
||||
private fun buildEndCallMuteMicMessage(muteMic: AapSetting.EndCallMuteMic.MuteMicMode, endCall: AapSetting.EndCallMuteMic.EndCallMode): ByteArray = byteArrayOf(
|
||||
0x04, 0x00, 0x04, 0x00,
|
||||
0x09, 0x00,
|
||||
SETTING_END_CALL_MUTE_MIC.toByte(), 0x21,
|
||||
muteMic.wireValue.toByte(), endCall.wireValue.toByte(),
|
||||
0x00,
|
||||
)
|
||||
|
||||
private fun decodeEndCallMuteMic(payload: ByteArray): Pair<KClass<out AapSetting>, AapSetting>? {
|
||||
if (payload.size < 4) return null
|
||||
val subType = payload[1].toInt() and 0xFF
|
||||
return when (subType) {
|
||||
0x21 -> {
|
||||
// Standard format: byte 2 = muteMic, byte 3 = endCall
|
||||
val muteMic = AapSetting.EndCallMuteMic.MuteMicMode.fromWire(payload[2].toInt() and 0xFF) ?: return null
|
||||
val endCall = AapSetting.EndCallMuteMic.EndCallMode.fromWire(payload[3].toInt() and 0xFF) ?: return null
|
||||
AapSetting.EndCallMuteMic::class to AapSetting.EndCallMuteMic(muteMic, endCall)
|
||||
}
|
||||
0x00, 0x20 -> {
|
||||
// Alternate/compact response format: byte 2 is a combined mode value
|
||||
val combined = payload[2].toInt() and 0xFF
|
||||
val (muteMic, endCall) = when (combined) {
|
||||
0x02 -> AapSetting.EndCallMuteMic.MuteMicMode.SINGLE_PRESS to AapSetting.EndCallMuteMic.EndCallMode.DOUBLE_PRESS
|
||||
0x03 -> AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS to AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS
|
||||
else -> return null
|
||||
}
|
||||
AapSetting.EndCallMuteMic::class to AapSetting.EndCallMuteMic(muteMic, endCall)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseNullTerminatedStrings(data: ByteArray): List<String> {
|
||||
val strings = mutableListOf<String>()
|
||||
var start = 0
|
||||
// Skip initial length/flags bytes (first few bytes before string data)
|
||||
val stringDataStart = data.indexOfFirst { it == 0x00.toByte() && data.indexOf(it.toByte()) > 2 }
|
||||
.takeIf { it >= 0 } ?: return strings
|
||||
|
||||
// Find runs of printable ASCII separated by null bytes
|
||||
// Find runs of printable ASCII (0x20..0x7E) separated by null bytes.
|
||||
// Header bytes and non-ASCII bytes are skipped.
|
||||
var i = 0
|
||||
while (i < data.size) {
|
||||
if (data[i] != 0x00.toByte() && data[i].toInt() and 0xFF >= 0x20) {
|
||||
val b = data[i].toInt() and 0xFF
|
||||
if (b in 0x20..0x7E) {
|
||||
start = i
|
||||
while (i < data.size && data[i] != 0x00.toByte()) i++
|
||||
strings.add(String(data, start, i - start, Charsets.US_ASCII))
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package eu.darken.capod.pods.core.apple.protocol.aap
|
||||
|
||||
/**
|
||||
* Result of parsing an AAP private key response (command 0x31).
|
||||
* Contains the Identity Resolving Key (IRK) for BLE RPA verification
|
||||
* and the Encryption Key (ENC) for BLE encrypted battery decryption.
|
||||
*/
|
||||
data class KeyExchangeResult(
|
||||
val irk: ByteArray?,
|
||||
val encKey: ByteArray?,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is KeyExchangeResult) return false
|
||||
return irk.contentEquals(other.irk) && encKey.contentEquals(other.encKey)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = irk?.contentHashCode() ?: 0
|
||||
result = 31 * result + (encKey?.contentHashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private fun ByteArray?.contentEquals(other: ByteArray?): Boolean = when {
|
||||
this == null && other == null -> true
|
||||
this != null && other != null -> this.contentEquals(other)
|
||||
else -> false
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package eu.darken.capod.reaction.core.aap
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
|
||||
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
||||
import eu.darken.capod.monitor.core.BlePodMonitor
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapConnectionManager
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapPodState
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AapAutoConnect @Inject constructor(
|
||||
private val aapManager: AapConnectionManager,
|
||||
private val profilesRepo: DeviceProfilesRepo,
|
||||
private val bluetoothManager: BluetoothManager2,
|
||||
private val blePodMonitor: BlePodMonitor,
|
||||
) {
|
||||
private val activeReconnects = java.util.Collections.synchronizedSet(mutableSetOf<String>())
|
||||
|
||||
fun monitor(): Flow<Unit> = merge(
|
||||
initialConnect(),
|
||||
reconnectOnDisconnect(),
|
||||
)
|
||||
|
||||
private fun initialConnect(): Flow<Unit> = profilesRepo.profiles
|
||||
.map { profiles ->
|
||||
val bondedDevices = bluetoothManager.bondedDevices().first()
|
||||
|
||||
for (profile in profiles) {
|
||||
val address = profile.address ?: continue
|
||||
val bonded = bondedDevices.firstOrNull { it.address == address } ?: continue
|
||||
|
||||
val currentState = aapManager.allStates.value[address]
|
||||
if (currentState != null && currentState.connectionState != AapPodState.ConnectionState.DISCONNECTED) {
|
||||
log(TAG, VERBOSE) { "AAP already connected/connecting to $address" }
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
log(TAG) { "AAP connecting to $address (${profile.label})" }
|
||||
aapManager.connect(address, bonded.internal, profile.model)
|
||||
log(TAG) { "AAP connected to $address" }
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "AAP connect failed for $address: ${e.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
.setupCommonEventHandlers(TAG) { "initialConnect" }
|
||||
|
||||
private fun reconnectOnDisconnect(): Flow<Unit> = aapManager.disconnectEvents
|
||||
.onEach { address ->
|
||||
if (!activeReconnects.add(address)) {
|
||||
log(TAG, VERBOSE) { "AAP reconnect already in progress for $address, skipping" }
|
||||
return@onEach
|
||||
}
|
||||
|
||||
val backoffDelays = longArrayOf(5_000, 10_000, 30_000, 60_000)
|
||||
|
||||
for ((attempt, delayMs) in backoffDelays.withIndex()) {
|
||||
delay(delayMs)
|
||||
|
||||
// Check if still profiled
|
||||
val profile = profilesRepo.profiles.first()
|
||||
.firstOrNull { it.address == address }
|
||||
if (profile == null) {
|
||||
log(TAG) { "AAP reconnect: $address no longer profiled, stopping" }
|
||||
break
|
||||
}
|
||||
|
||||
// Check if still bonded
|
||||
val bonded = bluetoothManager.bondedDevices().first()
|
||||
.firstOrNull { it.address == address }
|
||||
if (bonded == null) {
|
||||
log(TAG) { "AAP reconnect: $address no longer bonded, stopping" }
|
||||
break
|
||||
}
|
||||
|
||||
// Check if still visible in BLE
|
||||
val bleDevices = blePodMonitor.devices.first()
|
||||
if (bleDevices.none { it.address == address }) {
|
||||
log(TAG) { "AAP reconnect: $address no longer visible in BLE, stopping" }
|
||||
break
|
||||
}
|
||||
|
||||
// Check if already reconnected (e.g., by initialConnect)
|
||||
val currentState = aapManager.allStates.value[address]
|
||||
if (currentState != null && currentState.connectionState != AapPodState.ConnectionState.DISCONNECTED) {
|
||||
log(TAG) { "AAP reconnect: $address already reconnected" }
|
||||
break
|
||||
}
|
||||
|
||||
try {
|
||||
log(TAG) { "AAP reconnect attempt ${attempt + 1} for $address in ${delayMs}ms" }
|
||||
aapManager.connect(address, bonded.internal, profile.model)
|
||||
log(TAG) { "AAP reconnected to $address" }
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
log(TAG, WARN) { "AAP reconnect attempt ${attempt + 1} failed for $address: ${e.message}" }
|
||||
}
|
||||
}
|
||||
|
||||
activeReconnects.remove(address)
|
||||
}
|
||||
.map { } // SharedFlow<BluetoothAddress> → Flow<Unit>
|
||||
.setupCommonEventHandlers(TAG) { "reconnect" }
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Reaction", "AapAutoConnect")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package eu.darken.capod.reaction.core.aap
|
||||
|
||||
import eu.darken.capod.common.debug.logging.log
|
||||
import eu.darken.capod.common.debug.logging.logTag
|
||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapConnectionManager
|
||||
import eu.darken.capod.profiles.core.AppleDeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Persists IRK/ENC keys from AAP key exchange to [AppleDeviceProfile],
|
||||
* enabling BLE encrypted battery decryption with 1% granularity.
|
||||
*/
|
||||
@Singleton
|
||||
class AapKeyPersister @Inject constructor(
|
||||
private val aapManager: AapConnectionManager,
|
||||
private val profilesRepo: DeviceProfilesRepo,
|
||||
) {
|
||||
fun monitor(): Flow<Unit> = aapManager.keysReceived
|
||||
.onEach { (address, keys) ->
|
||||
val profile = profilesRepo.profiles.first()
|
||||
.filterIsInstance<AppleDeviceProfile>()
|
||||
.firstOrNull { it.address == address }
|
||||
|
||||
if (profile == null) {
|
||||
log(TAG) { "No profile found for $address, skipping key persistence" }
|
||||
return@onEach
|
||||
}
|
||||
|
||||
val updated = profile.copy(
|
||||
identityKey = keys.irk ?: profile.identityKey,
|
||||
encryptionKey = keys.encKey ?: profile.encryptionKey,
|
||||
)
|
||||
|
||||
if (updated != profile) {
|
||||
profilesRepo.updateProfile(updated)
|
||||
log(TAG) { "Persisted keys for $address (IRK=${keys.irk != null}, ENC=${keys.encKey != null})" }
|
||||
}
|
||||
}
|
||||
.map { }
|
||||
.setupCommonEventHandlers(TAG) { "keyPersister" }
|
||||
|
||||
companion object {
|
||||
private val TAG = logTag("Reaction", "AapKeyPersister")
|
||||
}
|
||||
}
|
||||
@@ -258,6 +258,11 @@
|
||||
<string name="pods_charging_label">Charging</string>
|
||||
<string name="pods_inear_label">In ear</string>
|
||||
<string name="pods_microphone_label">Microphone</string>
|
||||
<string name="anc_mode_off">Off</string>
|
||||
<string name="anc_mode_on">ANC</string>
|
||||
<string name="anc_mode_transparency">Transparency</string>
|
||||
<string name="anc_mode_adaptive">Adaptive</string>
|
||||
<string name="conversation_awareness_label">Conversation Awareness</string>
|
||||
<string name="pods_yours">Yours</string>
|
||||
<string name="headset_being_worn_label">Being worn</string>
|
||||
<string name="headset_not_being_worn_label">Not being worn</string>
|
||||
|
||||
@@ -125,6 +125,7 @@ class OverviewViewModelTest : BaseTest() {
|
||||
upgradeRepo = upgradeRepo,
|
||||
bluetoothManager = bluetoothManager,
|
||||
profilesRepo = profilesRepo,
|
||||
aapManager = mockk(relaxed = true),
|
||||
)
|
||||
|
||||
@Nested
|
||||
|
||||
@@ -7,10 +7,9 @@ import eu.darken.capod.pods.core.HasEarDetection
|
||||
import eu.darken.capod.pods.core.HasEarDetectionDual
|
||||
import eu.darken.capod.pods.core.PodModel
|
||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapConnectionState
|
||||
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapPodState
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AncModeValue
|
||||
import io.kotest.matchers.nulls.shouldBeNull
|
||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||
import io.kotest.matchers.shouldBe
|
||||
@@ -69,18 +68,18 @@ class PodDeviceTest : BaseTest() {
|
||||
@Test
|
||||
fun `AAP settings are available when connected`() {
|
||||
val aap = AapPodState(
|
||||
connectionState = AapConnectionState.READY,
|
||||
connectionState = AapPodState.ConnectionState.READY,
|
||||
settings = mapOf(
|
||||
AapSetting.AncMode::class to AapSetting.AncMode(
|
||||
AncModeValue.TRANSPARENCY,
|
||||
listOf(AncModeValue.ON, AncModeValue.TRANSPARENCY, AncModeValue.ADAPTIVE),
|
||||
AapSetting.AncMode.Value.TRANSPARENCY,
|
||||
listOf(AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.TRANSPARENCY, AapSetting.AncMode.Value.ADAPTIVE),
|
||||
),
|
||||
),
|
||||
)
|
||||
val device = PodDevice(ble = mockDualPod(), aap = aap)
|
||||
device.isAapConnected shouldBe true
|
||||
device.ancMode.shouldNotBeNull()
|
||||
device.ancMode!!.current shouldBe AncModeValue.TRANSPARENCY
|
||||
device.ancMode!!.current shouldBe AapSetting.AncMode.Value.TRANSPARENCY
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -192,4 +191,57 @@ class PodDeviceTest : BaseTest() {
|
||||
val device = PodDevice(ble = null, aap = null)
|
||||
device.rawDataHex shouldBe emptyList()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `battery falls back to BLE when AAP battery is null`() {
|
||||
val aap = AapPodState(connectionState = AapPodState.ConnectionState.READY)
|
||||
val device = PodDevice(ble = mockDualPod(leftBattery = 0.8f), aap = aap)
|
||||
device.batteryLeft shouldBe 0.8f
|
||||
device.isAapConnected shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AAP battery preferred over BLE battery`() {
|
||||
val aap = AapPodState(
|
||||
connectionState = AapPodState.ConnectionState.READY,
|
||||
batteries = mapOf(
|
||||
AapPodState.BatteryType.LEFT to AapPodState.Battery(AapPodState.BatteryType.LEFT, 0.79f, AapPodState.ChargingState.NOT_CHARGING),
|
||||
),
|
||||
)
|
||||
val device = PodDevice(ble = mockDualPod(leftBattery = 0.8f), aap = aap)
|
||||
device.batteryLeft shouldBe 0.79f // AAP 1% granularity wins over BLE 10%
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AAP charging preferred over BLE charging`() {
|
||||
val mock = mockk<DualApplePods>(relaxed = true) {
|
||||
every { model } returns PodModel.AIRPODS_PRO3
|
||||
every { (this@mockk as HasChargeDetectionDual).isLeftPodCharging } returns false
|
||||
}
|
||||
val aap = AapPodState(
|
||||
connectionState = AapPodState.ConnectionState.READY,
|
||||
batteries = mapOf(
|
||||
AapPodState.BatteryType.LEFT to AapPodState.Battery(AapPodState.BatteryType.LEFT, 0.8f, AapPodState.ChargingState.CHARGING_OPTIMIZED),
|
||||
),
|
||||
)
|
||||
val device = PodDevice(ble = mock, aap = aap)
|
||||
device.isLeftPodCharging shouldBe true // AAP CHARGING_OPTIMIZED counts as charging
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address returns bonded address from profile, not BLE RPA`() {
|
||||
val bondedAddress = "CC:22:FE:25:69:63"
|
||||
val bleRpa = "5A:3B:1C:2D:4E:6F"
|
||||
val profile = mockk<eu.darken.capod.profiles.core.AppleDeviceProfile>(relaxed = true) {
|
||||
every { address } returns bondedAddress
|
||||
}
|
||||
val ble = mockk<DualApplePods>(relaxed = true) {
|
||||
every { model } returns PodModel.AIRPODS_PRO3
|
||||
every { this@mockk.address } returns bleRpa
|
||||
every { meta } returns eu.darken.capod.pods.core.apple.ApplePods.AppleMeta(profile = profile)
|
||||
}
|
||||
val device = PodDevice(ble = ble, aap = null)
|
||||
device.address shouldBe bondedAddress
|
||||
device.bleAddress shouldBe bleRpa
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import io.kotest.matchers.types.instanceOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class AirPodsFactoryTest : BaseAirPodsTest() {
|
||||
class AirPodsFactoryTest : BaseBlePodsTest() {
|
||||
|
||||
@Test
|
||||
fun `create AirPodsGen1`() = runTest {
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ import testhelpers.BaseTest
|
||||
import java.time.Instant
|
||||
import javax.inject.Singleton
|
||||
|
||||
abstract class BaseAirPodsTest : BaseTest() {
|
||||
abstract class BaseBlePodsTest : BaseTest() {
|
||||
@Singleton
|
||||
@Component(modules = [AppleFactoryModule::class, SerializationModule::class])
|
||||
interface AppleFactoryTestComponent {
|
||||
@@ -4,7 +4,7 @@ import io.kotest.matchers.shouldBe
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class BasicSingleApplePodsTest : BaseAirPodsTest() {
|
||||
class BasicSingleApplePodsTest : BaseBlePodsTest() {
|
||||
|
||||
@Test
|
||||
fun `test mapping`() = runTest {
|
||||
|
||||
@@ -5,7 +5,7 @@ import io.kotest.matchers.shouldBe
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class DualApplePodsTest : BaseAirPodsTest() {
|
||||
class DualApplePodsTest : BaseBlePodsTest() {
|
||||
|
||||
@Test
|
||||
fun `test bit mapping`() = runTest {
|
||||
|
||||
@@ -4,7 +4,7 @@ import io.kotest.matchers.shouldBe
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class SingleApplePodsTest : BaseAirPodsTest() {
|
||||
class SingleApplePodsTest : BaseBlePodsTest() {
|
||||
|
||||
@Test
|
||||
fun `default bit mapping Max`() = runTest {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
package eu.darken.capod.pods.core.apple.airpods
|
||||
|
||||
import eu.darken.capod.pods.core.PodModel
|
||||
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
|
||||
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
|
||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
||||
import eu.darken.capod.pods.core.apple.HasAppleColor
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class AirPodsGen1Test : BaseAirPodsTest() {
|
||||
class AirPodsGen1Test : BaseBlePodsTest() {
|
||||
|
||||
// Test data from https://github.com/adolfintel/OpenPods/issues/39#issuecomment-557664269
|
||||
@Test
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
package eu.darken.capod.pods.core.apple.airpods
|
||||
|
||||
import eu.darken.capod.pods.core.PodModel
|
||||
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
|
||||
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
|
||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
||||
import eu.darken.capod.pods.core.apple.HasAppleColor
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class AirPodsGen2Test : BaseAirPodsTest() {
|
||||
class AirPodsGen2Test : BaseBlePodsTest() {
|
||||
|
||||
@Test
|
||||
fun `random Neighbor AirPodsGen2`() = runTest {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
package eu.darken.capod.pods.core.apple.airpods
|
||||
|
||||
import eu.darken.capod.pods.core.PodModel
|
||||
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
|
||||
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
|
||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
||||
import eu.darken.capod.pods.core.apple.HasAppleColor
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class AirPodsGen3Test : BaseAirPodsTest() {
|
||||
class AirPodsGen3Test : BaseBlePodsTest() {
|
||||
|
||||
@Test
|
||||
fun `AirPods Gen3`() = runTest {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
package eu.darken.capod.pods.core.apple.airpods
|
||||
|
||||
import eu.darken.capod.pods.core.PodModel
|
||||
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
|
||||
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
|
||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
||||
import eu.darken.capod.pods.core.apple.HasAppleColor
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class AirPodsGen4AncTest : BaseAirPodsTest() {
|
||||
class AirPodsGen4AncTest : BaseBlePodsTest() {
|
||||
|
||||
@Test
|
||||
fun `AirPods Gen4 with ANC via log from #226`() = runTest {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
package eu.darken.capod.pods.core.apple.airpods
|
||||
|
||||
import eu.darken.capod.pods.core.PodModel
|
||||
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
|
||||
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
|
||||
import eu.darken.capod.pods.core.apple.DualApplePods
|
||||
import eu.darken.capod.pods.core.apple.HasAppleColor
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class AirPodsGen4Test : BaseAirPodsTest() {
|
||||
class AirPodsGen4Test : BaseBlePodsTest() {
|
||||
|
||||
@Test
|
||||
fun `AirPods Gen4 via log from #225`() = runTest {
|
||||
|
||||
@@ -2,13 +2,13 @@ package eu.darken.capod.pods.core.apple.airpods
|
||||
|
||||
import eu.darken.capod.common.isBitSet
|
||||
import eu.darken.capod.pods.core.PodModel
|
||||
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
|
||||
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
|
||||
import eu.darken.capod.pods.core.apple.HasAppleColor
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class AirPodsMaxTest : BaseAirPodsTest() {
|
||||
class AirPodsMaxTest : BaseBlePodsTest() {
|
||||
|
||||
// Test data from https://github.com/adolfintel/OpenPods/issues/124
|
||||
@Test
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package eu.darken.capod.pods.core.apple.airpods
|
||||
|
||||
import eu.darken.capod.pods.core.PodModel
|
||||
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
|
||||
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class AirPodsMaxUsbcTest : BaseAirPodsTest() {
|
||||
class AirPodsMaxUsbcTest : BaseBlePodsTest() {
|
||||
|
||||
// Test data from https://github.com/d4rken-org/capod/issues/236
|
||||
@Test
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package eu.darken.capod.pods.core.apple.airpods
|
||||
|
||||
import eu.darken.capod.pods.core.PodModel
|
||||
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
|
||||
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
|
||||
import eu.darken.capod.pods.core.apple.HasAppleColor
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class AirPodsPro2Test : BaseAirPodsTest() {
|
||||
class AirPodsPro2Test : BaseBlePodsTest() {
|
||||
|
||||
/**
|
||||
* https://github.com/d4rken-org/capod/issues/31#issuecomment-1256791084
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package eu.darken.capod.pods.core.apple.airpods
|
||||
|
||||
import eu.darken.capod.pods.core.PodModel
|
||||
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
|
||||
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
|
||||
import eu.darken.capod.pods.core.apple.HasAppleColor
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class AirPodsPro2UsbcTest : BaseAirPodsTest() {
|
||||
class AirPodsPro2UsbcTest : BaseBlePodsTest() {
|
||||
|
||||
/**
|
||||
* https://github.com/d4rken-org/capod/issues/164
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package eu.darken.capod.pods.core.apple.airpods
|
||||
|
||||
import eu.darken.capod.pods.core.PodModel
|
||||
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
|
||||
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
|
||||
import eu.darken.capod.pods.core.apple.HasAppleColor
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class AirPodsPro3Test : BaseAirPodsTest() {
|
||||
class AirPodsPro3Test : BaseBlePodsTest() {
|
||||
|
||||
/**
|
||||
* Test case for AirPods Pro 3 - placeholder with correct device code
|
||||
|
||||
@@ -2,14 +2,14 @@ package eu.darken.capod.pods.core.apple.airpods
|
||||
|
||||
import eu.darken.capod.common.toHex
|
||||
import eu.darken.capod.pods.core.PodModel
|
||||
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
|
||||
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
|
||||
import eu.darken.capod.pods.core.apple.HasAppleColor
|
||||
import eu.darken.capod.profiles.core.AppleDeviceProfile
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class AirPodsProTest : BaseAirPodsTest() {
|
||||
class AirPodsProTest : BaseBlePodsTest() {
|
||||
|
||||
@Test
|
||||
fun `test AirPods Pro - default changed and in case`() = runTest {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package eu.darken.capod.pods.core.apple.beats
|
||||
|
||||
import eu.darken.capod.pods.core.PodModel
|
||||
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
|
||||
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
|
||||
import eu.darken.capod.pods.core.apple.HasAppleColor
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class BeatsFitProTest : BaseAirPodsTest() {
|
||||
class BeatsFitProTest : BaseBlePodsTest() {
|
||||
|
||||
/**
|
||||
* From https://github.com/d4rken-org/capod/issues/33#issuecomment-1256235651
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package eu.darken.capod.pods.core.apple.beats
|
||||
|
||||
import eu.darken.capod.pods.core.PodModel
|
||||
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
|
||||
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class BeatsFlexText : BaseAirPodsTest() {
|
||||
class BeatsFlexText : BaseBlePodsTest() {
|
||||
|
||||
// Raw data from https://github.com/adolfintel/OpenPods/issues/105
|
||||
@Test
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package eu.darken.capod.pods.core.apple.beats
|
||||
|
||||
import eu.darken.capod.pods.core.PodModel
|
||||
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
|
||||
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class BeatsSolo3Test : BaseAirPodsTest() {
|
||||
class BeatsSolo3Test : BaseBlePodsTest() {
|
||||
|
||||
// TODO This is handcrafted data, get actual data for tests
|
||||
@Test
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package eu.darken.capod.pods.core.apple.beats
|
||||
|
||||
import eu.darken.capod.pods.core.PodModel
|
||||
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
|
||||
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class BeatsStudio3Test : BaseAirPodsTest() {
|
||||
class BeatsStudio3Test : BaseBlePodsTest() {
|
||||
|
||||
// TODO This is handcrafted data, get actual data for tests
|
||||
@Test
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package eu.darken.capod.pods.core.apple.beats
|
||||
|
||||
import eu.darken.capod.pods.core.PodModel
|
||||
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
|
||||
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class BeatsXTest : BaseAirPodsTest() {
|
||||
class BeatsXTest : BaseBlePodsTest() {
|
||||
|
||||
// Raw data from https://github.com/adolfintel/OpenPods/issues/105
|
||||
@Test
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package eu.darken.capod.pods.core.apple.beats
|
||||
|
||||
import eu.darken.capod.pods.core.PodModel
|
||||
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
|
||||
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class PowerBeats3Test : BaseAirPodsTest() {
|
||||
class PowerBeats3Test : BaseBlePodsTest() {
|
||||
|
||||
// TODO This is handcrafted data, get actual data for tests
|
||||
@Test
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package eu.darken.capod.pods.core.apple.beats
|
||||
|
||||
import eu.darken.capod.pods.core.PodModel
|
||||
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
|
||||
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
|
||||
import eu.darken.capod.pods.core.apple.airpods.HasStateDetectionAirPods
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class PowerBeats4Test : BaseAirPodsTest() {
|
||||
class PowerBeats4Test : BaseBlePodsTest() {
|
||||
|
||||
@Test
|
||||
fun `playing music`() = runTest {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package eu.darken.capod.pods.core.apple.beats
|
||||
|
||||
import eu.darken.capod.pods.core.PodModel
|
||||
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
|
||||
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
|
||||
import eu.darken.capod.pods.core.apple.HasAppleColor
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class PowerBeatsPro2Test : BaseAirPodsTest() {
|
||||
class PowerBeatsPro2Test : BaseBlePodsTest() {
|
||||
|
||||
@Test
|
||||
fun `test PowerBeatsPro2`() = runTest {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package eu.darken.capod.pods.core.apple.beats
|
||||
|
||||
import eu.darken.capod.pods.core.PodModel
|
||||
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
|
||||
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
|
||||
import eu.darken.capod.pods.core.apple.HasAppleColor
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class PowerBeatsProTest : BaseAirPodsTest() {
|
||||
class PowerBeatsProTest : BaseBlePodsTest() {
|
||||
|
||||
@Test
|
||||
fun `test PowerBeatsPro`() = runTest {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package eu.darken.capod.pods.core.apple.misc
|
||||
|
||||
import eu.darken.capod.pods.core.PodModel
|
||||
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
|
||||
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class FakeAirPodsGen1Test : BaseAirPodsTest() {
|
||||
class FakeAirPodsGen1Test : BaseBlePodsTest() {
|
||||
|
||||
@Test
|
||||
fun `charging in box`() = runTest {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package eu.darken.capod.pods.core.apple.misc
|
||||
|
||||
import eu.darken.capod.pods.core.PodModel
|
||||
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
|
||||
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class FakeAirPodsGen2Test : BaseAirPodsTest() {
|
||||
class FakeAirPodsGen2Test : BaseBlePodsTest() {
|
||||
|
||||
@Test
|
||||
fun `charging in box`() = runTest {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package eu.darken.capod.pods.core.apple.misc
|
||||
|
||||
import eu.darken.capod.pods.core.PodModel
|
||||
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
|
||||
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class FakeAirPodsGen3Test : BaseAirPodsTest() {
|
||||
class FakeAirPodsGen3Test : BaseBlePodsTest() {
|
||||
|
||||
@Test
|
||||
fun `charging in case`() = runTest {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package eu.darken.capod.pods.core.apple.misc
|
||||
|
||||
import eu.darken.capod.pods.core.PodModel
|
||||
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
|
||||
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class FakeAirPodsPro2Test : BaseAirPodsTest() {
|
||||
class FakeAirPodsPro2Test : BaseBlePodsTest() {
|
||||
|
||||
@Test
|
||||
fun `guessed data`() = runTest {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package eu.darken.capod.pods.core.apple.misc
|
||||
|
||||
import eu.darken.capod.pods.core.PodModel
|
||||
import eu.darken.capod.pods.core.apple.BaseAirPodsTest
|
||||
import eu.darken.capod.pods.core.apple.BaseBlePodsTest
|
||||
import io.kotest.matchers.shouldBe
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
class FakeAirPodsProTest : BaseAirPodsTest() {
|
||||
class FakeAirPodsProTest : BaseBlePodsTest() {
|
||||
|
||||
@Test
|
||||
fun `guessed data`() = runTest {
|
||||
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package eu.darken.capod.pods.core.apple.protocol.aap
|
||||
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothSocket
|
||||
import eu.darken.capod.common.bluetooth.l2cap.L2capSocketFactory
|
||||
import eu.darken.capod.pods.core.PodModel
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.matchers.maps.shouldBeEmpty
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
import testhelpers.BaseTest
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class AapConnectionManagerTest : BaseTest() {
|
||||
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
private val testScope = TestScope(testDispatcher)
|
||||
|
||||
private lateinit var socketFactory: L2capSocketFactory
|
||||
private lateinit var manager: AapConnectionManager
|
||||
|
||||
private val testAddress = "AA:BB:CC:DD:EE:FF"
|
||||
private val testDevice: BluetoothDevice = mockk(relaxed = true) {
|
||||
every { address } returns testAddress
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
socketFactory = mockk(relaxed = true)
|
||||
manager = AapConnectionManager(
|
||||
socketFactory = socketFactory,
|
||||
scope = testScope,
|
||||
)
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class InitialState {
|
||||
@Test
|
||||
fun `allStates starts empty`() = testScope.runTest {
|
||||
manager.allStates.value.shouldBeEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class SendCommand {
|
||||
@Test
|
||||
fun `sendCommand throws when not connected`() = testScope.runTest {
|
||||
shouldThrow<IllegalStateException> {
|
||||
manager.sendCommand(testAddress, AapCommand.SetAncMode(AapSetting.AncMode.Value.ON))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class Disconnect {
|
||||
@Test
|
||||
fun `disconnect on unknown address is no-op`() = testScope.runTest {
|
||||
manager.disconnect(testAddress)
|
||||
manager.allStates.value.shouldBeEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class Connect {
|
||||
@Test
|
||||
fun `connect failure cleans up state`() = testScope.runTest {
|
||||
every { socketFactory.createSocket(any(), any()) } throws IOException("Connection refused")
|
||||
|
||||
shouldThrow<IOException> {
|
||||
manager.connect(testAddress, testDevice, PodModel.AIRPODS_PRO3)
|
||||
}
|
||||
|
||||
manager.allStates.value.shouldBeEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remote disconnect cleans up allStates`() = testScope.runTest {
|
||||
// Empty inputStream → readLoop gets -1 immediately → DISCONNECTED
|
||||
val socket = mockk<BluetoothSocket>(relaxed = true) {
|
||||
every { outputStream } returns ByteArrayOutputStream()
|
||||
every { inputStream } returns ByteArrayInputStream(byteArrayOf())
|
||||
}
|
||||
every { socketFactory.createSocket(any(), any()) } returns socket
|
||||
|
||||
manager.connect(testAddress, testDevice, PodModel.AIRPODS_PRO3)
|
||||
advanceUntilIdle()
|
||||
|
||||
// readLoop exited, collector cleaned up
|
||||
manager.allStates.value.containsKey(testAddress) shouldBe false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `can reconnect after remote disconnect`() = testScope.runTest {
|
||||
val socket = mockk<BluetoothSocket>(relaxed = true) {
|
||||
every { outputStream } returns ByteArrayOutputStream()
|
||||
every { inputStream } returns ByteArrayInputStream(byteArrayOf())
|
||||
}
|
||||
every { socketFactory.createSocket(any(), any()) } returns socket
|
||||
|
||||
manager.connect(testAddress, testDevice, PodModel.AIRPODS_PRO3)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Stale entry should be cleaned up — second connect should not throw "Already connected"
|
||||
manager.connect(testAddress, testDevice, PodModel.AIRPODS_PRO3)
|
||||
advanceUntilIdle()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,61 +11,137 @@ class AapPodStateTest : BaseTest() {
|
||||
@Test
|
||||
fun `setting lookup returns typed setting`() {
|
||||
val state = AapPodState(
|
||||
connectionState = AapConnectionState.READY,
|
||||
connectionState = AapPodState.ConnectionState.READY,
|
||||
settings = mapOf(
|
||||
AapSetting.AncMode::class to AapSetting.AncMode(AncModeValue.ON, listOf(AncModeValue.ON, AncModeValue.TRANSPARENCY)),
|
||||
AapSetting.AncMode::class to AapSetting.AncMode(
|
||||
AapSetting.AncMode.Value.ON,
|
||||
listOf(AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.TRANSPARENCY),
|
||||
),
|
||||
)
|
||||
)
|
||||
val anc = state.setting<AapSetting.AncMode>()
|
||||
anc.shouldNotBeNull()
|
||||
anc.current shouldBe AncModeValue.ON
|
||||
anc.current shouldBe AapSetting.AncMode.Value.ON
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setting lookup returns null for missing type`() {
|
||||
val state = AapPodState(connectionState = AapConnectionState.READY)
|
||||
val state = AapPodState(connectionState = AapPodState.ConnectionState.READY)
|
||||
state.setting<AapSetting.AncMode>().shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `withSetting merges into existing settings`() {
|
||||
val state = AapPodState(
|
||||
connectionState = AapConnectionState.READY,
|
||||
connectionState = AapPodState.ConnectionState.READY,
|
||||
settings = mapOf(
|
||||
AapSetting.AncMode::class to AapSetting.AncMode(AncModeValue.ON, listOf(AncModeValue.ON)),
|
||||
AapSetting.AncMode::class to AapSetting.AncMode(AapSetting.AncMode.Value.ON, listOf(AapSetting.AncMode.Value.ON)),
|
||||
)
|
||||
)
|
||||
val updated = state.withSetting(
|
||||
AapSetting.ConversationalAwareness::class,
|
||||
AapSetting.ConversationalAwareness(true),
|
||||
)
|
||||
// Original setting preserved
|
||||
updated.setting<AapSetting.AncMode>().shouldNotBeNull()
|
||||
// New setting added
|
||||
updated.setting<AapSetting.ConversationalAwareness>()!!.enabled shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `withSetting replaces existing setting of same type`() {
|
||||
val state = AapPodState(
|
||||
connectionState = AapConnectionState.READY,
|
||||
connectionState = AapPodState.ConnectionState.READY,
|
||||
settings = mapOf(
|
||||
AapSetting.AncMode::class to AapSetting.AncMode(AncModeValue.ON, listOf(AncModeValue.ON)),
|
||||
AapSetting.AncMode::class to AapSetting.AncMode(AapSetting.AncMode.Value.ON, listOf(AapSetting.AncMode.Value.ON)),
|
||||
)
|
||||
)
|
||||
val updated = state.withSetting(
|
||||
AapSetting.AncMode::class,
|
||||
AapSetting.AncMode(AncModeValue.TRANSPARENCY, listOf(AncModeValue.ON, AncModeValue.TRANSPARENCY)),
|
||||
AapSetting.AncMode(AapSetting.AncMode.Value.TRANSPARENCY, listOf(AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.TRANSPARENCY)),
|
||||
)
|
||||
updated.setting<AapSetting.AncMode>()!!.current shouldBe AncModeValue.TRANSPARENCY
|
||||
updated.setting<AapSetting.AncMode>()!!.current shouldBe AapSetting.AncMode.Value.TRANSPARENCY
|
||||
updated.settings.size shouldBe 1
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `default state is disconnected with no data`() {
|
||||
val state = AapPodState()
|
||||
state.connectionState shouldBe AapConnectionState.DISCONNECTED
|
||||
state.connectionState shouldBe AapPodState.ConnectionState.DISCONNECTED
|
||||
state.deviceInfo.shouldBeNull()
|
||||
state.settings shouldBe emptyMap()
|
||||
state.batteries shouldBe emptyMap()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `battery accessors map to correct types`() {
|
||||
val state = AapPodState(
|
||||
batteries = mapOf(
|
||||
AapPodState.BatteryType.LEFT to AapPodState.Battery(AapPodState.BatteryType.LEFT, 0.8f, AapPodState.ChargingState.NOT_CHARGING),
|
||||
AapPodState.BatteryType.RIGHT to AapPodState.Battery(AapPodState.BatteryType.RIGHT, 0.77f, AapPodState.ChargingState.CHARGING),
|
||||
AapPodState.BatteryType.CASE to AapPodState.Battery(AapPodState.BatteryType.CASE, 0.48f, AapPodState.ChargingState.CHARGING),
|
||||
),
|
||||
)
|
||||
state.batteryLeft shouldBe 0.8f
|
||||
state.batteryRight shouldBe 0.77f
|
||||
state.batteryCase shouldBe 0.48f
|
||||
state.batteryHeadset.shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `battery accessors null when type not present`() {
|
||||
val state = AapPodState()
|
||||
state.batteryLeft.shouldBeNull()
|
||||
state.batteryRight.shouldBeNull()
|
||||
state.batteryCase.shouldBeNull()
|
||||
state.batteryHeadset.shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `headset battery for single-pod devices`() {
|
||||
val state = AapPodState(
|
||||
batteries = mapOf(
|
||||
AapPodState.BatteryType.SINGLE to AapPodState.Battery(AapPodState.BatteryType.SINGLE, 0.6f, AapPodState.ChargingState.NOT_CHARGING),
|
||||
),
|
||||
)
|
||||
state.batteryHeadset shouldBe 0.6f
|
||||
state.batteryLeft.shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isCharging true for CHARGING state`() {
|
||||
val state = AapPodState(
|
||||
batteries = mapOf(
|
||||
AapPodState.BatteryType.LEFT to AapPodState.Battery(AapPodState.BatteryType.LEFT, 0.8f, AapPodState.ChargingState.CHARGING),
|
||||
),
|
||||
)
|
||||
state.isLeftCharging shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isCharging true for CHARGING_OPTIMIZED state`() {
|
||||
val state = AapPodState(
|
||||
batteries = mapOf(
|
||||
AapPodState.BatteryType.LEFT to AapPodState.Battery(AapPodState.BatteryType.LEFT, 0.8f, AapPodState.ChargingState.CHARGING_OPTIMIZED),
|
||||
),
|
||||
)
|
||||
state.isLeftCharging shouldBe true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isCharging false for NOT_CHARGING`() {
|
||||
val state = AapPodState(
|
||||
batteries = mapOf(
|
||||
AapPodState.BatteryType.LEFT to AapPodState.Battery(AapPodState.BatteryType.LEFT, 0.8f, AapPodState.ChargingState.NOT_CHARGING),
|
||||
),
|
||||
)
|
||||
state.isLeftCharging shouldBe false
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isCharging null when battery type absent`() {
|
||||
val state = AapPodState()
|
||||
state.isLeftCharging.shouldBeNull()
|
||||
state.isRightCharging.shouldBeNull()
|
||||
state.isCaseCharging.shouldBeNull()
|
||||
state.isHeadsetCharging.shouldBeNull()
|
||||
}
|
||||
}
|
||||
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
package eu.darken.capod.pods.core.apple.protocol.aap
|
||||
|
||||
import eu.darken.capod.pods.core.PodModel
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapPodState.BatteryType
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapPodState.ChargingState
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.AdaptiveAudioNoise
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.AncMode
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.ConversationalAwareness
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.ConversationalAwarenessState
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.EndCallMuteMic
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.EndCallMuteMic.EndCallMode
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.EndCallMuteMic.MuteMicMode
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.NcWithOneAirPod
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.PersonalizedVolume
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.PressHoldDuration
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.PressSpeed
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.ToneVolume
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.VolumeSwipe
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.VolumeSwipeLength
|
||||
import io.kotest.matchers.collections.shouldContainExactly
|
||||
import io.kotest.matchers.nulls.shouldBeNull
|
||||
import io.kotest.matchers.shouldBe
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* Tests the full AAP decode pipeline using real captured data from AirPods Pro 3.
|
||||
* Each test uses exact bytes observed on a live device via L2CAP SEQPACKET reads.
|
||||
*
|
||||
* Device: AirPods Pro 3 (model A3064, product ID 0x2720)
|
||||
* Phone: Pixel 8 (Android 17)
|
||||
* Captured: 2026-03-30
|
||||
*/
|
||||
class AirPodsPro3AapSessionTest : BaseAapSessionTest() {
|
||||
|
||||
override val podModel = PodModel.AIRPODS_PRO3
|
||||
|
||||
// ── Handshake ────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `handshake response - 18 bytes`() {
|
||||
val msg = aapMessage("01 00 04 00 00 00 01 00 03 00 00 00 00 00 00 00 00 00")
|
||||
msg.commandType shouldBe 0x0000
|
||||
msg.raw.size shouldBe 18
|
||||
msg.payload.size shouldBe 12
|
||||
}
|
||||
|
||||
// ── Device Info ──────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `device info - 246 bytes`() {
|
||||
val msg = aapMessage(
|
||||
"04 00 04 00 1D 00 02 ED 00 04 00",
|
||||
"41 69 72 50 6F 64 73 20 50 72 6F 20 33 00", // "AirPods Pro 3"
|
||||
"41 33 30 36 34 00", // "A3064"
|
||||
"41 70 70 6C 65 20 49 6E 63 2E 00", // "Apple Inc."
|
||||
"46 59 4C 39 37 33 30 33 54 39 00", // serial
|
||||
"38 31 2E 32 36 37 35 30 30 30 30 37 35 30 30 30 30 30 30 2E 36 35 30 33 00", // firmware
|
||||
"38 31 2E 32 36 37 35 30 30 30 30 37 35 30 30 30 30 30 30 2E 36 35 30 33 00",
|
||||
"31 2E 30 2E 30 00",
|
||||
"63 6F 6D 2E 61 70 70 6C 65 2E 61 63 63 65 73 73 6F 72 79 2E 75 70 64 61 74 65 72 2E 61 70 70 2E 37 31 00",
|
||||
"47 4D 50 48 4E 5A 31 36 50 35 5A 30 30 30 30 55 48 5A 00", // left serial
|
||||
"47 4D 56 48 4E 58 31 35 55 45 44 30 30 30 30 55 48 59 00", // right serial
|
||||
"38 34 35 34 36 32 34 00",
|
||||
"AE 84 20 32 A7 3E 46 46 A8 EA 0B 13 A9 27 8B 79 0D D3 55 98 AC 3D 1A 4F 2F 89 BE 5C 65 F5 FF 1C D9 AE",
|
||||
"31 37 36 37 33 36 34 30 37 34 00 31 37 36 37 33 36 34 30 37 34 00",
|
||||
)
|
||||
val info = profile.decodeDeviceInfo(msg)!!
|
||||
info.name shouldBe "AirPods Pro 3"
|
||||
info.modelNumber shouldBe "A3064"
|
||||
info.manufacturer shouldBe "Apple Inc."
|
||||
}
|
||||
|
||||
// ── Battery ──────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
inner class BatterySessionTests {
|
||||
@Test
|
||||
fun `in case charging - pods CHARGING_OPTIMIZED, case CHARGING`() {
|
||||
val r =
|
||||
profile.decodeBattery(aapMessage("04 00 04 00 04 00 03 04 01 50 05 01 02 01 4F 05 01 08 01 30 01 01"))!!
|
||||
r[BatteryType.LEFT]!!.let { it.percent shouldBe 0.8f; it.charging shouldBe ChargingState.CHARGING_OPTIMIZED }
|
||||
r[BatteryType.RIGHT]!!.let { it.percent shouldBe 0.79f; it.charging shouldBe ChargingState.CHARGING_OPTIMIZED }
|
||||
r[BatteryType.CASE]!!.let { it.percent shouldBe 0.48f; it.charging shouldBe ChargingState.CHARGING }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `out of case - pods NOT_CHARGING, case DISCONNECTED`() {
|
||||
val r =
|
||||
profile.decodeBattery(aapMessage("04 00 04 00 04 00 03 02 01 4D 02 01 04 01 50 02 01 08 01 00 04 01"))!!
|
||||
r[BatteryType.RIGHT]!!.charging shouldBe ChargingState.NOT_CHARGING
|
||||
r[BatteryType.LEFT]!!.charging shouldBe ChargingState.NOT_CHARGING
|
||||
r[BatteryType.CASE]!!.charging shouldBe ChargingState.DISCONNECTED
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private Keys ─────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `private key response - 47 bytes with IRK and ENC`() {
|
||||
val result = profile.decodePrivateKeyResponse(
|
||||
aapMessage(
|
||||
"04 00 04 00 31 00 02 " +
|
||||
"01 00 10 00 7C 64 9F C2 6C C1 07 2F 07 A2 BD 34 3A FA 8B A1 " +
|
||||
"04 00 10 00 A3 52 94 92 78 52 5F F0 95 E3 A6 C7 10 32 29 8B"
|
||||
)
|
||||
)!!
|
||||
result.irk!!.size shouldBe 16
|
||||
result.irk!![0] shouldBe 0x7C.toByte()
|
||||
result.encKey!!.size shouldBe 16
|
||||
result.encKey!![0] shouldBe 0xA3.toByte()
|
||||
}
|
||||
|
||||
// ── Settings Push ────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
inner class SettingsSessionTests {
|
||||
@Test fun `ANC mode ON`() {
|
||||
val anc = decodeSetting<AncMode>("04 00 04 00 09 00 0D 02 00 00 00")
|
||||
anc.current shouldBe AncMode.Value.ON
|
||||
anc.supported shouldContainExactly listOf(
|
||||
AncMode.Value.ON,
|
||||
AncMode.Value.TRANSPARENCY,
|
||||
AncMode.Value.ADAPTIVE
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun `press hold duration DEFAULT`() {
|
||||
decodeSetting<PressHoldDuration>("04 00 04 00 09 00 18 00 00 00 00").value shouldBe PressHoldDuration.Value.DEFAULT
|
||||
}
|
||||
|
||||
@Test fun `press speed DEFAULT`() {
|
||||
decodeSetting<PressSpeed>("04 00 04 00 09 00 17 00 00 00 00").value shouldBe PressSpeed.Value.DEFAULT
|
||||
}
|
||||
|
||||
@Test fun `volume swipe ON`() {
|
||||
decodeSetting<VolumeSwipe>("04 00 04 00 09 00 25 01 00 00 00").enabled shouldBe true
|
||||
}
|
||||
|
||||
@Test fun `volume swipe length DEFAULT`() {
|
||||
decodeSetting<VolumeSwipeLength>("04 00 04 00 09 00 23 00 00 00 00").value shouldBe VolumeSwipeLength.Value.DEFAULT
|
||||
}
|
||||
|
||||
@Test fun `tone volume 80`() {
|
||||
decodeSetting<ToneVolume>("04 00 04 00 09 00 1F 50 50 00 00").level shouldBe 0x50
|
||||
}
|
||||
|
||||
@Test fun `end call mute mic - subtype 0x00`() {
|
||||
decodeSetting<EndCallMuteMic>("04 00 04 00 09 00 24 00 03 00 00").let { it.muteMic shouldBe MuteMicMode.DOUBLE_PRESS; it.endCall shouldBe EndCallMode.SINGLE_PRESS }
|
||||
}
|
||||
|
||||
@Test fun `conversational awareness OFF`() {
|
||||
decodeSetting<ConversationalAwareness>("04 00 04 00 09 00 28 02 00 00 00").enabled shouldBe false
|
||||
}
|
||||
|
||||
@Test fun `conversational awareness ON after reconnect`() {
|
||||
decodeSetting<ConversationalAwareness>("04 00 04 00 09 00 28 01 00 00 00").enabled shouldBe true
|
||||
}
|
||||
|
||||
@Test fun `personalized volume OFF`() {
|
||||
decodeSetting<PersonalizedVolume>("04 00 04 00 09 00 26 02 00 00 00").enabled shouldBe false
|
||||
}
|
||||
|
||||
@Test fun `adaptive audio noise 50`() {
|
||||
decodeSetting<AdaptiveAudioNoise>("04 00 04 00 09 00 2E 32 00 00 00").level shouldBe 0x32
|
||||
}
|
||||
|
||||
@Test fun `NC one airpod OFF`() {
|
||||
decodeSetting<NcWithOneAirPod>("04 00 04 00 09 00 1B 02 00 00 00").enabled shouldBe false
|
||||
}
|
||||
}
|
||||
|
||||
// ── ANC Mode Switching (verified audible) ────────────────
|
||||
|
||||
@Nested
|
||||
inner class AncModeSwitchingTests {
|
||||
@Test fun `device echoes TRANSPARENCY`() {
|
||||
decodeSetting<AncMode>("04 00 04 00 09 00 0D 03 00 00 00").current shouldBe AncMode.Value.TRANSPARENCY
|
||||
}
|
||||
|
||||
@Test fun `device echoes ADAPTIVE`() {
|
||||
decodeSetting<AncMode>("04 00 04 00 09 00 0D 04 00 00 00").current shouldBe AncMode.Value.ADAPTIVE
|
||||
}
|
||||
}
|
||||
|
||||
// ── Conversation Awareness State (0x4B) ──────────────────
|
||||
|
||||
@Test
|
||||
fun `conversation awareness state - not speaking`() {
|
||||
decodeSetting<ConversationalAwarenessState>("04 00 04 00 4B 00 00").speaking shouldBe false
|
||||
}
|
||||
|
||||
// ── Unhandled Messages ───────────────────────────────────
|
||||
|
||||
@Nested
|
||||
inner class UnhandledMessageTests {
|
||||
@Test fun `cmd 0x002B init exchange`() {
|
||||
profile.decodeSetting(aapMessage("04 00 04 00 2B 00 01 22 00 E9 B4 03")).shouldBeNull()
|
||||
}
|
||||
|
||||
@Test fun `cmd 0x0006 ear detection`() {
|
||||
profile.decodeSetting(aapMessage("04 00 04 00 06 00 02 02")).shouldBeNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown settings IDs return null`() {
|
||||
val unknownIds = listOf(0x29, 0x2C, 0x2F, 0x33, 0x30, 0x35, 0x3E, 0x37, 0x38, 0x3B)
|
||||
for (id in unknownIds) {
|
||||
profile.decodeSetting(settingsMessage(id, 0x01)).shouldBeNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package eu.darken.capod.pods.core.apple.protocol.aap
|
||||
|
||||
import eu.darken.capod.pods.core.PodModel
|
||||
import testhelpers.BaseTest
|
||||
|
||||
/**
|
||||
* Shared base for AAP protocol tests. Provides hex parsing, message builders,
|
||||
* and type-safe setting extraction — mirrors [eu.darken.capod.pods.core.apple.BaseBlePodsTest]
|
||||
* for the BLE path.
|
||||
*
|
||||
* Subclasses set [podModel] to get a correctly-configured [profile].
|
||||
*/
|
||||
abstract class BaseAapSessionTest : BaseTest() {
|
||||
|
||||
abstract val podModel: PodModel
|
||||
|
||||
protected val profile: DefaultAapDeviceProfile by lazy { DefaultAapDeviceProfile(podModel) }
|
||||
|
||||
// ── Hex parsing ──────────────────────────────────────────
|
||||
|
||||
/** Parse hex string(s) into an [AapMessage]. Multiple args are concatenated. */
|
||||
protected fun aapMessage(vararg hexParts: String): AapMessage {
|
||||
val bytes = hexParts.joinToString(" ")
|
||||
.split(" ")
|
||||
.filter { it.isNotBlank() }
|
||||
.map { it.toInt(16).toByte() }
|
||||
.toByteArray()
|
||||
return AapMessage.parse(bytes) ?: error("Failed to parse AapMessage from: ${hexParts.joinToString(" ")}")
|
||||
}
|
||||
|
||||
// ── Message builders (hide protocol header bytes) ────────
|
||||
|
||||
/**
|
||||
* Build a settings message: header + cmd 0x09 + settingId + values + padding.
|
||||
* The `04 00 04 00` header and `09 00` command type are added automatically.
|
||||
*/
|
||||
protected fun settingsMessage(settingId: Int, vararg values: Int): AapMessage {
|
||||
val bytes = byteArrayOf(
|
||||
0x04, 0x00, 0x04, 0x00, 0x09, 0x00,
|
||||
settingId.toByte(),
|
||||
*values.map { it.toByte() }.toByteArray(),
|
||||
0x00, 0x00, 0x00,
|
||||
)
|
||||
return AapMessage.parse(bytes)!!
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a battery message: header + cmd 0x04 + entryBytes.
|
||||
* [entryBytes] should start with the count byte followed by 5-byte entries.
|
||||
*/
|
||||
protected fun batteryMessage(vararg entryBytes: Int): AapMessage {
|
||||
val payload = entryBytes.map { it.toByte() }.toByteArray()
|
||||
val header = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x04, 0x00)
|
||||
return AapMessage.parse(header + payload)!!
|
||||
}
|
||||
|
||||
// ── Type-safe setting decode ─────────────────────────────
|
||||
|
||||
/** Decode a setting from an [AapMessage], asserting the result is non-null and of type [T]. */
|
||||
protected inline fun <reified T : AapSetting> decodeSetting(msg: AapMessage): T {
|
||||
val (_, setting) = profile.decodeSetting(msg)
|
||||
?: error("decodeSetting returned null for cmd=0x${"%04X".format(msg.commandType)}")
|
||||
return setting as? T
|
||||
?: error("Expected ${T::class.simpleName} but got ${setting::class.simpleName}")
|
||||
}
|
||||
|
||||
/** Decode a setting from a hex string, asserting the result is non-null and of type [T]. */
|
||||
protected inline fun <reified T : AapSetting> decodeSetting(hex: String): T =
|
||||
decodeSetting(aapMessage(hex))
|
||||
}
|
||||
+385
-74
@@ -1,14 +1,34 @@
|
||||
package eu.darken.capod.pods.core.apple.protocol.aap
|
||||
|
||||
import eu.darken.capod.pods.core.PodModel
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapPodState.BatteryType
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapPodState.ChargingState
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.AncMode
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.AdaptiveAudioNoise
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.ConversationalAwareness
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.ConversationalAwarenessState
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.EndCallMuteMic
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.EndCallMuteMic.EndCallMode
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.EndCallMuteMic.MuteMicMode
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.NcWithOneAirPod
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.PersonalizedVolume
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.PressHoldDuration
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.PressSpeed
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.ToneVolume
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.VolumeSwipe
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting.VolumeSwipeLength
|
||||
import io.kotest.matchers.collections.shouldContainExactly
|
||||
import io.kotest.matchers.nulls.shouldBeNull
|
||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||
import io.kotest.matchers.shouldBe
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
import testhelpers.BaseTest
|
||||
|
||||
class DefaultAapDeviceProfileTest : BaseTest() {
|
||||
class DefaultAapDeviceProfileTest : BaseAapSessionTest() {
|
||||
|
||||
private val profile = DefaultAapDeviceProfile()
|
||||
override val podModel = PodModel.AIRPODS_PRO2
|
||||
|
||||
// ── Handshake ────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `encode handshake is 16 bytes`() {
|
||||
@@ -18,96 +38,387 @@ class DefaultAapDeviceProfileTest : BaseTest() {
|
||||
handshake[4] shouldBe 0x01.toByte()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encode SetAncMode ON`() {
|
||||
val bytes = profile.encodeCommand(AapCommand.SetAncMode(AncModeValue.ON))
|
||||
bytes.size shouldBe 11
|
||||
bytes[4] shouldBe 0x09.toByte() // command type low byte
|
||||
bytes[5] shouldBe 0x00.toByte() // command type high byte
|
||||
bytes[6] shouldBe 0x0D.toByte() // setting ID = ANC mode
|
||||
bytes[7] shouldBe 0x02.toByte() // value = ON
|
||||
// ── Notification Enable ──────────────────────────────────
|
||||
|
||||
@Nested
|
||||
inner class NotificationEnableTests {
|
||||
@Test
|
||||
fun `returns two packets`() {
|
||||
profile.encodeNotificationEnable().size shouldBe 2
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `first packet has 0xef filter`() {
|
||||
val p = profile.encodeNotificationEnable()[0]
|
||||
p[4] shouldBe 0x0f.toByte()
|
||||
p[8] shouldBe 0xef.toByte()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `second packet has 0xff filter`() {
|
||||
val p = profile.encodeNotificationEnable()[1]
|
||||
p[4] shouldBe 0x0f.toByte()
|
||||
p[8] shouldBe 0xff.toByte()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encode SetAncMode TRANSPARENCY`() {
|
||||
val bytes = profile.encodeCommand(AapCommand.SetAncMode(AncModeValue.TRANSPARENCY))
|
||||
bytes[6] shouldBe 0x0D.toByte()
|
||||
bytes[7] shouldBe 0x03.toByte()
|
||||
// ── InitExt ──────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
inner class InitExtTests {
|
||||
@Test fun `returned for Pro 2`() { DefaultAapDeviceProfile(PodModel.AIRPODS_PRO2).encodeInitExt().shouldNotBeNull() }
|
||||
@Test fun `returned for Pro 3`() { DefaultAapDeviceProfile(PodModel.AIRPODS_PRO3).encodeInitExt().shouldNotBeNull() }
|
||||
@Test fun `returned for AP4 ANC`() { DefaultAapDeviceProfile(PodModel.AIRPODS_GEN4_ANC).encodeInitExt().shouldNotBeNull() }
|
||||
@Test fun `null for basic AirPods`() { DefaultAapDeviceProfile(PodModel.AIRPODS_GEN3).encodeInitExt().shouldBeNull() }
|
||||
@Test fun `null for Pro 1`() { DefaultAapDeviceProfile(PodModel.AIRPODS_PRO).encodeInitExt().shouldBeNull() }
|
||||
@Test fun `null for Max`() { DefaultAapDeviceProfile(PodModel.AIRPODS_MAX).encodeInitExt().shouldBeNull() }
|
||||
|
||||
@Test
|
||||
fun `has correct command byte`() {
|
||||
profile.encodeInitExt()!![4] shouldBe 0x4d.toByte()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encode SetAncMode ADAPTIVE`() {
|
||||
val bytes = profile.encodeCommand(AapCommand.SetAncMode(AncModeValue.ADAPTIVE))
|
||||
bytes[7] shouldBe 0x04.toByte()
|
||||
// ── Supported ANC Modes per model ────────────────────────
|
||||
|
||||
@Nested
|
||||
inner class SupportedAncModesTests {
|
||||
private fun ancModesFor(model: PodModel): List<AncMode.Value> {
|
||||
val p = DefaultAapDeviceProfile(model)
|
||||
return decodeSetting<AncMode>(settingsMessage(0x0D, 0x02)).let {
|
||||
// Can't use the outer profile, need per-model profile
|
||||
(p.decodeSetting(settingsMessage(0x0D, 0x02))!!.second as AncMode).supported
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Pro 2 supports ON, TRANSPARENCY, ADAPTIVE`() {
|
||||
ancModesFor(PodModel.AIRPODS_PRO2) shouldContainExactly listOf(
|
||||
AncMode.Value.ON, AncMode.Value.TRANSPARENCY, AncMode.Value.ADAPTIVE,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Pro 1 supports ON, TRANSPARENCY only`() {
|
||||
ancModesFor(PodModel.AIRPODS_PRO) shouldContainExactly listOf(
|
||||
AncMode.Value.ON, AncMode.Value.TRANSPARENCY,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `basic AirPods have empty supported modes`() {
|
||||
ancModesFor(PodModel.AIRPODS_GEN1) shouldBe emptyList()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Max supports ON, TRANSPARENCY only`() {
|
||||
ancModesFor(PodModel.AIRPODS_MAX) shouldContainExactly listOf(
|
||||
AncMode.Value.ON, AncMode.Value.TRANSPARENCY,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encode SetConversationalAwareness enabled`() {
|
||||
val bytes = profile.encodeCommand(AapCommand.SetConversationalAwareness(true))
|
||||
bytes[6] shouldBe 0x18.toByte() // setting ID
|
||||
bytes[7] shouldBe 0x01.toByte() // enabled
|
||||
// ── ANC Mode encode/decode ───────────────────────────────
|
||||
|
||||
@Nested
|
||||
inner class AncModeTests {
|
||||
@Test fun `encode OFF`() { profile.encodeCommand(AapCommand.SetAncMode(AncMode.Value.OFF))[7] shouldBe 0x01.toByte() }
|
||||
@Test fun `encode ON`() { profile.encodeCommand(AapCommand.SetAncMode(AncMode.Value.ON))[7] shouldBe 0x02.toByte() }
|
||||
@Test fun `encode TRANSPARENCY`() { profile.encodeCommand(AapCommand.SetAncMode(AncMode.Value.TRANSPARENCY))[7] shouldBe 0x03.toByte() }
|
||||
@Test fun `encode ADAPTIVE`() { profile.encodeCommand(AapCommand.SetAncMode(AncMode.Value.ADAPTIVE))[7] shouldBe 0x04.toByte() }
|
||||
|
||||
@Test fun `decode OFF`() { decodeSetting<AncMode>(settingsMessage(0x0D, 0x01)).current shouldBe AncMode.Value.OFF }
|
||||
@Test fun `decode ON`() { decodeSetting<AncMode>(settingsMessage(0x0D, 0x02)).current shouldBe AncMode.Value.ON }
|
||||
@Test fun `decode TRANSPARENCY`() { decodeSetting<AncMode>(settingsMessage(0x0D, 0x03)).current shouldBe AncMode.Value.TRANSPARENCY }
|
||||
@Test fun `decode ADAPTIVE`() { decodeSetting<AncMode>(settingsMessage(0x0D, 0x04)).current shouldBe AncMode.Value.ADAPTIVE }
|
||||
@Test fun `decode unknown wire value returns null`() { profile.decodeSetting(settingsMessage(0x0D, 0x99)).shouldBeNull() }
|
||||
|
||||
@Test
|
||||
fun `round-trip all modes`() {
|
||||
for (mode in AncMode.Value.entries) {
|
||||
val encoded = profile.encodeCommand(AapCommand.SetAncMode(mode))
|
||||
val decoded = decodeSetting<AncMode>(AapMessage.parse(encoded)!!)
|
||||
decoded.current shouldBe mode
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encode SetConversationalAwareness disabled`() {
|
||||
val bytes = profile.encodeCommand(AapCommand.SetConversationalAwareness(false))
|
||||
bytes[6] shouldBe 0x18.toByte()
|
||||
bytes[7] shouldBe 0x00.toByte()
|
||||
// ── Conversational Awareness ─────────────────────────────
|
||||
|
||||
@Nested
|
||||
inner class ConversationalAwarenessTests {
|
||||
@Test fun `encode enabled`() { profile.encodeCommand(AapCommand.SetConversationalAwareness(true))[7] shouldBe 0x01.toByte() }
|
||||
@Test fun `encode disabled`() { profile.encodeCommand(AapCommand.SetConversationalAwareness(false))[7] shouldBe 0x02.toByte() }
|
||||
@Test fun `decode enabled`() { decodeSetting<ConversationalAwareness>(settingsMessage(0x28, 0x01)).enabled shouldBe true }
|
||||
@Test fun `decode disabled`() { decodeSetting<ConversationalAwareness>(settingsMessage(0x28, 0x02)).enabled shouldBe false }
|
||||
@Test fun `decode unknown value returns null`() { profile.decodeSetting(settingsMessage(0x28, 0x00)).shouldBeNull() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decode ANC mode setting`() {
|
||||
val msg = AapMessage.parse(
|
||||
byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x0D, 0x02, 0x00, 0x00, 0x00)
|
||||
)!!
|
||||
val result = profile.decodeSetting(msg)
|
||||
result.shouldNotBeNull()
|
||||
val (key, setting) = result
|
||||
key shouldBe AapSetting.AncMode::class
|
||||
(setting as AapSetting.AncMode).current shouldBe AncModeValue.ON
|
||||
// ── Press Speed ──────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
inner class PressSpeedTests {
|
||||
@Test fun `encode default`() { profile.encodeCommand(AapCommand.SetPressSpeed(PressSpeed.Value.DEFAULT))[7] shouldBe 0x00.toByte() }
|
||||
@Test fun `encode slower`() { profile.encodeCommand(AapCommand.SetPressSpeed(PressSpeed.Value.SLOWER))[7] shouldBe 0x01.toByte() }
|
||||
@Test fun `encode slowest`() { profile.encodeCommand(AapCommand.SetPressSpeed(PressSpeed.Value.SLOWEST))[7] shouldBe 0x02.toByte() }
|
||||
@Test fun `decode default`() { decodeSetting<PressSpeed>(settingsMessage(0x17, 0x00)).value shouldBe PressSpeed.Value.DEFAULT }
|
||||
@Test fun `decode slower`() { decodeSetting<PressSpeed>(settingsMessage(0x17, 0x01)).value shouldBe PressSpeed.Value.SLOWER }
|
||||
@Test fun `decode unknown returns null`() { profile.decodeSetting(settingsMessage(0x17, 0x99)).shouldBeNull() }
|
||||
|
||||
@Test
|
||||
fun `round-trip all values`() {
|
||||
for (v in PressSpeed.Value.entries) {
|
||||
val encoded = profile.encodeCommand(AapCommand.SetPressSpeed(v))
|
||||
decodeSetting<PressSpeed>(AapMessage.parse(encoded)!!).value shouldBe v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decode transparency mode`() {
|
||||
val msg = AapMessage.parse(
|
||||
byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x0D, 0x03, 0x00, 0x00, 0x00)
|
||||
)!!
|
||||
val (_, setting) = profile.decodeSetting(msg)!!
|
||||
(setting as AapSetting.AncMode).current shouldBe AncModeValue.TRANSPARENCY
|
||||
// ── Press & Hold Duration ────────────────────────────────
|
||||
|
||||
@Nested
|
||||
inner class PressHoldDurationTests {
|
||||
@Test fun `encode shorter`() { profile.encodeCommand(AapCommand.SetPressHoldDuration(PressHoldDuration.Value.SHORTER))[7] shouldBe 0x01.toByte() }
|
||||
@Test fun `decode shortest`() { decodeSetting<PressHoldDuration>(settingsMessage(0x18, 0x02)).value shouldBe PressHoldDuration.Value.SHORTEST }
|
||||
|
||||
@Test
|
||||
fun `round-trip all values`() {
|
||||
for (v in PressHoldDuration.Value.entries) {
|
||||
val encoded = profile.encodeCommand(AapCommand.SetPressHoldDuration(v))
|
||||
decodeSetting<PressHoldDuration>(AapMessage.parse(encoded)!!).value shouldBe v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decode conversational awareness`() {
|
||||
val msg = AapMessage.parse(
|
||||
byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x18, 0x01, 0x00, 0x00, 0x00)
|
||||
)!!
|
||||
val (key, setting) = profile.decodeSetting(msg)!!
|
||||
key shouldBe AapSetting.ConversationalAwareness::class
|
||||
(setting as AapSetting.ConversationalAwareness).enabled shouldBe true
|
||||
// ── NC with One AirPod ───────────────────────────────────
|
||||
|
||||
@Nested
|
||||
inner class NcOneAirPodTests {
|
||||
@Test fun `encode enabled`() { profile.encodeCommand(AapCommand.SetNcWithOneAirPod(true))[7] shouldBe 0x01.toByte() }
|
||||
@Test fun `encode disabled`() { profile.encodeCommand(AapCommand.SetNcWithOneAirPod(false))[7] shouldBe 0x02.toByte() }
|
||||
@Test fun `decode enabled`() { decodeSetting<NcWithOneAirPod>(settingsMessage(0x1B, 0x01)).enabled shouldBe true }
|
||||
@Test fun `decode disabled`() { decodeSetting<NcWithOneAirPod>(settingsMessage(0x1B, 0x02)).enabled shouldBe false }
|
||||
@Test fun `decode unknown returns null`() { profile.decodeSetting(settingsMessage(0x1B, 0x00)).shouldBeNull() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decode unknown setting returns null`() {
|
||||
val msg = AapMessage.parse(
|
||||
byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x7F.toByte(), 0x01, 0x00, 0x00, 0x00)
|
||||
)!!
|
||||
profile.decodeSetting(msg).shouldBeNull()
|
||||
// ── Tone Volume ──────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
inner class ToneVolumeTests {
|
||||
@Test fun `encode level 50`() { profile.encodeCommand(AapCommand.SetToneVolume(50))[7] shouldBe 50.toByte() }
|
||||
@Test fun `encode clamps to min 15`() { profile.encodeCommand(AapCommand.SetToneVolume(0))[7] shouldBe 0x0F.toByte() }
|
||||
@Test fun `encode clamps to max 100`() { profile.encodeCommand(AapCommand.SetToneVolume(200))[7] shouldBe 0x64.toByte() }
|
||||
@Test fun `decode level`() { decodeSetting<ToneVolume>(settingsMessage(0x1F, 50)).level shouldBe 50 }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decode non-settings message returns null`() {
|
||||
val msg = AapMessage.parse(
|
||||
byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x1D, 0x00, 0x01, 0x02, 0x03, 0x04)
|
||||
)!!
|
||||
profile.decodeSetting(msg).shouldBeNull()
|
||||
// ── Volume Swipe Length ──────────────────────────────────
|
||||
|
||||
@Nested
|
||||
inner class VolumeSwipeLengthTests {
|
||||
@Test fun `encode longest`() { profile.encodeCommand(AapCommand.SetVolumeSwipeLength(VolumeSwipeLength.Value.LONGEST))[7] shouldBe 0x02.toByte() }
|
||||
@Test fun `decode longer`() { decodeSetting<VolumeSwipeLength>(settingsMessage(0x23, 0x01)).value shouldBe VolumeSwipeLength.Value.LONGER }
|
||||
@Test fun `decode unknown returns null`() { profile.decodeSetting(settingsMessage(0x23, 0x99)).shouldBeNull() }
|
||||
|
||||
@Test
|
||||
fun `round-trip all values`() {
|
||||
for (v in VolumeSwipeLength.Value.entries) {
|
||||
val encoded = profile.encodeCommand(AapCommand.SetVolumeSwipeLength(v))
|
||||
decodeSetting<VolumeSwipeLength>(AapMessage.parse(encoded)!!).value shouldBe v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `round-trip encode then decode ANC mode`() {
|
||||
val command = AapCommand.SetAncMode(AncModeValue.TRANSPARENCY)
|
||||
val encoded = profile.encodeCommand(command)
|
||||
val msg = AapMessage.parse(encoded)!!
|
||||
val (_, setting) = profile.decodeSetting(msg)!!
|
||||
(setting as AapSetting.AncMode).current shouldBe AncModeValue.TRANSPARENCY
|
||||
// ── Volume Swipe ─────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
inner class VolumeSwipeTests {
|
||||
@Test fun `encode enabled`() { profile.encodeCommand(AapCommand.SetVolumeSwipe(true))[7] shouldBe 0x01.toByte() }
|
||||
@Test fun `decode disabled`() { decodeSetting<VolumeSwipe>(settingsMessage(0x25, 0x02)).enabled shouldBe false }
|
||||
@Test fun `decode unknown returns null`() { profile.decodeSetting(settingsMessage(0x25, 0x00)).shouldBeNull() }
|
||||
}
|
||||
|
||||
// ── Personalized Volume ──────────────────────────────────
|
||||
|
||||
@Nested
|
||||
inner class PersonalizedVolumeTests {
|
||||
@Test fun `encode enabled`() { profile.encodeCommand(AapCommand.SetPersonalizedVolume(true))[7] shouldBe 0x01.toByte() }
|
||||
@Test fun `decode disabled`() { decodeSetting<PersonalizedVolume>(settingsMessage(0x26, 0x02)).enabled shouldBe false }
|
||||
@Test fun `decode unknown returns null`() { profile.decodeSetting(settingsMessage(0x26, 0x00)).shouldBeNull() }
|
||||
}
|
||||
|
||||
// ── Adaptive Audio Noise ─────────────────────────────────
|
||||
|
||||
@Nested
|
||||
inner class AdaptiveAudioNoiseTests {
|
||||
@Test fun `encode level 50`() { profile.encodeCommand(AapCommand.SetAdaptiveAudioNoise(50))[7] shouldBe 50.toByte() }
|
||||
@Test fun `encode clamps to 0`() { profile.encodeCommand(AapCommand.SetAdaptiveAudioNoise(-5))[7] shouldBe 0x00.toByte() }
|
||||
@Test fun `encode clamps to 100`() { profile.encodeCommand(AapCommand.SetAdaptiveAudioNoise(150))[7] shouldBe 0x64.toByte() }
|
||||
@Test fun `decode level`() { decodeSetting<AdaptiveAudioNoise>(settingsMessage(0x2E, 64)).level shouldBe 64 }
|
||||
}
|
||||
|
||||
// ── EndCall / MuteMic ────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
inner class EndCallMuteMicTests {
|
||||
@Test
|
||||
fun `encode single press mute, double press end call`() {
|
||||
val bytes = profile.encodeCommand(AapCommand.SetEndCallMuteMic(MuteMicMode.SINGLE_PRESS, EndCallMode.DOUBLE_PRESS))
|
||||
bytes[6] shouldBe 0x24.toByte()
|
||||
bytes[7] shouldBe 0x21.toByte()
|
||||
bytes[8] shouldBe 0x23.toByte()
|
||||
bytes[9] shouldBe 0x02.toByte()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decode standard format 0x21`() {
|
||||
val ecm = decodeSetting<EndCallMuteMic>(aapMessage("04 00 04 00 09 00 24 21 22 03 00"))
|
||||
ecm.muteMic shouldBe MuteMicMode.DOUBLE_PRESS
|
||||
ecm.endCall shouldBe EndCallMode.SINGLE_PRESS
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decode compact format 0x20 combined 0x02`() {
|
||||
val ecm = decodeSetting<EndCallMuteMic>(aapMessage("04 00 04 00 09 00 24 20 02 00 00"))
|
||||
ecm.muteMic shouldBe MuteMicMode.SINGLE_PRESS
|
||||
ecm.endCall shouldBe EndCallMode.DOUBLE_PRESS
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decode compact format 0x20 combined 0x03`() {
|
||||
val ecm = decodeSetting<EndCallMuteMic>(aapMessage("04 00 04 00 09 00 24 20 03 00 00"))
|
||||
ecm.muteMic shouldBe MuteMicMode.DOUBLE_PRESS
|
||||
ecm.endCall shouldBe EndCallMode.SINGLE_PRESS
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decode compact format subtype 0x00 from real Pro 3`() {
|
||||
val ecm = decodeSetting<EndCallMuteMic>(aapMessage("04 00 04 00 09 00 24 00 03 00 00"))
|
||||
ecm.muteMic shouldBe MuteMicMode.DOUBLE_PRESS
|
||||
ecm.endCall shouldBe EndCallMode.SINGLE_PRESS
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decode unknown combined returns null`() {
|
||||
profile.decodeSetting(aapMessage("04 00 04 00 09 00 24 20 05 00 00")).shouldBeNull()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Conversation Awareness State (0x4B) ──────────────────
|
||||
|
||||
@Nested
|
||||
inner class ConversationAwarenessStateTests {
|
||||
@Test fun `speaking start`() { decodeSetting<ConversationalAwarenessState>(aapMessage("04 00 04 00 4B 00 01")).speaking shouldBe true }
|
||||
@Test fun `speaking stop`() { decodeSetting<ConversationalAwarenessState>(aapMessage("04 00 04 00 4B 00 04")).speaking shouldBe false }
|
||||
@Test fun `empty payload returns null`() { profile.decodeSetting(aapMessage("04 00 04 00 4B 00")).shouldBeNull() }
|
||||
}
|
||||
|
||||
// ── Battery ──────────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
inner class BatteryTests {
|
||||
@Test
|
||||
fun `decode single left pod`() {
|
||||
val r = profile.decodeBattery(batteryMessage(0x01, 0x04, 0x00, 85, 0x02, 0x00))!!
|
||||
r[BatteryType.LEFT]!!.percent shouldBe 0.85f
|
||||
r[BatteryType.LEFT]!!.charging shouldBe ChargingState.NOT_CHARGING
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decode dual pods plus case`() {
|
||||
val r = profile.decodeBattery(batteryMessage(
|
||||
0x03,
|
||||
0x02, 0x00, 90, 0x01, 0x00,
|
||||
0x04, 0x00, 80, 0x02, 0x00,
|
||||
0x08, 0x00, 50, 0x02, 0x00,
|
||||
))!!
|
||||
r.size shouldBe 3
|
||||
r[BatteryType.RIGHT]!!.percent shouldBe 0.9f
|
||||
r[BatteryType.RIGHT]!!.charging shouldBe ChargingState.CHARGING
|
||||
r[BatteryType.LEFT]!!.percent shouldBe 0.8f
|
||||
r[BatteryType.CASE]!!.percent shouldBe 0.5f
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decode headset (single)`() {
|
||||
val r = profile.decodeBattery(batteryMessage(0x01, 0x01, 0x00, 60, 0x04, 0x00))!!
|
||||
r[BatteryType.SINGLE]!!.percent shouldBe 0.6f
|
||||
r[BatteryType.SINGLE]!!.charging shouldBe ChargingState.DISCONNECTED
|
||||
}
|
||||
|
||||
@Test fun `skips percent above 100`() { profile.decodeBattery(batteryMessage(0x01, 0x04, 0x00, 127, 0x02, 0x00))!! shouldBe emptyMap() }
|
||||
@Test fun `skips percent 255`() { profile.decodeBattery(batteryMessage(0x01, 0x04, 0x00, 0xFF, 0x04, 0x00))!! shouldBe emptyMap() }
|
||||
@Test fun `empty count`() { profile.decodeBattery(batteryMessage(0x00))!! shouldBe emptyMap() }
|
||||
@Test fun `skips unknown type`() { profile.decodeBattery(batteryMessage(0x01, 0x10, 0x00, 50, 0x02, 0x00))!! shouldBe emptyMap() }
|
||||
@Test fun `handles truncated entry`() { profile.decodeBattery(batteryMessage(0x01, 0x04, 0x00, 50))!! shouldBe emptyMap() }
|
||||
@Test fun `non-battery message returns null`() { profile.decodeBattery(settingsMessage(0x0D, 0x02)).shouldBeNull() }
|
||||
@Test fun `zero percent is valid`() { profile.decodeBattery(batteryMessage(0x01, 0x04, 0x00, 0, 0x01, 0x00))!![BatteryType.LEFT]!!.percent shouldBe 0f }
|
||||
@Test fun `100 percent is valid`() { profile.decodeBattery(batteryMessage(0x01, 0x04, 0x00, 100, 0x02, 0x00))!![BatteryType.LEFT]!!.percent shouldBe 1f }
|
||||
|
||||
@Test
|
||||
fun `CHARGING_OPTIMIZED state 0x05`() {
|
||||
val r = profile.decodeBattery(batteryMessage(0x03, 0x04, 0x01, 80, 0x05, 0x01, 0x02, 0x01, 79, 0x05, 0x01, 0x08, 0x01, 48, 0x01, 0x01))!!
|
||||
r[BatteryType.LEFT]!!.charging shouldBe ChargingState.CHARGING_OPTIMIZED
|
||||
r[BatteryType.RIGHT]!!.charging shouldBe ChargingState.CHARGING_OPTIMIZED
|
||||
r[BatteryType.CASE]!!.charging shouldBe ChargingState.CHARGING
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pods out of case, case disconnected`() {
|
||||
val r = profile.decodeBattery(batteryMessage(0x03, 0x04, 0x01, 80, 0x02, 0x01, 0x02, 0x01, 77, 0x02, 0x01, 0x08, 0x01, 0, 0x04, 0x01))!!
|
||||
r[BatteryType.CASE]!!.percent shouldBe 0f
|
||||
r[BatteryType.CASE]!!.charging shouldBe ChargingState.DISCONNECTED
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `real 22-byte message from Pro 3`() {
|
||||
val msg = aapMessage("04 00 04 00 04 00 03 04 01 50 05 01 02 01 4F 05 01 08 01 30 01 01")
|
||||
val r = profile.decodeBattery(msg)!!
|
||||
r.size shouldBe 3
|
||||
r[BatteryType.LEFT]!!.percent shouldBe 0.8f
|
||||
r[BatteryType.RIGHT]!!.percent shouldBe 0.79f
|
||||
r[BatteryType.CASE]!!.percent shouldBe 0.48f
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private Keys ─────────────────────────────────────────
|
||||
|
||||
@Nested
|
||||
inner class PrivateKeyTests {
|
||||
@Test
|
||||
fun `encode request`() {
|
||||
val bytes = profile.encodePrivateKeyRequest()!!
|
||||
bytes[4] shouldBe 0x30.toByte()
|
||||
bytes.size shouldBe 8
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decode response with IRK and ENC`() {
|
||||
val irk = ByteArray(16) { 0x11.toByte() }
|
||||
val enc = ByteArray(16) { 0x22.toByte() }
|
||||
val payload = byteArrayOf(0x02, 0x01, 0x00, 16, 0x00, *irk, 0x04, 0x00, 16, 0x00, *enc)
|
||||
val raw = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x31, 0x00) + payload
|
||||
val result = profile.decodePrivateKeyResponse(AapMessage.parse(raw)!!)!!
|
||||
result.irk shouldBe irk
|
||||
result.encKey shouldBe enc
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decode response with only IRK`() {
|
||||
val irk = ByteArray(16) { 0xAA.toByte() }
|
||||
val raw = byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x31, 0x00, 0x01, 0x01, 0x00, 16, 0x00, *irk)
|
||||
val result = profile.decodePrivateKeyResponse(AapMessage.parse(raw)!!)!!
|
||||
result.irk shouldBe irk
|
||||
result.encKey.shouldBeNull()
|
||||
}
|
||||
|
||||
@Test fun `unknown key type returns null`() { profile.decodePrivateKeyResponse(aapMessage("04 00 04 00 31 00 01 07 00 10 00 ${" 00".repeat(16).trim()}")).shouldBeNull() }
|
||||
@Test fun `wrong key length returns null`() { profile.decodePrivateKeyResponse(aapMessage("04 00 04 00 31 00 01 01 00 08 00 ${" 00".repeat(8).trim()}")).shouldBeNull() }
|
||||
@Test fun `non-key message returns null`() { profile.decodePrivateKeyResponse(settingsMessage(0x0D, 0x02)).shouldBeNull() }
|
||||
}
|
||||
|
||||
// ── Edge Cases ───────────────────────────────────────────
|
||||
|
||||
@Test fun `unknown setting ID returns null`() { profile.decodeSetting(settingsMessage(0x7F, 0x01)).shouldBeNull() }
|
||||
@Test fun `non-settings command returns null`() { profile.decodeSetting(aapMessage("04 00 04 00 1D 00 01 02 03 04")).shouldBeNull() }
|
||||
@Test fun `payload too short returns null`() { profile.decodeSetting(aapMessage("04 00 04 00 09 00 0D")).shouldBeNull() }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
package eu.darken.capod.reaction.core.aap
|
||||
|
||||
import eu.darken.capod.common.bluetooth.BluetoothDevice2
|
||||
import eu.darken.capod.common.bluetooth.BluetoothManager2
|
||||
import eu.darken.capod.monitor.core.BlePodMonitor
|
||||
import eu.darken.capod.pods.core.BlePodSnapshot
|
||||
import eu.darken.capod.pods.core.PodModel
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapConnectionManager
|
||||
|
||||
import eu.darken.capod.pods.core.apple.protocol.aap.AapPodState
|
||||
import eu.darken.capod.profiles.core.AppleDeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfile
|
||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Nested
|
||||
import org.junit.jupiter.api.Test
|
||||
import testhelpers.BaseTest
|
||||
import java.io.IOException
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class AapAutoConnectTest : BaseTest() {
|
||||
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
|
||||
private lateinit var aapManager: AapConnectionManager
|
||||
private lateinit var profilesRepo: DeviceProfilesRepo
|
||||
private lateinit var bluetoothManager: BluetoothManager2
|
||||
private lateinit var blePodMonitor: BlePodMonitor
|
||||
|
||||
private lateinit var profilesFlow: MutableStateFlow<List<DeviceProfile>>
|
||||
private lateinit var disconnectEventsFlow: MutableSharedFlow<String>
|
||||
private lateinit var allStatesFlow: MutableStateFlow<Map<String, AapPodState>>
|
||||
|
||||
private val testAddress = "AA:BB:CC:DD:EE:FF"
|
||||
private val testProfile = AppleDeviceProfile(
|
||||
label = "Test AirPods",
|
||||
model = PodModel.AIRPODS_PRO3,
|
||||
address = testAddress,
|
||||
)
|
||||
private val testBondedDevice: BluetoothDevice2 = mockk(relaxed = true) {
|
||||
every { address } returns testAddress
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
profilesFlow = MutableStateFlow(emptyList())
|
||||
disconnectEventsFlow = MutableSharedFlow(extraBufferCapacity = 16)
|
||||
allStatesFlow = MutableStateFlow(emptyMap())
|
||||
|
||||
aapManager = mockk(relaxed = true) {
|
||||
every { allStates } returns allStatesFlow
|
||||
every { disconnectEvents } returns disconnectEventsFlow
|
||||
}
|
||||
|
||||
profilesRepo = mockk {
|
||||
every { profiles } returns profilesFlow
|
||||
}
|
||||
|
||||
bluetoothManager = mockk {
|
||||
every { bondedDevices() } returns flowOf(setOf(testBondedDevice))
|
||||
}
|
||||
|
||||
blePodMonitor = mockk {
|
||||
every { devices } returns flowOf(
|
||||
listOf(mockk<BlePodSnapshot>(relaxed = true) { every { address } returns testAddress })
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createAutoConnect() = AapAutoConnect(
|
||||
aapManager = aapManager,
|
||||
profilesRepo = profilesRepo,
|
||||
bluetoothManager = bluetoothManager,
|
||||
blePodMonitor = blePodMonitor,
|
||||
)
|
||||
|
||||
@Nested
|
||||
inner class InitialConnect {
|
||||
|
||||
@Test
|
||||
fun `connects when profiled device is bonded`() = runTest(testDispatcher) {
|
||||
val autoConnect = createAutoConnect()
|
||||
|
||||
val job = launch { autoConnect.monitor().toList() }
|
||||
profilesFlow.value = listOf(testProfile)
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 1) { aapManager.connect(testAddress, any(), PodModel.AIRPODS_PRO3) }
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skips profiles without address`() = runTest(testDispatcher) {
|
||||
val autoConnect = createAutoConnect()
|
||||
|
||||
val noAddressProfile = AppleDeviceProfile(label = "No Address", model = PodModel.AIRPODS_PRO3)
|
||||
|
||||
val job = launch { autoConnect.monitor().toList() }
|
||||
profilesFlow.value = listOf(noAddressProfile)
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 0) { aapManager.connect(any(), any(), any()) }
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skips profiles not in bonded devices`() = runTest(testDispatcher) {
|
||||
every { bluetoothManager.bondedDevices() } returns flowOf(emptySet())
|
||||
|
||||
val autoConnect = createAutoConnect()
|
||||
|
||||
val job = launch { autoConnect.monitor().toList() }
|
||||
profilesFlow.value = listOf(testProfile)
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 0) { aapManager.connect(any(), any(), any()) }
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skips already connected devices`() = runTest(testDispatcher) {
|
||||
allStatesFlow.value = mapOf(
|
||||
testAddress to AapPodState(connectionState = AapPodState.ConnectionState.READY)
|
||||
)
|
||||
|
||||
val autoConnect = createAutoConnect()
|
||||
|
||||
val job = launch { autoConnect.monitor().toList() }
|
||||
profilesFlow.value = listOf(testProfile)
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 0) { aapManager.connect(any(), any(), any()) }
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not skip disconnected devices`() = runTest(testDispatcher) {
|
||||
allStatesFlow.value = mapOf(
|
||||
testAddress to AapPodState(connectionState = AapPodState.ConnectionState.DISCONNECTED)
|
||||
)
|
||||
|
||||
val autoConnect = createAutoConnect()
|
||||
|
||||
val job = launch { autoConnect.monitor().toList() }
|
||||
profilesFlow.value = listOf(testProfile)
|
||||
advanceUntilIdle()
|
||||
|
||||
coVerify(exactly = 1) { aapManager.connect(testAddress, any(), PodModel.AIRPODS_PRO3) }
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
inner class Reconnect {
|
||||
|
||||
/**
|
||||
* For reconnect tests: set the device as "already connected" in allStates
|
||||
* so initialConnect() skips it, then clear allStates before emitting disconnect event.
|
||||
*/
|
||||
private fun kotlinx.coroutines.test.TestScope.setupForReconnect(autoConnect: AapAutoConnect): kotlinx.coroutines.Job {
|
||||
// Pre-set as connected so initialConnect doesn't fire for this address
|
||||
allStatesFlow.value = mapOf(
|
||||
testAddress to AapPodState(connectionState = AapPodState.ConnectionState.READY)
|
||||
)
|
||||
profilesFlow.value = listOf(testProfile)
|
||||
return launch { autoConnect.monitor().toList() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reconnect stops when device no longer profiled`() = runTest(testDispatcher) {
|
||||
val autoConnect = createAutoConnect()
|
||||
val job = setupForReconnect(autoConnect)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Clear profiles, then disconnect
|
||||
profilesFlow.value = emptyList()
|
||||
allStatesFlow.value = emptyMap()
|
||||
disconnectEventsFlow.tryEmit(testAddress)
|
||||
advanceUntilIdle()
|
||||
|
||||
// connect should only have been called by the profile change trigger (which also finds no profiles)
|
||||
// The reconnect loop should not call connect since profile is gone
|
||||
coVerify(exactly = 0) { aapManager.connect(testAddress, any(), any()) }
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reconnect stops when device no longer bonded`() = runTest(testDispatcher) {
|
||||
val autoConnect = createAutoConnect()
|
||||
val job = setupForReconnect(autoConnect)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Remove bonded device, then disconnect
|
||||
every { bluetoothManager.bondedDevices() } returns flowOf(emptySet())
|
||||
allStatesFlow.value = emptyMap()
|
||||
disconnectEventsFlow.tryEmit(testAddress)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Reconnect should not call connect since not bonded
|
||||
coVerify(exactly = 0) { aapManager.connect(testAddress, any(), any()) }
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reconnect stops when device no longer visible in BLE`() = runTest(testDispatcher) {
|
||||
val autoConnect = createAutoConnect()
|
||||
val job = setupForReconnect(autoConnect)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Remove from BLE scans, then disconnect
|
||||
every { blePodMonitor.devices } returns flowOf(emptyList())
|
||||
allStatesFlow.value = emptyMap()
|
||||
disconnectEventsFlow.tryEmit(testAddress)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Reconnect should not call connect since not visible in BLE
|
||||
coVerify(exactly = 0) { aapManager.connect(testAddress, any(), any()) }
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reconnect stops when device already reconnected`() = runTest(testDispatcher) {
|
||||
val autoConnect = createAutoConnect()
|
||||
val job = setupForReconnect(autoConnect)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Keep as READY, emit disconnect event
|
||||
// allStatesFlow still shows READY → reconnect should skip
|
||||
disconnectEventsFlow.tryEmit(testAddress)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Should not attempt connect — already connected
|
||||
coVerify(exactly = 0) { aapManager.connect(testAddress, any(), any()) }
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user