feat: add profile scoped device catalog

This commit is contained in:
2026-08-26 19:11:36 +08:00
parent 6e78b71d31
commit e14b4cee27
12 changed files with 284 additions and 22 deletions
@@ -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)
}
@@ -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<Any>(), result.value)
}
}
@@ -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<DomainResult<List<DeviceDescriptor>>> = 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))
}
}
@@ -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<List<ConfiguredDeviceEntity>>
@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
}
@@ -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,
)
@@ -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<DomainResult<List<DeviceDescriptor>>>
suspend fun add(profileId: ProviderProfileId, name: DeviceName, type: DeviceType): DomainResult<DeviceDescriptor>
suspend fun remove(profileId: ProviderProfileId, deviceName: DeviceName): DomainResult<Unit>
}
class RoomConfiguredDeviceRepository(
private val dao: ConfiguredDeviceDao,
) : ConfiguredDeviceRepository, DeviceCatalog {
override fun observe(profileId: ProviderProfileId): Flow<DomainResult<List<DeviceDescriptor>>> =
dao.observe(profileId.value).map { entities -> DomainResult.Success(entities.map { it.toDomain() }) }
override fun observeDevices(profileId: ProviderProfileId): Flow<DomainResult<List<DeviceDescriptor>>> = observe(profileId)
override suspend fun add(profileId: ProviderProfileId, name: DeviceName, type: DeviceType): DomainResult<DeviceDescriptor> {
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<Unit> = 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,
)
@@ -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
}
@@ -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
}