refactor(infrared): unify infrared remote with app provider architecture and complete localization

This commit is contained in:
2026-08-25 13:25:04 +08:00
parent 2185e73f5e
commit 4aa9116ac4
23 changed files with 296 additions and 1580 deletions
@@ -22,7 +22,6 @@ import com.flagship.abox.manager.designsystem.ABoxTheme
import com.flagship.abox.manager.domain.ProviderProfile import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.Username import com.flagship.abox.manager.domain.Username
import com.flagship.abox.manager.infrared.remote.ui.InfraredRemoteRoute import com.flagship.abox.manager.infrared.remote.ui.InfraredRemoteRoute
import com.flagship.abox.manager.infrared.ui.InfraredControlRoute
import com.flagship.abox.manager.ui.devicecontrollist.DeviceListScreen import com.flagship.abox.manager.ui.devicecontrollist.DeviceListScreen
import com.flagship.abox.manager.ui.login.LoginRoute import com.flagship.abox.manager.ui.login.LoginRoute
import com.flagship.abox.manager.ui.login.LoginViewModel import com.flagship.abox.manager.ui.login.LoginViewModel
@@ -115,22 +114,7 @@ private fun AuthenticatedNavHost(
navArgument("profileId") { type = NavType.StringType }, navArgument("profileId") { type = NavType.StringType },
), ),
) { ) {
val encodedProfileId = android.net.Uri.encode(profile.id.value)
InfraredRemoteRoute( InfraredRemoteRoute(
onBack = { navController.popBackStack() },
onOpenSettings = {
navController.navigate("infrared_settings/$encodedProfileId")
},
onSessionInvalid = onLogout,
)
}
composable(
route = "infrared_settings/{profileId}",
arguments = listOf(
navArgument("profileId") { type = NavType.StringType },
),
) {
InfraredControlRoute(
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
onSessionInvalid = onLogout, onSessionInvalid = onLogout,
) )
@@ -47,7 +47,6 @@ dependencies {
implementation(libs.room.ktx) implementation(libs.room.ktx)
kapt(libs.room.compiler) kapt(libs.room.compiler)
implementation(libs.datastore.preferences)
implementation(libs.androidx.lifecycle.viewmodel.ktx) implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
@@ -30,7 +30,6 @@ class InfraredRemoteScreenTest {
InfraredRemoteScreen( InfraredRemoteScreen(
state = state, state = state,
onBack = {}, onBack = {},
onOpenSettings = {},
onSessionInvalid = {}, onSessionInvalid = {},
onSceneSelected = {}, onSceneSelected = {},
onToggleLearningMode = {}, onToggleLearningMode = {},
@@ -1,121 +0,0 @@
package com.flagship.abox.manager.infrared.ui
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
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
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class InfraredControlScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun displaysModeAndDeviceInformation() {
val state = InfraredControlUiState(
runtimeMode = InfraredRuntimeMode.FAKE,
effectiveProfileId = ProviderProfileId("abox:infrared-fake"),
savedDeviceName = DeviceName("ir-living"),
loading = false,
)
composeTestRule.setContent {
InfraredControlScreen(
state = state,
nextSlot = 1,
onBack = {},
onSessionInvalid = {},
onDeviceNameChanged = {},
onSaveDeviceName = {},
onLearn = { _, _ -> },
onRetry = {},
onSend = {},
onRename = { _, _ -> },
onDelete = {},
onModeChanged = {},
onMessageShown = {},
)
}
composeTestRule.onNodeWithTag("infrared_mode_label").assertIsDisplayed()
composeTestRule.onNodeWithTag("infrared_device_name_input").assertIsDisplayed()
composeTestRule.onNodeWithTag("infrared_save_device").assertIsDisplayed()
composeTestRule.onNodeWithTag("infrared_learn_new_key").assertIsDisplayed()
}
@Test
fun rendersKeysWithCorrectActionButtons() {
val readyKey = SavedInfraredKey(
id = InfraredKeyId("k-ready"),
profileId = ProviderProfileId("abox"),
deviceName = DeviceName("ir-1"),
displayName = "Power Key",
sdkKey = "1",
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 100L,
updatedAtEpochMillis = 100L,
)
val failedKey = SavedInfraredKey(
id = InfraredKeyId("k-failed"),
profileId = ProviderProfileId("abox"),
deviceName = DeviceName("ir-1"),
displayName = "Volume Up",
sdkKey = "2",
status = InfraredKeyStatus.FAILED,
lastErrorCode = "20206",
lastErrorDetail = null,
createdAtEpochMillis = 200L,
updatedAtEpochMillis = 200L,
)
val state = InfraredControlUiState(
runtimeMode = InfraredRuntimeMode.REAL_ABSDK,
savedDeviceName = DeviceName("ir-1"),
keys = listOf(readyKey, failedKey),
loading = false,
)
var sentKeyId: InfraredKeyId? = null
var retriedKeyId: InfraredKeyId? = null
composeTestRule.setContent {
InfraredControlScreen(
state = state,
nextSlot = 3,
onBack = {},
onSessionInvalid = {},
onDeviceNameChanged = {},
onSaveDeviceName = {},
onLearn = { _, _ -> },
onRetry = { retriedKeyId = it },
onSend = { sentKeyId = it },
onRename = { _, _ -> },
onDelete = {},
onModeChanged = {},
onMessageShown = {},
)
}
composeTestRule.onNodeWithTag("infrared_key_k-ready").assertIsDisplayed()
composeTestRule.onNodeWithTag("infrared_send_k-ready").performClick()
assertEquals(InfraredKeyId("k-ready"), sentKeyId)
composeTestRule.onNodeWithTag("infrared_key_k-failed").assertIsDisplayed()
composeTestRule.onNodeWithTag("infrared_retry_k-failed").performClick()
assertEquals(InfraredKeyId("k-failed"), retriedKeyId)
}
}
@@ -11,9 +11,12 @@ import com.flagship.abox.manager.domain.infrared.RelearnInfraredKey
import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class InfraredLearningCoordinator( class InfraredLearningCoordinator(
private val learnInfraredKey: LearnInfraredKey, private val learnInfraredKey: LearnInfraredKey,
@@ -22,20 +25,32 @@ class InfraredLearningCoordinator(
private val keyRepository: InfraredKeyRepository, private val keyRepository: InfraredKeyRepository,
private val externalScope: CoroutineScope, private val externalScope: CoroutineScope,
) { ) {
private val mutex = Mutex()
private var cleanupJob: Job? = null
fun learn( fun learn(
profileId: ProviderProfileId, profileId: ProviderProfileId,
deviceName: DeviceName, deviceName: DeviceName,
displayName: String, displayName: String,
sdkKey: String? = null, sdkKey: String? = null,
): Deferred<DomainResult<InfraredKeyId>> = externalScope.async { ): Deferred<DomainResult<InfraredKeyId>> = externalScope.async {
mutex.withLock {
cleanupJob?.join()
}
learnInfraredKey(profileId, deviceName, displayName, sdkKey) learnInfraredKey(profileId, deviceName, displayName, sdkKey)
} }
fun retry(ref: InfraredKeyRef): Deferred<DomainResult<Unit>> = externalScope.async { fun retry(ref: InfraredKeyRef): Deferred<DomainResult<Unit>> = externalScope.async {
mutex.withLock {
cleanupJob?.join()
}
retryInfraredKeyLearning(ref) retryInfraredKeyLearning(ref)
} }
fun relearn(ref: InfraredKeyRef): Deferred<DomainResult<Unit>> = externalScope.async { fun relearn(ref: InfraredKeyRef): Deferred<DomainResult<Unit>> = externalScope.async {
mutex.withLock {
cleanupJob?.join()
}
relearnInfraredKey(ref) relearnInfraredKey(ref)
} }
@@ -43,13 +58,15 @@ class InfraredLearningCoordinator(
profileId: ProviderProfileId, profileId: ProviderProfileId,
cutoffEpochMillis: Long = System.currentTimeMillis(), cutoffEpochMillis: Long = System.currentTimeMillis(),
updatedAtEpochMillis: Long = cutoffEpochMillis, updatedAtEpochMillis: Long = cutoffEpochMillis,
) { ): Job {
externalScope.launch(NonCancellable) { val job = externalScope.launch(NonCancellable) {
keyRepository.failInterruptedLearning( keyRepository.failInterruptedLearning(
profileId = profileId, profileId = profileId,
cutoffEpochMillis = cutoffEpochMillis, cutoffEpochMillis = cutoffEpochMillis,
updatedAtEpochMillis = updatedAtEpochMillis, updatedAtEpochMillis = updatedAtEpochMillis,
) )
} }
cleanupJob = job
return job
} }
} }
@@ -1,10 +1,6 @@
package com.flagship.abox.manager.infrared.di package com.flagship.abox.manager.infrared.di
import android.content.Context import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile
import androidx.room.Room import androidx.room.Room
import com.flagship.abox.manager.domain.InfraredGateway import com.flagship.abox.manager.domain.InfraredGateway
import com.flagship.abox.manager.domain.infrared.EpochMillisClock import com.flagship.abox.manager.domain.infrared.EpochMillisClock
@@ -23,9 +19,6 @@ import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinato
import com.flagship.abox.manager.infrared.data.local.InfraredDao import com.flagship.abox.manager.infrared.data.local.InfraredDao
import com.flagship.abox.manager.infrared.data.local.InfraredDatabase import com.flagship.abox.manager.infrared.data.local.InfraredDatabase
import com.flagship.abox.manager.infrared.data.local.RoomInfraredKeyRepository import com.flagship.abox.manager.infrared.data.local.RoomInfraredKeyRepository
import com.flagship.abox.manager.infrared.runtime.DataStoreInfraredRuntimeModeStore
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@@ -42,10 +35,6 @@ import javax.inject.Singleton
@Retention(AnnotationRetention.BINARY) @Retention(AnnotationRetention.BINARY)
annotation class InfraredApplicationScope annotation class InfraredApplicationScope
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class InfraredDataStore
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
object InfraredFeatureModule { object InfraredFeatureModule {
@@ -83,28 +72,6 @@ object InfraredFeatureModule {
repository: RoomInfraredKeyRepository, repository: RoomInfraredKeyRepository,
): InfraredDeviceConfigurationRepository = repository ): InfraredDeviceConfigurationRepository = repository
@Provides
@Singleton
@InfraredDataStore
fun provideInfraredPreferencesDataStore(
@ApplicationContext context: Context,
): DataStore<Preferences> {
return PreferenceDataStoreFactory.create(
produceFile = { context.preferencesDataStoreFile("infrared_preferences") },
)
}
@Provides
@Singleton
fun provideInfraredRuntimeModeStore(
@InfraredDataStore dataStore: DataStore<Preferences>,
): InfraredRuntimeModeStore = DataStoreInfraredRuntimeModeStore(dataStore)
@Provides
@Singleton
fun provideInfraredRuntimeProfileResolver(): InfraredRuntimeProfileResolver =
InfraredRuntimeProfileResolver()
@Provides @Provides
fun provideInfraredKeyIdGenerator(): InfraredKeyIdGenerator = fun provideInfraredKeyIdGenerator(): InfraredKeyIdGenerator =
InfraredKeyIdGenerator { InfraredKeyId(UUID.randomUUID().toString()) } InfraredKeyIdGenerator { InfraredKeyId(UUID.randomUUID().toString()) }
@@ -14,10 +14,8 @@ import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import com.flagship.abox.manager.domain.infrared.SendInfraredKey import com.flagship.abox.manager.domain.infrared.SendInfraredKey
import com.flagship.abox.manager.infrared.R
import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator 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 dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -34,16 +32,15 @@ import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
sealed interface RemoteUiMessage { sealed interface RemoteUiMessage {
data class Info(val text: String) : RemoteUiMessage data class Info(val text: UiText) : RemoteUiMessage
data class Error(val text: String) : RemoteUiMessage data class Error(val text: UiText) : RemoteUiMessage
data object SessionInvalid : RemoteUiMessage data object SessionInvalid : RemoteUiMessage
} }
data class InfraredRemoteUiState( data class InfraredRemoteUiState(
val selectedScene: RemoteScene = RemoteScene.AIR_CONDITIONER, val selectedScene: RemoteScene = RemoteScene.AIR_CONDITIONER,
val isLearningMode: Boolean = false, val isLearningMode: Boolean = false,
val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE, val profileId: ProviderProfileId? = null,
val effectiveProfileId: ProviderProfileId? = null,
val savedDeviceName: DeviceName? = null, val savedDeviceName: DeviceName? = null,
val learnedSlotMap: Map<String, SavedInfraredKey> = emptyMap(), val learnedSlotMap: Map<String, SavedInfraredKey> = emptyMap(),
val sendingKeySlot: String? = null, val sendingKeySlot: String? = null,
@@ -57,14 +54,12 @@ class InfraredRemoteViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val keyRepository: InfraredKeyRepository, private val keyRepository: InfraredKeyRepository,
private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository, private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository,
private val runtimeModeStore: InfraredRuntimeModeStore,
private val profileResolver: InfraredRuntimeProfileResolver,
private val learningCoordinator: InfraredLearningCoordinator, private val learningCoordinator: InfraredLearningCoordinator,
private val sendInfraredKey: SendInfraredKey, private val sendInfraredKey: SendInfraredKey,
) : ViewModel() { ) : ViewModel() {
private val rawProfileId: String? = savedStateHandle["profileId"] private val rawProfileId: String? = savedStateHandle["profileId"]
private val authenticatedProfileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) } val profileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) }
private val sceneFlow = MutableStateFlow(RemoteScene.AIR_CONDITIONER) private val sceneFlow = MutableStateFlow(RemoteScene.AIR_CONDITIONER)
private val isLearningModeFlow = MutableStateFlow(false) private val isLearningModeFlow = MutableStateFlow(false)
@@ -75,66 +70,36 @@ class InfraredRemoteViewModel @Inject constructor(
private var underlyingLearningDeferred: kotlinx.coroutines.Deferred<*>? = null private var underlyingLearningDeferred: kotlinx.coroutines.Deferred<*>? = null
init { init {
if (authenticatedProfileId != null) { if (profileId != null) {
viewModelScope.launch {
val startupCutoff = System.currentTimeMillis() val startupCutoff = System.currentTimeMillis()
runtimeModeStore.mode.collect { mode -> learningCoordinator.failInterruptedLearning(profileId, cutoffEpochMillis = startupCutoff)
val effective = profileResolver.resolve(authenticatedProfileId, mode)
learningCoordinator.failInterruptedLearning(effective, cutoffEpochMillis = startupCutoff)
if (effective != authenticatedProfileId) {
learningCoordinator.failInterruptedLearning(authenticatedProfileId, cutoffEpochMillis = startupCutoff)
}
}
}
} }
} }
private data class InfraredRemoteConfigSnapshot( private data class InfraredRemoteConfigSnapshot(
val effectiveProfileId: ProviderProfileId? = null, val savedDeviceName: DeviceName = DEFAULT_DEVICE_NAME,
val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE,
val savedDeviceName: DeviceName? = null,
val learnedSlotMap: Map<String, SavedInfraredKey> = emptyMap(), val learnedSlotMap: Map<String, SavedInfraredKey> = emptyMap(),
val configError: DomainError? = null, val configError: DomainError? = null,
) )
private val configSnapshotFlow: Flow<InfraredRemoteConfigSnapshot> = private val configSnapshotFlow: Flow<InfraredRemoteConfigSnapshot> =
runtimeModeStore.mode.flatMapLatest { mode -> if (profileId == null) {
val profile = authenticatedProfileId?.let { profileResolver.resolve(it, mode) } flowOf(InfraredRemoteConfigSnapshot())
if (profile == null) {
flowOf(InfraredRemoteConfigSnapshot(effectiveProfileId = null, runtimeMode = mode))
} else { } else {
deviceConfigurationRepository.observeDeviceName(profile).flatMapLatest { deviceResult -> deviceConfigurationRepository.observeDeviceName(profileId).flatMapLatest { deviceResult ->
when (deviceResult) { when (deviceResult) {
is DomainResult.Failure -> flowOf( is DomainResult.Failure -> flowOf(
InfraredRemoteConfigSnapshot( InfraredRemoteConfigSnapshot(configError = deviceResult.error),
effectiveProfileId = profile,
runtimeMode = mode,
configError = deviceResult.error,
),
) )
is DomainResult.Success -> { is DomainResult.Success -> {
val deviceName = deviceResult.value val deviceName = deviceResult.value ?: DEFAULT_DEVICE_NAME
if (deviceName == null) { keyRepository.observeKeys(profileId, deviceName).map { keysResult ->
flowOf(
InfraredRemoteConfigSnapshot(
effectiveProfileId = profile,
runtimeMode = mode,
savedDeviceName = null,
learnedSlotMap = emptyMap(),
),
)
} else {
keyRepository.observeKeys(profile, deviceName).map { keysResult ->
when (keysResult) { when (keysResult) {
is DomainResult.Failure -> InfraredRemoteConfigSnapshot( is DomainResult.Failure -> InfraredRemoteConfigSnapshot(
effectiveProfileId = profile,
runtimeMode = mode,
savedDeviceName = deviceName, savedDeviceName = deviceName,
configError = keysResult.error, configError = keysResult.error,
) )
is DomainResult.Success -> InfraredRemoteConfigSnapshot( is DomainResult.Success -> InfraredRemoteConfigSnapshot(
effectiveProfileId = profile,
runtimeMode = mode,
savedDeviceName = deviceName, savedDeviceName = deviceName,
learnedSlotMap = keysResult.value.associateBy { it.sdkKey }, learnedSlotMap = keysResult.value.associateBy { it.sdkKey },
) )
@@ -144,8 +109,6 @@ class InfraredRemoteViewModel @Inject constructor(
} }
} }
} }
}
}
val state: StateFlow<InfraredRemoteUiState> = combine( val state: StateFlow<InfraredRemoteUiState> = combine(
sceneFlow, sceneFlow,
@@ -158,15 +121,19 @@ class InfraredRemoteViewModel @Inject constructor(
val resolvedMessage = msg ?: snapshot.configError?.let { error -> val resolvedMessage = msg ?: snapshot.configError?.let { error ->
when (error) { when (error) {
is DomainError.TokenInvalid, is DomainError.Authentication -> RemoteUiMessage.SessionInvalid is DomainError.TokenInvalid, is DomainError.Authentication -> RemoteUiMessage.SessionInvalid
is DomainError.DeviceOffline -> RemoteUiMessage.Error("红外控制器离线 (错误码: ${error.externalCode ?: "20206"})") is DomainError.DeviceOffline -> RemoteUiMessage.Error(
else -> RemoteUiMessage.Error(error.detail ?: "数据加载失败") UiText.StringResource(R.string.infrared_msg_device_offline, listOf(error.externalCode ?: "20206")),
)
else -> RemoteUiMessage.Error(
error.detail?.let { UiText.DynamicString(it) }
?: UiText.StringResource(R.string.infrared_msg_load_failed),
)
} }
} }
InfraredRemoteUiState( InfraredRemoteUiState(
selectedScene = scene, selectedScene = scene,
isLearningMode = isLearning, isLearningMode = isLearning,
runtimeMode = snapshot.runtimeMode, profileId = profileId,
effectiveProfileId = snapshot.effectiveProfileId,
savedDeviceName = snapshot.savedDeviceName, savedDeviceName = snapshot.savedDeviceName,
learnedSlotMap = snapshot.learnedSlotMap, learnedSlotMap = snapshot.learnedSlotMap,
sendingKeySlot = sendingSlot, sendingKeySlot = sendingSlot,
@@ -176,7 +143,7 @@ class InfraredRemoteViewModel @Inject constructor(
}.stateIn( }.stateIn(
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000), started = SharingStarted.WhileSubscribed(5_000),
initialValue = InfraredRemoteUiState(), initialValue = InfraredRemoteUiState(profileId = profileId),
) )
fun switchScene(scene: RemoteScene) { fun switchScene(scene: RemoteScene) {
@@ -192,35 +159,38 @@ class InfraredRemoteViewModel @Inject constructor(
activeLearningJob?.cancel() activeLearningJob?.cancel()
activeLearningJob = null activeLearningJob = null
activeLearningSlotFlow.value = null activeLearningSlotFlow.value = null
state.value.effectiveProfileId?.let { profile -> profileId?.let { profile ->
learningCoordinator.failInterruptedLearning(profile, cutoffEpochMillis = shutdownCutoff) learningCoordinator.failInterruptedLearning(profile, cutoffEpochMillis = shutdownCutoff)
} }
} else { } else {
messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制") messageFlow.value = RemoteUiMessage.Info(
UiText.StringResource(R.string.infrared_msg_learning_mode_enabled),
)
} }
} }
fun onButtonClick(spec: RemoteButtonSpec) { fun onButtonClick(spec: RemoteButtonSpec) {
val currentProfile = state.value.effectiveProfileId ?: return val currentProfile = profileId ?: return
val savedDevice = state.value.savedDeviceName val currentDevice = state.value.savedDeviceName ?: DEFAULT_DEVICE_NAME
if (savedDevice == null) {
messageFlow.value = RemoteUiMessage.Error("未配置红外设备名,请点击右上角设置配置")
return
}
if (isLearningModeFlow.value) { if (isLearningModeFlow.value) {
if (underlyingLearningDeferred?.isActive == true || activeLearningJob?.isActive == true || activeLearningSlotFlow.value != null) { if (underlyingLearningDeferred?.isActive == true || activeLearningJob?.isActive == true || activeLearningSlotFlow.value != null) {
return return
} }
val existingKey = state.value.learnedSlotMap[spec.defaultSlot] val existingKey = state.value.learnedSlotMap[spec.defaultSlot]
messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...") messageFlow.value = RemoteUiMessage.Info(
UiText.StringResource(
R.string.infrared_msg_recording_key,
listOf(spec.displayName, spec.defaultSlot),
),
)
activeLearningSlotFlow.value = spec.defaultSlot activeLearningSlotFlow.value = spec.defaultSlot
val deferred = when (existingKey?.status) { val deferred = when (existingKey?.status) {
InfraredKeyStatus.FAILED -> learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id)) InfraredKeyStatus.FAILED -> learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id))
InfraredKeyStatus.READY, InfraredKeyStatus.LEARNING -> learningCoordinator.relearn(InfraredKeyRef(currentProfile, existingKey.id)) InfraredKeyStatus.READY, InfraredKeyStatus.LEARNING -> learningCoordinator.relearn(InfraredKeyRef(currentProfile, existingKey.id))
null -> learningCoordinator.learn( null -> learningCoordinator.learn(
profileId = currentProfile, profileId = currentProfile,
deviceName = savedDevice, deviceName = currentDevice,
displayName = spec.displayName, displayName = spec.displayName,
sdkKey = spec.defaultSlot, sdkKey = spec.defaultSlot,
) )
@@ -240,7 +210,12 @@ class InfraredRemoteViewModel @Inject constructor(
} }
when (result) { when (result) {
is DomainResult.Success -> { is DomainResult.Success -> {
messageFlow.value = RemoteUiMessage.Info("【${spec.displayName}】录制成功") messageFlow.value = RemoteUiMessage.Info(
UiText.StringResource(
R.string.infrared_msg_learned_success,
listOf(spec.displayName),
),
)
} }
is DomainResult.Failure -> { is DomainResult.Failure -> {
handleDomainError(result.error) handleDomainError(result.error)
@@ -263,8 +238,13 @@ class InfraredRemoteViewModel @Inject constructor(
return return
} }
val key = state.value.learnedSlotMap[spec.defaultSlot] val key = state.value.learnedSlotMap[spec.defaultSlot]
if (key == null || key.status != InfraredKeyStatus.READY || key.profileId != currentProfile || key.deviceName != savedDevice) { if (key == null || key.status != InfraredKeyStatus.READY || key.profileId != currentProfile || key.deviceName != currentDevice) {
messageFlow.value = RemoteUiMessage.Error("按键【${spec.displayName}】尚未学习,请开启右上角学习模式进行录制") messageFlow.value = RemoteUiMessage.Error(
UiText.StringResource(
R.string.infrared_msg_key_not_learned,
listOf(spec.displayName),
),
)
return return
} }
sendingKeySlotFlow.value = spec.defaultSlot sendingKeySlotFlow.value = spec.defaultSlot
@@ -272,7 +252,12 @@ class InfraredRemoteViewModel @Inject constructor(
try { try {
when (val result = sendInfraredKey(InfraredKeyRef(currentProfile, key.id))) { when (val result = sendInfraredKey(InfraredKeyRef(currentProfile, key.id))) {
is DomainResult.Success -> { is DomainResult.Success -> {
messageFlow.value = RemoteUiMessage.Info("已发射: ${spec.displayName}") messageFlow.value = RemoteUiMessage.Info(
UiText.StringResource(
R.string.infrared_msg_signal_sent,
listOf(spec.displayName),
),
)
} }
is DomainResult.Failure -> { is DomainResult.Failure -> {
handleDomainError(result.error) handleDomainError(result.error)
@@ -292,7 +277,7 @@ class InfraredRemoteViewModel @Inject constructor(
underlyingLearningDeferred = null underlyingLearningDeferred = null
activeLearningJob?.cancel() activeLearningJob?.cancel()
activeLearningJob = null activeLearningJob = null
state.value.effectiveProfileId?.let { profile -> profileId?.let { profile ->
learningCoordinator.failInterruptedLearning(profile, cutoffEpochMillis = teardownCutoff) learningCoordinator.failInterruptedLearning(profile, cutoffEpochMillis = teardownCutoff)
} }
} }
@@ -307,11 +292,23 @@ class InfraredRemoteViewModel @Inject constructor(
messageFlow.value = RemoteUiMessage.SessionInvalid messageFlow.value = RemoteUiMessage.SessionInvalid
} }
is DomainError.DeviceOffline -> { is DomainError.DeviceOffline -> {
messageFlow.value = RemoteUiMessage.Error("红外控制器离线 (错误码: ${error.externalCode ?: "20206"})") messageFlow.value = RemoteUiMessage.Error(
UiText.StringResource(
R.string.infrared_msg_device_offline,
listOf(error.externalCode ?: "20206"),
),
)
} }
else -> { else -> {
messageFlow.value = RemoteUiMessage.Error(error.detail ?: "操作失败") messageFlow.value = RemoteUiMessage.Error(
error.detail?.let { UiText.DynamicString(it) }
?: UiText.StringResource(R.string.infrared_msg_operation_failed),
)
} }
} }
} }
companion object {
val DEFAULT_DEVICE_NAME = DeviceName("ir-1")
}
} }
@@ -1,5 +1,16 @@
package com.flagship.abox.manager.infrared.remote package com.flagship.abox.manager.infrared.remote
import androidx.annotation.StringRes
import com.flagship.abox.manager.infrared.R
sealed interface UiText {
data class DynamicString(val value: String) : UiText
data class StringResource(
@StringRes val resId: Int,
val args: List<Any> = emptyList(),
) : UiText
}
enum class RemoteScene { enum class RemoteScene {
AIR_CONDITIONER, AIR_CONDITIONER,
TELEVISION, TELEVISION,
@@ -8,38 +19,39 @@ enum class RemoteScene {
data class RemoteButtonSpec( data class RemoteButtonSpec(
val id: String, val id: String,
val defaultSlot: String, val defaultSlot: String,
@StringRes val nameRes: Int,
val displayName: String, val displayName: String,
val symbol: String, val symbol: String,
) )
object RemoteSceneConfig { object RemoteSceneConfig {
object AirConditioner { object AirConditioner {
val POWER = RemoteButtonSpec("AC_POWER", "1", "空调-电源", "⏻") val POWER = RemoteButtonSpec("AC_POWER", "1", R.string.infrared_key_ac_power, "空调-电源", "⏻")
val TEMP_UP = RemoteButtonSpec("AC_TEMP_UP", "2", "空调-温度+", "▲") val TEMP_UP = RemoteButtonSpec("AC_TEMP_UP", "2", R.string.infrared_key_ac_temp_up, "空调-温度+", "▲")
val TEMP_DOWN = RemoteButtonSpec("AC_TEMP_DOWN", "3", "空调-温度-", "▼") val TEMP_DOWN = RemoteButtonSpec("AC_TEMP_DOWN", "3", R.string.infrared_key_ac_temp_down, "空调-温度-", "▼")
val MODE = RemoteButtonSpec("AC_MODE", "4", "空调-模式", "模式 ❄/☀") val MODE = RemoteButtonSpec("AC_MODE", "4", R.string.infrared_key_ac_mode, "空调-模式", "模式 ❄/☀")
val FAN = RemoteButtonSpec("AC_FAN", "5", "空调-风速", "风速 💨") val FAN = RemoteButtonSpec("AC_FAN", "5", R.string.infrared_key_ac_fan, "空调-风速", "风速 💨")
val SWING = RemoteButtonSpec("AC_SWING", "6", "空调-扫风", "扫风 ↔") val SWING = RemoteButtonSpec("AC_SWING", "6", R.string.infrared_key_ac_swing, "空调-扫风", "扫风 ↔")
val TIMER = RemoteButtonSpec("AC_TIMER", "7", "空调-定时", "定时 ⏱") val TIMER = RemoteButtonSpec("AC_TIMER", "7", R.string.infrared_key_ac_timer, "空调-定时", "定时 ⏱")
val buttons = listOf(POWER, TEMP_UP, TEMP_DOWN, MODE, FAN, SWING, TIMER) val buttons = listOf(POWER, TEMP_UP, TEMP_DOWN, MODE, FAN, SWING, TIMER)
} }
object Television { object Television {
val POWER = RemoteButtonSpec("TV_POWER", "21", "电视-电源", "⏻") val POWER = RemoteButtonSpec("TV_POWER", "21", R.string.infrared_key_tv_power, "电视-电源", "⏻")
val MUTE = RemoteButtonSpec("TV_MUTE", "22", "电视-静音", "🔇") val MUTE = RemoteButtonSpec("TV_MUTE", "22", R.string.infrared_key_tv_mute, "电视-静音", "🔇")
val NAV_UP = RemoteButtonSpec("TV_NAV_UP", "23", "电视-上", "▲") val NAV_UP = RemoteButtonSpec("TV_NAV_UP", "23", R.string.infrared_key_tv_nav_up, "电视-上", "▲")
val NAV_DOWN = RemoteButtonSpec("TV_NAV_DOWN", "24", "电视-下", "▼") val NAV_DOWN = RemoteButtonSpec("TV_NAV_DOWN", "24", R.string.infrared_key_tv_nav_down, "电视-下", "▼")
val NAV_LEFT = RemoteButtonSpec("TV_NAV_LEFT", "25", "电视-左", "◀") val NAV_LEFT = RemoteButtonSpec("TV_NAV_LEFT", "25", R.string.infrared_key_tv_nav_left, "电视-左", "◀")
val NAV_RIGHT = RemoteButtonSpec("TV_NAV_RIGHT", "26", "电视-右", "▶") val NAV_RIGHT = RemoteButtonSpec("TV_NAV_RIGHT", "26", R.string.infrared_key_tv_nav_right, "电视-右", "▶")
val OK = RemoteButtonSpec("TV_OK", "27", "电视-确定", "OK") val OK = RemoteButtonSpec("TV_OK", "27", R.string.infrared_key_tv_ok, "电视-确定", "OK")
val VOL_UP = RemoteButtonSpec("TV_VOL_UP", "28", "电视-音量+", "VOL +") val VOL_UP = RemoteButtonSpec("TV_VOL_UP", "28", R.string.infrared_key_tv_vol_up, "电视-音量+", "VOL +")
val VOL_DOWN = RemoteButtonSpec("TV_VOL_DOWN", "29", "电视-音量-", "VOL -") val VOL_DOWN = RemoteButtonSpec("TV_VOL_DOWN", "29", R.string.infrared_key_tv_vol_down, "电视-音量-", "VOL -")
val CH_UP = RemoteButtonSpec("TV_CH_UP", "30", "电视-频道+", "CH +") val CH_UP = RemoteButtonSpec("TV_CH_UP", "30", R.string.infrared_key_tv_ch_up, "电视-频道+", "CH +")
val CH_DOWN = RemoteButtonSpec("TV_CH_DOWN", "31", "电视-频道-", "CH -") val CH_DOWN = RemoteButtonSpec("TV_CH_DOWN", "31", R.string.infrared_key_tv_ch_down, "电视-频道-", "CH -")
val HOME = RemoteButtonSpec("TV_HOME", "32", "电视-主页", "🏠") val HOME = RemoteButtonSpec("TV_HOME", "32", R.string.infrared_key_tv_home, "电视-主页", "🏠")
val BACK = RemoteButtonSpec("TV_BACK", "33", "电视-返回", "↩") val BACK = RemoteButtonSpec("TV_BACK", "33", R.string.infrared_key_tv_back, "电视-返回", "↩")
val MENU = RemoteButtonSpec("TV_MENU", "34", "电视-菜单", "☰") val MENU = RemoteButtonSpec("TV_MENU", "34", R.string.infrared_key_tv_menu, "电视-菜单", "☰")
val buttons = listOf( val buttons = listOf(
POWER, MUTE, NAV_UP, NAV_DOWN, NAV_LEFT, NAV_RIGHT, OK, POWER, MUTE, NAV_UP, NAV_DOWN, NAV_LEFT, NAV_RIGHT, OK,
@@ -20,12 +20,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import com.flagship.abox.manager.infrared.R
import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec
import com.flagship.abox.manager.infrared.remote.RemoteSceneConfig import com.flagship.abox.manager.infrared.remote.RemoteSceneConfig
@@ -78,7 +80,7 @@ fun AcRemotePanel(
isLearningMode = isLearningMode, isLearningMode = isLearningMode,
onUpClick = { onButtonClick(tempUpSpec) }, onUpClick = { onButtonClick(tempUpSpec) },
onDownClick = { onButtonClick(tempDownSpec) }, onDownClick = { onButtonClick(tempDownSpec) },
label = "温度调节", label = stringResource(R.string.infrared_label_temp_control),
) )
} }
@@ -148,13 +150,13 @@ private fun AcLcdDisplay() {
) { ) {
Column { Column {
Text( Text(
text = "❄️ 制冷模式", text = stringResource(R.string.infrared_label_cool_mode),
color = Color(0xFF38BDF8), color = Color(0xFF38BDF8),
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
) )
Text( Text(
text = "💨 自动风速 | ↔ 扫风", text = stringResource(R.string.infrared_label_ac_status),
color = Color(0xFF94A3B8), color = Color(0xFF94A3B8),
fontSize = 12.sp, fontSize = 12.sp,
) )
@@ -35,6 +35,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -50,11 +51,11 @@ import com.flagship.abox.manager.infrared.remote.InfraredRemoteViewModel
import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec
import com.flagship.abox.manager.infrared.remote.RemoteScene import com.flagship.abox.manager.infrared.remote.RemoteScene
import com.flagship.abox.manager.infrared.remote.RemoteUiMessage import com.flagship.abox.manager.infrared.remote.RemoteUiMessage
import com.flagship.abox.manager.infrared.remote.UiText
@Composable @Composable
fun InfraredRemoteRoute( fun InfraredRemoteRoute(
onBack: () -> Unit, onBack: () -> Unit,
onOpenSettings: () -> Unit,
onSessionInvalid: () -> Unit, onSessionInvalid: () -> Unit,
viewModel: InfraredRemoteViewModel = hiltViewModel(), viewModel: InfraredRemoteViewModel = hiltViewModel(),
) { ) {
@@ -63,7 +64,6 @@ fun InfraredRemoteRoute(
InfraredRemoteScreen( InfraredRemoteScreen(
state = state, state = state,
onBack = onBack, onBack = onBack,
onOpenSettings = onOpenSettings,
onSessionInvalid = onSessionInvalid, onSessionInvalid = onSessionInvalid,
onSceneSelected = viewModel::switchScene, onSceneSelected = viewModel::switchScene,
onToggleLearningMode = viewModel::toggleLearningMode, onToggleLearningMode = viewModel::toggleLearningMode,
@@ -77,7 +77,6 @@ fun InfraredRemoteRoute(
internal fun InfraredRemoteScreen( internal fun InfraredRemoteScreen(
state: InfraredRemoteUiState, state: InfraredRemoteUiState,
onBack: () -> Unit, onBack: () -> Unit,
onOpenSettings: () -> Unit,
onSessionInvalid: () -> Unit, onSessionInvalid: () -> Unit,
onSceneSelected: (RemoteScene) -> Unit, onSceneSelected: (RemoteScene) -> Unit,
onToggleLearningMode: (Boolean) -> Unit, onToggleLearningMode: (Boolean) -> Unit,
@@ -85,16 +84,25 @@ internal fun InfraredRemoteScreen(
onMessageShown: () -> Unit, onMessageShown: () -> Unit,
) { ) {
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
LaunchedEffect(state.message) { LaunchedEffect(state.message) {
state.message?.let { msg -> state.message?.let { msg ->
when (msg) { when (msg) {
is RemoteUiMessage.Info -> { is RemoteUiMessage.Info -> {
snackbarHostState.showSnackbar(msg.text) val text = when (val t = msg.text) {
is UiText.DynamicString -> t.value
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
}
snackbarHostState.showSnackbar(text)
onMessageShown() onMessageShown()
} }
is RemoteUiMessage.Error -> { is RemoteUiMessage.Error -> {
snackbarHostState.showSnackbar(msg.text) val text = when (val t = msg.text) {
is UiText.DynamicString -> t.value
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
}
snackbarHostState.showSnackbar(text)
onMessageShown() onMessageShown()
} }
is RemoteUiMessage.SessionInvalid -> { is RemoteUiMessage.SessionInvalid -> {
@@ -145,14 +153,6 @@ internal fun InfraredRemoteScreen(
), ),
) )
} }
// 设置齿轮按钮
IconButton(
onClick = onOpenSettings,
modifier = Modifier.testTag("remote_settings_button"),
) {
Text("⚙️", fontSize = 20.sp)
}
}, },
) )
}, },
@@ -10,9 +10,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import com.flagship.abox.manager.infrared.R
import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec
import com.flagship.abox.manager.infrared.remote.RemoteSceneConfig import com.flagship.abox.manager.infrared.remote.RemoteSceneConfig
@@ -118,7 +120,7 @@ fun TvRemotePanel(
isLearningMode = isLearningMode, isLearningMode = isLearningMode,
onUpClick = { onButtonClick(volUp) }, onUpClick = { onButtonClick(volUp) },
onDownClick = { onButtonClick(volDown) }, onDownClick = { onButtonClick(volDown) },
label = "音量 VOL", label = stringResource(R.string.infrared_label_vol),
) )
// 频道控制 // 频道控制
@@ -134,7 +136,7 @@ fun TvRemotePanel(
isLearningMode = isLearningMode, isLearningMode = isLearningMode,
onUpClick = { onButtonClick(chUp) }, onUpClick = { onButtonClick(chUp) },
onDownClick = { onButtonClick(chDown) }, onDownClick = { onButtonClick(chDown) },
label = "频道 CH", label = stringResource(R.string.infrared_label_ch),
) )
} }
} }
@@ -1,59 +0,0 @@
package com.flagship.abox.manager.infrared.runtime
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import java.io.IOException
class DataStoreInfraredRuntimeModeStore(
private val dataStore: DataStore<Preferences>,
) : InfraredRuntimeModeStore {
companion object {
private val KEY_RUNTIME_MODE = stringPreferencesKey("infrared_runtime_mode")
}
override val mode: Flow<InfraredRuntimeMode> = dataStore.data
.catch { exception ->
if (exception is IOException) {
emit(androidx.datastore.preferences.core.emptyPreferences())
} else {
throw exception
}
}
.map { preferences ->
val storedName = preferences[KEY_RUNTIME_MODE]
if (storedName != null) {
try {
InfraredRuntimeMode.valueOf(storedName)
} catch (_: IllegalArgumentException) {
InfraredRuntimeMode.FAKE
}
} else {
InfraredRuntimeMode.FAKE
}
}
override suspend fun currentMode(): InfraredRuntimeMode {
return mode.first()
}
override suspend fun setMode(mode: InfraredRuntimeMode): DomainResult<Unit> {
return try {
dataStore.edit { preferences ->
preferences[KEY_RUNTIME_MODE] = mode.name
}
DomainResult.Success(Unit)
} catch (throwable: Throwable) {
if (throwable is kotlinx.coroutines.CancellationException) throw throwable
DomainResult.Failure(DomainError.Unknown(detail = "Failed to update runtime mode"))
}
}
}
@@ -1,15 +0,0 @@
package com.flagship.abox.manager.infrared.runtime
import com.flagship.abox.manager.domain.DomainResult
import kotlinx.coroutines.flow.Flow
enum class InfraredRuntimeMode {
FAKE,
REAL_ABSDK,
}
interface InfraredRuntimeModeStore {
val mode: Flow<InfraredRuntimeMode>
suspend fun currentMode(): InfraredRuntimeMode
suspend fun setMode(mode: InfraredRuntimeMode): DomainResult<Unit>
}
@@ -1,12 +0,0 @@
package com.flagship.abox.manager.infrared.runtime
import com.flagship.abox.manager.domain.ProviderProfileId
class InfraredRuntimeProfileResolver {
fun resolve(realProfileId: ProviderProfileId, mode: InfraredRuntimeMode): ProviderProfileId {
return when (mode) {
InfraredRuntimeMode.REAL_ABSDK -> realProfileId
InfraredRuntimeMode.FAKE -> ProviderProfileId("${realProfileId.value}:infrared-fake")
}
}
}
@@ -1,466 +0,0 @@
package com.flagship.abox.manager.infrared.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.designsystem.ABoxButton
import com.flagship.abox.manager.designsystem.ABoxSurface
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.R
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode
@Composable
fun InfraredControlRoute(
onBack: () -> Unit,
onSessionInvalid: () -> Unit,
viewModel: InfraredControlViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
InfraredControlScreen(
state = state,
nextSlot = viewModel.nextAvailableKeySlot(),
onBack = onBack,
onSessionInvalid = onSessionInvalid,
onDeviceNameChanged = viewModel::onDeviceNameChanged,
onSaveDeviceName = viewModel::saveDeviceName,
onLearn = viewModel::learnKey,
onRetry = viewModel::retryLearning,
onSend = viewModel::sendKey,
onRename = viewModel::renameKey,
onDelete = viewModel::deleteKey,
onModeChanged = viewModel::setRuntimeMode,
onMessageShown = viewModel::clearMessage,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun InfraredControlScreen(
state: InfraredControlUiState,
nextSlot: Int,
onBack: () -> Unit,
onSessionInvalid: () -> Unit,
onDeviceNameChanged: (String) -> Unit,
onSaveDeviceName: () -> Unit,
onLearn: (String, String?) -> Unit,
onRetry: (InfraredKeyId) -> Unit,
onSend: (InfraredKeyId) -> Unit,
onRename: (InfraredKeyId, String) -> Unit,
onDelete: (InfraredKeyId) -> Unit,
onModeChanged: (InfraredRuntimeMode) -> Unit,
onMessageShown: () -> Unit,
) {
val snackbarHostState = remember { SnackbarHostState() }
var showLearnDialog by remember { mutableStateOf(false) }
var keyToRename by remember { mutableStateOf<SavedInfraredKey?>(null) }
var keyToDelete by remember { mutableStateOf<SavedInfraredKey?>(null) }
LaunchedEffect(state.message) {
state.message?.let { msg ->
when (msg) {
is InfraredUiMessage.Info -> {
snackbarHostState.showSnackbar(msg.text)
onMessageShown()
}
is InfraredUiMessage.Error -> {
snackbarHostState.showSnackbar(msg.text)
onMessageShown()
}
is InfraredUiMessage.SessionInvalid -> {
onSessionInvalid()
onMessageShown()
}
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.infrared_title)) },
navigationIcon = {
TextButton(onClick = onBack) {
Text(stringResource(R.string.infrared_back))
}
},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
ABoxSurface(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Mode indicator and toggle
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
val modeText = if (state.runtimeMode == InfraredRuntimeMode.FAKE) {
stringResource(R.string.infrared_mode_fake)
} else {
stringResource(R.string.infrared_mode_real)
}
Text(
text = modeText,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.testTag("infrared_mode_label"),
)
OutlinedButton(
onClick = {
val target = if (state.runtimeMode == InfraredRuntimeMode.FAKE) {
InfraredRuntimeMode.REAL_ABSDK
} else {
InfraredRuntimeMode.FAKE
}
onModeChanged(target)
},
enabled = !state.hasActiveLearning,
) {
Text(stringResource(R.string.infrared_switch_mode))
}
}
// Device name configuration card
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
val saved = state.savedDeviceName
Text(
text = if (saved != null) {
stringResource(R.string.infrared_saved_device, saved.value)
} else {
stringResource(R.string.infrared_no_device_saved)
},
style = MaterialTheme.typography.titleMedium,
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = state.deviceNameInput,
onValueChange = onDeviceNameChanged,
label = { Text(stringResource(R.string.infrared_device_name_label)) },
placeholder = { Text(stringResource(R.string.infrared_device_name_hint)) },
singleLine = true,
modifier = Modifier
.weight(1f)
.testTag("infrared_device_name_input"),
)
ABoxButton(
onClick = onSaveDeviceName,
modifier = Modifier.testTag("infrared_save_device"),
) {
Text(stringResource(R.string.infrared_save_device))
}
}
}
}
// Learned Keys Section
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.infrared_keys_title),
style = MaterialTheme.typography.titleMedium,
)
ABoxButton(
onClick = { showLearnDialog = true },
enabled = state.savedDeviceName != null,
modifier = Modifier.testTag("infrared_learn_new_key"),
) {
Text(stringResource(R.string.infrared_learn_new_key))
}
}
if (state.keys.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(R.string.infrared_no_keys),
style = MaterialTheme.typography.bodyMedium,
)
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(state.keys, key = { it.id.value }) { key ->
InfraredKeyRow(
key = key,
isSending = state.sendingKeyIds.contains(key.id),
onSend = { onSend(key.id) },
onRetry = { onRetry(key.id) },
onRename = { keyToRename = key },
onDelete = { keyToDelete = key },
)
}
}
}
}
}
}
if (showLearnDialog) {
var keyName by remember { mutableStateOf("") }
var keySlot by remember { mutableStateOf(nextSlot.toString()) }
AlertDialog(
onDismissRequest = { showLearnDialog = false },
title = { Text(stringResource(R.string.infrared_learn_dialog_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.infrared_learn_dialog_prompt))
OutlinedTextField(
value = keyName,
onValueChange = { keyName = it },
label = { Text(stringResource(R.string.infrared_learn_dialog_input_label)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = keySlot,
onValueChange = { keySlot = it },
label = { Text(stringResource(R.string.infrared_learn_dialog_slot_label)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
Button(
onClick = {
onLearn(keyName, keySlot)
showLearnDialog = false
},
enabled = keyName.isNotBlank() && keySlot.isNotBlank(),
) {
Text(stringResource(R.string.infrared_learn_dialog_start))
}
},
dismissButton = {
TextButton(onClick = { showLearnDialog = false }) {
Text(stringResource(R.string.infrared_dialog_cancel))
}
},
)
}
keyToRename?.let { targetKey ->
var newName by remember { mutableStateOf(targetKey.displayName) }
AlertDialog(
onDismissRequest = { keyToRename = null },
title = { Text(stringResource(R.string.infrared_rename_dialog_title)) },
text = {
OutlinedTextField(
value = newName,
onValueChange = { newName = it },
label = { Text(stringResource(R.string.infrared_rename_dialog_input_label)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
},
confirmButton = {
Button(
onClick = {
onRename(targetKey.id, newName)
keyToRename = null
},
enabled = newName.isNotBlank(),
) {
Text(stringResource(R.string.infrared_dialog_confirm))
}
},
dismissButton = {
TextButton(onClick = { keyToRename = null }) {
Text(stringResource(R.string.infrared_dialog_cancel))
}
},
)
}
keyToDelete?.let { targetKey ->
AlertDialog(
onDismissRequest = { keyToDelete = null },
title = { Text(stringResource(R.string.infrared_delete_dialog_title)) },
text = { Text(stringResource(R.string.infrared_delete_dialog_message)) },
confirmButton = {
Button(
onClick = {
onDelete(targetKey.id)
keyToDelete = null
},
) {
Text(stringResource(R.string.infrared_dialog_confirm))
}
},
dismissButton = {
TextButton(onClick = { keyToDelete = null }) {
Text(stringResource(R.string.infrared_dialog_cancel))
}
},
)
}
}
@Composable
private fun InfraredKeyRow(
key: SavedInfraredKey,
isSending: Boolean,
onSend: () -> Unit,
onRetry: () -> Unit,
onRename: () -> Unit,
onDelete: () -> Unit,
) {
Card(
modifier = Modifier
.fillMaxWidth()
.testTag("infrared_key_${key.id.value}"),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(
text = key.displayName,
style = MaterialTheme.typography.titleMedium,
)
Text(
text = stringResource(R.string.infrared_key_slot_label, key.sdkKey),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
val statusLabel = when (key.status) {
InfraredKeyStatus.LEARNING -> stringResource(R.string.infrared_status_learning)
InfraredKeyStatus.READY -> stringResource(R.string.infrared_status_ready)
InfraredKeyStatus.FAILED -> stringResource(R.string.infrared_status_failed)
}
Text(
text = statusLabel,
style = MaterialTheme.typography.bodyMedium,
color = when (key.status) {
InfraredKeyStatus.LEARNING -> MaterialTheme.colorScheme.primary
InfraredKeyStatus.READY -> MaterialTheme.colorScheme.tertiary
InfraredKeyStatus.FAILED -> MaterialTheme.colorScheme.error
},
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
if (key.status == InfraredKeyStatus.READY) {
Button(
onClick = onSend,
enabled = !isSending,
modifier = Modifier.testTag("infrared_send_${key.id.value}"),
) {
if (isSending) {
Text(stringResource(R.string.infrared_sending))
} else {
Text(stringResource(R.string.infrared_send))
}
}
Spacer(modifier = Modifier.width(8.dp))
}
if (key.status == InfraredKeyStatus.FAILED) {
Button(
onClick = onRetry,
modifier = Modifier.testTag("infrared_retry_${key.id.value}"),
) {
Text(stringResource(R.string.infrared_retry))
}
Spacer(modifier = Modifier.width(8.dp))
}
if (key.status != InfraredKeyStatus.LEARNING) {
OutlinedButton(onClick = onRename) {
Text(stringResource(R.string.infrared_rename))
}
Spacer(modifier = Modifier.width(8.dp))
OutlinedButton(onClick = onDelete) {
Text(stringResource(R.string.infrared_delete))
}
} else {
CircularProgressIndicator(modifier = Modifier.height(24.dp).width(24.dp))
}
}
}
}
}
@@ -1,27 +0,0 @@
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 }
}
@@ -1,342 +0,0 @@
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 nextAvailableKeySlot(): Int {
val usedSlots = state.value.keys.mapNotNull { it.sdkKey.toIntOrNull() }.toSet()
return (1..100).firstOrNull { it !in usedSlots } ?: 1
}
fun learnKey(displayName: String, keySlotInput: String? = null) {
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
}
val slotString = if (keySlotInput != null && keySlotInput.isNotBlank()) {
val slotInt = keySlotInput.trim().toIntOrNull()
if (slotInt == null || slotInt !in 1..100) {
messageFlow.value = InfraredUiMessage.Error("Key slot must be an integer between 1 and 100")
return
}
slotInt.toString()
} else {
nextAvailableKeySlot().toString()
}
learningCoordinator.learn(currentProfile, savedDevice, trimmedName, slotString)
}
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")
}
}
}
}
@@ -39,4 +39,41 @@
<string name="infrared_remote_scene_ac">❄️ 空调遥控</string> <string name="infrared_remote_scene_ac">❄️ 空调遥控</string>
<string name="infrared_remote_scene_tv">📺 电视遥控</string> <string name="infrared_remote_scene_tv">📺 电视遥控</string>
<string name="infrared_remote_settings">设置</string> <string name="infrared_remote_settings">设置</string>
<string name="infrared_msg_learning_mode_enabled">🔴 学习录制模式已开启:点击任意按键即可开始录制</string>
<string name="infrared_msg_recording_key">正在录制【%1$s】(Slot %2$s),请按遥控器按键...</string>
<string name="infrared_msg_learned_success">【%1$s】录制成功</string>
<string name="infrared_msg_key_not_learned">按键【%1$s】尚未学习,请开启右上角学习模式进行录制</string>
<string name="infrared_msg_signal_sent">已发射: %1$s</string>
<string name="infrared_msg_device_offline">红外控制器离线 (错误码: %1$s)</string>
<string name="infrared_msg_load_failed">数据加载失败</string>
<string name="infrared_msg_operation_failed">操作失败</string>
<string name="infrared_label_temp_control">温度调节</string>
<string name="infrared_label_vol">音量 VOL</string>
<string name="infrared_label_ch">频道 CH</string>
<string name="infrared_label_cool_mode">❄️ 制冷模式</string>
<string name="infrared_label_ac_status">💨 自动风速 | ↔ 扫风</string>
<string name="infrared_key_ac_power">空调-电源</string>
<string name="infrared_key_ac_temp_up">空调-温度+</string>
<string name="infrared_key_ac_temp_down">空调-温度-</string>
<string name="infrared_key_ac_mode">空调-模式</string>
<string name="infrared_key_ac_fan">空调-风速</string>
<string name="infrared_key_ac_swing">空调-扫风</string>
<string name="infrared_key_ac_timer">空调-定时</string>
<string name="infrared_key_tv_power">电视-电源</string>
<string name="infrared_key_tv_mute">电视-静音</string>
<string name="infrared_key_tv_nav_up">电视-上</string>
<string name="infrared_key_tv_nav_down">电视-下</string>
<string name="infrared_key_tv_nav_left">电视-左</string>
<string name="infrared_key_tv_nav_right">电视-右</string>
<string name="infrared_key_tv_ok">电视-确定</string>
<string name="infrared_key_tv_vol_up">电视-音量+</string>
<string name="infrared_key_tv_vol_down">电视-音量-</string>
<string name="infrared_key_tv_ch_up">电视-频道+</string>
<string name="infrared_key_tv_ch_down">电视-频道-</string>
<string name="infrared_key_tv_home">电视-主页</string>
<string name="infrared_key_tv_back">电视-返回</string>
<string name="infrared_key_tv_menu">电视-菜单</string>
</resources> </resources>
@@ -39,4 +39,41 @@
<string name="infrared_remote_scene_ac">❄️ Air Conditioner</string> <string name="infrared_remote_scene_ac">❄️ Air Conditioner</string>
<string name="infrared_remote_scene_tv">📺 Television</string> <string name="infrared_remote_scene_tv">📺 Television</string>
<string name="infrared_remote_settings">Settings</string> <string name="infrared_remote_settings">Settings</string>
<string name="infrared_msg_learning_mode_enabled">🔴 Learn mode active: tap any button to record</string>
<string name="infrared_msg_recording_key">Recording [%1$s] (Slot %2$s), press remote button…</string>
<string name="infrared_msg_learned_success">[%1$s] recorded successfully</string>
<string name="infrared_msg_key_not_learned">Button [%1$s] is not learned. Enable learn mode to record.</string>
<string name="infrared_msg_signal_sent">Sent: %1$s</string>
<string name="infrared_msg_device_offline">Infrared controller offline (code: %1$s)</string>
<string name="infrared_msg_load_failed">Failed to load data</string>
<string name="infrared_msg_operation_failed">Operation failed</string>
<string name="infrared_label_temp_control">Temp Control</string>
<string name="infrared_label_vol">VOL</string>
<string name="infrared_label_ch">CH</string>
<string name="infrared_label_cool_mode">❄️ Cool Mode</string>
<string name="infrared_label_ac_status">💨 Auto Fan | ↔ Swing</string>
<string name="infrared_key_ac_power">AC Power</string>
<string name="infrared_key_ac_temp_up">AC Temp +</string>
<string name="infrared_key_ac_temp_down">AC Temp -</string>
<string name="infrared_key_ac_mode">AC Mode</string>
<string name="infrared_key_ac_fan">AC Fan Speed</string>
<string name="infrared_key_ac_swing">AC Swing</string>
<string name="infrared_key_ac_timer">AC Timer</string>
<string name="infrared_key_tv_power">TV Power</string>
<string name="infrared_key_tv_mute">TV Mute</string>
<string name="infrared_key_tv_nav_up">TV Up</string>
<string name="infrared_key_tv_nav_down">TV Down</string>
<string name="infrared_key_tv_nav_left">TV Left</string>
<string name="infrared_key_tv_nav_right">TV Right</string>
<string name="infrared_key_tv_ok">TV OK</string>
<string name="infrared_key_tv_vol_up">TV Vol +</string>
<string name="infrared_key_tv_vol_down">TV Vol -</string>
<string name="infrared_key_tv_ch_up">TV CH +</string>
<string name="infrared_key_tv_ch_down">TV CH -</string>
<string name="infrared_key_tv_home">TV Home</string>
<string name="infrared_key_tv_back">TV Back</string>
<string name="infrared_key_tv_menu">TV Menu</string>
</resources> </resources>
@@ -125,4 +125,32 @@ class InfraredLearningCoordinatorTest {
val updated = (repository.getKey(InfraredKeyRef(profile, InfraredKeyId("key1"))) as DomainResult.Success).value!! val updated = (repository.getKey(InfraredKeyRef(profile, InfraredKeyId("key1"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.FAILED, updated.status) assertEquals(InfraredKeyStatus.FAILED, updated.status)
} }
@Test
fun subsequentLearnWaitsForCleanupCompletion() = runTest {
val learningKey = SavedInfraredKey(
id = InfraredKeyId("key1"),
profileId = profile,
deviceName = device,
displayName = "Power",
sdkKey = "1",
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 500L,
updatedAtEpochMillis = 500L,
)
repository.insert(learningKey)
val coordinator = InfraredLearningCoordinator(learn, retry, relearn, repository, this)
coordinator.failInterruptedLearning(profile, cutoffEpochMillis = 1000L)
val relearnJob = coordinator.relearn(InfraredKeyRef(profile, InfraredKeyId("key1")))
advanceUntilIdle()
val relearnResult = relearnJob.await()
assertTrue(relearnResult is DomainResult.Success)
val updated = (repository.getKey(InfraredKeyRef(profile, InfraredKeyId("key1"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.READY, updated.status)
}
} }
@@ -12,17 +12,13 @@ import com.flagship.abox.manager.domain.infrared.LearnInfraredKey
import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import com.flagship.abox.manager.domain.infrared.SendInfraredKey import com.flagship.abox.manager.domain.infrared.SendInfraredKey
import com.flagship.abox.manager.infrared.R
import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator 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.FakeInfraredDeviceConfigurationRepository
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
import com.flagship.abox.manager.testing.fakes.FakeInfraredKeyRepository import com.flagship.abox.manager.testing.fakes.FakeInfraredKeyRepository
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
@@ -35,30 +31,17 @@ import org.junit.Assert.assertEquals
import org.junit.Before import org.junit.Before
import org.junit.Test 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) @OptIn(ExperimentalCoroutinesApi::class)
class InfraredRemoteViewModelTest { class InfraredRemoteViewModelTest {
private val testDispatcher = StandardTestDispatcher() private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher) private val testScope = TestScope(testDispatcher)
private val profile = ProviderProfileId("abox") private val profile = ProviderProfileId("abox")
private val fakeProfile = ProviderProfileId("abox:infrared-fake")
private val device = DeviceName("ir-living") private val device = DeviceName("ir-living")
private val clock = EpochMillisClock { 10_000L } private val clock = EpochMillisClock { 10_000L }
private val keyRepo = FakeInfraredKeyRepository() private val keyRepo = FakeInfraredKeyRepository()
private val deviceRepo = FakeInfraredDeviceConfigurationRepository() private val deviceRepo = FakeInfraredDeviceConfigurationRepository()
private val modeStore = TestModeStore()
private val resolver = InfraredRuntimeProfileResolver()
private val gateway = FakeInfraredGateway() private val gateway = FakeInfraredGateway()
private val factory = InfraredKeyFactory({ InfraredKeyId("01J5MOLERABC123") }, clock) private val factory = InfraredKeyFactory({ InfraredKeyId("01J5MOLERABC123") }, clock)
@@ -84,8 +67,6 @@ class InfraredRemoteViewModelTest {
savedStateHandle = SavedStateHandle(mapOf("profileId" to "abox")), savedStateHandle = SavedStateHandle(mapOf("profileId" to "abox")),
keyRepository = keyRepo, keyRepository = keyRepo,
deviceConfigurationRepository = deviceRepo, deviceConfigurationRepository = deviceRepo,
runtimeModeStore = modeStore,
profileResolver = resolver,
learningCoordinator = coordinator, learningCoordinator = coordinator,
sendInfraredKey = send, sendInfraredKey = send,
) )
@@ -93,10 +74,10 @@ class InfraredRemoteViewModelTest {
@Test @Test
fun controlModeSendsSignalWhenKeyIsReady() = testScope.runTest { fun controlModeSendsSignalWhenKeyIsReady() = testScope.runTest {
deviceRepo.setDeviceName(fakeProfile, device) deviceRepo.setDeviceName(profile, device)
val readyKey = SavedInfraredKey( val readyKey = SavedInfraredKey(
id = InfraredKeyId("k1"), id = InfraredKeyId("k1"),
profileId = fakeProfile, profileId = profile,
deviceName = device, deviceName = device,
displayName = "空调-电源", displayName = "空调-电源",
sdkKey = "1", sdkKey = "1",
@@ -123,7 +104,7 @@ class InfraredRemoteViewModelTest {
@Test @Test
fun learningModeTriggersLearnOnButtonClick() = testScope.runTest { fun learningModeTriggersLearnOnButtonClick() = testScope.runTest {
deviceRepo.setDeviceName(fakeProfile, device) deviceRepo.setDeviceName(profile, device)
val vm = createViewModel() val vm = createViewModel()
val job = launch { vm.state.collect {} } val job = launch { vm.state.collect {} }
advanceUntilIdle() advanceUntilIdle()
@@ -141,7 +122,7 @@ class InfraredRemoteViewModelTest {
@Test @Test
fun disablingLearningModeClearsActiveLearningSlot() = testScope.runTest { fun disablingLearningModeClearsActiveLearningSlot() = testScope.runTest {
deviceRepo.setDeviceName(fakeProfile, device) deviceRepo.setDeviceName(profile, device)
val vm = createViewModel() val vm = createViewModel()
val job = launch { vm.state.collect {} } val job = launch { vm.state.collect {} }
advanceUntilIdle() advanceUntilIdle()
@@ -157,10 +138,10 @@ class InfraredRemoteViewModelTest {
@Test @Test
fun duplicateSendButtonClickIsIgnoredWhileSending() = testScope.runTest { fun duplicateSendButtonClickIsIgnoredWhileSending() = testScope.runTest {
deviceRepo.setDeviceName(fakeProfile, device) deviceRepo.setDeviceName(profile, device)
val readyKey = SavedInfraredKey( val readyKey = SavedInfraredKey(
id = InfraredKeyId("k1"), id = InfraredKeyId("k1"),
profileId = fakeProfile, profileId = profile,
deviceName = device, deviceName = device,
displayName = "空调-电源", displayName = "空调-电源",
sdkKey = "1", sdkKey = "1",
@@ -187,10 +168,10 @@ class InfraredRemoteViewModelTest {
@Test @Test
fun learningExistingKeyRetriesExistingKey() = testScope.runTest { fun learningExistingKeyRetriesExistingKey() = testScope.runTest {
deviceRepo.setDeviceName(fakeProfile, device) deviceRepo.setDeviceName(profile, device)
val failedKey = SavedInfraredKey( val failedKey = SavedInfraredKey(
id = InfraredKeyId("k1"), id = InfraredKeyId("k1"),
profileId = fakeProfile, profileId = profile,
deviceName = device, deviceName = device,
displayName = "空调-电源", displayName = "空调-电源",
sdkKey = "1", sdkKey = "1",
@@ -212,16 +193,19 @@ class InfraredRemoteViewModelTest {
assertEquals(1, gateway.learnedKeys.size) assertEquals(1, gateway.learnedKeys.size)
assertEquals("1", gateway.learnedKeys.first().key) assertEquals("1", gateway.learnedKeys.first().key)
val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(fakeProfile, InfraredKeyId("k1"))) as DomainResult.Success).value!! val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(profile, InfraredKeyId("k1"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.READY, updated.status) assertEquals(InfraredKeyStatus.READY, updated.status)
assertEquals(RemoteUiMessage.Info("【空调-电源】录制成功"), vm.state.value.message) assertEquals(
RemoteUiMessage.Info(UiText.StringResource(R.string.infrared_msg_learned_success, listOf("空调-电源"))),
vm.state.value.message,
)
job.cancel() job.cancel()
} }
@Test @Test
fun learningFailurePropagatesErrorMessage() = testScope.runTest { fun learningFailurePropagatesErrorMessage() = testScope.runTest {
deviceRepo.setDeviceName(fakeProfile, device) deviceRepo.setDeviceName(profile, device)
gateway.learnResult = DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.DeviceOffline(externalCode = "20206")) gateway.learnResult = DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.DeviceOffline(externalCode = "20206"))
val vm = createViewModel() val vm = createViewModel()
@@ -232,14 +216,17 @@ class InfraredRemoteViewModelTest {
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER) vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
advanceUntilIdle() advanceUntilIdle()
assertEquals(RemoteUiMessage.Error("红外控制器离线 (错误码: 20206)"), vm.state.value.message) assertEquals(
RemoteUiMessage.Error(UiText.StringResource(R.string.infrared_msg_device_offline, listOf("20206"))),
vm.state.value.message,
)
job.cancel() job.cancel()
} }
@Test @Test
fun learningSessionInvalidPropagatesSessionInvalidMessage() = testScope.runTest { fun learningSessionInvalidPropagatesSessionInvalidMessage() = testScope.runTest {
deviceRepo.setDeviceName(fakeProfile, device) deviceRepo.setDeviceName(profile, device)
gateway.learnResult = DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.TokenInvalid(detail = "Session expired")) gateway.learnResult = DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.TokenInvalid(detail = "Session expired"))
val vm = createViewModel() val vm = createViewModel()
@@ -257,7 +244,7 @@ class InfraredRemoteViewModelTest {
@Test @Test
fun disablingLearningModeCleansUpAndFailsInterruptedLearning() = testScope.runTest { fun disablingLearningModeCleansUpAndFailsInterruptedLearning() = testScope.runTest {
deviceRepo.setDeviceName(fakeProfile, device) deviceRepo.setDeviceName(profile, device)
val vm = createViewModel() val vm = createViewModel()
val job = launch { vm.state.collect {} } val job = launch { vm.state.collect {} }
advanceUntilIdle() advanceUntilIdle()
@@ -274,10 +261,10 @@ class InfraredRemoteViewModelTest {
@Test @Test
fun relearningExistingReadyKeyOverwritesKey() = testScope.runTest { fun relearningExistingReadyKeyOverwritesKey() = testScope.runTest {
deviceRepo.setDeviceName(fakeProfile, device) deviceRepo.setDeviceName(profile, device)
val readyKey = SavedInfraredKey( val readyKey = SavedInfraredKey(
id = InfraredKeyId("k1"), id = InfraredKeyId("k1"),
profileId = fakeProfile, profileId = profile,
deviceName = device, deviceName = device,
displayName = "空调-电源", displayName = "空调-电源",
sdkKey = "1", sdkKey = "1",
@@ -299,19 +286,22 @@ class InfraredRemoteViewModelTest {
assertEquals(1, gateway.learnedKeys.size) assertEquals(1, gateway.learnedKeys.size)
assertEquals("1", gateway.learnedKeys.first().key) assertEquals("1", gateway.learnedKeys.first().key)
val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(fakeProfile, InfraredKeyId("k1"))) as DomainResult.Success).value!! val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(profile, InfraredKeyId("k1"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.READY, updated.status) assertEquals(InfraredKeyStatus.READY, updated.status)
assertEquals(RemoteUiMessage.Info("【空调-电源】录制成功"), vm.state.value.message) assertEquals(
RemoteUiMessage.Info(UiText.StringResource(R.string.infrared_msg_learned_success, listOf("空调-电源"))),
vm.state.value.message,
)
job.cancel() job.cancel()
} }
@Test @Test
fun relearningKeyInLearningStatusSucceeds() = testScope.runTest { fun relearningKeyInLearningStatusSucceeds() = testScope.runTest {
deviceRepo.setDeviceName(fakeProfile, device) deviceRepo.setDeviceName(profile, device)
val learningKey = SavedInfraredKey( val learningKey = SavedInfraredKey(
id = InfraredKeyId("k1"), id = InfraredKeyId("k1"),
profileId = fakeProfile, profileId = profile,
deviceName = device, deviceName = device,
displayName = "空调-电源", displayName = "空调-电源",
sdkKey = "1", sdkKey = "1",
@@ -333,9 +323,12 @@ class InfraredRemoteViewModelTest {
assertEquals(1, gateway.learnedKeys.size) assertEquals(1, gateway.learnedKeys.size)
assertEquals("1", gateway.learnedKeys.first().key) assertEquals("1", gateway.learnedKeys.first().key)
val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(fakeProfile, InfraredKeyId("k1"))) as DomainResult.Success).value!! val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(profile, InfraredKeyId("k1"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.READY, updated.status) assertEquals(InfraredKeyStatus.READY, updated.status)
assertEquals(RemoteUiMessage.Info("【空调-电源】录制成功"), vm.state.value.message) assertEquals(
RemoteUiMessage.Info(UiText.StringResource(R.string.infrared_msg_learned_success, listOf("空调-电源"))),
vm.state.value.message,
)
job.cancel() job.cancel()
} }
@@ -348,7 +341,7 @@ class InfraredRemoteViewModelTest {
val job = launch { vm.state.collect {} } val job = launch { vm.state.collect {} }
advanceUntilIdle() advanceUntilIdle()
assertEquals(RemoteUiMessage.Error("Disk failure"), vm.state.value.message) assertEquals(RemoteUiMessage.Error(UiText.DynamicString("Disk failure")), vm.state.value.message)
job.cancel() job.cancel()
} }
@@ -1,21 +0,0 @@
package com.flagship.abox.manager.infrared.runtime
import com.flagship.abox.manager.domain.ProviderProfileId
import org.junit.Assert.assertEquals
import org.junit.Test
class InfraredRuntimeProfileResolverTest {
@Test
fun realModeUsesAuthenticatedProfileAndFakeModeUsesIsolatedProfile() {
val resolver = InfraredRuntimeProfileResolver()
assertEquals(
ProviderProfileId("abox"),
resolver.resolve(ProviderProfileId("abox"), InfraredRuntimeMode.REAL_ABSDK),
)
assertEquals(
ProviderProfileId("abox:infrared-fake"),
resolver.resolve(ProviderProfileId("abox"), InfraredRuntimeMode.FAKE),
)
}
}
@@ -1,295 +0,0 @@
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 relearnUseCase = com.flagship.abox.manager.domain.infrared.RelearnInfraredKey(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, relearnUseCase, keyRepository, 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()
}
}