diff --git a/app/src/main/java/eu/darken/capod/common/bluetooth/l2cap/HiddenApiBypass.kt b/app/src/main/java/eu/darken/capod/common/bluetooth/l2cap/HiddenApiBypass.kt index b9368d44..58e1e7ea 100644 --- a/app/src/main/java/eu/darken/capod/common/bluetooth/l2cap/HiddenApiBypass.kt +++ b/app/src/main/java/eu/darken/capod/common/bluetooth/l2cap/HiddenApiBypass.kt @@ -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() @@ -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 diff --git a/app/src/main/java/eu/darken/capod/common/bluetooth/l2cap/L2capSocketFactory.kt b/app/src/main/java/eu/darken/capod/common/bluetooth/l2cap/L2capSocketFactory.kt index b43d775c..2b1e68d3 100644 --- a/app/src/main/java/eu/darken/capod/common/bluetooth/l2cap/L2capSocketFactory.kt +++ b/app/src/main/java/eu/darken/capod/common/bluetooth/l2cap/L2capSocketFactory.kt @@ -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 } } diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt index f29747eb..5daecb26 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewScreen.kt @@ -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) } } diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt index 1061dbf0..ac6a79fc 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/OverviewViewModel.kt @@ -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() @@ -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") } diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt index 81380461..45924bc7 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/DualPodsCard.kt @@ -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) diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/PodCardComponents.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/PodCardComponents.kt index de176ff8..49a10620 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/PodCardComponents.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/PodCardComponents.kt @@ -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, + 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, + ) + } +} diff --git a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt index ba3a7413..5ee76c0c 100644 --- a/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt +++ b/app/src/main/java/eu/darken/capod/main/ui/overview/cards/SinglePodsCard.kt @@ -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) diff --git a/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt index 7ae0801a..c4bd7a71 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/DeviceMonitor.kt @@ -23,9 +23,12 @@ class DeviceMonitor @Inject constructor( val devices: Flow> = 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) } diff --git a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt index 30a3838e..a03ea2a0 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/PodDevice.kt @@ -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 } diff --git a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt index e9fed4db..617fd463 100644 --- a/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt +++ b/app/src/main/java/eu/darken/capod/monitor/core/worker/MonitorService.kt @@ -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" } diff --git a/app/src/main/java/eu/darken/capod/pods/core/PodModel.kt b/app/src/main/java/eu/darken/capod/pods/core/PodModel.kt index 43080aa1..97459479 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/PodModel.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/PodModel.kt @@ -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, ) } \ No newline at end of file diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapCommand.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapCommand.kt index 7f3f57c9..d22a0504 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapCommand.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapCommand.kt @@ -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() } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapConnection.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapConnection.kt index fa786a6e..237f3e85 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapConnection.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapConnection.kt @@ -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 = _state.asStateFlow() + private val _keysReceived = MutableSharedFlow(extraBufferCapacity = 1) + val keysReceived: SharedFlow = _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() { diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapConnectionManager.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapConnectionManager.kt index dc4001c7..b8878eb1 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapConnectionManager.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapConnectionManager.kt @@ -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() + private val connectionJobs = mutableMapOf() + private val intentionalDisconnects = mutableSetOf() + private val _allStates = MutableStateFlow>(emptyMap()) val allStates: StateFlow> = _allStates.asStateFlow() - fun deviceState(address: BluetoothAddress): Flow = - _allStates.map { it[address] } + private val _disconnectEvents = MutableSharedFlow(extraBufferCapacity = 16) + val disconnectEvents: SharedFlow = _disconnectEvents.asSharedFlow() + + /** Emits when a connection receives private keys (IRK/ENC) from the device. */ + private val _keysReceived = MutableSharedFlow>(extraBufferCapacity = 16) + val keysReceived: SharedFlow> = _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 } - } } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapDeviceProfile.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapDeviceProfile.kt index 0e7da1bd..a4f4a6c9 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapDeviceProfile.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapDeviceProfile.kt @@ -19,6 +19,12 @@ interface AapDeviceProfile { */ fun decodeSetting(message: AapMessage): Pair, AapSetting>? + /** + * Decode a battery notification (command 0x04). + * Returns null if the message is not a battery update. + */ + fun decodeBattery(message: AapMessage): Map? + /** * 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 + + /** + * 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) } } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapPodState.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapPodState.kt index 7f20dd5a..eceaeeda 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapPodState.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapPodState.kt @@ -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, AapSetting> = emptyMap(), + val batteries: Map = emptyMap(), ) { inline fun setting(): T? = settings[T::class] as? T fun withSetting(key: KClass, 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 + } + } } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapSetting.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapSetting.kt index d4e1544a..c5e6f0a7 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapSetting.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapSetting.kt @@ -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, - ) : AapSetting() + val current: Value, + val supported: List, + ) : 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() } diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AncModeValue.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AncModeValue.kt deleted file mode 100644 index a222f2e7..00000000 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AncModeValue.kt +++ /dev/null @@ -1,8 +0,0 @@ -package eu.darken.capod.pods.core.apple.protocol.aap - -enum class AncModeValue { - OFF, - ON, - TRANSPARENCY, - ADAPTIVE, -} diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/DefaultAapDeviceProfile.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/DefaultAapDeviceProfile.kt index bfb68349..9e2b09b2 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/DefaultAapDeviceProfile.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/DefaultAapDeviceProfile.kt @@ -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 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 = 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, 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? { + 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() + 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, 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 { val strings = mutableListOf() 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)) diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/KeyExchangeResult.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/KeyExchangeResult.kt new file mode 100644 index 00000000..7cfc5f6a --- /dev/null +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/KeyExchangeResult.kt @@ -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 +} diff --git a/app/src/main/java/eu/darken/capod/reaction/core/aap/AapAutoConnect.kt b/app/src/main/java/eu/darken/capod/reaction/core/aap/AapAutoConnect.kt new file mode 100644 index 00000000..c91c02be --- /dev/null +++ b/app/src/main/java/eu/darken/capod/reaction/core/aap/AapAutoConnect.kt @@ -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()) + + fun monitor(): Flow = merge( + initialConnect(), + reconnectOnDisconnect(), + ) + + private fun initialConnect(): Flow = 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 = 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 → Flow + .setupCommonEventHandlers(TAG) { "reconnect" } + + companion object { + private val TAG = logTag("Reaction", "AapAutoConnect") + } +} diff --git a/app/src/main/java/eu/darken/capod/reaction/core/aap/AapKeyPersister.kt b/app/src/main/java/eu/darken/capod/reaction/core/aap/AapKeyPersister.kt new file mode 100644 index 00000000..d3d37a92 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/reaction/core/aap/AapKeyPersister.kt @@ -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 = aapManager.keysReceived + .onEach { (address, keys) -> + val profile = profilesRepo.profiles.first() + .filterIsInstance() + .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") + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f885d022..a5c93f9b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -258,6 +258,11 @@ Charging In ear Microphone + Off + ANC + Transparency + Adaptive + Conversation Awareness Yours Being worn Not being worn diff --git a/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt b/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt index 2e67987a..a5982cac 100644 --- a/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt +++ b/app/src/test/java/eu/darken/capod/main/ui/overview/OverviewViewModelTest.kt @@ -125,6 +125,7 @@ class OverviewViewModelTest : BaseTest() { upgradeRepo = upgradeRepo, bluetoothManager = bluetoothManager, profilesRepo = profilesRepo, + aapManager = mockk(relaxed = true), ) @Nested diff --git a/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt index e92244ad..09df9df0 100644 --- a/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt +++ b/app/src/test/java/eu/darken/capod/monitor/core/PodDeviceTest.kt @@ -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(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(relaxed = true) { + every { address } returns bondedAddress + } + val ble = mockk(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 + } } diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/AirPodsFactoryTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/AirPodsFactoryTest.kt index 929817f7..d59a71fb 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/AirPodsFactoryTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/AirPodsFactoryTest.kt @@ -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 { diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/BaseAirPodsTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/BaseBlePodsTest.kt similarity index 98% rename from app/src/test/java/eu/darken/capod/pods/core/apple/BaseAirPodsTest.kt rename to app/src/test/java/eu/darken/capod/pods/core/apple/BaseBlePodsTest.kt index 4eac66c7..9d5aaebb 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/BaseAirPodsTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/BaseBlePodsTest.kt @@ -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 { diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/BasicSingleApplePodsTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/BasicSingleApplePodsTest.kt index 7781f00f..3a86f71c 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/BasicSingleApplePodsTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/BasicSingleApplePodsTest.kt @@ -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 { diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/DualApplePodsTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/DualApplePodsTest.kt index f1cccc9e..88af27aa 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/DualApplePodsTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/DualApplePodsTest.kt @@ -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 { diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/SingleApplePodsTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/SingleApplePodsTest.kt index a4216ffa..d7ed8839 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/SingleApplePodsTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/SingleApplePodsTest.kt @@ -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 { diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsGen1Test.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsGen1Test.kt index 3d9bda5f..8150ae95 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsGen1Test.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsGen1Test.kt @@ -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 diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsGen2Test.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsGen2Test.kt index 8e6bb4e7..421e1160 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsGen2Test.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsGen2Test.kt @@ -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 { diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsGen3Test.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsGen3Test.kt index 68f1a78c..8ea772bd 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsGen3Test.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsGen3Test.kt @@ -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 { diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsGen4AncTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsGen4AncTest.kt index 1228ac21..e932b615 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsGen4AncTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsGen4AncTest.kt @@ -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 { diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsGen4Test.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsGen4Test.kt index 3b83434c..858ea20a 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsGen4Test.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsGen4Test.kt @@ -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 { diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsMaxTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsMaxTest.kt index f32720d8..a3f8eacf 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsMaxTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsMaxTest.kt @@ -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 diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsMaxUsbcTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsMaxUsbcTest.kt index 2accc380..a61647a0 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsMaxUsbcTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsMaxUsbcTest.kt @@ -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 diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro2Test.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro2Test.kt index 9439be35..24ac2307 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro2Test.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro2Test.kt @@ -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 diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro2UsbcTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro2UsbcTest.kt index b3799b77..4fb9c9da 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro2UsbcTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro2UsbcTest.kt @@ -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 diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro3Test.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro3Test.kt index 0ae9086c..779f6a0a 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro3Test.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsPro3Test.kt @@ -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 diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsProTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsProTest.kt index bfcffc2e..bc4d39d6 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsProTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/airpods/AirPodsProTest.kt @@ -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 { diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/beats/BeatsFitProTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/beats/BeatsFitProTest.kt index 05a7a42f..3228b8c1 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/beats/BeatsFitProTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/beats/BeatsFitProTest.kt @@ -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 diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/beats/BeatsFlexText.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/beats/BeatsFlexText.kt index 92b839d5..14acd01a 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/beats/BeatsFlexText.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/beats/BeatsFlexText.kt @@ -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 diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/beats/BeatsSolo3Test.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/beats/BeatsSolo3Test.kt index 502c15f6..a9ab9174 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/beats/BeatsSolo3Test.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/beats/BeatsSolo3Test.kt @@ -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 diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/beats/BeatsStudio3Test.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/beats/BeatsStudio3Test.kt index 2bb2047c..79f86e6b 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/beats/BeatsStudio3Test.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/beats/BeatsStudio3Test.kt @@ -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 diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/beats/BeatsXTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/beats/BeatsXTest.kt index 6e0000e1..2fde8514 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/beats/BeatsXTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/beats/BeatsXTest.kt @@ -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 diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/beats/PowerBeats3Test.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/beats/PowerBeats3Test.kt index e5b4de9f..60b0c36e 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/beats/PowerBeats3Test.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/beats/PowerBeats3Test.kt @@ -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 diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/beats/PowerBeats4Test.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/beats/PowerBeats4Test.kt index 52dc0259..4d60f6dd 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/beats/PowerBeats4Test.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/beats/PowerBeats4Test.kt @@ -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 { diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/beats/PowerBeatsPro2Test.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/beats/PowerBeatsPro2Test.kt index aea1d139..1909be41 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/beats/PowerBeatsPro2Test.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/beats/PowerBeatsPro2Test.kt @@ -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 { diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/beats/PowerBeatsProTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/beats/PowerBeatsProTest.kt index d312f820..b5125172 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/beats/PowerBeatsProTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/beats/PowerBeatsProTest.kt @@ -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 { diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/misc/FakeAirPodsGen1Test.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/misc/FakeAirPodsGen1Test.kt index c59902f3..ea101f6d 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/misc/FakeAirPodsGen1Test.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/misc/FakeAirPodsGen1Test.kt @@ -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 { diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/misc/FakeAirPodsGen2Test.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/misc/FakeAirPodsGen2Test.kt index e30be28d..04aab399 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/misc/FakeAirPodsGen2Test.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/misc/FakeAirPodsGen2Test.kt @@ -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 { diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/misc/FakeAirPodsGen3Test.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/misc/FakeAirPodsGen3Test.kt index 297fe761..e3e15230 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/misc/FakeAirPodsGen3Test.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/misc/FakeAirPodsGen3Test.kt @@ -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 { diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/misc/FakeAirPodsPro2Test.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/misc/FakeAirPodsPro2Test.kt index b72c6c5f..7564642b 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/misc/FakeAirPodsPro2Test.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/misc/FakeAirPodsPro2Test.kt @@ -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 { diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/misc/FakeAirPodsProTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/misc/FakeAirPodsProTest.kt index e03f6e57..55d5e89d 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/misc/FakeAirPodsProTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/misc/FakeAirPodsProTest.kt @@ -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 { diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/protocol/aap/AapConnectionManagerTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/protocol/aap/AapConnectionManagerTest.kt new file mode 100644 index 00000000..f380dd9a --- /dev/null +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/protocol/aap/AapConnectionManagerTest.kt @@ -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 { + 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 { + 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(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(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() + } + } +} diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/protocol/aap/AapPodStateTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/protocol/aap/AapPodStateTest.kt index cba401da..917f39cc 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/protocol/aap/AapPodStateTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/protocol/aap/AapPodStateTest.kt @@ -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() 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().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().shouldNotBeNull() - // New setting added updated.setting()!!.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()!!.current shouldBe AncModeValue.TRANSPARENCY + updated.setting()!!.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() } } diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/protocol/aap/AirPodsPro3AapSessionTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/protocol/aap/AirPodsPro3AapSessionTest.kt new file mode 100644 index 00000000..0fb6c1ca --- /dev/null +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/protocol/aap/AirPodsPro3AapSessionTest.kt @@ -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("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("04 00 04 00 09 00 18 00 00 00 00").value shouldBe PressHoldDuration.Value.DEFAULT + } + + @Test fun `press speed DEFAULT`() { + decodeSetting("04 00 04 00 09 00 17 00 00 00 00").value shouldBe PressSpeed.Value.DEFAULT + } + + @Test fun `volume swipe ON`() { + decodeSetting("04 00 04 00 09 00 25 01 00 00 00").enabled shouldBe true + } + + @Test fun `volume swipe length DEFAULT`() { + decodeSetting("04 00 04 00 09 00 23 00 00 00 00").value shouldBe VolumeSwipeLength.Value.DEFAULT + } + + @Test fun `tone volume 80`() { + decodeSetting("04 00 04 00 09 00 1F 50 50 00 00").level shouldBe 0x50 + } + + @Test fun `end call mute mic - subtype 0x00`() { + decodeSetting("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("04 00 04 00 09 00 28 02 00 00 00").enabled shouldBe false + } + + @Test fun `conversational awareness ON after reconnect`() { + decodeSetting("04 00 04 00 09 00 28 01 00 00 00").enabled shouldBe true + } + + @Test fun `personalized volume OFF`() { + decodeSetting("04 00 04 00 09 00 26 02 00 00 00").enabled shouldBe false + } + + @Test fun `adaptive audio noise 50`() { + decodeSetting("04 00 04 00 09 00 2E 32 00 00 00").level shouldBe 0x32 + } + + @Test fun `NC one airpod OFF`() { + decodeSetting("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("04 00 04 00 09 00 0D 03 00 00 00").current shouldBe AncMode.Value.TRANSPARENCY + } + + @Test fun `device echoes ADAPTIVE`() { + decodeSetting("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("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() + } + } + } +} diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/protocol/aap/BaseAapSessionTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/protocol/aap/BaseAapSessionTest.kt new file mode 100644 index 00000000..be925faa --- /dev/null +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/protocol/aap/BaseAapSessionTest.kt @@ -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 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 decodeSetting(hex: String): T = + decodeSetting(aapMessage(hex)) +} diff --git a/app/src/test/java/eu/darken/capod/pods/core/apple/protocol/aap/DefaultAapDeviceProfileTest.kt b/app/src/test/java/eu/darken/capod/pods/core/apple/protocol/aap/DefaultAapDeviceProfileTest.kt index 16a8771f..aa1007ab 100644 --- a/app/src/test/java/eu/darken/capod/pods/core/apple/protocol/aap/DefaultAapDeviceProfileTest.kt +++ b/app/src/test/java/eu/darken/capod/pods/core/apple/protocol/aap/DefaultAapDeviceProfileTest.kt @@ -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 { + val p = DefaultAapDeviceProfile(model) + return decodeSetting(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(settingsMessage(0x0D, 0x01)).current shouldBe AncMode.Value.OFF } + @Test fun `decode ON`() { decodeSetting(settingsMessage(0x0D, 0x02)).current shouldBe AncMode.Value.ON } + @Test fun `decode TRANSPARENCY`() { decodeSetting(settingsMessage(0x0D, 0x03)).current shouldBe AncMode.Value.TRANSPARENCY } + @Test fun `decode ADAPTIVE`() { decodeSetting(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(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(settingsMessage(0x28, 0x01)).enabled shouldBe true } + @Test fun `decode disabled`() { decodeSetting(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(settingsMessage(0x17, 0x00)).value shouldBe PressSpeed.Value.DEFAULT } + @Test fun `decode slower`() { decodeSetting(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(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(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(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(settingsMessage(0x1B, 0x01)).enabled shouldBe true } + @Test fun `decode disabled`() { decodeSetting(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(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(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(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(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(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(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(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(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(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(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(aapMessage("04 00 04 00 4B 00 01")).speaking shouldBe true } + @Test fun `speaking stop`() { decodeSetting(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() } } diff --git a/app/src/test/java/eu/darken/capod/reaction/core/aap/AapAutoConnectTest.kt b/app/src/test/java/eu/darken/capod/reaction/core/aap/AapAutoConnectTest.kt new file mode 100644 index 00000000..5c56f4cb --- /dev/null +++ b/app/src/test/java/eu/darken/capod/reaction/core/aap/AapAutoConnectTest.kt @@ -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> + private lateinit var disconnectEventsFlow: MutableSharedFlow + private lateinit var allStatesFlow: MutableStateFlow> + + 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(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() + } + } +}