refactor(aap): Send InitExt unconditionally to all devices

Older devices silently ignore the 0x4D packet, so there is no need to gate it per model. Sending it unconditionally prevents silent feature degradation when new H2+ models are added without the flag.

Remove needsInitExt from PodModel.Features.
This commit is contained in:
darken
2026-04-15 08:07:12 +02:00
committed by Matthias Urhahn
parent f17b6ca99f
commit 6f90687b72
6 changed files with 13 additions and 35 deletions
@@ -99,7 +99,6 @@ enum class PodModel(
hasAllowOffOption = true,
hasStemConfig = true,
hasSleepDetection = true,
needsInitExt = true,
),
modelNumbers = setOf("A3055", "A3056", "A3057"), // earphones
leftPodIconRes = R.drawable.device_airpods_gen4anc_left,
@@ -158,7 +157,6 @@ enum class PodModel(
hasAllowOffOption = true,
hasStemConfig = true,
hasSleepDetection = true,
needsInitExt = true,
),
modelNumbers = setOf("A2698", "A2699", "A2931"), // earphones
leftPodIconRes = R.drawable.device_airpods_pro2_left,
@@ -192,7 +190,6 @@ enum class PodModel(
hasAllowOffOption = true,
hasStemConfig = true,
hasSleepDetection = true,
needsInitExt = true,
),
modelNumbers = setOf("A3047", "A3048", "A3049"), // earphones
leftPodIconRes = R.drawable.device_airpods_pro2_left,
@@ -226,7 +223,6 @@ enum class PodModel(
hasAllowOffOption = true,
hasStemConfig = true,
hasSleepDetection = true,
needsInitExt = true,
),
modelNumbers = setOf("A3063", "A3064", "A3065"), // earphones
leftPodIconRes = R.drawable.device_airpods_pro2_left,
@@ -281,7 +277,6 @@ enum class PodModel(
hasEarDetectionToggle = true,
hasListeningModeCycle = true,
hasAllowOffOption = true,
needsInitExt = true,
),
modelNumbers = setOf("A3454"), // headphones
),
@@ -550,7 +545,5 @@ enum class PodModel(
val hasAllowOffOption: Boolean = false,
val hasStemConfig: Boolean = false,
val hasSleepDetection: Boolean = false,
// Protocol
val needsInitExt: Boolean = false,
)
}
@@ -110,12 +110,10 @@ internal class AapConnection(
}
log(TAG) { "Notification enable sent" }
// Send InitExt for models that need it (Pro 2/3/USB-C, AP4 ANC)
profile.encodeInitExt()?.let { initExt ->
sock.outputStream.write(initExt)
sock.outputStream.flush()
log(TAG) { "InitExt sent" }
}
// Send InitExt — enables advanced features on H2+ devices, ignored by older models
sock.outputStream.write(profile.encodeInitExt())
sock.outputStream.flush()
log(TAG) { "InitExt sent" }
// Request private keys (IRK + ENC) for BLE encrypted battery
profile.encodePrivateKeyRequest()?.let { keyReq ->
@@ -50,10 +50,11 @@ interface AapDeviceProfile {
fun encodeNotificationEnable(): List<ByteArray>
/**
* Encode the extended init packet (0x4D) for models that require it
* (e.g., Pro 2/3/USB-C, AirPods 4 ANC). Returns null if not needed.
* Encode the extended init packet (0x4D).
* Enables advanced features (Adaptive Transparency, Conversational Awareness during playback)
* on H2+ devices; silently ignored by older models.
*/
fun encodeInitExt(): ByteArray?
fun encodeInitExt(): ByteArray
/**
* Encode a private key request (command 0x30).
@@ -83,13 +83,10 @@ class DefaultAapDeviceProfile(
byteArrayOf(0x04, 0x00, 0x04, 0x00, 0x0f, 0x00, 0xff.toByte(), 0xff.toByte(), 0xff.toByte(), 0xff.toByte()),
)
override fun encodeInitExt(): ByteArray? {
if (!model.features.needsInitExt) return null
return byteArrayOf(
0x04, 0x00, 0x04, 0x00, 0x4d, 0x00, 0xd7.toByte(), 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
)
}
override fun encodeInitExt(): ByteArray = byteArrayOf(
0x04, 0x00, 0x04, 0x00, 0x4d, 0x00, 0xd7.toByte(), 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))
@@ -349,7 +349,6 @@ class DefaultAapDeviceProfileNewSettingsTest : BaseAapSessionTest() {
f.hasListeningModeCycle shouldBe true
f.hasAllowOffOption shouldBe true
f.hasEarDetectionToggle shouldBe true
f.needsInitExt shouldBe true
// Headphone — no stem/swipe/dual-pod/case features
f.hasDualPods shouldBe false
f.hasCase shouldBe false
@@ -57,19 +57,9 @@ class DefaultAapDeviceProfileTest : BaseAapSessionTest() {
@Nested
inner class InitExtTests {
@Test
fun `returned for Pro 2`() { DefaultAapDeviceProfile(PodModel.AIRPODS_PRO2).encodeInitExt().shouldNotBeNull() }
@Test fun `returned for Pro 3`() { DefaultAapDeviceProfile(PodModel.AIRPODS_PRO3).encodeInitExt().shouldNotBeNull() }
@Test fun `returned for AP4 ANC`() { DefaultAapDeviceProfile(PodModel.AIRPODS_GEN4_ANC).encodeInitExt().shouldNotBeNull() }
@Test
fun `null for basic AirPods`() { DefaultAapDeviceProfile(PodModel.AIRPODS_GEN3).encodeInitExt().shouldBeNull() }
@Test fun `null for Pro 1`() { DefaultAapDeviceProfile(PodModel.AIRPODS_PRO).encodeInitExt().shouldBeNull() }
@Test fun `null for Max`() { DefaultAapDeviceProfile(PodModel.AIRPODS_MAX).encodeInitExt().shouldBeNull() }
@Test fun `returned for Max 2`() { DefaultAapDeviceProfile(PodModel.AIRPODS_MAX2).encodeInitExt().shouldNotBeNull() }
@Test
fun `has correct command byte`() {
profile.encodeInitExt()!![4] shouldBe 0x4d.toByte()
profile.encodeInitExt()[4] shouldBe 0x4d.toByte()
}
}