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:
darken
2026-03-31 19:17:09 +02:00
committed by Matthias Urhahn
parent 3fa01bb510
commit c1cd99f701
61 changed files with 2354 additions and 289 deletions
@@ -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() {
@@ -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,
}
@@ -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")
}
}
+5
View File
@@ -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 {
@@ -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 {
@@ -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()
}
}
@@ -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))
}
@@ -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()
}
}
}