From a3bcdd973c76f8bcaa671794c3d3387b43caebd9 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Tue, 25 Aug 2026 12:14:12 +0800 Subject: [PATCH] fix(infrared): support re-learning existing keys, cancel active learning jobs, and propagate CancellationException --- .../data/local/RoomInfraredKeyRepository.kt | 5 +++ .../remote/InfraredRemoteViewModel.kt | 35 ++++++++++++++----- .../DataStoreInfraredRuntimeModeStore.kt | 1 + .../remote/InfraredRemoteViewModelTest.kt | 33 +++++++++++++++++ 4 files changed, 66 insertions(+), 8 deletions(-) diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/RoomInfraredKeyRepository.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/RoomInfraredKeyRepository.kt index 79eb56b..e6f52ef 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/RoomInfraredKeyRepository.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/RoomInfraredKeyRepository.kt @@ -28,6 +28,7 @@ class RoomInfraredKeyRepository( DomainResult.Success(entities.map { it.toDomain() }) } .catch { throwable -> + if (throwable is kotlinx.coroutines.CancellationException) throw throwable emit(DomainResult.Failure(mapThrowableToDomainError(throwable))) } } @@ -38,6 +39,7 @@ class RoomInfraredKeyRepository( DomainResult.Success(entity?.deviceName?.let { DeviceName(it) }) } .catch { throwable -> + if (throwable is kotlinx.coroutines.CancellationException) throw throwable emit(DomainResult.Failure(mapThrowableToDomainError(throwable))) } } @@ -182,6 +184,9 @@ class RoomInfraredKeyRepository( } private fun mapThrowableToDomainError(throwable: Throwable): DomainError { + if (throwable is kotlinx.coroutines.CancellationException) { + throw throwable + } return when (throwable) { is SQLiteConstraintException -> DomainError.InvalidRequest(detail = "Database constraint violation") else -> DomainError.Unknown(detail = "Database operation failed") diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt index a0cdefb..b2b6439 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt @@ -19,6 +19,7 @@ import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -68,6 +69,7 @@ class InfraredRemoteViewModel @Inject constructor( private val sendingKeySlotFlow = MutableStateFlow(null) private val activeLearningSlotFlow = MutableStateFlow(null) private val messageFlow = MutableStateFlow(null) + private var activeLearningJob: Job? = null private val effectiveProfileAndMode: Flow> = runtimeModeStore.mode.flatMapLatest { mode -> @@ -149,6 +151,8 @@ class InfraredRemoteViewModel @Inject constructor( fun toggleLearningMode(enabled: Boolean) { isLearningModeFlow.value = enabled if (!enabled) { + activeLearningJob?.cancel() + activeLearningJob = null activeLearningSlotFlow.value = null } else { messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制") @@ -168,15 +172,24 @@ class InfraredRemoteViewModel @Inject constructor( return } activeLearningSlotFlow.value = spec.defaultSlot - val job = learningCoordinator.learn( - profileId = currentProfile, - deviceName = savedDevice, - displayName = spec.displayName, - sdkKey = spec.defaultSlot, - ) + val existingKey = state.value.learnedSlotMap[spec.defaultSlot] + val job = if (existingKey != null) { + learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id)) + } else { + learningCoordinator.learn( + profileId = currentProfile, + deviceName = savedDevice, + displayName = spec.displayName, + sdkKey = spec.defaultSlot, + ) + } + activeLearningJob = job job.invokeOnCompletion { - if (activeLearningSlotFlow.value == spec.defaultSlot) { - activeLearningSlotFlow.value = null + if (activeLearningJob === job) { + activeLearningJob = null + if (activeLearningSlotFlow.value == spec.defaultSlot) { + activeLearningSlotFlow.value = null + } } } messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...") @@ -207,6 +220,12 @@ class InfraredRemoteViewModel @Inject constructor( } } + override fun onCleared() { + super.onCleared() + activeLearningJob?.cancel() + activeLearningJob = null + } + fun clearMessage() { messageFlow.value = null } diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/DataStoreInfraredRuntimeModeStore.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/DataStoreInfraredRuntimeModeStore.kt index 1ce2c0c..eb2e141 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/DataStoreInfraredRuntimeModeStore.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/DataStoreInfraredRuntimeModeStore.kt @@ -52,6 +52,7 @@ class DataStoreInfraredRuntimeModeStore( } DomainResult.Success(Unit) } catch (throwable: Throwable) { + if (throwable is kotlinx.coroutines.CancellationException) throw throwable DomainResult.Failure(DomainError.Unknown(detail = "Failed to update runtime mode")) } } diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt index 71e7d93..fc4e03d 100644 --- a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt @@ -183,4 +183,37 @@ class InfraredRemoteViewModelTest { job.cancel() } + + @Test + fun learningExistingKeyRetriesExistingKey() = testScope.runTest { + deviceRepo.setDeviceName(fakeProfile, device) + val failedKey = SavedInfraredKey( + id = InfraredKeyId("k1"), + profileId = fakeProfile, + deviceName = device, + displayName = "空调-电源", + sdkKey = "1", + status = InfraredKeyStatus.FAILED, + lastErrorCode = "20206", + lastErrorDetail = "offline", + createdAtEpochMillis = 100L, + updatedAtEpochMillis = 100L, + ) + keyRepo.insert(failedKey) + + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.toggleLearningMode(true) + vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER) + advanceUntilIdle() + + assertEquals(1, gateway.learnedKeys.size) + 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) + + job.cancel() + } }