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