feat: add infrared control state machine

This commit is contained in:
2026-08-24 11:17:11 +08:00
parent 0f17b21a59
commit 0004a3bf86
3 changed files with 648 additions and 0 deletions
@@ -0,0 +1,27 @@
package com.flagship.abox.manager.infrared.ui
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode
sealed interface InfraredUiMessage {
data class Info(val text: String) : InfraredUiMessage
data class Error(val text: String) : InfraredUiMessage
data object SessionInvalid : InfraredUiMessage
}
data class InfraredControlUiState(
val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE,
val effectiveProfileId: ProviderProfileId? = null,
val deviceNameInput: String = "",
val savedDeviceName: DeviceName? = null,
val keys: List<SavedInfraredKey> = emptyList(),
val loading: Boolean = true,
val sendingKeyIds: Set<InfraredKeyId> = emptySet(),
val message: InfraredUiMessage? = null,
) {
val hasActiveLearning: Boolean get() = keys.any { it.status == InfraredKeyStatus.LEARNING }
}
@@ -0,0 +1,327 @@
package com.flagship.abox.manager.infrared.ui
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.DeleteInfraredKey
import com.flagship.abox.manager.domain.infrared.EpochMillisClock
import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
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.RecoverInterruptedInfraredLearning
import com.flagship.abox.manager.domain.infrared.RenameInfraredKey
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
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class InfraredControlViewModel @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,
private val renameInfraredKey: RenameInfraredKey,
private val deleteInfraredKey: DeleteInfraredKey,
private val recoverInterruptedInfraredLearning: RecoverInterruptedInfraredLearning,
private val clock: EpochMillisClock,
) : ViewModel() {
private val rawProfileId: String? = savedStateHandle["profileId"]
private val authenticatedProfileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) }
private val deviceNameInputFlow = MutableStateFlow("")
private val sendingKeyIdsFlow = MutableStateFlow<Set<InfraredKeyId>>(emptySet())
private val messageFlow = MutableStateFlow<InfraredUiMessage?>(null)
private val effectiveProfileAndMode: Flow<Pair<ProviderProfileId?, InfraredRuntimeMode>> =
runtimeModeStore.mode.mapModeToProfile()
private fun Flow<InfraredRuntimeMode>.mapModeToProfile(): Flow<Pair<ProviderProfileId?, InfraredRuntimeMode>> {
return flatMapLatest { mode ->
if (authenticatedProfileId == null) {
flowOf(Pair(null, mode))
} else {
val effectiveProfile = profileResolver.resolve(authenticatedProfileId, mode)
flowOf(Pair(effectiveProfile, 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))
}
}
}
}
private data class ServerState(
val profile: ProviderProfileId?,
val mode: InfraredRuntimeMode,
val deviceResult: DomainResult<DeviceName?>,
val keysResult: DomainResult<List<SavedInfraredKey>>,
)
private val serverStateFlow: Flow<ServerState> = combine(
effectiveProfileAndMode,
deviceNameFlow,
keysFlow,
) { profileAndMode, deviceResult, keysResult ->
ServerState(
profile = profileAndMode.first,
mode = profileAndMode.second,
deviceResult = deviceResult,
keysResult = keysResult,
)
}
val state: StateFlow<InfraredControlUiState> = combine(
serverStateFlow,
deviceNameInputFlow,
sendingKeyIdsFlow,
messageFlow,
) { serverState, input, sendingIds, msg ->
val savedDevice = (serverState.deviceResult as? DomainResult.Success<DeviceName?>)?.value
val keys = (serverState.keysResult as? DomainResult.Success<List<SavedInfraredKey>>)?.value ?: emptyList()
val loading = serverState.profile == null ||
serverState.deviceResult !is DomainResult.Success ||
serverState.keysResult !is DomainResult.Success
InfraredControlUiState(
runtimeMode = serverState.mode,
effectiveProfileId = serverState.profile,
deviceNameInput = input,
savedDeviceName = savedDevice,
keys = keys,
loading = loading,
sendingKeyIds = sendingIds,
message = if (authenticatedProfileId == null) {
InfraredUiMessage.Error("Invalid or missing profile ID")
} else {
msg
},
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = InfraredControlUiState(
runtimeMode = InfraredRuntimeMode.FAKE,
effectiveProfileId = null,
loading = true,
),
)
init {
if (authenticatedProfileId != null) {
viewModelScope.launch {
val currentMode = runtimeModeStore.currentMode()
val effectiveProfile = profileResolver.resolve(authenticatedProfileId, currentMode)
recoverInterruptedInfraredLearning(effectiveProfile)
}
}
}
fun onDeviceNameChanged(value: String) {
deviceNameInputFlow.value = value
}
fun saveDeviceName() {
val currentProfile = state.value.effectiveProfileId ?: return
val trimmed = deviceNameInputFlow.value.trim()
if (trimmed.isBlank()) {
messageFlow.value = InfraredUiMessage.Error("Device name cannot be blank")
return
}
viewModelScope.launch {
when (val result = deviceConfigurationRepository.setDeviceName(currentProfile, DeviceName(trimmed))) {
is DomainResult.Success -> {
messageFlow.value = InfraredUiMessage.Info("Device name saved")
deviceNameInputFlow.value = ""
}
is DomainResult.Failure -> {
handleDomainError(result.error)
}
}
}
}
fun learnKey(displayName: String) {
val currentProfile = state.value.effectiveProfileId ?: return
val savedDevice = state.value.savedDeviceName
if (savedDevice == null) {
messageFlow.value = InfraredUiMessage.Error("Please set and save a device name first")
return
}
val trimmedName = displayName.trim()
if (trimmedName.isBlank()) {
messageFlow.value = InfraredUiMessage.Error("Key name cannot be blank")
return
}
learningCoordinator.learn(currentProfile, savedDevice, trimmedName)
}
fun retryLearning(keyId: InfraredKeyId) {
val currentProfile = state.value.effectiveProfileId ?: return
learningCoordinator.retry(InfraredKeyRef(currentProfile, keyId))
}
fun sendKey(keyId: InfraredKeyId) {
val currentProfile = state.value.effectiveProfileId ?: return
if (state.value.sendingKeyIds.contains(keyId)) return
val key = state.value.keys.find { it.id == keyId }
if (key == null || key.status != InfraredKeyStatus.READY) {
messageFlow.value = InfraredUiMessage.Error("Key is not ready to send")
return
}
sendingKeyIdsFlow.value = sendingKeyIdsFlow.value + keyId
viewModelScope.launch {
try {
when (val result = sendInfraredKey(InfraredKeyRef(currentProfile, keyId))) {
is DomainResult.Success -> {
messageFlow.value = InfraredUiMessage.Info("Signal sent successfully")
}
is DomainResult.Failure -> {
handleDomainError(result.error)
}
}
} finally {
sendingKeyIdsFlow.value = sendingKeyIdsFlow.value - keyId
}
}
}
fun renameKey(keyId: InfraredKeyId, displayName: String) {
val currentProfile = state.value.effectiveProfileId ?: return
val trimmed = displayName.trim()
if (trimmed.isBlank()) {
messageFlow.value = InfraredUiMessage.Error("Name cannot be blank")
return
}
viewModelScope.launch {
when (val result = renameInfraredKey(InfraredKeyRef(currentProfile, keyId), trimmed)) {
is DomainResult.Success -> {
messageFlow.value = InfraredUiMessage.Info("Key renamed")
}
is DomainResult.Failure -> {
handleDomainError(result.error)
}
}
}
}
fun deleteKey(keyId: InfraredKeyId) {
val currentProfile = state.value.effectiveProfileId ?: return
viewModelScope.launch {
when (val result = deleteInfraredKey(InfraredKeyRef(currentProfile, keyId))) {
is DomainResult.Success -> {
messageFlow.value = InfraredUiMessage.Info("Key removed locally")
}
is DomainResult.Failure -> {
handleDomainError(result.error)
}
}
}
}
fun setRuntimeMode(mode: InfraredRuntimeMode) {
if (state.value.hasActiveLearning) {
messageFlow.value = InfraredUiMessage.Error("Cannot change mode while learning is active")
return
}
viewModelScope.launch {
when (val result = runtimeModeStore.setMode(mode)) {
is DomainResult.Success -> {
if (authenticatedProfileId != null) {
val newEffectiveProfile = profileResolver.resolve(authenticatedProfileId, mode)
recoverInterruptedInfraredLearning(newEffectiveProfile)
}
}
is DomainResult.Failure -> {
handleDomainError(result.error)
}
}
}
}
fun clearMessage() {
messageFlow.value = null
}
private fun handleDomainError(error: DomainError) {
when (error) {
is DomainError.TokenInvalid, is DomainError.Authentication -> {
messageFlow.value = InfraredUiMessage.SessionInvalid
}
is DomainError.DeviceOffline -> {
messageFlow.value = InfraredUiMessage.Error("Infrared controller is offline (code: ${error.externalCode ?: "20206"})")
}
is DomainError.DeviceNotFound -> {
messageFlow.value = InfraredUiMessage.Error("Device not found (code: ${error.externalCode ?: "20203"})")
}
is DomainError.InvalidRequest -> {
messageFlow.value = InfraredUiMessage.Error(error.detail ?: "Invalid request")
}
is DomainError.OperationFailed -> {
messageFlow.value = InfraredUiMessage.Error(error.detail ?: "Operation failed (code: ${error.externalCode ?: "unknown"})")
}
is DomainError.Network -> {
messageFlow.value = InfraredUiMessage.Error("Network error: ${error.detail ?: "connection failed"}")
}
is DomainError.NoData -> {
messageFlow.value = InfraredUiMessage.Error(error.detail ?: "No data found")
}
is DomainError.Unknown -> {
messageFlow.value = InfraredUiMessage.Error(error.detail ?: "Unknown error")
}
}
}
}
@@ -0,0 +1,294 @@
package com.flagship.abox.manager.infrared.ui
import androidx.lifecycle.SavedStateHandle
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.DeleteInfraredKey
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.InfraredKeyRef
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.LearnInfraredKey
import com.flagship.abox.manager.domain.infrared.RecoverInterruptedInfraredLearning
import com.flagship.abox.manager.domain.infrared.RenameInfraredKey
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.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class FakeRuntimeModeStore : 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 InfraredControlViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
private val baseProfile = ProviderProfileId("abox")
private val fakeProfile = ProviderProfileId("abox:infrared-fake")
private val device = DeviceName("ir-living")
private val clock = EpochMillisClock { 10_000L }
private val keyRepository = FakeInfraredKeyRepository()
private val deviceConfigRepository = FakeInfraredDeviceConfigurationRepository()
private val runtimeModeStore = FakeRuntimeModeStore()
private val profileResolver = InfraredRuntimeProfileResolver()
private val gateway = FakeInfraredGateway()
private val factory = InfraredKeyFactory({ InfraredKeyId("01J5MOLERABC123") }, clock)
private val learnUseCase = LearnInfraredKey(keyRepository, gateway, factory, clock)
private val retryUseCase = RetryInfraredKeyLearning(keyRepository, gateway, clock)
private val sendUseCase = SendInfraredKey(keyRepository, gateway)
private val renameUseCase = RenameInfraredKey(keyRepository, clock)
private val deleteUseCase = DeleteInfraredKey(keyRepository)
private val recoverUseCase = RecoverInterruptedInfraredLearning(keyRepository, clock)
private lateinit var coordinator: InfraredLearningCoordinator
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
coordinator = InfraredLearningCoordinator(learnUseCase, retryUseCase, testScope)
}
@After
fun teardown() {
Dispatchers.resetMain()
}
private fun createViewModel(profileId: String? = "abox"): InfraredControlViewModel {
val handle = SavedStateHandle(if (profileId != null) mapOf("profileId" to profileId) else emptyMap())
return InfraredControlViewModel(
savedStateHandle = handle,
keyRepository = keyRepository,
deviceConfigurationRepository = deviceConfigRepository,
runtimeModeStore = runtimeModeStore,
profileResolver = profileResolver,
learningCoordinator = coordinator,
sendInfraredKey = sendUseCase,
renameInfraredKey = renameUseCase,
deleteInfraredKey = deleteUseCase,
recoverInterruptedInfraredLearning = recoverUseCase,
clock = clock,
)
}
@Test
fun resolvesEffectiveProfileByRuntimeMode() = testScope.runTest {
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
assertEquals(fakeProfile, vm.state.value.effectiveProfileId)
assertEquals(InfraredRuntimeMode.FAKE, vm.state.value.runtimeMode)
vm.setRuntimeMode(InfraredRuntimeMode.REAL_ABSDK)
advanceUntilIdle()
assertEquals(baseProfile, vm.state.value.effectiveProfileId)
assertEquals(InfraredRuntimeMode.REAL_ABSDK, vm.state.value.runtimeMode)
job.cancel()
}
@Test
fun blankDeviceNameReturnsValidationError() = testScope.runTest {
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.onDeviceNameChanged(" ")
vm.saveDeviceName()
advanceUntilIdle()
val msg = vm.state.value.message
assertTrue(msg is InfraredUiMessage.Error)
assertEquals("Device name cannot be blank", (msg as InfraredUiMessage.Error).text)
job.cancel()
}
@Test
fun saveDeviceNamePersistsAndTrims() = testScope.runTest {
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.onDeviceNameChanged(" ir-main ")
vm.saveDeviceName()
advanceUntilIdle()
assertEquals(DeviceName("ir-main"), vm.state.value.savedDeviceName)
assertEquals("", vm.state.value.deviceNameInput)
assertTrue(vm.state.value.message is InfraredUiMessage.Info)
job.cancel()
}
@Test
fun learnWithoutSavedDeviceFails() = testScope.runTest {
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.learnKey("Power")
advanceUntilIdle()
val msg = vm.state.value.message
assertTrue(msg is InfraredUiMessage.Error)
assertEquals("Please set and save a device name first", (msg as InfraredUiMessage.Error).text)
job.cancel()
}
@Test
fun learnWithSavedDeviceSucceeds() = testScope.runTest {
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.onDeviceNameChanged("ir-1")
vm.saveDeviceName()
advanceUntilIdle()
vm.learnKey("Air Conditioner")
advanceUntilIdle()
val keys = vm.state.value.keys
assertEquals(1, keys.size)
assertEquals("Air Conditioner", keys.first().displayName)
assertEquals(InfraredKeyStatus.READY, keys.first().status)
job.cancel()
}
@Test
fun sendReadyKeySendsSignalAndClearsSendingSet() = testScope.runTest {
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
deviceConfigRepository.setDeviceName(fakeProfile, device)
val key = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = fakeProfile,
deviceName = device,
displayName = "Power",
sdkKey = "app_ir_k1",
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 100L,
updatedAtEpochMillis = 100L,
)
keyRepository.insert(key)
advanceUntilIdle()
vm.sendKey(InfraredKeyId("k1"))
advanceUntilIdle()
assertTrue(vm.state.value.sendingKeyIds.isEmpty())
assertTrue(vm.state.value.message is InfraredUiMessage.Info)
assertEquals(1, gateway.sentKeys.size)
job.cancel()
}
@Test
fun sendFailurePreservesReadyStatus() = testScope.runTest {
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
deviceConfigRepository.setDeviceName(fakeProfile, device)
val key = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = fakeProfile,
deviceName = device,
displayName = "Power",
sdkKey = "app_ir_k1",
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 100L,
updatedAtEpochMillis = 100L,
)
keyRepository.insert(key)
gateway.sendResult = DomainResult.Failure(DomainError.DeviceOffline("20206"))
advanceUntilIdle()
vm.sendKey(InfraredKeyId("k1"))
advanceUntilIdle()
val fetched = vm.state.value.keys.single()
assertEquals(InfraredKeyStatus.READY, fetched.status)
assertTrue(vm.state.value.message is InfraredUiMessage.Error)
job.cancel()
}
@Test
fun setModeBlockedWhileLearningIsActive() = testScope.runTest {
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
deviceConfigRepository.setDeviceName(fakeProfile, device)
val key = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = fakeProfile,
deviceName = device,
displayName = "Power",
sdkKey = "app_ir_k1",
status = InfraredKeyStatus.LEARNING,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 100L,
updatedAtEpochMillis = 100L,
)
keyRepository.insert(key)
advanceUntilIdle()
assertTrue(vm.state.value.hasActiveLearning)
vm.setRuntimeMode(InfraredRuntimeMode.REAL_ABSDK)
advanceUntilIdle()
assertEquals(InfraredRuntimeMode.FAKE, vm.state.value.runtimeMode)
assertTrue(vm.state.value.message is InfraredUiMessage.Error)
job.cancel()
}
}