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
@@ -15,6 +15,41 @@ interface SessionGateway {
suspend fun logout(profileId: ProviderProfileId): DomainResult<Unit>
}
interface ProviderProfileStore {
fun observeProfiles(): Flow<List<ProviderProfile>>
fun observeActiveProfile(): Flow<ProviderProfile?>
fun observeLoginPreferences(profileId: ProviderProfileId): Flow<LoginPreferences>
suspend fun profiles(): List<ProviderProfile>
suspend fun activeProfile(): ProviderProfile?
suspend fun loginPreferences(profileId: ProviderProfileId): LoginPreferences
suspend fun saveProfile(profile: ProviderProfile): DomainResult<Unit>
suspend fun setActiveProfile(profileId: ProviderProfileId?): DomainResult<Unit>
suspend fun saveLoginPreferences(preferences: LoginPreferences): DomainResult<Unit>
}
interface CredentialStore {
suspend fun saveCredentials(
profileId: ProviderProfileId,
credentials: LoginCredentials,
): DomainResult<Unit>
suspend fun loadCredentials(
profileId: ProviderProfileId,
): DomainResult<LoginCredentials?>
suspend fun clearCredentials(profileId: ProviderProfileId): DomainResult<Unit>
}
interface LocalSessionGateway : SessionGateway
interface DeviceCatalog {
fun observeDevices(
profileId: ProviderProfileId,
@@ -25,7 +25,6 @@ value class Password(val value: String) {
enum class ProviderType {
ABOX,
COMPATIBLE_BACKEND,
LOCAL_EXPERIENCE,
}
@@ -53,6 +52,13 @@ data class LoginCredentials(
val password: Password,
)
data class LoginPreferences(
val profileId: ProviderProfileId,
val rememberedUsername: Username? = null,
val rememberUsername: Boolean = false,
val autoLoginEnabled: Boolean = false,
)
data class Session(
val profileId: ProviderProfileId,
val username: Username,
@@ -0,0 +1,206 @@
package com.flagship.abox.manager.domain
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
@OptIn(ExperimentalCoroutinesApi::class)
class SessionCoordinator(
private val profileStore: ProviderProfileStore,
private val credentialStore: CredentialStore,
private val remoteGateway: SessionGateway,
private val localGateway: LocalSessionGateway,
) {
fun observeActiveSession(): Flow<Session?> = profileStore.observeActiveProfile()
.flatMapLatest { profile ->
profile?.gateway()?.observeSession(profile.id) ?: flowOf(null)
}
suspend fun bootstrap(): DomainResult<Session?> = storeOperation {
val profile = profileStore.activeProfile() ?: return@storeOperation DomainResult.Success(null)
if (profile.type == ProviderType.LOCAL_EXPERIENCE) {
return@storeOperation localGateway.login(profile.id, localCredentials())
.mapSuccess { it }
}
val preferences = profileStore.loginPreferences(profile.id)
if (!preferences.autoLoginEnabled) return@storeOperation DomainResult.Success(null)
when (val credentials = credentialStore.loadCredentials(profile.id)) {
is DomainResult.Failure -> credentials
is DomainResult.Success -> {
val savedCredentials = credentials.value ?: return@storeOperation DomainResult.Success(null)
if (profileStore.activeProfile()?.id != profile.id) {
return@storeOperation DomainResult.Success(null)
}
profile.gateway().login(profile.id, savedCredentials).mapSuccess { it }
}
}
}
suspend fun selectProvider(profile: ProviderProfile): DomainResult<Session?> = storeOperation {
val current = profileStore.activeProfile()
if (current?.id != profile.id && current != null) {
when (val switched = switchProvider()) {
is DomainResult.Failure -> return@storeOperation switched
is DomainResult.Success -> Unit
}
}
when (val saved = profileStore.saveProfile(profile)) {
is DomainResult.Failure -> return@storeOperation saved
is DomainResult.Success -> Unit
}
when (val activated = profileStore.setActiveProfile(profile.id)) {
is DomainResult.Failure -> return@storeOperation activated
is DomainResult.Success -> Unit
}
if (profile.type == ProviderType.LOCAL_EXPERIENCE) {
localGateway.login(profile.id, localCredentials()).mapSuccess { it }
} else {
DomainResult.Success(null)
}
}
suspend fun login(
profileId: ProviderProfileId,
credentials: LoginCredentials,
rememberUsername: Boolean,
autoLogin: Boolean,
): DomainResult<Session> = storeOperation {
val profile = profileStore.activeProfile()
?: return@storeOperation DomainResult.Failure(
DomainError.InvalidRequest(detail = "No active provider profile"),
)
if (profile.id != profileId || profile.type == ProviderType.LOCAL_EXPERIENCE) {
return@storeOperation DomainResult.Failure(
DomainError.InvalidRequest(detail = "The active provider does not require this login"),
)
}
when (val result = remoteGateway.login(profileId, credentials)) {
is DomainResult.Failure -> result
is DomainResult.Success -> {
val previousPreferences = profileStore.loginPreferences(profileId)
val previousCredentials = credentialStore.loadCredentials(profileId)
if (previousCredentials is DomainResult.Failure) {
remoteGateway.logout(profileId)
return@storeOperation previousCredentials
}
val preferences = LoginPreferences(
profileId = profileId,
rememberedUsername = credentials.username.takeIf { rememberUsername },
rememberUsername = rememberUsername,
autoLoginEnabled = autoLogin,
)
val credentialResult = if (autoLogin) {
credentialStore.saveCredentials(profileId, credentials)
} else {
credentialStore.clearCredentials(profileId)
}
if (credentialResult is DomainResult.Failure) {
remoteGateway.logout(profileId)
return@storeOperation credentialResult
}
when (val preferencesResult = profileStore.saveLoginPreferences(preferences)) {
is DomainResult.Success -> result
is DomainResult.Failure -> {
when (previousCredentials) {
is DomainResult.Success -> previousCredentials.value?.let {
credentialStore.saveCredentials(profileId, it)
} ?: credentialStore.clearCredentials(profileId)
is DomainResult.Failure -> Unit
}
profileStore.saveLoginPreferences(previousPreferences)
remoteGateway.logout(profileId)
preferencesResult
}
}
}
}
}
suspend fun recoverSession(profileId: ProviderProfileId): DomainResult<Session?> = storeOperation {
val profile = profileStore.activeProfile()
if (profile?.id != profileId || profile.type == ProviderType.LOCAL_EXPERIENCE) {
return@storeOperation DomainResult.Success(null)
}
val preferences = profileStore.loginPreferences(profileId)
if (!preferences.autoLoginEnabled) return@storeOperation DomainResult.Success(null)
when (val credentials = credentialStore.loadCredentials(profileId)) {
is DomainResult.Failure -> credentials
is DomainResult.Success -> {
val savedCredentials = credentials.value ?: return@storeOperation DomainResult.Success(null)
when (val result = remoteGateway.login(profileId, savedCredentials)) {
is DomainResult.Success -> result.mapSuccess { it }
is DomainResult.Failure -> {
if (result.error is DomainError.Authentication ||
result.error is DomainError.TokenInvalid
) {
credentialStore.clearCredentials(profileId)
profileStore.saveLoginPreferences(
preferences.copy(autoLoginEnabled = false),
)
}
result
}
}
}
}
}
suspend fun logout(): DomainResult<Unit> = storeOperation {
val profile = profileStore.activeProfile() ?: return@storeOperation DomainResult.Success(Unit)
when (val credentialResult = credentialStore.clearCredentials(profile.id)) {
is DomainResult.Failure -> return@storeOperation credentialResult
is DomainResult.Success -> Unit
}
val preferences = profileStore.loginPreferences(profile.id)
when (
val preferencesResult = profileStore.saveLoginPreferences(
preferences.copy(autoLoginEnabled = false),
)
) {
is DomainResult.Failure -> return@storeOperation preferencesResult
is DomainResult.Success -> Unit
}
when (val logoutResult = profile.gateway().logout(profile.id)) {
is DomainResult.Failure -> return@storeOperation logoutResult
is DomainResult.Success -> Unit
}
if (profile.type == ProviderType.LOCAL_EXPERIENCE) {
profileStore.setActiveProfile(null)
} else {
DomainResult.Success(Unit)
}
}
suspend fun switchProvider(): DomainResult<Unit> = storeOperation {
when (val logoutResult = logout()) {
is DomainResult.Failure -> logoutResult
is DomainResult.Success -> profileStore.setActiveProfile(null)
}
}
private fun ProviderProfile.gateway(): SessionGateway =
if (type == ProviderType.LOCAL_EXPERIENCE) localGateway else remoteGateway
private fun localCredentials(): LoginCredentials = LoginCredentials(
username = Username("local"),
password = Password(""),
)
private suspend fun <T> storeOperation(block: suspend () -> DomainResult<T>): DomainResult<T> =
try {
block()
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
DomainResult.Failure(
DomainError.Unknown(detail = error.message ?: error::class.java.simpleName),
)
}
}
private inline fun <T, R> DomainResult<T>.mapSuccess(transform: (T) -> R): DomainResult<R> = when (this) {
is DomainResult.Failure -> this
is DomainResult.Success -> DomainResult.Success(transform(value))
}