feat: route device operations by active provider

This commit is contained in:
2026-08-24 20:56:40 +08:00
parent 4121836947
commit 2ecca00371
5 changed files with 431 additions and 55 deletions
@@ -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()
}
@@ -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)
}
@@ -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<ProviderType> = 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<SocketStatus> = 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<SocketStatus> = 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<TemperatureHumidityReading> = 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<Unit> = 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<Unit> = 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<InfraredCode>,
): DomainResult<List<InfraredCodeDownloadResult>> = 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
}
@@ -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<SocketStatus> {
calls += 1
return DomainResult.Success(SocketStatus(profileId, deviceName, SocketPowerState.OFF))
}
override suspend fun setPower(
profileId: ProviderProfileId,
deviceName: DeviceName,
powerState: SocketPowerState,
): DomainResult<SocketStatus> {
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<TemperatureHumidityReading> {
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<Unit> {
calls += 1
return DomainResult.Success(Unit)
}
override suspend fun learnKey(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
): DomainResult<Unit> {
calls += 1
return DomainResult.Success(Unit)
}
override suspend fun downloadCodes(
profileId: ProviderProfileId,
codes: List<InfraredCode>,
): DomainResult<List<InfraredCodeDownloadResult>> {
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")
}
@@ -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<Session?> = flowOf(null)
override suspend fun login(
profileId: ProviderProfileId,
credentials: LoginCredentials,
): DomainResult<Session> = unavailable()
override suspend fun validateSession(
profileId: ProviderProfileId,
): DomainResult<SessionValidity> = unavailable()
override suspend fun logout(profileId: ProviderProfileId): DomainResult<Unit> =
DomainResult.Success(Unit)
private fun <T> unavailable(): DomainResult<T> = DomainResult.Failure(
DomainError.InvalidRequest(detail = "Remote providers are not enabled in this build"),
)
}