feat(infrared): add infrared control, learning, and virtual remote module #9
@@ -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<DomainResult<Unit>> = externalScope.async {
|
||||
retryInfraredKeyLearning(ref)
|
||||
}
|
||||
|
||||
fun relearn(ref: InfraredKeyRef): Deferred<DomainResult<Unit>> = externalScope.async {
|
||||
relearnInfraredKey(ref)
|
||||
}
|
||||
|
||||
fun failInterruptedLearning(profileId: ProviderProfileId, updatedAtEpochMillis: Long = System.currentTimeMillis()) {
|
||||
externalScope.launch(NonCancellable) {
|
||||
keyRepository.failInterruptedLearning(profileId, updatedAtEpochMillis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 ->
|
||||
|
pr-agent-bot
commented
取消失效 关闭学习模式时取消的只是等待结果的 ViewModel Job,而 **取消失效**
关闭学习模式时取消的只是等待结果的 ViewModel Job,而 `learn`/`retry` 返回的任务由独立的应用级 scope 执行;取消 `await` 不会停止底层学习。用户关闭录制或离开页面后,硬件仍可能继续学习,并且清空 active slot 后重新开启模式可启动另一个并发学习。应提供对 coordinator 底层任务的取消能力,或在任务真正结束前保留忙碌状态并阻止新任务。
<!-- pr-agent-dedup: 42f163fda940 -->
<!-- pr-agent-key-issue-location: bc00d351aea6 -->
|
||||
viewModelScope.launch(kotlinx.coroutines.NonCancellable) {
|
||||
keyRepository.failInterruptedLearning(profile, System.currentTimeMillis())
|
||||
|
pr-agent-bot
commented
取消残留 关闭学习模式会取消底层 **取消残留**
关闭学习模式会取消底层 `Deferred` 和等待任务,但此路径没有将已持久化的 `LEARNING` 记录转为 `FAILED` 或删除,`onCleared` 也采用相同方式。如果协调器没有在 `CancellationException` 下以不可取消方式回滚,用户中途关闭开关或离开页面后,该 slot 会保持 `LEARNING`,后续学习请求又会被直接忽略。请显式完成取消状态收尾,并补充取消学习的持久化测试。
<!-- pr-agent-dedup: bc52949c626d -->
<!-- pr-agent-key-issue-location: 5e714cca24cf -->
|
||||
}
|
||||
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}),请按遥控器按键...")
|
||||
|
pr-agent-bot
commented
重复发射 发射过程中没有检查已有的 **重复发射**
发射过程中没有检查已有的 `sendingKeySlotFlow`,而遥控器按键在显示加载状态时仍可点击。用户连续点击电源键会并发执行多次红外发射,可能导致设备连续切换两次并回到原状态;其中任一任务先结束还会提前清除另一个任务的加载指示。请在 ViewModel 中阻止重复请求,或按请求分别跟踪并禁用相应按键。
<!-- pr-agent-dedup: fede3506d9e3 -->
<!-- pr-agent-key-issue-location: d6cc1c91e5b2 -->
|
||||
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,
|
||||
|
pr-agent-bot
commented
错误丢失 学习任务的完成回调只清理加载状态,没有读取或传播学习结果,并且 **错误丢失**
学习任务的完成回调只清理加载状态,没有读取或传播学习结果,并且 `handleDomainError` 仅用于发送操作。设备离线或认证失效导致学习失败时,界面不会显示失败原因,认证失效也不会触发 `onSessionInvalid`;应由协调器返回或发布结果,并将错误映射为对应的 `RemoteUiMessage`。
<!-- pr-agent-dedup: 16b03ab5c738 -->
<!-- pr-agent-key-issue-location: 392b62285515 -->
pr-agent-bot
commented
竞态条件 关闭学习模式时会异步触发按 profile 清理全部 **竞态条件**
关闭学习模式时会异步触发按 profile 清理全部 `LEARNING` 记录,但界面立即允许重新开启学习。若用户快速重新开启并开始新任务,尚未执行的清理可能把新任务也标记为 `FAILED`,导致随后成功的学习无法转换为 `READY`;初始化和 `onCleared` 中也存在同类风险。应等待清理完成后再允许新任务,或使用时间戳/任务标识仅清理旧记录。
<!-- pr-agent-dedup: 943572f8ab5b -->
<!-- pr-agent-key-issue-location: e2acde597e7e -->
|
||||
@@ -254,6 +258,9 @@ class InfraredRemoteViewModel @Inject constructor(
|
||||
underlyingLearningDeferred = null
|
||||
activeLearningJob?.cancel()
|
||||
activeLearningJob = null
|
||||
state.value.effectiveProfileId?.let { profile ->
|
||||
learningCoordinator.failInterruptedLearning(profile)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearMessage() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Unit> {
|
||||
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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
恢复错位
failInterruptedLearning使用原始认证配置,而按键数据使用经运行模式解析后的effectiveProfileId。在 FAKE 模式下,按键通常存储于派生配置(如abox:infrared-fake);进程中断后,这些LEARNING记录不会被标记为失败,而onButtonClick又会直接忽略已有的LEARNING按键,导致该按键无法重试或重新学习。应针对当前运行模式解析出的配置执行恢复。