From 56917e3c8ed6fa0c13e17445c6cd4f36e767a26a Mon Sep 17 00:00:00 2001 From: mxhsnd <2049295946@qq.com> Date: Mon, 24 Aug 2026 15:16:16 +0800 Subject: [PATCH] feat: implement infrared remote control viewmodel --- .../remote/InfraredRemoteViewModel.kt | 214 ++++++++++++++++++ .../remote/InfraredRemoteViewModelTest.kt | 139 ++++++++++++ 2 files changed, 353 insertions(+) create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt create mode 100644 android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt 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 new file mode 100644 index 0000000..b50f7e4 --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt @@ -0,0 +1,214 @@ +package com.flagship.abox.manager.infrared.remote + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DomainError +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository +import com.flagship.abox.manager.domain.infrared.InfraredKeyRef +import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository +import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus +import com.flagship.abox.manager.domain.infrared.SavedInfraredKey +import com.flagship.abox.manager.domain.infrared.SendInfraredKey +import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +sealed interface RemoteUiMessage { + data class Info(val text: String) : RemoteUiMessage + data class Error(val text: String) : RemoteUiMessage + data object SessionInvalid : RemoteUiMessage +} + +data class InfraredRemoteUiState( + val selectedScene: RemoteScene = RemoteScene.AIR_CONDITIONER, + val isLearningMode: Boolean = false, + val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE, + val effectiveProfileId: ProviderProfileId? = null, + val savedDeviceName: DeviceName? = null, + val learnedSlotMap: Map = emptyMap(), + val sendingKeySlot: String? = null, + val activeLearningSlot: String? = null, + val message: RemoteUiMessage? = null, +) + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class InfraredRemoteViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val keyRepository: InfraredKeyRepository, + private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository, + private val runtimeModeStore: InfraredRuntimeModeStore, + private val profileResolver: InfraredRuntimeProfileResolver, + private val learningCoordinator: InfraredLearningCoordinator, + private val sendInfraredKey: SendInfraredKey, +) : ViewModel() { + + private val rawProfileId: String? = savedStateHandle["profileId"] + private val authenticatedProfileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) } + + private val sceneFlow = MutableStateFlow(RemoteScene.AIR_CONDITIONER) + private val isLearningModeFlow = MutableStateFlow(false) + private val sendingKeySlotFlow = MutableStateFlow(null) + private val activeLearningSlotFlow = MutableStateFlow(null) + private val messageFlow = MutableStateFlow(null) + + private val effectiveProfileAndMode: Flow> = + runtimeModeStore.mode.flatMapLatest { mode -> + if (authenticatedProfileId == null) { + flowOf(Pair(null, 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) + } + is DomainResult.Failure -> flowOf(DomainResult.Failure(deviceResult.error)) + } + } + } + } + + 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 } + + InfraredRemoteUiState( + selectedScene = scene, + isLearningMode = isLearning, + runtimeMode = mode, + effectiveProfileId = profile, + savedDeviceName = savedDevice, + learnedSlotMap = slotMap, + sendingKeySlot = sendingSlot, + activeLearningSlot = learningSlot, + message = msg, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = InfraredRemoteUiState(), + ) + + fun switchScene(scene: RemoteScene) { + sceneFlow.value = scene + } + + fun toggleLearningMode(enabled: Boolean) { + isLearningModeFlow.value = enabled + if (enabled) { + messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制") + } + } + + fun onButtonClick(spec: RemoteButtonSpec) { + val currentProfile = state.value.effectiveProfileId ?: return + val savedDevice = state.value.savedDeviceName + if (savedDevice == null) { + messageFlow.value = RemoteUiMessage.Error("未配置红外设备名,请点击右上角设置配置") + return + } + + if (isLearningModeFlow.value) { + activeLearningSlotFlow.value = spec.defaultSlot + learningCoordinator.learn( + profileId = currentProfile, + deviceName = savedDevice, + displayName = spec.displayName, + sdkKey = spec.defaultSlot, + ) + messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...") + } else { + val key = state.value.learnedSlotMap[spec.defaultSlot] + if (key == null || key.status != InfraredKeyStatus.READY) { + messageFlow.value = RemoteUiMessage.Error("按键【${spec.displayName}】尚未学习,请开启右上角学习模式进行录制") + return + } + sendingKeySlotFlow.value = spec.defaultSlot + viewModelScope.launch { + try { + when (val result = sendInfraredKey(InfraredKeyRef(currentProfile, key.id))) { + is DomainResult.Success -> { + messageFlow.value = RemoteUiMessage.Info("已发射: ${spec.displayName}") + } + is DomainResult.Failure -> { + handleDomainError(result.error) + } + } + } finally { + sendingKeySlotFlow.value = null + } + } + } + } + + fun clearMessage() { + messageFlow.value = null + } + + private fun handleDomainError(error: DomainError) { + when (error) { + is DomainError.TokenInvalid, is DomainError.Authentication -> { + messageFlow.value = RemoteUiMessage.SessionInvalid + } + is DomainError.DeviceOffline -> { + messageFlow.value = RemoteUiMessage.Error("红外控制器离线 (错误码: ${error.externalCode ?: "20206"})") + } + else -> { + messageFlow.value = RemoteUiMessage.Error(error.detail ?: "操作失败") + } + } + } +} 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 new file mode 100644 index 0000000..6bca3ff --- /dev/null +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt @@ -0,0 +1,139 @@ +package com.flagship.abox.manager.infrared.remote + +import androidx.lifecycle.SavedStateHandle +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.infrared.EpochMillisClock +import com.flagship.abox.manager.domain.infrared.InfraredKeyFactory +import com.flagship.abox.manager.domain.infrared.InfraredKeyId +import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus +import com.flagship.abox.manager.domain.infrared.LearnInfraredKey +import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning +import com.flagship.abox.manager.domain.infrared.SavedInfraredKey +import com.flagship.abox.manager.domain.infrared.SendInfraredKey +import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver +import com.flagship.abox.manager.testing.fakes.FakeInfraredDeviceConfigurationRepository +import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway +import com.flagship.abox.manager.testing.fakes.FakeInfraredKeyRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +private class TestModeStore : InfraredRuntimeModeStore { + private val modeFlow = MutableStateFlow(InfraredRuntimeMode.FAKE) + override val mode: Flow = modeFlow + override suspend fun currentMode(): InfraredRuntimeMode = modeFlow.value + override suspend fun setMode(mode: InfraredRuntimeMode): DomainResult { + modeFlow.value = mode + return DomainResult.Success(Unit) + } +} + +@OptIn(ExperimentalCoroutinesApi::class) +class InfraredRemoteViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + + private val profile = ProviderProfileId("abox") + private val fakeProfile = ProviderProfileId("abox:infrared-fake") + private val device = DeviceName("ir-living") + private val clock = EpochMillisClock { 10_000L } + + private val keyRepo = FakeInfraredKeyRepository() + private val deviceRepo = FakeInfraredDeviceConfigurationRepository() + private val modeStore = TestModeStore() + private val resolver = InfraredRuntimeProfileResolver() + private val gateway = FakeInfraredGateway() + private val factory = InfraredKeyFactory({ InfraredKeyId("01J5MOLERABC123") }, clock) + + private val learn = LearnInfraredKey(keyRepo, gateway, factory, clock) + private val retry = RetryInfraredKeyLearning(keyRepo, gateway, clock) + private val send = SendInfraredKey(keyRepo, gateway) + private lateinit var coordinator: InfraredLearningCoordinator + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + coordinator = InfraredLearningCoordinator(learn, retry, testScope) + } + + @After + fun teardown() { + Dispatchers.resetMain() + } + + private fun createViewModel(): InfraredRemoteViewModel { + return InfraredRemoteViewModel( + savedStateHandle = SavedStateHandle(mapOf("profileId" to "abox")), + keyRepository = keyRepo, + deviceConfigurationRepository = deviceRepo, + runtimeModeStore = modeStore, + profileResolver = resolver, + learningCoordinator = coordinator, + sendInfraredKey = send, + ) + } + + @Test + fun controlModeSendsSignalWhenKeyIsReady() = testScope.runTest { + deviceRepo.setDeviceName(fakeProfile, device) + val readyKey = SavedInfraredKey( + id = InfraredKeyId("k1"), + profileId = fakeProfile, + deviceName = device, + displayName = "空调-电源", + sdkKey = "1", + status = InfraredKeyStatus.READY, + lastErrorCode = null, + lastErrorDetail = null, + createdAtEpochMillis = 100L, + updatedAtEpochMillis = 100L, + ) + keyRepo.insert(readyKey) + + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER) + advanceUntilIdle() + + assertEquals(1, gateway.sentKeys.size) + assertEquals("1", gateway.sentKeys.first().key) + + job.cancel() + } + + @Test + fun learningModeTriggersLearnOnButtonClick() = testScope.runTest { + deviceRepo.setDeviceName(fakeProfile, device) + 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) + + job.cancel() + } +}