feat(infrared): add infrared control, learning, and virtual remote module #9

Merged
KremeCN merged 41 commits from feat/moler-infrared-control into main 2026-08-25 14:51:34 +08:00
2 changed files with 29 additions and 10 deletions
Showing only changes of commit 3561f3dd4a - Show all commits
@@ -100,9 +100,10 @@ private fun AuthenticatedNavHost(
actionInProgress = actionInProgress, actionInProgress = actionInProgress,
errorMessage = errorMessage, errorMessage = errorMessage,
devicesSlot = { devicesSlot = {
val encodedProfileId = android.net.Uri.encode(profile.id.value)
DeviceListScreen( DeviceListScreen(
onInfraredClick = { onInfraredClick = {
navController.navigate("infrared_remote/${profile.id.value}") navController.navigate("infrared_remote/$encodedProfileId")
}, },
) )
}, },
@@ -114,10 +115,11 @@ private fun AuthenticatedNavHost(
navArgument("profileId") { type = NavType.StringType }, navArgument("profileId") { type = NavType.StringType },
), ),
) { ) {
val encodedProfileId = android.net.Uri.encode(profile.id.value)
InfraredRemoteRoute( InfraredRemoteRoute(
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
onOpenSettings = { onOpenSettings = {
navController.navigate("infrared_settings/${profile.id.value}") navController.navigate("infrared_settings/$encodedProfileId")
}, },
onSessionInvalid = onLogout, onSessionInvalid = onLogout,
) )
@@ -8,6 +8,7 @@ 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.ProviderProfileId import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
@@ -70,6 +71,7 @@ class InfraredRemoteViewModel @Inject constructor(
private val activeLearningSlotFlow = MutableStateFlow<String?>(null) private val activeLearningSlotFlow = MutableStateFlow<String?>(null)
private val messageFlow = MutableStateFlow<RemoteUiMessage?>(null) private val messageFlow = MutableStateFlow<RemoteUiMessage?>(null)
private var activeLearningJob: Job? = null private var activeLearningJob: Job? = null
private var underlyingLearningDeferred: kotlinx.coroutines.Deferred<*>? = null
private val effectiveProfileAndMode: Flow<Pair<ProviderProfileId?, InfraredRuntimeMode>> = private val effectiveProfileAndMode: Flow<Pair<ProviderProfileId?, InfraredRuntimeMode>> =
runtimeModeStore.mode.flatMapLatest { mode -> runtimeModeStore.mode.flatMapLatest { mode ->
@@ -151,6 +153,8 @@ class InfraredRemoteViewModel @Inject constructor(
fun toggleLearningMode(enabled: Boolean) { fun toggleLearningMode(enabled: Boolean) {
isLearningModeFlow.value = enabled isLearningModeFlow.value = enabled
if (!enabled) { if (!enabled) {
underlyingLearningDeferred?.cancel()
underlyingLearningDeferred = null
activeLearningJob?.cancel() activeLearningJob?.cancel()
activeLearningJob = null activeLearningJob = null
activeLearningSlotFlow.value = null activeLearningSlotFlow.value = null
@@ -168,7 +172,7 @@ class InfraredRemoteViewModel @Inject constructor(
} }
if (isLearningModeFlow.value) { if (isLearningModeFlow.value) {
if (activeLearningSlotFlow.value != null) { if (underlyingLearningDeferred?.isActive == true || activeLearningJob?.isActive == true || activeLearningSlotFlow.value != null) {
return return
} }
val existingKey = state.value.learnedSlotMap[spec.defaultSlot] val existingKey = state.value.learnedSlotMap[spec.defaultSlot]
@@ -176,17 +180,25 @@ class InfraredRemoteViewModel @Inject constructor(
return return
} }
activeLearningSlotFlow.value = spec.defaultSlot activeLearningSlotFlow.value = spec.defaultSlot
val job = viewModelScope.launch { val deferred = if (existingKey != null) {
try { learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id))
val result = if (existingKey != null) {
learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id)).await()
} else { } else {
when (val res = learningCoordinator.learn( learningCoordinator.learn(
profileId = currentProfile, profileId = currentProfile,
deviceName = savedDevice, deviceName = savedDevice,
displayName = spec.displayName, displayName = spec.displayName,
sdkKey = spec.defaultSlot, sdkKey = spec.defaultSlot,
).await()) { )
}
underlyingLearningDeferred = deferred
val job = viewModelScope.launch {
try {
val result = if (existingKey != null) {
@Suppress("UNCHECKED_CAST")
(deferred as kotlinx.coroutines.Deferred<DomainResult<Unit>>).await()
} else {
@Suppress("UNCHECKED_CAST")
when (val res = (deferred as kotlinx.coroutines.Deferred<DomainResult<InfraredKeyId>>).await()) {
is DomainResult.Success -> DomainResult.Success(Unit) is DomainResult.Success -> DomainResult.Success(Unit)
Review

设备错绑

配置尚未加载时,savedDeviceName 为 null,这里会直接回退到 ir-1,但遥控按键仍可操作。若该配置实际保存了其他设备名,冷启动后快速开启学习并点击按键会将按键绑定到错误设备,配置加载完成后该按键还会从当前列表消失。应在设备配置解析完成前禁用学习操作。

**设备错绑** 配置尚未加载时,`savedDeviceName` 为 `null`,这里会直接回退到 `ir-1`,但遥控按键仍可操作。若该配置实际保存了其他设备名,冷启动后快速开启学习并点击按键会将按键绑定到错误设备,配置加载完成后该按键还会从当前列表消失。应在设备配置解析完成前禁用学习操作。 <!-- pr-agent-dedup: 64f51ac400f4 --> <!-- pr-agent-key-issue-location: 40951f9578b1 -->
is DomainResult.Failure -> DomainResult.Failure(res.error) is DomainResult.Failure -> DomainResult.Failure(res.error)
} }
@@ -202,6 +214,9 @@ class InfraredRemoteViewModel @Inject constructor(
} catch (e: kotlinx.coroutines.CancellationException) { } catch (e: kotlinx.coroutines.CancellationException) {
throw e throw e
} finally { } finally {
if (underlyingLearningDeferred === deferred) {
underlyingLearningDeferred = null
}
if (activeLearningSlotFlow.value == spec.defaultSlot) { if (activeLearningSlotFlow.value == spec.defaultSlot) {
activeLearningSlotFlow.value = null activeLearningSlotFlow.value = null
} }
@@ -238,6 +253,8 @@ class InfraredRemoteViewModel @Inject constructor(
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
underlyingLearningDeferred?.cancel()
underlyingLearningDeferred = null
activeLearningJob?.cancel() activeLearningJob?.cancel()
activeLearningJob = null activeLearningJob = null
} }