feat(infrared): add infrared control, learning, and virtual remote module #9
+27
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+184
@@ -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,
|
||||
)
|
||||
}
|
||||
+58
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -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>
|
||||
}
|
||||
+12
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -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)
|
||||
}
|
||||
}
|
||||
+21
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -6,3 +6,4 @@ plugins {
|
||||
alias(libs.plugins.kotlin.kapt) apply false
|
||||
alias(libs.plugins.hilt.android) apply false
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user