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 a8d6725..e203719 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 @@ -75,8 +75,16 @@ class InfraredRemoteViewModel @Inject constructor( private var underlyingLearningDeferred: kotlinx.coroutines.Deferred<*>? = null init { - authenticatedProfileId?.let { profile -> - learningCoordinator.failInterruptedLearning(profile) + if (authenticatedProfileId != null) { + viewModelScope.launch { + runtimeModeStore.mode.collect { mode -> + val effective = profileResolver.resolve(authenticatedProfileId, mode) + learningCoordinator.failInterruptedLearning(effective) + if (effective != authenticatedProfileId) { + learningCoordinator.failInterruptedLearning(authenticatedProfileId) + } + } + } } } @@ -85,6 +93,7 @@ class InfraredRemoteViewModel @Inject constructor( val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE, val savedDeviceName: DeviceName? = null, val learnedSlotMap: Map = emptyMap(), + val configError: DomainError? = null, ) private val configSnapshotFlow: Flow = @@ -94,25 +103,43 @@ class InfraredRemoteViewModel @Inject constructor( flowOf(InfraredRemoteConfigSnapshot(effectiveProfileId = null, runtimeMode = mode)) } else { deviceConfigurationRepository.observeDeviceName(profile).flatMapLatest { deviceResult -> - val deviceName = (deviceResult as? DomainResult.Success)?.value - if (deviceName == null) { - flowOf( + when (deviceResult) { + is DomainResult.Failure -> flowOf( InfraredRemoteConfigSnapshot( effectiveProfileId = profile, runtimeMode = mode, - savedDeviceName = null, - learnedSlotMap = emptyMap(), + configError = deviceResult.error, ), ) - } 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.Success -> { + val deviceName = deviceResult.value + if (deviceName == null) { + flowOf( + InfraredRemoteConfigSnapshot( + effectiveProfileId = profile, + runtimeMode = mode, + savedDeviceName = null, + learnedSlotMap = emptyMap(), + ), + ) + } else { + keyRepository.observeKeys(profile, deviceName).map { keysResult -> + when (keysResult) { + is DomainResult.Failure -> InfraredRemoteConfigSnapshot( + effectiveProfileId = profile, + runtimeMode = mode, + savedDeviceName = deviceName, + configError = keysResult.error, + ) + is DomainResult.Success -> InfraredRemoteConfigSnapshot( + effectiveProfileId = profile, + runtimeMode = mode, + savedDeviceName = deviceName, + learnedSlotMap = keysResult.value.associateBy { it.sdkKey }, + ) + } + } + } } } } @@ -127,6 +154,13 @@ class InfraredRemoteViewModel @Inject constructor( Triple(sending, learning, msg) }, ) { scene, isLearning, snapshot, (sendingSlot, learningSlot, msg) -> + val resolvedMessage = msg ?: snapshot.configError?.let { error -> + when (error) { + is DomainError.TokenInvalid, is DomainError.Authentication -> RemoteUiMessage.SessionInvalid + is DomainError.DeviceOffline -> RemoteUiMessage.Error("红外控制器离线 (错误码: ${error.externalCode ?: "20206"})") + else -> RemoteUiMessage.Error(error.detail ?: "数据加载失败") + } + } InfraredRemoteUiState( selectedScene = scene, isLearningMode = isLearning, @@ -136,7 +170,7 @@ class InfraredRemoteViewModel @Inject constructor( learnedSlotMap = snapshot.learnedSlotMap, sendingKeySlot = sendingSlot, activeLearningSlot = learningSlot, - message = msg, + message = resolvedMessage, ) }.stateIn( scope = viewModelScope, @@ -177,15 +211,12 @@ class InfraredRemoteViewModel @Inject constructor( return } val existingKey = state.value.learnedSlotMap[spec.defaultSlot] - if (existingKey?.status == InfraredKeyStatus.LEARNING) { - return - } messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...") activeLearningSlotFlow.value = spec.defaultSlot val deferred = when (existingKey?.status) { InfraredKeyStatus.FAILED -> learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id)) - InfraredKeyStatus.READY -> learningCoordinator.relearn(InfraredKeyRef(currentProfile, existingKey.id)) - else -> learningCoordinator.learn( + InfraredKeyStatus.READY, InfraredKeyStatus.LEARNING -> learningCoordinator.relearn(InfraredKeyRef(currentProfile, existingKey.id)) + null -> learningCoordinator.learn( profileId = currentProfile, deviceName = savedDevice, displayName = spec.displayName, 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 7c362d1..0700476 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 @@ -305,4 +305,51 @@ class InfraredRemoteViewModelTest { job.cancel() } + + @Test + fun relearningKeyInLearningStatusSucceeds() = testScope.runTest { + deviceRepo.setDeviceName(fakeProfile, device) + val learningKey = SavedInfraredKey( + id = InfraredKeyId("k1"), + profileId = fakeProfile, + deviceName = device, + displayName = "空调-电源", + sdkKey = "1", + status = InfraredKeyStatus.LEARNING, + lastErrorCode = null, + lastErrorDetail = null, + createdAtEpochMillis = 100L, + updatedAtEpochMillis = 100L, + ) + keyRepo.insert(learningKey) + + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.toggleLearningMode(true) + vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER) + advanceUntilIdle() + + assertEquals(1, gateway.learnedKeys.size) + assertEquals("1", gateway.learnedKeys.first().key) + val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(fakeProfile, InfraredKeyId("k1"))) as DomainResult.Success).value!! + assertEquals(InfraredKeyStatus.READY, updated.status) + assertEquals(RemoteUiMessage.Info("【空调-电源】录制成功"), vm.state.value.message) + + job.cancel() + } + + @Test + fun configSnapshotStorageErrorPropagatesToUiStateMessage() = testScope.runTest { + deviceRepo.observeError = com.flagship.abox.manager.domain.DomainError.Unknown(detail = "Disk failure") + + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + assertEquals(RemoteUiMessage.Error("Disk failure"), vm.state.value.message) + + 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 a23fbac..43919ad 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 @@ -176,11 +176,6 @@ class RelearnInfraredKey( is DomainResult.Failure -> return result } - if (key.status == InfraredKeyStatus.LEARNING) { - return DomainResult.Failure( - DomainError.InvalidRequest(detail = "Infrared key is already learning"), - ) - } when ( val result = repository.transitionStatus( @@ -188,6 +183,7 @@ class RelearnInfraredKey( expectedStatuses = setOf( InfraredKeyStatus.READY, InfraredKeyStatus.FAILED, + InfraredKeyStatus.LEARNING, ), targetStatus = InfraredKeyStatus.LEARNING, lastErrorCode = null, 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 ce1d104..0c727df 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 @@ -159,7 +159,7 @@ class LearnInfraredKeyTest { assertEquals(0, repository.insertedKeys.size) assertEquals(2, repository.transitionCalls.size) assertEquals( - setOf(InfraredKeyStatus.READY, InfraredKeyStatus.FAILED), + setOf(InfraredKeyStatus.READY, InfraredKeyStatus.FAILED, InfraredKeyStatus.LEARNING), repository.transitionCalls.first().expectedStatuses, ) assertEquals( diff --git a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepository.kt b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepository.kt index f08509c..6219a58 100644 --- a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepository.kt +++ b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepository.kt @@ -134,10 +134,12 @@ class FakeInfraredKeyRepository : InfraredKeyRepository { class FakeInfraredDeviceConfigurationRepository : InfraredDeviceConfigurationRepository { private val lock = Any() private val deviceNames = MutableStateFlow>(emptyMap()) + var observeError: DomainError? = null override fun observeDeviceName( profileId: ProviderProfileId, ): Flow> = deviceNames.map { values -> + observeError?.let { return@map DomainResult.Failure(it) } DomainResult.Success(values[profileId]) }