diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt index 4001ee4..ddb83a5 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt @@ -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> = + private data class InfraredRemoteConfigSnapshot( + val effectiveProfileId: ProviderProfileId? = null, + val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE, + val savedDeviceName: DeviceName? = null, + val learnedSlotMap: Map = emptyMap(), + ) + + private val configSnapshotFlow: Flow = 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> = - effectiveProfileAndMode.flatMapLatest { (profile, _) -> - if (profile == null) flowOf(DomainResult.Success(null)) - else deviceConfigurationRepository.observeDeviceName(profile) - } - - private val keysFlow: Flow>> = - 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>)?.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 = combine( sceneFlow, isLearningModeFlow, - effectiveProfileAndMode, - deviceNameFlow, - keysFlow, - sendingKeySlotFlow, - activeLearningSlotFlow, - messageFlow, - ) { args: Array -> - val scene = args[0] as RemoteScene - val isLearning = args[1] as Boolean - val profileAndMode = args[2] as Pair - val deviceResult = args[3] as DomainResult - val keysResult = args[4] as DomainResult> - 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)?.value - val keys = (keysResult as? DomainResult.Success>)?.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 } diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt index fcbedff..d6a7b42 100644 --- a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt @@ -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() + } } diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt index 394557d..97b69c8 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt @@ -5,6 +5,9 @@ import com.flagship.abox.manager.domain.DomainError import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.InfraredGateway import com.flagship.abox.manager.domain.ProviderProfileId +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext class LearnInfraredKey( private val repository: InfraredKeyRepository, @@ -28,13 +31,27 @@ class LearnInfraredKey( is DomainResult.Failure -> return result } - return when ( - val result = gateway.learnKey( + val result = try { + gateway.learnKey( profileId = key.profileId, deviceName = key.deviceName, key = key.sdkKey, ) - ) { + } catch (e: CancellationException) { + withContext(NonCancellable) { + repository.transitionStatus( + ref = key.ref(), + expectedStatuses = setOf(InfraredKeyStatus.LEARNING), + targetStatus = InfraredKeyStatus.FAILED, + lastErrorCode = null, + lastErrorDetail = "Cancelled", + updatedAtEpochMillis = clock.now(), + ) + } + throw e + } + + return when (result) { is DomainResult.Success -> when ( val transition = repository.transitionStatus( ref = key.ref(), @@ -101,13 +118,27 @@ class RetryInfraredKeyLearning( is DomainResult.Failure -> return result } - return when ( - val result = gateway.learnKey( + val result = try { + gateway.learnKey( profileId = key.profileId, deviceName = key.deviceName, key = key.sdkKey, ) - ) { + } catch (e: CancellationException) { + withContext(NonCancellable) { + repository.transitionStatus( + ref = ref, + expectedStatuses = setOf(InfraredKeyStatus.LEARNING), + targetStatus = InfraredKeyStatus.FAILED, + lastErrorCode = null, + lastErrorDetail = "Cancelled", + updatedAtEpochMillis = clock.now(), + ) + } + throw e + } + + return when (result) { is DomainResult.Success -> repository.transitionStatus( ref = ref, expectedStatuses = setOf(InfraredKeyStatus.LEARNING), diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKeyTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKeyTest.kt index 0f4e3d4..c62e620 100644 --- a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKeyTest.kt +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKeyTest.kt @@ -269,6 +269,22 @@ class LearnInfraredKeyTest { assertEquals(1, gateway.learnedKeys.size) } + @Test + fun learningCancellationTransitionsStatusToFailed() = runTest { + gateway.onLearn = { _, _, _ -> + throw kotlinx.coroutines.CancellationException("User cancelled") + } + + try { + learn(profile, device, "Power") + } catch (_: kotlinx.coroutines.CancellationException) { + // expected + } + + assertEquals(InfraredKeyStatus.FAILED, repository.onlyKey().status) + assertEquals("Cancelled", repository.onlyKey().lastErrorDetail) + } + private fun failedKey( id: InfraredKeyId = keyId, sdkKey: String = "app_ir_key1",