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 index 2052c9e..cc805b8 100644 --- 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 @@ -1,14 +1,7 @@ package com.flagship.abox.manager.di import com.flagship.abox.manager.domain.DeviceCatalog -import com.flagship.abox.manager.domain.InfraredGateway -import com.flagship.abox.manager.domain.SocketGateway -import com.flagship.abox.manager.domain.TemperatureHumidityGateway import com.flagship.abox.manager.testing.fakes.FakeDeviceCatalog -import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway -import com.flagship.abox.manager.testing.fakes.FakeSessionGateway -import com.flagship.abox.manager.testing.fakes.FakeSocketGateway -import com.flagship.abox.manager.testing.fakes.FakeTemperatureHumidityGateway import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -18,24 +11,12 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object FoundationModule { - @Provides - @Singleton - fun provideFakeSessionGateway(): FakeSessionGateway = FakeSessionGateway() - + // 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() - - @Provides - @Singleton - fun provideSocketGateway(): SocketGateway = FakeSocketGateway() - - @Provides - @Singleton - fun provideTemperatureHumidityGateway(): TemperatureHumidityGateway = - FakeTemperatureHumidityGateway() - - @Provides - @Singleton - fun provideInfraredGateway(): InfraredGateway = FakeInfraredGateway() } 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 new file mode 100644 index 0000000..88ee898 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/di/DeviceGatewayModule.kt @@ -0,0 +1,62 @@ +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.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 dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** + * Binds each public device gateway to a router that dispatches on the active provider: + * ABox goes to the shared real [AbsdkBridge] and local experience goes to the shared + * production simulation gateway. + */ +@Module +@InstallIn(SingletonComponent::class) +object DeviceGatewayModule { + @Provides + @Singleton + fun provideSimulationSocketGateway(): SimulationSocketGateway = SimulationSocketGateway() + + @Provides + @Singleton + fun provideSimulationTemperatureHumidityGateway(): SimulationTemperatureHumidityGateway = + SimulationTemperatureHumidityGateway() + + @Provides + @Singleton + fun provideSimulationInfraredGateway(): SimulationInfraredGateway = SimulationInfraredGateway() + + @Provides + @Singleton + fun provideSocketGateway( + profileStore: ProviderProfileStore, + bridge: AbsdkBridge, + simulation: SimulationSocketGateway, + ): SocketGateway = RoutingSocketGateway(profileStore, bridge, simulation) + + @Provides + @Singleton + fun provideTemperatureHumidityGateway( + profileStore: ProviderProfileStore, + bridge: AbsdkBridge, + simulation: SimulationTemperatureHumidityGateway, + ): TemperatureHumidityGateway = + RoutingTemperatureHumidityGateway(profileStore, bridge, simulation) + + @Provides + @Singleton + fun provideInfraredGateway( + profileStore: ProviderProfileStore, + bridge: AbsdkBridge, + simulation: SimulationInfraredGateway, + ): InfraredGateway = RoutingInfraredGateway(profileStore, bridge, simulation) +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/di/ProviderRoutingGateways.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/di/ProviderRoutingGateways.kt new file mode 100644 index 0000000..5d019c7 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/di/ProviderRoutingGateways.kt @@ -0,0 +1,134 @@ +package com.flagship.abox.manager.di + +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DomainError +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.InfraredCode +import com.flagship.abox.manager.domain.InfraredCodeDownloadResult +import com.flagship.abox.manager.domain.InfraredGateway +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.ProviderProfileStore +import com.flagship.abox.manager.domain.ProviderType +import com.flagship.abox.manager.domain.SocketGateway +import com.flagship.abox.manager.domain.SocketPowerState +import com.flagship.abox.manager.domain.SocketStatus +import com.flagship.abox.manager.domain.TemperatureHumidityGateway +import com.flagship.abox.manager.domain.TemperatureHumidityReading +import kotlinx.coroutines.CancellationException + +/** + * Resolves the provider type a device operation must be routed to. + * + * A device call is only valid for the currently active provider profile: a missing + * active profile or a mismatched [profileId] is rejected with [DomainError.InvalidRequest] + * before any gateway is invoked. + */ +internal class ActiveProviderGatewayResolver( + private val profileStore: ProviderProfileStore, +) { + suspend fun resolve(profileId: ProviderProfileId): DomainResult = try { + val active = profileStore.activeProfile() + when { + active == null -> + DomainResult.Failure(DomainError.InvalidRequest(detail = "No active provider")) + active.id != profileId -> + DomainResult.Failure( + DomainError.InvalidRequest(detail = "Provider profile is not active"), + ) + else -> DomainResult.Success(active.type) + } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + DomainResult.Failure( + DomainError.Unknown(detail = error.message ?: error::class.java.simpleName), + ) + } +} + +/** Routes socket operations to the real ABSDK bridge or the simulation gateway by active provider. */ +internal class RoutingSocketGateway( + profileStore: ProviderProfileStore, + private val real: SocketGateway, + private val simulation: SocketGateway, +) : SocketGateway { + private val resolver = ActiveProviderGatewayResolver(profileStore) + + override suspend fun getStatus( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): DomainResult = when (val provider = resolver.resolve(profileId)) { + is DomainResult.Failure -> provider + is DomainResult.Success -> delegate(provider.value).getStatus(profileId, deviceName) + } + + override suspend fun setPower( + profileId: ProviderProfileId, + deviceName: DeviceName, + powerState: SocketPowerState, + ): DomainResult = when (val provider = resolver.resolve(profileId)) { + is DomainResult.Failure -> provider + is DomainResult.Success -> delegate(provider.value).setPower(profileId, deviceName, powerState) + } + + private fun delegate(type: ProviderType): SocketGateway = + if (type == ProviderType.ABOX) real else simulation +} + +/** Routes temperature/humidity operations by active provider, mirroring [RoutingSocketGateway]. */ +internal class RoutingTemperatureHumidityGateway( + profileStore: ProviderProfileStore, + private val real: TemperatureHumidityGateway, + private val simulation: TemperatureHumidityGateway, +) : TemperatureHumidityGateway { + private val resolver = ActiveProviderGatewayResolver(profileStore) + + override suspend fun getReading( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): DomainResult = when (val provider = resolver.resolve(profileId)) { + is DomainResult.Failure -> provider + is DomainResult.Success -> delegate(provider.value).getReading(profileId, deviceName) + } + + private fun delegate(type: ProviderType): TemperatureHumidityGateway = + if (type == ProviderType.ABOX) real else simulation +} + +/** Routes infrared operations by active provider, mirroring [RoutingSocketGateway]. */ +internal class RoutingInfraredGateway( + profileStore: ProviderProfileStore, + private val real: InfraredGateway, + private val simulation: InfraredGateway, +) : InfraredGateway { + private val resolver = ActiveProviderGatewayResolver(profileStore) + + override suspend fun sendKey( + profileId: ProviderProfileId, + deviceName: DeviceName, + key: String, + ): DomainResult = when (val provider = resolver.resolve(profileId)) { + is DomainResult.Failure -> provider + is DomainResult.Success -> delegate(provider.value).sendKey(profileId, deviceName, key) + } + + override suspend fun learnKey( + profileId: ProviderProfileId, + deviceName: DeviceName, + key: String, + ): DomainResult = when (val provider = resolver.resolve(profileId)) { + is DomainResult.Failure -> provider + is DomainResult.Success -> delegate(provider.value).learnKey(profileId, deviceName, key) + } + + override suspend fun downloadCodes( + profileId: ProviderProfileId, + codes: List, + ): DomainResult> = when (val provider = resolver.resolve(profileId)) { + is DomainResult.Failure -> provider + is DomainResult.Success -> delegate(provider.value).downloadCodes(profileId, codes) + } + + private fun delegate(type: ProviderType): InfraredGateway = + if (type == ProviderType.ABOX) real else simulation +} diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/di/ProviderRoutingGatewaysTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/di/ProviderRoutingGatewaysTest.kt new file mode 100644 index 0000000..1fa2587 --- /dev/null +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/di/ProviderRoutingGatewaysTest.kt @@ -0,0 +1,230 @@ +package com.flagship.abox.manager.di + +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DomainError +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.InfraredCode +import com.flagship.abox.manager.domain.InfraredCodeDownloadResult +import com.flagship.abox.manager.domain.InfraredGateway +import com.flagship.abox.manager.domain.ProviderProfile +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.ProviderProfileStore +import com.flagship.abox.manager.domain.ProviderType +import com.flagship.abox.manager.domain.SocketGateway +import com.flagship.abox.manager.domain.SocketPowerState +import com.flagship.abox.manager.domain.SocketStatus +import com.flagship.abox.manager.domain.TemperatureHumidityGateway +import com.flagship.abox.manager.domain.TemperatureHumidityReading +import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ProviderRoutingGatewaysTest { + @Test + fun socketRoutesAboxToRealAndLocalToSimulation() = runTest { + val store = FakeProviderProfileStore() + val real = RecordingSocketGateway() + val simulation = RecordingSocketGateway() + val router = RoutingSocketGateway(store, real, simulation) + + val aboxId = activate(store, ProviderType.ABOX) + router.getStatus(aboxId, DeviceName("socket-1")) + assertEquals(1, real.calls) + assertEquals(0, simulation.calls) + + val localId = activate(store, ProviderType.LOCAL_EXPERIENCE) + router.getStatus(localId, DeviceName("socket-1")) + assertEquals(1, real.calls) + assertEquals(1, simulation.calls) + } + + @Test + fun temperatureHumidityRoutesAboxToRealAndLocalToSimulation() = runTest { + val store = FakeProviderProfileStore() + val real = RecordingTemperatureHumidityGateway() + val simulation = RecordingTemperatureHumidityGateway() + val router = RoutingTemperatureHumidityGateway(store, real, simulation) + + val aboxId = activate(store, ProviderType.ABOX) + router.getReading(aboxId, DeviceName("th-1")) + assertEquals(1, real.calls) + assertEquals(0, simulation.calls) + + val localId = activate(store, ProviderType.LOCAL_EXPERIENCE) + router.getReading(localId, DeviceName("th-1")) + assertEquals(1, real.calls) + assertEquals(1, simulation.calls) + } + + @Test + fun infraredRoutesAboxToRealAndLocalToSimulation() = runTest { + val store = FakeProviderProfileStore() + val real = RecordingInfraredGateway() + val simulation = RecordingInfraredGateway() + val router = RoutingInfraredGateway(store, real, simulation) + + val aboxId = activate(store, ProviderType.ABOX) + router.sendKey(aboxId, DeviceName("ir-1"), "power") + assertEquals(1, real.calls) + assertEquals(0, simulation.calls) + + val localId = activate(store, ProviderType.LOCAL_EXPERIENCE) + router.sendKey(localId, DeviceName("ir-1"), "power") + assertEquals(1, real.calls) + assertEquals(1, simulation.calls) + } + + @Test + fun missingActiveProfileReturnsInvalidRequest() = runTest { + val router = RoutingSocketGateway( + FakeProviderProfileStore(), + RecordingSocketGateway(), + RecordingSocketGateway(), + ) + + val result = router.getStatus(ProviderProfileId("any"), DeviceName("socket-1")) + + assertTrue(result is DomainResult.Failure) + assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest) + } + + @Test + fun mismatchedActiveProfileReturnsInvalidRequest() = runTest { + val store = FakeProviderProfileStore() + val real = RecordingSocketGateway() + val simulation = RecordingSocketGateway() + val router = RoutingSocketGateway(store, real, simulation) + activate(store, ProviderType.ABOX) + + val result = router.getStatus(ProviderProfileId("other"), DeviceName("socket-1")) + + assertTrue(result is DomainResult.Failure) + assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest) + assertEquals(0, real.calls) + assertEquals(0, simulation.calls) + } + + @Test + fun cancellationFromProfileStoreIsRethrown() = runTest { + val router = RoutingSocketGateway( + CancellingProviderProfileStore(), + RecordingSocketGateway(), + RecordingSocketGateway(), + ) + + try { + router.getStatus(ProviderProfileId("any"), DeviceName("socket-1")) + error("Expected CancellationException") + } catch (expected: CancellationException) { + // Rethrown so coroutine cancellation is never swallowed. + } + } + + @Test + fun unexpectedProfileStoreErrorReturnsUnknown() = runTest { + val router = RoutingSocketGateway( + FailingProviderProfileStore(), + RecordingSocketGateway(), + RecordingSocketGateway(), + ) + + val result = router.getStatus(ProviderProfileId("any"), DeviceName("socket-1")) + + assertTrue(result is DomainResult.Failure) + val error = (result as DomainResult.Failure).error + assertTrue(error is DomainError.Unknown) + assertEquals("storage exploded", error.detail) + } + + private suspend fun activate( + store: FakeProviderProfileStore, + type: ProviderType, + ): ProviderProfileId { + val id = ProviderProfileId(type.name.lowercase()) + store.saveProfile(ProviderProfile(id, type, type.name)) + store.setActiveProfile(id) + return id + } +} + +private class RecordingSocketGateway : SocketGateway { + var calls: Int = 0 + private set + + override suspend fun getStatus( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): DomainResult { + calls += 1 + return DomainResult.Success(SocketStatus(profileId, deviceName, SocketPowerState.OFF)) + } + + override suspend fun setPower( + profileId: ProviderProfileId, + deviceName: DeviceName, + powerState: SocketPowerState, + ): DomainResult { + calls += 1 + return DomainResult.Success(SocketStatus(profileId, deviceName, powerState)) + } +} + +private class RecordingTemperatureHumidityGateway : TemperatureHumidityGateway { + var calls: Int = 0 + private set + + override suspend fun getReading( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): DomainResult { + calls += 1 + return DomainResult.Success(TemperatureHumidityReading(profileId, deviceName, "0", "0")) + } +} + +private class RecordingInfraredGateway : InfraredGateway { + var calls: Int = 0 + private set + + override suspend fun sendKey( + profileId: ProviderProfileId, + deviceName: DeviceName, + key: String, + ): DomainResult { + calls += 1 + return DomainResult.Success(Unit) + } + + override suspend fun learnKey( + profileId: ProviderProfileId, + deviceName: DeviceName, + key: String, + ): DomainResult { + calls += 1 + return DomainResult.Success(Unit) + } + + override suspend fun downloadCodes( + profileId: ProviderProfileId, + codes: List, + ): DomainResult> { + calls += 1 + return DomainResult.Success(codes.map { InfraredCodeDownloadResult(it, successful = true) }) + } +} + +private class CancellingProviderProfileStore( + private val delegate: ProviderProfileStore = FakeProviderProfileStore(), +) : ProviderProfileStore by delegate { + override suspend fun activeProfile(): Nothing = throw CancellationException("cancelled") +} + +private class FailingProviderProfileStore( + private val delegate: ProviderProfileStore = FakeProviderProfileStore(), +) : ProviderProfileStore by delegate { + override suspend fun activeProfile(): Nothing = + throw IllegalStateException("storage exploded") +} diff --git a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/gateway/UnavailableRemoteSessionGateway.kt b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/gateway/UnavailableRemoteSessionGateway.kt deleted file mode 100644 index 5a8111c..0000000 --- a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/gateway/UnavailableRemoteSessionGateway.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.flagship.abox.manager.session.gateway - -import com.flagship.abox.manager.domain.DomainError -import com.flagship.abox.manager.domain.DomainResult -import com.flagship.abox.manager.domain.LoginCredentials -import com.flagship.abox.manager.domain.ProviderProfileId -import com.flagship.abox.manager.domain.Session -import com.flagship.abox.manager.domain.SessionGateway -import com.flagship.abox.manager.domain.SessionValidity -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOf - -class UnavailableRemoteSessionGateway : SessionGateway { - override fun observeSession(profileId: ProviderProfileId): Flow = flowOf(null) - - override suspend fun login( - profileId: ProviderProfileId, - credentials: LoginCredentials, - ): DomainResult = unavailable() - - override suspend fun validateSession( - profileId: ProviderProfileId, - ): DomainResult = unavailable() - - override suspend fun logout(profileId: ProviderProfileId): DomainResult = - DomainResult.Success(Unit) - - private fun unavailable(): DomainResult = DomainResult.Failure( - DomainError.InvalidRequest(detail = "Remote providers are not enabled in this build"), - ) -}