feat: Add AAP L2CAP protocol support for reading and writing AirPods settings

Full integration of the Apple Accessory Protocol (AAP) over L2CAP, enabling direct communication with AirPods for 1% battery granularity, ANC mode control, Conversation Awareness toggle, and private key exchange for BLE encrypted battery.
This commit is contained in:
darken
2026-03-31 19:17:09 +02:00
committed by Matthias Urhahn
parent 3fa01bb510
commit c1cd99f701
61 changed files with 2354 additions and 289 deletions
@@ -1,6 +1,8 @@
package eu.darken.capod.common.bluetooth.l2cap
import android.util.Log
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import java.lang.invoke.MethodHandles
import java.lang.invoke.MethodType
import java.lang.reflect.Method
@@ -20,7 +22,7 @@ import java.lang.reflect.Method
*/
object HiddenApiBypass {
private const val TAG = "HiddenApiBypass"
private val TAG = logTag("HiddenApiBypass")
private val exemptedPrefixes = mutableSetOf<String>()
@@ -114,7 +116,7 @@ object HiddenApiBypass {
}
stubMethod.invoke(runtime, allPrefixes as Any)
exemptionsSet = true
Log.d(TAG, "setHiddenApiExemptions OK: ${allPrefixes.contentToString()}")
log(TAG, VERBOSE) { "setHiddenApiExemptions OK: ${allPrefixes.contentToString()}" }
}
if (runtime != null && exemptionsSet) break
@@ -3,7 +3,9 @@ package eu.darken.capod.common.bluetooth.l2cap
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket
import android.util.Log
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
@@ -18,7 +20,7 @@ import javax.inject.Singleton
@SuppressLint("MissingPermission")
class L2capSocketFactory @Inject constructor() {
private val TAG = "L2capSocketFactory"
private val TAG = logTag("L2capSocketFactory")
private val TYPE_L2CAP = 3
/**
@@ -36,12 +38,12 @@ class L2capSocketFactory @Inject constructor() {
// Strategy 1: Public API via BluetoothSocketSettings (API 37+)
tryPublicApi(device, psm)?.let { socket ->
Log.d(TAG, "Socket created via public BluetoothSocketSettings API")
log(TAG, VERBOSE) { "Socket created via public BluetoothSocketSettings API" }
return socket
}
// Strategy 2: Hidden API via reflection + bypass
Log.d(TAG, "Public API unavailable, using hidden API bypass")
log(TAG, VERBOSE) { "Public API unavailable, using hidden API bypass" }
return createViaHiddenApi(device, psm)
}
@@ -60,18 +62,18 @@ class L2capSocketFactory @Inject constructor() {
val createMethod = BluetoothDevice::class.java.getMethod("createUsingSocketSettings", settingsClass)
createMethod.invoke(device, settings) as BluetoothSocket
} catch (e: ClassNotFoundException) {
Log.d(TAG, "BluetoothSocketSettings not available (pre-API 37)")
log(TAG, VERBOSE) { "BluetoothSocketSettings not available (pre-API 37)" }
null
} catch (e: Exception) {
val cause = if (e is java.lang.reflect.InvocationTargetException) e.cause ?: e else e
when (cause) {
is IllegalArgumentException -> {
Log.d(TAG, "BluetoothSocketSettings does not support TYPE_L2CAP: ${cause.message}")
log(TAG, VERBOSE) { "BluetoothSocketSettings does not support TYPE_L2CAP: ${cause.message}" }
null
}
is SecurityException -> throw cause
else -> {
Log.d(TAG, "BluetoothSocketSettings failed: ${cause::class.simpleName}: ${cause.message}")
log(TAG, VERBOSE) { "BluetoothSocketSettings failed: ${cause::class.simpleName}: ${cause.message}" }
null
}
}
@@ -55,6 +55,7 @@ import eu.darken.capod.main.ui.overview.cards.UnknownPodDeviceCard
import eu.darken.capod.main.ui.overview.cards.UnmatchedDevicesCard
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.PodModel
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting
import java.time.Instant
@Composable
@@ -127,6 +128,8 @@ fun OverviewScreenHost(vm: OverviewViewModel = hiltViewModel()) {
onSettings = { vm.goToSettings() },
onUpgrade = { vm.onUpgrade() },
onToggleUnmatched = { vm.toggleUnmatchedDevices() },
onAncModeChange = { device, mode -> vm.setAncMode(device, mode) },
onConversationAwarenessChange = { device, enabled -> vm.setConversationalAwareness(device, enabled) },
)
}
@@ -138,6 +141,8 @@ fun OverviewScreen(
onSettings: () -> Unit,
onUpgrade: () -> Unit,
onToggleUnmatched: () -> Unit,
onAncModeChange: (PodDevice, AapSetting.AncMode.Value) -> Unit = { _, _ -> },
onConversationAwarenessChange: (PodDevice, Boolean) -> Unit = { _, _ -> },
) {
Scaffold(
topBar = {
@@ -222,7 +227,13 @@ fun OverviewScreen(
items = state.profiledDevices,
key = { it.identifier.hashCode() },
) { device ->
PodDeviceCard(device = device, showDebug = state.isDebugMode, now = state.now)
PodDeviceCard(
device = device,
showDebug = state.isDebugMode,
now = state.now,
onAncModeChange = { mode -> onAncModeChange(device, mode) },
onConversationAwarenessChange = { enabled -> onConversationAwarenessChange(device, enabled) },
)
}
// 5. Monitoring active card
@@ -247,7 +258,13 @@ fun OverviewScreen(
items = state.unmatchedDevices,
key = { "unmatched_${it.identifier.hashCode()}" },
) { device ->
PodDeviceCard(device = device, showDebug = state.isDebugMode, now = state.now)
PodDeviceCard(
device = device,
showDebug = state.isDebugMode,
now = state.now,
onAncModeChange = { mode -> onAncModeChange(device, mode) },
onConversationAwarenessChange = { enabled -> onConversationAwarenessChange(device, enabled) },
)
}
}
}
@@ -257,10 +274,20 @@ fun OverviewScreen(
}
@Composable
private fun PodDeviceCard(device: PodDevice, showDebug: Boolean, now: Instant) {
private fun PodDeviceCard(
device: PodDevice,
showDebug: Boolean,
now: Instant,
onAncModeChange: (AapSetting.AncMode.Value) -> Unit,
onConversationAwarenessChange: (Boolean) -> Unit = {},
) {
when {
device.hasDualPods -> DualPodsCard(device = device, showDebug = showDebug, now = now)
device.model != PodModel.UNKNOWN -> SinglePodsCard(device = device, showDebug = showDebug, now = now)
device.hasDualPods -> DualPodsCard(
device = device, showDebug = showDebug, now = now,
onAncModeChange = onAncModeChange,
onConversationAwarenessChange = onConversationAwarenessChange,
)
device.model != PodModel.UNKNOWN -> SinglePodsCard(device = device, showDebug = showDebug, now = now, onAncModeChange = onAncModeChange)
else -> UnknownPodDeviceCard(device = device, showDebug = showDebug, now = now)
}
}
@@ -20,6 +20,9 @@ import eu.darken.capod.main.core.PermissionTool
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.monitor.core.worker.MonitorControl
import eu.darken.capod.pods.core.apple.protocol.aap.AapCommand
import eu.darken.capod.pods.core.apple.protocol.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import kotlinx.coroutines.delay
@@ -46,6 +49,7 @@ class OverviewViewModel @Inject constructor(
private val upgradeRepo: UpgradeRepo,
private val bluetoothManager: BluetoothManager2,
private val profilesRepo: DeviceProfilesRepo,
private val aapManager: AapConnectionManager,
) : ViewModel4(dispatcherProvider) {
val requestPermissionEvent = SingleEventFlow<Permission>()
@@ -158,6 +162,30 @@ class OverviewViewModel @Inject constructor(
requestPermissionEvent.tryEmit(permission)
}
fun setAncMode(device: PodDevice, mode: AapSetting.AncMode.Value) {
val address = device.address ?: return
launch {
try {
aapManager.sendCommand(address, AapCommand.SetAncMode(mode))
log(TAG) { "ANC mode set to $mode for $address" }
} catch (e: Exception) {
log(TAG) { "Failed to set ANC mode: ${e.message}" }
}
}
}
fun setConversationalAwareness(device: PodDevice, enabled: Boolean) {
val address = device.address ?: return
launch {
try {
aapManager.sendCommand(address, AapCommand.SetConversationalAwareness(enabled))
log(TAG) { "Conversation awareness set to $enabled for $address" }
} catch (e: Exception) {
log(TAG) { "Failed to set conversation awareness: ${e.message}" }
}
}
}
companion object {
private val TAG = logTag("Overview", "VM")
}
@@ -53,6 +53,7 @@ import eu.darken.capod.pods.core.HasStateDetection
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.DualApplePods
import eu.darken.capod.pods.core.apple.DualApplePods.LidState
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting
import eu.darken.capod.pods.core.formatBatteryPercent
import eu.darken.capod.pods.core.toBatteryFloat
import eu.darken.capod.pods.core.toBatteryOrNull
@@ -64,6 +65,8 @@ fun DualPodsCard(
device: PodDevice,
showDebug: Boolean,
now: Instant,
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null,
onConversationAwarenessChange: ((Boolean) -> Unit)? = null,
) {
val context = LocalContext.current
@@ -211,6 +214,27 @@ fun DualPodsCard(
)
}
// ANC mode selector
val ancMode = device.ancMode
if (device.isAapConnected && device.hasAncControl && ancMode != null) {
Spacer(modifier = Modifier.height(12.dp))
AncModeSelector(
currentMode = ancMode.current,
supportedModes = ancMode.supported,
onModeSelected = { onAncModeChange?.invoke(it) },
)
}
// Conversation awareness toggle
val convAwareness = device.conversationalAwareness
if (device.isAapConnected && device.model.features.hasConversationAwareness && convAwareness != null) {
Spacer(modifier = Modifier.height(8.dp))
ConversationAwarenessToggle(
enabled = convAwareness.enabled,
onToggle = { onConversationAwarenessChange?.invoke(it) },
)
}
// Debug info
if (showDebug) {
DebugSection(rawDataHex = device.rawDataHex)
@@ -26,7 +26,11 @@ import androidx.compose.material.icons.twotone.KeyboardVoice
import androidx.compose.material.icons.twotone.SettingsInputAntenna
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -34,7 +38,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import eu.darken.capod.R
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting
private val CapsuleShape = RoundedCornerShape(6.dp)
@@ -193,3 +201,53 @@ fun DebugSection(
)
}
}
@Composable
fun AncModeSelector(
currentMode: AapSetting.AncMode.Value,
supportedModes: List<AapSetting.AncMode.Value>,
onModeSelected: (AapSetting.AncMode.Value) -> Unit,
) {
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
supportedModes.forEachIndexed { index, mode ->
SegmentedButton(
selected = mode == currentMode,
onClick = { onModeSelected(mode) },
shape = SegmentedButtonDefaults.itemShape(index, supportedModes.size),
label = {
Text(
text = when (mode) {
AapSetting.AncMode.Value.OFF -> stringResource(R.string.anc_mode_off)
AapSetting.AncMode.Value.ON -> stringResource(R.string.anc_mode_on)
AapSetting.AncMode.Value.TRANSPARENCY -> stringResource(R.string.anc_mode_transparency)
AapSetting.AncMode.Value.ADAPTIVE -> stringResource(R.string.anc_mode_adaptive)
},
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
)
}
}
}
@Composable
fun ConversationAwarenessToggle(
enabled: Boolean,
onToggle: (Boolean) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.conversation_awareness_label),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
Switch(
checked = enabled,
onCheckedChange = onToggle,
)
}
}
@@ -49,6 +49,7 @@ import eu.darken.capod.monitor.core.firstSeenFormatted
import eu.darken.capod.monitor.core.getSignalQuality
import eu.darken.capod.monitor.core.lastSeenFormatted
import eu.darken.capod.pods.core.apple.ApplePods
import eu.darken.capod.pods.core.apple.protocol.aap.AapSetting
import eu.darken.capod.pods.core.formatBatteryPercent
import java.time.Duration
import java.time.Instant
@@ -59,6 +60,7 @@ fun SinglePodsCard(
device: PodDevice,
showDebug: Boolean,
now: Instant,
onAncModeChange: ((AapSetting.AncMode.Value) -> Unit)? = null,
) {
val context = LocalContext.current
@@ -224,6 +226,17 @@ fun SinglePodsCard(
}
}
// ANC mode selector
val ancMode = device.ancMode
if (device.isAapConnected && device.hasAncControl && ancMode != null) {
Spacer(modifier = Modifier.height(12.dp))
AncModeSelector(
currentMode = ancMode.current,
supportedModes = ancMode.supported,
onModeSelected = { onAncModeChange?.invoke(it) },
)
}
// Debug info
if (showDebug) {
DebugSection(rawDataHex = device.rawDataHex)
@@ -23,9 +23,12 @@ class DeviceMonitor @Inject constructor(
val devices: Flow<List<PodDevice>> = blePodMonitor.devices
.combine(aapManager.allStates) { pods, aapStates ->
pods.map { pod ->
// AAP connections are keyed by bonded BR/EDR address (from profile),
// BLE scans use rotating RPAs. Bridge via the profile's bonded address.
val bondedAddress = pod.meta?.profile?.address
PodDevice(
ble = pod,
aap = aapStates[pod.address],
aap = bondedAddress?.let { aapStates[it] },
)
}
}
@@ -33,7 +36,8 @@ class DeviceMonitor @Inject constructor(
suspend fun getDeviceForProfile(profileId: String): PodDevice? {
log(TAG) { "getDeviceForProfile(profileId=$profileId)" }
val bleDevice = blePodMonitor.getDeviceForProfile(profileId) ?: return null
val aapState = aapManager.allStates.firstOrNull()?.get(bleDevice.address)
val bondedAddress = bleDevice.meta?.profile?.address
val aapState = bondedAddress?.let { aapManager.allStates.firstOrNull()?.get(it) }
return PodDevice(ble = bleDevice, aap = aapState)
}
@@ -28,7 +28,10 @@ data class PodDevice(
) {
// Identity
val model: PodModel get() = ble?.model ?: PodModel.UNKNOWN
val address: BluetoothAddress? get() = ble?.address
/** Bonded BR/EDR address (from profile). Used for AAP commands. */
val address: BluetoothAddress? get() = ble?.meta?.profile?.address
/** BLE scan address (RPA, rotates). */
val bleAddress: BluetoothAddress? get() = ble?.address
val identifier: BlePodSnapshot.Id? get() = ble?.identifier
val meta: BlePodSnapshot.Meta? get() = ble?.meta
@@ -45,31 +48,31 @@ data class PodDevice(
val signalQuality: Float get() = ble?.signalQuality ?: 0f
val rssi: Int get() = ble?.rssi ?: 0
// Battery — best available source
// Battery — AAP preferred, BLE fallback
val batteryLeft: Float?
get() = (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent
get() = aap?.batteryLeft ?: (ble as? DualBlePodSnapshot)?.batteryLeftPodPercent
val batteryRight: Float?
get() = (ble as? DualBlePodSnapshot)?.batteryRightPodPercent
get() = aap?.batteryRight ?: (ble as? DualBlePodSnapshot)?.batteryRightPodPercent
val batteryCase: Float?
get() = (ble as? HasCase)?.batteryCasePercent
get() = aap?.batteryCase ?: (ble as? HasCase)?.batteryCasePercent
val batteryHeadset: Float?
get() = (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent
get() = aap?.batteryHeadset ?: (ble as? SingleBlePodSnapshot)?.batteryHeadsetPercent
// Charging
// Charging — AAP preferred, BLE fallback
val isLeftPodCharging: Boolean?
get() = (ble as? HasChargeDetectionDual)?.isLeftPodCharging
get() = aap?.isLeftCharging ?: (ble as? HasChargeDetectionDual)?.isLeftPodCharging
val isRightPodCharging: Boolean?
get() = (ble as? HasChargeDetectionDual)?.isRightPodCharging
get() = aap?.isRightCharging ?: (ble as? HasChargeDetectionDual)?.isRightPodCharging
val isCaseCharging: Boolean?
get() = (ble as? HasCase)?.isCaseCharging
get() = aap?.isCaseCharging ?: (ble as? HasCase)?.isCaseCharging
val isHeadsetBeingCharged: Boolean?
get() = (ble as? HasChargeDetection)?.isHeadsetBeingCharged
get() = aap?.isHeadsetCharging ?: (ble as? HasChargeDetection)?.isHeadsetBeingCharged
// Ear detection
val isLeftInEar: Boolean?
@@ -115,5 +118,23 @@ data class PodDevice(
val ancMode: AapSetting.AncMode?
get() = aap?.setting()
val conversationalAwareness: AapSetting.ConversationalAwareness?
get() = aap?.setting()
val toneVolume: AapSetting.ToneVolume?
get() = aap?.setting()
val personalizedVolume: AapSetting.PersonalizedVolume?
get() = aap?.setting()
val volumeSwipe: AapSetting.VolumeSwipe?
get() = aap?.setting()
val ncWithOneAirPod: AapSetting.NcWithOneAirPod?
get() = aap?.setting()
val adaptiveAudioNoise: AapSetting.AdaptiveAudioNoise?
get() = aap?.setting()
val isAapConnected: Boolean get() = aap != null
}
@@ -34,6 +34,8 @@ import eu.darken.capod.monitor.core.primaryDevice
import eu.darken.capod.monitor.ui.MonitorNotifications
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import eu.darken.capod.reaction.core.aap.AapAutoConnect
import eu.darken.capod.reaction.core.aap.AapKeyPersister
import eu.darken.capod.reaction.core.autoconnect.AutoConnect
import eu.darken.capod.reaction.core.playpause.PlayPause
import eu.darken.capod.reaction.core.popup.PopUpReaction
@@ -72,6 +74,8 @@ class MonitorService : Service() {
@Inject lateinit var popUpReaction: PopUpReaction
@Inject lateinit var popUpWindow: PopUpWindow
@Inject lateinit var profilesRepo: DeviceProfilesRepo
@Inject lateinit var aapAutoConnect: AapAutoConnect
@Inject lateinit var aapKeyPersister: AapKeyPersister
private val monitorScope = MonitorCoroutineScope()
private var monitoringJob: Job? = null
@@ -287,6 +291,16 @@ class MonitorService : Service() {
.catch { log(TAG, WARN) { "autoConnect failed:\n${it.asLog()}" } }
.launchIn(monitorScope)
aapAutoConnect.monitor()
.setupCommonEventHandlers(TAG) { "aapAutoConnect" }
.catch { log(TAG, WARN) { "aapAutoConnect failed:\n${it.asLog()}" } }
.launchIn(monitorScope)
aapKeyPersister.monitor()
.setupCommonEventHandlers(TAG) { "aapKeyPersister" }
.catch { log(TAG, WARN) { "aapKeyPersister failed:\n${it.asLog()}" } }
.launchIn(monitorScope)
log(TAG, VERBOSE) { "Monitor job is active" }
monitorJob.join()
log(TAG, VERBOSE) { "Monitor job quit" }
@@ -29,31 +29,82 @@ enum class PodModel(
),
@SerialName("airpods.gen4.anc") AIRPODS_GEN4_ANC(
"AirPods (Gen 4 ANC)", R.drawable.device_airpods_gen4anc_both,
Features(hasDualPods = true, hasCase = true, hasEarDetection = true, hasAncControl = true),
Features(
hasDualPods = true, hasCase = true, hasEarDetection = true,
hasAncControl = true, hasAdaptiveAnc = true,
hasConversationAwareness = true, hasNcOneAirpod = true,
hasPressSpeed = true, hasPressHoldDuration = true,
hasVolumeSwipe = true, hasVolumeSwipeLength = true,
hasPersonalizedVolume = true, hasToneVolume = true,
hasEndCallMuteMic = true, hasAdaptiveAudioNoise = true,
needsInitExt = true,
),
),
@SerialName("airpods.pro") AIRPODS_PRO(
"AirPods Pro", R.drawable.device_airpods_pro2_both,
Features(hasDualPods = true, hasCase = true, hasEarDetection = true, hasAncControl = true),
Features(
hasDualPods = true, hasCase = true, hasEarDetection = true,
hasAncControl = true,
hasNcOneAirpod = true,
hasPressSpeed = true, hasPressHoldDuration = true,
hasVolumeSwipe = true, hasVolumeSwipeLength = true,
hasToneVolume = true,
),
),
@SerialName("airpods.pro2") AIRPODS_PRO2(
"AirPods Pro 2", R.drawable.device_airpods_pro2_both,
Features(hasDualPods = true, hasCase = true, hasEarDetection = true, hasAncControl = true),
Features(
hasDualPods = true, hasCase = true, hasEarDetection = true,
hasAncControl = true, hasAdaptiveAnc = true,
hasConversationAwareness = true, hasNcOneAirpod = true,
hasPressSpeed = true, hasPressHoldDuration = true,
hasVolumeSwipe = true, hasVolumeSwipeLength = true,
hasPersonalizedVolume = true, hasToneVolume = true,
hasEndCallMuteMic = true, hasAdaptiveAudioNoise = true,
needsInitExt = true,
),
),
@SerialName("airpods.pro2.usbc") AIRPODS_PRO2_USBC(
"AirPods Pro 2 USB-C", R.drawable.device_airpods_pro2_both,
Features(hasDualPods = true, hasCase = true, hasEarDetection = true, hasAncControl = true),
Features(
hasDualPods = true, hasCase = true, hasEarDetection = true,
hasAncControl = true, hasAdaptiveAnc = true,
hasConversationAwareness = true, hasNcOneAirpod = true,
hasPressSpeed = true, hasPressHoldDuration = true,
hasVolumeSwipe = true, hasVolumeSwipeLength = true,
hasPersonalizedVolume = true, hasToneVolume = true,
hasEndCallMuteMic = true, hasAdaptiveAudioNoise = true,
needsInitExt = true,
),
),
@SerialName("airpods.pro3") AIRPODS_PRO3(
"AirPods Pro 3", R.drawable.device_airpods_pro2_both,
Features(hasDualPods = true, hasCase = true, hasEarDetection = true, hasAncControl = true),
Features(
hasDualPods = true, hasCase = true, hasEarDetection = true,
hasAncControl = true, hasAdaptiveAnc = true,
hasConversationAwareness = true, hasNcOneAirpod = true,
hasPressSpeed = true, hasPressHoldDuration = true,
hasVolumeSwipe = true, hasVolumeSwipeLength = true,
hasPersonalizedVolume = true, hasToneVolume = true,
hasEndCallMuteMic = true, hasAdaptiveAudioNoise = true,
needsInitExt = true,
),
),
@SerialName("airpods.max") AIRPODS_MAX(
"AirPods Max", R.drawable.device_airpods_max,
Features(hasAncControl = true),
Features(
hasAncControl = true,
hasPressSpeed = true, hasPressHoldDuration = true,
hasToneVolume = true,
),
),
@SerialName("airpods.max.usbc") AIRPODS_MAX_USBC(
"AirPods Max USB-C", R.drawable.device_airpods_max,
Features(hasAncControl = true),
Features(
hasAncControl = true,
hasPressSpeed = true, hasPressHoldDuration = true,
hasToneVolume = true,
),
),
@SerialName("beats.flex") BEATS_FLEX(
"Beats Flex", R.drawable.device_beats_earbuds,
@@ -61,10 +112,33 @@ enum class PodModel(
@SerialName("beats.solo.3") BEATS_SOLO_3(
"Beats Solo 3", R.drawable.device_beats_headphones,
),
@SerialName("beats.solo.pro") BEATS_SOLO_PRO(
"Beats Solo Pro", R.drawable.device_beats_headphones,
Features(hasAncControl = true),
),
@SerialName("beats.solo.4") BEATS_SOLO_4(
"Beats Solo 4", R.drawable.device_beats_headphones,
),
@SerialName("beats.solo.buds") BEATS_SOLO_BUDS(
"Beats Solo Buds", R.drawable.device_beats_earbuds,
Features(hasDualPods = true, hasCase = true),
),
@SerialName("beats.studio.3") BEATS_STUDIO_3(
"Beats Studio 3", R.drawable.device_beats_studio3,
Features(hasAncControl = true),
),
@SerialName("beats.studio.buds") BEATS_STUDIO_BUDS(
"Beats Studio Buds", R.drawable.device_beats_earbuds,
Features(hasDualPods = true, hasCase = true, hasEarDetection = true, hasAncControl = true),
),
@SerialName("beats.studio.buds.plus") BEATS_STUDIO_BUDS_PLUS(
"Beats Studio Buds+", R.drawable.device_beats_earbuds,
Features(hasDualPods = true, hasCase = true, hasEarDetection = true, hasAncControl = true),
),
@SerialName("beats.studio.pro") BEATS_STUDIO_PRO(
"Beats Studio Pro", R.drawable.device_beats_headphones,
Features(hasAncControl = true),
),
@SerialName("beats.x") BEATS_X(
"Beats X", R.drawable.device_beats_x,
),
@@ -111,9 +185,25 @@ enum class PodModel(
);
data class Features(
// Physical form
val hasDualPods: Boolean = false,
val hasCase: Boolean = false,
val hasEarDetection: Boolean = false,
// ANC
val hasAncControl: Boolean = false,
val hasAdaptiveAnc: Boolean = false,
// AAP settings
val hasConversationAwareness: Boolean = false,
val hasNcOneAirpod: Boolean = false,
val hasPressSpeed: Boolean = false,
val hasPressHoldDuration: Boolean = false,
val hasVolumeSwipe: Boolean = false,
val hasVolumeSwipeLength: Boolean = false,
val hasPersonalizedVolume: Boolean = false,
val hasToneVolume: Boolean = false,
val hasEndCallMuteMic: Boolean = false,
val hasAdaptiveAudioNoise: Boolean = false,
// Protocol
val needsInitExt: Boolean = false,
)
}
@@ -5,6 +5,15 @@ package eu.darken.capod.pods.core.apple.protocol.aap
* The [AapDeviceProfile] encodes these into the device-specific wire format.
*/
sealed class AapCommand {
data class SetAncMode(val mode: AncModeValue) : AapCommand()
data class SetAncMode(val mode: AapSetting.AncMode.Value) : AapCommand()
data class SetConversationalAwareness(val enabled: Boolean) : AapCommand()
data class SetPressSpeed(val value: AapSetting.PressSpeed.Value) : AapCommand()
data class SetPressHoldDuration(val value: AapSetting.PressHoldDuration.Value) : AapCommand()
data class SetNcWithOneAirPod(val enabled: Boolean) : AapCommand()
data class SetToneVolume(val level: Int) : AapCommand()
data class SetVolumeSwipeLength(val value: AapSetting.VolumeSwipeLength.Value) : AapCommand()
data class SetEndCallMuteMic(val muteMic: AapSetting.EndCallMuteMic.MuteMicMode, val endCall: AapSetting.EndCallMuteMic.EndCallMode) : AapCommand()
data class SetVolumeSwipe(val enabled: Boolean) : AapCommand()
data class SetPersonalizedVolume(val enabled: Boolean) : AapCommand()
data class SetAdaptiveAudioNoise(val level: Int) : AapCommand()
}
@@ -3,13 +3,20 @@ package eu.darken.capod.pods.core.apple.protocol.aap
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket
import android.util.Log
import eu.darken.capod.common.bluetooth.l2cap.L2capSocketFactory
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
@@ -30,63 +37,88 @@ internal class AapConnection(
private val psm: Int = 0x1001,
) {
companion object {
private const val TAG = "AapConnection"
private val TAG = logTag("AapConnection")
}
private val _state = MutableStateFlow(AapPodState())
val state: StateFlow<AapPodState> = _state.asStateFlow()
private val _keysReceived = MutableSharedFlow<KeyExchangeResult>(extraBufferCapacity = 1)
val keysReceived: SharedFlow<KeyExchangeResult> = _keysReceived.asSharedFlow()
private var socket: BluetoothSocket? = null
private var readerJob: Job? = null
private val writeMutex = Mutex()
private val framer = AapFramer()
suspend fun connect() = withContext(Dispatchers.IO) {
if (_state.value.connectionState != AapConnectionState.DISCONNECTED) {
Log.w(TAG, "connect() called in state ${_state.value.connectionState}")
return@withContext
/**
* Opens the L2CAP socket, sends the handshake, and launches the read loop.
* Returns after the handshake is sent — the read loop runs in [scope] independently.
*/
suspend fun connect(scope: CoroutineScope) = withContext(Dispatchers.IO) {
if (_state.value.connectionState != AapPodState.ConnectionState.DISCONNECTED) {
throw IllegalStateException("connect() called in state ${_state.value.connectionState}")
}
_state.value = _state.value.copy(connectionState = AapConnectionState.CONNECTING)
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.CONNECTING)
try {
val sock = socketFactory.createSocket(device, psm)
sock.connect()
socket = sock
Log.d(TAG, "Connected to ${device.address}")
log(TAG) { "Connected to ${device.address}" }
_state.value = _state.value.copy(connectionState = AapConnectionState.HANDSHAKING)
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.HANDSHAKING)
// Send handshake
val handshake = profile.encodeHandshake()
sock.outputStream.write(handshake)
sock.outputStream.flush()
Log.d(TAG, "Handshake sent")
log(TAG) { "Handshake sent" }
// Start read loop
coroutineScope {
readerJob = launch { readLoop(sock) }
// Send notification enable packets — tells device to push battery/settings updates
for (packet in profile.encodeNotificationEnable()) {
sock.outputStream.write(packet)
sock.outputStream.flush()
}
log(TAG) { "Notification enable sent" }
// Send InitExt for models that need it (Pro 2/3/USB-C, AP4 ANC)
profile.encodeInitExt()?.let { initExt ->
sock.outputStream.write(initExt)
sock.outputStream.flush()
log(TAG) { "InitExt sent" }
}
// Request private keys (IRK + ENC) for BLE encrypted battery
profile.encodePrivateKeyRequest()?.let { keyReq ->
sock.outputStream.write(keyReq)
sock.outputStream.flush()
log(TAG) { "Private key request sent" }
}
// Launch read loop in the provided scope — connect() returns immediately
readerJob = scope.launch(Dispatchers.IO) { readLoop(sock) }
} catch (e: Exception) {
Log.e(TAG, "Connection failed", e)
log(TAG, ERROR) { "Connection failed: $e" }
cleanupSocket()
_state.value = AapPodState(connectionState = AapConnectionState.DISCONNECTED)
_state.value = AapPodState(connectionState = AapPodState.ConnectionState.DISCONNECTED)
throw e
}
}
suspend fun disconnect() = withContext(Dispatchers.IO) {
Log.d(TAG, "Disconnecting")
log(TAG) { "Disconnecting" }
readerJob?.cancel()
readerJob = null
cleanupSocket()
framer.reset()
_state.value = AapPodState(connectionState = AapConnectionState.DISCONNECTED)
_state.value = AapPodState(connectionState = AapPodState.ConnectionState.DISCONNECTED)
}
suspend fun send(command: AapCommand) {
val currentState = _state.value
if (currentState.connectionState != AapConnectionState.READY) {
if (currentState.connectionState != AapPodState.ConnectionState.READY) {
throw IllegalStateException("Cannot send command in state ${currentState.connectionState}")
}
@@ -96,7 +128,7 @@ internal class AapConnection(
val sock = socket ?: throw IOException("Socket is null")
sock.outputStream.write(bytes)
sock.outputStream.flush()
Log.d(TAG, "Sent command: $command (${bytes.size} bytes)")
log(TAG) { "Sent command: $command (${bytes.size} bytes)" }
}
}
}
@@ -109,45 +141,67 @@ internal class AapConnection(
while (isActive) {
val len = sock.inputStream.read(buf)
if (len == -1) {
Log.d(TAG, "Stream closed by remote")
log(TAG) { "Stream closed by remote" }
break
}
val messages = framer.consume(buf, 0, len)
for (message in messages) {
// L2CAP SEQPACKET: each read() returns exactly one complete message
val raw = buf.copyOfRange(0, len)
val message = AapMessage.parse(raw)
if (message != null) {
processMessage(message)
if (!handshakeResponseReceived && message.commandType != 0x0009) {
handshakeResponseReceived = true
}
}
// Transition to READY after processing first batch of messages
if (handshakeResponseReceived && _state.value.connectionState == AapConnectionState.HANDSHAKING) {
_state.value = _state.value.copy(connectionState = AapConnectionState.READY)
Log.d(TAG, "Connection READY")
if (handshakeResponseReceived && _state.value.connectionState == AapPodState.ConnectionState.HANDSHAKING) {
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.READY)
log(TAG) { "Connection READY" }
}
}
} catch (e: IOException) {
if (isActive) Log.e(TAG, "Read error", e)
if (isActive) log(TAG, ERROR) { "Read error: $e" }
} finally {
cleanupSocket()
_state.value = _state.value.copy(connectionState = AapConnectionState.DISCONNECTED)
_state.value = _state.value.copy(connectionState = AapPodState.ConnectionState.DISCONNECTED)
}
}
private fun processMessage(message: AapMessage) {
val hex = message.raw.joinToString(" ") { "%02X".format(it) }
log(TAG, VERBOSE) { "MSG cmd=0x${"%04X".format(message.commandType)} len=${message.raw.size} raw=$hex" }
// Try battery
profile.decodeBattery(message)?.let { batteries ->
_state.value = _state.value.copy(batteries = batteries)
log(TAG) { "Battery update: ${batteries.entries.map { "${it.key}=${(it.value.percent * 100).toInt()}% ${it.value.charging}" }}" }
return
}
// Try private key response
profile.decodePrivateKeyResponse(message)?.let { keys ->
log(TAG) { "Private keys received: IRK=${keys.irk != null}, ENC=${keys.encKey != null}" }
_keysReceived.tryEmit(keys)
return
}
// Try device info
profile.decodeDeviceInfo(message)?.let { info ->
_state.value = _state.value.copy(deviceInfo = info)
Log.d(TAG, "Device info: ${info.name} (${info.modelNumber})")
log(TAG) { "Device info: ${info.name} (${info.modelNumber})" }
return
}
// Try setting update (merge into existing state)
profile.decodeSetting(message)?.let { (key, value) ->
_state.value = _state.value.withSetting(key, value)
log(TAG) { "Setting: ${key.simpleName} = $value" }
return
}
log(TAG) { "Unhandled message: cmd=0x${"%04X".format(message.commandType)} payload=${message.payload.size}B" }
}
private fun cleanupSocket() {
@@ -1,15 +1,26 @@
package eu.darken.capod.pods.core.apple.protocol.aap
import android.bluetooth.BluetoothDevice
import android.util.Log
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.bluetooth.l2cap.L2capSocketFactory
import eu.darken.capod.common.coroutine.AppScope
import eu.darken.capod.pods.core.PodModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Inject
import javax.inject.Singleton
@@ -21,57 +32,104 @@ import javax.inject.Singleton
@Singleton
class AapConnectionManager @Inject constructor(
private val socketFactory: L2capSocketFactory,
@AppScope private val scope: CoroutineScope,
) {
companion object {
private const val TAG = "AapConnectionMgr"
private val TAG = logTag("AapConnectionMgr")
}
private val mutex = Mutex()
private val connections = mutableMapOf<BluetoothAddress, AapConnection>()
private val connectionJobs = mutableMapOf<BluetoothAddress, Job>()
private val intentionalDisconnects = mutableSetOf<BluetoothAddress>()
private val _allStates = MutableStateFlow<Map<BluetoothAddress, AapPodState>>(emptyMap())
val allStates: StateFlow<Map<BluetoothAddress, AapPodState>> = _allStates.asStateFlow()
fun deviceState(address: BluetoothAddress): Flow<AapPodState?> =
_allStates.map { it[address] }
private val _disconnectEvents = MutableSharedFlow<BluetoothAddress>(extraBufferCapacity = 16)
val disconnectEvents: SharedFlow<BluetoothAddress> = _disconnectEvents.asSharedFlow()
/** Emits when a connection receives private keys (IRK/ENC) from the device. */
private val _keysReceived = MutableSharedFlow<Pair<BluetoothAddress, KeyExchangeResult>>(extraBufferCapacity = 16)
val keysReceived: SharedFlow<Pair<BluetoothAddress, KeyExchangeResult>> = _keysReceived.asSharedFlow()
fun deviceState(address: BluetoothAddress) = _allStates.map { it[address] }
suspend fun connect(
address: BluetoothAddress,
device: BluetoothDevice,
model: PodModel,
) {
) = mutex.withLock {
if (connections.containsKey(address)) {
Log.d(TAG, "Already connected to $address")
log(TAG) { "Already connected to $address" }
return
}
// Clear stale intentional-disconnect flag from previous connection lifecycle
intentionalDisconnects.remove(address)
val profile = AapDeviceProfile.forModel(model)
val connection = AapConnection(device, profile, socketFactory)
connections[address] = connection
try {
connection.connect()
connection.connect(scope)
} catch (e: Exception) {
connections.remove(address)
throw e
}
// Observe connection state and propagate to allStates
// Note: in production this would use a coroutine scope to collect the flow
updateStates()
// Collect connection state and propagate live updates to allStates.
// The coroutine cancels itself after handling DISCONNECTED,
// which also cancels the child key-forwarding coroutine.
connectionJobs[address] = scope.launch {
// Forward private keys from this connection (child coroutine)
launch {
connection.keysReceived.collect { keys ->
_keysReceived.tryEmit(address to keys)
}
}
connection.state.collect { podState ->
if (podState.connectionState == AapPodState.ConnectionState.DISCONNECTED) {
log(TAG) { "Connection to $address disconnected" }
val wasIntentional = mutex.withLock {
connections.remove(address)
connectionJobs.remove(address)
connection.disconnect()
_allStates.update { it - address }
val intentional = address in intentionalDisconnects
intentionalDisconnects.remove(address)
intentional
}
if (!wasIntentional) {
_disconnectEvents.tryEmit(address)
}
// End this collector coroutine (also cancels child key-forwarding coroutine)
cancel()
} else {
_allStates.update { it + (address to podState) }
}
}
}
}
suspend fun disconnect(address: BluetoothAddress) {
val connection = connections.remove(address) ?: return
suspend fun disconnect(address: BluetoothAddress) = mutex.withLock {
intentionalDisconnects.add(address)
val connection = connections.remove(address) ?: run {
intentionalDisconnects.remove(address)
return
}
connectionJobs.remove(address)?.cancel()
connection.disconnect()
updateStates()
_allStates.update { it - address }
}
suspend fun sendCommand(address: BluetoothAddress, command: AapCommand) {
val connection = connections[address]
val connection = mutex.withLock { connections[address] }
?: throw IllegalStateException("No connection for $address")
connection.send(command)
}
private fun updateStates() {
_allStates.value = connections.mapValues { (_, conn) -> conn.state.value }
}
}
@@ -19,6 +19,12 @@ interface AapDeviceProfile {
*/
fun decodeSetting(message: AapMessage): Pair<KClass<out AapSetting>, AapSetting>?
/**
* Decode a battery notification (command 0x04).
* Returns null if the message is not a battery update.
*/
fun decodeBattery(message: AapMessage): Map<AapPodState.BatteryType, AapPodState.Battery>?
/**
* Decode a device info message (typically command type 0x001D).
*/
@@ -35,7 +41,32 @@ interface AapDeviceProfile {
*/
fun encodeHandshake(): ByteArray
/**
* Encode notification enable packets. These tell the device to push
* battery, settings, and other event notifications.
* Sent after the handshake, before the read loop starts.
*/
fun encodeNotificationEnable(): List<ByteArray>
/**
* Encode the extended init packet (0x4D) for models that require it
* (e.g., Pro 2/3/USB-C, AirPods 4 ANC). Returns null if not needed.
*/
fun encodeInitExt(): ByteArray?
/**
* Encode a private key request (command 0x30).
* Returns null if the model doesn't support key exchange.
*/
fun encodePrivateKeyRequest(): ByteArray?
/**
* Decode a private key response (command 0x31).
* Returns null if the message is not a key response.
*/
fun decodePrivateKeyResponse(message: AapMessage): KeyExchangeResult?
companion object {
fun forModel(model: PodModel): AapDeviceProfile = DefaultAapDeviceProfile()
fun forModel(model: PodModel): AapDeviceProfile = DefaultAapDeviceProfile(model)
}
}
@@ -6,19 +6,70 @@ import kotlin.reflect.KClass
* Pure data representing the current state of an AAP connection. No connection handle.
*/
data class AapPodState(
val connectionState: AapConnectionState = AapConnectionState.DISCONNECTED,
val connectionState: ConnectionState = ConnectionState.DISCONNECTED,
val deviceInfo: AapDeviceInfo? = null,
val settings: Map<KClass<out AapSetting>, AapSetting> = emptyMap(),
val batteries: Map<BatteryType, Battery> = emptyMap(),
) {
inline fun <reified T : AapSetting> setting(): T? = settings[T::class] as? T
fun withSetting(key: KClass<out AapSetting>, value: AapSetting): AapPodState =
copy(settings = settings + (key to value))
}
enum class AapConnectionState {
DISCONNECTED,
CONNECTING,
HANDSHAKING,
READY,
// Battery — from AAP command 0x04, 1% granularity
val batteryLeft: Float? get() = batteries[BatteryType.LEFT]?.percent
val batteryRight: Float? get() = batteries[BatteryType.RIGHT]?.percent
val batteryCase: Float? get() = batteries[BatteryType.CASE]?.percent
val batteryHeadset: Float? get() = batteries[BatteryType.SINGLE]?.percent
// Charging state from AAP battery
val isLeftCharging: Boolean?
get() = batteries[BatteryType.LEFT]?.let { it.charging == ChargingState.CHARGING || it.charging == ChargingState.CHARGING_OPTIMIZED }
val isRightCharging: Boolean?
get() = batteries[BatteryType.RIGHT]?.let { it.charging == ChargingState.CHARGING || it.charging == ChargingState.CHARGING_OPTIMIZED }
val isCaseCharging: Boolean?
get() = batteries[BatteryType.CASE]?.let { it.charging == ChargingState.CHARGING || it.charging == ChargingState.CHARGING_OPTIMIZED }
val isHeadsetCharging: Boolean?
get() = batteries[BatteryType.SINGLE]?.let { it.charging == ChargingState.CHARGING || it.charging == ChargingState.CHARGING_OPTIMIZED }
enum class ConnectionState {
DISCONNECTED,
CONNECTING,
HANDSHAKING,
READY,
}
/** Battery entry from an AAP battery notification (command 0x04). */
data class Battery(
val type: BatteryType,
val percent: Float,
val charging: ChargingState,
)
enum class BatteryType(val wireValue: Int) {
SINGLE(0x01),
RIGHT(0x02),
LEFT(0x04),
CASE(0x08),
;
companion object {
fun fromWire(value: Int): BatteryType? = entries.firstOrNull { it.wireValue == value }
}
}
enum class ChargingState(val wireValue: Int) {
UNDEFINED(0x00),
CHARGING(0x01),
NOT_CHARGING(0x02),
DISCONNECTED(0x04),
// TODO: Observed on AirPods Pro 3 in charging case. Possibly Apple's "Optimized Battery Charging" limit.
// Verify behavior with Pro 2 and Pro 1 to confirm whether this is model-specific or firmware-specific.
CHARGING_OPTIMIZED(0x05),
;
companion object {
fun fromWire(value: Int): ChargingState = entries.firstOrNull { it.wireValue == value } ?: UNDEFINED
}
}
}
@@ -8,11 +8,97 @@ package eu.darken.capod.pods.core.apple.protocol.aap
sealed class AapSetting {
data class AncMode(
val current: AncModeValue,
val supported: List<AncModeValue>,
) : AapSetting()
val current: Value,
val supported: List<Value>,
) : AapSetting() {
enum class Value {
OFF, ON, TRANSPARENCY, ADAPTIVE,
}
}
data class ConversationalAwareness(
val enabled: Boolean,
) : AapSetting()
data class PressSpeed(
val value: Value,
) : AapSetting() {
enum class Value(val wireValue: Int) {
DEFAULT(0x00), SLOWER(0x01), SLOWEST(0x02);
companion object {
fun fromWire(value: Int): Value? = entries.firstOrNull { it.wireValue == value }
}
}
}
data class PressHoldDuration(
val value: Value,
) : AapSetting() {
enum class Value(val wireValue: Int) {
DEFAULT(0x00), SHORTER(0x01), SHORTEST(0x02);
companion object {
fun fromWire(value: Int): Value? = entries.firstOrNull { it.wireValue == value }
}
}
}
data class NcWithOneAirPod(
val enabled: Boolean,
) : AapSetting()
data class ToneVolume(
val level: Int,
) : AapSetting()
data class VolumeSwipeLength(
val value: Value,
) : AapSetting() {
enum class Value(val wireValue: Int) {
DEFAULT(0x00), LONGER(0x01), LONGEST(0x02);
companion object {
fun fromWire(value: Int): Value? = entries.firstOrNull { it.wireValue == value }
}
}
}
data class EndCallMuteMic(
val muteMic: MuteMicMode,
val endCall: EndCallMode,
) : AapSetting() {
enum class MuteMicMode(val wireValue: Int) {
SINGLE_PRESS(0x23), DOUBLE_PRESS(0x22);
companion object {
fun fromWire(value: Int): MuteMicMode? = entries.firstOrNull { it.wireValue == value }
}
}
enum class EndCallMode(val wireValue: Int) {
DOUBLE_PRESS(0x02), SINGLE_PRESS(0x03);
companion object {
fun fromWire(value: Int): EndCallMode? = entries.firstOrNull { it.wireValue == value }
}
}
}
data class VolumeSwipe(
val enabled: Boolean,
) : AapSetting()
data class PersonalizedVolume(
val enabled: Boolean,
) : AapSetting()
data class AdaptiveAudioNoise(
val level: Int,
) : AapSetting()
/** Push-only from device — reports speaking detection state (command 0x4B). */
data class ConversationalAwarenessState(
val speaking: Boolean,
) : AapSetting()
}
@@ -1,8 +0,0 @@
package eu.darken.capod.pods.core.apple.protocol.aap
enum class AncModeValue {
OFF,
ON,
TRANSPARENCY,
ADAPTIVE,
}
@@ -1,32 +1,54 @@
package eu.darken.capod.pods.core.apple.protocol.aap
import eu.darken.capod.pods.core.PodModel
import kotlin.reflect.KClass
/**
* Default AAP device profile covering the known protocol from MagicPodsCore + PoC captures.
* Handles the common wire format used by AirPods Pro 2, Pro 3, and similar H2/H3 chip devices.
* Default AAP device profile covering the known protocol from MagicPodsCore + LibrePods research.
* Handles the common wire format used by all Apple/Beats devices over L2CAP PSM 0x1001.
*
* When model-specific differences are discovered, subclass and override the relevant methods.
* Model-specific behavior (supported ANC modes, InitExt, feature gating) is driven by
* [PodModel.features] — no subclassing needed.
*/
class DefaultAapDeviceProfile : AapDeviceProfile {
class DefaultAapDeviceProfile(
private val model: PodModel = PodModel.UNKNOWN,
) : AapDeviceProfile {
companion object {
// AAP command types (bytes 4-5 of the message, little-endian)
const val CMD_SETTINGS = 0x0009
const val CMD_BATTERY = 0x0004
const val CMD_DEVICE_INFO = 0x001D
const val CMD_PRIVATE_KEYS_RESPONSE = 0x0031
const val CMD_CONVERSATION_AWARENESS_STATE = 0x004B
// Setting IDs (first byte of settings command payload)
const val SETTING_ANC_MODE = 0x0D
const val SETTING_CONVERSATIONAL_AWARENESS = 0x18
const val SETTING_PRESS_SPEED = 0x17
const val SETTING_PRESS_HOLD_DURATION = 0x18
const val SETTING_NC_ONE_AIRPOD = 0x1B
const val SETTING_TONE_VOLUME = 0x1F
const val SETTING_VOLUME_SWIPE_LENGTH = 0x23
const val SETTING_END_CALL_MUTE_MIC = 0x24
const val SETTING_VOLUME_SWIPE = 0x25
const val SETTING_PERSONALIZED_VOLUME = 0x26
const val SETTING_CONVERSATIONAL_AWARENESS = 0x28
const val SETTING_ADAPTIVE_AUDIO_NOISE = 0x2E
// ANC mode wire values
const val ANC_WIRE_OFF = 0x01
const val ANC_WIRE_ON = 0x02
const val ANC_WIRE_TRANSPARENCY = 0x03
const val ANC_WIRE_ADAPTIVE = 0x04
}
// Default supported ANC modes
val DEFAULT_ANC_MODES = listOf(AncModeValue.ON, AncModeValue.TRANSPARENCY, AncModeValue.ADAPTIVE)
private val supportedAncModes: List<AapSetting.AncMode.Value> by lazy {
val features = model.features
when {
!features.hasAncControl -> emptyList()
features.hasAdaptiveAnc -> listOf(AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.TRANSPARENCY, AapSetting.AncMode.Value.ADAPTIVE)
else -> listOf(AapSetting.AncMode.Value.ON, AapSetting.AncMode.Value.TRANSPARENCY)
}
}
override fun encodeHandshake(): ByteArray = byteArrayOf(
@@ -34,18 +56,42 @@ class DefaultAapDeviceProfile : AapDeviceProfile {
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
)
override fun encodeCommand(command: AapCommand): ByteArray = when (command) {
is AapCommand.SetAncMode -> buildSettingsMessage(
SETTING_ANC_MODE,
encodeAncMode(command.mode)
)
is AapCommand.SetConversationalAwareness -> buildSettingsMessage(
SETTING_CONVERSATIONAL_AWARENESS,
if (command.enabled) 0x01 else 0x00
override fun encodeNotificationEnable(): List<ByteArray> = listOf(
byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x0f, 0x00, 0xff.toByte(), 0xff.toByte(), 0xef.toByte(), 0xff.toByte()),
byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x0f, 0x00, 0xff.toByte(), 0xff.toByte(), 0xff.toByte(), 0xff.toByte()),
)
override fun encodeInitExt(): ByteArray? {
if (!model.features.needsInitExt) return null
return byteArrayOf(
0x04, 0x00, 0x04, 0x00, 0x4d, 0x00, 0x0e, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
)
}
override fun encodeCommand(command: AapCommand): ByteArray = when (command) {
is AapCommand.SetAncMode -> buildSettingsMessage(SETTING_ANC_MODE, encodeAncMode(command.mode))
is AapCommand.SetConversationalAwareness -> buildSettingsMessage(SETTING_CONVERSATIONAL_AWARENESS, encodeAppleBool(command.enabled))
is AapCommand.SetPressSpeed -> buildSettingsMessage(SETTING_PRESS_SPEED, command.value.wireValue)
is AapCommand.SetPressHoldDuration -> buildSettingsMessage(SETTING_PRESS_HOLD_DURATION, command.value.wireValue)
is AapCommand.SetNcWithOneAirPod -> buildSettingsMessage(SETTING_NC_ONE_AIRPOD, encodeAppleBool(command.enabled))
is AapCommand.SetToneVolume -> buildSettingsMessage(SETTING_TONE_VOLUME, command.level.coerceIn(0x0F, 0x64))
is AapCommand.SetVolumeSwipeLength -> buildSettingsMessage(SETTING_VOLUME_SWIPE_LENGTH, command.value.wireValue)
is AapCommand.SetVolumeSwipe -> buildSettingsMessage(SETTING_VOLUME_SWIPE, encodeAppleBool(command.enabled))
is AapCommand.SetPersonalizedVolume -> buildSettingsMessage(SETTING_PERSONALIZED_VOLUME, encodeAppleBool(command.enabled))
is AapCommand.SetAdaptiveAudioNoise -> buildSettingsMessage(SETTING_ADAPTIVE_AUDIO_NOISE, command.level.coerceIn(0, 100))
is AapCommand.SetEndCallMuteMic -> buildEndCallMuteMicMessage(command.muteMic, command.endCall)
}
override fun decodeSetting(message: AapMessage): Pair<KClass<out AapSetting>, AapSetting>? {
// Conversation Awareness State is a separate command type (push-only)
if (message.commandType == CMD_CONVERSATION_AWARENESS_STATE) {
if (message.payload.isEmpty()) return null
val value = message.payload[0].toInt() and 0xFF
val speaking = value == 0x01
return AapSetting.ConversationalAwarenessState::class to AapSetting.ConversationalAwarenessState(speaking)
}
if (message.commandType != CMD_SETTINGS) return null
if (message.payload.size < 2) return null
@@ -55,20 +101,123 @@ class DefaultAapDeviceProfile : AapDeviceProfile {
return when (settingId) {
SETTING_ANC_MODE -> {
val mode = decodeAncMode(value) ?: return null
AapSetting.AncMode::class to AapSetting.AncMode(
current = mode,
supported = DEFAULT_ANC_MODES,
)
AapSetting.AncMode::class to AapSetting.AncMode(current = mode, supported = supportedAncModes)
}
SETTING_CONVERSATIONAL_AWARENESS -> {
AapSetting.ConversationalAwareness::class to AapSetting.ConversationalAwareness(
enabled = value != 0,
)
val enabled = decodeAppleBool(value) ?: return null
AapSetting.ConversationalAwareness::class to AapSetting.ConversationalAwareness(enabled)
}
SETTING_PRESS_SPEED -> {
val speed = AapSetting.PressSpeed.Value.fromWire(value) ?: return null
AapSetting.PressSpeed::class to AapSetting.PressSpeed(speed)
}
SETTING_PRESS_HOLD_DURATION -> {
val duration = AapSetting.PressHoldDuration.Value.fromWire(value) ?: return null
AapSetting.PressHoldDuration::class to AapSetting.PressHoldDuration(duration)
}
SETTING_NC_ONE_AIRPOD -> {
val enabled = decodeAppleBool(value) ?: return null
AapSetting.NcWithOneAirPod::class to AapSetting.NcWithOneAirPod(enabled)
}
SETTING_TONE_VOLUME -> {
AapSetting.ToneVolume::class to AapSetting.ToneVolume(level = value)
}
SETTING_VOLUME_SWIPE_LENGTH -> {
val length = AapSetting.VolumeSwipeLength.Value.fromWire(value) ?: return null
AapSetting.VolumeSwipeLength::class to AapSetting.VolumeSwipeLength(length)
}
SETTING_END_CALL_MUTE_MIC -> {
decodeEndCallMuteMic(message.payload)
}
SETTING_VOLUME_SWIPE -> {
val enabled = decodeAppleBool(value) ?: return null
AapSetting.VolumeSwipe::class to AapSetting.VolumeSwipe(enabled)
}
SETTING_PERSONALIZED_VOLUME -> {
val enabled = decodeAppleBool(value) ?: return null
AapSetting.PersonalizedVolume::class to AapSetting.PersonalizedVolume(enabled)
}
SETTING_ADAPTIVE_AUDIO_NOISE -> {
AapSetting.AdaptiveAudioNoise::class to AapSetting.AdaptiveAudioNoise(level = value)
}
else -> null
}
}
override fun decodeBattery(message: AapMessage): Map<AapPodState.BatteryType, AapPodState.Battery>? {
if (message.commandType != CMD_BATTERY) return null
val payload = message.payload
if (payload.isEmpty()) return null
val count = payload[0].toInt() and 0xFF
if (count == 0) return emptyMap()
// Each battery entry is 5 bytes starting at offset 1
val result = mutableMapOf<AapPodState.BatteryType, AapPodState.Battery>()
var offset = 1
for (i in 0 until count) {
if (offset + 5 > payload.size) break
val type = AapPodState.BatteryType.fromWire(payload[offset].toInt() and 0xFF) ?: run {
offset += 5
continue
}
// payload[offset+1] is unknown/reserved
val percent = payload[offset + 2].toInt() and 0xFF
val charging = AapPodState.ChargingState.fromWire(payload[offset + 3].toInt() and 0xFF)
// payload[offset+4] is unknown/reserved
offset += 5
// Values above 100 are not valid battery percentages.
// Known cases: 127 (0x7F) = fake reading after case close, 255 (0xFF) = disconnected.
if (percent > 100) continue
result[type] = AapPodState.Battery(
type = type,
percent = percent / 100f,
charging = charging,
)
}
return result
}
override fun encodePrivateKeyRequest(): ByteArray = byteArrayOf(
0x04, 0x00, 0x04, 0x00, 0x30, 0x00, 0x05, 0x00
)
override fun decodePrivateKeyResponse(message: AapMessage): KeyExchangeResult? {
if (message.commandType != CMD_PRIVATE_KEYS_RESPONSE) return null
val payload = message.payload
if (payload.isEmpty()) return null
val keyCount = payload[0].toInt() and 0xFF
var irk: ByteArray? = null
var encKey: ByteArray? = null
var offset = 1
for (i in 0 until keyCount) {
// Each entry: keyType(1), unknown(1), keyLength(1), unknown(1), keyData(keyLength)
if (offset + 4 > payload.size) break
val keyType = payload[offset].toInt() and 0xFF
// offset+1 is unknown
val keyLength = payload[offset + 2].toInt() and 0xFF
// offset+3 is unknown
offset += 4
if (offset + keyLength > payload.size) break
val keyData = payload.copyOfRange(offset, offset + keyLength)
offset += keyLength
when (keyType) {
0x01 -> if (keyLength == 16) irk = keyData // IRK
0x04 -> if (keyLength == 16) encKey = keyData // ENC
}
}
return if (irk != null || encKey != null) KeyExchangeResult(irk, encKey) else null
}
override fun decodeDeviceInfo(message: AapMessage): AapDeviceInfo? {
if (message.commandType != CMD_DEVICE_INFO) return null
if (message.payload.size < 10) return null
@@ -87,18 +236,28 @@ class DefaultAapDeviceProfile : AapDeviceProfile {
)
}
protected fun encodeAncMode(mode: AncModeValue): Int = when (mode) {
AncModeValue.OFF -> ANC_WIRE_OFF
AncModeValue.ON -> ANC_WIRE_ON
AncModeValue.TRANSPARENCY -> ANC_WIRE_TRANSPARENCY
AncModeValue.ADAPTIVE -> ANC_WIRE_ADAPTIVE
protected fun encodeAncMode(mode: AapSetting.AncMode.Value): Int = when (mode) {
AapSetting.AncMode.Value.OFF -> ANC_WIRE_OFF
AapSetting.AncMode.Value.ON -> ANC_WIRE_ON
AapSetting.AncMode.Value.TRANSPARENCY -> ANC_WIRE_TRANSPARENCY
AapSetting.AncMode.Value.ADAPTIVE -> ANC_WIRE_ADAPTIVE
}
protected fun decodeAncMode(wireValue: Int): AncModeValue? = when (wireValue) {
ANC_WIRE_OFF -> AncModeValue.OFF
ANC_WIRE_ON -> AncModeValue.ON
ANC_WIRE_TRANSPARENCY -> AncModeValue.TRANSPARENCY
ANC_WIRE_ADAPTIVE -> AncModeValue.ADAPTIVE
protected fun decodeAncMode(wireValue: Int): AapSetting.AncMode.Value? = when (wireValue) {
ANC_WIRE_OFF -> AapSetting.AncMode.Value.OFF
ANC_WIRE_ON -> AapSetting.AncMode.Value.ON
ANC_WIRE_TRANSPARENCY -> AapSetting.AncMode.Value.TRANSPARENCY
ANC_WIRE_ADAPTIVE -> AapSetting.AncMode.Value.ADAPTIVE
else -> null
}
/** Standard Apple boolean encoding: 0x01=on, 0x02=off. */
private fun encodeAppleBool(enabled: Boolean): Int = if (enabled) 0x01 else 0x02
/** Decode Apple boolean: 0x01=true, 0x02=false, anything else=null (unknown). */
private fun decodeAppleBool(wireValue: Int): Boolean? = when (wireValue) {
0x01 -> true
0x02 -> false
else -> null
}
@@ -109,17 +268,49 @@ class DefaultAapDeviceProfile : AapDeviceProfile {
0x00, 0x00, 0x00,
)
/** EndCallMuteMic uses a special 2-byte format: [0x24] [0x21] [muteMic] [endCall] [0x00] */
private fun buildEndCallMuteMicMessage(muteMic: AapSetting.EndCallMuteMic.MuteMicMode, endCall: AapSetting.EndCallMuteMic.EndCallMode): ByteArray = byteArrayOf(
0x04, 0x00, 0x04, 0x00,
0x09, 0x00,
SETTING_END_CALL_MUTE_MIC.toByte(), 0x21,
muteMic.wireValue.toByte(), endCall.wireValue.toByte(),
0x00,
)
private fun decodeEndCallMuteMic(payload: ByteArray): Pair<KClass<out AapSetting>, AapSetting>? {
if (payload.size < 4) return null
val subType = payload[1].toInt() and 0xFF
return when (subType) {
0x21 -> {
// Standard format: byte 2 = muteMic, byte 3 = endCall
val muteMic = AapSetting.EndCallMuteMic.MuteMicMode.fromWire(payload[2].toInt() and 0xFF) ?: return null
val endCall = AapSetting.EndCallMuteMic.EndCallMode.fromWire(payload[3].toInt() and 0xFF) ?: return null
AapSetting.EndCallMuteMic::class to AapSetting.EndCallMuteMic(muteMic, endCall)
}
0x00, 0x20 -> {
// Alternate/compact response format: byte 2 is a combined mode value
val combined = payload[2].toInt() and 0xFF
val (muteMic, endCall) = when (combined) {
0x02 -> AapSetting.EndCallMuteMic.MuteMicMode.SINGLE_PRESS to AapSetting.EndCallMuteMic.EndCallMode.DOUBLE_PRESS
0x03 -> AapSetting.EndCallMuteMic.MuteMicMode.DOUBLE_PRESS to AapSetting.EndCallMuteMic.EndCallMode.SINGLE_PRESS
else -> return null
}
AapSetting.EndCallMuteMic::class to AapSetting.EndCallMuteMic(muteMic, endCall)
}
else -> null
}
}
private fun parseNullTerminatedStrings(data: ByteArray): List<String> {
val strings = mutableListOf<String>()
var start = 0
// Skip initial length/flags bytes (first few bytes before string data)
val stringDataStart = data.indexOfFirst { it == 0x00.toByte() && data.indexOf(it.toByte()) > 2 }
.takeIf { it >= 0 } ?: return strings
// Find runs of printable ASCII separated by null bytes
// Find runs of printable ASCII (0x20..0x7E) separated by null bytes.
// Header bytes and non-ASCII bytes are skipped.
var i = 0
while (i < data.size) {
if (data[i] != 0x00.toByte() && data[i].toInt() and 0xFF >= 0x20) {
val b = data[i].toInt() and 0xFF
if (b in 0x20..0x7E) {
start = i
while (i < data.size && data[i] != 0x00.toByte()) i++
strings.add(String(data, start, i - start, Charsets.US_ASCII))
@@ -0,0 +1,29 @@
package eu.darken.capod.pods.core.apple.protocol.aap
/**
* Result of parsing an AAP private key response (command 0x31).
* Contains the Identity Resolving Key (IRK) for BLE RPA verification
* and the Encryption Key (ENC) for BLE encrypted battery decryption.
*/
data class KeyExchangeResult(
val irk: ByteArray?,
val encKey: ByteArray?,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is KeyExchangeResult) return false
return irk.contentEquals(other.irk) && encKey.contentEquals(other.encKey)
}
override fun hashCode(): Int {
var result = irk?.contentHashCode() ?: 0
result = 31 * result + (encKey?.contentHashCode() ?: 0)
return result
}
}
private fun ByteArray?.contentEquals(other: ByteArray?): Boolean = when {
this == null && other == null -> true
this != null && other != null -> this.contentEquals(other)
else -> false
}
@@ -0,0 +1,121 @@
package eu.darken.capod.reaction.core.aap
import eu.darken.capod.common.bluetooth.BluetoothManager2
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.monitor.core.BlePodMonitor
import eu.darken.capod.pods.core.apple.protocol.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.protocol.aap.AapPodState
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AapAutoConnect @Inject constructor(
private val aapManager: AapConnectionManager,
private val profilesRepo: DeviceProfilesRepo,
private val bluetoothManager: BluetoothManager2,
private val blePodMonitor: BlePodMonitor,
) {
private val activeReconnects = java.util.Collections.synchronizedSet(mutableSetOf<String>())
fun monitor(): Flow<Unit> = merge(
initialConnect(),
reconnectOnDisconnect(),
)
private fun initialConnect(): Flow<Unit> = profilesRepo.profiles
.map { profiles ->
val bondedDevices = bluetoothManager.bondedDevices().first()
for (profile in profiles) {
val address = profile.address ?: continue
val bonded = bondedDevices.firstOrNull { it.address == address } ?: continue
val currentState = aapManager.allStates.value[address]
if (currentState != null && currentState.connectionState != AapPodState.ConnectionState.DISCONNECTED) {
log(TAG, VERBOSE) { "AAP already connected/connecting to $address" }
continue
}
try {
log(TAG) { "AAP connecting to $address (${profile.label})" }
aapManager.connect(address, bonded.internal, profile.model)
log(TAG) { "AAP connected to $address" }
} catch (e: Exception) {
log(TAG, WARN) { "AAP connect failed for $address: ${e.message}" }
}
}
}
.setupCommonEventHandlers(TAG) { "initialConnect" }
private fun reconnectOnDisconnect(): Flow<Unit> = aapManager.disconnectEvents
.onEach { address ->
if (!activeReconnects.add(address)) {
log(TAG, VERBOSE) { "AAP reconnect already in progress for $address, skipping" }
return@onEach
}
val backoffDelays = longArrayOf(5_000, 10_000, 30_000, 60_000)
for ((attempt, delayMs) in backoffDelays.withIndex()) {
delay(delayMs)
// Check if still profiled
val profile = profilesRepo.profiles.first()
.firstOrNull { it.address == address }
if (profile == null) {
log(TAG) { "AAP reconnect: $address no longer profiled, stopping" }
break
}
// Check if still bonded
val bonded = bluetoothManager.bondedDevices().first()
.firstOrNull { it.address == address }
if (bonded == null) {
log(TAG) { "AAP reconnect: $address no longer bonded, stopping" }
break
}
// Check if still visible in BLE
val bleDevices = blePodMonitor.devices.first()
if (bleDevices.none { it.address == address }) {
log(TAG) { "AAP reconnect: $address no longer visible in BLE, stopping" }
break
}
// Check if already reconnected (e.g., by initialConnect)
val currentState = aapManager.allStates.value[address]
if (currentState != null && currentState.connectionState != AapPodState.ConnectionState.DISCONNECTED) {
log(TAG) { "AAP reconnect: $address already reconnected" }
break
}
try {
log(TAG) { "AAP reconnect attempt ${attempt + 1} for $address in ${delayMs}ms" }
aapManager.connect(address, bonded.internal, profile.model)
log(TAG) { "AAP reconnected to $address" }
break
} catch (e: Exception) {
log(TAG, WARN) { "AAP reconnect attempt ${attempt + 1} failed for $address: ${e.message}" }
}
}
activeReconnects.remove(address)
}
.map { } // SharedFlow<BluetoothAddress> → Flow<Unit>
.setupCommonEventHandlers(TAG) { "reconnect" }
companion object {
private val TAG = logTag("Reaction", "AapAutoConnect")
}
}
@@ -0,0 +1,52 @@
package eu.darken.capod.reaction.core.aap
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
import eu.darken.capod.pods.core.apple.protocol.aap.AapConnectionManager
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
import javax.inject.Singleton
/**
* Persists IRK/ENC keys from AAP key exchange to [AppleDeviceProfile],
* enabling BLE encrypted battery decryption with 1% granularity.
*/
@Singleton
class AapKeyPersister @Inject constructor(
private val aapManager: AapConnectionManager,
private val profilesRepo: DeviceProfilesRepo,
) {
fun monitor(): Flow<Unit> = aapManager.keysReceived
.onEach { (address, keys) ->
val profile = profilesRepo.profiles.first()
.filterIsInstance<AppleDeviceProfile>()
.firstOrNull { it.address == address }
if (profile == null) {
log(TAG) { "No profile found for $address, skipping key persistence" }
return@onEach
}
val updated = profile.copy(
identityKey = keys.irk ?: profile.identityKey,
encryptionKey = keys.encKey ?: profile.encryptionKey,
)
if (updated != profile) {
profilesRepo.updateProfile(updated)
log(TAG) { "Persisted keys for $address (IRK=${keys.irk != null}, ENC=${keys.encKey != null})" }
}
}
.map { }
.setupCommonEventHandlers(TAG) { "keyPersister" }
companion object {
private val TAG = logTag("Reaction", "AapKeyPersister")
}
}
+5
View File
@@ -258,6 +258,11 @@
<string name="pods_charging_label">Charging</string>
<string name="pods_inear_label">In ear</string>
<string name="pods_microphone_label">Microphone</string>
<string name="anc_mode_off">Off</string>
<string name="anc_mode_on">ANC</string>
<string name="anc_mode_transparency">Transparency</string>
<string name="anc_mode_adaptive">Adaptive</string>
<string name="conversation_awareness_label">Conversation Awareness</string>
<string name="pods_yours">Yours</string>
<string name="headset_being_worn_label">Being worn</string>
<string name="headset_not_being_worn_label">Not being worn</string>