feat(infrared): add infrared control, learning, and virtual remote module #9

Merged
KremeCN merged 41 commits from feat/moler-infrared-control into main 2026-08-25 14:51:34 +08:00
23 changed files with 296 additions and 1580 deletions
Showing only changes of commit 4aa9116ac4 - Show all commits
@@ -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
Review

数据丢失

使用 fallbackToDestructiveMigration() 会在数据库版本升级且缺少对应迁移时直接清空整个红外数据库,导致用户已录制的红外码和遥控器配置不可恢复地丢失。应为生产数据库提供显式 Migration,避免启用破坏性回退。

**数据丢失** 使用 `fallbackToDestructiveMigration()` 会在数据库版本升级且缺少对应迁移时直接清空整个红外数据库,导致用户已录制的红外码和遥控器配置不可恢复地丢失。应为生产数据库提供显式 `Migration`,避免启用破坏性回退。 <!-- pr-agent-dedup: 80d09de2eaf5 --> <!-- pr-agent-key-issue-location: fb291c03ba0c -->
@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) {
Outdated
Review

恢复错位

failInterruptedLearning 使用原始认证配置,而按键数据使用经运行模式解析后的 effectiveProfileId。在 FAKE 模式下,按键通常存储于派生配置(如 abox:infrared-fake);进程中断后,这些 LEARNING 记录不会被标记为失败,而 onButtonClick 又会直接忽略已有的 LEARNING 按键,导致该按键无法重试或重新学习。应针对当前运行模式解析出的配置执行恢复。

**恢复错位** `failInterruptedLearning` 使用原始认证配置,而按键数据使用经运行模式解析后的 `effectiveProfileId`。在 FAKE 模式下,按键通常存储于派生配置(如 `abox:infrared-fake`);进程中断后,这些 `LEARNING` 记录不会被标记为失败,而 `onButtonClick` 又会直接忽略已有的 `LEARNING` 按键,导致该按键无法重试或重新学习。应针对当前运行模式解析出的配置执行恢复。 <!-- pr-agent-dedup: fe03f4d5ccbe --> <!-- pr-agent-key-issue-location: 97afcff45f2a -->
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(),
Outdated
Review

错误吞没

设备配置和按键观察流中的 DomainResult.Failure 分别被转换为“未配置设备”和空按键列表。数据库读取失败时,界面会错误地提示用户配置设备或重新学习按键,而不是报告实际存储错误。应将失败状态传播到 UI,避免把读取故障当成真实的空数据。

**错误吞没** 设备配置和按键观察流中的 `DomainResult.Failure` 分别被转换为“未配置设备”和空按键列表。数据库读取失败时,界面会错误地提示用户配置设备或重新学习按键,而不是报告实际存储错误。应将失败状态传播到 UI,避免把读取故障当成真实的空数据。 <!-- pr-agent-dedup: f7ef39caa7e5 --> <!-- pr-agent-key-issue-location: babb0d2f9517 -->
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(
Outdated
Review

状态错配

effectiveProfileAndMode、deviceNameFlow 和 keysFlow 被独立组合,设备名或运行模式切换时可能短暂生成“新配置 + 旧按键列表”的状态。用户此时点击按键,会使用旧按键引用向旧红外设备发送命令,或用新 profile 查询旧按键。应在同一个 flatMapLatest 中生成相互关联的 profile、设备和按键快照,或发送前校验按键的 profile、deviceName 与当前配置一致。

**状态错配** `effectiveProfileAndMode`、`deviceNameFlow` 和 `keysFlow` 被独立组合,设备名或运行模式切换时可能短暂生成“新配置 + 旧按键列表”的状态。用户此时点击按键,会使用旧按键引用向旧红外设备发送命令,或用新 profile 查询旧按键。应在同一个 `flatMapLatest` 中生成相互关联的 profile、设备和按键快照,或发送前校验按键的 profile、deviceName 与当前配置一致。 <!-- pr-agent-dedup: 0c88096cf35a --> <!-- pr-agent-key-issue-location: 6aab6621dcad -->
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(
} }
} }
} }
}
}
Outdated
Review

任务未取消

