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
10 changed files with 88 additions and 21 deletions
Showing only changes of commit 78dac5519a - Show all commits
@@ -75,5 +75,3 @@ private fun AppSessionUiState.screenKey(): String = when (this) {
is AppSessionUiState.Login -> "login:${profile.id.value}" is AppSessionUiState.Login -> "login:${profile.id.value}"
is AppSessionUiState.Authenticated -> "authenticated:${profile.id.value}" is AppSessionUiState.Authenticated -> "authenticated:${profile.id.value}"
} }
}
@@ -3,7 +3,6 @@ package com.flagship.abox.manager.infrared.ui
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.domain.DeviceName import com.flagship.abox.manager.domain.DeviceName
@@ -13,7 +12,6 @@ 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.runtime.InfraredRuntimeMode import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@@ -36,11 +34,12 @@ class InfraredControlScreenTest {
composeTestRule.setContent { composeTestRule.setContent {
InfraredControlScreen( InfraredControlScreen(
state = state, state = state,
nextSlot = 1,
onBack = {}, onBack = {},
onSessionInvalid = {}, onSessionInvalid = {},
onDeviceNameChanged = {}, onDeviceNameChanged = {},
onSaveDeviceName = {}, onSaveDeviceName = {},
onLearn = {}, onLearn = { _, _ -> },
onRetry = {}, onRetry = {},
onSend = {}, onSend = {},
onRename = { _, _ -> }, onRename = { _, _ -> },
@@ -63,7 +62,7 @@ class InfraredControlScreenTest {
profileId = ProviderProfileId("abox"), profileId = ProviderProfileId("abox"),
deviceName = DeviceName("ir-1"), deviceName = DeviceName("ir-1"),
displayName = "Power Key", displayName = "Power Key",
sdkKey = "app_ir_kready", sdkKey = "1",
status = InfraredKeyStatus.READY, status = InfraredKeyStatus.READY,
lastErrorCode = null, lastErrorCode = null,
lastErrorDetail = null, lastErrorDetail = null,
@@ -75,7 +74,7 @@ class InfraredControlScreenTest {
profileId = ProviderProfileId("abox"), profileId = ProviderProfileId("abox"),
deviceName = DeviceName("ir-1"), deviceName = DeviceName("ir-1"),
displayName = "Volume Up", displayName = "Volume Up",
sdkKey = "app_ir_kfailed", sdkKey = "2",
status = InfraredKeyStatus.FAILED, status = InfraredKeyStatus.FAILED,
lastErrorCode = "20206", lastErrorCode = "20206",
lastErrorDetail = null, lastErrorDetail = null,
@@ -96,11 +95,12 @@ class InfraredControlScreenTest {
composeTestRule.setContent { composeTestRule.setContent {
InfraredControlScreen( InfraredControlScreen(
state = state, state = state,
nextSlot = 3,
onBack = {}, onBack = {},
onSessionInvalid = {}, onSessionInvalid = {},
onDeviceNameChanged = {}, onDeviceNameChanged = {},
onSaveDeviceName = {}, onSaveDeviceName = {},
onLearn = {}, onLearn = { _, _ -> },
onRetry = { retriedKeyId = it }, onRetry = { retriedKeyId = it },
onSend = { sentKeyId = it }, onSend = { sentKeyId = it },
onRename = { _, _ -> }, onRename = { _, _ -> },
@@ -13,9 +13,14 @@ class InfraredLearningCoordinator(
private val retryInfraredKeyLearning: RetryInfraredKeyLearning, private val retryInfraredKeyLearning: RetryInfraredKeyLearning,
private val externalScope: CoroutineScope, private val externalScope: CoroutineScope,
) { ) {
fun learn(profileId: ProviderProfileId, deviceName: DeviceName, displayName: String) { fun learn(
profileId: ProviderProfileId,
deviceName: DeviceName,
displayName: String,
sdkKey: String? = null,
) {
externalScope.launch { externalScope.launch {
learnInfraredKey(profileId, deviceName, displayName) learnInfraredKey(profileId, deviceName, displayName, sdkKey)
} }
} }
@@ -1,6 +1,5 @@
package com.flagship.abox.manager.infrared.ui package com.flagship.abox.manager.infrared.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -58,6 +57,7 @@ fun InfraredControlRoute(
InfraredControlScreen( InfraredControlScreen(
state = state, state = state,
nextSlot = viewModel.nextAvailableKeySlot(),
onBack = onBack, onBack = onBack,
onSessionInvalid = onSessionInvalid, onSessionInvalid = onSessionInvalid,
onDeviceNameChanged = viewModel::onDeviceNameChanged, onDeviceNameChanged = viewModel::onDeviceNameChanged,
@@ -76,11 +76,12 @@ fun InfraredControlRoute(
@Composable @Composable
internal fun InfraredControlScreen( internal fun InfraredControlScreen(
state: InfraredControlUiState, state: InfraredControlUiState,
nextSlot: Int,
onBack: () -> Unit, onBack: () -> Unit,
onSessionInvalid: () -> Unit, onSessionInvalid: () -> Unit,
onDeviceNameChanged: (String) -> Unit, onDeviceNameChanged: (String) -> Unit,
onSaveDeviceName: () -> Unit, onSaveDeviceName: () -> Unit,
onLearn: (String) -> Unit, onLearn: (String, String?) -> Unit,
onRetry: (InfraredKeyId) -> Unit, onRetry: (InfraredKeyId) -> Unit,
onSend: (InfraredKeyId) -> Unit, onSend: (InfraredKeyId) -> Unit,
onRename: (InfraredKeyId, String) -> Unit, onRename: (InfraredKeyId, String) -> Unit,
@@ -265,6 +266,8 @@ internal fun InfraredControlScreen(
if (showLearnDialog) { if (showLearnDialog) {
var keyName by remember { mutableStateOf("") } var keyName by remember { mutableStateOf("") }
var keySlot by remember { mutableStateOf(nextSlot.toString()) }
AlertDialog( AlertDialog(
onDismissRequest = { showLearnDialog = false }, onDismissRequest = { showLearnDialog = false },
title = { Text(stringResource(R.string.infrared_learn_dialog_title)) }, title = { Text(stringResource(R.string.infrared_learn_dialog_title)) },
@@ -278,15 +281,22 @@ internal fun InfraredControlScreen(
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(), 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 = { confirmButton = {
Button( Button(
onClick = { onClick = {
onLearn(keyName) onLearn(keyName, keySlot)
showLearnDialog = false showLearnDialog = false
}, },
enabled = keyName.isNotBlank(), enabled = keyName.isNotBlank() && keySlot.isNotBlank(),
) { ) {
Text(stringResource(R.string.infrared_learn_dialog_start)) Text(stringResource(R.string.infrared_learn_dialog_start))
} }
@@ -387,7 +397,7 @@ private fun InfraredKeyRow(
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
) )
Text( Text(
text = "SDK: ${key.sdkKey}", text = stringResource(R.string.infrared_key_slot_label, key.sdkKey),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -192,7 +192,12 @@ class InfraredControlViewModel @Inject constructor(
} }
} }
fun learnKey(displayName: String) { fun nextAvailableKeySlot(): Int {
val usedSlots = state.value.keys.mapNotNull { it.sdkKey.toIntOrNull() }.toSet()
return (1..100).firstOrNull { it !in usedSlots } ?: 1
}
fun learnKey(displayName: String, keySlotInput: String? = null) {
val currentProfile = state.value.effectiveProfileId ?: return val currentProfile = state.value.effectiveProfileId ?: return
val savedDevice = state.value.savedDeviceName val savedDevice = state.value.savedDeviceName
if (savedDevice == null) { if (savedDevice == null) {
@@ -204,7 +209,17 @@ class InfraredControlViewModel @Inject constructor(
messageFlow.value = InfraredUiMessage.Error("Key name cannot be blank") messageFlow.value = InfraredUiMessage.Error("Key name cannot be blank")
return return
} }
learningCoordinator.learn(currentProfile, savedDevice, trimmedName) 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) { fun retryLearning(keyId: InfraredKeyId) {
@@ -22,6 +22,8 @@
<string name="infrared_learn_dialog_title">学习红外按键</string> <string name="infrared_learn_dialog_title">学习红外按键</string>
<string name="infrared_learn_dialog_prompt">请准备好实体遥控器。点击开始后,请将遥控器对准红外控制器并按下目标按键。</string> <string name="infrared_learn_dialog_prompt">请准备好实体遥控器。点击开始后,请将遥控器对准红外控制器并按下目标按键。</string>
<string name="infrared_learn_dialog_input_label">按键名称(如:电源)</string> <string name="infrared_learn_dialog_input_label">按键名称(如:电源)</string>
<string name="infrared_learn_dialog_slot_label">按键编号(1-100)</string>
<string name="infrared_key_slot_label">按键编号:%1$s</string>
<string name="infrared_learn_dialog_start">开始学习</string> <string name="infrared_learn_dialog_start">开始学习</string>
<string name="infrared_rename_dialog_title">重命名按键</string> <string name="infrared_rename_dialog_title">重命名按键</string>
<string name="infrared_rename_dialog_input_label">新按键名称</string> <string name="infrared_rename_dialog_input_label">新按键名称</string>
@@ -22,6 +22,8 @@
<string name="infrared_learn_dialog_title">Learn Infrared Key</string> <string name="infrared_learn_dialog_title">Learn Infrared Key</string>
<string name="infrared_learn_dialog_prompt">Prepare the physical remote. After starting, point it at the infrared controller and press the target button.</string> <string name="infrared_learn_dialog_prompt">Prepare the physical remote. After starting, point it at the infrared controller and press the target button.</string>
<string name="infrared_learn_dialog_input_label">Key Name (e.g. Power)</string> <string name="infrared_learn_dialog_input_label">Key Name (e.g. Power)</string>
<string name="infrared_learn_dialog_slot_label">Key Slot (1-100)</string>
<string name="infrared_key_slot_label">Key Slot: %1$s</string>
<string name="infrared_learn_dialog_start">Start Learning</string> <string name="infrared_learn_dialog_start">Start Learning</string>
<string name="infrared_rename_dialog_title">Rename Key</string> <string name="infrared_rename_dialog_title">Rename Key</string>
<string name="infrared_rename_dialog_input_label">New Display Name</string> <string name="infrared_rename_dialog_input_label">New Display Name</string>
@@ -21,6 +21,7 @@ class InfraredKeyFactory(
profileId: ProviderProfileId, profileId: ProviderProfileId,
deviceName: DeviceName, deviceName: DeviceName,
displayName: String, displayName: String,
sdkKey: String? = null,
): DomainResult<SavedInfraredKey> { ): DomainResult<SavedInfraredKey> {
val normalizedName = displayName.trim() val normalizedName = displayName.trim()
if ( if (
@@ -39,6 +40,20 @@ class InfraredKeyFactory(
?: return DomainResult.Failure( ?: return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Infrared key id is invalid"), DomainError.InvalidRequest(detail = "Infrared key id is invalid"),
) )
val finalSdkKey = if (sdkKey != null) {
val trimmed = sdkKey.trim()
val intVal = trimmed.toIntOrNull()
if (intVal == null || intVal !in 1..100) {
return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Infrared key slot must be an integer between 1 and 100"),
)
}
intVal.toString()
} else {
"app_ir_$canonicalId"
}
val now = clock.now() val now = clock.now()
return DomainResult.Success( return DomainResult.Success(
SavedInfraredKey( SavedInfraredKey(
@@ -46,7 +61,7 @@ class InfraredKeyFactory(
profileId = profileId, profileId = profileId,
deviceName = deviceName, deviceName = deviceName,
displayName = normalizedName, displayName = normalizedName,
sdkKey = "app_ir_$canonicalId", sdkKey = finalSdkKey,
status = InfraredKeyStatus.LEARNING, status = InfraredKeyStatus.LEARNING,
lastErrorCode = null, lastErrorCode = null,
lastErrorDetail = null, lastErrorDetail = null,
@@ -68,4 +83,4 @@ class InfraredKeyFactory(
"^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$", "^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$",
) )
} }
} }
@@ -16,8 +16,9 @@ class LearnInfraredKey(
profileId: ProviderProfileId, profileId: ProviderProfileId,
deviceName: DeviceName, deviceName: DeviceName,
displayName: String, displayName: String,
sdkKey: String? = null,
): DomainResult<InfraredKeyId> { ): DomainResult<InfraredKeyId> {
val key = when (val result = factory.create(profileId, deviceName, displayName)) { val key = when (val result = factory.create(profileId, deviceName, displayName, sdkKey)) {
is DomainResult.Success -> result.value is DomainResult.Success -> result.value
is DomainResult.Failure -> return result is DomainResult.Failure -> return result
} }
@@ -32,6 +32,25 @@ class InfraredKeyFactoryTest {
assertEquals(result.value.createdAtEpochMillis, result.value.updatedAtEpochMillis) assertEquals(result.value.createdAtEpochMillis, result.value.updatedAtEpochMillis)
} }
@Test
fun createsLearningKeyWithCustomNumericSlot() {
val result = factory.create(
ProviderProfileId("abox"),
DeviceName("ir-1"),
"空调开",
sdkKey = "1",
) as DomainResult.Success
assertEquals("1", result.value.sdkKey)
}
@Test
fun rejectsInvalidNumericSlots() {
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1"), "Power", sdkKey = "0") is DomainResult.Failure)
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1"), "Power", sdkKey = "101") is DomainResult.Failure)
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1"), "Power", sdkKey = "abc") is DomainResult.Failure)
}
@Test @Test
fun rejectsBlankDeviceOrDisplayName() { fun rejectsBlankDeviceOrDisplayName() {
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName(""), "Power") is DomainResult.Failure) assertTrue(factory.create(ProviderProfileId("abox"), DeviceName(""), "Power") is DomainResult.Failure)
@@ -67,4 +86,4 @@ class InfraredKeyFactoryTest {
DeviceName("ir-1"), DeviceName("ir-1"),
"Power", "Power",
) )
} }