diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml
new file mode 100644
index 00000000..7fa95757
--- /dev/null
+++ b/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/debug/java/eu/darken/capod/debug/l2cap/L2capPocActivity.kt b/app/src/debug/java/eu/darken/capod/debug/l2cap/L2capPocActivity.kt
new file mode 100644
index 00000000..9c2094b1
--- /dev/null
+++ b/app/src/debug/java/eu/darken/capod/debug/l2cap/L2capPocActivity.kt
@@ -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() }
+ val logListState = rememberLazyListState()
+ var selectedDevice by remember { mutableStateOf(null) }
+ var socket by remember { mutableStateOf(null) }
+ var outputStream by remember { mutableStateOf(null) }
+ var connected by remember { mutableStateOf(false) }
+ val scope = rememberCoroutineScope()
+ var readerJob by remember { mutableStateOf(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)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/eu/darken/capod/common/bluetooth/l2cap/ArtMirror.kt b/app/src/main/java/eu/darken/capod/common/bluetooth/l2cap/ArtMirror.kt
new file mode 100644
index 00000000..0634bcd2
--- /dev/null
+++ b/app/src/main/java/eu/darken/capod/common/bluetooth/l2cap/ArtMirror.kt
@@ -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? = 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? = 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
+ }
+}
diff --git a/app/src/main/java/eu/darken/capod/common/bluetooth/l2cap/HiddenApiBypass.kt b/app/src/main/java/eu/darken/capod/common/bluetooth/l2cap/HiddenApiBypass.kt
new file mode 100644
index 00000000..b9368d44
--- /dev/null
+++ b/app/src/main/java/eu/darken/capod/common/bluetooth/l2cap/HiddenApiBypass.kt
@@ -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()
+
+ @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::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::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")
+ }
+}
diff --git a/app/src/main/java/eu/darken/capod/common/bluetooth/l2cap/L2capSocketFactory.kt b/app/src/main/java/eu/darken/capod/common/bluetooth/l2cap/L2capSocketFactory.kt
new file mode 100644
index 00000000..0209db55
--- /dev/null
+++ b/app/src/main/java/eu/darken/capod/common/bluetooth/l2cap/L2capSocketFactory.kt
@@ -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)
+ }
+ }
+}
diff --git a/app/src/main/java/eu/darken/capod/common/bluetooth/l2cap/NeverCall.java b/app/src/main/java/eu/darken/capod/common/bluetooth/l2cap/NeverCall.java
new file mode 100644
index 00000000..69af01c3
--- /dev/null
+++ b/app/src/main/java/eu/darken/capod/common/bluetooth/l2cap/NeverCall.java
@@ -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(); }
+}
diff --git a/app/src/main/java/eu/darken/capod/pods/core/PodDevice.kt b/app/src/main/java/eu/darken/capod/pods/core/PodDevice.kt
index 817ab93c..cb5ad63b 100644
--- a/app/src/main/java/eu/darken/capod/pods/core/PodDevice.kt
+++ b/app/src/main/java/eu/darken/capod/pods/core/PodDevice.kt
@@ -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 {
diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapCommand.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapCommand.kt
new file mode 100644
index 00000000..7f3f57c9
--- /dev/null
+++ b/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapCommand.kt
@@ -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()
+}
diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapConnection.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapConnection.kt
new file mode 100644
index 00000000..fa786a6e
--- /dev/null
+++ b/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapConnection.kt
@@ -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 = _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
+ }
+}
diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapConnectionManager.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapConnectionManager.kt
new file mode 100644
index 00000000..d877cb1c
--- /dev/null
+++ b/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapConnectionManager.kt
@@ -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()
+ private val _allStates = MutableStateFlow