mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-14 18:26:11 -04:00
feat(aap): Decode unknown settings, extend DeviceInfo, fix UTF-8 parsing
- Add UnknownSetting catch-all for 9 unconfirmed H2+ setting IDs (0x29, 0x2C, 0x2F, 0x30, 0x33, 0x37, 0x38, 0x3B, 0x3E) - Downgrade known non-settings commands (0x0000, 0x0002, 0x000C, 0x0017, 0x002B, 0x004E, 0x0055, 0x0057) from INFO to VERBOSE - Add earbud serials and build number to DeviceInfo (parsed from 0x001D segments 8-10, shown in DeviceInfoCard, persisted in cache) - Fix UTF-8 device name parsing (curly quotes etc. no longer break segment indices) - Add [was: ...] to setting change logs for easier protocol diff analysis - Label DeviceInfoDump segments 5-12 (firmwareVersionDup, protocolVersion, updaterAppId, earbudSerials, buildNumber, encryptedBlob, timestamp)
This commit is contained in:
@@ -21,16 +21,28 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import eu.darken.capod.R
|
||||
import eu.darken.capod.common.compose.Preview2
|
||||
import eu.darken.capod.common.compose.PreviewWrapper
|
||||
import eu.darken.capod.main.ui.devicesettings.dialogs.RenameDialog
|
||||
import eu.darken.capod.monitor.core.PodDevice
|
||||
import eu.darken.capod.pods.core.apple.PodModel
|
||||
import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo
|
||||
|
||||
internal fun buildModelLabel(device: PodDevice): String? {
|
||||
if (device.model == PodModel.UNKNOWN) return null
|
||||
val modelNumber = device.deviceInfo?.modelNumber?.takeIf { it.isNotBlank() }
|
||||
return if (modelNumber != null) "${device.model.label} ($modelNumber)" else device.model.label
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DeviceInfoCard(
|
||||
deviceInfo: AapDeviceInfo?,
|
||||
modelLabel: String?,
|
||||
systemBluetoothName: String?,
|
||||
connectionStateLabel: String?,
|
||||
lastSeen: String?,
|
||||
firstSeen: String?,
|
||||
@@ -57,17 +69,25 @@ internal fun DeviceInfoCard(
|
||||
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 1.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
if (modelLabel != null) {
|
||||
InfoRow(
|
||||
label = stringResource(R.string.device_settings_info_model_label),
|
||||
value = modelLabel,
|
||||
)
|
||||
}
|
||||
if (deviceInfo != null) {
|
||||
if (deviceInfo.name.isNotBlank()) {
|
||||
val nameMismatch = systemBluetoothName != null && systemBluetoothName != deviceInfo.name
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
InfoRow(
|
||||
label = stringResource(R.string.device_settings_info_name_label),
|
||||
label = stringResource(R.string.device_settings_info_bt_name_label),
|
||||
value = deviceInfo.name,
|
||||
modifier = Modifier.weight(1f),
|
||||
valueFontFamily = if (nameMismatch) FontFamily.Cursive else null,
|
||||
)
|
||||
if (canRename) {
|
||||
IconButton(onClick = { showRenameDialog = true }) {
|
||||
@@ -92,6 +112,31 @@ internal fun DeviceInfoCard(
|
||||
value = deviceInfo.firmwareVersion,
|
||||
)
|
||||
}
|
||||
if (!deviceInfo.buildNumber.isNullOrBlank()) {
|
||||
InfoRow(
|
||||
label = stringResource(R.string.device_settings_info_build_label),
|
||||
value = deviceInfo.buildNumber,
|
||||
)
|
||||
}
|
||||
if (!deviceInfo.leftEarbudSerial.isNullOrBlank() || !deviceInfo.rightEarbudSerial.isNullOrBlank()) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
if (!deviceInfo.leftEarbudSerial.isNullOrBlank()) {
|
||||
InfoRow(
|
||||
label = stringResource(R.string.device_settings_info_left_serial_label),
|
||||
value = deviceInfo.leftEarbudSerial,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
if (!deviceInfo.rightEarbudSerial.isNullOrBlank()) {
|
||||
InfoRow(
|
||||
label = stringResource(R.string.device_settings_info_right_serial_label),
|
||||
value = deviceInfo.rightEarbudSerial,
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = if (!deviceInfo.leftEarbudSerial.isNullOrBlank()) TextAlign.End else TextAlign.Start,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (connectionStateLabel != null) {
|
||||
InfoRow(
|
||||
@@ -99,24 +144,38 @@ internal fun DeviceInfoCard(
|
||||
value = connectionStateLabel,
|
||||
)
|
||||
}
|
||||
if (lastSeen != null) {
|
||||
if (lastSeen != null && firstSeen != null) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
InfoRow(
|
||||
label = stringResource(R.string.device_settings_info_last_seen_label),
|
||||
value = lastSeen,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
InfoRow(
|
||||
label = stringResource(R.string.device_settings_info_first_seen_label),
|
||||
value = firstSeen,
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.End,
|
||||
)
|
||||
}
|
||||
} else if (lastSeen != null) {
|
||||
InfoRow(
|
||||
label = stringResource(R.string.device_settings_info_last_seen_label),
|
||||
value = lastSeen,
|
||||
)
|
||||
}
|
||||
if (firstSeen != null) {
|
||||
InfoRow(
|
||||
label = stringResource(R.string.device_settings_info_first_seen_label),
|
||||
value = firstSeen,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfoRow(label: String, value: String, modifier: Modifier = Modifier) {
|
||||
private fun InfoRow(
|
||||
label: String,
|
||||
value: String,
|
||||
modifier: Modifier = Modifier,
|
||||
textAlign: TextAlign = TextAlign.Start,
|
||||
valueFontFamily: FontFamily? = null,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.padding(vertical = 2.dp),
|
||||
) {
|
||||
@@ -124,10 +183,15 @@ private fun InfoRow(label: String, value: String, modifier: Modifier = Modifier)
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = textAlign,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = textAlign,
|
||||
fontFamily = valueFontFamily,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -142,7 +206,12 @@ private fun DeviceInfoCardFullPreview() = PreviewWrapper {
|
||||
manufacturer = "Apple Inc.",
|
||||
serialNumber = "W5J7KV0N04",
|
||||
firmwareVersion = "7A305",
|
||||
leftEarbudSerial = "H3KL7HR926JY",
|
||||
rightEarbudSerial = "H3KL2AYL26K0",
|
||||
buildNumber = "8454624",
|
||||
),
|
||||
modelLabel = "AirPods Pro 2 (A2699)",
|
||||
systemBluetoothName = "AirPods Pro",
|
||||
connectionStateLabel = "Connected",
|
||||
lastSeen = "Just now",
|
||||
firstSeen = "5 minutes ago",
|
||||
@@ -152,7 +221,27 @@ private fun DeviceInfoCardFullPreview() = PreviewWrapper {
|
||||
|
||||
@Composable
|
||||
@Preview2
|
||||
private fun DeviceInfoCardWithoutRenamePreview() = PreviewWrapper {
|
||||
private fun DeviceInfoCardMismatchPreview() = PreviewWrapper {
|
||||
DeviceInfoCard(
|
||||
deviceInfo = AapDeviceInfo(
|
||||
name = "My AirPods Pro",
|
||||
modelNumber = "A2699",
|
||||
manufacturer = "Apple Inc.",
|
||||
serialNumber = "W5J7KV0N04",
|
||||
firmwareVersion = "7A305",
|
||||
),
|
||||
modelLabel = "AirPods Pro 2 (A2699)",
|
||||
systemBluetoothName = "AirPods Pro",
|
||||
connectionStateLabel = "Connected",
|
||||
lastSeen = "Just now",
|
||||
firstSeen = "5 minutes ago",
|
||||
canRename = true,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview2
|
||||
private fun DeviceInfoCardLastSeenOnlyPreview() = PreviewWrapper {
|
||||
DeviceInfoCard(
|
||||
deviceInfo = AapDeviceInfo(
|
||||
name = "AirPods Pro",
|
||||
@@ -161,6 +250,8 @@ private fun DeviceInfoCardWithoutRenamePreview() = PreviewWrapper {
|
||||
serialNumber = "W5J7KV0N04",
|
||||
firmwareVersion = "7A305",
|
||||
),
|
||||
modelLabel = "AirPods Pro 2 (A2699)",
|
||||
systemBluetoothName = "AirPods Pro",
|
||||
connectionStateLabel = "Disconnected",
|
||||
lastSeen = "2 hours ago",
|
||||
firstSeen = null,
|
||||
@@ -173,6 +264,8 @@ private fun DeviceInfoCardWithoutRenamePreview() = PreviewWrapper {
|
||||
private fun DeviceInfoCardSparsePreview() = PreviewWrapper {
|
||||
DeviceInfoCard(
|
||||
deviceInfo = null,
|
||||
modelLabel = "AirPods Pro 2",
|
||||
systemBluetoothName = null,
|
||||
connectionStateLabel = "Disconnected",
|
||||
lastSeen = "Just now",
|
||||
firstSeen = null,
|
||||
|
||||
@@ -23,6 +23,9 @@ data class CachedDeviceState(
|
||||
@SerialName("deviceName") val deviceName: String? = null,
|
||||
@SerialName("serialNumber") val serialNumber: String? = null,
|
||||
@SerialName("firmwareVersion") val firmwareVersion: String? = null,
|
||||
@SerialName("leftEarbudSerial") val leftEarbudSerial: String? = null,
|
||||
@SerialName("rightEarbudSerial") val rightEarbudSerial: String? = null,
|
||||
@SerialName("buildNumber") val buildNumber: String? = null,
|
||||
@Serializable(with = InstantEpochMillisSerializer::class)
|
||||
@SerialName("lastSeenAt") val lastSeenAt: Instant,
|
||||
) {
|
||||
@@ -35,6 +38,9 @@ data class CachedDeviceState(
|
||||
manufacturer = "",
|
||||
serialNumber = serialNumber ?: "",
|
||||
firmwareVersion = firmwareVersion ?: "",
|
||||
leftEarbudSerial = leftEarbudSerial,
|
||||
rightEarbudSerial = rightEarbudSerial,
|
||||
buildNumber = buildNumber,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -48,6 +48,9 @@ fun PodDevice.toCachedState(
|
||||
deviceName = liveDeviceInfo?.name ?: existing?.deviceName,
|
||||
serialNumber = liveDeviceInfo?.serialNumber ?: existing?.serialNumber,
|
||||
firmwareVersion = liveDeviceInfo?.firmwareVersion ?: existing?.firmwareVersion,
|
||||
leftEarbudSerial = liveDeviceInfo?.leftEarbudSerial ?: existing?.leftEarbudSerial,
|
||||
rightEarbudSerial = liveDeviceInfo?.rightEarbudSerial ?: existing?.rightEarbudSerial,
|
||||
buildNumber = liveDeviceInfo?.buildNumber ?: existing?.buildNumber,
|
||||
lastSeenAt = seenLastAt ?: now,
|
||||
)
|
||||
|
||||
|
||||
@@ -346,6 +346,14 @@ internal class AapConnection(
|
||||
2 -> "manufacturer"
|
||||
3 -> "serialNumber"
|
||||
4 -> "firmwareVersion"
|
||||
5 -> "firmwareVersionDup"
|
||||
6 -> "protocolVersion"
|
||||
7 -> "updaterAppId"
|
||||
8 -> "leftEarbudSerial"
|
||||
9 -> "rightEarbudSerial"
|
||||
10 -> "buildNumber"
|
||||
11 -> "encryptedBlob"
|
||||
12 -> "timestamp"
|
||||
else -> "unknown"
|
||||
}
|
||||
val rendered = seg.utf8?.let { "\"$it\"" } ?: "<non-utf8>"
|
||||
@@ -390,6 +398,8 @@ internal class AapConnection(
|
||||
|
||||
// Try setting update (merge into existing state)
|
||||
profile.decodeSetting(message)?.let { (key, value) ->
|
||||
val previous = _state.value.settings[key]
|
||||
|
||||
// Debounce device-initiated ANC mode changes (firmware cycles modes on ear transitions).
|
||||
// Skip debounce for: first ANC mode (initial setup), echoes after our own command.
|
||||
if (value is AapSetting.AncMode) {
|
||||
@@ -398,7 +408,7 @@ internal class AapConnection(
|
||||
if (isFirstAncMode || sinceLastCommand <= 3000L) {
|
||||
ancDebounceJob?.cancel()
|
||||
_state.value = _state.value.withSetting(key, value).copy(lastMessageAt = timeSource.now())
|
||||
log(TAG) { "Setting: ${key.simpleName} = $value" }
|
||||
log(TAG) { "Setting: ${key.simpleName} = $value [was: $previous]" }
|
||||
|
||||
// After our command, firmware may cycle through modes before settling.
|
||||
// Schedule a verification: if settled mode != commanded mode, re-send once.
|
||||
@@ -421,7 +431,7 @@ internal class AapConnection(
|
||||
ancDebounceJob = connectionScope?.launch {
|
||||
delay(1500L)
|
||||
_state.value = _state.value.withSetting(key, value).copy(lastMessageAt = timeSource.now())
|
||||
log(TAG) { "Setting (debounced): ${key.simpleName} = $value" }
|
||||
log(TAG) { "Setting (debounced): ${key.simpleName} = $value [was: $previous]" }
|
||||
}
|
||||
}
|
||||
return
|
||||
@@ -438,7 +448,7 @@ internal class AapConnection(
|
||||
newState = newState.copy(settings = newState.settings - AapSetting.PrimaryPod::class)
|
||||
}
|
||||
_state.value = newState
|
||||
log(TAG) { "Setting: ${key.simpleName} = $value${if (clearPrimaryPod) " (swap, PrimaryPod cleared)" else ""}" }
|
||||
log(TAG) { "Setting: ${key.simpleName} = $value${if (clearPrimaryPod) " (swap, PrimaryPod cleared)" else ""} [was: $previous]" }
|
||||
|
||||
// Flush queued ANC command when a pod goes in ear
|
||||
if (value is AapSetting.EarDetection && value.isEitherPodInEar) {
|
||||
@@ -463,6 +473,59 @@ internal class AapConnection(
|
||||
val payloadHex = message.payload.joinToString(" ") { "%02X".format(it) }
|
||||
val sinceSend = if (lastSentAt == 0L) -1L else timeSource.currentTimeMillis() - lastSentAt
|
||||
val lastSend = lastSentCommand?.let { it::class.simpleName } ?: "none"
|
||||
|
||||
if (message.commandType == 0x0009 && message.payload.size >= 2) {
|
||||
val settingId = message.payload[0].toInt() and 0xFF
|
||||
val value = message.payload[1].toInt() and 0xFF
|
||||
val boolHint = appleBoolHint(value)
|
||||
val tailHex = if (message.payload.size > 2) {
|
||||
message.payload.copyOfRange(2, message.payload.size).joinToString(" ") { "%02X".format(it) }
|
||||
} else {
|
||||
""
|
||||
}
|
||||
log(TAG, INFO) {
|
||||
buildString {
|
||||
append("Unhandled setting id=0x${"%02X".format(settingId)} ")
|
||||
append("value=0x${"%02X".format(value)}")
|
||||
boolHint?.let { append(" appleBool=$it") }
|
||||
append(" payload=${message.payload.size}B")
|
||||
if (tailHex.isNotEmpty()) append(" tail=[$tailHex]")
|
||||
append(" sinceLastSend=${sinceSend}ms lastSend=$lastSend")
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (message.commandType == 0x000C && message.payload.size >= 6) {
|
||||
val macRaw = formatMac(message.payload, reverse = false)
|
||||
val macReversed = formatMac(message.payload, reverse = true)
|
||||
val tailHex = if (message.payload.size > 6) {
|
||||
message.payload.copyOfRange(6, message.payload.size).joinToString(" ") { "%02X".format(it) }
|
||||
} else {
|
||||
""
|
||||
}
|
||||
_state.value = _state.value.copy(lastMessageAt = timeSource.now())
|
||||
log(TAG, VERBOSE) {
|
||||
buildString {
|
||||
append("Known cmd=0x000C")
|
||||
append(" payload=${message.payload.size}B")
|
||||
append(" macRaw=$macRaw macReversed=$macReversed")
|
||||
if (tailHex.isNotEmpty()) append(" tail=[$tailHex]")
|
||||
append(" sinceLastSend=${sinceSend}ms lastSend=$lastSend")
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Known non-settings commands: log at VERBOSE, refresh lastMessageAt
|
||||
if (message.commandType in KNOWN_NON_SETTINGS_COMMANDS) {
|
||||
_state.value = _state.value.copy(lastMessageAt = timeSource.now())
|
||||
log(TAG, VERBOSE) {
|
||||
"Known cmd=0x${"%04X".format(message.commandType)} payload=${message.payload.size}B [$payloadHex] sinceLastSend=${sinceSend}ms lastSend=$lastSend"
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
log(TAG, INFO) {
|
||||
"Unhandled cmd=0x${"%04X".format(message.commandType)} payload=${message.payload.size}B [$payloadHex] sinceLastSend=${sinceSend}ms lastSend=$lastSend"
|
||||
}
|
||||
@@ -479,6 +542,29 @@ internal class AapConnection(
|
||||
companion object {
|
||||
private val TAG = logTag("AapConnection")
|
||||
|
||||
// Non-settings commands observed in real sessions. Logged at VERBOSE instead of INFO
|
||||
// to reduce noise, but still fully logged with payload hex for debug log analysis.
|
||||
private val KNOWN_NON_SETTINGS_COMMANDS = setOf(
|
||||
0x0000, // Handshake acknowledgment
|
||||
0x0002, // Capability/feature table (H2+ only)
|
||||
0x0017, // HID/service descriptors
|
||||
0x002B, // Session metadata / event history
|
||||
0x004E, // Unknown (all-zero payload)
|
||||
0x0055, // Audio/session state
|
||||
0x0057, // Connection lifecycle
|
||||
)
|
||||
|
||||
private fun appleBoolHint(wireValue: Int): Boolean? = when (wireValue) {
|
||||
0x01 -> true
|
||||
0x02 -> false
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun formatMac(bytes: ByteArray, reverse: Boolean): String {
|
||||
val indices = if (reverse) (5 downTo 0) else (0..5)
|
||||
return indices.joinToString(":") { "%02X".format(bytes[it]) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostic-only NUL-delimited segmentation of a 0x1D INFORMATION payload, used for the
|
||||
* issue #173 engraving discovery logging. Not part of the production decode path — the
|
||||
|
||||
@@ -9,4 +9,7 @@ data class AapDeviceInfo(
|
||||
val manufacturer: String,
|
||||
val serialNumber: String,
|
||||
val firmwareVersion: String,
|
||||
val leftEarbudSerial: String? = null,
|
||||
val rightEarbudSerial: String? = null,
|
||||
val buildNumber: String? = null,
|
||||
)
|
||||
|
||||
@@ -186,4 +186,17 @@ sealed class AapSetting {
|
||||
) : AapSetting() {
|
||||
enum class Pod { LEFT, RIGHT }
|
||||
}
|
||||
|
||||
/**
|
||||
* Catch-all for setting IDs whose semantics are unconfirmed.
|
||||
* Decoded to keep lastMessageAt fresh and log via the normal "Setting:" path.
|
||||
* Not exposed in UI. When a setting's purpose is confirmed, promote it to
|
||||
* its own named subclass.
|
||||
*/
|
||||
data class UnknownSetting(
|
||||
val settingId: Int,
|
||||
val rawValue: Int,
|
||||
) : AapSetting() {
|
||||
override fun toString(): String = "UnknownSetting(settingId=0x%02X, rawValue=0x%02X)".format(settingId, rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
+39
-12
@@ -50,6 +50,12 @@ class DefaultAapDeviceProfile(
|
||||
const val SETTING_SLEEP_DETECTION = 0x35
|
||||
const val SETTING_STEM_CONFIG = 0x39
|
||||
|
||||
// Known-but-unconfirmed setting IDs (H2+ exclusive). Decoded as UnknownSetting
|
||||
// to keep lastMessageAt fresh. Observed on Pro 2 USB-C and/or Pro 3.
|
||||
val UNCONFIRMED_SETTING_IDS = setOf(
|
||||
0x29, 0x2C, 0x2F, 0x30, 0x33, 0x37, 0x38, 0x3B, 0x3E,
|
||||
)
|
||||
|
||||
// Command types for non-settings messages
|
||||
const val CMD_RENAME = 0x001E
|
||||
const val CMD_STEM_PRESS = 0x0019
|
||||
@@ -269,6 +275,12 @@ class DefaultAapDeviceProfile(
|
||||
val enabled = decodeAppleBool(value) ?: return null
|
||||
AapSetting.InCaseTone::class to AapSetting.InCaseTone(enabled)
|
||||
}
|
||||
in UNCONFIRMED_SETTING_IDS -> {
|
||||
AapSetting.UnknownSetting::class to AapSetting.UnknownSetting(
|
||||
settingId = settingId,
|
||||
rawValue = value,
|
||||
)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -362,6 +374,12 @@ class DefaultAapDeviceProfile(
|
||||
manufacturer = strings.getOrElse(2) { "" },
|
||||
serialNumber = strings.getOrElse(3) { "" },
|
||||
firmwareVersion = strings.getOrElse(4) { "" },
|
||||
// Segments [5]=firmware dup, [6]=protocol version, [7]=updater app ID — skipped
|
||||
// Segments [8] and [9] are individual earbud serials (observed on Pro 1, Pro 2, Pro 3)
|
||||
leftEarbudSerial = strings.getOrNull(8)?.takeIf { it.isNotBlank() },
|
||||
rightEarbudSerial = strings.getOrNull(9)?.takeIf { it.isNotBlank() },
|
||||
// Segment [10] is the build number (e.g. "8454624")
|
||||
buildNumber = strings.getOrNull(10)?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -493,20 +511,29 @@ class DefaultAapDeviceProfile(
|
||||
}
|
||||
|
||||
private fun parseNullTerminatedStrings(data: ByteArray): List<String> {
|
||||
val strings = mutableListOf<String>()
|
||||
var start = 0
|
||||
// Skip binary header until the first printable byte (device name always starts
|
||||
// with a printable character). This skips the length/type prefix bytes.
|
||||
var headerEnd = 0
|
||||
while (headerEnd < data.size) {
|
||||
val b = data[headerEnd].toInt() and 0xFF
|
||||
if (b in 0x20..0x7E) break
|
||||
headerEnd++
|
||||
}
|
||||
if (headerEnd >= data.size) return emptyList()
|
||||
|
||||
// Find runs of printable ASCII (0x20..0x7E) separated by null bytes.
|
||||
// Header bytes and non-ASCII bytes are skipped.
|
||||
var i = 0
|
||||
// Split on NUL bytes and decode each segment as UTF-8.
|
||||
// This handles device names with non-ASCII characters (e.g. curly quotes
|
||||
// in "Matthias\u2019s AirPods Pro") that the old ASCII-only scanner would break on.
|
||||
val strings = mutableListOf<String>()
|
||||
var i = headerEnd
|
||||
while (i < data.size) {
|
||||
val b = data[i].toInt() and 0xFF
|
||||
if (b in 0x20..0x7E) {
|
||||
start = i
|
||||
while (i < data.size && data[i] != 0x00.toByte()) i++
|
||||
strings.add(String(data, start, i - start, Charsets.US_ASCII))
|
||||
}
|
||||
i++
|
||||
// Skip NUL separators
|
||||
while (i < data.size && data[i] == 0x00.toByte()) i++
|
||||
if (i >= data.size) break
|
||||
|
||||
val segStart = i
|
||||
while (i < data.size && data[i] != 0x00.toByte()) i++
|
||||
strings.add(String(data, segStart, i - segStart, Charsets.UTF_8))
|
||||
}
|
||||
return strings
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<string name="general_donate_action">Donate</string>
|
||||
<string name="general_check_action">Check</string>
|
||||
<string name="general_close_action">Close</string>
|
||||
<string name="general_edit_action">Edit</string>
|
||||
<string name="general_save_action">Save</string>
|
||||
<string name="general_guide_action">Guide</string>
|
||||
<string name="general_continue_action">Continue</string>
|
||||
@@ -438,9 +439,15 @@
|
||||
|
||||
<!-- Device Settings -->
|
||||
<string name="device_settings_title">Device Settings</string>
|
||||
<string name="device_settings_subtitle_profile_prefix">Profile: %s</string>
|
||||
<string name="device_settings_info_name_label">Name</string>
|
||||
<string name="device_settings_info_bt_name_label">Bluetooth Device Label</string>
|
||||
<string name="device_settings_info_model_label">Model</string>
|
||||
<string name="device_settings_info_serial_label">Serial Number</string>
|
||||
<string name="device_settings_info_firmware_label">Firmware</string>
|
||||
<string name="device_settings_info_build_label">Build</string>
|
||||
<string name="device_settings_info_left_serial_label">Left Serial</string>
|
||||
<string name="device_settings_info_right_serial_label">Right Serial</string>
|
||||
<string name="device_settings_info_status_label">Status</string>
|
||||
<string name="device_settings_info_last_seen_label">Last Seen</string>
|
||||
<string name="device_settings_info_first_seen_label">First Seen</string>
|
||||
@@ -495,13 +502,18 @@
|
||||
<string name="device_settings_microphone_mode_auto">Auto</string>
|
||||
<string name="device_settings_microphone_mode_right">Right</string>
|
||||
<string name="device_settings_microphone_mode_left">Left</string>
|
||||
<string name="device_settings_listening_mode_cycle_label">Noise Control Cycle</string>
|
||||
<string name="device_settings_listening_mode_cycle_description">Select which modes cycle when pressing and holding the stem</string>
|
||||
<string name="device_settings_noise_control_current_mode_label">Current mode</string>
|
||||
<string name="device_settings_listening_mode_cycle_label">Long-press cycle</string>
|
||||
<string name="device_settings_listening_mode_cycle_description">Choose which modes are available when holding the stem and in the quick switcher.</string>
|
||||
<string name="device_settings_listening_mode_cycle_dialog_title">Customize Long-Press Cycle</string>
|
||||
<string name="device_settings_listening_mode_cycle_summary_helper">Used when holding the stem and in the quick switcher</string>
|
||||
<string name="device_settings_listening_mode_cycle_summary_helper_override">Used in the quick switcher. A custom stem long-press action overrides AirPods cycling.</string>
|
||||
<string name="device_settings_listening_mode_cycle_minimum">At least 2 modes must stay enabled.</string>
|
||||
<string name="device_settings_listening_mode_cycle_off">Off</string>
|
||||
<string name="device_settings_listening_mode_cycle_anc">Noise Cancellation</string>
|
||||
<string name="device_settings_listening_mode_cycle_transparency">Transparency</string>
|
||||
<string name="device_settings_listening_mode_cycle_adaptive">Adaptive</string>
|
||||
<string name="device_settings_allow_off_label">Include Off in Noise Control</string>
|
||||
<string name="device_settings_allow_off_label">Include Off</string>
|
||||
<string name="device_settings_allow_off_description">Show Off as an option when cycling noise control modes</string>
|
||||
<string name="device_settings_sleep_detection_label">Sleep Detection</string>
|
||||
<string name="device_settings_sleep_detection_description">Automatically pause audio when you fall asleep</string>
|
||||
@@ -542,5 +554,6 @@
|
||||
<string name="stem_action_volume_down">Volume Down</string>
|
||||
<string name="stem_actions_reset_label">Reset to defaults</string>
|
||||
<string name="stem_actions_reset_confirm_message">Reset all stem actions to defaults?</string>
|
||||
<string name="device_settings_noise_control_open_stem_actions_action">Open Stem Actions</string>
|
||||
|
||||
</resources>
|
||||
|
||||
+28
@@ -572,4 +572,32 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() {
|
||||
@Test fun `unknown setting ID returns null`() { profile.decodeSetting(settingsMessage(0x7F, 0x01)).shouldBeNull() }
|
||||
@Test fun `non-settings command returns null`() { profile.decodeSetting(aapMessage("04 00 04 00 1D 00 01 02 03 04")).shouldBeNull() }
|
||||
@Test fun `payload too short returns null`() { profile.decodeSetting(aapMessage("04 00 04 00 09 00 0D")).shouldBeNull() }
|
||||
|
||||
// ── DeviceInfo UTF-8 ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `decodeDeviceInfo handles curly quote in device name`() {
|
||||
// "Matthias\u2019s AirPods Pro" — curly right single quote is UTF-8 E2 80 99.
|
||||
// The old ASCII-only parser would split at these bytes, breaking all segment indices.
|
||||
// Header bytes must all be < 0x20 to avoid being mistaken for string content.
|
||||
val msg = aapMessage(
|
||||
"04 00 04 00 1D 00 02 0F 00 04 00",
|
||||
// [0] name: "Matthias\u2019s AirPods Pro" (UTF-8)
|
||||
"4D 61 74 74 68 69 61 73 E2 80 99 73 20 41 69 72 50 6F 64 73 20 50 72 6F 00",
|
||||
// [1] model
|
||||
"41 33 30 34 38 00",
|
||||
// [2] manufacturer
|
||||
"41 70 70 6C 65 20 49 6E 63 2E 00",
|
||||
// [3] serial
|
||||
"57 35 4A 37 4B 56 30 4E 30 34 00",
|
||||
// [4] firmware
|
||||
"37 41 33 30 35 00",
|
||||
)
|
||||
val info = profile.decodeDeviceInfo(msg)!!
|
||||
info.name shouldBe "Matthias\u2019s AirPods Pro"
|
||||
info.modelNumber shouldBe "A3048"
|
||||
info.manufacturer shouldBe "Apple Inc."
|
||||
info.serialNumber shouldBe "W5J7KV0N04"
|
||||
info.firmwareVersion shouldBe "7A305"
|
||||
}
|
||||
}
|
||||
|
||||
+14
-4
@@ -56,6 +56,9 @@ class AirPodsPro2UsbcAapSessionTest : BaseAapSessionTest() {
|
||||
info.name shouldBe "AirPods Pro"
|
||||
info.modelNumber shouldBe "A3048"
|
||||
info.manufacturer shouldBe "Apple Inc."
|
||||
info.leftEarbudSerial shouldBe "H3KL7HR926JY"
|
||||
info.rightEarbudSerial shouldBe "H3KL2AYL26K0"
|
||||
info.buildNumber shouldBe "8454480"
|
||||
}
|
||||
|
||||
// ── Battery ──────────────────────────────────────────────
|
||||
@@ -242,11 +245,18 @@ class AirPodsPro2UsbcAapSessionTest : BaseAapSessionTest() {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown settings IDs return null`() {
|
||||
val unknownIds = listOf(0x29, 0x2C, 0x2F, 0x33)
|
||||
for (id in unknownIds) {
|
||||
profile.decodeSetting(settingsMessage(id, 0x02)).shouldBeNull()
|
||||
fun `unconfirmed settings IDs decode as UnknownSetting`() {
|
||||
val unconfirmedIds = listOf(0x29, 0x2C, 0x2F, 0x33)
|
||||
for (id in unconfirmedIds) {
|
||||
val setting = decodeSetting<AapSetting.UnknownSetting>(settingsMessage(id, 0x02))
|
||||
setting.settingId shouldBe id
|
||||
setting.rawValue shouldBe 0x02
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `truly unknown setting ID returns null`() {
|
||||
profile.decodeSetting(settingsMessage(0x7F, 0x01)).shouldBeNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
-4
@@ -56,6 +56,9 @@ class AirPodsPro3AapSessionTest : BaseAapSessionTest() {
|
||||
info.name shouldBe "AirPods Pro 3"
|
||||
info.modelNumber shouldBe "A3064"
|
||||
info.manufacturer shouldBe "Apple Inc."
|
||||
info.leftEarbudSerial shouldBe "GMPHNZ16P5Z0000UHZ"
|
||||
info.rightEarbudSerial shouldBe "GMVHNX15UED0000UHY"
|
||||
info.buildNumber shouldBe "8454624"
|
||||
}
|
||||
|
||||
// ── Battery ──────────────────────────────────────────────
|
||||
@@ -236,11 +239,18 @@ class AirPodsPro3AapSessionTest : BaseAapSessionTest() {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown settings IDs return null`() {
|
||||
val unknownIds = listOf(0x29, 0x2C, 0x2F, 0x33, 0x30, 0x37, 0x38, 0x3B)
|
||||
for (id in unknownIds) {
|
||||
profile.decodeSetting(settingsMessage(id, 0x01)).shouldBeNull()
|
||||
fun `unconfirmed settings IDs decode as UnknownSetting`() {
|
||||
val unconfirmedIds = listOf(0x29, 0x2C, 0x2F, 0x33, 0x30, 0x37, 0x38, 0x3B)
|
||||
for (id in unconfirmedIds) {
|
||||
val setting = decodeSetting<AapSetting.UnknownSetting>(settingsMessage(id, 0x01))
|
||||
setting.settingId shouldBe id
|
||||
setting.rawValue shouldBe 0x01
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `truly unknown setting ID returns null`() {
|
||||
profile.decodeSetting(settingsMessage(0x7F, 0x01)).shouldBeNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -56,6 +56,9 @@ class AirPodsProAapSessionTest : BaseAapSessionTest() {
|
||||
info.name shouldBe "AirPods Pro"
|
||||
info.modelNumber shouldBe "A2084"
|
||||
info.manufacturer shouldBe "Apple Inc."
|
||||
info.leftEarbudSerial shouldBe "GXDDRFNW0C6K"
|
||||
info.rightEarbudSerial shouldBe "H6RHL0HF0C6J"
|
||||
info.buildNumber shouldBe "3344646"
|
||||
}
|
||||
|
||||
// ── Battery ──────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user