feat(ui): complete ABox app experience redesign #10
@@ -67,6 +67,7 @@ dependencies {
|
|||||||
implementation(project(":domain"))
|
implementation(project(":domain"))
|
||||||
implementation(project(":android:core:designsystem"))
|
implementation(project(":android:core:designsystem"))
|
||||||
implementation(project(":android:core:preferences"))
|
implementation(project(":android:core:preferences"))
|
||||||
|
implementation(project(":android:core:devicecatalog"))
|
||||||
implementation(project(":android:core:session"))
|
implementation(project(":android:core:session"))
|
||||||
implementation(project(":android:feature:infrared"))
|
implementation(project(":android:feature:infrared"))
|
||||||
implementation(project(":absdk-bridge"))
|
implementation(project(":absdk-bridge"))
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -2,12 +2,16 @@ package com.flagship.abox.manager.di
|
|||||||
|
|
||||||
import com.flagship.abox.manager.absdk.bridge.AbsdkBridge
|
import com.flagship.abox.manager.absdk.bridge.AbsdkBridge
|
||||||
import com.flagship.abox.manager.domain.InfraredGateway
|
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.ProviderProfileStore
|
||||||
import com.flagship.abox.manager.domain.SocketGateway
|
import com.flagship.abox.manager.domain.SocketGateway
|
||||||
import com.flagship.abox.manager.domain.TemperatureHumidityGateway
|
import com.flagship.abox.manager.domain.TemperatureHumidityGateway
|
||||||
import com.flagship.abox.manager.simulation.SimulationInfraredGateway
|
import com.flagship.abox.manager.simulation.SimulationInfraredGateway
|
||||||
import com.flagship.abox.manager.simulation.SimulationSocketGateway
|
import com.flagship.abox.manager.simulation.SimulationSocketGateway
|
||||||
import com.flagship.abox.manager.simulation.SimulationTemperatureHumidityGateway
|
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.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
@@ -22,6 +26,18 @@ import javax.inject.Singleton
|
|||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
object DeviceGatewayModule {
|
object DeviceGatewayModule {
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideSimulationDeviceCatalog(): SimulationDeviceCatalog = SimulationDeviceCatalog()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideDeviceCatalog(
|
||||||
|
profileStore: ProviderProfileStore,
|
||||||
|
configured: RoomConfiguredDeviceRepository,
|
||||||
|
simulation: SimulationDeviceCatalog,
|
||||||
|
): DeviceCatalog = RoutingDeviceCatalog(profileStore, configured, simulation)
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideSimulationSocketGateway(): SimulationSocketGateway = SimulationSocketGateway()
|
fun provideSimulationSocketGateway(): SimulationSocketGateway = SimulationSocketGateway()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
+57
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
@@ -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
|
||||||
|
}
|
||||||
+16
@@ -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,
|
||||||
|
)
|
||||||
+61
@@ -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,
|
||||||
|
)
|
||||||
+9
@@ -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
|
||||||
|
}
|
||||||
+31
@@ -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
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ include(":absdk-bridge")
|
|||||||
include(":testing:fakes")
|
include(":testing:fakes")
|
||||||
include(":android:core:designsystem")
|
include(":android:core:designsystem")
|
||||||
include(":android:core:preferences")
|
include(":android:core:preferences")
|
||||||
|
include(":android:core:devicecatalog")
|
||||||
include(":android:core:session")
|
include(":android:core:session")
|
||||||
include(":android:feature:infrared")
|
include(":android:feature:infrared")
|
||||||
include(":android:app")
|
include(":android:app")
|
||||||
|
|||||||
Reference in New Issue
Block a user