关闭学习模式时仅清空 UI 槽位,没有保存或取消正在运行的学习任务。用户在录制过程中关闭开关后,底层学习仍会继续,而界面允许发送或再次开始学习;若再次学习同一槽位,旧任务的完成回调还可能提前清除新任务的加载状态。应持有并取消当前任务,或在任务真正结束前保持活动状态。

**任务未取消** 关闭学习模式时仅清空 UI 槽位,没有保存或取消正在运行的学习任务。用户在录制过程中关闭开关后,底层学习仍会继续,而界面允许发送或再次开始学习;若再次学习同一槽位,旧任务的完成回调还可能提前清除新任务的加载状态。应持有并取消当前任务,或在任务真正结束前保持活动状态。 <!-- pr-agent-dedup: 67a90f12e304 --> <!-- pr-agent-key-issue-location: f63601c36e3b -->
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),
)
} }
} }
Outdated
Review

加载卡死

启动学习后,activeLearningSlotFlow 只会被赋值,从未在学习成功、失败或关闭学习模式时清空,因此最后操作的按键会一直显示为加载中。与此同时,协调器丢弃了学习结果,Token 失效等错误也无法触发会话失效处理或错误提示。请根据用例结果或仓库中的按键状态清理活动槽位并处理错误。

**加载卡死** 启动学习后,`activeLearningSlotFlow` 只会被赋值,从未在学习成功、失败或关闭学习模式时清空,因此最后操作的按键会一直显示为加载中。与此同时,协调器丢弃了学习结果,Token 失效等错误也无法触发会话失效处理或错误提示。请根据用例结果或仓库中的按键状态清理活动槽位并处理错误。 <!-- pr-agent-dedup: 08cbe6405e17 --> <!-- pr-agent-key-issue-location: a26f8ce229dc -->
Outdated
Review

状态残留

开始学习时会设置 activeLearningSlotFlow,但该状态之后没有任何清空路径,也未关联学习成功或失败结果。完成一次学习后,对应按键将永久显示加载状态,即使关闭学习模式也不会恢复。请在协调器任务结束时根据结果清空该状态。

