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:
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user