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
9 changed files with 89 additions and 12 deletions
Showing only changes of commit 2185e73f5e - Show all commits
@@ -139,6 +139,25 @@ class RoomInfraredKeyRepositoryTest {
assertEquals(InfraredKeyStatus.READY, k3.status) assertEquals(InfraredKeyStatus.READY, k3.status)
} }
@Test
fun failInterruptedLearningRespectsCutoffEpochMillis() = runTest {
repository.insert(testKey(id = "old_k", sdkKey = "old_k", status = InfraredKeyStatus.LEARNING).copy(createdAtEpochMillis = 100L))
repository.insert(testKey(id = "new_k", sdkKey = "new_k", status = InfraredKeyStatus.LEARNING).copy(createdAtEpochMillis = 200L))
val countResult = repository.failInterruptedLearning(
profileId = ProviderProfileId("profile1"),
cutoffEpochMillis = 150L,
updatedAtEpochMillis = 150L,
) as DomainResult.Success
assertEquals(1, countResult.value)
val oldK = (repository.getKey(InfraredKeyRef(ProviderProfileId("profile1"), InfraredKeyId("old_k"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.FAILED, oldK.status)
val newK = (repository.getKey(InfraredKeyRef(ProviderProfileId("profile1"), InfraredKeyId("new_k"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.LEARNING, newK.status)
}
@Test @Test
fun persistsAndObservesDeviceName() = runTest { fun persistsAndObservesDeviceName() = runTest {
val initial = repository.observeDeviceName(ProviderProfileId("profile1")).first() as DomainResult.Success val initial = repository.observeDeviceName(ProviderProfileId("profile1")).first() as DomainResult.Success
@@ -39,9 +39,17 @@ class InfraredLearningCoordinator(
relearnInfraredKey(ref) relearnInfraredKey(ref)
} }
fun failInterruptedLearning(profileId: ProviderProfileId, updatedAtEpochMillis: Long = System.currentTimeMillis()) { fun failInterruptedLearning(
Outdated
Review

竞态条件

failInterruptedLearning 启动脱离调用者的协程后立即返回。关闭学习模式后,用户可以马上重新开启并对同一旧按键执行 relearn;尚未完成的清理仍可能按旧按键的创建时间命中该记录,在新学习开始后将其改为 FAILED。建议返回并等待清理任务,或在清理完成前通过互斥和状态控制阻止新的学习操作。

**竞态条件** `failInterruptedLearning` 启动脱离调用者的协程后立即返回。关闭学习模式后,用户可以马上重新开启并对同一旧按键执行 `relearn`;尚未完成的清理仍可能按旧按键的创建时间命中该记录,在新学习开始后将其改为 `FAILED`。建议返回并等待清理任务,或在清理完成前通过互斥和状态控制阻止新的学习操作。 <!-- pr-agent-dedup: 8b20f5d5f95c --> <!-- pr-agent-key-issue-location: 6978a0e3566e -->
profileId: ProviderProfileId,
cutoffEpochMillis: Long = System.currentTimeMillis(),
updatedAtEpochMillis: Long = cutoffEpochMillis,
) {
externalScope.launch(NonCancellable) { externalScope.launch(NonCancellable) {
keyRepository.failInterruptedLearning(profileId, updatedAtEpochMillis) keyRepository.failInterruptedLearning(
profileId = profileId,
cutoffEpochMillis = cutoffEpochMillis,
updatedAtEpochMillis = updatedAtEpochMillis,
)
} }
} }
} }
@@ -81,13 +81,16 @@ interface InfraredDao {
lastErrorCode = NULL, lastErrorCode = NULL,
lastErrorDetail = :errorDetail, lastErrorDetail = :errorDetail,
updatedAtEpochMillis = :updatedAtEpochMillis updatedAtEpochMillis = :updatedAtEpochMillis
WHERE profileId = :profileId AND status = 'LEARNING' WHERE profileId = :profileId
AND status = 'LEARNING'
AND createdAtEpochMillis <= :cutoffEpochMillis
""", """,
) )
suspend fun failInterruptedLearning( suspend fun failInterruptedLearning(
profileId: String, profileId: String,
targetStatus: String, targetStatus: String,
errorDetail: String, errorDetail: String,
cutoffEpochMillis: Long,
updatedAtEpochMillis: Long, updatedAtEpochMillis: Long,
): Int ): Int
@@ -11,6 +11,7 @@ import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository 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 kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@@ -147,6 +148,7 @@ class RoomInfraredKeyRepository(
override suspend fun failInterruptedLearning( override suspend fun failInterruptedLearning(
profileId: ProviderProfileId, profileId: ProviderProfileId,
cutoffEpochMillis: Long,
updatedAtEpochMillis: Long, updatedAtEpochMillis: Long,
): DomainResult<Int> { ): DomainResult<Int> {
return try { return try {
@@ -154,10 +156,14 @@ class RoomInfraredKeyRepository(
profileId = profileId.value, profileId = profileId.value,
targetStatus = InfraredKeyStatus.FAILED.name, targetStatus = InfraredKeyStatus.FAILED.name,
errorDetail = INTERRUPTED_LEARNING_ERROR_DETAIL, errorDetail = INTERRUPTED_LEARNING_ERROR_DETAIL,
cutoffEpochMillis = cutoffEpochMillis,
updatedAtEpochMillis = updatedAtEpochMillis, updatedAtEpochMillis = updatedAtEpochMillis,
) )
DomainResult.Success(count) DomainResult.Success(count)
} catch (throwable: Throwable) { } catch (throwable: Throwable) {
if (throwable is CancellationException) {
throw throwable
}
DomainResult.Failure(mapThrowableToDomainError(throwable)) DomainResult.Failure(mapThrowableToDomainError(throwable))
} }
} }
@@ -77,11 +77,12 @@ class InfraredRemoteViewModel @Inject constructor(
init { init {
if (authenticatedProfileId != null) { if (authenticatedProfileId != 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 { viewModelScope.launch {
val startupCutoff = System.currentTimeMillis()
runtimeModeStore.mode.collect { mode -> runtimeModeStore.mode.collect { mode ->
val effective = profileResolver.resolve(authenticatedProfileId, mode) val effective = profileResolver.resolve(authenticatedProfileId, mode)
learningCoordinator.failInterruptedLearning(effective) learningCoordinator.failInterruptedLearning(effective, cutoffEpochMillis = startupCutoff)
if (effective != authenticatedProfileId) { if (effective != authenticatedProfileId) {
learningCoordinator.failInterruptedLearning(authenticatedProfileId) learningCoordinator.failInterruptedLearning(authenticatedProfileId, cutoffEpochMillis = startupCutoff)
} }
} }
} }
@@ -185,13 +186,14 @@ class InfraredRemoteViewModel @Inject constructor(
fun toggleLearningMode(enabled: Boolean) { fun toggleLearningMode(enabled: Boolean) {
isLearningModeFlow.value = enabled isLearningModeFlow.value = enabled
if (!enabled) { if (!enabled) {
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 -->
val shutdownCutoff = System.currentTimeMillis()
underlyingLearningDeferred?.cancel() underlyingLearningDeferred?.cancel()
underlyingLearningDeferred = null underlyingLearningDeferred = null
activeLearningJob?.cancel() activeLearningJob?.cancel()
Review

设备错绑

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

**设备错绑** 配置尚未加载时,`savedDeviceName` 为 `null`,这里会直接回退到 `ir-1`,但遥控按键仍可操作。若该配置实际保存了其他设备名,冷启动后快速开启学习并点击按键会将按键绑定到错误设备,配置加载完成后该按键还会从当前列表消失。应在设备配置解析完成前禁用学习操作。 <!-- pr-agent-dedup: 64f51ac400f4 --> <!-- pr-agent-key-issue-location: 40951f9578b1 -->
activeLearningJob = null activeLearningJob = null
activeLearningSlotFlow.value = null activeLearningSlotFlow.value = null
state.value.effectiveProfileId?.let { profile -> state.value.effectiveProfileId?.let { profile ->
learningCoordinator.failInterruptedLearning(profile) learningCoordinator.failInterruptedLearning(profile, cutoffEpochMillis = shutdownCutoff)
} }
} else { } else {
messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制") messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制")
@@ -285,12 +287,13 @@ class InfraredRemoteViewModel @Inject constructor(
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
val teardownCutoff = System.currentTimeMillis()
underlyingLearningDeferred?.cancel() underlyingLearningDeferred?.cancel()
underlyingLearningDeferred = null underlyingLearningDeferred = null
activeLearningJob?.cancel() activeLearningJob?.cancel()
activeLearningJob = null activeLearningJob = null
state.value.effectiveProfileId?.let { profile -> state.value.effectiveProfileId?.let { profile ->
learningCoordinator.failInterruptedLearning(profile) learningCoordinator.failInterruptedLearning(profile, cutoffEpochMillis = teardownCutoff)
} }
} }
@@ -33,12 +33,13 @@ interface InfraredKeyRepository {
suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult<Unit> suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult<Unit>
/** /**
* Atomically marks this profile's interrupted LEARNING keys as FAILED, clears the vendor * Atomically marks this profile's interrupted LEARNING keys created on or before [cutoffEpochMillis]
* error code, stores [INTERRUPTED_LEARNING_ERROR_DETAIL], and updates their timestamp. * as FAILED, clears the vendor error code, stores [INTERRUPTED_LEARNING_ERROR_DETAIL], and updates their timestamp.
*/ */
suspend fun failInterruptedLearning( suspend fun failInterruptedLearning(
profileId: ProviderProfileId, profileId: ProviderProfileId,
updatedAtEpochMillis: Long, cutoffEpochMillis: Long = Long.MAX_VALUE,
updatedAtEpochMillis: Long = cutoffEpochMillis,
): DomainResult<Int> ): DomainResult<Int>
} }
@@ -159,13 +159,14 @@ class InMemoryInfraredKeyRepository : InfraredKeyRepository {
override suspend fun failInterruptedLearning( override suspend fun failInterruptedLearning(
profileId: ProviderProfileId, profileId: ProviderProfileId,
cutoffEpochMillis: Long,
updatedAtEpochMillis: Long, updatedAtEpochMillis: Long,
): DomainResult<Int> { ): DomainResult<Int> {
recoveryCalls += InfraredRecoveryCall(profileId, updatedAtEpochMillis) recoveryCalls += InfraredRecoveryCall(profileId, updatedAtEpochMillis)
recoveryError?.let { return DomainResult.Failure(it) } recoveryError?.let { return DomainResult.Failure(it) }
var changed = 0 var changed = 0
keys.replaceAll { _, key -> keys.replaceAll { _, key ->
if (key.profileId == profileId && key.status == InfraredKeyStatus.LEARNING) { if (key.profileId == profileId && key.status == InfraredKeyStatus.LEARNING && key.createdAtEpochMillis <= cutoffEpochMillis) {
changed += 1 changed += 1
key.copy( key.copy(
status = InfraredKeyStatus.FAILED, status = InfraredKeyStatus.FAILED,
@@ -100,11 +100,12 @@ class FakeInfraredKeyRepository : InfraredKeyRepository {
override suspend fun failInterruptedLearning( override suspend fun failInterruptedLearning(
profileId: ProviderProfileId, profileId: ProviderProfileId,
cutoffEpochMillis: Long,
updatedAtEpochMillis: Long, updatedAtEpochMillis: Long,
): DomainResult<Int> = synchronized(lock) { ): DomainResult<Int> = synchronized(lock) {
var changed = 0 var changed = 0
keys.replaceAll { _, key -> keys.replaceAll { _, key ->
if (key.profileId == profileId && key.status == InfraredKeyStatus.LEARNING) { if (key.profileId == profileId && key.status == InfraredKeyStatus.LEARNING && key.createdAtEpochMillis <= cutoffEpochMillis) {
changed += 1 changed += 1
key.copy( key.copy(
status = InfraredKeyStatus.FAILED, status = InfraredKeyStatus.FAILED,
@@ -207,6 +207,41 @@ class FakeInfraredKeyRepositoryTest {
) )
} }
@Test
fun failInterruptedLearningRespectsCutoffEpochMillis() = runTest {
val repository = FakeInfraredKeyRepository()
repository.insert(
savedKey(
id = "old-learning",
sdkKey = "old-learning",
status = InfraredKeyStatus.LEARNING,
).copy(createdAtEpochMillis = 100L),
)
repository.insert(
savedKey(
id = "new-learning",
sdkKey = "new-learning",
status = InfraredKeyStatus.LEARNING,
).copy(createdAtEpochMillis = 200L),
)
val result = repository.failInterruptedLearning(
profileId = profileOne,
cutoffEpochMillis = 150L,
updatedAtEpochMillis = 150L,
)
assertEquals(DomainResult.Success(1), result)
assertEquals(
InfraredKeyStatus.FAILED,
repository.successfulKey(InfraredKeyRef(profileOne, InfraredKeyId("old-learning")))?.status,
)
assertEquals(
InfraredKeyStatus.LEARNING,
repository.successfulKey(InfraredKeyRef(profileOne, InfraredKeyId("new-learning")))?.status,
)
}
@Test @Test
fun deviceConfigurationIsIsolatedByProfile() = runTest { fun deviceConfigurationIsIsolatedByProfile() = runTest {
val repository = FakeInfraredDeviceConfigurationRepository() val repository = FakeInfraredDeviceConfigurationRepository()