mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-15 02:36:12 -04:00
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:
+46
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+17
-8
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user