fix(infrared): unify atomic config state snapshot, avoid message race, and rollback cancelled learning status
This commit is contained in:
+47
-50
@@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -73,33 +74,40 @@ class InfraredRemoteViewModel @Inject constructor(
|
|||||||
private var activeLearningJob: Job? = null
|
private var activeLearningJob: Job? = null
|
||||||
private var underlyingLearningDeferred: kotlinx.coroutines.Deferred<*>? = null
|
private var underlyingLearningDeferred: kotlinx.coroutines.Deferred<*>? = null
|
||||||
|
|
||||||
private val effectiveProfileAndMode: Flow<Pair<ProviderProfileId?, InfraredRuntimeMode>> =
|
private data class InfraredRemoteConfigSnapshot(
|
||||||
|
val effectiveProfileId: ProviderProfileId? = null,
|
||||||
|
val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE,
|
||||||
|
val savedDeviceName: DeviceName? = null,
|
||||||
|
val learnedSlotMap: Map<String, SavedInfraredKey> = emptyMap(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val configSnapshotFlow: Flow<InfraredRemoteConfigSnapshot> =
|
||||||
runtimeModeStore.mode.flatMapLatest { mode ->
|
runtimeModeStore.mode.flatMapLatest { mode ->
|
||||||
if (authenticatedProfileId == null) {
|
val profile = authenticatedProfileId?.let { profileResolver.resolve(it, mode) }
|
||||||
flowOf(Pair(null, mode))
|
if (profile == null) {
|
||||||
|
flowOf(InfraredRemoteConfigSnapshot(effectiveProfileId = null, runtimeMode = mode))
|
||||||
} else {
|
} else {
|
||||||
flowOf(Pair(profileResolver.resolve(authenticatedProfileId, mode), mode))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val deviceNameFlow: Flow<DomainResult<DeviceName?>> =
|
|
||||||
effectiveProfileAndMode.flatMapLatest { (profile, _) ->
|
|
||||||
if (profile == null) flowOf(DomainResult.Success(null))
|
|
||||||
else deviceConfigurationRepository.observeDeviceName(profile)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val keysFlow: Flow<DomainResult<List<SavedInfraredKey>>> =
|
|
||||||
effectiveProfileAndMode.flatMapLatest { (profile, _) ->
|
|
||||||
if (profile == null) flowOf(DomainResult.Success(emptyList()))
|
|
||||||
else {
|
|
||||||
deviceConfigurationRepository.observeDeviceName(profile).flatMapLatest { deviceResult ->
|
deviceConfigurationRepository.observeDeviceName(profile).flatMapLatest { deviceResult ->
|
||||||
when (deviceResult) {
|
val deviceName = (deviceResult as? DomainResult.Success)?.value
|
||||||
is DomainResult.Success -> {
|
if (deviceName == null) {
|
||||||
val device = deviceResult.value
|
flowOf(
|
||||||
if (device == null) flowOf(DomainResult.Success(emptyList()))
|
InfraredRemoteConfigSnapshot(
|
||||||
else keyRepository.observeKeys(profile, device)
|
effectiveProfileId = profile,
|
||||||
|
runtimeMode = mode,
|
||||||
|
savedDeviceName = null,
|
||||||
|
learnedSlotMap = emptyMap(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
keyRepository.observeKeys(profile, deviceName).map { keysResult ->
|
||||||
|
val keys = (keysResult as? DomainResult.Success<List<SavedInfraredKey>>)?.value ?: emptyList()
|
||||||
|
InfraredRemoteConfigSnapshot(
|
||||||
|
effectiveProfileId = profile,
|
||||||
|
runtimeMode = mode,
|
||||||
|
savedDeviceName = deviceName,
|
||||||
|
learnedSlotMap = keys.associateBy { it.sdkKey },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
is DomainResult.Failure -> flowOf(DomainResult.Failure(deviceResult.error))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,34 +116,18 @@ class InfraredRemoteViewModel @Inject constructor(
|
|||||||
val state: StateFlow<InfraredRemoteUiState> = combine(
|
val state: StateFlow<InfraredRemoteUiState> = combine(
|
||||||
sceneFlow,
|
sceneFlow,
|
||||||
isLearningModeFlow,
|
isLearningModeFlow,
|
||||||
effectiveProfileAndMode,
|
configSnapshotFlow,
|
||||||
deviceNameFlow,
|
combine(sendingKeySlotFlow, activeLearningSlotFlow, messageFlow) { sending, learning, msg ->
|
||||||
keysFlow,
|
Triple(sending, learning, msg)
|
||||||
sendingKeySlotFlow,
|
},
|
||||||
activeLearningSlotFlow,
|
) { scene, isLearning, snapshot, (sendingSlot, learningSlot, msg) ->
|
||||||
messageFlow,
|
|
||||||
) { args: Array<Any?> ->
|
|
||||||
val scene = args[0] as RemoteScene
|
|
||||||
val isLearning = args[1] as Boolean
|
|
||||||
val profileAndMode = args[2] as Pair<ProviderProfileId?, InfraredRuntimeMode>
|
|
||||||
val deviceResult = args[3] as DomainResult<DeviceName?>
|
|
||||||
val keysResult = args[4] as DomainResult<List<SavedInfraredKey>>
|
|
||||||
val sendingSlot = args[5] as String?
|
|
||||||
val learningSlot = args[6] as String?
|
|
||||||
val msg = args[7] as RemoteUiMessage?
|
|
||||||
|
|
||||||
val (profile, mode) = profileAndMode
|
|
||||||
val savedDevice = (deviceResult as? DomainResult.Success<DeviceName?>)?.value
|
|
||||||
val keys = (keysResult as? DomainResult.Success<List<SavedInfraredKey>>)?.value ?: emptyList()
|
|
||||||
val slotMap = keys.associateBy { it.sdkKey }
|
|
||||||
|
|
||||||
InfraredRemoteUiState(
|
InfraredRemoteUiState(
|
||||||
selectedScene = scene,
|
selectedScene = scene,
|
||||||
isLearningMode = isLearning,
|
isLearningMode = isLearning,
|
||||||
runtimeMode = mode,
|
runtimeMode = snapshot.runtimeMode,
|
||||||
effectiveProfileId = profile,
|
effectiveProfileId = snapshot.effectiveProfileId,
|
||||||
savedDeviceName = savedDevice,
|
savedDeviceName = snapshot.savedDeviceName,
|
||||||
learnedSlotMap = slotMap,
|
learnedSlotMap = snapshot.learnedSlotMap,
|
||||||
sendingKeySlot = sendingSlot,
|
sendingKeySlot = sendingSlot,
|
||||||
activeLearningSlot = learningSlot,
|
activeLearningSlot = learningSlot,
|
||||||
message = msg,
|
message = msg,
|
||||||
@@ -158,6 +150,11 @@ class InfraredRemoteViewModel @Inject constructor(
|
|||||||
activeLearningJob?.cancel()
|
activeLearningJob?.cancel()
|
||||||
activeLearningJob = null
|
activeLearningJob = null
|
||||||
activeLearningSlotFlow.value = null
|
activeLearningSlotFlow.value = null
|
||||||
|
state.value.effectiveProfileId?.let { profile ->
|
||||||
|
viewModelScope.launch(kotlinx.coroutines.NonCancellable) {
|
||||||
|
keyRepository.failInterruptedLearning(profile, System.currentTimeMillis())
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制")
|
messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制")
|
||||||
}
|
}
|
||||||
@@ -179,6 +176,7 @@ class InfraredRemoteViewModel @Inject constructor(
|
|||||||
if (existingKey?.status == InfraredKeyStatus.LEARNING) {
|
if (existingKey?.status == InfraredKeyStatus.LEARNING) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...")
|
||||||
activeLearningSlotFlow.value = spec.defaultSlot
|
activeLearningSlotFlow.value = spec.defaultSlot
|
||||||
val deferred = if (existingKey != null) {
|
val deferred = if (existingKey != null) {
|
||||||
learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id))
|
learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id))
|
||||||
@@ -223,13 +221,12 @@ class InfraredRemoteViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
activeLearningJob = job
|
activeLearningJob = job
|
||||||
messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...")
|
|
||||||
} else {
|
} else {
|
||||||
if (sendingKeySlotFlow.value != null) {
|
if (sendingKeySlotFlow.value != null) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val key = state.value.learnedSlotMap[spec.defaultSlot]
|
val key = state.value.learnedSlotMap[spec.defaultSlot]
|
||||||
if (key == null || key.status != InfraredKeyStatus.READY) {
|
if (key == null || key.status != InfraredKeyStatus.READY || key.profileId != currentProfile || key.deviceName != savedDevice) {
|
||||||
messageFlow.value = RemoteUiMessage.Error("按键【${spec.displayName}】尚未学习,请开启右上角学习模式进行录制")
|
messageFlow.value = RemoteUiMessage.Error("按键【${spec.displayName}】尚未学习,请开启右上角学习模式进行录制")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+17
@@ -253,4 +253,21 @@ class InfraredRemoteViewModelTest {
|
|||||||
|
|
||||||
job.cancel()
|
job.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun disablingLearningModeCleansUpAndFailsInterruptedLearning() = testScope.runTest {
|
||||||
|
deviceRepo.setDeviceName(fakeProfile, device)
|
||||||
|
val vm = createViewModel()
|
||||||
|
val job = launch { vm.state.collect {} }
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
vm.toggleLearningMode(true)
|
||||||
|
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
|
||||||
|
vm.toggleLearningMode(false)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(null, vm.state.value.activeLearningSlot)
|
||||||
|
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-6
@@ -5,6 +5,9 @@ import com.flagship.abox.manager.domain.DomainError
|
|||||||
import com.flagship.abox.manager.domain.DomainResult
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
import com.flagship.abox.manager.domain.InfraredGateway
|
import com.flagship.abox.manager.domain.InfraredGateway
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
class LearnInfraredKey(
|
class LearnInfraredKey(
|
||||||
private val repository: InfraredKeyRepository,
|
private val repository: InfraredKeyRepository,
|
||||||
@@ -28,13 +31,27 @@ class LearnInfraredKey(
|
|||||||
is DomainResult.Failure -> return result
|
is DomainResult.Failure -> return result
|
||||||
}
|
}
|
||||||
|
|
||||||
return when (
|
val result = try {
|
||||||
val result = gateway.learnKey(
|
gateway.learnKey(
|
||||||
profileId = key.profileId,
|
profileId = key.profileId,
|
||||||
deviceName = key.deviceName,
|
deviceName = key.deviceName,
|
||||||
key = key.sdkKey,
|
key = key.sdkKey,
|
||||||
)
|
)
|
||||||
) {
|
} catch (e: CancellationException) {
|
||||||
|
withContext(NonCancellable) {
|
||||||
|
repository.transitionStatus(
|
||||||
|
ref = key.ref(),
|
||||||
|
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
|
||||||
|
targetStatus = InfraredKeyStatus.FAILED,
|
||||||
|
lastErrorCode = null,
|
||||||
|
lastErrorDetail = "Cancelled",
|
||||||
|
updatedAtEpochMillis = clock.now(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
|
return when (result) {
|
||||||
is DomainResult.Success -> when (
|
is DomainResult.Success -> when (
|
||||||
val transition = repository.transitionStatus(
|
val transition = repository.transitionStatus(
|
||||||
ref = key.ref(),
|
ref = key.ref(),
|
||||||
@@ -101,13 +118,27 @@ class RetryInfraredKeyLearning(
|
|||||||
is DomainResult.Failure -> return result
|
is DomainResult.Failure -> return result
|
||||||
}
|
}
|
||||||
|
|
||||||
return when (
|
val result = try {
|
||||||
val result = gateway.learnKey(
|
gateway.learnKey(
|
||||||
profileId = key.profileId,
|
profileId = key.profileId,
|
||||||
deviceName = key.deviceName,
|
deviceName = key.deviceName,
|
||||||
key = key.sdkKey,
|
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(
|
is DomainResult.Success -> repository.transitionStatus(
|
||||||
ref = ref,
|
ref = ref,
|
||||||
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
|
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
|
||||||
|
|||||||
+16
@@ -269,6 +269,22 @@ class LearnInfraredKeyTest {
|
|||||||
assertEquals(1, gateway.learnedKeys.size)
|
assertEquals(1, gateway.learnedKeys.size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun learningCancellationTransitionsStatusToFailed() = runTest {
|
||||||
|
gateway.onLearn = { _, _, _ ->
|
||||||
|
throw kotlinx.coroutines.CancellationException("User cancelled")
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
learn(profile, device, "Power")
|
||||||
|
} catch (_: kotlinx.coroutines.CancellationException) {
|
||||||
|
// expected
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(InfraredKeyStatus.FAILED, repository.onlyKey().status)
|
||||||
|
assertEquals("Cancelled", repository.onlyKey().lastErrorDetail)
|
||||||
|
}
|
||||||
|
|
||||||
private fun failedKey(
|
private fun failedKey(
|
||||||
id: InfraredKeyId = keyId,
|
id: InfraredKeyId = keyId,
|
||||||
sdkKey: String = "app_ir_key1",
|
sdkKey: String = "app_ir_key1",
|
||||||
|
|||||||
Reference in New Issue
Block a user