fix(monitor): Persist AAP keys only when their content changed

AppleDeviceProfile is a data class whose key fields are ByteArrays, so the
generated equals() compares them by reference and the "did anything change"
guard was true on every key exchange. That meant a redundant profile write on
every connect and a "Persisted keys" line that said nothing about whether a
key had actually changed.

Compare content instead, per key, and name the key that changed in the log.
This commit is contained in:
darken
2026-08-31 15:54:51 +02:00
committed by Matthias Urhahn
parent cc99ad59ab
commit f82adeba42
2 changed files with 123 additions and 7 deletions
@@ -1,5 +1,6 @@
package eu.darken.capod.monitor.core.aap
import eu.darken.capod.common.debug.logging.Logging.Priority.VERBOSE
import eu.darken.capod.common.debug.logging.log
import eu.darken.capod.common.debug.logging.logTag
import eu.darken.capod.common.flow.setupCommonEventHandlers
@@ -33,15 +34,22 @@ class AapKeyPersister @Inject constructor(
return@onEach
}
val updated = profile.copy(
identityKey = keys.irk ?: profile.identityKey,
encryptionKey = keys.encKey ?: profile.encryptionKey,
)
// The profile's key fields are ByteArrays, so the generated equals() compares them by
// reference — content comparison is what decides whether anything actually changed.
val irkChanged = keys.irk != null && !keys.irk.contentEquals(profile.identityKey)
val encChanged = keys.encKey != null && !keys.encKey.contentEquals(profile.encryptionKey)
if (updated != profile) {
profilesRepo.updateProfile(updated)
log(TAG) { "Persisted keys for $address (IRK=${keys.irk != null}, ENC=${keys.encKey != null})" }
if (!irkChanged && !encChanged) {
log(TAG, VERBOSE) { "Keys for $address are unchanged, skipping key persistence" }
return@onEach
}
val updated = profile.copy(
identityKey = if (irkChanged) keys.irk else profile.identityKey,
encryptionKey = if (encChanged) keys.encKey else profile.encryptionKey,
)
profilesRepo.updateProfile(updated)
log(TAG) { "Persisted keys for $address (IRK changed=$irkChanged, ENC changed=$encChanged)" }
}
.map { }
.setupCommonEventHandlers(TAG) { "keyPersister" }
@@ -0,0 +1,108 @@
package eu.darken.capod.monitor.core.aap
import eu.darken.capod.common.bluetooth.BluetoothAddress
import eu.darken.capod.common.fromHex
import eu.darken.capod.pods.core.apple.PodModel
import eu.darken.capod.pods.core.apple.aap.AapConnectionManager
import eu.darken.capod.pods.core.apple.aap.protocol.KeyExchangeResult
import eu.darken.capod.profiles.core.AppleDeviceProfile
import eu.darken.capod.profiles.core.DeviceProfile
import eu.darken.capod.profiles.core.DeviceProfilesRepo
import io.kotest.matchers.shouldBe
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
/**
* The key fields on [AppleDeviceProfile] are ByteArrays, so a wrong null-handling or
* reference-comparison here either writes the profile on every connect or stops persisting keys
* entirely.
*/
class AapKeyPersisterTest : BaseTest() {
private val address: BluetoothAddress = "AA:BB:CC:DD:EE:FF"
private val irkHex = "79-04-65-1E-E2-CC-D9-26-F2-6E-20-EE-3E-CC-DE-79"
private val encHex = "11-22-33-44-55-66-77-88-99-AA-BB-CC-DD-EE-FF-00"
private val aapManager = mockk<AapConnectionManager>()
private val profilesRepo = mockk<DeviceProfilesRepo>()
private suspend fun runPersister(profile: AppleDeviceProfile, keys: KeyExchangeResult) {
val keysReceived = MutableSharedFlow<Pair<BluetoothAddress, KeyExchangeResult>>(replay = 1)
keysReceived.tryEmit(address to keys)
every { aapManager.keysReceived } returns keysReceived
every { profilesRepo.profiles } returns flowOf(listOf(profile))
coEvery { profilesRepo.updateProfile(any()) } returns Unit
AapKeyPersister(aapManager, profilesRepo).monitor().first()
}
private fun profile(
identityKey: ByteArray? = null,
encryptionKey: ByteArray? = null,
) = AppleDeviceProfile(
label = "Mine",
model = PodModel.AIRPODS_PRO2_USBC,
identityKey = identityKey,
encryptionKey = encryptionKey,
address = address,
)
private fun capturedProfile(): AppleDeviceProfile {
val captured = slot<DeviceProfile>()
coVerify(exactly = 1) { profilesRepo.updateProfile(capture(captured)) }
return captured.captured as AppleDeviceProfile
}
@Test
fun `a first key is persisted`() = runTest {
runPersister(
profile = profile(),
keys = KeyExchangeResult(irk = irkHex.fromHex(), encKey = null),
)
capturedProfile().identityKey.contentEquals(irkHex.fromHex()) shouldBe true
}
@Test
fun `a key equal by content is not persisted again`() = runTest {
runPersister(
profile = profile(identityKey = irkHex.fromHex(), encryptionKey = encHex.fromHex()),
keys = KeyExchangeResult(irk = irkHex.fromHex(), encKey = encHex.fromHex()),
)
coVerify(exactly = 0) { profilesRepo.updateProfile(any()) }
}
@Test
fun `a missing incoming key leaves the stored one alone`() = runTest {
runPersister(
profile = profile(identityKey = irkHex.fromHex(), encryptionKey = encHex.fromHex()),
keys = KeyExchangeResult(irk = null, encKey = encHex.fromHex()),
)
coVerify(exactly = 0) { profilesRepo.updateProfile(any()) }
}
@Test
fun `a changed encryption key is persisted without touching the identity key`() = runTest {
val newEnc = "00-11-22-33-44-55-66-77-88-99-AA-BB-CC-DD-EE-FF".fromHex()
runPersister(
profile = profile(identityKey = irkHex.fromHex(), encryptionKey = encHex.fromHex()),
keys = KeyExchangeResult(irk = irkHex.fromHex(), encKey = newEnc),
)
val written = capturedProfile()
written.encryptionKey.contentEquals(newEnc) shouldBe true
written.identityKey.contentEquals(irkHex.fromHex()) shouldBe true
}
}