feat: Add AAP protocol layer, L2CAP socket factory, and Model.Features

Add L2CAP infrastructure (HiddenApiBypass, ArtMirror, L2capSocketFactory) in common/bluetooth/l2cap/ for BR/EDR L2CAP socket creation on Android 17+.

Add AAP protocol abstraction (AapSetting, AapCommand, AapMessage, AapFramer, AapDeviceProfile, AapConnection, AapConnectionManager) with sealed domain classes containing zero wire bytes.

Add Model.Features capability flags (hasDualPods, hasCase, hasEarDetection, hasAncControl) to all 25 device model enum entries.

Add debug-only L2capPocActivity for testing AAP connections.
This commit is contained in:
darken
2026-03-31 19:17:09 +02:00
committed by Matthias Urhahn
parent b919d8d19b
commit 3e2852e622
17 changed files with 1197 additions and 50 deletions
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name="eu.darken.capod.debug.l2cap.L2capPocActivity"
android:exported="true"
android:label="L2CAP PoC">
<intent-filter>
<action android:name="eu.darken.capod.L2CAP_POC" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,256 @@
package eu.darken.capod.debug.l2cap
import eu.darken.capod.common.bluetooth.l2cap.L2capSocketFactory
import android.Manifest
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothSocket
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.InputStream
import java.io.OutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class L2capPocActivity : ComponentActivity() {
companion object {
private const val TAG = "L2capPoc"
private const val PSM = 0x1001
private val HANDSHAKE = byteArrayOf(
0x00, 0x00, 0x04, 0x00, 0x01, 0x00, 0x02, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
)
private val ANC_OFF = byteArrayOf(
0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x0d, 0x01, 0x00, 0x00, 0x00
)
private val ANC_ON = byteArrayOf(
0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x0d, 0x02, 0x00, 0x00, 0x00
)
private val TRANSPARENCY = byteArrayOf(
0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x0d, 0x03, 0x00, 0x00, 0x00
)
}
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) Log.d(TAG, "BLUETOOTH_CONNECT granted")
else Log.w(TAG, "BLUETOOTH_CONNECT denied")
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED
) {
permissionLauncher.launch(Manifest.permission.BLUETOOTH_CONNECT)
}
setContent {
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
PocScreen()
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@SuppressLint("MissingPermission")
@Composable
private fun PocScreen() {
val btManager = remember { getSystemService(BluetoothManager::class.java) }
val adapter: BluetoothAdapter? = remember { btManager?.adapter }
val logEntries = remember { mutableStateListOf<String>() }
val logListState = rememberLazyListState()
var selectedDevice by remember { mutableStateOf<BluetoothDevice?>(null) }
var socket by remember { mutableStateOf<BluetoothSocket?>(null) }
var outputStream by remember { mutableStateOf<OutputStream?>(null) }
var connected by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
var readerJob by remember { mutableStateOf<Job?>(null) }
val timeFmt = remember { SimpleDateFormat("HH:mm:ss.SSS", Locale.US) }
fun log(msg: String) {
val line = "${timeFmt.format(Date())} $msg"
Log.d(TAG, msg)
logEntries.add(line)
}
fun ByteArray.hex(): String = joinToString(" ") { "%02X".format(it) }
LaunchedEffect(logEntries.size) {
if (logEntries.isNotEmpty()) logListState.animateScrollToItem(logEntries.size - 1)
}
fun startReader(input: InputStream) {
readerJob = scope.launch(Dispatchers.IO) {
val buf = ByteArray(1024)
try {
while (isActive) {
val len = input.read(buf)
if (len == -1) {
launch(Dispatchers.Main) { log("<<< Stream closed by remote") }
break
}
val data = buf.copyOf(len)
launch(Dispatchers.Main) { log("<<< [${data.size}] ${data.hex()}") }
}
} catch (e: Exception) {
launch(Dispatchers.Main) { log("<<< Read error: ${e.message}") }
}
}
}
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Text("L2CAP PoC — AAP Connection", style = MaterialTheme.typography.titleMedium)
if (adapter == null) {
Text("No Bluetooth adapter found", color = MaterialTheme.colorScheme.error)
return@Column
}
// Bonded devices
if (!connected) {
Text("Bonded Devices:", style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(top = 8.dp))
val bonded = remember(adapter) {
try { adapter.bondedDevices?.toList() ?: emptyList() }
catch (_: SecurityException) { emptyList() }
}
bonded.forEach { device ->
val name = try { device.name ?: "Unknown" } catch (_: SecurityException) { "Unknown" }
val isSelected = selectedDevice == device
Card(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp).clickable { selectedDevice = device },
colors = if (isSelected) CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer) else CardDefaults.cardColors()
) {
Row(modifier = Modifier.padding(8.dp)) {
Text("$name ${device.address}", style = MaterialTheme.typography.bodySmall)
}
}
}
}
// Connection controls
FlowRow(modifier = Modifier.padding(vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = {
val dev = selectedDevice ?: return@Button
log("=== Connecting to ${dev.address} ===")
scope.launch(Dispatchers.IO) {
try {
val sock = L2capSocketFactory.createSocket(dev, PSM)
withContext(Dispatchers.Main) { log("Socket created, connecting...") }
sock.connect()
withContext(Dispatchers.Main) {
log("Connected!")
socket = sock
outputStream = sock.outputStream
connected = true
}
startReader(sock.inputStream)
withContext(Dispatchers.Main) { log(">>> Handshake [${HANDSHAKE.size}] ${HANDSHAKE.hex()}") }
sock.outputStream.write(HANDSHAKE)
sock.outputStream.flush()
} catch (e: Exception) {
withContext(Dispatchers.Main) { log("Failed: ${e::class.simpleName}: ${e.message}") }
}
}
},
enabled = selectedDevice != null && !connected
) { Text("Connect") }
Button(
onClick = {
log("=== Disconnecting ===")
readerJob?.cancel()
readerJob = null
try { socket?.close() } catch (_: Exception) {}
socket = null
outputStream = null
connected = false
},
enabled = connected
) { Text("Disconnect") }
}
// AAP command buttons
if (connected) {
FlowRow(modifier = Modifier.padding(vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
fun sendCommand(name: String, cmd: ByteArray) {
scope.launch(Dispatchers.IO) {
try {
withContext(Dispatchers.Main) { log(">>> $name [${cmd.size}] ${cmd.hex()}") }
outputStream?.write(cmd)
outputStream?.flush()
} catch (e: Exception) {
withContext(Dispatchers.Main) { log(">>> Send error: ${e.message}") }
}
}
}
Button(onClick = { sendCommand("ANC Off", ANC_OFF) }) { Text("ANC Off") }
Button(onClick = { sendCommand("ANC On", ANC_ON) }) { Text("ANC On") }
Button(onClick = { sendCommand("Transparency", TRANSPARENCY) }) { Text("Transparency") }
Button(onClick = { sendCommand("Handshake", HANDSHAKE) }) { Text("Handshake") }
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
LazyColumn(state = logListState, modifier = Modifier.weight(1f)) {
items(logEntries) { entry ->
Text(text = entry, fontFamily = FontFamily.Monospace, fontSize = 11.sp, lineHeight = 14.sp)
}
}
}
}
}
@@ -0,0 +1,71 @@
@file:Suppress("unused")
package eu.darken.capod.common.bluetooth.l2cap
import androidx.annotation.Keep
import java.lang.invoke.MethodType
/**
* Mirror classes matching ART's internal field layout for offset calculation.
* [sun.misc.Unsafe.objectFieldOffset] on these classes gives the same offsets as the real
* hidden fields, because the field types and order are identical.
*
* Based on LSPosed/AndroidHiddenApiBypass (Apache 2.0)
* https://github.com/LSPosed/AndroidHiddenApiBypass
*/
@Keep
object ArtMirror {
@Keep
open class AccessibleObject {
private val override: Boolean = false
}
@Keep
class Executable : AccessibleObject() {
private val declaringClass: Any? = null
private val declaringClassOfOverriddenMethod: Any? = null
private val parameters: Array<Any>? = null
@JvmField val artMethod: Long = 0
private val accessFlags: Int = 0
}
@Keep
class MethodHandle {
private val type: MethodType? = null
private val nominalType: MethodType? = null
private val cachedSpreadInvoker: MethodHandle? = null
private val handleKind: Int = 0
@JvmField val artFieldOrMethod: Long = 0
}
@Keep
class Class {
private val classLoader: ClassLoader? = null
private val componentType: kotlin.Any? = null
private val dexCache: kotlin.Any? = null
private val extData: kotlin.Any? = null
private val ifTable: Array<kotlin.Any>? = null
private val name: String? = null
private val superClass: kotlin.Any? = null
private val vtable: kotlin.Any? = null
@JvmField val iFields: Long = 0
@JvmField val methods: Long = 0
@JvmField val sFields: Long = 0
private val accessFlags: Int = 0
private val classFlags: Int = 0
private val classSize: Int = 0
private val clinitThreadId: Int = 0
private val dexClassDefIndex: Int = 0
@Volatile private var dexTypeIndex: Int = 0
private val numReferenceInstanceFields: Int = 0
private val numReferenceStaticFields: Int = 0
private val objectSize: Int = 0
private val objectSizeAllocFastPath: Int = 0
private val primitiveType: Int = 0
private val referenceInstanceOffsets: Int = 0
private val status: Int = 0
private val copiedMethodsOffset: Short = 0
private val virtualMethodsOffset: Short = 0
}
}
@@ -0,0 +1,130 @@
package eu.darken.capod.common.bluetooth.l2cap
import android.util.Log
import java.lang.invoke.MethodHandles
import java.lang.invoke.MethodType
import java.lang.reflect.Method
/**
* Bypasses Android's hidden API restrictions by calling [dalvik.system.VMRuntime.setHiddenApiExemptions].
*
* Technique: Uses [sun.misc.Unsafe] to iterate ART's internal method array and swap a stub method's
* artMethod pointer to execute hidden methods. Mirror classes in [ArtMirror] provide field offsets
* without triggering hidden API checks.
*
* Based on LSPosed/AndroidHiddenApiBypass (Apache 2.0)
* https://github.com/LSPosed/AndroidHiddenApiBypass
*
* Copyright 2021 LSPosed
* Licensed under the Apache License, Version 2.0
*/
object HiddenApiBypass {
private const val TAG = "HiddenApiBypass"
private val exemptedPrefixes = mutableSetOf<String>()
@androidx.annotation.Keep
private object InvokeStub {
@JvmStatic
fun invoke(vararg args: Any?): Any? {
throw IllegalStateException("Stub — artMethod not swapped")
}
}
@Synchronized
fun setExemptions(vararg prefixes: String) {
// Merge with existing prefixes (VMRuntime.setHiddenApiExemptions replaces, not merges)
val newPrefixes = prefixes.filter { it !in exemptedPrefixes }
if (newPrefixes.isEmpty()) return
exemptedPrefixes.addAll(newPrefixes)
val allPrefixes = exemptedPrefixes.toTypedArray()
val unsafe = Class.forName("sun.misc.Unsafe").getDeclaredMethod("getUnsafe").invoke(null)
val unsafeClass = unsafe::class.java
val objectFieldOffset = unsafeClass.getMethod("objectFieldOffset", java.lang.reflect.Field::class.java)
val getLong = unsafeClass.getMethod("getLong", Any::class.java, Long::class.javaPrimitiveType)
val putLong = unsafeClass.getMethod("putLong", Any::class.java, Long::class.javaPrimitiveType, Long::class.javaPrimitiveType)
val getInt = unsafeClass.getMethod("getInt", Long::class.javaPrimitiveType)
// --- Offset calculation from mirror classes ---
val artMethodOff = objectFieldOffset.invoke(unsafe,
ArtMirror.Executable::class.java.getDeclaredField("artMethod")) as Long
val artFieldOrMethodOff = objectFieldOffset.invoke(unsafe,
ArtMirror.MethodHandle::class.java.getDeclaredField("artFieldOrMethod")) as Long
val methodsOff = objectFieldOffset.invoke(unsafe,
ArtMirror.Class::class.java.getDeclaredField("methods")) as Long
// --- Sanity check: verify artMethod offset matches the real accessible field ---
val realArtMethodOff = objectFieldOffset.invoke(unsafe,
java.lang.reflect.Executable::class.java.getDeclaredField("artMethod")) as Long
check(artMethodOff == realArtMethodOff) {
"ArtMirror.Executable.artMethod offset ($artMethodOff) != real offset ($realArtMethodOff). " +
"ART field layout has changed — mirror classes need updating."
}
// --- Calculate artMethodSize and bias from NeverCall ---
val mA = NeverCall::class.java.getDeclaredMethod("a").apply { isAccessible = true }
val mB = NeverCall::class.java.getDeclaredMethod("b").apply { isAccessible = true }
val mhA = MethodHandles.lookup().unreflect(mA)
val mhB = MethodHandles.lookup().unreflect(mB)
val aAddr = getLong.invoke(unsafe, mhA, artFieldOrMethodOff) as Long
val bAddr = getLong.invoke(unsafe, mhB, artFieldOrMethodOff) as Long
val artMethodSize = bAddr - aAddr
check(artMethodSize in 16..256) {
"artMethodSize=$artMethodSize is outside expected range [16, 256]. ART internals may have changed."
}
val ncMethods = getLong.invoke(unsafe, NeverCall::class.java, methodsOff) as Long
val artMethodBias = aAddr - ncMethods - artMethodSize
// --- Iterate VMRuntime methods ---
val vmRuntimeClass = Class.forName("dalvik.system.VMRuntime")
val vmMethods = getLong.invoke(unsafe, vmRuntimeClass, methodsOff) as Long
val numMethods = getInt.invoke(unsafe, vmMethods) as Int
check(numMethods in 1..10000) {
"VMRuntime numMethods=$numMethods is outside expected range. Pointer may be invalid."
}
val stubMethod: Method = InvokeStub::class.java.getDeclaredMethod("invoke", Array<Any?>::class.java)
stubMethod.isAccessible = true
val originalArtMethod = getLong.invoke(unsafe, stubMethod, artMethodOff) as Long
var runtime: Any? = null
var exemptionsSet = false
try {
for (i in 0 until numMethods) {
val methodPtr = vmMethods + i * artMethodSize + artMethodBias
putLong.invoke(unsafe, stubMethod, artMethodOff, methodPtr)
val name = stubMethod.name
val params = stubMethod.parameterTypes
if (name == "getRuntime" && params.isEmpty() && runtime == null) {
runtime = stubMethod.invoke(null)
}
if (name == "setHiddenApiExemptions" && params.size == 1 && params[0] == Array<String>::class.java) {
if (runtime == null) {
throw IllegalStateException("Found setHiddenApiExemptions before getRuntime")
}
stubMethod.invoke(runtime, allPrefixes as Any)
exemptionsSet = true
Log.d(TAG, "setHiddenApiExemptions OK: ${allPrefixes.contentToString()}")
}
if (runtime != null && exemptionsSet) break
}
} finally {
// Always restore the stub's original artMethod pointer
putLong.invoke(unsafe, stubMethod, artMethodOff, originalArtMethod)
}
if (runtime == null) throw RuntimeException("VMRuntime.getRuntime() not found in method array")
if (!exemptionsSet) throw RuntimeException("VMRuntime.setHiddenApiExemptions() not found in method array")
}
}
@@ -0,0 +1,97 @@
package eu.darken.capod.common.bluetooth.l2cap
import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket
import android.util.Log
import java.io.IOException
/**
* Creates BR/EDR L2CAP sockets for connecting to devices like AirPods.
*
* Tries the public [android.bluetooth.BluetoothSocketSettings] API first (API 37+).
* Falls back to the hidden `createInsecureL2capSocket` method via [HiddenApiBypass].
*/
@SuppressLint("MissingPermission")
object L2capSocketFactory {
private const val TAG = "L2capSocketFactory"
private const val TYPE_L2CAP = 3
/**
* Creates an insecure BR/EDR L2CAP socket for the given [device] and [psm].
*
* The socket is created but not connected — call [BluetoothSocket.connect] separately.
*
* @throws IllegalArgumentException if [psm] is invalid
* @throws SecurityException if BLUETOOTH_CONNECT permission is missing
* @throws NoSuchMethodException if the hidden API is unavailable on this Android version
* @throws IOException if socket creation fails at the transport level
*/
fun createSocket(device: BluetoothDevice, psm: Int): BluetoothSocket {
require(psm > 0) { "Invalid PSM: $psm" }
// Strategy 1: Public API via BluetoothSocketSettings (API 37+)
tryPublicApi(device, psm)?.let { socket ->
Log.d(TAG, "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")
return createViaHiddenApi(device, psm)
}
private fun tryPublicApi(device: BluetoothDevice, psm: Int): BluetoothSocket? {
return try {
val settingsClass = Class.forName("android.bluetooth.BluetoothSocketSettings")
val builderClass = Class.forName("android.bluetooth.BluetoothSocketSettings\$Builder")
val builder = builderClass.getDeclaredConstructor().newInstance()
builderClass.getMethod("setSocketType", Int::class.javaPrimitiveType).invoke(builder, TYPE_L2CAP)
builderClass.getMethod("setL2capPsm", Int::class.javaPrimitiveType).invoke(builder, psm)
builderClass.getMethod("setAuthenticationRequired", Boolean::class.javaPrimitiveType).invoke(builder, false)
builderClass.getMethod("setEncryptionRequired", Boolean::class.javaPrimitiveType).invoke(builder, false)
val settings = builderClass.getMethod("build").invoke(builder)
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)")
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}")
null
}
is SecurityException -> throw cause
else -> {
Log.d(TAG, "BluetoothSocketSettings failed: ${cause::class.simpleName}: ${cause.message}")
null
}
}
}
}
private fun createViaHiddenApi(device: BluetoothDevice, psm: Int): BluetoothSocket {
HiddenApiBypass.setExemptions("Landroid/bluetooth/")
return try {
val method = BluetoothDevice::class.java.getDeclaredMethod(
"createInsecureL2capSocket",
Int::class.javaPrimitiveType
)
method.invoke(device, psm) as BluetoothSocket
} catch (e: java.lang.reflect.InvocationTargetException) {
throw e.cause ?: IOException("createInsecureL2capSocket failed", e)
} catch (e: NoSuchMethodException) {
throw e
} catch (e: SecurityException) {
throw e
} catch (e: ReflectiveOperationException) {
throw IOException("Failed to create L2CAP socket via hidden API", e)
}
}
}
@@ -0,0 +1,14 @@
package eu.darken.capod.common.bluetooth.l2cap;
import androidx.annotation.Keep;
/**
* Helper class for ART method size calculation.
* Methods a() and b() must be adjacent in ART's internal method array.
* Plain Java (not Kotlin) to avoid synthetic bridge methods from companion objects.
*/
@Keep
class NeverCall {
private static void a() { throw new RuntimeException(); }
private static void b() { throw new RuntimeException(); }
}
@@ -78,110 +78,113 @@ interface PodDevice {
enum class Model(
val label: String,
@DrawableRes val iconRes: Int = R.drawable.device_earbuds_generic_both,
val features: Features = Features(),
) {
@SerialName("airpods.gen1") AIRPODS_GEN1(
label = "AirPods (Gen 1)",
iconRes = R.drawable.device_airpods_gen1_both,
"AirPods (Gen 1)", R.drawable.device_airpods_gen1_both,
Features(hasDualPods = true, hasCase = true),
),
@SerialName("airpods.gen2") AIRPODS_GEN2(
"AirPods (Gen 2)",
R.drawable.device_airpods_gen1_both,
"AirPods (Gen 2)", R.drawable.device_airpods_gen1_both,
Features(hasDualPods = true, hasCase = true),
),
@SerialName("airpods.gen3") AIRPODS_GEN3(
"AirPods (Gen 3)",
R.drawable.device_airpods_gen3_both,
"AirPods (Gen 3)", R.drawable.device_airpods_gen3_both,
Features(hasDualPods = true, hasCase = true),
),
@SerialName("airpods.gen4") AIRPODS_GEN4(
"AirPods (Gen 4)",
R.drawable.device_airpods_gen3_both,
"AirPods (Gen 4)", R.drawable.device_airpods_gen3_both,
Features(hasDualPods = true, hasCase = true),
),
@SerialName("airpods.gen4.anc") AIRPODS_GEN4_ANC(
"AirPods (Gen 4 ANC)",
R.drawable.device_airpods_gen4anc_both,
"AirPods (Gen 4 ANC)", R.drawable.device_airpods_gen4anc_both,
Features(hasDualPods = true, hasCase = true, hasEarDetection = true, hasAncControl = true),
),
@SerialName("airpods.pro") AIRPODS_PRO(
"AirPods Pro",
R.drawable.device_airpods_pro2_both
"AirPods Pro", R.drawable.device_airpods_pro2_both,
Features(hasDualPods = true, hasCase = true, hasEarDetection = true, hasAncControl = true),
),
@SerialName("airpods.pro2") AIRPODS_PRO2(
"AirPods Pro 2",
R.drawable.device_airpods_pro2_both
"AirPods Pro 2", R.drawable.device_airpods_pro2_both,
Features(hasDualPods = true, hasCase = true, hasEarDetection = true, hasAncControl = true),
),
@SerialName("airpods.pro2.usbc") AIRPODS_PRO2_USBC(
"AirPods Pro 2 USB-C",
R.drawable.device_airpods_pro2_both
"AirPods Pro 2 USB-C", R.drawable.device_airpods_pro2_both,
Features(hasDualPods = true, hasCase = true, hasEarDetection = true, hasAncControl = true),
),
@SerialName("airpods.pro3") AIRPODS_PRO3(
"AirPods Pro 3",
R.drawable.device_airpods_pro2_both
"AirPods Pro 3", R.drawable.device_airpods_pro2_both,
Features(hasDualPods = true, hasCase = true, hasEarDetection = true, hasAncControl = true),
),
@SerialName("airpods.max") AIRPODS_MAX(
"AirPods Max",
R.drawable.device_airpods_max
"AirPods Max", R.drawable.device_airpods_max,
Features(hasAncControl = true),
),
@SerialName("airpods.max.usbc") AIRPODS_MAX_USBC(
"AirPods Max USB-C",
R.drawable.device_airpods_max
"AirPods Max USB-C", R.drawable.device_airpods_max,
Features(hasAncControl = true),
),
@SerialName("beats.flex") BEATS_FLEX(
"Beats Flex",
R.drawable.device_beats_earbuds,
"Beats Flex", R.drawable.device_beats_earbuds,
),
@SerialName("beats.solo.3") BEATS_SOLO_3(
"Beats Solo 3",
R.drawable.device_beats_headphones,
"Beats Solo 3", R.drawable.device_beats_headphones,
),
@SerialName("beats.studio.3") BEATS_STUDIO_3(
"Beats Studio 3",
R.drawable.device_beats_studio3,
"Beats Studio 3", R.drawable.device_beats_studio3,
Features(hasAncControl = true),
),
@SerialName("beats.x") BEATS_X(
"Beats X",
R.drawable.device_beats_x,
"Beats X", R.drawable.device_beats_x,
),
@SerialName("beats.powerbeats.3") POWERBEATS_3(
"Power Beats 3",
R.drawable.device_powerbeats_3,
"Power Beats 3", R.drawable.device_powerbeats_3,
),
@SerialName("beats.powerbeats.4") POWERBEATS_4(
"Power Beats 4",
R.drawable.device_powerbeats_4,
"Power Beats 4", R.drawable.device_powerbeats_4,
),
@SerialName("beats.powerbeats.pro") POWERBEATS_PRO(
"Power Beats Pro",
R.drawable.device_powerbeats_pro_both,
"Power Beats Pro", R.drawable.device_powerbeats_pro_both,
Features(hasDualPods = true, hasCase = true, hasEarDetection = true),
),
@SerialName("beats.powerbeats.pro2") POWERBEATS_PRO2(
"Power Beats Pro 2",
R.drawable.device_powerbeats_pro2_both,
"Power Beats Pro 2", R.drawable.device_powerbeats_pro2_both,
Features(hasDualPods = true, hasCase = true, hasEarDetection = true, hasAncControl = true),
),
@SerialName("beats.fit.pro") BEATS_FIT_PRO(
"Beats Fit Pro",
R.drawable.device_beats_fitpro_both,
"Beats Fit Pro", R.drawable.device_beats_fitpro_both,
Features(hasDualPods = true, hasCase = true, hasEarDetection = true, hasAncControl = true),
),
@SerialName("fakes.tws.i99999") FAKE_AIRPODS_GEN1(
"AirPods (Gen 1)? \uD83C\uDFAD",
R.drawable.device_airpods_gen1_both,
"AirPods (Gen 1)? \uD83C\uDFAD", R.drawable.device_airpods_gen1_both,
Features(hasDualPods = true, hasCase = true),
),
@SerialName("fakes.generic.airpods.gen2") FAKE_AIRPODS_GEN2(
"AirPods (Gen 2)? \uD83C\uDFAD",
R.drawable.device_airpods_gen1_both,
"AirPods (Gen 2)? \uD83C\uDFAD", R.drawable.device_airpods_gen1_both,
Features(hasDualPods = true, hasCase = true),
),
@SerialName("fakes.generic.airpods.gen3") FAKE_AIRPODS_GEN3(
"AirPods (Gen 3)? \uD83C\uDFAD",
R.drawable.device_airpods_gen3_both,
"AirPods (Gen 3)? \uD83C\uDFAD", R.drawable.device_airpods_gen3_both,
Features(hasDualPods = true, hasCase = true),
),
@SerialName("fakes.varunr.airpodspro") FAKE_AIRPODS_PRO(
"AirPods Pro? \uD83C\uDFAD",
R.drawable.device_airpods_pro2_both,
"AirPods Pro? \uD83C\uDFAD", R.drawable.device_airpods_pro2_both,
Features(hasDualPods = true, hasCase = true, hasEarDetection = true, hasAncControl = true),
),
@SerialName("fakes.generic.airpods.pro2") FAKE_AIRPODS_PRO2(
"AirPods Pro2? \uD83C\uDFAD",
R.drawable.device_airpods_pro2_both,
"AirPods Pro2? \uD83C\uDFAD", R.drawable.device_airpods_pro2_both,
Features(hasDualPods = true, hasCase = true, hasEarDetection = true, hasAncControl = true),
),
@SerialName("unknown") UNKNOWN(
"Unknown"
);
data class Features(
val hasDualPods: Boolean = false,
val hasCase: Boolean = false,
val hasEarDetection: Boolean = false,
val hasAncControl: Boolean = false,
)
}
companion object {
@@ -0,0 +1,10 @@
package eu.darken.capod.pods.core.apple.protocol.aap
/**
* Outbound commands to change device settings. Pure domain — no wire protocol bytes.
* The [AapDeviceProfile] encodes these into the device-specific wire format.
*/
sealed class AapCommand {
data class SetAncMode(val mode: AncModeValue) : AapCommand()
data class SetConversationalAwareness(val enabled: Boolean) : AapCommand()
}
@@ -0,0 +1,160 @@
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.IOException
/**
* Manages a single AAP L2CAP connection to a device.
* Internal — not exposed outside [AapConnectionManager].
*/
@SuppressLint("MissingPermission")
internal class AapConnection(
private val device: BluetoothDevice,
private val profile: AapDeviceProfile,
private val socketFactory: L2capSocketFactory,
private val psm: Int = 0x1001,
) {
companion object {
private const val TAG = "AapConnection"
}
private val _state = MutableStateFlow(AapPodState())
val state: StateFlow<AapPodState> = _state.asStateFlow()
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
}
_state.value = _state.value.copy(connectionState = AapConnectionState.CONNECTING)
try {
val sock = socketFactory.createSocket(device, psm)
sock.connect()
socket = sock
Log.d(TAG, "Connected to ${device.address}")
_state.value = _state.value.copy(connectionState = AapConnectionState.HANDSHAKING)
// Send handshake
val handshake = profile.encodeHandshake()
sock.outputStream.write(handshake)
sock.outputStream.flush()
Log.d(TAG, "Handshake sent")
// Start read loop
coroutineScope {
readerJob = launch { readLoop(sock) }
}
} catch (e: Exception) {
Log.e(TAG, "Connection failed", e)
cleanupSocket()
_state.value = AapPodState(connectionState = AapConnectionState.DISCONNECTED)
throw e
}
}
suspend fun disconnect() = withContext(Dispatchers.IO) {
Log.d(TAG, "Disconnecting")
readerJob?.cancel()
readerJob = null
cleanupSocket()
framer.reset()
_state.value = AapPodState(connectionState = AapConnectionState.DISCONNECTED)
}
suspend fun send(command: AapCommand) {
val currentState = _state.value
if (currentState.connectionState != AapConnectionState.READY) {
throw IllegalStateException("Cannot send command in state ${currentState.connectionState}")
}
val bytes = profile.encodeCommand(command)
writeMutex.withLock {
withContext(Dispatchers.IO) {
val sock = socket ?: throw IOException("Socket is null")
sock.outputStream.write(bytes)
sock.outputStream.flush()
Log.d(TAG, "Sent command: $command (${bytes.size} bytes)")
}
}
}
private suspend fun readLoop(sock: BluetoothSocket) = withContext(Dispatchers.IO) {
val buf = ByteArray(2048)
var handshakeResponseReceived = false
try {
while (isActive) {
val len = sock.inputStream.read(buf)
if (len == -1) {
Log.d(TAG, "Stream closed by remote")
break
}
val messages = framer.consume(buf, 0, len)
for (message in messages) {
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")
}
}
} catch (e: IOException) {
if (isActive) Log.e(TAG, "Read error", e)
} finally {
cleanupSocket()
_state.value = _state.value.copy(connectionState = AapConnectionState.DISCONNECTED)
}
}
private fun processMessage(message: AapMessage) {
// Try device info
profile.decodeDeviceInfo(message)?.let { info ->
_state.value = _state.value.copy(deviceInfo = info)
Log.d(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)
}
}
private fun cleanupSocket() {
try {
socket?.close()
} catch (_: Exception) {
}
socket = null
}
}
@@ -0,0 +1,77 @@
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.bluetooth.l2cap.L2capSocketFactory
import eu.darken.capod.pods.core.PodDevice
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
/**
* Singleton managing all AAP L2CAP connections.
* Keyed by [BluetoothAddress] (stable across scan gaps and app restarts for bonded devices).
* Connections are owned internally — consumers interact via [sendCommand] and observe via [allStates].
*/
@Singleton
class AapConnectionManager @Inject constructor(
private val socketFactory: L2capSocketFactory,
) {
companion object {
private const val TAG = "AapConnectionMgr"
}
private val connections = mutableMapOf<BluetoothAddress, AapConnection>()
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] }
suspend fun connect(
address: BluetoothAddress,
device: BluetoothDevice,
model: PodDevice.Model,
) {
if (connections.containsKey(address)) {
Log.d(TAG, "Already connected to $address")
return
}
val profile = AapDeviceProfile.forModel(model)
val connection = AapConnection(device, profile, socketFactory)
connections[address] = connection
try {
connection.connect()
} 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()
}
suspend fun disconnect(address: BluetoothAddress) {
val connection = connections.remove(address) ?: return
connection.disconnect()
updateStates()
}
suspend fun sendCommand(address: BluetoothAddress, command: AapCommand) {
val connection = connections[address]
?: throw IllegalStateException("No connection for $address")
connection.send(command)
}
private fun updateStates() {
_allStates.value = connections.mapValues { (_, conn) -> conn.state.value }
}
}
@@ -0,0 +1,12 @@
package eu.darken.capod.pods.core.apple.protocol.aap
/**
* Device identity parsed from the AAP handshake response (message type 0x1D).
*/
data class AapDeviceInfo(
val name: String,
val modelNumber: String,
val manufacturer: String,
val serialNumber: String,
val firmwareVersion: String,
)
@@ -0,0 +1,41 @@
package eu.darken.capod.pods.core.apple.protocol.aap
import eu.darken.capod.pods.core.PodDevice
import kotlin.reflect.KClass
/**
* Per-model mapping between AAP wire format and domain types.
* All wire protocol bytes are encapsulated here — sealed classes [AapSetting] and [AapCommand]
* contain zero protocol knowledge.
*
* If Apple changes a setting ID or value encoding for a new model, only the profile changes.
*/
interface AapDeviceProfile {
/**
* Decode a single message into a setting update.
* Returns null if the message is not a recognized setting.
* Callers merge the returned pair into existing state (incremental update).
*/
fun decodeSetting(message: AapMessage): Pair<KClass<out AapSetting>, AapSetting>?
/**
* Decode a device info message (typically command type 0x001D).
*/
fun decodeDeviceInfo(message: AapMessage): AapDeviceInfo?
/**
* Encode a domain command into wire bytes ready to send.
* @throws UnsupportedOperationException if the command is not supported by this profile.
*/
fun encodeCommand(command: AapCommand): ByteArray
/**
* Encode the handshake message that initiates the AAP session.
*/
fun encodeHandshake(): ByteArray
companion object {
fun forModel(model: PodDevice.Model): AapDeviceProfile = DefaultAapDeviceProfile()
}
}
@@ -0,0 +1,79 @@
package eu.darken.capod.pods.core.apple.protocol.aap
/**
* A parsed AAP protocol message.
*/
data class AapMessage(
val raw: ByteArray,
val commandType: Int,
val payload: ByteArray,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is AapMessage) return false
return raw.contentEquals(other.raw)
}
override fun hashCode(): Int = raw.contentHashCode()
companion object {
/**
* Parse a complete AAP message from raw bytes.
* AAP messages have the format: [4-byte header] [2-byte command type] [payload...]
* Minimum message size is 6 bytes (header + command type with no payload).
*/
fun parse(raw: ByteArray): AapMessage? {
if (raw.size < 6) return null
val commandType = (raw[4].toInt() and 0xFF) or ((raw[5].toInt() and 0xFF) shl 8)
val payload = if (raw.size > 6) raw.copyOfRange(6, raw.size) else ByteArray(0)
return AapMessage(raw = raw.copyOf(), commandType = commandType, payload = payload)
}
}
}
/**
* Accumulates bytes from a stream and emits complete [AapMessage] objects.
*
* Raw L2CAP reads can return partial or multiple messages in a single read.
* The framer buffers partial data and splits multi-message reads.
*
* AAP message framing: first 4 bytes are header, bytes 2-3 (little-endian)
* indicate total message length (excluding the first 4 header bytes).
*/
class AapFramer {
private val buffer = mutableListOf<Byte>()
/**
* Feed raw bytes from a socket read. Returns any complete messages found.
*/
fun consume(bytes: ByteArray, offset: Int = 0, length: Int = bytes.size): List<AapMessage> {
for (i in offset until offset + length) {
buffer.add(bytes[i])
}
val messages = mutableListOf<AapMessage>()
while (buffer.size >= 4) {
// Bytes 2-3 (little-endian) = payload length after the 4-byte header
val payloadLength = (buffer[2].toInt() and 0xFF) or ((buffer[3].toInt() and 0xFF) shl 8)
val totalLength = 4 + payloadLength
if (buffer.size < totalLength) break // Need more data
val messageBytes = ByteArray(totalLength)
for (i in 0 until totalLength) {
messageBytes[i] = buffer[i]
}
buffer.subList(0, totalLength).clear()
AapMessage.parse(messageBytes)?.let { messages.add(it) }
}
return messages
}
fun reset() {
buffer.clear()
}
}
@@ -0,0 +1,24 @@
package eu.darken.capod.pods.core.apple.protocol.aap
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 deviceInfo: AapDeviceInfo? = null,
val settings: Map<KClass<out AapSetting>, AapSetting> = 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,
}
@@ -0,0 +1,18 @@
package eu.darken.capod.pods.core.apple.protocol.aap
/**
* Device-reported settings. Pure domain — no wire protocol bytes.
* Each subclass represents a capability with its current state and supported values.
* The [AapDeviceProfile] handles all wire ↔ domain translation.
*/
sealed class AapSetting {
data class AncMode(
val current: AncModeValue,
val supported: List<AncModeValue>,
) : AapSetting()
data class ConversationalAwareness(
val enabled: Boolean,
) : AapSetting()
}
@@ -0,0 +1,8 @@
package eu.darken.capod.pods.core.apple.protocol.aap
enum class AncModeValue {
OFF,
ON,
TRANSPARENCY,
ADAPTIVE,
}
@@ -0,0 +1,131 @@
package eu.darken.capod.pods.core.apple.protocol.aap
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.
*
* When model-specific differences are discovered, subclass and override the relevant methods.
*/
class DefaultAapDeviceProfile : AapDeviceProfile {
companion object {
// AAP command types (bytes 4-5 of the message, little-endian)
const val CMD_SETTINGS = 0x0009
const val CMD_DEVICE_INFO = 0x001D
// Setting IDs (first byte of settings command payload)
const val SETTING_ANC_MODE = 0x0D
const val SETTING_CONVERSATIONAL_AWARENESS = 0x18
// 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)
}
override fun encodeHandshake(): ByteArray = byteArrayOf(
0x00, 0x00, 0x04, 0x00, 0x01, 0x00, 0x02, 0x00,
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 decodeSetting(message: AapMessage): Pair<KClass<out AapSetting>, AapSetting>? {
if (message.commandType != CMD_SETTINGS) return null
if (message.payload.size < 2) return null
val settingId = message.payload[0].toInt() and 0xFF
val value = message.payload[1].toInt() and 0xFF
return when (settingId) {
SETTING_ANC_MODE -> {
val mode = decodeAncMode(value) ?: return null
AapSetting.AncMode::class to AapSetting.AncMode(
current = mode,
supported = DEFAULT_ANC_MODES,
)
}
SETTING_CONVERSATIONAL_AWARENESS -> {
AapSetting.ConversationalAwareness::class to AapSetting.ConversationalAwareness(
enabled = value != 0,
)
}
else -> null
}
}
override fun decodeDeviceInfo(message: AapMessage): AapDeviceInfo? {
if (message.commandType != CMD_DEVICE_INFO) return null
if (message.payload.size < 10) return null
// Device info payload contains null-terminated ASCII strings
// Format: [length prefix] [strings...]
val strings = parseNullTerminatedStrings(message.payload)
if (strings.size < 4) return null
return AapDeviceInfo(
name = strings.getOrElse(0) { "" },
modelNumber = strings.getOrElse(1) { "" },
manufacturer = strings.getOrElse(2) { "" },
serialNumber = strings.getOrElse(3) { "" },
firmwareVersion = strings.getOrElse(4) { "" },
)
}
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 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
else -> null
}
private fun buildSettingsMessage(settingId: Int, value: Int): ByteArray = byteArrayOf(
0x04, 0x00, 0x04, 0x00,
0x09, 0x00,
settingId.toByte(), value.toByte(),
0x00, 0x00, 0x00,
)
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
var i = 0
while (i < data.size) {
if (data[i] != 0x00.toByte() && data[i].toInt() and 0xFF >= 0x20) {
start = i
while (i < data.size && data[i] != 0x00.toByte()) i++
strings.add(String(data, start, i - start, Charsets.US_ASCII))
}
i++
}
return strings
}
}