**状态残留** 开始学习时会设置 `activeLearningSlotFlow`,但该状态之后没有任何清空路径,也未关联学习成功或失败结果。完成一次学习后,对应按键将永久显示加载状态,即使关闭学习模式也不会恢复。请在协调器任务结束时根据结果清空该状态。 <!-- pr-agent-dedup: 643c68fe6ccb --> <!-- pr-agent-key-issue-location: 4c87bb7dcaf1 -->
InfraredRemoteUiState( InfraredRemoteUiState(
selectedScene = scene, selectedScene = scene,
Outdated
Review

无法重学

学习分支不区分槽位是否已有记录,始终调用 learningCoordinator.learn。仓库明确拒绝同一 SDK 槽位的重复绑定,因此用户重新录制 READY 或 FAILED 按键时会失败,尤其 FAILED 按键在此界面没有可用的恢复路径。应根据 learnedSlotMap 对已有失败项调用重试流程,或显式替换现有绑定。

**无法重学** 学习分支不区分槽位是否已有记录,始终调用 `learningCoordinator.learn`。仓库明确拒绝同一 SDK 槽位的重复绑定,因此用户重新录制 READY 或 FAILED 按键时会失败,尤其 FAILED 按键在此界面没有可用的恢复路径。应根据 `learnedSlotMap` 对已有失败项调用重试流程,或显式替换现有绑定。 <!-- pr-agent-dedup: 347060413a4b --> <!-- pr-agent-key-issue-location: 36abc05cf3f2 -->
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(
Outdated
Review

并发冲突

发送信号期间仍可开启学习模式,而学习分支没有检查 sendingKeySlotFlow。此时再次点击按键会让发送和学习请求同时操作同一红外网关及设备,可能导致学习失败或捕获到错误信号。应在两种操作之间实施统一互斥,或在发送完成前禁用学习模式开关。

**并发冲突** 发送信号期间仍可开启学习模式,而学习分支没有检查 `sendingKeySlotFlow`。此时再次点击按键会让发送和学习请求同时操作同一红外网关及设备,可能导致学习失败或捕获到错误信号。应在两种操作之间实施统一互斥,或在发送完成前禁用学习模式开关。 <!-- pr-agent-dedup: 69d5674e86b4 --> <!-- pr-agent-key-issue-location: f2db1b110ebc -->
scope = viewModelScope, scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000), started = SharingStarted.WhileSubscribed(5_000),
initialValue = InfraredRemoteUiState(), initialValue = InfraredRemoteUiState(profileId = profileId),
Outdated
Review

重复发射

发射过程中没有检查已有的 sendingKeySlotFlow,而遥控器按键在显示加载状态时仍可点击。用户连续点击电源键会并发执行多次红外发射,可能导致设备连续切换两次并回到原状态;其中任一任务先结束还会提前清除另一个任务的加载指示。请在 ViewModel 中阻止重复请求,或按请求分别跟踪并禁用相应按键。

**重复发射** 发射过程中没有检查已有的 `sendingKeySlotFlow`,而遥控器按键在显示加载状态时仍可点击。用户连续点击电源键会并发执行多次红外发射,可能导致设备连续切换两次并回到原状态;其中任一任务先结束还会提前清除另一个任务的加载指示。请在 ViewModel 中阻止重复请求,或按请求分别跟踪并禁用相应按键。 <!-- pr-agent-dedup: fede3506d9e3 --> <!-- pr-agent-key-issue-location: d6cc1c91e5b2 -->
) )
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(
Outdated
Review

本地化缺失

多条直接展示给用户的学习状态和错误提示被硬编码为中文,而该功能的其他界面文案支持按 locale 切换。英语等非中文环境执行开启学习、缺少设备或录制按键等流程时仍会显示中文。建议通过资源标识或可本地化的 UI 文本模型传递这些消息;遥控面板中的硬编码标签也应一并迁移。

**本地化缺失** 多条直接展示给用户的学习状态和错误提示被硬编码为中文,而该功能的其他界面文案支持按 locale 切换。英语等非中文环境执行开启学习、缺少设备或录制按键等流程时仍会显示中文。建议通过资源标识或可本地化的 UI 文本模型传递这些消息;遥控面板中的硬编码标签也应一并迁移。 <!-- pr-agent-dedup: 984743d900d6 --> <!-- pr-agent-key-issue-location: ee423d2e68cf -->
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,
Outdated
Review

消息覆盖

“正在录制”消息在启动等待任务之后写入。如果协调器返回已完成的 Deferred,例如 FAKE 模式立即完成,viewModelScope.launch 可能先同步写入成功、失败或 SessionInvalid,随后又被该录制消息覆盖;认证失效时甚至不会触发退出。请在启动任务前设置录制消息。

**消息覆盖** “正在录制”消息在启动等待任务之后写入。如果协调器返回已完成的 `Deferred`,例如 FAKE 模式立即完成,`viewModelScope.launch` 可能先同步写入成功、失败或 `SessionInvalid`,随后又被该录制消息覆盖;认证失效时甚至不会触发退出。请在启动任务前设置录制消息。 <!-- pr-agent-dedup: a62768f2c90c --> <!-- pr-agent-key-issue-location: 4c7d8e5d15f8 -->
) )
@@ -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, "电视-电源", "⏻")
Review

本地化缺失

所有按键的 displayName 都硬编码为中文,并被用于持久化名称和 Snackbar 消息;因此非中文语言环境下仍会显示并保存中文文本,尽管每个按键已经提供了 nameRes。应通过资源解析显示名称,避免将本地化文案固定在模型中。

**本地化缺失** 所有按键的 `displayName` 都硬编码为中文,并被用于持久化名称和 Snackbar 消息;因此非中文语言环境下仍会显示并保存中文文本,尽管每个按键已经提供了 `nameRes`。应通过资源解析显示名称,避免将本地化文案固定在模型中。 <!-- pr-agent-dedup: 40f1e05bb9d8 --> <!-- pr-agent-key-issue-location: c48521f26417 -->
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)) },
Outdated
Review

构建失败

