feat: add provider login flow (#4)
## Summary - replace the former three-provider model with two pre-login choices: - **ABox**: authenticate through the vendor ABSDK on the current local network - **Local experience**: establish a production simulation session without credentials, network access, or ABSDK calls - remove the compatible-backend product/server scope and safely ignore legacy persisted compatible-provider values - bind one process-wide `AbsdkBridge` for ABox session, socket, temperature/humidity, and infrared operations - add production `:runtime:simulation` implementations and route device operations by the active provider - retain provider-scoped username preferences, Keystore-encrypted automatic-login credentials, token recovery, logout, and session isolation - add regression coverage for authentication gating, profile/session alignment, provider switching cleanup, routing, and simulation behavior - align requirement, plan, architecture, and protocol documentation with LAN ABSDK access ## Verification ```text ./gradlew check testDebugUnitTest test BUILD SUCCESSFUL (220 actionable tasks) ./gradlew :android:app:assembleDebug :android:app:assembleRelease BUILD SUCCESSFUL (217 actionable tasks) ./gradlew :android:app:compileDebugAndroidTestKotlin BUILD SUCCESSFUL git diff --check passed ``` Emulator smoke validation: - provider selection displays ABox and Local experience only - Local experience enters the authenticated route without credentials - Local experience also enters while emulator Wi-Fi/data are disabled - Local Sign out and Switch provider return to provider selection - ABox opens the credential form; empty submission stays on login with the required-fields error ## Known constraints and accepted risks - The vendor JAR uses cleartext HTTP and hardcodes `a-box.com.cn`; Debug and Release intentionally share the same ABox capability under the approved no-Release-exception decision. `@CleartextAbsdkTransport`, `usesCleartextTraffic`, and the architecture/protocol documents explicitly record this risk. This does not provide confidentiality or integrity. - Real authenticated ABox and read-only hardware smoke validation is deferred because the gateway is currently unavailable locally. This PR does not claim that current-environment validation completed. - The mapping from the vendor JAR's hardcoded hostname to the LAN gateway remains an external network/deployment prerequisite. The ABSDK JAR and bridge path remain identical to the previously hardware-validated `feat/moler-infrared-control` branch. - `FakeDeviceCatalog` remains a temporary debug-only placeholder until the device-catalog module provides the production ABox catalog. ABox catalog requests are not routed to Local experience simulation. Reviewed-on: #4 Co-authored-by: KremeCN <3265494849@qq.com>
This commit was merged in pull request #4.
This commit is contained in:
+50
@@ -0,0 +1,50 @@
|
||||
package com.flagship.abox.manager.simulation
|
||||
|
||||
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.DomainResult
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
class SimulationDeviceCatalog : DeviceCatalog {
|
||||
private val devices = ConcurrentHashMap<
|
||||
ProviderProfileId,
|
||||
MutableStateFlow<DomainResult<List<DeviceDescriptor>>>,
|
||||
>()
|
||||
|
||||
override fun observeDevices(
|
||||
profileId: ProviderProfileId,
|
||||
): Flow<DomainResult<List<DeviceDescriptor>>> = deviceFlow(profileId)
|
||||
|
||||
private fun deviceFlow(
|
||||
profileId: ProviderProfileId,
|
||||
): MutableStateFlow<DomainResult<List<DeviceDescriptor>>> = devices.getOrPut(profileId) {
|
||||
MutableStateFlow(DomainResult.Success(defaultDevices(profileId)))
|
||||
}
|
||||
|
||||
private fun defaultDevices(profileId: ProviderProfileId): List<DeviceDescriptor> = listOf(
|
||||
DeviceDescriptor(
|
||||
id = DeviceId("${profileId.value}:socket-1"),
|
||||
profileId = profileId,
|
||||
name = DeviceName("socket-1"),
|
||||
type = DeviceType.SOCKET,
|
||||
),
|
||||
DeviceDescriptor(
|
||||
id = DeviceId("${profileId.value}:th-1"),
|
||||
profileId = profileId,
|
||||
name = DeviceName("th-1"),
|
||||
type = DeviceType.TEMPERATURE_HUMIDITY,
|
||||
),
|
||||
DeviceDescriptor(
|
||||
id = DeviceId("${profileId.value}:ir-1"),
|
||||
profileId = profileId,
|
||||
name = DeviceName("ir-1"),
|
||||
type = DeviceType.INFRARED,
|
||||
),
|
||||
)
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package com.flagship.abox.manager.simulation
|
||||
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
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.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 java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class SimulationSocketGateway : SocketGateway {
|
||||
private val states = ConcurrentHashMap<DeviceKey, SocketPowerState>()
|
||||
|
||||
override suspend fun getStatus(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
): DomainResult<SocketStatus> {
|
||||
val key = DeviceKey(profileId, deviceName)
|
||||
return DomainResult.Success(SocketStatus(profileId, deviceName, states[key] ?: SocketPowerState.OFF))
|
||||
}
|
||||
|
||||
override suspend fun setPower(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
powerState: SocketPowerState,
|
||||
): DomainResult<SocketStatus> {
|
||||
val key = DeviceKey(profileId, deviceName)
|
||||
states[key] = powerState
|
||||
return DomainResult.Success(SocketStatus(profileId, deviceName, powerState))
|
||||
}
|
||||
}
|
||||
|
||||
class SimulationTemperatureHumidityGateway : TemperatureHumidityGateway {
|
||||
override suspend fun getReading(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
): DomainResult<TemperatureHumidityReading> = DomainResult.Success(
|
||||
TemperatureHumidityReading(
|
||||
profileId = profileId,
|
||||
deviceName = deviceName,
|
||||
temperature = "23.5",
|
||||
humidity = "45",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
class SimulationInfraredGateway : InfraredGateway {
|
||||
override suspend fun sendKey(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
key: String,
|
||||
): DomainResult<Unit> = DomainResult.Success(Unit)
|
||||
|
||||
override suspend fun learnKey(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
key: String,
|
||||
): DomainResult<Unit> = DomainResult.Success(Unit)
|
||||
|
||||
override suspend fun downloadCodes(
|
||||
profileId: ProviderProfileId,
|
||||
codes: List<InfraredCode>,
|
||||
): DomainResult<List<InfraredCodeDownloadResult>> =
|
||||
DomainResult.Success(codes.map { InfraredCodeDownloadResult(it, successful = true) })
|
||||
}
|
||||
|
||||
private data class DeviceKey(
|
||||
val profileId: ProviderProfileId,
|
||||
val deviceName: DeviceName,
|
||||
)
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package com.flagship.abox.manager.simulation
|
||||
|
||||
import com.flagship.abox.manager.domain.DomainResult
|
||||
import com.flagship.abox.manager.domain.LocalSessionGateway
|
||||
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.SessionValidity
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
class SimulationSessionGateway : LocalSessionGateway {
|
||||
private val sessions = ConcurrentHashMap<ProviderProfileId, MutableStateFlow<Session?>>()
|
||||
|
||||
override fun observeSession(profileId: ProviderProfileId): Flow<Session?> = flow(profileId)
|
||||
|
||||
override suspend fun login(
|
||||
profileId: ProviderProfileId,
|
||||
credentials: LoginCredentials,
|
||||
): DomainResult<Session> = DomainResult.Success(
|
||||
Session(profileId, credentials.username).also { flow(profileId).value = it },
|
||||
)
|
||||
|
||||
override suspend fun validateSession(
|
||||
profileId: ProviderProfileId,
|
||||
): DomainResult<SessionValidity> = DomainResult.Success(
|
||||
if (flow(profileId).value == null) SessionValidity.INVALID else SessionValidity.VALID,
|
||||
)
|
||||
|
||||
override suspend fun logout(profileId: ProviderProfileId): DomainResult<Unit> {
|
||||
flow(profileId).value = null
|
||||
return DomainResult.Success(Unit)
|
||||
}
|
||||
|
||||
private fun flow(profileId: ProviderProfileId): MutableStateFlow<Session?> =
|
||||
sessions.getOrPut(profileId) { MutableStateFlow(null) }
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package com.flagship.abox.manager.simulation
|
||||
|
||||
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 com.flagship.abox.manager.domain.SocketPowerState
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SimulationDeviceGatewaysTest {
|
||||
@Test
|
||||
fun catalogProvidesSocketTemperatureHumidityAndInfraredDevicesForEachProfile() = runTest {
|
||||
val profile = ProviderProfileId("local")
|
||||
val devices =
|
||||
(SimulationDeviceCatalog().observeDevices(profile).first() as DomainResult.Success).value
|
||||
assertEquals(
|
||||
setOf(DeviceType.SOCKET, DeviceType.TEMPERATURE_HUMIDITY, DeviceType.INFRARED),
|
||||
devices.map { it.type }.toSet(),
|
||||
)
|
||||
assertTrue(devices.all { it.profileId == profile })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun socketStateIsMutableAndIsolatedByProfile() = runTest {
|
||||
val gateway = SimulationSocketGateway()
|
||||
val device = DeviceName("socket-1")
|
||||
val first = ProviderProfileId("first")
|
||||
val second = ProviderProfileId("second")
|
||||
gateway.setPower(first, device, SocketPowerState.ON)
|
||||
assertEquals(
|
||||
SocketPowerState.ON,
|
||||
(gateway.getStatus(first, device) as DomainResult.Success).value.powerState,
|
||||
)
|
||||
assertEquals(
|
||||
SocketPowerState.OFF,
|
||||
(gateway.getStatus(second, device) as DomainResult.Success).value.powerState,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sensorAndInfraredOperationsSucceedWithoutNetworkConfiguration() = runTest {
|
||||
val profile = ProviderProfileId("local")
|
||||
val th = SimulationTemperatureHumidityGateway().getReading(profile, DeviceName("th-1"))
|
||||
assertTrue(th is DomainResult.Success)
|
||||
assertEquals(
|
||||
DomainResult.Success(Unit),
|
||||
SimulationInfraredGateway().sendKey(profile, DeviceName("ir-1"), "power"),
|
||||
)
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.flagship.abox.manager.simulation
|
||||
|
||||
import com.flagship.abox.manager.domain.DomainResult
|
||||
import com.flagship.abox.manager.domain.LoginCredentials
|
||||
import com.flagship.abox.manager.domain.Password
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.SessionValidity
|
||||
import com.flagship.abox.manager.domain.Username
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class SimulationSessionGatewayTest {
|
||||
@Test
|
||||
fun loginObserveValidateAndLogoutAreProfileScoped() = runTest {
|
||||
val gateway = SimulationSessionGateway()
|
||||
val first = ProviderProfileId("local-one")
|
||||
val second = ProviderProfileId("local-two")
|
||||
|
||||
val result = gateway.login(first, LoginCredentials(Username("local"), Password("")))
|
||||
|
||||
assertEquals(Username("local"), (result as DomainResult.Success).value.username)
|
||||
assertEquals(first, gateway.observeSession(first).first()?.profileId)
|
||||
assertNull(gateway.observeSession(second).first())
|
||||
assertEquals(DomainResult.Success(SessionValidity.VALID), gateway.validateSession(first))
|
||||
gateway.logout(first)
|
||||
assertEquals(DomainResult.Success(SessionValidity.INVALID), gateway.validateSession(first))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user