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 new file mode 100644 index 0000000..8dbc7ef --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt @@ -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) + } + } +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt new file mode 100644 index 0000000..5e2f51f --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt @@ -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 { + return PreferenceDataStoreFactory.create( + produceFile = { context.preferencesDataStoreFile("infrared_preferences") }, + ) + } + + @Provides + @Singleton + fun provideInfraredRuntimeModeStore( + @InfraredDataStore dataStore: DataStore, + ): 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, + ) +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/DataStoreInfraredRuntimeModeStore.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/DataStoreInfraredRuntimeModeStore.kt new file mode 100644 index 0000000..1ce2c0c --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/DataStoreInfraredRuntimeModeStore.kt @@ -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, +) : InfraredRuntimeModeStore { + + companion object { + private val KEY_RUNTIME_MODE = stringPreferencesKey("infrared_runtime_mode") + } + + override val mode: Flow = 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 { + 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")) + } + } +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeMode.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeMode.kt new file mode 100644 index 0000000..15f540d --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeMode.kt @@ -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 + suspend fun currentMode(): InfraredRuntimeMode + suspend fun setMode(mode: InfraredRuntimeMode): DomainResult +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeProfileResolver.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeProfileResolver.kt new file mode 100644 index 0000000..df0f84d --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeProfileResolver.kt @@ -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") + } + } +} diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinatorTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinatorTest.kt new file mode 100644 index 0000000..d9e2393 --- /dev/null +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinatorTest.kt @@ -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) + } +} diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeProfileResolverTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeProfileResolverTest.kt new file mode 100644 index 0000000..6249db9 --- /dev/null +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeProfileResolverTest.kt @@ -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), + ) + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 9b1f2d2..46c8d89 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,3 +6,4 @@ plugins { alias(libs.plugins.kotlin.kapt) apply false alias(libs.plugins.hilt.android) apply false } +