此处通过红外功能模块的 R 引用多个字符串,但本 PR 仅在 android/app 模块中添加了对应资源。应用模块依赖功能模块,因此功能模块编译时无法访问应用资源,这些 R.string.infrared_* 引用会无法解析。请将字符串移到该功能模块的 src/main/res,或放入两者共同依赖的资源模块。

**构建失败** 此处通过红外功能模块的 `R` 引用多个字符串,但本 PR 仅在 `android/app` 模块中添加了对应资源。应用模块依赖功能模块,因此功能模块编译时无法访问应用资源,这些 `R.string.infrared_*` 引用会无法解析。请将字符串移到该功能模块的 `src/main/res`,或放入两者共同依赖的资源模块。 <!-- pr-agent-dedup: 9cd9eff60a4d --> <!-- pr-agent-key-issue-location: 49b89a99e9ef -->
Outdated
Review

构建失败

该功能源码引用的是 feature 模块的 com.flagship.abox.manager.infrared.R,但本 PR 的 infrared_* 字符串资源仅添加在宿主 app 模块。Android library 无法反向访问宿主 app 的资源,因此这些 R.string.infrared_* 引用无法解析,feature 将无法编译。请将资源放入 feature 的 src/main/res,或放入其依赖的共享资源模块。

**构建失败** 该功能源码引用的是 feature 模块的 `com.flagship.abox.manager.infrared.R`,但本 PR 的 `infrared_*` 字符串资源仅添加在宿主 app 模块。Android library 无法反向访问宿主 app 的资源,因此这些 `R.string.infrared_*` 引用无法解析,feature 将无法编译。请将资源放入 feature 的 `src/main/res`,或放入其依赖的共享资源模块。 <!-- pr-agent-dedup: 7e961c69657f --> <!-- pr-agent-key-issue-location: d7f5a2b91813 -->
Outdated
Review

构建失败

该库模块引用自身的 com.flagship.abox.manager.infrared.R.string,但本 PR 只在 android/app 的资源目录中添加或修改了这些红外字符串。消费方 app 的资源不会进入库模块的编译类路径,因此 infrared_title 等资源符号无法解析。请将这些字符串移至 android/feature/infrared/src/main/res。

**构建失败** 该库模块引用自身的 `com.flagship.abox.manager.infrared.R.string`,但本 PR 只在 `android/app` 的资源目录中添加或修改了这些红外字符串。消费方 app 的资源不会进入库模块的编译类路径,因此 `infrared_title` 等资源符号无法解析。请将这些字符串移至 `android/feature/infrared/src/main/res`。 <!-- pr-agent-dedup: 06cb602884ea --> <!-- pr-agent-key-issue-location: 5133200eafc7 -->
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
}
Review

容量回绕

当 1 到 100 的槽位全部被占用时,该方法仍返回已占用的槽位 1,导致新增对话框提供一个必然触发唯一约束失败的默认值。应返回无可用槽位的状态,并禁用新增操作或提示容量已满。

**容量回绕** 当 1 到 100 的槽位全部被占用时,该方法仍返回已占用的槽位 1,导致新增对话框提供一个必然触发唯一约束失败的默认值。应返回无可用槽位的状态,并禁用新增操作或提示容量已满。 <!-- pr-agent-dedup: 8d26af909fc3 --> <!-- pr-agent-key-issue-location: c6973e0768bd -->
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) {
Review

结果丢失

learningCoordinator.learn() 和 retry() 返回的异步结果被直接丢弃。遇到重复槽位、设备离线或认证失效时,不会调用 handleDomainError 或触发会话失效处理;如果新记录尚未插入,列表也不会变化,用户只会看到对话框关闭。应在 viewModelScope 中等待结果并统一处理失败。

**结果丢失** `learningCoordinator.learn()` 和 `retry()` 返回的异步结果被直接丢弃。遇到重复槽位、设备离线或认证失效时,不会调用 `handleDomainError` 或触发会话失效处理;如果新记录尚未插入,列表也不会变化,用户只会看到对话框关闭。应在 `viewModelScope` 中等待结果并统一处理失败。 <!-- pr-agent-dedup: a29431c5b567 --> <!-- pr-agent-key-issue-location: 76371e98dff6 -->
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()
}
}