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
6 changed files with 99 additions and 24 deletions
Showing only changes of commit 8682f81cb8 - Show all commits
@@ -1,13 +1,15 @@
package com.flagship.abox.manager.infrared.application
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
import com.flagship.abox.manager.domain.infrared.LearnInfraredKey
import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
class InfraredLearningCoordinator(
private val learnInfraredKey: LearnInfraredKey,
@@ -19,11 +21,11 @@ class InfraredLearningCoordinator(
deviceName: DeviceName,
displayName: String,
sdkKey: String? = null,
): Job = externalScope.launch {
): Deferred<DomainResult<InfraredKeyId>> = externalScope.async {
learnInfraredKey(profileId, deviceName, displayName, sdkKey)
}
fun retry(ref: InfraredKeyRef): Job = externalScope.launch {
fun retry(ref: InfraredKeyRef): Deferred<DomainResult<Unit>> = externalScope.async {
retryInfraredKeyLearning(ref)
}
}
@@ -171,27 +171,43 @@ class InfraredRemoteViewModel @Inject constructor(
if (activeLearningSlotFlow.value != null) {
return
}
Outdated
Review

设备竞态

savedDeviceName 尚未从持久化配置加载时会直接回退到 DEFAULT_DEVICE_NAME。如果用户快速开启学习模式并点击按键,而实际配置的是其他设备,学习请求会发送给 ir-1,并产生不会显示在当前设备面板中的错误键记录。应在配置成功加载前禁用操作或直接返回,而不是把“尚未加载”解释为默认设备。

**设备竞态** `savedDeviceName` 尚未从持久化配置加载时会直接回退到 `DEFAULT_DEVICE_NAME`。如果用户快速开启学习模式并点击按键,而实际配置的是其他设备,学习请求会发送给 `ir-1`,并产生不会显示在当前设备面板中的错误键记录。应在配置成功加载前禁用操作或直接返回,而不是把“尚未加载”解释为默认设备。 <!-- pr-agent-dedup: 20ea2fda4715 --> <!-- pr-agent-key-issue-location: 702140543860 -->
activeLearningSlotFlow.value = spec.defaultSlot
val existingKey = state.value.learnedSlotMap[spec.defaultSlot]
Outdated
Review

重学失败

existingKey 只要非空就会调用 learningCoordinator.retry,但该重试路径用于失败按键,而学习模式也允许点击已经处于 READY 状态的按键。用户重新录制已学习按键时会因状态转换不合法而失败;应提供明确的重新学习或覆盖路径,或者禁止点击已学习按键。

**重学失败** `existingKey` 只要非空就会调用 `learningCoordinator.retry`,但该重试路径用于失败按键,而学习模式也允许点击已经处于 `READY` 状态的按键。用户重新录制已学习按键时会因状态转换不合法而失败;应提供明确的重新学习或覆盖路径,或者禁止点击已学习按键。 <!-- pr-agent-dedup: 365d84abc324 --> <!-- pr-agent-key-issue-location: 6a2d13d13315 -->
Outdated
Review

无法重录

只要槽位已存在就调用 learningCoordinator.retry,其中也包括状态为 READY 的已学习按键。但重试流程用于失败按键,设置页也只对 FAILED 状态提供重试,因此用户在学习模式下点击已学习按键以更新红外码时会被状态校验拒绝。应仅对失败按键调用重试,并为已就绪按键提供明确的重新学习或覆盖流程。

**无法重录** 只要槽位已存在就调用 `learningCoordinator.retry`,其中也包括状态为 `READY` 的已学习按键。但重试流程用于失败按键,设置页也只对 `FAILED` 状态提供重试,因此用户在学习模式下点击已学习按键以更新红外码时会被状态校验拒绝。应仅对失败按键调用重试,并为已就绪按键提供明确的重新学习或覆盖流程。 <!-- pr-agent-dedup: 6de2ba8f0bdf --> <!-- pr-agent-key-issue-location: b2730b9541cd -->
val job = if (existingKey != null) {
Outdated
Review

并发冲突

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

**并发冲突** 发送信号期间仍可开启学习模式,而学习分支没有检查 `sendingKeySlotFlow`。此时再次点击按键会让发送和学习请求同时操作同一红外网关及设备,可能导致学习失败或捕获到错误信号。应在两种操作之间实施统一互斥,或在发送完成前禁用学习模式开关。 <!-- pr-agent-dedup: 69d5674e86b4 --> <!-- pr-agent-key-issue-location: f2db1b110ebc -->
learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id))
if (existingKey?.status == InfraredKeyStatus.LEARNING) {
return
}
activeLearningSlotFlow.value = spec.defaultSlot
val job = viewModelScope.launch {
try {
val result = if (existingKey != null) {
learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id)).await()
} else {
learningCoordinator.learn(
Outdated
Review

重复发射

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

**重复发射** 发射过程中没有检查已有的 `sendingKeySlotFlow`,而遥控器按键在显示加载状态时仍可点击。用户连续点击电源键会并发执行多次红外发射,可能导致设备连续切换两次并回到原状态;其中任一任务先结束还会提前清除另一个任务的加载指示。请在 ViewModel 中阻止重复请求,或按请求分别跟踪并禁用相应按键。 <!-- pr-agent-dedup: fede3506d9e3 --> <!-- pr-agent-key-issue-location: d6cc1c91e5b2 -->
when (val res = learningCoordinator.learn(
profileId = currentProfile,
deviceName = savedDevice,
displayName = spec.displayName,
sdkKey = spec.defaultSlot,
)
).await()) {
is DomainResult.Success -> DomainResult.Success(Unit)
is DomainResult.Failure -> DomainResult.Failure(res.error)
}
activeLearningJob = job
job.invokeOnCompletion {
Outdated
Review

错误丢失

学习任务的完成回调只清理加载状态,没有读取或传播学习结果,并且 handleDomainError 仅用于发送操作。设备离线或认证失效导致学习失败时,界面不会显示失败原因,认证失效也不会触发 onSessionInvalid;应由协调器返回或发布结果,并将错误映射为对应的 RemoteUiMessage。

**错误丢失** 学习任务的完成回调只清理加载状态,没有读取或传播学习结果,并且 `handleDomainError` 仅用于发送操作。设备离线或认证失效导致学习失败时,界面不会显示失败原因,认证失效也不会触发 `onSessionInvalid`;应由协调器返回或发布结果,并将错误映射为对应的 `RemoteUiMessage`。 <!-- pr-agent-dedup: 16b03ab5c738 --> <!-- pr-agent-key-issue-location: 392b62285515 -->
Outdated
Review

竞态条件

关闭学习模式时会异步触发按 profile 清理全部 LEARNING 记录,但界面立即允许重新开启学习。若用户快速重新开启并开始新任务,尚未执行的清理可能把新任务也标记为 FAILED,导致随后成功的学习无法转换为 READY;初始化和 onCleared 中也存在同类风险。应等待清理完成后再允许新任务,或使用时间戳/任务标识仅清理旧记录。

**竞态条件** 关闭学习模式时会异步触发按 profile 清理全部 `LEARNING` 记录,但界面立即允许重新开启学习。若用户快速重新开启并开始新任务,尚未执行的清理可能把新任务也标记为 `FAILED`,导致随后成功的学习无法转换为 `READY`;初始化和 `onCleared` 中也存在同类风险。应等待清理完成后再允许新任务,或使用时间戳/任务标识仅清理旧记录。 <!-- pr-agent-dedup: 943572f8ab5b --> <!-- pr-agent-key-issue-location: e2acde597e7e -->
if (activeLearningJob === job) {
activeLearningJob = null
}
when (result) {
is DomainResult.Success -> {
messageFlow.value = RemoteUiMessage.Info("【${spec.displayName}】录制成功")
}
is DomainResult.Failure -> {
handleDomainError(result.error)
}
}
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} finally {
if (activeLearningSlotFlow.value == spec.defaultSlot) {
Review

设备错绑

配置尚未加载时,savedDeviceName 为 null,这里会直接回退到 ir-1,但遥控按键仍可操作。若该配置实际保存了其他设备名,冷启动后快速开启学习并点击按键会将按键绑定到错误设备,配置加载完成后该按键还会从当前列表消失。应在设备配置解析完成前禁用学习操作。

**设备错绑** 配置尚未加载时,`savedDeviceName` 为 `null`,这里会直接回退到 `ir-1`,但遥控按键仍可操作。若该配置实际保存了其他设备名,冷启动后快速开启学习并点击按键会将按键绑定到错误设备,配置加载完成后该按键还会从当前列表消失。应在设备配置解析完成前禁用学习操作。 <!-- pr-agent-dedup: 64f51ac400f4 --> <!-- pr-agent-key-issue-location: 40951f9578b1 -->
activeLearningSlotFlow.value = null
}
}
}
activeLearningJob = job
messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...")
} else {
if (sendingKeySlotFlow.value != null) {
@@ -37,12 +37,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.designsystem.ABoxSurface
import com.flagship.abox.manager.infrared.R
import com.flagship.abox.manager.infrared.remote.InfraredRemoteUiState
import com.flagship.abox.manager.infrared.remote.InfraredRemoteViewModel
import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec
@@ -108,13 +110,13 @@ internal fun InfraredRemoteScreen(
TopAppBar(
title = {
Text(
text = "红外遥控器",
Outdated
Review

本地化缺失

遥控器标题、返回按钮以及该页面的其他提示和场景标签都直接写死为中文。应用运行在非中文区域设置下仍会显示中文,也无法利用本次新增的字符串资源;应统一改用 stringResource。

**本地化缺失** 遥控器标题、返回按钮以及该页面的其他提示和场景标签都直接写死为中文。应用运行在非中文区域设置下仍会显示中文,也无法利用本次新增的字符串资源;应统一改用 `stringResource`。 <!-- pr-agent-dedup: 362d988ef914 --> <!-- pr-agent-key-issue-location: 132ce4939c22 -->
text = stringResource(R.string.infrared_remote_title),
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
)
},
navigationIcon = {
TextButton(onClick = onBack) {
Text("返回")
Text(stringResource(R.string.infrared_back))
}
},
actions = {
@@ -124,7 +126,11 @@ internal fun InfraredRemoteScreen(
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = if (state.isLearningMode) "录制中" else "学习模式",
text = if (state.isLearningMode) {
stringResource(R.string.infrared_remote_recording)
} else {
stringResource(R.string.infrared_remote_learning_mode)
},
fontSize = 12.sp,
fontWeight = if (state.isLearningMode) FontWeight.Bold else FontWeight.Normal,
color = if (state.isLearningMode) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface,
@@ -178,7 +184,7 @@ internal fun InfraredRemoteScreen(
contentAlignment = Alignment.Center,
) {
Text(
text = "🔴 红外学习模式已开启:点击任意按键即可录制信号",
text = stringResource(R.string.infrared_remote_learning_banner),
style = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onErrorContainer,
fontWeight = FontWeight.SemiBold,
@@ -196,7 +202,7 @@ internal fun InfraredRemoteScreen(
FilterChip(
selected = state.selectedScene == RemoteScene.AIR_CONDITIONER,
onClick = { onSceneSelected(RemoteScene.AIR_CONDITIONER) },
label = { Text("❄️ 空调遥控") },
label = { Text(stringResource(R.string.infrared_remote_scene_ac)) },
modifier = Modifier
.padding(end = 8.dp)
.testTag("scene_tab_ac"),
@@ -208,7 +214,7 @@ internal fun InfraredRemoteScreen(
FilterChip(
selected = state.selectedScene == RemoteScene.TELEVISION,
onClick = { onSceneSelected(RemoteScene.TELEVISION) },
label = { Text("📺 电视遥控") },
label = { Text(stringResource(R.string.infrared_remote_scene_tv)) },
modifier = Modifier.testTag("scene_tab_tv"),
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
@@ -32,4 +32,11 @@
<string name="infrared_dialog_cancel">取消</string>
<string name="infrared_dialog_confirm">确认</string>
<string name="infrared_back">返回</string>
<string name="infrared_remote_title">红外遥控器</string>
<string name="infrared_remote_recording">录制中</string>
<string name="infrared_remote_learning_mode">学习模式</string>
<string name="infrared_remote_learning_banner">🔴 红外学习模式已开启:点击任意按键即可录制信号</string>
<string name="infrared_remote_scene_ac">❄️ 空调遥控</string>
<string name="infrared_remote_scene_tv">📺 电视遥控</string>
<string name="infrared_remote_settings">设置</string>
</resources>
@@ -32,4 +32,11 @@
<string name="infrared_dialog_cancel">Cancel</string>
<string name="infrared_dialog_confirm">Confirm</string>
<string name="infrared_back">Back</string>
<string name="infrared_remote_title">Infrared Remote</string>
<string name="infrared_remote_recording">Recording</string>
<string name="infrared_remote_learning_mode">Learn Mode</string>
<string name="infrared_remote_learning_banner">🔴 Learn mode active: tap any button to record</string>
<string name="infrared_remote_scene_ac">❄️ Air Conditioner</string>
<string name="infrared_remote_scene_tv">📺 Television</string>
<string name="infrared_remote_settings">Settings</string>
</resources>
@@ -213,6 +213,43 @@ class InfraredRemoteViewModelTest {
assertEquals("1", gateway.learnedKeys.first().key)
val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(fakeProfile, InfraredKeyId("k1"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.READY, updated.status)
assertEquals(RemoteUiMessage.Info("【空调-电源】录制成功"), vm.state.value.message)
job.cancel()
}
@Test
fun learningFailurePropagatesErrorMessage() = testScope.runTest {
deviceRepo.setDeviceName(fakeProfile, device)
gateway.learnResult = DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.DeviceOffline(externalCode = "20206"))
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.toggleLearningMode(true)
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
advanceUntilIdle()
assertEquals(RemoteUiMessage.Error("红外控制器离线 (错误码: 20206)"), vm.state.value.message)
job.cancel()
}
@Test
fun learningSessionInvalidPropagatesSessionInvalidMessage() = testScope.runTest {
deviceRepo.setDeviceName(fakeProfile, device)
gateway.learnResult = DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.TokenInvalid(detail = "Session expired"))
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.toggleLearningMode(true)
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
advanceUntilIdle()
assertEquals(RemoteUiMessage.SessionInvalid, vm.state.value.message)
job.cancel()
}