feat: add provider login flow #4
@@ -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
|
||||
}
|
||||
+230
@@ -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")
|
||||
}
|
||||
-31
@@ -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"),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user