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
2 changed files with 29 additions and 10 deletions
Showing only changes of commit 3561f3dd4a - Show all commits
@@ -100,9 +100,10 @@ private fun AuthenticatedNavHost(
actionInProgress = actionInProgress,
errorMessage = errorMessage,
devicesSlot = {
val encodedProfileId = android.net.Uri.encode(profile.id.value)
DeviceListScreen(
onInfraredClick = {
navController.navigate("infrared_remote/${profile.id.value}")
Outdated
Review

路由失败

profile.id.value 被直接拼接到导航路径中。若提供方返回包含 /、?、# 或 % 的 profile ID,该值会被当作路由结构或编码处理,导致目的地匹配失败或 SavedStateHandle 收到错误参数。请在两个红外路由中使用 Uri.encode,或改用类型安全导航传参。

**路由失败** `profile.id.value` 被直接拼接到导航路径中。若提供方返回包含 `/`、`?`、`#` 或 `%` 的 profile ID,该值会被当作路由结构或编码处理,导致目的地匹配失败或 `SavedStateHandle` 收到错误参数。请在两个红外路由中使用 `Uri.encode`,或改用类型安全导航传参。 <!-- pr-agent-dedup: cc48881b23bf --> <!-- pr-agent-key-issue-location: 18ec65a4c571 -->
navController.navigate("infrared_remote/$encodedProfileId")
},
)
},
@@ -114,10 +115,11 @@ private fun AuthenticatedNavHost(
navArgument("profileId") { type = NavType.StringType },
),
) {
val encodedProfileId = android.net.Uri.encode(profile.id.value)
InfraredRemoteRoute(
onBack = { navController.popBackStack() },
onOpenSettings = {
navController.navigate("infrared_settings/${profile.id.value}")
navController.navigate("infrared_settings/$encodedProfileId")
},
onSessionInvalid = onLogout,
)
@@ -8,6 +8,7 @@ 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.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
@@ -70,6 +71,7 @@ class InfraredRemoteViewModel @Inject constructor(
private val activeLearningSlotFlow = MutableStateFlow<String?>(null)
private val messageFlow = MutableStateFlow<RemoteUiMessage?>(null)
private var activeLearningJob: Job? = null
private var underlyingLearningDeferred: kotlinx.coroutines.Deferred<*>? = null
private val effectiveProfileAndMode: Flow<Pair<ProviderProfileId?, InfraredRuntimeMode>> =
runtimeModeStore.mode.flatMapLatest { mode ->
@@ -151,6 +153,8 @@ class InfraredRemoteViewModel @Inject constructor(
fun toggleLearningMode(enabled: Boolean) {
isLearningModeFlow.value = enabled
if (!enabled) {
Outdated
Review

取消失效

关闭学习模式时取消的只是等待结果的 ViewModel Job,而 learn/retry 返回的任务由独立的应用级 scope 执行;取消 await 不会停止底层学习。用户关闭录制或离开页面后,硬件仍可能继续学习,并且清空 active slot 后重新开启模式可启动另一个并发学习。应提供对 coordinator 底层任务的取消能力,或在任务真正结束前保留忙碌状态并阻止新任务。

**取消失效** 关闭学习模式时取消的只是等待结果的 ViewModel Job,而 `learn`/`retry` 返回的任务由独立的应用级 scope 执行;取消 `await` 不会停止底层学习。用户关闭录制或离开页面后,硬件仍可能继续学习,并且清空 active slot 后重新开启模式可启动另一个并发学习。应提供对 coordinator 底层任务的取消能力,或在任务真正结束前保留忙碌状态并阻止新任务。 <!-- pr-agent-dedup: 42f163fda940 --> <!-- pr-agent-key-issue-location: bc00d351aea6 -->
underlyingLearningDeferred?.cancel()
underlyingLearningDeferred = null
activeLearningJob?.cancel()
activeLearningJob = null
Outdated
Review

取消残留

关闭学习模式会取消底层 Deferred 和等待任务,但此路径没有将已持久化的 LEARNING 记录转为 FAILED 或删除,onCleared 也采用相同方式。如果协调器没有在 CancellationException 下以不可取消方式回滚,用户中途关闭开关或离开页面后,该 slot 会保持 LEARNING,后续学习请求又会被直接忽略。请显式完成取消状态收尾,并补充取消学习的持久化测试。

**取消残留** 关闭学习模式会取消底层 `Deferred` 和等待任务,但此路径没有将已持久化的 `LEARNING` 记录转为 `FAILED` 或删除,`onCleared` 也采用相同方式。如果协调器没有在 `CancellationException` 下以不可取消方式回滚,用户中途关闭开关或离开页面后,该 slot 会保持 `LEARNING`,后续学习请求又会被直接忽略。请显式完成取消状态收尾,并补充取消学习的持久化测试。 <!-- pr-agent-dedup: bc52949c626d --> <!-- pr-agent-key-issue-location: 5e714cca24cf -->
activeLearningSlotFlow.value = null
@@ -168,7 +172,7 @@ class InfraredRemoteViewModel @Inject constructor(
}
if (isLearningModeFlow.value) {
if (activeLearningSlotFlow.value != null) {
if (underlyingLearningDeferred?.isActive == true || activeLearningJob?.isActive == true || 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 -->
val existingKey = state.value.learnedSlotMap[spec.defaultSlot]
@@ -176,17 +180,25 @@ class InfraredRemoteViewModel @Inject constructor(
return
Outdated
Review

并发冲突

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

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

重复发射

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

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

设备错绑

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

**设备错绑** 配置尚未加载时,`savedDeviceName` 为 `null`,这里会直接回退到 `ir-1`,但遥控按键仍可操作。若该配置实际保存了其他设备名,冷启动后快速开启学习并点击按键会将按键绑定到错误设备,配置加载完成后该按键还会从当前列表消失。应在设备配置解析完成前禁用学习操作。 <!-- pr-agent-dedup: 64f51ac400f4 --> <!-- pr-agent-key-issue-location: 40951f9578b1 -->
is DomainResult.Failure -> DomainResult.Failure(res.error)
}
@@ -202,6 +214,9 @@ class InfraredRemoteViewModel @Inject constructor(
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} finally {
if (underlyingLearningDeferred === deferred) {
underlyingLearningDeferred = null
}
if (activeLearningSlotFlow.value == spec.defaultSlot) {
activeLearningSlotFlow.value = null
}
@@ -238,6 +253,8 @@ class InfraredRemoteViewModel @Inject constructor(
override fun onCleared() {
super.onCleared()
underlyingLearningDeferred?.cancel()
underlyingLearningDeferred = null
activeLearningJob?.cancel()
activeLearningJob = null
}