feat: coordinate infrared runtime state

This commit is contained in:
2026-08-25 11:40:23 +08:00
committed by KremeCN
parent ff85b3eb45
commit 9ccbfbc297
8 changed files with 394 additions and 0 deletions
@@ -0,0 +1,27 @@
package com.flagship.abox.manager.infrared.application
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
import com.flagship.abox.manager.domain.infrared.LearnInfraredKey
import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
class InfraredLearningCoordinator(
private val learnInfraredKey: LearnInfraredKey,
private val retryInfraredKeyLearning: RetryInfraredKeyLearning,
private val externalScope: CoroutineScope,
) {
fun learn(profileId: ProviderProfileId, deviceName: DeviceName, displayName: String) {
externalScope.launch {
learnInfraredKey(profileId, deviceName, displayName)
}
}
fun retry(ref: InfraredKeyRef) {
externalScope.launch {
retryInfraredKeyLearning(ref)
}
}
}
@@ -0,0 +1,184 @@
package com.flagship.abox.manager.infrared.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile
import androidx.room.Room
import com.flagship.abox.manager.domain.InfraredGateway
import com.flagship.abox.manager.domain.infrared.EpochMillisClock
import com.flagship.abox.manager.domain.infrared.DeleteInfraredKey
import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository
import com.flagship.abox.manager.domain.infrared.InfraredKeyFactory
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyIdGenerator
import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository
import com.flagship.abox.manager.domain.infrared.LearnInfraredKey
import com.flagship.abox.manager.domain.infrared.RecoverInterruptedInfraredLearning
import com.flagship.abox.manager.domain.infrared.RenameInfraredKey
import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning
import com.flagship.abox.manager.domain.infrared.SendInfraredKey
import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator
import com.flagship.abox.manager.infrared.data.local.InfraredDao
import com.flagship.abox.manager.infrared.data.local.InfraredDatabase
import com.flagship.abox.manager.infrared.data.local.RoomInfraredKeyRepository
import com.flagship.abox.manager.infrared.runtime.DataStoreInfraredRuntimeModeStore
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import java.util.UUID
import javax.inject.Qualifier
import javax.inject.Singleton
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class InfraredApplicationScope
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class InfraredDataStore
@Module
@InstallIn(SingletonComponent::class)
object InfraredFeatureModule {
@Provides
@Singleton
fun provideInfraredDatabase(
@ApplicationContext context: Context,
): InfraredDatabase {
return Room.databaseBuilder(
context,
InfraredDatabase::class.java,
"abox_infrared.db",
).build()
}
@Provides
@Singleton
fun provideInfraredDao(database: InfraredDatabase): InfraredDao = database.infraredDao()
@Provides
@Singleton
fun provideRoomInfraredKeyRepository(dao: InfraredDao): RoomInfraredKeyRepository =
RoomInfraredKeyRepository(dao)
@Provides
@Singleton
fun provideInfraredKeyRepository(
repository: RoomInfraredKeyRepository,
): InfraredKeyRepository = repository
@Provides
@Singleton
fun provideInfraredDeviceConfigurationRepository(
repository: RoomInfraredKeyRepository,
): InfraredDeviceConfigurationRepository = repository
@Provides
@Singleton
@InfraredDataStore
fun provideInfraredPreferencesDataStore(
@ApplicationContext context: Context,
): DataStore<Preferences> {
return PreferenceDataStoreFactory.create(
produceFile = { context.preferencesDataStoreFile("infrared_preferences") },
)
}
@Provides
@Singleton
fun provideInfraredRuntimeModeStore(
@InfraredDataStore dataStore: DataStore<Preferences>,
): InfraredRuntimeModeStore = DataStoreInfraredRuntimeModeStore(dataStore)
@Provides
@Singleton
fun provideInfraredRuntimeProfileResolver(): InfraredRuntimeProfileResolver =
InfraredRuntimeProfileResolver()
@Provides
fun provideInfraredKeyIdGenerator(): InfraredKeyIdGenerator =
InfraredKeyIdGenerator { InfraredKeyId(UUID.randomUUID().toString()) }
@Provides
fun provideEpochMillisClock(): EpochMillisClock =
EpochMillisClock { System.currentTimeMillis() }
@Provides
@Singleton
fun provideInfraredKeyFactory(
idGenerator: InfraredKeyIdGenerator,
clock: EpochMillisClock,
): InfraredKeyFactory = InfraredKeyFactory(idGenerator, clock)
@Provides
@Singleton
@InfraredApplicationScope
fun provideInfraredApplicationScope(): CoroutineScope =
CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Provides
@Singleton
fun provideLearnInfraredKey(
repository: InfraredKeyRepository,
gateway: InfraredGateway,
factory: InfraredKeyFactory,
clock: EpochMillisClock,
): LearnInfraredKey = LearnInfraredKey(repository, gateway, factory, clock)
@Provides
@Singleton
fun provideRetryInfraredKeyLearning(
repository: InfraredKeyRepository,
gateway: InfraredGateway,
clock: EpochMillisClock,
): RetryInfraredKeyLearning = RetryInfraredKeyLearning(repository, gateway, clock)
@Provides
@Singleton
fun provideSendInfraredKey(
repository: InfraredKeyRepository,
gateway: InfraredGateway,
): SendInfraredKey = SendInfraredKey(repository, gateway)
@Provides
@Singleton
fun provideRenameInfraredKey(
repository: InfraredKeyRepository,
clock: EpochMillisClock,
): RenameInfraredKey = RenameInfraredKey(repository, clock)
@Provides
@Singleton
fun provideDeleteInfraredKey(
repository: InfraredKeyRepository,
): DeleteInfraredKey = DeleteInfraredKey(repository)
@Provides
@Singleton
fun provideRecoverInterruptedInfraredLearning(
repository: InfraredKeyRepository,
clock: EpochMillisClock,
): RecoverInterruptedInfraredLearning = RecoverInterruptedInfraredLearning(repository, clock)
@Provides
@Singleton
fun provideInfraredLearningCoordinator(
learnInfraredKey: LearnInfraredKey,
retryInfraredKeyLearning: RetryInfraredKeyLearning,
@InfraredApplicationScope scope: CoroutineScope,
): InfraredLearningCoordinator = InfraredLearningCoordinator(
learnInfraredKey = learnInfraredKey,
retryInfraredKeyLearning = retryInfraredKeyLearning,
externalScope = scope,
)
}
@@ -0,0 +1,58 @@
package com.flagship.abox.manager.infrared.runtime
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import java.io.IOException
class DataStoreInfraredRuntimeModeStore(
private val dataStore: DataStore<Preferences>,
) : InfraredRuntimeModeStore {
companion object {
private val KEY_RUNTIME_MODE = stringPreferencesKey("infrared_runtime_mode")
}
override val mode: Flow<InfraredRuntimeMode> = dataStore.data
.catch { exception ->
if (exception is IOException) {
emit(androidx.datastore.preferences.core.emptyPreferences())
} else {
throw exception
}
}
.map { preferences ->
val storedName = preferences[KEY_RUNTIME_MODE]
if (storedName != null) {
try {
InfraredRuntimeMode.valueOf(storedName)
} catch (_: IllegalArgumentException) {
InfraredRuntimeMode.FAKE
}
} else {
InfraredRuntimeMode.FAKE
}
}
override suspend fun currentMode(): InfraredRuntimeMode {
return mode.first()
}
override suspend fun setMode(mode: InfraredRuntimeMode): DomainResult<Unit> {
return try {
dataStore.edit { preferences ->
preferences[KEY_RUNTIME_MODE] = mode.name
}
DomainResult.Success(Unit)
} catch (throwable: Throwable) {
DomainResult.Failure(DomainError.Unknown(detail = "Failed to update runtime mode"))
}
}
}
@@ -0,0 +1,15 @@
package com.flagship.abox.manager.infrared.runtime
import com.flagship.abox.manager.domain.DomainResult
import kotlinx.coroutines.flow.Flow
enum class InfraredRuntimeMode {
FAKE,
REAL_ABSDK,
}
interface InfraredRuntimeModeStore {
val mode: Flow<InfraredRuntimeMode>
suspend fun currentMode(): InfraredRuntimeMode
suspend fun setMode(mode: InfraredRuntimeMode): DomainResult<Unit>
}
@@ -0,0 +1,12 @@
package com.flagship.abox.manager.infrared.runtime
import com.flagship.abox.manager.domain.ProviderProfileId
class InfraredRuntimeProfileResolver {
fun resolve(realProfileId: ProviderProfileId, mode: InfraredRuntimeMode): ProviderProfileId {
return when (mode) {
InfraredRuntimeMode.REAL_ABSDK -> realProfileId
InfraredRuntimeMode.FAKE -> ProviderProfileId("${realProfileId.value}:infrared-fake")
}
}
}
@@ -0,0 +1,76 @@
package com.flagship.abox.manager.infrared.application
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.EpochMillisClock
import com.flagship.abox.manager.domain.infrared.InfraredKeyFactory
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.RetryInfraredKeyLearning
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
import com.flagship.abox.manager.testing.fakes.FakeInfraredKeyRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class InfraredLearningCoordinatorTest {
private val profile = ProviderProfileId("profile1")
private val device = DeviceName("ir-1")
private val clock = EpochMillisClock { 1_000L }
private val repository = FakeInfraredKeyRepository()
private val gateway = FakeInfraredGateway()
private val factory = InfraredKeyFactory({ InfraredKeyId("key1") }, clock)
private val learn = LearnInfraredKey(repository, gateway, factory, clock)
private val retry = RetryInfraredKeyLearning(repository, gateway, clock)
@Test
fun learnRunsAsynchronouslyInExternalScope() = runTest {
val coordinator = InfraredLearningCoordinator(learn, retry, this)
coordinator.learn(profile, device, "Power")
advanceUntilIdle()
val keysResult = repository.observeKeys(profile, device).first()
assertTrue(keysResult is DomainResult.Success)
val keys = (keysResult as DomainResult.Success).value
assertEquals(1, keys.size)
assertEquals(InfraredKeyStatus.READY, keys.single().status)
}
@Test
fun retryRunsAsynchronouslyInExternalScope() = runTest {
val failedKey = SavedInfraredKey(
id = InfraredKeyId("key1"),
profileId = profile,
deviceName = device,
displayName = "Power",
sdkKey = "app_ir_key1",
status = InfraredKeyStatus.FAILED,
lastErrorCode = "20206",
lastErrorDetail = "offline",
createdAtEpochMillis = 500L,
updatedAtEpochMillis = 500L,
)
repository.insert(failedKey)
val coordinator = InfraredLearningCoordinator(learn, retry, this)
coordinator.retry(InfraredKeyRef(profile, InfraredKeyId("key1")))
advanceUntilIdle()
val updated = (repository.getKey(InfraredKeyRef(profile, InfraredKeyId("key1"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.READY, updated.status)
}
}
@@ -0,0 +1,21 @@
package com.flagship.abox.manager.infrared.runtime
import com.flagship.abox.manager.domain.ProviderProfileId
import org.junit.Assert.assertEquals
import org.junit.Test
class InfraredRuntimeProfileResolverTest {
@Test
fun realModeUsesAuthenticatedProfileAndFakeModeUsesIsolatedProfile() {
val resolver = InfraredRuntimeProfileResolver()
assertEquals(
ProviderProfileId("abox"),
resolver.resolve(ProviderProfileId("abox"), InfraredRuntimeMode.REAL_ABSDK),
)
assertEquals(
ProviderProfileId("abox:infrared-fake"),
resolver.resolve(ProviderProfileId("abox"), InfraredRuntimeMode.FAKE),
)
}
}
+1
View File
@@ -6,3 +6,4 @@ plugins {
alias(libs.plugins.kotlin.kapt) apply false alias(libs.plugins.kotlin.kapt) apply false
alias(libs.plugins.hilt.android) apply false alias(libs.plugins.hilt.android) apply false
} }