mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-16 19:26:12 -04:00
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.
This commit is contained in:
@@ -10,6 +10,7 @@ enum class PodModel(
|
|||||||
val label: String,
|
val label: String,
|
||||||
@DrawableRes val iconRes: Int = R.drawable.device_earbuds_generic_both,
|
@DrawableRes val iconRes: Int = R.drawable.device_earbuds_generic_both,
|
||||||
val features: Features = Features(),
|
val features: Features = Features(),
|
||||||
|
val modelNumbers: Set<String> = emptySet(),
|
||||||
) {
|
) {
|
||||||
@SerialName("airpods.gen1")
|
@SerialName("airpods.gen1")
|
||||||
AIRPODS_GEN1(
|
AIRPODS_GEN1(
|
||||||
@@ -19,6 +20,7 @@ enum class PodModel(
|
|||||||
hasDualPods = true,
|
hasDualPods = true,
|
||||||
hasCase = true,
|
hasCase = true,
|
||||||
),
|
),
|
||||||
|
modelNumbers = setOf("A1523", "A1722"), // L/R earphones
|
||||||
),
|
),
|
||||||
|
|
||||||
@SerialName("airpods.gen2")
|
@SerialName("airpods.gen2")
|
||||||
@@ -29,6 +31,7 @@ enum class PodModel(
|
|||||||
hasDualPods = true,
|
hasDualPods = true,
|
||||||
hasCase = true,
|
hasCase = true,
|
||||||
),
|
),
|
||||||
|
modelNumbers = setOf("A2031", "A2032"), // L/R earphones
|
||||||
),
|
),
|
||||||
|
|
||||||
@SerialName("airpods.gen3")
|
@SerialName("airpods.gen3")
|
||||||
@@ -39,6 +42,7 @@ enum class PodModel(
|
|||||||
hasDualPods = true,
|
hasDualPods = true,
|
||||||
hasCase = true,
|
hasCase = true,
|
||||||
),
|
),
|
||||||
|
modelNumbers = setOf("A2564", "A2565"), // L/R earphones
|
||||||
),
|
),
|
||||||
|
|
||||||
@SerialName("airpods.gen4")
|
@SerialName("airpods.gen4")
|
||||||
@@ -49,6 +53,7 @@ enum class PodModel(
|
|||||||
hasDualPods = true,
|
hasDualPods = true,
|
||||||
hasCase = true,
|
hasCase = true,
|
||||||
),
|
),
|
||||||
|
modelNumbers = setOf("A3050", "A3053", "A3054"), // earphones
|
||||||
),
|
),
|
||||||
|
|
||||||
@SerialName("airpods.gen4.anc")
|
@SerialName("airpods.gen4.anc")
|
||||||
@@ -73,6 +78,7 @@ enum class PodModel(
|
|||||||
hasAdaptiveAudioNoise = true,
|
hasAdaptiveAudioNoise = true,
|
||||||
needsInitExt = true,
|
needsInitExt = true,
|
||||||
),
|
),
|
||||||
|
modelNumbers = setOf("A3055", "A3056", "A3057"), // earphones
|
||||||
),
|
),
|
||||||
|
|
||||||
@SerialName("airpods.pro")
|
@SerialName("airpods.pro")
|
||||||
@@ -91,6 +97,7 @@ enum class PodModel(
|
|||||||
hasVolumeSwipeLength = true,
|
hasVolumeSwipeLength = true,
|
||||||
hasToneVolume = true,
|
hasToneVolume = true,
|
||||||
),
|
),
|
||||||
|
modelNumbers = setOf("A2083", "A2084"), // L/R earphones
|
||||||
),
|
),
|
||||||
|
|
||||||
@SerialName("airpods.pro2")
|
@SerialName("airpods.pro2")
|
||||||
@@ -115,6 +122,7 @@ enum class PodModel(
|
|||||||
hasAdaptiveAudioNoise = true,
|
hasAdaptiveAudioNoise = true,
|
||||||
needsInitExt = true,
|
needsInitExt = true,
|
||||||
),
|
),
|
||||||
|
modelNumbers = setOf("A2698", "A2699", "A2931"), // earphones
|
||||||
),
|
),
|
||||||
|
|
||||||
@SerialName("airpods.pro2.usbc")
|
@SerialName("airpods.pro2.usbc")
|
||||||
@@ -139,6 +147,7 @@ enum class PodModel(
|
|||||||
hasAdaptiveAudioNoise = true,
|
hasAdaptiveAudioNoise = true,
|
||||||
needsInitExt = true,
|
needsInitExt = true,
|
||||||
),
|
),
|
||||||
|
modelNumbers = setOf("A3047", "A3048", "A3049"), // earphones
|
||||||
),
|
),
|
||||||
|
|
||||||
@SerialName("airpods.pro3")
|
@SerialName("airpods.pro3")
|
||||||
@@ -163,6 +172,7 @@ enum class PodModel(
|
|||||||
hasAdaptiveAudioNoise = true,
|
hasAdaptiveAudioNoise = true,
|
||||||
needsInitExt = true,
|
needsInitExt = true,
|
||||||
),
|
),
|
||||||
|
modelNumbers = setOf("A3063", "A3064", "A3065"), // earphones
|
||||||
),
|
),
|
||||||
|
|
||||||
@SerialName("airpods.max")
|
@SerialName("airpods.max")
|
||||||
@@ -175,6 +185,7 @@ enum class PodModel(
|
|||||||
hasPressHoldDuration = true,
|
hasPressHoldDuration = true,
|
||||||
hasToneVolume = true,
|
hasToneVolume = true,
|
||||||
),
|
),
|
||||||
|
modelNumbers = setOf("A2096"), // headphones
|
||||||
),
|
),
|
||||||
|
|
||||||
@SerialName("airpods.max.usbc")
|
@SerialName("airpods.max.usbc")
|
||||||
@@ -187,23 +198,37 @@ enum class PodModel(
|
|||||||
hasPressHoldDuration = true,
|
hasPressHoldDuration = true,
|
||||||
hasToneVolume = true,
|
hasToneVolume = true,
|
||||||
),
|
),
|
||||||
|
modelNumbers = setOf("A3184"), // headphones
|
||||||
),
|
),
|
||||||
|
|
||||||
@SerialName("beats.flex")
|
@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")
|
@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")
|
@SerialName("beats.solo.pro")
|
||||||
BEATS_SOLO_PRO(
|
BEATS_SOLO_PRO(
|
||||||
"Beats Solo Pro",
|
"Beats Solo Pro",
|
||||||
R.drawable.device_beats_headphones,
|
R.drawable.device_beats_headphones,
|
||||||
Features(hasAncControl = true),
|
Features(hasAncControl = true),
|
||||||
|
modelNumbers = setOf("A1881"), // headphones
|
||||||
),
|
),
|
||||||
|
|
||||||
@SerialName("beats.solo.4")
|
@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")
|
@SerialName("beats.solo.buds")
|
||||||
BEATS_SOLO_BUDS(
|
BEATS_SOLO_BUDS(
|
||||||
@@ -213,6 +238,7 @@ enum class PodModel(
|
|||||||
hasDualPods = true,
|
hasDualPods = true,
|
||||||
hasCase = true,
|
hasCase = true,
|
||||||
),
|
),
|
||||||
|
modelNumbers = setOf("A3150", "A3151", "A3153"), // L/R earbuds + case
|
||||||
),
|
),
|
||||||
|
|
||||||
@SerialName("beats.studio.3")
|
@SerialName("beats.studio.3")
|
||||||
@@ -220,6 +246,7 @@ enum class PodModel(
|
|||||||
"Beats Studio 3",
|
"Beats Studio 3",
|
||||||
R.drawable.device_beats_studio3,
|
R.drawable.device_beats_studio3,
|
||||||
Features(hasAncControl = true),
|
Features(hasAncControl = true),
|
||||||
|
modelNumbers = setOf("A1914"), // headphones
|
||||||
),
|
),
|
||||||
|
|
||||||
@SerialName("beats.studio.buds")
|
@SerialName("beats.studio.buds")
|
||||||
@@ -232,6 +259,7 @@ enum class PodModel(
|
|||||||
hasEarDetection = true,
|
hasEarDetection = true,
|
||||||
hasAncControl = true,
|
hasAncControl = true,
|
||||||
),
|
),
|
||||||
|
modelNumbers = setOf("A2512", "A2513", "A2514"), // L/R earbuds + case
|
||||||
),
|
),
|
||||||
|
|
||||||
@SerialName("beats.studio.buds.plus")
|
@SerialName("beats.studio.buds.plus")
|
||||||
@@ -244,6 +272,7 @@ enum class PodModel(
|
|||||||
hasEarDetection = true,
|
hasEarDetection = true,
|
||||||
hasAncControl = true,
|
hasAncControl = true,
|
||||||
),
|
),
|
||||||
|
modelNumbers = setOf("A2871", "A2872", "A2952"), // L/R earbuds + case
|
||||||
),
|
),
|
||||||
|
|
||||||
@SerialName("beats.studio.pro")
|
@SerialName("beats.studio.pro")
|
||||||
@@ -251,16 +280,29 @@ enum class PodModel(
|
|||||||
"Beats Studio Pro",
|
"Beats Studio Pro",
|
||||||
R.drawable.device_beats_headphones,
|
R.drawable.device_beats_headphones,
|
||||||
Features(hasAncControl = true),
|
Features(hasAncControl = true),
|
||||||
|
modelNumbers = setOf("A2924"), // headphones
|
||||||
),
|
),
|
||||||
|
|
||||||
@SerialName("beats.x")
|
@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")
|
@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")
|
@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")
|
@SerialName("beats.powerbeats.pro")
|
||||||
POWERBEATS_PRO(
|
POWERBEATS_PRO(
|
||||||
@@ -271,6 +313,7 @@ enum class PodModel(
|
|||||||
hasCase = true,
|
hasCase = true,
|
||||||
hasEarDetection = true,
|
hasEarDetection = true,
|
||||||
),
|
),
|
||||||
|
modelNumbers = setOf("A2047", "A2048", "A2453", "A2454"), // L/R earbuds, 2019 + 2020 revisions
|
||||||
),
|
),
|
||||||
|
|
||||||
@SerialName("beats.powerbeats.pro2")
|
@SerialName("beats.powerbeats.pro2")
|
||||||
@@ -283,6 +326,7 @@ enum class PodModel(
|
|||||||
hasEarDetection = true,
|
hasEarDetection = true,
|
||||||
hasAncControl = true,
|
hasAncControl = true,
|
||||||
),
|
),
|
||||||
|
modelNumbers = setOf("A3157", "A3158", "A3159"), // L/R earbuds + case
|
||||||
),
|
),
|
||||||
|
|
||||||
@SerialName("beats.fit.pro")
|
@SerialName("beats.fit.pro")
|
||||||
@@ -295,6 +339,7 @@ enum class PodModel(
|
|||||||
hasEarDetection = true,
|
hasEarDetection = true,
|
||||||
hasAncControl = true,
|
hasAncControl = true,
|
||||||
),
|
),
|
||||||
|
modelNumbers = setOf("A2576", "A2577", "A2578"), // L/R earbuds + case
|
||||||
),
|
),
|
||||||
|
|
||||||
@SerialName("fakes.tws.i99999")
|
@SerialName("fakes.tws.i99999")
|
||||||
@@ -355,6 +400,16 @@ enum class PodModel(
|
|||||||
UNKNOWN("Unknown"),
|
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(
|
data class Features(
|
||||||
// Physical form
|
// Physical form
|
||||||
val hasDualPods: Boolean = false,
|
val hasDualPods: Boolean = false,
|
||||||
@@ -377,4 +432,4 @@ enum class PodModel(
|
|||||||
// Protocol
|
// Protocol
|
||||||
val needsInitExt: Boolean = false,
|
val needsInitExt: Boolean = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.debug.logging.logTag
|
||||||
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
import eu.darken.capod.common.flow.setupCommonEventHandlers
|
||||||
import eu.darken.capod.monitor.core.BlePodMonitor
|
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.AapConnectionManager
|
||||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
||||||
|
import eu.darken.capod.profiles.core.AppleDeviceProfile
|
||||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.merge
|
import kotlinx.coroutines.flow.merge
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
@@ -28,10 +32,12 @@ class AapAutoConnect @Inject constructor(
|
|||||||
private val blePodMonitor: BlePodMonitor,
|
private val blePodMonitor: BlePodMonitor,
|
||||||
) {
|
) {
|
||||||
private val activeReconnects = java.util.Collections.synchronizedSet(mutableSetOf<String>())
|
private val activeReconnects = java.util.Collections.synchronizedSet(mutableSetOf<String>())
|
||||||
|
private val processedModelCorrections = java.util.Collections.synchronizedSet(mutableSetOf<String>())
|
||||||
|
|
||||||
fun monitor(): Flow<Unit> = merge(
|
fun monitor(): Flow<Unit> = merge(
|
||||||
initialConnect(),
|
initialConnect(),
|
||||||
reconnectOnDisconnect(),
|
reconnectOnDisconnect(),
|
||||||
|
correctModelOnDeviceInfo(),
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun initialConnect(): Flow<Unit> = combine(
|
private fun initialConnect(): Flow<Unit> = combine(
|
||||||
@@ -136,6 +142,54 @@ class AapAutoConnect @Inject constructor(
|
|||||||
.map { } // SharedFlow<BluetoothAddress> → Flow<Unit>
|
.map { } // SharedFlow<BluetoothAddress> → Flow<Unit>
|
||||||
.setupCommonEventHandlers(TAG) { "reconnect" }
|
.setupCommonEventHandlers(TAG) { "reconnect" }
|
||||||
|
|
||||||
|
private fun correctModelOnDeviceInfo(): Flow<Unit> = 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<AppleDeviceProfile>()
|
||||||
|
.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 {
|
companion object {
|
||||||
private val TAG = logTag("Reaction", "AapAutoConnect")
|
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)
|
internal val RETRY_DELAYS = longArrayOf(3_000, 3_000, 3_000, 5_000, 5_000, 10_000, 10_000)
|
||||||
|
|||||||
@@ -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.ble.BlePodSnapshot
|
||||||
import eu.darken.capod.pods.core.apple.PodModel
|
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.AapConnectionManager
|
||||||
|
|
||||||
import eu.darken.capod.pods.core.apple.aap.AapPodState
|
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.AppleDeviceProfile
|
||||||
import eu.darken.capod.profiles.core.DeviceProfile
|
import eu.darken.capod.profiles.core.DeviceProfile
|
||||||
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
import eu.darken.capod.profiles.core.DeviceProfilesRepo
|
||||||
|
import io.kotest.matchers.shouldBe
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.coVerify
|
import io.mockk.coVerify
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
@@ -66,7 +67,7 @@ class AapAutoConnectTest : BaseTest() {
|
|||||||
every { disconnectEvents } returns disconnectEventsFlow
|
every { disconnectEvents } returns disconnectEventsFlow
|
||||||
}
|
}
|
||||||
|
|
||||||
profilesRepo = mockk {
|
profilesRepo = mockk(relaxUnitFun = true) {
|
||||||
every { profiles } returns profilesFlow
|
every { profiles } returns profilesFlow
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,4 +367,171 @@ class AapAutoConnectTest : BaseTest() {
|
|||||||
job.cancel()
|
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<AppleDeviceProfile>()) } 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<AppleDeviceProfile>()) }
|
||||||
|
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<AppleDeviceProfile>()) }
|
||||||
|
|
||||||
|
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<AppleDeviceProfile>()) }
|
||||||
|
|
||||||
|
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<AppleDeviceProfile>()) }
|
||||||
|
|
||||||
|
// 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<AppleDeviceProfile>()) }
|
||||||
|
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user