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
12 changed files with 1248 additions and 17 deletions
Showing only changes of commit 86af7f4c91 - Show all commits
@@ -22,6 +22,7 @@ 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
@@ -114,7 +115,22 @@ 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,
) )
@@ -25,11 +25,13 @@ class InfraredRemoteScreenTest {
savedDeviceName = DeviceName("ir-1"), savedDeviceName = DeviceName("ir-1"),
) )
var clickedSpec: RemoteButtonSpec? = null var clickedSpec: RemoteButtonSpec? = null
var settingsClicked = false
composeTestRule.setContent { composeTestRule.setContent {
InfraredRemoteScreen( InfraredRemoteScreen(
state = state, state = state,
onBack = {}, onBack = {},
onOpenSettings = { settingsClicked = true },
onSessionInvalid = {}, onSessionInvalid = {},
onSceneSelected = {}, onSceneSelected = {},
onToggleLearningMode = {}, onToggleLearningMode = {},
@@ -40,5 +42,8 @@ class InfraredRemoteScreenTest {
composeTestRule.onNodeWithTag("remote_button_AC_POWER").assertIsDisplayed().performClick() composeTestRule.onNodeWithTag("remote_button_AC_POWER").assertIsDisplayed().performClick()
assertEquals("AC_POWER", clickedSpec?.id) assertEquals("AC_POWER", clickedSpec?.id)
composeTestRule.onNodeWithTag("remote_settings_button").assertIsDisplayed().performClick()
assertTrue(settingsClicked)
} }
} }
@@ -0,0 +1,77 @@
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.compose.ui.test.performTextInput
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.ProviderType
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 org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
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 rendersControlScreenAndTriggersKeyOperations() {
val testKey = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = ProviderProfileId("profile1"),
deviceName = DeviceName("ir-1"),
displayName = "Power",
sdkKey = "1",
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 100L,
updatedAtEpochMillis = 100L,
)
val state = InfraredControlUiState(
profileId = ProviderProfileId("profile1"),
providerType = ProviderType.LOCAL_EXPERIENCE,
savedDeviceName = DeviceName("ir-1"),
keys = listOf(testKey),
loading = false,
)
var sendClicked = false
var deleteClicked = false
composeTestRule.setContent {
InfraredControlScreen(
state = state,
onBack = {},
onSessionInvalid = {},
onDeviceNameChanged = {},
onSaveDeviceName = {},
onLearn = { _, _ -> },
onRetry = {},
onSend = { sendClicked = true },
onRename = { _, _ -> },
onDelete = { deleteClicked = true },
onMessageShown = {},
nextSlot = 2,
)
}
composeTestRule.onNodeWithTag("infrared_key_k1").assertIsDisplayed()
composeTestRule.onNodeWithTag("infrared_send_k1").assertIsDisplayed().performClick()
assertTrue(sendClicked)
composeTestRule.onNodeWithTag("infrared_delete_btn_k1").assertIsDisplayed().performClick()
composeTestRule.onNodeWithTag("infrared_delete_confirm").assertIsDisplayed().performClick()
assertTrue(deleteClicked)
}
}
@@ -6,7 +6,10 @@ import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.DeviceName import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository
import com.flagship.abox.manager.domain.infrared.InfraredKeyId import com.flagship.abox.manager.domain.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
@@ -41,6 +44,8 @@ data class InfraredRemoteUiState(
val selectedScene: RemoteScene = RemoteScene.AIR_CONDITIONER, val selectedScene: RemoteScene = RemoteScene.AIR_CONDITIONER,
val isLearningMode: Boolean = false, val isLearningMode: Boolean = false,
val profileId: ProviderProfileId? = null, val profileId: ProviderProfileId? = null,
val providerType: ProviderType? = null,
val providerDisplayName: String? = 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,
@@ -54,6 +59,7 @@ 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 providerProfileStore: ProviderProfileStore,
private val learningCoordinator: InfraredLearningCoordinator, private val learningCoordinator: InfraredLearningCoordinator,
private val sendInfraredKey: SendInfraredKey, private val sendInfraredKey: SendInfraredKey,
) : ViewModel() { ) : ViewModel() {
@@ -77,6 +83,7 @@ class InfraredRemoteViewModel @Inject constructor(
} }
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 -->
private data class InfraredRemoteConfigSnapshot( private data class InfraredRemoteConfigSnapshot(
val providerProfile: ProviderProfile? = null,
val savedDeviceName: DeviceName = DEFAULT_DEVICE_NAME, val savedDeviceName: DeviceName = DEFAULT_DEVICE_NAME,
val learnedSlotMap: Map<String, SavedInfraredKey> = emptyMap(), val learnedSlotMap: Map<String, SavedInfraredKey> = emptyMap(),
val configError: DomainError? = null, val configError: DomainError? = null,
@@ -86,20 +93,27 @@ class InfraredRemoteViewModel @Inject constructor(
if (profileId == null) { if (profileId == null) {
flowOf(InfraredRemoteConfigSnapshot()) flowOf(InfraredRemoteConfigSnapshot())
} else { } else {
providerProfileStore.observeProfiles().flatMapLatest { profiles ->
val active = profiles.find { it.id == profileId }
deviceConfigurationRepository.observeDeviceName(profileId).flatMapLatest { deviceResult -> deviceConfigurationRepository.observeDeviceName(profileId).flatMapLatest { deviceResult ->
when (deviceResult) { when (deviceResult) {
is DomainResult.Failure -> flowOf( is DomainResult.Failure -> flowOf(
InfraredRemoteConfigSnapshot(configError = deviceResult.error), InfraredRemoteConfigSnapshot(
providerProfile = active,
configError = deviceResult.error,
),
) )
is DomainResult.Success -> { is DomainResult.Success -> {
val deviceName = deviceResult.value ?: DEFAULT_DEVICE_NAME val deviceName = deviceResult.value ?: DEFAULT_DEVICE_NAME
keyRepository.observeKeys(profileId, deviceName).map { keysResult -> keyRepository.observeKeys(profileId, deviceName).map { keysResult ->
Outdated
Review

错误吞没

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

**错误吞没** 设备配置和按键观察流中的 `DomainResult.Failure` 分别被转换为“未配置设备”和空按键列表。数据库读取失败时,界面会错误地提示用户配置设备或重新学习按键,而不是报告实际存储错误。应将失败状态传播到 UI,避免把读取故障当成真实的空数据。 <!-- pr-agent-dedup: f7ef39caa7e5 --> <!-- pr-agent-key-issue-location: babb0d2f9517 -->
when (keysResult) { when (keysResult) {
is DomainResult.Failure -> InfraredRemoteConfigSnapshot( is DomainResult.Failure -> InfraredRemoteConfigSnapshot(
providerProfile = active,
savedDeviceName = deviceName, savedDeviceName = deviceName,
configError = keysResult.error, configError = keysResult.error,
) )
is DomainResult.Success -> InfraredRemoteConfigSnapshot( is DomainResult.Success -> InfraredRemoteConfigSnapshot(
providerProfile = active,
savedDeviceName = deviceName, savedDeviceName = deviceName,
learnedSlotMap = keysResult.value.associateBy { it.sdkKey }, learnedSlotMap = keysResult.value.associateBy { it.sdkKey },
) )
@@ -109,6 +123,7 @@ class InfraredRemoteViewModel @Inject constructor(
} }
} }
} }
}
val state: StateFlow<InfraredRemoteUiState> = combine( val state: StateFlow<InfraredRemoteUiState> = combine(
sceneFlow, sceneFlow,
@@ -134,6 +149,8 @@ class InfraredRemoteViewModel @Inject constructor(
selectedScene = scene, selectedScene = scene,
isLearningMode = isLearning, isLearningMode = isLearning,
profileId = profileId, profileId = profileId,
providerType = snapshot.providerProfile?.type,
providerDisplayName = snapshot.providerProfile?.displayName,
savedDeviceName = snapshot.savedDeviceName, savedDeviceName = snapshot.savedDeviceName,
learnedSlotMap = snapshot.learnedSlotMap, learnedSlotMap = snapshot.learnedSlotMap,
sendingKeySlot = sendingSlot, sendingKeySlot = sendingSlot,
@@ -56,6 +56,7 @@ 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(),
) { ) {
@@ -64,6 +65,7 @@ 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,6 +79,7 @@ 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,
@@ -153,6 +156,14 @@ internal fun InfraredRemoteScreen(
), ),
) )
} }
// 按键管理 / 设置入口按钮
IconButton(
onClick = onOpenSettings,
modifier = Modifier.testTag("remote_settings_button"),
) {
Text("⚙️", fontSize = 20.sp)
}
}, },
) )
}, },
@@ -0,0 +1,468 @@
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.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.designsystem.ABoxSurface
import com.flagship.abox.manager.domain.ProviderType
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.remote.UiText
@Composable
fun InfraredControlRoute(
onBack: () -> Unit,
onSessionInvalid: () -> Unit,
viewModel: InfraredControlViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
InfraredControlScreen(
state = state,
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,
onMessageShown = viewModel::clearMessage,
nextSlot = viewModel.nextAvailableKeySlot(),
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun InfraredControlScreen(
state: InfraredControlUiState,
onBack: () -> Unit,
onSessionInvalid: () -> Unit,
onDeviceNameChanged: (String) -> Unit,
onSaveDeviceName: () -> Unit,
onLearn: (name: String, slot: String?) -> Unit,
onRetry: (InfraredKeyId) -> Unit,
onSend: (InfraredKeyId) -> Unit,
onRename: (InfraredKeyId, String) -> Unit,
onDelete: (InfraredKeyId) -> Unit,
onMessageShown: () -> Unit,
nextSlot: Int,
) {
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
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 -> {
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()
}
is InfraredUiMessage.Error -> {
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()
}
is InfraredUiMessage.SessionInvalid -> {
onSessionInvalid()
onMessageShown()
}
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.infrared_control_title)) },
navigationIcon = {
TextButton(onClick = onBack) {
Text(stringResource(R.string.infrared_back))
}
},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
ABoxSurface(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// 环境与设备卡片
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
val envText = when (state.providerType) {
ProviderType.LOCAL_EXPERIENCE -> stringResource(R.string.infrared_mode_local_simulation)
ProviderType.ABOX -> stringResource(R.string.infrared_mode_abox_real)
null -> state.profileId?.value ?: ""
}
Text(
text = stringResource(R.string.infrared_mode_label, envText),
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.primary,
)
Text(
text = stringResource(R.string.infrared_saved_device, state.savedDeviceName.value),
style = MaterialTheme.typography.bodyMedium,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
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"),
)
Button(
onClick = onSaveDeviceName,
modifier = Modifier.testTag("infrared_save_device_button"),
) {
Text(stringResource(R.string.infrared_save_device))
}
}
}
}
// 已学习按键列表卡片
Card(modifier = Modifier.fillMaxWidth().weight(1f)) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.infrared_keys_title),
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
)
Button(
onClick = { showLearnDialog = true },
modifier = Modifier.testTag("infrared_learn_new_key_button"),
) {
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,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
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().testTag("infrared_learn_name_input"),
)
OutlinedTextField(
value = keySlot,
onValueChange = { keySlot = it },
label = { Text(stringResource(R.string.infrared_learn_dialog_slot_label)) },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("infrared_learn_slot_input"),
)
}
},
confirmButton = {
Button(
onClick = {
onLearn(keyName, keySlot)
showLearnDialog = false
},
enabled = keyName.isNotBlank() && keySlot.isNotBlank(),
modifier = Modifier.testTag("infrared_learn_dialog_confirm"),
) {
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().testTag("infrared_rename_input"),
)
},
confirmButton = {
Button(
onClick = {
onRename(targetKey.id, newName)
keyToRename = null
},
enabled = newName.isNotBlank(),
modifier = Modifier.testTag("infrared_rename_confirm"),
) {
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
},
modifier = Modifier.testTag("infrared_delete_confirm"),
) {
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,
modifier = Modifier.testTag("infrared_rename_btn_${key.id.value}"),
) {
Text(stringResource(R.string.infrared_rename))
}
Spacer(modifier = Modifier.width(8.dp))
OutlinedButton(
onClick = onDelete,
modifier = Modifier.testTag("infrared_delete_btn_${key.id.value}"),
) {
Text(stringResource(R.string.infrared_delete))
}
} else {
CircularProgressIndicator(modifier = Modifier.height(24.dp).width(24.dp))
}
}
}
}
}
@@ -0,0 +1,30 @@
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.ProviderType
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.remote.UiText
sealed interface InfraredUiMessage {
data class Info(val text: UiText) : InfraredUiMessage
data class Error(val text: UiText) : InfraredUiMessage
data object SessionInvalid : InfraredUiMessage
}
data class InfraredControlUiState(
val profileId: ProviderProfileId? = null,
val providerType: ProviderType? = null,
val providerDisplayName: String? = null,
val deviceNameInput: String = "",
val savedDeviceName: DeviceName = DeviceName("ir-1"),
val keys: List<SavedInfraredKey> = emptyList(),
val loading: Boolean = true,
val sendingKeyIds: Set<InfraredKeyId> = emptySet(),
val message: InfraredUiMessage? = null,
) {
val hasActiveLearning: Boolean
get() = keys.any { it.status == InfraredKeyStatus.LEARNING }
}
@@ -0,0 +1,307 @@
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.ProviderProfileStore
import com.flagship.abox.manager.domain.infrared.DeleteInfraredKey
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.R
import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator
import com.flagship.abox.manager.infrared.remote.UiText
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.map
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 providerProfileStore: ProviderProfileStore,
private val learningCoordinator: InfraredLearningCoordinator,
private val sendInfraredKey: SendInfraredKey,
private val renameInfraredKey: RenameInfraredKey,
private val deleteInfraredKey: DeleteInfraredKey,
private val recoverInterruptedInfraredLearning: RecoverInterruptedInfraredLearning,
) : ViewModel() {
private val rawProfileId: String? = savedStateHandle["profileId"]
val profileId: 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)
init {
if (profileId != null) {
val startupCutoff = System.currentTimeMillis()
learningCoordinator.failInterruptedLearning(profileId, cutoffEpochMillis = startupCutoff)
}
}
private data class ServerState(
val providerProfile: com.flagship.abox.manager.domain.ProviderProfile? = null,
val savedDevice: DeviceName = DEFAULT_DEVICE_NAME,
val keys: List<SavedInfraredKey> = emptyList(),
val loading: Boolean = false,
val configError: DomainError? = null,
)
private val serverStateFlow: Flow<ServerState> =
if (profileId == null) {
flowOf(ServerState(loading = false))
} else {
providerProfileStore.observeProfiles().flatMapLatest { profiles ->
val active = profiles.find { it.id == profileId }
deviceConfigurationRepository.observeDeviceName(profileId).flatMapLatest { deviceResult ->
when (deviceResult) {
is DomainResult.Failure -> flowOf(
ServerState(
providerProfile = active,
configError = deviceResult.error,
),
)
is DomainResult.Success -> {
val deviceName = deviceResult.value ?: DEFAULT_DEVICE_NAME
keyRepository.observeKeys(profileId, deviceName).map { keysResult ->
when (keysResult) {
is DomainResult.Failure -> ServerState(
providerProfile = active,
savedDevice = deviceName,
configError = keysResult.error,
)
is DomainResult.Success -> ServerState(
providerProfile = active,
savedDevice = deviceName,
keys = keysResult.value,
)
}
}
}
}
}
}
}
val state: StateFlow<InfraredControlUiState> = combine(
serverStateFlow,
deviceNameInputFlow,
sendingKeyIdsFlow,
messageFlow,
) { serverState, input, sendingIds, msg ->
val resolvedMessage = msg ?: serverState.configError?.let { error ->
when (error) {
is DomainError.TokenInvalid, is DomainError.Authentication -> InfraredUiMessage.SessionInvalid
is DomainError.DeviceOffline -> InfraredUiMessage.Error(
UiText.StringResource(R.string.infrared_msg_device_offline, listOf(error.externalCode ?: "20206")),
)
else -> InfraredUiMessage.Error(
error.detail?.let { UiText.DynamicString(it) }
?: UiText.StringResource(R.string.infrared_msg_load_failed),
)
}
}
InfraredControlUiState(
profileId = profileId,
providerType = serverState.providerProfile?.type,
providerDisplayName = serverState.providerProfile?.displayName,
deviceNameInput = input,
savedDeviceName = serverState.savedDevice,
keys = serverState.keys,
loading = serverState.loading,
sendingKeyIds = sendingIds,
message = resolvedMessage,
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = InfraredControlUiState(profileId = profileId, loading = true),
)
fun onDeviceNameChanged(value: String) {
deviceNameInputFlow.value = value
}
fun saveDeviceName() {
val currentProfile = profileId ?: return
val trimmed = deviceNameInputFlow.value.trim()
if (trimmed.isBlank()) {
messageFlow.value = InfraredUiMessage.Error(
UiText.StringResource(R.string.infrared_msg_device_name_blank),
)
return
}
viewModelScope.launch {
when (val result = deviceConfigurationRepository.setDeviceName(currentProfile, DeviceName(trimmed))) {
is DomainResult.Success -> {
messageFlow.value = InfraredUiMessage.Info(
UiText.StringResource(R.string.infrared_msg_device_name_saved),
)
deviceNameInputFlow.value = ""
}
is DomainResult.Failure -> {
handleDomainError(result.error)
}
}
}
}
fun nextAvailableKeySlot(): Int {
val usedSlots = state.value.keys.mapNotNull { it.sdkKey.toIntOrNull() }.toSet()
return (1..100).firstOrNull { it !in usedSlots } ?: 1
}
fun learnKey(displayName: String, keySlotInput: String? = null) {
val currentProfile = profileId ?: return
val savedDevice = state.value.savedDeviceName
val trimmedName = displayName.trim()
if (trimmedName.isBlank()) {
messageFlow.value = InfraredUiMessage.Error(
UiText.StringResource(R.string.infrared_msg_key_name_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(
UiText.StringResource(R.string.infrared_msg_slot_invalid),
)
return
}
slotInt.toString()
} else {
nextAvailableKeySlot().toString()
}
learningCoordinator.learn(currentProfile, savedDevice, trimmedName, slotString)
}
fun retryLearning(keyId: InfraredKeyId) {
val currentProfile = profileId ?: return
learningCoordinator.retry(InfraredKeyRef(currentProfile, keyId))
}
fun sendKey(keyId: InfraredKeyId) {
val currentProfile = profileId ?: 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(
UiText.StringResource(R.string.infrared_msg_key_not_ready),
)
return
}
sendingKeyIdsFlow.value = sendingKeyIdsFlow.value + keyId
viewModelScope.launch {
try {
when (val result = sendInfraredKey(InfraredKeyRef(currentProfile, keyId))) {
is DomainResult.Success -> {
messageFlow.value = InfraredUiMessage.Info(
UiText.StringResource(R.string.infrared_msg_signal_sent, listOf(key.displayName)),
)
}
is DomainResult.Failure -> {
handleDomainError(result.error)
}
}
} finally {
sendingKeyIdsFlow.value = sendingKeyIdsFlow.value - keyId
}
}
}
fun renameKey(keyId: InfraredKeyId, displayName: String) {
val currentProfile = profileId ?: return
val trimmed = displayName.trim()
if (trimmed.isBlank()) {
messageFlow.value = InfraredUiMessage.Error(
UiText.StringResource(R.string.infrared_msg_key_name_blank),
)
return
}
viewModelScope.launch {
when (val result = renameInfraredKey(InfraredKeyRef(currentProfile, keyId), trimmed)) {
is DomainResult.Success -> {
messageFlow.value = InfraredUiMessage.Info(
UiText.StringResource(R.string.infrared_msg_key_renamed),
)
}
is DomainResult.Failure -> {
handleDomainError(result.error)
}
}
}
}
fun deleteKey(keyId: InfraredKeyId) {
val currentProfile = profileId ?: return
viewModelScope.launch {
when (val result = deleteInfraredKey(InfraredKeyRef(currentProfile, keyId))) {
is DomainResult.Success -> {
messageFlow.value = InfraredUiMessage.Info(
UiText.StringResource(R.string.infrared_msg_key_deleted),
)
}
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(
UiText.StringResource(
R.string.infrared_msg_device_offline,
listOf(error.externalCode ?: "20206"),
),
)
}
else -> {
messageFlow.value = InfraredUiMessage.Error(
error.detail?.let { UiText.DynamicString(it) }
?: UiText.StringResource(R.string.infrared_msg_operation_failed),
)
}
}
}
companion object {
val DEFAULT_DEVICE_NAME = DeviceName("ir-1")
}
}
@@ -76,4 +76,15 @@
<string name="infrared_key_tv_home">电视-主页</string> <string name="infrared_key_tv_home">电视-主页</string>
<string name="infrared_key_tv_back">电视-返回</string> <string name="infrared_key_tv_back">电视-返回</string>
<string name="infrared_key_tv_menu">电视-菜单</string> <string name="infrared_key_tv_menu">电视-菜单</string>
<string name="infrared_control_title">红外按键管理</string>
<string name="infrared_mode_label">运行环境:%1$s</string>
<string name="infrared_mode_local_simulation">本地体验(模拟)</string>
<string name="infrared_mode_abox_real">ABox(真实硬件)</string>
<string name="infrared_msg_device_name_saved">设备名称已保存</string>
<string name="infrared_msg_device_name_blank">设备名称不能为空</string>
<string name="infrared_msg_key_name_blank">按键名称不能为空</string>
<string name="infrared_msg_slot_invalid">按键编号必须在 1 到 100 之间</string>
<string name="infrared_msg_key_renamed">按键已重命名</string>
<string name="infrared_msg_key_deleted">按键已从本机删除</string>
<string name="infrared_msg_key_not_ready">按键尚未就绪,无法发射</string>
</resources> </resources>
@@ -76,4 +76,15 @@
<string name="infrared_key_tv_home">TV Home</string> <string name="infrared_key_tv_home">TV Home</string>
<string name="infrared_key_tv_back">TV Back</string> <string name="infrared_key_tv_back">TV Back</string>
<string name="infrared_key_tv_menu">TV Menu</string> <string name="infrared_key_tv_menu">TV Menu</string>
<string name="infrared_control_title">Infrared Key Management</string>
<string name="infrared_mode_label">Environment: %1$s</string>
<string name="infrared_mode_local_simulation">Local Experience (Simulation)</string>
<string name="infrared_mode_abox_real">ABox (Real Hardware)</string>
<string name="infrared_msg_device_name_saved">Device name saved</string>
<string name="infrared_msg_device_name_blank">Device name cannot be blank</string>
<string name="infrared_msg_key_name_blank">Key name cannot be blank</string>
<string name="infrared_msg_slot_invalid">Key slot must be between 1 and 100</string>
<string name="infrared_msg_key_renamed">Key renamed</string>
<string name="infrared_msg_key_deleted">Key removed locally</string>
<string name="infrared_msg_key_not_ready">Key is not ready to send</string>
</resources> </resources>
@@ -42,6 +42,7 @@ class InfraredRemoteViewModelTest {
private val keyRepo = FakeInfraredKeyRepository() private val keyRepo = FakeInfraredKeyRepository()
private val deviceRepo = FakeInfraredDeviceConfigurationRepository() private val deviceRepo = FakeInfraredDeviceConfigurationRepository()
private val profileStore = com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore()
private val gateway = FakeInfraredGateway() private val gateway = FakeInfraredGateway()
private val factory = InfraredKeyFactory({ InfraredKeyId("01J5MOLERABC123") }, clock) private val factory = InfraredKeyFactory({ InfraredKeyId("01J5MOLERABC123") }, clock)
@@ -67,6 +68,7 @@ class InfraredRemoteViewModelTest {
savedStateHandle = SavedStateHandle(mapOf("profileId" to "abox")), savedStateHandle = SavedStateHandle(mapOf("profileId" to "abox")),
keyRepository = keyRepo, keyRepository = keyRepo,
deviceConfigurationRepository = deviceRepo, deviceConfigurationRepository = deviceRepo,
providerProfileStore = profileStore,
learningCoordinator = coordinator, learningCoordinator = coordinator,
sendInfraredKey = send, sendInfraredKey = send,
) )
@@ -0,0 +1,276 @@
package com.flagship.abox.manager.infrared.ui
import androidx.lifecycle.SavedStateHandle
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType
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.R
import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator
import com.flagship.abox.manager.infrared.remote.UiText
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 com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
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.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class InfraredControlViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
private val profileId = ProviderProfileId("profile1")
private val providerProfile = ProviderProfile(
id = profileId,
type = ProviderType.LOCAL_EXPERIENCE,
displayName = "Local Simulation",
)
private val device = DeviceName("ir-living")
private val clock = EpochMillisClock { 10_000L }
private val keyRepo = FakeInfraredKeyRepository()
private val deviceRepo = FakeInfraredDeviceConfigurationRepository()
private val profileStore = FakeProviderProfileStore()
private val gateway = FakeInfraredGateway()
private val factory = InfraredKeyFactory({ InfraredKeyId("key1") }, clock)
private val learn = LearnInfraredKey(keyRepo, gateway, factory, clock)
private val retry = RetryInfraredKeyLearning(keyRepo, gateway, clock)
private val relearn = com.flagship.abox.manager.domain.infrared.RelearnInfraredKey(keyRepo, gateway, clock)
private val send = SendInfraredKey(keyRepo, gateway)
private val rename = RenameInfraredKey(keyRepo, clock)
private val delete = DeleteInfraredKey(keyRepo)
private val recover = RecoverInterruptedInfraredLearning(keyRepo, clock)
private lateinit var coordinator: InfraredLearningCoordinator
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
coordinator = InfraredLearningCoordinator(learn, retry, relearn, keyRepo, testScope)
}
@After
fun teardown() {
Dispatchers.resetMain()
}
private fun createViewModel(): InfraredControlViewModel {
return InfraredControlViewModel(
savedStateHandle = SavedStateHandle(mapOf("profileId" to "profile1")),
keyRepository = keyRepo,
deviceConfigurationRepository = deviceRepo,
providerProfileStore = profileStore,
learningCoordinator = coordinator,
sendInfraredKey = send,
renameInfraredKey = rename,
deleteInfraredKey = delete,
recoverInterruptedInfraredLearning = recover,
)
}
@Test
fun initialStateLoadsKeysAndDeviceNameForProfile() = testScope.runTest {
profileStore.saveProfile(providerProfile)
deviceRepo.setDeviceName(profileId, device)
val readyKey = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = profileId,
deviceName = device,
displayName = "Power",
sdkKey = "1",
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 100L,
updatedAtEpochMillis = 100L,
)
keyRepo.insert(readyKey)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
assertEquals(ProviderType.LOCAL_EXPERIENCE, vm.state.value.providerType)
assertEquals(device, vm.state.value.savedDeviceName)
assertEquals(1, vm.state.value.keys.size)
assertEquals("Power", vm.state.value.keys.first().displayName)
job.cancel()
}
@Test
fun saveDeviceNameUpdatesDeviceConfiguration() = testScope.runTest {
profileStore.saveProfile(providerProfile)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.onDeviceNameChanged("ir-bedroom")
vm.saveDeviceName()
advanceUntilIdle()
assertEquals(DeviceName("ir-bedroom"), vm.state.value.savedDeviceName)
assertEquals(
InfraredUiMessage.Info(UiText.StringResource(R.string.infrared_msg_device_name_saved)),
vm.state.value.message,
)
job.cancel()
}
@Test
fun blankDeviceNameEmitsErrorMessage() = testScope.runTest {
profileStore.saveProfile(providerProfile)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.onDeviceNameChanged(" ")
vm.saveDeviceName()
advanceUntilIdle()
assertEquals(
InfraredUiMessage.Error(UiText.StringResource(R.string.infrared_msg_device_name_blank)),
vm.state.value.message,
)
job.cancel()
}
@Test
fun learnKeyTriggersCoordinator() = testScope.runTest {
profileStore.saveProfile(providerProfile)
deviceRepo.setDeviceName(profileId, device)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.learnKey("Volume Up", "5")
advanceUntilIdle()
assertEquals(1, gateway.learnedKeys.size)
assertEquals("5", gateway.learnedKeys.first().key)
job.cancel()
}
@Test
fun sendKeyTriggersSendInfraredKeyWhenReady() = testScope.runTest {
profileStore.saveProfile(providerProfile)
deviceRepo.setDeviceName(profileId, device)
val readyKey = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = profileId,
deviceName = device,
displayName = "Power",
sdkKey = "1",
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 100L,
updatedAtEpochMillis = 100L,
)
keyRepo.insert(readyKey)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.sendKey(InfraredKeyId("k1"))
advanceUntilIdle()
assertEquals(1, gateway.sentKeys.size)
assertEquals("1", gateway.sentKeys.first().key)
job.cancel()
}
@Test
fun renameKeyUpdatesKeyName() = testScope.runTest {
profileStore.saveProfile(providerProfile)
deviceRepo.setDeviceName(profileId, device)
val readyKey = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = profileId,
deviceName = device,
displayName = "Power",
sdkKey = "1",
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 100L,
updatedAtEpochMillis = 100L,
)
keyRepo.insert(readyKey)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.renameKey(InfraredKeyId("k1"), "Main Power")
advanceUntilIdle()
val updated = (keyRepo.getKey(InfraredKeyRef(profileId, InfraredKeyId("k1"))) as DomainResult.Success).value!!
assertEquals("Main Power", updated.displayName)
job.cancel()
}
@Test
fun deleteKeyRemovesKey() = testScope.runTest {
profileStore.saveProfile(providerProfile)
deviceRepo.setDeviceName(profileId, device)
val readyKey = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = profileId,
deviceName = device,
displayName = "Power",
sdkKey = "1",
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 100L,
updatedAtEpochMillis = 100L,
)
keyRepo.insert(readyKey)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.deleteKey(InfraredKeyId("k1"))
advanceUntilIdle()
val result = (keyRepo.getKey(InfraredKeyRef(profileId, InfraredKeyId("k1"))) as DomainResult.Success).value
assertEquals(null, result)
job.cancel()
}
}