feat: implement infrared remote control viewmodel

This commit is contained in:
2026-08-24 15:16:16 +08:00
parent 6e7bd7910a
commit 56917e3c8e
2 changed files with 353 additions and 0 deletions
@@ -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<String, SavedInfraredKey> = 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<String?>(null)
private val activeLearningSlotFlow = MutableStateFlow<String?>(null)
private val messageFlow = MutableStateFlow<RemoteUiMessage?>(null)
private val effectiveProfileAndMode: Flow<Pair<ProviderProfileId?, InfraredRuntimeMode>> =
runtimeModeStore.mode.flatMapLatest { mode ->
if (authenticatedProfileId == null) {
flowOf(Pair(null, 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)
}
is DomainResult.Failure -> flowOf(DomainResult.Failure(deviceResult.error))
}
}
}
}
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 }
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 ?: "操作失败")
}
}
}
}
@@ -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<InfraredRuntimeMode> = modeFlow
override suspend fun currentMode(): InfraredRuntimeMode = modeFlow.value
override suspend fun setMode(mode: InfraredRuntimeMode): DomainResult<Unit> {
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()
}
}