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:
2026-08-25 01:59:00 +08:00
committed by KremeCN
parent 6f7d3248b0
commit 06ccfdf952
59 changed files with 4982 additions and 209 deletions
@@ -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)
}
}
@@ -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) }
}
@@ -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),
)
}
}
@@ -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)
}
}
@@ -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())
}
}
@@ -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)
}
}
}