fix(aap): Make AirPods rename actually apply on device

Switch the AAP rename packet to the opcode 0x1A format (04 00 04 00 1A 00 01 [size] 00 [name]) matching the LibrePods documentation and Linux implementation. The previous 0x1E variant (from the LibrePods Android code) was silently ignored by AirPods Pro 2 USB-C firmware — no 0x001D echo, no persistence across reconnect.

Verified on AirPods Pro 2 USB-C (firmware 81.2675...): the device now echoes the new name back via the next 0x001D INFORMATION message, and the name persists after disconnect/reconnect.

Also hardens the rename UX: gate the edit icon on isAapReady (was isAapConnected, which allowed sending during HANDSHAKING), apply an optimistic deviceInfo update with a scoped rollback on send failure, surface send errors via a new Event.SendFailed + snackbar, and restrict dialog input to ASCII with inline error feedback. Unifies send() / sendProGated() through a single sendInternal() helper so error plumbing benefits every command, not just rename.

Note: this only updates the AirPods firmware's self-reported name. Android's system Bluetooth settings read from the bond database and are not affected — renaming there still requires the Android system Bluetooth UI.
This commit is contained in:
darken
2026-04-08 15:36:18 +02:00
committed by Matthias Urhahn
parent ccd95a0981
commit 41245d928c
7 changed files with 143 additions and 30 deletions
@@ -7,11 +7,15 @@ import eu.darken.capod.common.upgrade.UpgradeRepo
import eu.darken.capod.monitor.core.DeviceMonitor
import eu.darken.capod.monitor.core.PodDevice
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.protocol.AapCommand
import io.kotest.matchers.shouldBe
import io.kotest.matchers.types.shouldBeInstanceOf
import io.mockk.Called
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -215,4 +219,46 @@ class DeviceSettingsViewModelTest : BaseTest() {
coVerify(exactly = 1) { bluetoothManager.nudgeConnection(bonded) }
vm.state.first().isForceConnecting shouldBe false
}
@Test
fun `setDeviceName forwards SetDeviceName command to aapManager`() = runTest(testDispatcher) {
val vm = createViewModel()
vm.initialize(testAddress)
vm.state.first()
vm.setDeviceName("NewName")
coVerify { aapManager.sendCommand(testAddress, AapCommand.SetDeviceName("NewName")) }
}
@Test
fun `setDeviceName when no target address is a no-op`() = runTest(testDispatcher) {
val vm = createViewModel()
// Intentionally skip initialize — targetAddress stays null.
vm.setDeviceName("NewName")
// `any<AapCommand>()` can't be used here (AapCommand is sealed, mockk can't stub it),
// so verify the entire manager was not called instead.
verify { aapManager wasNot Called }
}
@Test
fun `setDeviceName failure emits SendFailed event`() = runTest(testDispatcher) {
val failure = IllegalStateException("socket closed")
coEvery {
aapManager.sendCommand(testAddress, AapCommand.SetDeviceName("NewName"))
} throws failure
val vm = createViewModel()
vm.initialize(testAddress)
vm.state.first()
vm.setDeviceName("NewName")
val event = vm.events.first()
val sendFailed = event.shouldBeInstanceOf<DeviceSettingsViewModel.Event.SendFailed>()
sendFailed.command shouldBe AapCommand.SetDeviceName("NewName")
sendFailed.message shouldBe "socket closed"
}
}
@@ -128,16 +128,23 @@ class DefaultAapDeviceProfileNewSettingsTest : BaseAapSessionTest() {
@Test fun `decode unknown returns null`() { profile.decodeSetting(settingsMessage(0x31, 0x00)).shouldBeNull() }
}
// ── Device Rename (0x1E) ────────────────────────────────
// ── Device Rename (0x1A) ────────────────────────────────
// The working opcode is 0x1A (not the 0x1E variant in LibrePods Android); see the rationale
// comment in DefaultAapDeviceProfile.buildRenameMessage for the on-device test details.
@Nested
inner class DeviceRenameTests {
@Test
fun `encode simple ASCII name`() {
fun `encode simple ASCII name full frame`() {
// Locks in the full wire format: header + opcode prefix (1A 00 01) + length (LE u16) + name.
val bytes = profile.encodeCommand(AapCommand.SetDeviceName("MyPods"))
bytes[4] shouldBe 0x1E.toByte()
bytes[6] shouldBe 6.toByte() // length
String(bytes, 8, 6, Charsets.UTF_8) shouldBe "MyPods"
val expected = byteArrayOf(
0x04, 0x00, 0x04, 0x00,
0x1A, 0x00, 0x01,
0x06, 0x00,
0x4D, 0x79, 0x50, 0x6F, 0x64, 0x73,
)
bytes shouldBe expected
}
@Test
@@ -145,8 +152,9 @@ class DefaultAapDeviceProfileNewSettingsTest : BaseAapSessionTest() {
val name = "AirPods \uD83C\uDFA7" // headphone emoji
val nameBytes = name.toByteArray(Charsets.UTF_8)
val bytes = profile.encodeCommand(AapCommand.SetDeviceName(name))
bytes[6] shouldBe nameBytes.size.toByte()
String(bytes, 8, nameBytes.size, Charsets.UTF_8) shouldBe name
bytes.size shouldBe 9 + nameBytes.size
bytes[7] shouldBe nameBytes.size.toByte()
String(bytes, 9, nameBytes.size, Charsets.UTF_8) shouldBe name
}
@Test
@@ -159,7 +167,8 @@ class DefaultAapDeviceProfileNewSettingsTest : BaseAapSessionTest() {
fun `encode accepts 127 byte name`() {
val name = "A".repeat(127)
val bytes = profile.encodeCommand(AapCommand.SetDeviceName(name))
bytes[6] shouldBe 127.toByte()
bytes.size shouldBe 9 + 127
bytes[7] shouldBe 127.toByte()
}
}