fix(infrared): unify atomic config state snapshot, avoid message race, and rollback cancelled learning status

This commit is contained in:
2026-08-25 12:36:19 +08:00
parent 3561f3dd4a
commit 8cd44c93e1
4 changed files with 117 additions and 56 deletions
@@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -73,33 +74,40 @@ class InfraredRemoteViewModel @Inject constructor(
private var activeLearningJob: Job? = null
private var underlyingLearningDeferred: kotlinx.coroutines.Deferred<*>? = null
private val effectiveProfileAndMode: Flow<Pair<ProviderProfileId?, InfraredRuntimeMode>> =
private data class InfraredRemoteConfigSnapshot(
val effectiveProfileId: ProviderProfileId? = null,
val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE,
val savedDeviceName: DeviceName? = null,
val learnedSlotMap: Map<String, SavedInfraredKey> = emptyMap(),
)
private val configSnapshotFlow: Flow<InfraredRemoteConfigSnapshot> =
runtimeModeStore.mode.flatMapLatest { mode ->
if (authenticatedProfileId == null) {
flowOf(Pair(null, mode))
val profile = authenticatedProfileId?.let { profileResolver.resolve(it, mode) }
if (profile == null) {
flowOf(InfraredRemoteConfigSnapshot(effectiveProfileId = null, runtimeMode = mode))
} else {
flowOf(Pair(profileResolver.resolve(authenticatedProfileId, mode), mode))
}
}
private val deviceNameFlow: Flow<DomainResult<DeviceName?>> =
effectiveProfileAndMode.flatMapLatest { (profile, _) ->
if (profile == null) flowOf(DomainResult.Success(null))
else deviceConfigurationRepository.observeDeviceName(profile)
}
private val keysFlow: Flow<DomainResult<List<SavedInfraredKey>>> =
effectiveProfileAndMode.flatMapLatest { (profile, _) ->
if (profile == null) flowOf(DomainResult.Success(emptyList()))
else {
deviceConfigurationRepository.observeDeviceName(profile).flatMapLatest { deviceResult ->
when (deviceResult) {
is DomainResult.Success -> {
val device = deviceResult.value
if (device == null) flowOf(DomainResult.Success(emptyList()))
else keyRepository.observeKeys(profile, device)
val deviceName = (deviceResult as? DomainResult.Success)?.value
if (deviceName == null) {
flowOf(
InfraredRemoteConfigSnapshot(
effectiveProfileId = profile,
runtimeMode = mode,
savedDeviceName = null,
learnedSlotMap = emptyMap(),
),
)
} else {
keyRepository.observeKeys(profile, deviceName).map { keysResult ->
val keys = (keysResult as? DomainResult.Success<List<SavedInfraredKey>>)?.value ?: emptyList()
InfraredRemoteConfigSnapshot(
effectiveProfileId = profile,
runtimeMode = mode,
savedDeviceName = deviceName,
learnedSlotMap = keys.associateBy { it.sdkKey },
)
}
is DomainResult.Failure -> flowOf(DomainResult.Failure(deviceResult.error))
}
}
}
@@ -108,34 +116,18 @@ class InfraredRemoteViewModel @Inject constructor(
val state: StateFlow<InfraredRemoteUiState> = combine(
sceneFlow,
isLearningModeFlow,
effectiveProfileAndMode,
deviceNameFlow,
keysFlow,
sendingKeySlotFlow,
activeLearningSlotFlow,
messageFlow,
) { args: Array<Any?> ->
val scene = args[0] as RemoteScene
val isLearning = args[1] as Boolean
val profileAndMode = args[2] as Pair<ProviderProfileId?, InfraredRuntimeMode>
val deviceResult = args[3] as DomainResult<DeviceName?>
val keysResult = args[4] as DomainResult<List<SavedInfraredKey>>
val sendingSlot = args[5] as String?
val learningSlot = args[6] as String?
val msg = args[7] as RemoteUiMessage?
val (profile, mode) = profileAndMode
val savedDevice = (deviceResult as? DomainResult.Success<DeviceName?>)?.value
val keys = (keysResult as? DomainResult.Success<List<SavedInfraredKey>>)?.value ?: emptyList()
val slotMap = keys.associateBy { it.sdkKey }
configSnapshotFlow,
combine(sendingKeySlotFlow, activeLearningSlotFlow, messageFlow) { sending, learning, msg ->
Triple(sending, learning, msg)
},
) { scene, isLearning, snapshot, (sendingSlot, learningSlot, msg) ->
InfraredRemoteUiState(
selectedScene = scene,
isLearningMode = isLearning,
runtimeMode = mode,
effectiveProfileId = profile,
savedDeviceName = savedDevice,
learnedSlotMap = slotMap,
runtimeMode = snapshot.runtimeMode,
effectiveProfileId = snapshot.effectiveProfileId,
savedDeviceName = snapshot.savedDeviceName,
learnedSlotMap = snapshot.learnedSlotMap,
sendingKeySlot = sendingSlot,
activeLearningSlot = learningSlot,
message = msg,
@@ -158,6 +150,11 @@ class InfraredRemoteViewModel @Inject constructor(
activeLearningJob?.cancel()
activeLearningJob = null
activeLearningSlotFlow.value = null
state.value.effectiveProfileId?.let { profile ->
viewModelScope.launch(kotlinx.coroutines.NonCancellable) {
keyRepository.failInterruptedLearning(profile, System.currentTimeMillis())
}
}
} else {
messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制")
}
@@ -179,6 +176,7 @@ class InfraredRemoteViewModel @Inject constructor(
if (existingKey?.status == InfraredKeyStatus.LEARNING) {
return
}
messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...")
activeLearningSlotFlow.value = spec.defaultSlot
val deferred = if (existingKey != null) {
learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id))
@@ -223,13 +221,12 @@ class InfraredRemoteViewModel @Inject constructor(
}
}
activeLearningJob = job
messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...")
} else {
if (sendingKeySlotFlow.value != null) {
return
}
val key = state.value.learnedSlotMap[spec.defaultSlot]
if (key == null || key.status != InfraredKeyStatus.READY) {
if (key == null || key.status != InfraredKeyStatus.READY || key.profileId != currentProfile || key.deviceName != savedDevice) {
messageFlow.value = RemoteUiMessage.Error("按键【${spec.displayName}】尚未学习,请开启右上角学习模式进行录制")
return
}
@@ -253,4 +253,21 @@ class InfraredRemoteViewModelTest {
job.cancel()
}
@Test
fun disablingLearningModeCleansUpAndFailsInterruptedLearning() = testScope.runTest {
deviceRepo.setDeviceName(fakeProfile, device)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.toggleLearningMode(true)
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
vm.toggleLearningMode(false)
advanceUntilIdle()
assertEquals(null, vm.state.value.activeLearningSlot)
job.cancel()
}
}