From 3e2852e622a808fcd21bd90e4ed42cc728f54e1f Mon Sep 17 00:00:00 2001 From: darken Date: Mon, 30 Mar 2026 02:17:58 +0200 Subject: [PATCH] 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. --- app/src/debug/AndroidManifest.xml | 16 ++ .../capod/debug/l2cap/L2capPocActivity.kt | 256 ++++++++++++++++++ .../capod/common/bluetooth/l2cap/ArtMirror.kt | 71 +++++ .../common/bluetooth/l2cap/HiddenApiBypass.kt | 130 +++++++++ .../bluetooth/l2cap/L2capSocketFactory.kt | 97 +++++++ .../common/bluetooth/l2cap/NeverCall.java | 14 + .../eu/darken/capod/pods/core/PodDevice.kt | 103 +++---- .../core/apple/protocol/aap/AapCommand.kt | 10 + .../core/apple/protocol/aap/AapConnection.kt | 160 +++++++++++ .../protocol/aap/AapConnectionManager.kt | 77 ++++++ .../core/apple/protocol/aap/AapDeviceInfo.kt | 12 + .../apple/protocol/aap/AapDeviceProfile.kt | 41 +++ .../core/apple/protocol/aap/AapMessage.kt | 79 ++++++ .../core/apple/protocol/aap/AapPodState.kt | 24 ++ .../core/apple/protocol/aap/AapSetting.kt | 18 ++ .../core/apple/protocol/aap/AncModeValue.kt | 8 + .../protocol/aap/DefaultAapDeviceProfile.kt | 131 +++++++++ 17 files changed, 1197 insertions(+), 50 deletions(-) create mode 100644 app/src/debug/AndroidManifest.xml create mode 100644 app/src/debug/java/eu/darken/capod/debug/l2cap/L2capPocActivity.kt create mode 100644 app/src/main/java/eu/darken/capod/common/bluetooth/l2cap/ArtMirror.kt create mode 100644 app/src/main/java/eu/darken/capod/common/bluetooth/l2cap/HiddenApiBypass.kt create mode 100644 app/src/main/java/eu/darken/capod/common/bluetooth/l2cap/L2capSocketFactory.kt create mode 100644 app/src/main/java/eu/darken/capod/common/bluetooth/l2cap/NeverCall.java create mode 100644 app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapCommand.kt create mode 100644 app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapConnection.kt create mode 100644 app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapConnectionManager.kt create mode 100644 app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapDeviceInfo.kt create mode 100644 app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapDeviceProfile.kt create mode 100644 app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapMessage.kt create mode 100644 app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapPodState.kt create mode 100644 app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapSetting.kt create mode 100644 app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AncModeValue.kt create mode 100644 app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/DefaultAapDeviceProfile.kt 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>(emptyMap()) + val allStates: StateFlow> = _allStates.asStateFlow() + + fun deviceState(address: BluetoothAddress): Flow = + _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 } + } +} diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapDeviceInfo.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapDeviceInfo.kt new file mode 100644 index 00000000..28cc21b6 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapDeviceInfo.kt @@ -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, +) diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapDeviceProfile.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapDeviceProfile.kt new file mode 100644 index 00000000..c0ace96c --- /dev/null +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapDeviceProfile.kt @@ -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, 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() + } +} diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapMessage.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapMessage.kt new file mode 100644 index 00000000..c8043978 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapMessage.kt @@ -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() + + /** + * Feed raw bytes from a socket read. Returns any complete messages found. + */ + fun consume(bytes: ByteArray, offset: Int = 0, length: Int = bytes.size): List { + for (i in offset until offset + length) { + buffer.add(bytes[i]) + } + + val messages = mutableListOf() + + 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() + } +} diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapPodState.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapPodState.kt new file mode 100644 index 00000000..7f20dd5a --- /dev/null +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapPodState.kt @@ -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, AapSetting> = emptyMap(), +) { + inline fun setting(): T? = settings[T::class] as? T + + fun withSetting(key: KClass, value: AapSetting): AapPodState = + copy(settings = settings + (key to value)) +} + +enum class AapConnectionState { + DISCONNECTED, + CONNECTING, + HANDSHAKING, + READY, +} diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapSetting.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapSetting.kt new file mode 100644 index 00000000..d4e1544a --- /dev/null +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AapSetting.kt @@ -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, + ) : AapSetting() + + data class ConversationalAwareness( + val enabled: Boolean, + ) : AapSetting() +} diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AncModeValue.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AncModeValue.kt new file mode 100644 index 00000000..a222f2e7 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/AncModeValue.kt @@ -0,0 +1,8 @@ +package eu.darken.capod.pods.core.apple.protocol.aap + +enum class AncModeValue { + OFF, + ON, + TRANSPARENCY, + ADAPTIVE, +} diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/DefaultAapDeviceProfile.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/DefaultAapDeviceProfile.kt new file mode 100644 index 00000000..bfb68349 --- /dev/null +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/protocol/aap/DefaultAapDeviceProfile.kt @@ -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, 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 { + val strings = mutableListOf() + var start = 0 + // Skip initial length/flags bytes (first few bytes before string data) + val stringDataStart = data.indexOfFirst { it == 0x00.toByte() && data.indexOf(it.toByte()) > 2 } + .takeIf { it >= 0 } ?: return strings + + // Find runs of printable ASCII separated by null bytes + 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 + } +}