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:
+28
@@ -0,0 +1,28 @@
|
||||
package com.flagship.abox.manager.testing.fakes
|
||||
|
||||
import com.flagship.abox.manager.domain.CredentialStore
|
||||
import com.flagship.abox.manager.domain.DomainResult
|
||||
import com.flagship.abox.manager.domain.LoginCredentials
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class FakeCredentialStore : CredentialStore {
|
||||
private val values = ConcurrentHashMap<ProviderProfileId, LoginCredentials>()
|
||||
|
||||
override suspend fun saveCredentials(
|
||||
profileId: ProviderProfileId,
|
||||
credentials: LoginCredentials,
|
||||
): DomainResult<Unit> {
|
||||
values[profileId] = credentials
|
||||
return DomainResult.Success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun loadCredentials(
|
||||
profileId: ProviderProfileId,
|
||||
): DomainResult<LoginCredentials?> = DomainResult.Success(values[profileId])
|
||||
|
||||
override suspend fun clearCredentials(profileId: ProviderProfileId): DomainResult<Unit> {
|
||||
values.remove(profileId)
|
||||
return DomainResult.Success(Unit)
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package com.flagship.abox.manager.testing.fakes
|
||||
|
||||
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 FakeLocalSessionGateway : 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) }
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.flagship.abox.manager.testing.fakes
|
||||
|
||||
import com.flagship.abox.manager.domain.DomainResult
|
||||
import com.flagship.abox.manager.domain.LoginPreferences
|
||||
import com.flagship.abox.manager.domain.ProviderProfile
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.ProviderProfileStore
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
class FakeProviderProfileStore : ProviderProfileStore {
|
||||
private val profileValues = ConcurrentHashMap<ProviderProfileId, ProviderProfile>()
|
||||
private val preferenceValues = ConcurrentHashMap<ProviderProfileId, LoginPreferences>()
|
||||
private val profilesFlow = MutableStateFlow<List<ProviderProfile>>(emptyList())
|
||||
private val activeProfileFlow = MutableStateFlow<ProviderProfile?>(null)
|
||||
private val preferenceFlows = ConcurrentHashMap<ProviderProfileId, MutableStateFlow<LoginPreferences>>()
|
||||
|
||||
override fun observeProfiles(): Flow<List<ProviderProfile>> = profilesFlow
|
||||
|
||||
override fun observeActiveProfile(): Flow<ProviderProfile?> = activeProfileFlow
|
||||
|
||||
override fun observeLoginPreferences(profileId: ProviderProfileId): Flow<LoginPreferences> =
|
||||
preferenceFlow(profileId)
|
||||
|
||||
override suspend fun profiles(): List<ProviderProfile> = profilesFlow.value
|
||||
|
||||
override suspend fun activeProfile(): ProviderProfile? = activeProfileFlow.value
|
||||
|
||||
override suspend fun loginPreferences(profileId: ProviderProfileId): LoginPreferences =
|
||||
preferenceFlow(profileId).value
|
||||
|
||||
override suspend fun saveProfile(profile: ProviderProfile): DomainResult<Unit> {
|
||||
profileValues[profile.id] = profile
|
||||
profilesFlow.value = profileValues.values.sortedBy { it.id.value }
|
||||
if (activeProfileFlow.value?.id == profile.id) activeProfileFlow.value = profile
|
||||
return DomainResult.Success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun setActiveProfile(profileId: ProviderProfileId?): DomainResult<Unit> {
|
||||
activeProfileFlow.value = profileId?.let(profileValues::get)
|
||||
return DomainResult.Success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun saveLoginPreferences(preferences: LoginPreferences): DomainResult<Unit> {
|
||||
preferenceValues[preferences.profileId] = preferences
|
||||
preferenceFlow(preferences.profileId).value = preferences
|
||||
return DomainResult.Success(Unit)
|
||||
}
|
||||
|
||||
private fun preferenceFlow(profileId: ProviderProfileId): MutableStateFlow<LoginPreferences> =
|
||||
preferenceFlows.getOrPut(profileId) {
|
||||
MutableStateFlow(
|
||||
preferenceValues[profileId] ?: LoginPreferences(profileId),
|
||||
)
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.flagship.abox.manager.testing.fakes
|
||||
|
||||
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.Username
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class FakeCredentialStoreTest {
|
||||
@Test
|
||||
fun credentialsAreIsolatedByProfileAndClearIndependently() = runTest {
|
||||
val store = FakeCredentialStore()
|
||||
val first = ProviderProfileId("first")
|
||||
val second = ProviderProfileId("second")
|
||||
val firstCredentials = LoginCredentials(Username("one"), Password("secret-one"))
|
||||
val secondCredentials = LoginCredentials(Username("two"), Password("secret-two"))
|
||||
|
||||
store.saveCredentials(first, firstCredentials)
|
||||
store.saveCredentials(second, secondCredentials)
|
||||
store.clearCredentials(first)
|
||||
|
||||
assertNull((store.loadCredentials(first) as DomainResult.Success).value)
|
||||
assertEquals(secondCredentials, (store.loadCredentials(second) as DomainResult.Success).value)
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.flagship.abox.manager.testing.fakes
|
||||
|
||||
import com.flagship.abox.manager.domain.DomainResult
|
||||
import com.flagship.abox.manager.domain.LoginPreferences
|
||||
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.Username
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class FakeProviderProfileStoreTest {
|
||||
@Test
|
||||
fun storesProfilesActiveSelectionAndPreferences() = runTest {
|
||||
val store = FakeProviderProfileStore()
|
||||
val id = ProviderProfileId("local")
|
||||
val profile = ProviderProfile(id, ProviderType.LOCAL_EXPERIENCE, "Local")
|
||||
val preferences = LoginPreferences(id, Username("demo"), true, false)
|
||||
|
||||
assertEquals(DomainResult.Success(Unit), store.saveProfile(profile))
|
||||
store.setActiveProfile(id)
|
||||
store.saveLoginPreferences(preferences)
|
||||
|
||||
assertEquals(listOf(profile), store.observeProfiles().first())
|
||||
assertEquals(profile, store.observeActiveProfile().first())
|
||||
assertEquals(preferences, store.observeLoginPreferences(id).first())
|
||||
}
|
||||
}
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
package com.flagship.abox.manager.testing.fakes
|
||||
|
||||
import com.flagship.abox.manager.domain.CredentialStore
|
||||
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.LoginPreferences
|
||||
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.SessionCoordinator
|
||||
import com.flagship.abox.manager.domain.SessionGateway
|
||||
import com.flagship.abox.manager.domain.Session
|
||||
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.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SessionCoordinatorTest {
|
||||
private val remoteProfile = ProviderProfile(
|
||||
ProviderProfileId("abox"),
|
||||
ProviderType.ABOX,
|
||||
"ABox",
|
||||
)
|
||||
private val localProfile = ProviderProfile(
|
||||
ProviderProfileId("local"),
|
||||
ProviderType.LOCAL_EXPERIENCE,
|
||||
"Local",
|
||||
)
|
||||
private val credentials = LoginCredentials(Username("demo"), Password("secret"))
|
||||
|
||||
@Test
|
||||
fun localProviderCreatesSessionWithoutRemoteLogin() = runTest {
|
||||
val fixture = Fixture()
|
||||
|
||||
val result = fixture.coordinator.selectProvider(localProfile)
|
||||
|
||||
assertTrue(result is DomainResult.Success)
|
||||
assertEquals(localProfile.id, fixture.coordinator.observeActiveSession().first()?.profileId)
|
||||
assertNull(fixture.remote.observeSession(localProfile.id).first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun localSelectionCreatesSessionWithoutAboxOrCredentialAccess() = runTest {
|
||||
val remote = CountingSessionGateway()
|
||||
val credentialsStore = CountingCredentialStore()
|
||||
val coordinator = SessionCoordinator(
|
||||
FakeProviderProfileStore(),
|
||||
credentialsStore,
|
||||
remote,
|
||||
FakeLocalSessionGateway(),
|
||||
)
|
||||
|
||||
val result = coordinator.selectProvider(localProfile)
|
||||
|
||||
assertTrue(result is DomainResult.Success)
|
||||
assertEquals(0, remote.loginCalls)
|
||||
assertEquals(0, credentialsStore.loadCalls + credentialsStore.saveCalls)
|
||||
assertEquals(localProfile.id, coordinator.observeActiveSession().first()?.profileId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aboxAuthenticationFailureDoesNotCreateSession() = runTest {
|
||||
val fixture = Fixture()
|
||||
fixture.coordinator.selectProvider(remoteProfile)
|
||||
fixture.remote.failLogin(remoteProfile.id, DomainError.Authentication())
|
||||
|
||||
val result = fixture.coordinator.login(remoteProfile.id, credentials, false, false)
|
||||
|
||||
assertTrue(result is DomainResult.Failure)
|
||||
assertNull(fixture.coordinator.observeActiveSession().first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loginPersistsIndependentUsernameAndAutoLoginChoices() = runTest {
|
||||
val fixture = Fixture()
|
||||
fixture.coordinator.selectProvider(remoteProfile)
|
||||
|
||||
fixture.coordinator.login(
|
||||
remoteProfile.id,
|
||||
credentials,
|
||||
rememberUsername = false,
|
||||
autoLogin = true,
|
||||
)
|
||||
|
||||
val preferences = fixture.profiles.loginPreferences(remoteProfile.id)
|
||||
assertNull(preferences.rememberedUsername)
|
||||
assertTrue(preferences.autoLoginEnabled)
|
||||
assertEquals(
|
||||
credentials,
|
||||
(fixture.credentials.loadCredentials(remoteProfile.id) as DomainResult.Success).value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bootstrapUsesSavedAutoLoginCredentials() = runTest {
|
||||
val fixture = Fixture()
|
||||
fixture.profiles.saveProfile(remoteProfile)
|
||||
fixture.profiles.setActiveProfile(remoteProfile.id)
|
||||
fixture.profiles.saveLoginPreferences(
|
||||
LoginPreferences(remoteProfile.id, autoLoginEnabled = true),
|
||||
)
|
||||
fixture.credentials.saveCredentials(remoteProfile.id, credentials)
|
||||
|
||||
val result = fixture.coordinator.bootstrap()
|
||||
|
||||
assertEquals(remoteProfile.id, (result as DomainResult.Success).value?.profileId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun remoteLogoutClearsPasswordButKeepsProfileAndUsername() = runTest {
|
||||
val fixture = Fixture()
|
||||
fixture.coordinator.selectProvider(remoteProfile)
|
||||
fixture.coordinator.login(
|
||||
remoteProfile.id,
|
||||
credentials,
|
||||
rememberUsername = true,
|
||||
autoLogin = true,
|
||||
)
|
||||
|
||||
fixture.coordinator.logout()
|
||||
|
||||
assertEquals(remoteProfile, fixture.profiles.activeProfile())
|
||||
val preferences = fixture.profiles.loginPreferences(remoteProfile.id)
|
||||
assertEquals(credentials.username, preferences.rememberedUsername)
|
||||
assertTrue(!preferences.autoLoginEnabled)
|
||||
assertNull(
|
||||
(fixture.credentials.loadCredentials(remoteProfile.id) as DomainResult.Success).value,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authenticationFailureDuringRecoveryClearsSavedCredential() = runTest {
|
||||
val fixture = Fixture()
|
||||
fixture.profiles.saveProfile(remoteProfile)
|
||||
fixture.profiles.setActiveProfile(remoteProfile.id)
|
||||
fixture.profiles.saveLoginPreferences(
|
||||
LoginPreferences(remoteProfile.id, autoLoginEnabled = true),
|
||||
)
|
||||
fixture.credentials.saveCredentials(remoteProfile.id, credentials)
|
||||
fixture.remote.failLogin(remoteProfile.id, DomainError.Authentication())
|
||||
|
||||
val result = fixture.coordinator.recoverSession(remoteProfile.id)
|
||||
|
||||
assertTrue(result is DomainResult.Failure)
|
||||
assertNull(
|
||||
(fixture.credentials.loadCredentials(remoteProfile.id) as DomainResult.Success).value,
|
||||
)
|
||||
assertTrue(!fixture.profiles.loginPreferences(remoteProfile.id).autoLoginEnabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun switchingFromAuthenticatedAboxToLocalClearsRemoteSessionAndIsolatesState() = runTest {
|
||||
val fixture = Fixture()
|
||||
fixture.coordinator.selectProvider(remoteProfile)
|
||||
val loginResult = fixture.coordinator.login(
|
||||
remoteProfile.id,
|
||||
credentials,
|
||||
rememberUsername = true,
|
||||
autoLogin = true,
|
||||
)
|
||||
|
||||
val switchResult = fixture.coordinator.selectProvider(localProfile)
|
||||
|
||||
assertTrue(loginResult is DomainResult.Success)
|
||||
assertTrue(switchResult is DomainResult.Success)
|
||||
assertEquals(
|
||||
localProfile.id,
|
||||
(switchResult as DomainResult.Success).value?.profileId,
|
||||
)
|
||||
assertEquals(localProfile.id, fixture.profiles.activeProfile()?.id)
|
||||
assertEquals(
|
||||
localProfile.id,
|
||||
fixture.coordinator.observeActiveSession().first()?.profileId,
|
||||
)
|
||||
assertNull(fixture.remote.observeSession(remoteProfile.id).first())
|
||||
assertNull(fixture.remote.observeSession(localProfile.id).first())
|
||||
assertEquals(
|
||||
localProfile.id,
|
||||
fixture.local.observeSession(localProfile.id).first()?.profileId,
|
||||
)
|
||||
assertNull(
|
||||
(fixture.credentials.loadCredentials(remoteProfile.id) as DomainResult.Success).value,
|
||||
)
|
||||
assertTrue(!fixture.profiles.loginPreferences(remoteProfile.id).autoLoginEnabled)
|
||||
}
|
||||
|
||||
private class Fixture {
|
||||
val profiles = FakeProviderProfileStore()
|
||||
val credentials = FakeCredentialStore()
|
||||
val remote = FakeSessionGateway()
|
||||
val local = FakeLocalSessionGateway()
|
||||
val coordinator = SessionCoordinator(profiles, credentials, remote, local)
|
||||
}
|
||||
|
||||
private class CountingSessionGateway(
|
||||
private val delegate: FakeSessionGateway = FakeSessionGateway(),
|
||||
) : SessionGateway by delegate {
|
||||
var loginCalls = 0
|
||||
private set
|
||||
|
||||
override suspend fun login(
|
||||
profileId: ProviderProfileId,
|
||||
credentials: LoginCredentials,
|
||||
): DomainResult<Session> {
|
||||
loginCalls++
|
||||
return delegate.login(profileId, credentials)
|
||||
}
|
||||
|
||||
fun failLogin(profileId: ProviderProfileId, error: DomainError) {
|
||||
delegate.failLogin(profileId, error)
|
||||
}
|
||||
}
|
||||
|
||||
private class CountingCredentialStore(
|
||||
private val delegate: FakeCredentialStore = FakeCredentialStore(),
|
||||
) : CredentialStore by delegate {
|
||||
var loadCalls = 0
|
||||
private set
|
||||
var saveCalls = 0
|
||||
private set
|
||||
|
||||
override suspend fun loadCredentials(
|
||||
profileId: ProviderProfileId,
|
||||
): DomainResult<LoginCredentials?> {
|
||||
loadCalls++
|
||||
return delegate.loadCredentials(profileId)
|
||||
}
|
||||
|
||||
override suspend fun saveCredentials(
|
||||
profileId: ProviderProfileId,
|
||||
credentials: LoginCredentials,
|
||||
): DomainResult<Unit> {
|
||||
saveCalls++
|
||||
return delegate.saveCredentials(profileId, credentials)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user