From 7bdc9ca21285f3459663be90f1c21d43c5eb7e63 Mon Sep 17 00:00:00 2001 From: darken Date: Tue, 31 Mar 2026 20:46:16 +0200 Subject: [PATCH] feat: Auto-correct device model from AAP device info When connected via AAP, the device reports its hardware model number. If the profile has a wrong or missing model, detect the mismatch and automatically correct it with a reconnect to apply correct feature flags (ANC modes, InitExt, etc.). Adds modelNumbers field to PodModel enum with Apple hardware identifiers for all known devices, and a fromModelNumber() lookup function. --- .../darken/capod/pods/core/apple/PodModel.kt | 69 ++++++- .../capod/reaction/core/aap/AapAutoConnect.kt | 54 ++++++ .../reaction/core/aap/AapAutoConnectTest.kt | 172 +++++++++++++++++- 3 files changed, 286 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/eu/darken/capod/pods/core/apple/PodModel.kt b/app/src/main/java/eu/darken/capod/pods/core/apple/PodModel.kt index 93653ed5..b5870523 100644 --- a/app/src/main/java/eu/darken/capod/pods/core/apple/PodModel.kt +++ b/app/src/main/java/eu/darken/capod/pods/core/apple/PodModel.kt @@ -10,6 +10,7 @@ enum class PodModel( val label: String, @DrawableRes val iconRes: Int = R.drawable.device_earbuds_generic_both, val features: Features = Features(), + val modelNumbers: Set = emptySet(), ) { @SerialName("airpods.gen1") AIRPODS_GEN1( @@ -19,6 +20,7 @@ enum class PodModel( hasDualPods = true, hasCase = true, ), + modelNumbers = setOf("A1523", "A1722"), // L/R earphones ), @SerialName("airpods.gen2") @@ -29,6 +31,7 @@ enum class PodModel( hasDualPods = true, hasCase = true, ), + modelNumbers = setOf("A2031", "A2032"), // L/R earphones ), @SerialName("airpods.gen3") @@ -39,6 +42,7 @@ enum class PodModel( hasDualPods = true, hasCase = true, ), + modelNumbers = setOf("A2564", "A2565"), // L/R earphones ), @SerialName("airpods.gen4") @@ -49,6 +53,7 @@ enum class PodModel( hasDualPods = true, hasCase = true, ), + modelNumbers = setOf("A3050", "A3053", "A3054"), // earphones ), @SerialName("airpods.gen4.anc") @@ -73,6 +78,7 @@ enum class PodModel( hasAdaptiveAudioNoise = true, needsInitExt = true, ), + modelNumbers = setOf("A3055", "A3056", "A3057"), // earphones ), @SerialName("airpods.pro") @@ -91,6 +97,7 @@ enum class PodModel( hasVolumeSwipeLength = true, hasToneVolume = true, ), + modelNumbers = setOf("A2083", "A2084"), // L/R earphones ), @SerialName("airpods.pro2") @@ -115,6 +122,7 @@ enum class PodModel( hasAdaptiveAudioNoise = true, needsInitExt = true, ), + modelNumbers = setOf("A2698", "A2699", "A2931"), // earphones ), @SerialName("airpods.pro2.usbc") @@ -139,6 +147,7 @@ enum class PodModel( hasAdaptiveAudioNoise = true, needsInitExt = true, ), + modelNumbers = setOf("A3047", "A3048", "A3049"), // earphones ), @SerialName("airpods.pro3") @@ -163,6 +172,7 @@ enum class PodModel( hasAdaptiveAudioNoise = true, needsInitExt = true, ), + modelNumbers = setOf("A3063", "A3064", "A3065"), // earphones ), @SerialName("airpods.max") @@ -175,6 +185,7 @@ enum class PodModel( hasPressHoldDuration = true, hasToneVolume = true, ), + modelNumbers = setOf("A2096"), // headphones ), @SerialName("airpods.max.usbc") @@ -187,23 +198,37 @@ enum class PodModel( hasPressHoldDuration = true, hasToneVolume = true, ), + modelNumbers = setOf("A3184"), // headphones ), @SerialName("beats.flex") - BEATS_FLEX("Beats Flex", R.drawable.device_beats_earbuds), + BEATS_FLEX( + "Beats Flex", + R.drawable.device_beats_earbuds, + modelNumbers = setOf("A2295"), + ), @SerialName("beats.solo.3") - BEATS_SOLO_3("Beats Solo 3", R.drawable.device_beats_headphones), + BEATS_SOLO_3( + "Beats Solo 3", + R.drawable.device_beats_headphones, + modelNumbers = setOf("A1796"), // headphones + ), @SerialName("beats.solo.pro") BEATS_SOLO_PRO( "Beats Solo Pro", R.drawable.device_beats_headphones, Features(hasAncControl = true), + modelNumbers = setOf("A1881"), // headphones ), @SerialName("beats.solo.4") - BEATS_SOLO_4("Beats Solo 4", R.drawable.device_beats_headphones), + BEATS_SOLO_4( + "Beats Solo 4", + R.drawable.device_beats_headphones, + modelNumbers = setOf("A3140"), // headphones + ), @SerialName("beats.solo.buds") BEATS_SOLO_BUDS( @@ -213,6 +238,7 @@ enum class PodModel( hasDualPods = true, hasCase = true, ), + modelNumbers = setOf("A3150", "A3151", "A3153"), // L/R earbuds + case ), @SerialName("beats.studio.3") @@ -220,6 +246,7 @@ enum class PodModel( "Beats Studio 3", R.drawable.device_beats_studio3, Features(hasAncControl = true), + modelNumbers = setOf("A1914"), // headphones ), @SerialName("beats.studio.buds") @@ -232,6 +259,7 @@ enum class PodModel( hasEarDetection = true, hasAncControl = true, ), + modelNumbers = setOf("A2512", "A2513", "A2514"), // L/R earbuds + case ), @SerialName("beats.studio.buds.plus") @@ -244,6 +272,7 @@ enum class PodModel( hasEarDetection = true, hasAncControl = true, ), + modelNumbers = setOf("A2871", "A2872", "A2952"), // L/R earbuds + case ), @SerialName("beats.studio.pro") @@ -251,16 +280,29 @@ enum class PodModel( "Beats Studio Pro", R.drawable.device_beats_headphones, Features(hasAncControl = true), + modelNumbers = setOf("A2924"), // headphones ), @SerialName("beats.x") - BEATS_X("Beats X", R.drawable.device_beats_x), + BEATS_X( + "Beats X", + R.drawable.device_beats_x, + modelNumbers = setOf("A1763"), + ), @SerialName("beats.powerbeats.3") - POWERBEATS_3("Power Beats 3", R.drawable.device_powerbeats_3), + POWERBEATS_3( + "Power Beats 3", + R.drawable.device_powerbeats_3, + modelNumbers = setOf("A1747"), + ), @SerialName("beats.powerbeats.4") - POWERBEATS_4("Power Beats 4", R.drawable.device_powerbeats_4), + POWERBEATS_4( + "Power Beats 4", + R.drawable.device_powerbeats_4, + modelNumbers = setOf("A2015"), + ), @SerialName("beats.powerbeats.pro") POWERBEATS_PRO( @@ -271,6 +313,7 @@ enum class PodModel( hasCase = true, hasEarDetection = true, ), + modelNumbers = setOf("A2047", "A2048", "A2453", "A2454"), // L/R earbuds, 2019 + 2020 revisions ), @SerialName("beats.powerbeats.pro2") @@ -283,6 +326,7 @@ enum class PodModel( hasEarDetection = true, hasAncControl = true, ), + modelNumbers = setOf("A3157", "A3158", "A3159"), // L/R earbuds + case ), @SerialName("beats.fit.pro") @@ -295,6 +339,7 @@ enum class PodModel( hasEarDetection = true, hasAncControl = true, ), + modelNumbers = setOf("A2576", "A2577", "A2578"), // L/R earbuds + case ), @SerialName("fakes.tws.i99999") @@ -355,6 +400,16 @@ enum class PodModel( UNKNOWN("Unknown"), ; + companion object { + fun fromModelNumber(modelNumber: String): PodModel? { + val normalized = modelNumber.trim().uppercase() + if (normalized.isBlank()) return null + return entries.firstOrNull { entry -> + entry != UNKNOWN && !entry.name.startsWith("FAKE_") && normalized in entry.modelNumbers + } + } + } + data class Features( // Physical form val hasDualPods: Boolean = false, @@ -377,4 +432,4 @@ enum class PodModel( // Protocol val needsInitExt: Boolean = false, ) -} \ No newline at end of file +} diff --git a/app/src/main/java/eu/darken/capod/reaction/core/aap/AapAutoConnect.kt b/app/src/main/java/eu/darken/capod/reaction/core/aap/AapAutoConnect.kt index 4b5102f2..f96e2537 100644 --- a/app/src/main/java/eu/darken/capod/reaction/core/aap/AapAutoConnect.kt +++ b/app/src/main/java/eu/darken/capod/reaction/core/aap/AapAutoConnect.kt @@ -7,13 +7,17 @@ import eu.darken.capod.common.debug.logging.log import eu.darken.capod.common.debug.logging.logTag import eu.darken.capod.common.flow.setupCommonEventHandlers import eu.darken.capod.monitor.core.BlePodMonitor +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.AapPodState +import eu.darken.capod.profiles.core.AppleDeviceProfile import eu.darken.capod.profiles.core.DeviceProfilesRepo import kotlinx.coroutines.delay +import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach @@ -28,10 +32,12 @@ class AapAutoConnect @Inject constructor( private val blePodMonitor: BlePodMonitor, ) { private val activeReconnects = java.util.Collections.synchronizedSet(mutableSetOf()) + private val processedModelCorrections = java.util.Collections.synchronizedSet(mutableSetOf()) fun monitor(): Flow = merge( initialConnect(), reconnectOnDisconnect(), + correctModelOnDeviceInfo(), ) private fun initialConnect(): Flow = combine( @@ -136,6 +142,54 @@ class AapAutoConnect @Inject constructor( .map { } // SharedFlow → Flow .setupCommonEventHandlers(TAG) { "reconnect" } + private fun correctModelOnDeviceInfo(): Flow = aapManager.allStates + .map { states -> states.mapValues { (_, state) -> state.deviceInfo?.modelNumber } } + .distinctUntilChanged() + .map { modelNumbers -> + // Clean up tracking for addresses that disconnected + processedModelCorrections.retainAll(modelNumbers.keys) + + for ((address, modelNumber) in modelNumbers) { + if (modelNumber == null) continue + if (address in processedModelCorrections) continue + processedModelCorrections.add(address) + + try { + val detectedModel = PodModel.fromModelNumber(modelNumber) ?: continue + + val profile = profilesRepo.profiles.first() + .filterIsInstance() + .firstOrNull { it.address == address } + ?: continue + + if (profile.model == detectedModel) { + log(TAG, VERBOSE) { "AAP model confirmed for $address: $detectedModel" } + continue + } + + log(TAG) { "AAP model mismatch for $address: profile=${profile.model}, detected=$detectedModel" } + + // Allow key exchange (AapKeyPersister) to complete before disconnecting + delay(2.seconds) + + aapManager.disconnect(address) + profilesRepo.updateProfile(profile.copy(model = detectedModel)) + log(TAG) { "AAP model corrected for $address: ${profile.model} -> $detectedModel" } + + // Reconnect explicitly — initialConnect may be blocked by retry loops for other devices + val bonded = bluetoothManager.bondedDevices().first() + .firstOrNull { it.address == address } + if (bonded != null) { + aapManager.connect(address, bonded.internal, detectedModel) + log(TAG) { "AAP reconnected $address with corrected model $detectedModel" } + } + } catch (e: Exception) { + log(TAG, WARN) { "AAP model correction failed for $address: ${e.message}" } + } + } + } + .setupCommonEventHandlers(TAG) { "correctModel" } + companion object { private val TAG = logTag("Reaction", "AapAutoConnect") internal val RETRY_DELAYS = longArrayOf(3_000, 3_000, 3_000, 5_000, 5_000, 10_000, 10_000) diff --git a/app/src/test/java/eu/darken/capod/reaction/core/aap/AapAutoConnectTest.kt b/app/src/test/java/eu/darken/capod/reaction/core/aap/AapAutoConnectTest.kt index 7ccb55c0..70e385dc 100644 --- a/app/src/test/java/eu/darken/capod/reaction/core/aap/AapAutoConnectTest.kt +++ b/app/src/test/java/eu/darken/capod/reaction/core/aap/AapAutoConnectTest.kt @@ -6,11 +6,12 @@ import eu.darken.capod.monitor.core.BlePodMonitor import eu.darken.capod.pods.core.apple.ble.BlePodSnapshot 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.AapPodState +import eu.darken.capod.pods.core.apple.aap.protocol.AapDeviceInfo 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 @@ -66,7 +67,7 @@ class AapAutoConnectTest : BaseTest() { every { disconnectEvents } returns disconnectEventsFlow } - profilesRepo = mockk { + profilesRepo = mockk(relaxUnitFun = true) { every { profiles } returns profilesFlow } @@ -366,4 +367,171 @@ class AapAutoConnectTest : BaseTest() { job.cancel() } } + + @Nested + inner class ModelCorrection { + + private val testDeviceInfo = AapDeviceInfo( + name = "AirPods Pro 3", + modelNumber = "A3064", + manufacturer = "Apple Inc.", + serialNumber = "XXXX", + firmwareVersion = "1.0", + ) + + @Test + fun `corrects model when deviceInfo reports different model`() = runTest(testDispatcher) { + val wrongModelProfile = AppleDeviceProfile( + label = "Test AirPods", + model = PodModel.UNKNOWN, + address = testAddress, + ) + profilesFlow.value = listOf(wrongModelProfile) + + var capturedProfile: AppleDeviceProfile? = null + coEvery { profilesRepo.updateProfile(ofType()) } coAnswers { + capturedProfile = firstArg() + } + + val autoConnect = createAutoConnect() + val job = launch { autoConnect.monitor().toList() } + advanceUntilIdle() + + // Simulate AAP connection with device info reporting AirPods Pro 3 + allStatesFlow.value = mapOf( + testAddress to AapPodState( + connectionState = AapPodState.ConnectionState.READY, + deviceInfo = testDeviceInfo, + ) + ) + advanceUntilIdle() + + capturedProfile!!.model shouldBe PodModel.AIRPODS_PRO3 + coVerify(exactly = 1) { aapManager.disconnect(testAddress) } + coVerify { aapManager.connect(testAddress, any(), PodModel.AIRPODS_PRO3) } + + job.cancel() + } + + @Test + fun `does not correct when model already matches`() = runTest(testDispatcher) { + // Profile already has correct model + profilesFlow.value = listOf(testProfile) // PodModel.AIRPODS_PRO3 + + val autoConnect = createAutoConnect() + val job = launch { autoConnect.monitor().toList() } + advanceUntilIdle() + + allStatesFlow.value = mapOf( + testAddress to AapPodState( + connectionState = AapPodState.ConnectionState.READY, + deviceInfo = testDeviceInfo, // A3064 = AIRPODS_PRO3 + ) + ) + advanceUntilIdle() + + coVerify(exactly = 0) { profilesRepo.updateProfile(ofType()) } + coVerify(exactly = 0) { aapManager.disconnect(testAddress) } + + job.cancel() + } + + @Test + fun `does not correct when modelNumber is unrecognized`() = runTest(testDispatcher) { + profilesFlow.value = listOf(testProfile) + + val autoConnect = createAutoConnect() + val job = launch { autoConnect.monitor().toList() } + advanceUntilIdle() + + allStatesFlow.value = mapOf( + testAddress to AapPodState( + connectionState = AapPodState.ConnectionState.READY, + deviceInfo = testDeviceInfo.copy(modelNumber = "ZZZZ"), + ) + ) + advanceUntilIdle() + + coVerify(exactly = 0) { profilesRepo.updateProfile(ofType()) } + + job.cancel() + } + + @Test + fun `correction not re-triggered on subsequent state emissions`() = runTest(testDispatcher) { + val wrongModelProfile = AppleDeviceProfile( + label = "Test AirPods", + model = PodModel.UNKNOWN, + address = testAddress, + ) + profilesFlow.value = listOf(wrongModelProfile) + + val autoConnect = createAutoConnect() + val job = launch { autoConnect.monitor().toList() } + advanceUntilIdle() + + val readyState = AapPodState( + connectionState = AapPodState.ConnectionState.READY, + deviceInfo = testDeviceInfo, + ) + + // First emission with deviceInfo + allStatesFlow.value = mapOf(testAddress to readyState) + advanceUntilIdle() + + // Second emission — same modelNumber but different state (simulates battery/settings churn) + // StateFlow needs a structurally different value to emit; distinctUntilChanged on + // the modelNumber sub-map then suppresses re-processing + allStatesFlow.value = mapOf(testAddress to readyState.copy(lastMessageAt = java.time.Instant.now())) + advanceUntilIdle() + + coVerify(exactly = 1) { profilesRepo.updateProfile(ofType()) } + + job.cancel() + } + + @Test + fun `clears processed state on disconnect for future reconnects`() = runTest(testDispatcher) { + val wrongModelProfile = AppleDeviceProfile( + label = "Test AirPods", + model = PodModel.UNKNOWN, + address = testAddress, + ) + profilesFlow.value = listOf(wrongModelProfile) + + val autoConnect = createAutoConnect() + val job = launch { autoConnect.monitor().toList() } + advanceUntilIdle() + + // First connection with deviceInfo + allStatesFlow.value = mapOf( + testAddress to AapPodState( + connectionState = AapPodState.ConnectionState.READY, + deviceInfo = testDeviceInfo, + ) + ) + advanceUntilIdle() + + coVerify(exactly = 1) { profilesRepo.updateProfile(ofType()) } + + // Simulate disconnect (address disappears) + allStatesFlow.value = emptyMap() + advanceUntilIdle() + + // Second connection — profile is now corrected, so no second updateProfile + profilesFlow.value = listOf(wrongModelProfile.copy(model = PodModel.AIRPODS_PRO3)) + allStatesFlow.value = mapOf( + testAddress to AapPodState( + connectionState = AapPodState.ConnectionState.READY, + deviceInfo = testDeviceInfo, + ) + ) + advanceUntilIdle() + + // Still only 1 updateProfile — model now matches + coVerify(exactly = 1) { profilesRepo.updateProfile(ofType()) } + + job.cancel() + } + } }