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:
+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")
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.flagship.abox.manager.testing
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.rules.TestWatcher
|
||||
import org.junit.runner.Description
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class MainDispatcherRule(
|
||||
val dispatcher: TestDispatcher = StandardTestDispatcher(),
|
||||
) : TestWatcher() {
|
||||
override fun starting(description: Description) {
|
||||
Dispatchers.setMain(dispatcher)
|
||||
}
|
||||
|
||||
override fun finished(description: Description) {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package com.flagship.abox.manager.ui.provider
|
||||
|
||||
import com.flagship.abox.manager.domain.ProviderType
|
||||
import com.flagship.abox.manager.domain.SessionCoordinator
|
||||
import com.flagship.abox.manager.testing.MainDispatcherRule
|
||||
import com.flagship.abox.manager.testing.fakes.FakeCredentialStore
|
||||
import com.flagship.abox.manager.testing.fakes.FakeLocalSessionGateway
|
||||
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
|
||||
import com.flagship.abox.manager.testing.fakes.FakeSessionGateway
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ProviderSelectionViewModelTest {
|
||||
@get:Rule
|
||||
val mainDispatcherRule = MainDispatcherRule()
|
||||
|
||||
private val coordinator = SessionCoordinator(
|
||||
profileStore = FakeProviderProfileStore(),
|
||||
credentialStore = FakeCredentialStore(),
|
||||
remoteGateway = FakeSessionGateway(),
|
||||
localGateway = FakeLocalSessionGateway(),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun exposesExactlyAboxAndLocalExperience() {
|
||||
val viewModel = ProviderSelectionViewModel(coordinator)
|
||||
|
||||
assertEquals(
|
||||
listOf(ProviderType.ABOX, ProviderType.LOCAL_EXPERIENCE),
|
||||
viewModel.uiState.value.providers.map { it.type },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun selectingLocalExperienceEstablishesSessionImmediately() = runTest {
|
||||
val viewModel = ProviderSelectionViewModel(coordinator)
|
||||
val local = viewModel.uiState.value.providers
|
||||
.single { it.type == ProviderType.LOCAL_EXPERIENCE }
|
||||
|
||||
viewModel.select(local)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(local.id, coordinator.observeActiveSession().first()?.profileId)
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package com.flagship.abox.manager.ui.session
|
||||
|
||||
import com.flagship.abox.manager.domain.DomainError
|
||||
import com.flagship.abox.manager.domain.LoginCredentials
|
||||
import com.flagship.abox.manager.domain.Password
|
||||
import com.flagship.abox.manager.domain.ProviderProfile
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.ProviderType
|
||||
import com.flagship.abox.manager.domain.Session
|
||||
import com.flagship.abox.manager.domain.SessionCoordinator
|
||||
import com.flagship.abox.manager.domain.SessionGateway
|
||||
import com.flagship.abox.manager.domain.Username
|
||||
import com.flagship.abox.manager.testing.MainDispatcherRule
|
||||
import com.flagship.abox.manager.testing.fakes.FakeCredentialStore
|
||||
import com.flagship.abox.manager.testing.fakes.FakeLocalSessionGateway
|
||||
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
|
||||
import com.flagship.abox.manager.testing.fakes.FakeSessionGateway
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Regression tests for real ABox login gating: the authenticated UI may appear only when the
|
||||
* active provider profile owns a successful session, and a failed ABox login stays on Login.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class AppSessionViewModelTest {
|
||||
|
||||
@get:Rule
|
||||
val mainDispatcherRule = MainDispatcherRule()
|
||||
|
||||
private val profiles = FakeProviderProfileStore()
|
||||
private val remote = FakeSessionGateway()
|
||||
private val local = FakeLocalSessionGateway()
|
||||
private val credentials = LoginCredentials(
|
||||
username = Username("user"),
|
||||
password = Password("password"),
|
||||
)
|
||||
|
||||
private fun newCoordinator(remoteGateway: SessionGateway = remote) = SessionCoordinator(
|
||||
profileStore = profiles,
|
||||
credentialStore = FakeCredentialStore(),
|
||||
remoteGateway = remoteGateway,
|
||||
localGateway = local,
|
||||
)
|
||||
|
||||
private suspend fun saveAndActivateAboxProfile(id: ProviderProfileId) {
|
||||
profiles.saveProfile(ProviderProfile(id, ProviderType.ABOX, id.value))
|
||||
profiles.setActiveProfile(id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun failedAboxLoginLeavesUiOnLogin() = runTest {
|
||||
saveAndActivateAboxProfile(aboxProfileId)
|
||||
remote.failLogin(aboxProfileId, DomainError.Authentication())
|
||||
val coordinator = newCoordinator()
|
||||
val viewModel = AppSessionViewModel(coordinator, profiles)
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
advanceUntilIdle()
|
||||
|
||||
coordinator.login(
|
||||
profileId = aboxProfileId,
|
||||
credentials = credentials,
|
||||
rememberUsername = false,
|
||||
autoLogin = false,
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(viewModel.uiState.value is AppSessionUiState.Login)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionForDifferentProfileCannotAuthenticateActiveProfile() = runTest {
|
||||
profiles.saveProfile(ProviderProfile(otherProfileId, ProviderType.ABOX, otherProfileId.value))
|
||||
remote.login(otherProfileId, credentials)
|
||||
saveAndActivateAboxProfile(aboxProfileId)
|
||||
|
||||
// The gateway exposes the other profile's session no matter which profile is observed.
|
||||
val crossProfileGateway = object : SessionGateway by remote {
|
||||
override fun observeSession(profileId: ProviderProfileId): Flow<Session?> =
|
||||
remote.observeSession(otherProfileId)
|
||||
}
|
||||
val viewModel = AppSessionViewModel(newCoordinator(crossProfileGateway), profiles)
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(viewModel.uiState.value is AppSessionUiState.Login)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun successfulAboxLoginAuthenticatesActiveProfile() = runTest {
|
||||
saveAndActivateAboxProfile(aboxProfileId)
|
||||
val coordinator = newCoordinator()
|
||||
val viewModel = AppSessionViewModel(coordinator, profiles)
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.uiState.collect {}
|
||||
}
|
||||
advanceUntilIdle()
|
||||
|
||||
coordinator.login(
|
||||
profileId = aboxProfileId,
|
||||
credentials = credentials,
|
||||
rememberUsername = false,
|
||||
autoLogin = false,
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
val state = viewModel.uiState.value
|
||||
assertTrue(state is AppSessionUiState.Authenticated)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val aboxProfileId = ProviderProfileId("abox-default")
|
||||
val otherProfileId = ProviderProfileId("abox-other")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user