diff --git a/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/data/local/RoomInfraredKeyRepositoryTest.kt b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/data/local/RoomInfraredKeyRepositoryTest.kt index 2212e77..97efdde 100644 --- a/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/data/local/RoomInfraredKeyRepositoryTest.kt +++ b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/data/local/RoomInfraredKeyRepositoryTest.kt @@ -139,6 +139,25 @@ class RoomInfraredKeyRepositoryTest { 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 fun persistsAndObservesDeviceName() = runTest { val initial = repository.observeDeviceName(ProviderProfileId("profile1")).first() as DomainResult.Success 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 434abc5..8fb6dcf 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 @@ -39,9 +39,17 @@ class InfraredLearningCoordinator( relearnInfraredKey(ref) } - fun failInterruptedLearning(profileId: ProviderProfileId, updatedAtEpochMillis: Long = System.currentTimeMillis()) { + fun failInterruptedLearning( + profileId: ProviderProfileId, + cutoffEpochMillis: Long = System.currentTimeMillis(), + updatedAtEpochMillis: Long = cutoffEpochMillis, + ) { externalScope.launch(NonCancellable) { - keyRepository.failInterruptedLearning(profileId, updatedAtEpochMillis) + keyRepository.failInterruptedLearning( + profileId = profileId, + cutoffEpochMillis = cutoffEpochMillis, + updatedAtEpochMillis = updatedAtEpochMillis, + ) } } } diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredDao.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredDao.kt index 6a1f240..3d24b10 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredDao.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredDao.kt @@ -81,13 +81,16 @@ interface InfraredDao { lastErrorCode = NULL, lastErrorDetail = :errorDetail, updatedAtEpochMillis = :updatedAtEpochMillis - WHERE profileId = :profileId AND status = 'LEARNING' + WHERE profileId = :profileId + AND status = 'LEARNING' + AND createdAtEpochMillis <= :cutoffEpochMillis """, ) suspend fun failInterruptedLearning( profileId: String, targetStatus: String, errorDetail: String, + cutoffEpochMillis: Long, updatedAtEpochMillis: Long, ): Int 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 e6f52ef..b748e39 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 @@ -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.InfraredKeyStatus import com.flagship.abox.manager.domain.infrared.SavedInfraredKey +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map @@ -147,6 +148,7 @@ class RoomInfraredKeyRepository( override suspend fun failInterruptedLearning( profileId: ProviderProfileId, + cutoffEpochMillis: Long, updatedAtEpochMillis: Long, ): DomainResult { return try { @@ -154,10 +156,14 @@ class RoomInfraredKeyRepository( profileId = profileId.value, targetStatus = InfraredKeyStatus.FAILED.name, errorDetail = INTERRUPTED_LEARNING_ERROR_DETAIL, + cutoffEpochMillis = cutoffEpochMillis, updatedAtEpochMillis = updatedAtEpochMillis, ) DomainResult.Success(count) } catch (throwable: Throwable) { + if (throwable is CancellationException) { + throw throwable + } DomainResult.Failure(mapThrowableToDomainError(throwable)) } } 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 e203719..2bb1601 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 @@ -77,11 +77,12 @@ class InfraredRemoteViewModel @Inject constructor( init { if (authenticatedProfileId != null) { viewModelScope.launch { + val startupCutoff = System.currentTimeMillis() runtimeModeStore.mode.collect { mode -> val effective = profileResolver.resolve(authenticatedProfileId, mode) - learningCoordinator.failInterruptedLearning(effective) + learningCoordinator.failInterruptedLearning(effective, cutoffEpochMillis = startupCutoff) if (effective != authenticatedProfileId) { - learningCoordinator.failInterruptedLearning(authenticatedProfileId) + learningCoordinator.failInterruptedLearning(authenticatedProfileId, cutoffEpochMillis = startupCutoff) } } } @@ -185,13 +186,14 @@ class InfraredRemoteViewModel @Inject constructor( fun toggleLearningMode(enabled: Boolean) { isLearningModeFlow.value = enabled if (!enabled) { + val shutdownCutoff = System.currentTimeMillis() underlyingLearningDeferred?.cancel() underlyingLearningDeferred = null activeLearningJob?.cancel() activeLearningJob = null activeLearningSlotFlow.value = null state.value.effectiveProfileId?.let { profile -> - learningCoordinator.failInterruptedLearning(profile) + learningCoordinator.failInterruptedLearning(profile, cutoffEpochMillis = shutdownCutoff) } } else { messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制") @@ -285,12 +287,13 @@ class InfraredRemoteViewModel @Inject constructor( override fun onCleared() { super.onCleared() + val teardownCutoff = System.currentTimeMillis() underlyingLearningDeferred?.cancel() underlyingLearningDeferred = null activeLearningJob?.cancel() activeLearningJob = null state.value.effectiveProfileId?.let { profile -> - learningCoordinator.failInterruptedLearning(profile) + learningCoordinator.failInterruptedLearning(profile, cutoffEpochMillis = teardownCutoff) } } diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyRepository.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyRepository.kt index 164f5ef..c0cf9ff 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyRepository.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyRepository.kt @@ -33,12 +33,13 @@ interface InfraredKeyRepository { suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult /** - * Atomically marks this profile's interrupted LEARNING keys as FAILED, clears the vendor - * error code, stores [INTERRUPTED_LEARNING_ERROR_DETAIL], and updates their timestamp. + * Atomically marks this profile's interrupted LEARNING keys created on or before [cutoffEpochMillis] + * as FAILED, clears the vendor error code, stores [INTERRUPTED_LEARNING_ERROR_DETAIL], and updates their timestamp. */ suspend fun failInterruptedLearning( profileId: ProviderProfileId, - updatedAtEpochMillis: Long, + cutoffEpochMillis: Long = Long.MAX_VALUE, + updatedAtEpochMillis: Long = cutoffEpochMillis, ): DomainResult } diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InMemoryInfraredTestDoubles.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InMemoryInfraredTestDoubles.kt index db2a57f..15f9bc4 100644 --- a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InMemoryInfraredTestDoubles.kt +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InMemoryInfraredTestDoubles.kt @@ -159,13 +159,14 @@ class InMemoryInfraredKeyRepository : InfraredKeyRepository { override suspend fun failInterruptedLearning( profileId: ProviderProfileId, + cutoffEpochMillis: Long, updatedAtEpochMillis: Long, ): DomainResult { recoveryCalls += InfraredRecoveryCall(profileId, updatedAtEpochMillis) recoveryError?.let { return DomainResult.Failure(it) } var changed = 0 keys.replaceAll { _, key -> - if (key.profileId == profileId && key.status == InfraredKeyStatus.LEARNING) { + if (key.profileId == profileId && key.status == InfraredKeyStatus.LEARNING && key.createdAtEpochMillis <= cutoffEpochMillis) { changed += 1 key.copy( status = InfraredKeyStatus.FAILED, diff --git a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepository.kt b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepository.kt index 6219a58..d50a42d 100644 --- a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepository.kt +++ b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepository.kt @@ -100,11 +100,12 @@ class FakeInfraredKeyRepository : InfraredKeyRepository { override suspend fun failInterruptedLearning( profileId: ProviderProfileId, + cutoffEpochMillis: Long, updatedAtEpochMillis: Long, ): DomainResult = synchronized(lock) { var changed = 0 keys.replaceAll { _, key -> - if (key.profileId == profileId && key.status == InfraredKeyStatus.LEARNING) { + if (key.profileId == profileId && key.status == InfraredKeyStatus.LEARNING && key.createdAtEpochMillis <= cutoffEpochMillis) { changed += 1 key.copy( status = InfraredKeyStatus.FAILED, diff --git a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepositoryTest.kt b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepositoryTest.kt index df699a1..6e3dd45 100644 --- a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepositoryTest.kt +++ b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeInfraredKeyRepositoryTest.kt @@ -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 fun deviceConfigurationIsIsolatedByProfile() = runTest { val repository = FakeInfraredDeviceConfigurationRepository()