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 + } +}