diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 9fce481..a278a2c 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -67,6 +67,7 @@ dependencies { implementation(project(":domain")) implementation(project(":android:core:designsystem")) implementation(project(":android:core:preferences")) + implementation(project(":android:core:devicecatalog")) implementation(project(":android:core:session")) implementation(project(":android:feature:infrared")) implementation(project(":absdk-bridge")) diff --git a/android/app/src/debug/kotlin/com/flagship/abox/manager/di/FoundationModule.kt b/android/app/src/debug/kotlin/com/flagship/abox/manager/di/FoundationModule.kt deleted file mode 100644 index cc805b8..0000000 --- a/android/app/src/debug/kotlin/com/flagship/abox/manager/di/FoundationModule.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.flagship.abox.manager.di - -import com.flagship.abox.manager.domain.DeviceCatalog -import com.flagship.abox.manager.testing.fakes.FakeDeviceCatalog -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -@Module -@InstallIn(SingletonComponent::class) -object FoundationModule { - // TEMPORARY boundary: ABSDK has no device catalog, and the provider-scoped production - // ABox catalog is owned by the device-catalog module. Until that exists, debug builds - // keep the fake ABox catalog here. Do NOT route ABox catalog requests to - // SimulationDeviceCatalog; release builds must not request a DeviceCatalog binding - // until a real release consumer appears. - @Provides - @Singleton - fun provideDeviceCatalog(): DeviceCatalog = FakeDeviceCatalog() -} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/di/DeviceGatewayModule.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/di/DeviceGatewayModule.kt index 88ee898..c1e027b 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/di/DeviceGatewayModule.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/di/DeviceGatewayModule.kt @@ -2,12 +2,16 @@ package com.flagship.abox.manager.di import com.flagship.abox.manager.absdk.bridge.AbsdkBridge import com.flagship.abox.manager.domain.InfraredGateway +import com.flagship.abox.manager.domain.DeviceCatalog import com.flagship.abox.manager.domain.ProviderProfileStore import com.flagship.abox.manager.domain.SocketGateway import com.flagship.abox.manager.domain.TemperatureHumidityGateway import com.flagship.abox.manager.simulation.SimulationInfraredGateway import com.flagship.abox.manager.simulation.SimulationSocketGateway import com.flagship.abox.manager.simulation.SimulationTemperatureHumidityGateway +import com.flagship.abox.manager.simulation.SimulationDeviceCatalog +import com.flagship.abox.manager.devicecatalog.RoutingDeviceCatalog +import com.flagship.abox.manager.devicecatalog.data.RoomConfiguredDeviceRepository import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -22,6 +26,18 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object DeviceGatewayModule { + @Provides + @Singleton + fun provideSimulationDeviceCatalog(): SimulationDeviceCatalog = SimulationDeviceCatalog() + + @Provides + @Singleton + fun provideDeviceCatalog( + profileStore: ProviderProfileStore, + configured: RoomConfiguredDeviceRepository, + simulation: SimulationDeviceCatalog, + ): DeviceCatalog = RoutingDeviceCatalog(profileStore, configured, simulation) + @Provides @Singleton fun provideSimulationSocketGateway(): SimulationSocketGateway = SimulationSocketGateway() diff --git a/android/core/devicecatalog/build.gradle.kts b/android/core/devicecatalog/build.gradle.kts new file mode 100644 index 0000000..d59a566 --- /dev/null +++ b/android/core/devicecatalog/build.gradle.kts @@ -0,0 +1,46 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.kapt) + alias(libs.plugins.hilt.android) +} + +android { + namespace = "com.flagship.abox.manager.devicecatalog" + compileSdk = 37 + + defaultConfig { + minSdk = 26 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +kotlin { + compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } +} + +kapt { + correctErrorTypes = true + arguments { arg("room.schemaLocation", "$projectDir/schemas") } +} + +dependencies { + api(project(":domain")) + implementation(libs.room.runtime) + implementation(libs.room.ktx) + kapt(libs.room.compiler) + implementation(libs.hilt.android) + kapt(libs.hilt.compiler) + implementation(libs.kotlinx.coroutines.core) + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.room.testing) + androidTestImplementation(libs.kotlinx.coroutines.test) +} diff --git a/android/core/devicecatalog/src/androidTest/kotlin/com/flagship/abox/manager/devicecatalog/RoomConfiguredDeviceCatalogTest.kt b/android/core/devicecatalog/src/androidTest/kotlin/com/flagship/abox/manager/devicecatalog/RoomConfiguredDeviceCatalogTest.kt new file mode 100644 index 0000000..f78936f --- /dev/null +++ b/android/core/devicecatalog/src/androidTest/kotlin/com/flagship/abox/manager/devicecatalog/RoomConfiguredDeviceCatalogTest.kt @@ -0,0 +1,57 @@ +package com.flagship.abox.manager.devicecatalog + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.flagship.abox.manager.devicecatalog.data.DeviceCatalogDatabase +import com.flagship.abox.manager.devicecatalog.data.RoomConfiguredDeviceRepository +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DeviceType +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RoomConfiguredDeviceCatalogTest { + private lateinit var database: DeviceCatalogDatabase + private lateinit var repository: RoomConfiguredDeviceRepository + + @Before + fun setUp() { + database = Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext(), DeviceCatalogDatabase::class.java).build() + repository = RoomConfiguredDeviceRepository(database.configuredDeviceDao()) + } + + @After + fun tearDown() = database.close() + + @Test + fun configuredDevicesAreIsolatedByProfile() = runTest { + repository.add(ProviderProfileId("one"), DeviceName("plug"), DeviceType.SOCKET) + val result = repository.observeDevices(ProviderProfileId("two")).first() as DomainResult.Success + assertTrue(result.value.isEmpty()) + } + + @Test + fun sameProfileRejectsDuplicateDeviceNameAcrossTypes() = runTest { + val profile = ProviderProfileId("one") + assertTrue(repository.add(profile, DeviceName("same"), DeviceType.SOCKET) is DomainResult.Success) + assertTrue(repository.add(profile, DeviceName("same"), DeviceType.INFRARED) is DomainResult.Failure) + } + + @Test + fun removeEmitsUpdatedCatalog() = runTest { + val profile = ProviderProfileId("one") + repository.add(profile, DeviceName("plug"), DeviceType.SOCKET) + repository.remove(profile, DeviceName("plug")) + val result = repository.observeDevices(profile).first() as DomainResult.Success + assertEquals(emptyList(), result.value) + } +} diff --git a/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/RoutingDeviceCatalog.kt b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/RoutingDeviceCatalog.kt new file mode 100644 index 0000000..30b050c --- /dev/null +++ b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/RoutingDeviceCatalog.kt @@ -0,0 +1,27 @@ +package com.flagship.abox.manager.devicecatalog + +import com.flagship.abox.manager.domain.DeviceCatalog +import com.flagship.abox.manager.domain.DeviceDescriptor +import com.flagship.abox.manager.domain.DomainError +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.ProviderProfileStore +import com.flagship.abox.manager.domain.ProviderType +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow + +class RoutingDeviceCatalog( + private val profileStore: ProviderProfileStore, + private val abox: DeviceCatalog, + private val local: DeviceCatalog, +) : DeviceCatalog { + override fun observeDevices(profileId: ProviderProfileId): Flow>> = flow { + val active = profileStore.activeProfile() + if (active?.id != profileId) { + emit(DomainResult.Failure(DomainError.InvalidRequest(detail = "Provider profile is not active"))) + return@flow + } + emitAll(if (active.type == ProviderType.ABOX) abox.observeDevices(profileId) else local.observeDevices(profileId)) + } +} diff --git a/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/ConfiguredDeviceDao.kt b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/ConfiguredDeviceDao.kt new file mode 100644 index 0000000..ffe44d0 --- /dev/null +++ b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/ConfiguredDeviceDao.kt @@ -0,0 +1,19 @@ +package com.flagship.abox.manager.devicecatalog.data + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +@Dao +interface ConfiguredDeviceDao { + @Query("SELECT * FROM configured_devices WHERE profileId = :profileId ORDER BY deviceName") + fun observe(profileId: String): Flow> + + @Insert(onConflict = OnConflictStrategy.ABORT) + suspend fun insert(device: ConfiguredDeviceEntity) + + @Query("DELETE FROM configured_devices WHERE profileId = :profileId AND deviceName = :deviceName") + suspend fun remove(profileId: String, deviceName: String): Int +} diff --git a/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/ConfiguredDeviceEntity.kt b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/ConfiguredDeviceEntity.kt new file mode 100644 index 0000000..d823784 --- /dev/null +++ b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/ConfiguredDeviceEntity.kt @@ -0,0 +1,16 @@ +package com.flagship.abox.manager.devicecatalog.data + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "configured_devices", + indices = [Index(value = ["profileId", "deviceName"], unique = true)], +) +data class ConfiguredDeviceEntity( + @PrimaryKey val id: String, + val profileId: String, + val deviceName: String, + val deviceType: String, +) diff --git a/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/ConfiguredDeviceRepository.kt b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/ConfiguredDeviceRepository.kt new file mode 100644 index 0000000..c29616f --- /dev/null +++ b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/ConfiguredDeviceRepository.kt @@ -0,0 +1,61 @@ +package com.flagship.abox.manager.devicecatalog.data + +import com.flagship.abox.manager.domain.DeviceCatalog +import com.flagship.abox.manager.domain.DeviceDescriptor +import com.flagship.abox.manager.domain.DeviceId +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DeviceType +import com.flagship.abox.manager.domain.DomainError +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +interface ConfiguredDeviceRepository { + fun observe(profileId: ProviderProfileId): Flow>> + suspend fun add(profileId: ProviderProfileId, name: DeviceName, type: DeviceType): DomainResult + suspend fun remove(profileId: ProviderProfileId, deviceName: DeviceName): DomainResult +} + +class RoomConfiguredDeviceRepository( + private val dao: ConfiguredDeviceDao, +) : ConfiguredDeviceRepository, DeviceCatalog { + override fun observe(profileId: ProviderProfileId): Flow>> = + dao.observe(profileId.value).map { entities -> DomainResult.Success(entities.map { it.toDomain() }) } + + override fun observeDevices(profileId: ProviderProfileId): Flow>> = observe(profileId) + + override suspend fun add(profileId: ProviderProfileId, name: DeviceName, type: DeviceType): DomainResult { + if (profileId.value.isBlank() || name.value.isBlank()) { + return DomainResult.Failure(DomainError.InvalidRequest(detail = "Profile and device name are required")) + } + val descriptor = DeviceDescriptor(DeviceId("${profileId.value}:${name.value}"), profileId, name, type) + return try { + dao.insert(descriptor.toEntity()) + DomainResult.Success(descriptor) + } catch (error: Exception) { + DomainResult.Failure(DomainError.InvalidRequest(detail = error.message ?: "Duplicate device name")) + } + } + + override suspend fun remove(profileId: ProviderProfileId, deviceName: DeviceName): DomainResult = try { + dao.remove(profileId.value, deviceName.value) + DomainResult.Success(Unit) + } catch (error: Exception) { + DomainResult.Failure(DomainError.Unknown(detail = error.message ?: "Unable to remove device")) + } +} + +private fun ConfiguredDeviceEntity.toDomain() = DeviceDescriptor( + id = DeviceId(id), + profileId = ProviderProfileId(profileId), + name = DeviceName(deviceName), + type = DeviceType.valueOf(deviceType), +) + +private fun DeviceDescriptor.toEntity() = ConfiguredDeviceEntity( + id = id.value, + profileId = profileId.value, + deviceName = name.value, + deviceType = type.name, +) diff --git a/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/DeviceCatalogDatabase.kt b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/DeviceCatalogDatabase.kt new file mode 100644 index 0000000..fde611b --- /dev/null +++ b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/DeviceCatalogDatabase.kt @@ -0,0 +1,9 @@ +package com.flagship.abox.manager.devicecatalog.data + +import androidx.room.Database +import androidx.room.RoomDatabase + +@Database(entities = [ConfiguredDeviceEntity::class], version = 1, exportSchema = false) +abstract class DeviceCatalogDatabase : RoomDatabase() { + abstract fun configuredDeviceDao(): ConfiguredDeviceDao +} diff --git a/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/di/DeviceCatalogModule.kt b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/di/DeviceCatalogModule.kt new file mode 100644 index 0000000..447f4b7 --- /dev/null +++ b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/di/DeviceCatalogModule.kt @@ -0,0 +1,31 @@ +package com.flagship.abox.manager.devicecatalog.di + +import android.content.Context +import androidx.room.Room +import com.flagship.abox.manager.devicecatalog.data.ConfiguredDeviceRepository +import com.flagship.abox.manager.devicecatalog.data.DeviceCatalogDatabase +import com.flagship.abox.manager.devicecatalog.data.RoomConfiguredDeviceRepository +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object DeviceCatalogModule { + @Provides + @Singleton + fun provideDatabase(@ApplicationContext context: Context): DeviceCatalogDatabase = + Room.databaseBuilder(context, DeviceCatalogDatabase::class.java, "abox_devices.db").build() + + @Provides + @Singleton + fun provideRepository(database: DeviceCatalogDatabase): RoomConfiguredDeviceRepository = + RoomConfiguredDeviceRepository(database.configuredDeviceDao()) + + @Provides + @Singleton + fun provideConfiguredDeviceRepository(repository: RoomConfiguredDeviceRepository): ConfiguredDeviceRepository = repository +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 8ed9912..b31f7a6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -32,6 +32,7 @@ include(":absdk-bridge") include(":testing:fakes") include(":android:core:designsystem") include(":android:core:preferences") +include(":android:core:devicecatalog") include(":android:core:session") include(":android:feature:infrared") include(":android:app")