diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt index 468b9e3..434abc5 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt @@ -5,15 +5,21 @@ 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.InfraredKeyRepository import com.flagship.abox.manager.domain.infrared.LearnInfraredKey +import com.flagship.abox.manager.domain.infrared.RelearnInfraredKey import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.async +import kotlinx.coroutines.launch class InfraredLearningCoordinator( private val learnInfraredKey: LearnInfraredKey, private val retryInfraredKeyLearning: RetryInfraredKeyLearning, + private val relearnInfraredKey: RelearnInfraredKey, + private val keyRepository: InfraredKeyRepository, private val externalScope: CoroutineScope, ) { fun learn( @@ -28,4 +34,14 @@ class InfraredLearningCoordinator( fun retry(ref: InfraredKeyRef): Deferred> = externalScope.async { retryInfraredKeyLearning(ref) } + + fun relearn(ref: InfraredKeyRef): Deferred> = externalScope.async { + relearnInfraredKey(ref) + } + + fun failInterruptedLearning(profileId: ProviderProfileId, updatedAtEpochMillis: Long = System.currentTimeMillis()) { + externalScope.launch(NonCancellable) { + keyRepository.failInterruptedLearning(profileId, updatedAtEpochMillis) + } + } } diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt index 5e2f51f..58179f1 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt @@ -170,15 +170,28 @@ object InfraredFeatureModule { clock: EpochMillisClock, ): RecoverInterruptedInfraredLearning = RecoverInterruptedInfraredLearning(repository, clock) + @Provides + @Singleton + fun provideRelearnInfraredKey( + repository: InfraredKeyRepository, + gateway: InfraredGateway, + clock: EpochMillisClock, + ): com.flagship.abox.manager.domain.infrared.RelearnInfraredKey = + com.flagship.abox.manager.domain.infrared.RelearnInfraredKey(repository, gateway, clock) + @Provides @Singleton fun provideInfraredLearningCoordinator( learnInfraredKey: LearnInfraredKey, retryInfraredKeyLearning: RetryInfraredKeyLearning, + relearnInfraredKey: com.flagship.abox.manager.domain.infrared.RelearnInfraredKey, + repository: InfraredKeyRepository, @InfraredApplicationScope scope: CoroutineScope, ): InfraredLearningCoordinator = InfraredLearningCoordinator( learnInfraredKey = learnInfraredKey, retryInfraredKeyLearning = retryInfraredKeyLearning, + relearnInfraredKey = relearnInfraredKey, + keyRepository = repository, externalScope = scope, ) } 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 ddb83a5..a8d6725 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 @@ -74,6 +74,12 @@ class InfraredRemoteViewModel @Inject constructor( private var activeLearningJob: Job? = null private var underlyingLearningDeferred: kotlinx.coroutines.Deferred<*>? = null + init { + authenticatedProfileId?.let { profile -> + learningCoordinator.failInterruptedLearning(profile) + } + } + private data class InfraredRemoteConfigSnapshot( val effectiveProfileId: ProviderProfileId? = null, val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE, @@ -151,9 +157,7 @@ class InfraredRemoteViewModel @Inject constructor( activeLearningJob = null activeLearningSlotFlow.value = null state.value.effectiveProfileId?.let { profile -> - viewModelScope.launch(kotlinx.coroutines.NonCancellable) { - keyRepository.failInterruptedLearning(profile, System.currentTimeMillis()) - } + learningCoordinator.failInterruptedLearning(profile) } } else { messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制") @@ -178,10 +182,10 @@ class InfraredRemoteViewModel @Inject constructor( } messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...") activeLearningSlotFlow.value = spec.defaultSlot - val deferred = if (existingKey != null) { - learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id)) - } else { - learningCoordinator.learn( + val deferred = when (existingKey?.status) { + InfraredKeyStatus.FAILED -> learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id)) + InfraredKeyStatus.READY -> learningCoordinator.relearn(InfraredKeyRef(currentProfile, existingKey.id)) + else -> learningCoordinator.learn( profileId = currentProfile, deviceName = savedDevice, displayName = spec.displayName, @@ -254,6 +258,9 @@ class InfraredRemoteViewModel @Inject constructor( underlyingLearningDeferred = null activeLearningJob?.cancel() activeLearningJob = null + state.value.effectiveProfileId?.let { profile -> + learningCoordinator.failInterruptedLearning(profile) + } } fun clearMessage() { diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinatorTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinatorTest.kt index d9e2393..f9123c9 100644 --- a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinatorTest.kt +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinatorTest.kt @@ -9,6 +9,7 @@ import com.flagship.abox.manager.domain.infrared.InfraredKeyId import com.flagship.abox.manager.domain.infrared.InfraredKeyRef import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus import com.flagship.abox.manager.domain.infrared.LearnInfraredKey +import com.flagship.abox.manager.domain.infrared.RelearnInfraredKey import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning import com.flagship.abox.manager.domain.infrared.SavedInfraredKey import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway @@ -34,10 +35,11 @@ class InfraredLearningCoordinatorTest { private val learn = LearnInfraredKey(repository, gateway, factory, clock) private val retry = RetryInfraredKeyLearning(repository, gateway, clock) + private val relearn = RelearnInfraredKey(repository, gateway, clock) @Test fun learnRunsAsynchronouslyInExternalScope() = runTest { - val coordinator = InfraredLearningCoordinator(learn, retry, this) + val coordinator = InfraredLearningCoordinator(learn, retry, relearn, repository, this) coordinator.learn(profile, device, "Power") advanceUntilIdle() @@ -65,7 +67,7 @@ class InfraredLearningCoordinatorTest { ) repository.insert(failedKey) - val coordinator = InfraredLearningCoordinator(learn, retry, this) + val coordinator = InfraredLearningCoordinator(learn, retry, relearn, repository, this) coordinator.retry(InfraredKeyRef(profile, InfraredKeyId("key1"))) advanceUntilIdle() @@ -73,4 +75,54 @@ class InfraredLearningCoordinatorTest { val updated = (repository.getKey(InfraredKeyRef(profile, InfraredKeyId("key1"))) as DomainResult.Success).value!! assertEquals(InfraredKeyStatus.READY, updated.status) } + + @Test + fun relearnRunsAsynchronouslyInExternalScope() = runTest { + val readyKey = SavedInfraredKey( + id = InfraredKeyId("key1"), + profileId = profile, + deviceName = device, + displayName = "Power", + sdkKey = "app_ir_key1", + status = InfraredKeyStatus.READY, + lastErrorCode = null, + lastErrorDetail = null, + createdAtEpochMillis = 500L, + updatedAtEpochMillis = 500L, + ) + repository.insert(readyKey) + + val coordinator = InfraredLearningCoordinator(learn, retry, relearn, repository, this) + + coordinator.relearn(InfraredKeyRef(profile, InfraredKeyId("key1"))) + advanceUntilIdle() + + val updated = (repository.getKey(InfraredKeyRef(profile, InfraredKeyId("key1"))) as DomainResult.Success).value!! + assertEquals(InfraredKeyStatus.READY, updated.status) + } + + @Test + fun failInterruptedLearningFailsLearningKeys() = runTest { + val learningKey = SavedInfraredKey( + id = InfraredKeyId("key1"), + profileId = profile, + deviceName = device, + displayName = "Power", + sdkKey = "app_ir_key1", + status = InfraredKeyStatus.LEARNING, + lastErrorCode = null, + lastErrorDetail = null, + createdAtEpochMillis = 500L, + updatedAtEpochMillis = 500L, + ) + repository.insert(learningKey) + + val coordinator = InfraredLearningCoordinator(learn, retry, relearn, repository, this) + + coordinator.failInterruptedLearning(profile, 2_000L) + advanceUntilIdle() + + val updated = (repository.getKey(InfraredKeyRef(profile, InfraredKeyId("key1"))) as DomainResult.Success).value!! + assertEquals(InfraredKeyStatus.FAILED, updated.status) + } } 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 d6a7b42..7c362d1 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 @@ -64,13 +64,14 @@ class InfraredRemoteViewModelTest { private val learn = LearnInfraredKey(keyRepo, gateway, factory, clock) private val retry = RetryInfraredKeyLearning(keyRepo, gateway, clock) + private val relearn = com.flagship.abox.manager.domain.infrared.RelearnInfraredKey(keyRepo, gateway, clock) private val send = SendInfraredKey(keyRepo, gateway) private lateinit var coordinator: InfraredLearningCoordinator @Before fun setup() { Dispatchers.setMain(testDispatcher) - coordinator = InfraredLearningCoordinator(learn, retry, testScope) + coordinator = InfraredLearningCoordinator(learn, retry, relearn, keyRepo, testScope) } @After @@ -270,4 +271,38 @@ class InfraredRemoteViewModelTest { job.cancel() } + + @Test + fun relearningExistingReadyKeyOverwritesKey() = testScope.runTest { + deviceRepo.setDeviceName(fakeProfile, device) + val readyKey = SavedInfraredKey( + id = InfraredKeyId("k1"), + profileId = fakeProfile, + deviceName = device, + displayName = "空调-电源", + sdkKey = "1", + status = InfraredKeyStatus.READY, + lastErrorCode = null, + lastErrorDetail = null, + createdAtEpochMillis = 100L, + updatedAtEpochMillis = 100L, + ) + keyRepo.insert(readyKey) + + 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) + assertEquals(RemoteUiMessage.Info("【空调-电源】录制成功"), vm.state.value.message) + + job.cancel() + } } diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModelTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModelTest.kt index 014c39d..822a811 100644 --- a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModelTest.kt +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModelTest.kt @@ -71,6 +71,7 @@ class InfraredControlViewModelTest { private val learnUseCase = LearnInfraredKey(keyRepository, gateway, factory, clock) private val retryUseCase = RetryInfraredKeyLearning(keyRepository, gateway, clock) + private val relearnUseCase = com.flagship.abox.manager.domain.infrared.RelearnInfraredKey(keyRepository, gateway, clock) private val sendUseCase = SendInfraredKey(keyRepository, gateway) private val renameUseCase = RenameInfraredKey(keyRepository, clock) private val deleteUseCase = DeleteInfraredKey(keyRepository) @@ -81,7 +82,7 @@ class InfraredControlViewModelTest { @Before fun setup() { Dispatchers.setMain(testDispatcher) - coordinator = InfraredLearningCoordinator(learnUseCase, retryUseCase, testScope) + coordinator = InfraredLearningCoordinator(learnUseCase, retryUseCase, relearnUseCase, keyRepository, testScope) } @After diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt index 97b69c8..a23fbac 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt @@ -106,7 +106,88 @@ class RetryInfraredKeyLearning( ref = ref, expectedStatuses = setOf( InfraredKeyStatus.FAILED, + ), + targetStatus = InfraredKeyStatus.LEARNING, + lastErrorCode = null, + lastErrorDetail = null, + updatedAtEpochMillis = clock.now(), + ) + ) { + is DomainResult.Success -> Unit + is DomainResult.Failure -> return result + } + + val result = try { + gateway.learnKey( + profileId = key.profileId, + deviceName = key.deviceName, + key = key.sdkKey, + ) + } catch (e: CancellationException) { + withContext(NonCancellable) { + repository.transitionStatus( + ref = ref, + expectedStatuses = setOf(InfraredKeyStatus.LEARNING), + targetStatus = InfraredKeyStatus.FAILED, + lastErrorCode = null, + lastErrorDetail = "Cancelled", + updatedAtEpochMillis = clock.now(), + ) + } + throw e + } + + return when (result) { + is DomainResult.Success -> repository.transitionStatus( + ref = ref, + expectedStatuses = setOf(InfraredKeyStatus.LEARNING), + targetStatus = InfraredKeyStatus.READY, + lastErrorCode = null, + lastErrorDetail = null, + updatedAtEpochMillis = clock.now(), + ) + + is DomainResult.Failure -> { + repository.transitionStatus( + ref = ref, + expectedStatuses = setOf(InfraredKeyStatus.LEARNING), + targetStatus = InfraredKeyStatus.FAILED, + lastErrorCode = result.error.externalCode, + lastErrorDetail = null, + updatedAtEpochMillis = clock.now(), + ) + result + } + } + } +} + +class RelearnInfraredKey( + private val repository: InfraredKeyRepository, + private val gateway: InfraredGateway, + private val clock: EpochMillisClock, +) { + suspend operator fun invoke(ref: InfraredKeyRef): DomainResult { + val key = when (val result = repository.getKey(ref)) { + is DomainResult.Success -> result.value + ?: return DomainResult.Failure( + DomainError.InvalidRequest(detail = "Infrared key not found"), + ) + + is DomainResult.Failure -> return result + } + if (key.status == InfraredKeyStatus.LEARNING) { + return DomainResult.Failure( + DomainError.InvalidRequest(detail = "Infrared key is already learning"), + ) + } + + when ( + val result = repository.transitionStatus( + ref = ref, + expectedStatuses = setOf( InfraredKeyStatus.READY, + InfraredKeyStatus.FAILED, ), targetStatus = InfraredKeyStatus.LEARNING, lastErrorCode = null, diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKeyTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKeyTest.kt index c62e620..ce1d104 100644 --- a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKeyTest.kt +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKeyTest.kt @@ -22,6 +22,7 @@ class LearnInfraredKeyTest { private val gateway = RecordingInfraredGateway() private val learn = LearnInfraredKey(repository, gateway, factory, clock) private val retry = RetryInfraredKeyLearning(repository, gateway, clock) + private val relearn = RelearnInfraredKey(repository, gateway, clock) @Test fun persistsLearningBeforeCallingHardwareThenMarksReady() = runTest { @@ -131,7 +132,34 @@ class LearnInfraredKeyTest { assertEquals(0, repository.insertedKeys.size) assertEquals(2, repository.transitionCalls.size) assertEquals( - setOf(InfraredKeyStatus.FAILED, InfraredKeyStatus.READY), + setOf(InfraredKeyStatus.FAILED), + repository.transitionCalls.first().expectedStatuses, + ) + assertEquals( + setOf(InfraredKeyStatus.LEARNING), + repository.transitionCalls.last().expectedStatuses, + ) + } + + @Test + fun relearnReusesStableIdAndSdkKeyForReadyKey() = runTest { + val original = failedKey(id = InfraredKeyId("key1"), sdkKey = "app_ir_key1").copy( + status = InfraredKeyStatus.READY, + lastErrorCode = null, + lastErrorDetail = null, + ) + repository.seed(original) + + val result = relearn(original.ref()) + + assertEquals(DomainResult.Success(Unit), result) + assertEquals("app_ir_key1", gateway.learnedKeys.single().key) + assertEquals(InfraredKeyId("key1"), repository.onlyKey().id) + assertEquals(InfraredKeyStatus.READY, repository.onlyKey().status) + assertEquals(0, repository.insertedKeys.size) + assertEquals(2, repository.transitionCalls.size) + assertEquals( + setOf(InfraredKeyStatus.READY, InfraredKeyStatus.FAILED), repository.transitionCalls.first().expectedStatuses, ) assertEquals(