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,99 @@
package com.flagship.abox.manager.ui.login
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.designsystem.ABoxTheme
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class LoginScreenTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
private val profile = ProviderProfile(
ProviderProfileId("abox"),
ProviderType.ABOX,
"ABox",
)
@Test
fun backControlsSwitchProviderBeforeLogin() {
var switches = 0
composeRule.setContent {
ABoxTheme {
LoginScreen(
profile = profile,
state = LoginUiState(),
onUsernameChange = {},
onPasswordChange = {},
onRememberUsernameChange = {},
onAutoLoginChange = {},
onSubmit = {},
onSwitchProvider = { switches++ },
)
}
}
composeRule.onNodeWithContentDescription("back-to-providers")
.assertIsEnabled()
.performClick()
composeRule.onNodeWithText("Back to providers").performClick()
assertEquals(2, switches)
}
@Test
fun systemBackIsConsumedWhileSubmitting() {
var switches = 0
composeRule.setContent {
ABoxTheme {
LoginScreen(
profile = profile,
state = LoginUiState(isSubmitting = true),
onUsernameChange = {},
onPasswordChange = {},
onRememberUsernameChange = {},
onAutoLoginChange = {},
onSubmit = {},
onSwitchProvider = { switches++ },
)
}
}
composeRule.activity.onBackPressedDispatcher.onBackPressed()
composeRule.waitForIdle()
assertEquals(0, switches)
}
@Test
fun backControlsAreDisabledWhileSubmitting() {
composeRule.setContent {
ABoxTheme {
LoginScreen(
profile = profile,
state = LoginUiState(isSubmitting = true),
onUsernameChange = {},
onPasswordChange = {},
onRememberUsernameChange = {},
onAutoLoginChange = {},
onSubmit = {},
onSwitchProvider = {},
)
}
}
composeRule.onNodeWithContentDescription("back-to-providers").assertIsNotEnabled()
composeRule.onNodeWithText("Back to providers").assertIsNotEnabled()
}
}
@@ -0,0 +1,46 @@
package com.flagship.abox.manager.ui.provider
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.designsystem.ABoxTheme
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ProviderSelectionScreenTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun aboxAndLocalExperienceAreBothSelectableAndNoCompatibleOptionIsShown() {
val abox = ProviderProfile(ProviderProfileId("abox"), ProviderType.ABOX, "ABox")
val local = ProviderProfile(
ProviderProfileId("local"),
ProviderType.LOCAL_EXPERIENCE,
"Local",
)
var selected: ProviderProfile? = null
composeRule.setContent {
ABoxTheme {
ProviderSelectionScreen(
state = ProviderSelectionUiState(listOf(abox, local)),
onSelect = { selected = it },
)
}
}
composeRule.onNodeWithText("ABox").assertIsEnabled()
composeRule.onNodeWithText("Local experience").assertIsEnabled().performClick()
assertEquals(local, selected)
composeRule.onNodeWithText("Compatible backend").assertDoesNotExist()
}
}
@@ -1,15 +1,7 @@
package com.flagship.abox.manager.di
import com.flagship.abox.manager.domain.DeviceCatalog
import com.flagship.abox.manager.domain.InfraredGateway
import com.flagship.abox.manager.domain.SessionGateway
import com.flagship.abox.manager.domain.SocketGateway
import com.flagship.abox.manager.domain.TemperatureHumidityGateway
import com.flagship.abox.manager.testing.fakes.FakeDeviceCatalog
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
import com.flagship.abox.manager.testing.fakes.FakeSessionGateway
import com.flagship.abox.manager.testing.fakes.FakeSocketGateway
import com.flagship.abox.manager.testing.fakes.FakeTemperatureHumidityGateway
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -19,24 +11,12 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object FoundationModule {
@Provides
@Singleton
fun provideSessionGateway(): SessionGateway = FakeSessionGateway()
// TEMPORARY boundary: ABSDK has no device catalog, and the provider-scoped production
// ABox catalog is owned by the device-catalog module. Until that exists, debug builds
// keep the fake ABox catalog here. Do NOT route ABox catalog requests to
// SimulationDeviceCatalog; release builds must not request a DeviceCatalog binding
// until a real release consumer appears.
@Provides
@Singleton
fun provideDeviceCatalog(): DeviceCatalog = FakeDeviceCatalog()
@Provides
@Singleton
fun provideSocketGateway(): SocketGateway = FakeSocketGateway()
@Provides
@Singleton
fun provideTemperatureHumidityGateway(): TemperatureHumidityGateway =
FakeTemperatureHumidityGateway()
@Provides
@Singleton
fun provideInfraredGateway(): InfraredGateway = FakeInfraredGateway()
}
+2
View File
@@ -1,9 +1,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".ABoxManagerApplication"
android:allowBackup="false"
android:label="@string/app_name"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.ABoxManager">
<activity
android:name=".MainActivity"
@@ -0,0 +1,62 @@
package com.flagship.abox.manager.di
import com.flagship.abox.manager.absdk.bridge.AbsdkBridge
import com.flagship.abox.manager.domain.InfraredGateway
import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.SocketGateway
import com.flagship.abox.manager.domain.TemperatureHumidityGateway
import com.flagship.abox.manager.simulation.SimulationInfraredGateway
import com.flagship.abox.manager.simulation.SimulationSocketGateway
import com.flagship.abox.manager.simulation.SimulationTemperatureHumidityGateway
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/**
* Binds each public device gateway to a router that dispatches on the active provider:
* ABox goes to the shared real [AbsdkBridge] and local experience goes to the shared
* production simulation gateway.
*/
@Module
@InstallIn(SingletonComponent::class)
object DeviceGatewayModule {
@Provides
@Singleton
fun provideSimulationSocketGateway(): SimulationSocketGateway = SimulationSocketGateway()
@Provides
@Singleton
fun provideSimulationTemperatureHumidityGateway(): SimulationTemperatureHumidityGateway =
SimulationTemperatureHumidityGateway()
@Provides
@Singleton
fun provideSimulationInfraredGateway(): SimulationInfraredGateway = SimulationInfraredGateway()
@Provides
@Singleton
fun provideSocketGateway(
profileStore: ProviderProfileStore,
bridge: AbsdkBridge,
simulation: SimulationSocketGateway,
): SocketGateway = RoutingSocketGateway(profileStore, bridge, simulation)
@Provides
@Singleton
fun provideTemperatureHumidityGateway(
profileStore: ProviderProfileStore,
bridge: AbsdkBridge,
simulation: SimulationTemperatureHumidityGateway,
): TemperatureHumidityGateway =
RoutingTemperatureHumidityGateway(profileStore, bridge, simulation)
@Provides
@Singleton
fun provideInfraredGateway(
profileStore: ProviderProfileStore,
bridge: AbsdkBridge,
simulation: SimulationInfraredGateway,
): InfraredGateway = RoutingInfraredGateway(profileStore, bridge, simulation)
}
@@ -0,0 +1,140 @@
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.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 kotlinx.coroutines.CancellationException
/**
* Resolves the provider type a device operation must be routed to.
*
* A device call is only valid for the currently active provider profile: a missing
* active profile or a mismatched [profileId] is rejected with [DomainError.InvalidRequest]
* before any gateway is invoked.
*/
internal class ActiveProviderGatewayResolver(
private val profileStore: ProviderProfileStore,
) {
suspend fun resolve(profileId: ProviderProfileId): DomainResult<ProviderType> = try {
val active = profileStore.activeProfile()
when {
active == null ->
DomainResult.Failure(DomainError.InvalidRequest(detail = "No active provider"))
active.id != profileId ->
DomainResult.Failure(
DomainError.InvalidRequest(detail = "Provider profile is not active"),
)
else -> DomainResult.Success(active.type)
}
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
DomainResult.Failure(
DomainError.Unknown(detail = error.message ?: error::class.java.simpleName),
)
}
}
/** Routes socket operations to the real ABSDK bridge or the simulation gateway by active provider. */
internal class RoutingSocketGateway(
profileStore: ProviderProfileStore,
private val real: SocketGateway,
private val simulation: SocketGateway,
) : SocketGateway {
private val resolver = ActiveProviderGatewayResolver(profileStore)
override suspend fun getStatus(
profileId: ProviderProfileId,
deviceName: DeviceName,
): DomainResult<SocketStatus> = when (val provider = resolver.resolve(profileId)) {
is DomainResult.Failure -> provider
is DomainResult.Success -> delegate(provider.value).getStatus(profileId, deviceName)
}
override suspend fun setPower(
profileId: ProviderProfileId,
deviceName: DeviceName,
powerState: SocketPowerState,
): DomainResult<SocketStatus> = when (val provider = resolver.resolve(profileId)) {
is DomainResult.Failure -> provider
is DomainResult.Success -> delegate(provider.value).setPower(profileId, deviceName, powerState)
}
private fun delegate(type: ProviderType): SocketGateway = when (type) {
ProviderType.ABOX -> real
ProviderType.LOCAL_EXPERIENCE -> simulation
}
}
/** Routes temperature/humidity operations by active provider, mirroring [RoutingSocketGateway]. */
internal class RoutingTemperatureHumidityGateway(
profileStore: ProviderProfileStore,
private val real: TemperatureHumidityGateway,
private val simulation: TemperatureHumidityGateway,
) : TemperatureHumidityGateway {
private val resolver = ActiveProviderGatewayResolver(profileStore)
override suspend fun getReading(
profileId: ProviderProfileId,
deviceName: DeviceName,
): DomainResult<TemperatureHumidityReading> = when (val provider = resolver.resolve(profileId)) {
is DomainResult.Failure -> provider
is DomainResult.Success -> delegate(provider.value).getReading(profileId, deviceName)
}
private fun delegate(type: ProviderType): TemperatureHumidityGateway = when (type) {
ProviderType.ABOX -> real
ProviderType.LOCAL_EXPERIENCE -> simulation
}
}
/** Routes infrared operations by active provider, mirroring [RoutingSocketGateway]. */
internal class RoutingInfraredGateway(
profileStore: ProviderProfileStore,
private val real: InfraredGateway,
private val simulation: InfraredGateway,
) : InfraredGateway {
private val resolver = ActiveProviderGatewayResolver(profileStore)
override suspend fun sendKey(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
): DomainResult<Unit> = when (val provider = resolver.resolve(profileId)) {
is DomainResult.Failure -> provider
is DomainResult.Success -> delegate(provider.value).sendKey(profileId, deviceName, key)
}
override suspend fun learnKey(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
): DomainResult<Unit> = when (val provider = resolver.resolve(profileId)) {
is DomainResult.Failure -> provider
is DomainResult.Success -> delegate(provider.value).learnKey(profileId, deviceName, key)
}
override suspend fun downloadCodes(
profileId: ProviderProfileId,
codes: List<InfraredCode>,
): DomainResult<List<InfraredCodeDownloadResult>> = when (val provider = resolver.resolve(profileId)) {
is DomainResult.Failure -> provider
is DomainResult.Success -> delegate(provider.value).downloadCodes(profileId, codes)
}
private fun delegate(type: ProviderType): InfraredGateway = when (type) {
ProviderType.ABOX -> real
ProviderType.LOCAL_EXPERIENCE -> simulation
}
}
@@ -0,0 +1,80 @@
package com.flagship.abox.manager.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import com.flagship.abox.manager.absdk.bridge.AbsdkBridge
import com.flagship.abox.manager.absdk.bridge.CleartextAbsdkTransport
import com.flagship.abox.manager.domain.CredentialStore
import com.flagship.abox.manager.domain.LocalSessionGateway
import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.SessionCoordinator
import com.flagship.abox.manager.domain.SessionGateway
import com.flagship.abox.manager.session.credential.AndroidKeystoreCredentialCipher
import com.flagship.abox.manager.session.credential.CredentialCipher
import com.flagship.abox.manager.session.credential.KeystoreCredentialStore
import com.flagship.abox.manager.session.profile.ProviderProfileStoreImpl
import com.flagship.abox.manager.session.profile.sessionDataStore
import com.flagship.abox.manager.simulation.SimulationSessionGateway
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object SessionModule {
@Provides
@Singleton
fun provideSessionDataStore(
@ApplicationContext context: Context,
): DataStore<Preferences> = context.sessionDataStore
@Provides
@Singleton
fun provideProviderProfileStore(
dataStore: DataStore<Preferences>,
): ProviderProfileStore = ProviderProfileStoreImpl(dataStore)
@Provides
@Singleton
fun provideCredentialCipher(): CredentialCipher = AndroidKeystoreCredentialCipher()
@Provides
@Singleton
fun provideCredentialStore(
dataStore: DataStore<Preferences>,
cipher: CredentialCipher,
): CredentialStore = KeystoreCredentialStore(dataStore, cipher)
@Provides
@Singleton
@OptIn(CleartextAbsdkTransport::class)
fun provideAbsdkBridge(): AbsdkBridge = AbsdkBridge.create()
@Provides
@Singleton
fun provideRemoteSessionGateway(
bridge: AbsdkBridge,
): SessionGateway = bridge
@Provides
@Singleton
fun provideLocalSessionGateway(): LocalSessionGateway = SimulationSessionGateway()
@Provides
@Singleton
fun provideSessionCoordinator(
profileStore: ProviderProfileStore,
credentialStore: CredentialStore,
remoteGateway: SessionGateway,
localGateway: LocalSessionGateway,
): SessionCoordinator = SessionCoordinator(
profileStore,
credentialStore,
remoteGateway,
localGateway,
)
}
@@ -1,94 +1,77 @@
package com.flagship.abox.manager.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.flagship.abox.manager.R
import com.flagship.abox.manager.designsystem.ABoxButton
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator
import com.flagship.abox.manager.designsystem.ABoxSurface
import com.flagship.abox.manager.designsystem.ABoxTheme
private const val FOUNDATION_ROUTE = "foundation"
private const val PLACEHOLDER_ROUTE = "placeholder"
import com.flagship.abox.manager.ui.authenticated.AuthenticatedPlaceholderScreen
import com.flagship.abox.manager.ui.login.LoginRoute
import com.flagship.abox.manager.ui.login.LoginViewModel
import com.flagship.abox.manager.ui.provider.ProviderSelectionRoute
import com.flagship.abox.manager.ui.session.AppSessionUiState
import com.flagship.abox.manager.ui.session.AppSessionViewModel
import com.flagship.abox.manager.ui.session.SessionActionsViewModel
@Composable
fun ABoxManagerApp() {
fun ABoxManagerApp(
appSessionViewModel: AppSessionViewModel = hiltViewModel(),
sessionActionsViewModel: SessionActionsViewModel = hiltViewModel(),
loginViewModel: LoginViewModel = hiltViewModel(),
) {
val actionState = sessionActionsViewModel.uiState.collectAsStateWithLifecycle().value
val sessionState = appSessionViewModel.uiState.collectAsStateWithLifecycle().value
val screenKey = sessionState.screenKey()
LaunchedEffect(screenKey) {
sessionActionsViewModel.dismissError()
if (sessionState !is AppSessionUiState.Login) {
loginViewModel.clearSensitiveState()
}
}
ABoxTheme {
ABoxSurface(modifier = Modifier.fillMaxSize()) {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = FOUNDATION_ROUTE,
) {
composable(FOUNDATION_ROUTE) {
FoundationScreen(onContinue = { navController.navigate(PLACEHOLDER_ROUTE) })
}
composable(PLACEHOLDER_ROUTE) {
MessageScreen(
title = stringResource(R.string.placeholder_title),
description = stringResource(R.string.placeholder_description),
)
when (val state = sessionState) {
AppSessionUiState.Loading -> Box(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing),
contentAlignment = Alignment.Center,
) {
ABoxLoadingIndicator()
}
AppSessionUiState.ProviderSelection -> ProviderSelectionRoute()
is AppSessionUiState.Login -> LoginRoute(
profile = state.profile,
bootstrapError = state.bootstrapError,
actionError = actionState.errorMessage,
actionInProgress = actionState.inProgress,
onSwitchProvider = sessionActionsViewModel::switchProvider,
viewModel = loginViewModel,
)
is AppSessionUiState.Authenticated -> AuthenticatedPlaceholderScreen(
profile = state.profile,
actionInProgress = actionState.inProgress,
errorMessage = actionState.errorMessage,
onLogout = sessionActionsViewModel::logout,
onSwitchProvider = sessionActionsViewModel::switchProvider,
)
}
}
}
}
@Composable
private fun FoundationScreen(onContinue: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
MessageScreen(
title = stringResource(R.string.foundation_title),
description = stringResource(R.string.foundation_description),
)
ABoxButton(
onClick = onContinue,
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp),
) {
Text(stringResource(R.string.continue_action))
}
}
}
@Composable
private fun MessageScreen(
title: String,
description: String,
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = title,
style = androidx.compose.material3.MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Text(
text = description,
style = androidx.compose.material3.MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
}
private fun AppSessionUiState.screenKey(): String = when (this) {
AppSessionUiState.Loading -> "loading"
AppSessionUiState.ProviderSelection -> "providers"
is AppSessionUiState.Login -> "login:${profile.id.value}"
is AppSessionUiState.Authenticated -> "authenticated:${profile.id.value}"
}
@@ -0,0 +1,79 @@
package com.flagship.abox.manager.ui.authenticated
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.flagship.abox.manager.R
import com.flagship.abox.manager.designsystem.ABoxButton
import com.flagship.abox.manager.domain.ProviderProfile
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AuthenticatedPlaceholderScreen(
profile: ProviderProfile,
actionInProgress: Boolean,
errorMessage: String?,
onLogout: () -> Unit,
onSwitchProvider: () -> Unit,
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.authenticated_title, profile.displayName)) },
)
},
) { contentPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(contentPadding),
contentAlignment = Alignment.Center,
) {
Column(
modifier = Modifier
.widthIn(max = 520.dp)
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(
text = stringResource(R.string.authenticated_description),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
errorMessage?.let { Text(it, color = MaterialTheme.colorScheme.error) }
ABoxButton(
onClick = onLogout,
enabled = !actionInProgress,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.action_logout))
}
ABoxButton(
onClick = onSwitchProvider,
enabled = !actionInProgress,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.action_switch_provider))
}
}
}
}
}
@@ -0,0 +1,235 @@
package com.flagship.abox.manager.ui.login
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.R
import com.flagship.abox.manager.designsystem.ABoxButton
import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderType
@Composable
fun LoginRoute(
profile: ProviderProfile,
bootstrapError: String? = null,
actionError: String? = null,
actionInProgress: Boolean = false,
onSwitchProvider: () -> Unit,
viewModel: LoginViewModel = hiltViewModel(),
) {
LaunchedEffect(profile.id) { viewModel.loadProfile(profile) }
val state = viewModel.uiState.collectAsStateWithLifecycle().value
val errorText = state.errorMessage?.let { stringResource(it.stringResource()) }
?: actionError
?: bootstrapError
LoginScreen(
profile = profile,
state = state,
errorText = errorText,
onUsernameChange = viewModel::setUsername,
onPasswordChange = viewModel::setPassword,
onRememberUsernameChange = viewModel::setRememberUsername,
onAutoLoginChange = viewModel::setAutoLogin,
onSubmit = viewModel::submit,
onSwitchProvider = onSwitchProvider,
switchingProvider = actionInProgress,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoginScreen(
profile: ProviderProfile,
state: LoginUiState,
errorText: String? = null,
onUsernameChange: (String) -> Unit,
onPasswordChange: (String) -> Unit,
onRememberUsernameChange: (Boolean) -> Unit,
onAutoLoginChange: (Boolean) -> Unit,
onSubmit: () -> Unit,
onSwitchProvider: () -> Unit,
switchingProvider: Boolean = false,
) {
val formEnabled = !state.isSubmitting && !state.isLoadingPreferences
val navigationEnabled = formEnabled && !switchingProvider
BackHandler {
if (navigationEnabled) onSwitchProvider()
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.login_title, providerName(profile.type))) },
navigationIcon = {
IconButton(
onClick = onSwitchProvider,
enabled = navigationEnabled,
modifier = Modifier.semantics {
contentDescription = "back-to-providers"
},
) {
Text(
text = "‹",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 4.dp),
)
}
},
)
},
) { contentPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(contentPadding),
contentAlignment = Alignment.TopCenter,
) {
Column(
modifier = Modifier
.widthIn(max = 520.dp)
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
stringResource(R.string.login_provider_context, providerName(profile.type)),
style = MaterialTheme.typography.titleMedium,
)
Text(
providerDescription(profile.type),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
OutlinedTextField(
value = state.username,
onValueChange = onUsernameChange,
label = { Text(stringResource(R.string.login_username)) },
enabled = formEnabled,
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.password,
onValueChange = onPasswordChange,
label = { Text(stringResource(R.string.login_password)) },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
autoCorrect = false,
),
enabled = formEnabled,
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
ToggleRow(
label = stringResource(R.string.login_remember_username),
checked = state.rememberUsername,
enabled = formEnabled,
onCheckedChange = onRememberUsernameChange,
)
ToggleRow(
label = stringResource(R.string.login_auto_login),
checked = state.autoLogin,
enabled = formEnabled,
onCheckedChange = onAutoLoginChange,
)
errorText?.let { Text(it, color = MaterialTheme.colorScheme.error) }
ABoxButton(
onClick = onSubmit,
enabled = formEnabled && !switchingProvider,
modifier = Modifier.fillMaxWidth(),
) {
if (state.isSubmitting || state.isLoadingPreferences || switchingProvider) {
ABoxLoadingIndicator()
}
else Text(stringResource(R.string.action_login))
}
ABoxButton(
onClick = onSwitchProvider,
enabled = navigationEnabled,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.login_back_to_providers))
}
}
}
}
}
@Composable
private fun ToggleRow(
label: String,
checked: Boolean,
enabled: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled)
Text(label)
}
}
private fun LoginErrorMessage.stringResource(): Int = when (this) {
LoginErrorMessage.REQUIRED_FIELDS -> R.string.login_error_required
LoginErrorMessage.INVALID_CREDENTIALS -> R.string.login_error_invalid_credentials
LoginErrorMessage.NETWORK_UNAVAILABLE -> R.string.login_error_network
LoginErrorMessage.SESSION_EXPIRED -> R.string.login_error_session_expired
LoginErrorMessage.PREFERENCES_UNAVAILABLE -> R.string.login_error_preferences
LoginErrorMessage.LOGIN_FAILED -> R.string.login_error_failed
}
@Composable
private fun providerName(type: ProviderType): String = stringResource(
when (type) {
ProviderType.ABOX -> R.string.provider_abox_name
ProviderType.LOCAL_EXPERIENCE -> R.string.provider_local_name
},
)
@Composable
private fun providerDescription(type: ProviderType): String = stringResource(
when (type) {
ProviderType.ABOX -> R.string.provider_abox_description
ProviderType.LOCAL_EXPERIENCE -> R.string.provider_local_description
},
)
@@ -0,0 +1,181 @@
package com.flagship.abox.manager.ui.login
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.Password
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.SessionCoordinator
import com.flagship.abox.manager.domain.Username
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
@HiltViewModel
class LoginViewModel @Inject constructor(
private val coordinator: SessionCoordinator,
private val profileStore: ProviderProfileStore,
) : ViewModel() {
private val _uiState = MutableStateFlow(LoginUiState())
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
private var profile: ProviderProfile? = null
private var loadingJob: Job? = null
private var submitJob: Job? = null
private var usernameEdited = false
private var rememberUsernameEdited = false
private var autoLoginEdited = false
private var loadedProfileId: com.flagship.abox.manager.domain.ProviderProfileId? = null
fun loadProfile(value: ProviderProfile) {
if (loadedProfileId == value.id ||
profile?.id == value.id &&
(loadingJob?.isActive == true || submitJob?.isActive == true)
) return
loadingJob?.cancel()
profile = value
usernameEdited = false
rememberUsernameEdited = false
autoLoginEdited = false
_uiState.value = LoginUiState(isLoadingPreferences = true)
loadingJob = viewModelScope.launch {
try {
val preferences = profileStore.loginPreferences(value.id)
if (profile?.id != value.id) return@launch
_uiState.value = _uiState.value.copy(
username = if (usernameEdited) {
_uiState.value.username
} else {
preferences.rememberedUsername?.value.orEmpty()
},
rememberUsername = if (rememberUsernameEdited) {
_uiState.value.rememberUsername
} else {
preferences.rememberUsername
},
autoLogin = if (autoLoginEdited) {
_uiState.value.autoLogin
} else {
preferences.autoLoginEnabled
},
isLoadingPreferences = false,
)
loadedProfileId = value.id
} catch (error: CancellationException) {
throw error
} catch (_: Exception) {
if (profile?.id == value.id) {
_uiState.value = _uiState.value.copy(
isLoadingPreferences = false,
errorMessage = LoginErrorMessage.PREFERENCES_UNAVAILABLE,
)
}
} finally {
loadingJob = null
}
}
}
fun clearSensitiveState() {
loadingJob?.cancel()
loadingJob = null
if (submitJob?.isActive == true) {
_uiState.value = _uiState.value.copy(
password = "",
errorMessage = null,
isSubmitting = true,
)
return
}
profile = null
loadedProfileId = null
usernameEdited = false
rememberUsernameEdited = false
autoLoginEdited = false
_uiState.value = LoginUiState()
}
fun setUsername(value: String) {
usernameEdited = true
_uiState.value = _uiState.value.copy(username = value, errorMessage = null)
}
fun setPassword(value: String) {
_uiState.value = _uiState.value.copy(password = value, errorMessage = null)
}
fun setRememberUsername(value: Boolean) {
rememberUsernameEdited = true
_uiState.value = _uiState.value.copy(rememberUsername = value)
}
fun setAutoLogin(value: Boolean) {
autoLoginEdited = true
_uiState.value = _uiState.value.copy(autoLogin = value)
}
fun submit() {
if (submitJob?.isActive == true) return
val activeProfile = profile ?: return
val state = _uiState.value
if (state.username.isBlank() ||
state.password.isEmpty() ||
state.isSubmitting ||
state.isLoadingPreferences
) {
_uiState.value = state.copy(errorMessage = LoginErrorMessage.REQUIRED_FIELDS)
return
}
submitJob = viewModelScope.launch {
try {
_uiState.value = state.copy(isSubmitting = true, errorMessage = null)
val result = coordinator.login(
activeProfile.id,
LoginCredentials(Username(state.username.trim()), Password(state.password)),
rememberUsername = state.rememberUsername,
autoLogin = state.autoLogin,
)
_uiState.value = _uiState.value.copy(
password = "",
isSubmitting = false,
errorMessage = (result as? DomainResult.Failure)?.error?.toUiMessage(),
)
} finally {
submitJob = null
}
}
}
private fun DomainError.toUiMessage(): LoginErrorMessage = when (this) {
is DomainError.Authentication -> LoginErrorMessage.INVALID_CREDENTIALS
is DomainError.Network -> LoginErrorMessage.NETWORK_UNAVAILABLE
is DomainError.TokenInvalid -> LoginErrorMessage.SESSION_EXPIRED
else -> LoginErrorMessage.LOGIN_FAILED
}
}
enum class LoginErrorMessage {
REQUIRED_FIELDS,
INVALID_CREDENTIALS,
NETWORK_UNAVAILABLE,
SESSION_EXPIRED,
PREFERENCES_UNAVAILABLE,
LOGIN_FAILED,
}
data class LoginUiState(
val username: String = "",
val password: String = "",
val rememberUsername: Boolean = false,
val autoLogin: Boolean = false,
val isLoadingPreferences: Boolean = false,
val isSubmitting: Boolean = false,
val errorMessage: LoginErrorMessage? = null,
)
@@ -0,0 +1,10 @@
package com.flagship.abox.manager.ui.navigation
object Routes {
const val STARTUP = "startup"
const val PROVIDER_SELECTION = "provider_selection"
const val LOGIN = "login"
/** Login-complete route contract; the device navigation owner replaces its placeholder. */
const val AUTHENTICATED = "authenticated"
}
@@ -0,0 +1,183 @@
package com.flagship.abox.manager.ui.provider
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.R
import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderType
@Composable
fun ProviderSelectionRoute(
viewModel: ProviderSelectionViewModel = hiltViewModel(),
) {
val state = viewModel.uiState.collectAsStateWithLifecycle().value
ProviderSelectionScreen(state = state, onSelect = viewModel::select)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ProviderSelectionScreen(
state: ProviderSelectionUiState,
onSelect: (ProviderProfile) -> Unit,
) {
Scaffold(
topBar = {
TopAppBar(title = { Text(stringResource(R.string.provider_selection_title)) })
},
) { contentPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(contentPadding),
contentAlignment = Alignment.TopCenter,
) {
Column(
modifier = Modifier
.widthIn(max = 640.dp)
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(
text = stringResource(R.string.provider_selection_description),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 6.dp),
)
state.providers.forEach { profile ->
ProviderOptionCard(
profile = profile,
selecting = state.isSelecting,
onSelect = onSelect,
)
}
state.errorMessage?.let {
Text(it, color = MaterialTheme.colorScheme.error)
}
if (state.isSelecting) {
ABoxLoadingIndicator(
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(16.dp),
)
}
Spacer(Modifier.padding(bottom = 12.dp))
}
}
}
}
@Composable
private fun ProviderOptionCard(
profile: ProviderProfile,
selecting: Boolean,
onSelect: (ProviderProfile) -> Unit,
) {
val displayName = providerName(profile.type)
val description = providerDescription(profile.type)
OutlinedCard(
onClick = { onSelect(profile) },
enabled = !selecting,
shape = MaterialTheme.shapes.medium,
colors = CardDefaults.outlinedCardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
modifier = Modifier
.fillMaxWidth()
.semantics {
role = Role.Button
contentDescription = displayName
},
) {
Row(
modifier = Modifier.padding(18.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.16f)),
contentAlignment = Alignment.Center,
) {
Text(
text = providerMark(profile.type),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
)
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(displayName, style = MaterialTheme.typography.titleMedium)
Text(
description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
"›",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
}
@Composable
private fun providerName(type: ProviderType): String = stringResource(
when (type) {
ProviderType.ABOX -> R.string.provider_abox_name
ProviderType.LOCAL_EXPERIENCE -> R.string.provider_local_name
},
)
@Composable
private fun providerDescription(type: ProviderType): String = stringResource(
when (type) {
ProviderType.ABOX -> R.string.provider_abox_description
ProviderType.LOCAL_EXPERIENCE -> R.string.provider_local_description
},
)
private fun providerMark(type: ProviderType): String = when (type) {
ProviderType.ABOX -> "A"
ProviderType.LOCAL_EXPERIENCE -> "L"
}
@@ -0,0 +1,52 @@
package com.flagship.abox.manager.ui.provider
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.DomainResult
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 dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
@HiltViewModel
class ProviderSelectionViewModel @Inject constructor(
private val coordinator: SessionCoordinator,
) : ViewModel() {
private val _uiState = MutableStateFlow(ProviderSelectionUiState(providers = PROFILES))
val uiState: StateFlow<ProviderSelectionUiState> = _uiState.asStateFlow()
fun select(profile: ProviderProfile) {
if (_uiState.value.isSelecting) return
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isSelecting = true, errorMessage = null)
val result = coordinator.selectProvider(profile)
_uiState.value = _uiState.value.copy(
isSelecting = false,
errorMessage = (result as? DomainResult.Failure)?.error?.detail,
)
}
}
companion object {
private val PROFILES = listOf(
ProviderProfile(ProviderProfileId("abox-default"), ProviderType.ABOX, "ABox"),
ProviderProfile(
ProviderProfileId("local-experience"),
ProviderType.LOCAL_EXPERIENCE,
"Local experience",
),
)
}
}
data class ProviderSelectionUiState(
val providers: List<ProviderProfile>,
val isSelecting: Boolean = false,
val errorMessage: String? = null,
)
@@ -0,0 +1,127 @@
package com.flagship.abox.manager.ui.session
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.DomainResult
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.Session
import com.flagship.abox.manager.domain.SessionCoordinator
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@HiltViewModel
class AppSessionViewModel @Inject constructor(
private val coordinator: SessionCoordinator,
private val profileStore: ProviderProfileStore,
) : ViewModel() {
private val bootstrapResult = MutableStateFlow<ProviderBootstrapResult?>(null)
val uiState: StateFlow<AppSessionUiState> = combine(
bootstrapResult,
profileStore.observeActiveProfile(),
coordinator.observeActiveSession(),
) { bootstrap, profile, session ->
if (bootstrap == null) AppSessionUiState.Loading
else stateFor(
profile = profile,
session = session,
bootstrapError = bootstrap.errorFor(profile?.id),
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = AppSessionUiState.Loading,
)
init {
viewModelScope.launch {
try {
val activeProfile = profileStore.activeProfile()
bootstrapResult.value = ProviderBootstrapResult(
profileId = activeProfile?.id,
result = coordinator.bootstrap(),
)
} catch (error: kotlinx.coroutines.CancellationException) {
throw error
} catch (error: Exception) {
bootstrapResult.value = ProviderBootstrapResult(
profileId = null,
result = DomainResult.Failure(
com.flagship.abox.manager.domain.DomainError.Unknown(
detail = error.message ?: "Unable to load provider settings",
),
),
)
}
}
viewModelScope.launch {
coordinator.observeActiveSession()
.map { it?.profileId }
.distinctUntilChanged()
.collect { sessionProfileId ->
if (sessionProfileId != null && bootstrapResult.value?.profileId == sessionProfileId) {
bootstrapResult.value = ProviderBootstrapResult(
profileId = sessionProfileId,
result = DomainResult.Success(null),
)
}
}
}
viewModelScope.launch {
profileStore.observeActiveProfile()
.map { it?.id }
.distinctUntilChanged()
.collect { activeProfileId ->
val currentBootstrap = bootstrapResult.value
if (currentBootstrap != null && currentBootstrap.profileId != activeProfileId) {
bootstrapResult.value = ProviderBootstrapResult(
profileId = activeProfileId,
result = DomainResult.Success(null),
)
}
}
}
}
private fun stateFor(
profile: ProviderProfile?,
session: Session?,
bootstrapError: String?,
): AppSessionUiState = when {
profile == null -> AppSessionUiState.ProviderSelection
session?.profileId == profile.id -> AppSessionUiState.Authenticated(profile)
else -> AppSessionUiState.Login(profile, bootstrapError)
}
}
private data class ProviderBootstrapResult(
val profileId: ProviderProfileId?,
val result: DomainResult<Session?>,
) {
fun errorFor(currentProfileId: ProviderProfileId?): String? =
if (profileId == currentProfileId) {
(result as? DomainResult.Failure)?.error?.detail
} else {
null
}
}
sealed interface AppSessionUiState {
data object Loading : AppSessionUiState
data object ProviderSelection : AppSessionUiState
data class Login(
val profile: ProviderProfile,
val bootstrapError: String? = null,
) : AppSessionUiState
data class Authenticated(val profile: ProviderProfile) : AppSessionUiState
}
@@ -0,0 +1,44 @@
package com.flagship.abox.manager.ui.session
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.SessionCoordinator
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
@HiltViewModel
class SessionActionsViewModel @Inject constructor(
private val coordinator: SessionCoordinator,
) : ViewModel() {
private val _uiState = MutableStateFlow(SessionActionsUiState())
val uiState: StateFlow<SessionActionsUiState> = _uiState.asStateFlow()
fun logout() = runAction(coordinator::logout)
fun switchProvider() = runAction(coordinator::switchProvider)
fun dismissError() {
_uiState.value = _uiState.value.copy(errorMessage = null)
}
private fun runAction(action: suspend () -> DomainResult<Unit>) {
if (_uiState.value.inProgress) return
viewModelScope.launch {
_uiState.value = SessionActionsUiState(inProgress = true)
val result = action()
_uiState.value = SessionActionsUiState(
errorMessage = (result as? DomainResult.Failure)?.error?.detail,
)
}
}
}
data class SessionActionsUiState(
val inProgress: Boolean = false,
val errorMessage: String? = null,
)
@@ -1,8 +1,27 @@
<resources>
<string name="app_name">ABox Manager</string>
<string name="foundation_title">工程基础已就绪</string>
<string name="foundation_description">公共领域接口和本地开发提供方已经可用。</string>
<string name="continue_action">继续</string>
<string name="placeholder_title">功能模块可以开始开发</string>
<string name="placeholder_description">设备、红外和情景页面将由对应模块负责人实现。</string>
<string name="provider_selection_title">选择提供方</string>
<string name="provider_selection_description">选择当前配置的连接方式。不同提供方之间的会话、凭据和本地数据相互隔离。</string>
<string name="provider_abox_name">ABox</string>
<string name="provider_abox_description">通过 ABSDK 连接当前局域网中的 ABox 智能家居网关。</string>
<string name="provider_local_name">本地体验</string>
<string name="provider_local_description">无需网络,通过本地模拟设备体验应用。</string>
<string name="login_title">登录 %1$s</string>
<string name="login_provider_context">当前提供方:%1$s</string>
<string name="login_back_to_providers">返回提供方选择</string>
<string name="login_username">用户名</string>
<string name="login_password">密码</string>
<string name="login_remember_username">记住用户名</string>
<string name="login_auto_login">自动登录</string>
<string name="login_error_required">请输入用户名和密码。</string>
<string name="login_error_invalid_credentials">用户名或密码不正确。</string>
<string name="login_error_network">网络不可用,请重试。</string>
<string name="login_error_session_expired">会话已失效,请重新登录。</string>
<string name="login_error_preferences">无法加载已保存的登录设置。</string>
<string name="login_error_failed">登录失败,请重试。</string>
<string name="action_login">登录</string>
<string name="action_logout">退出登录</string>
<string name="action_switch_provider">切换提供方</string>
<string name="authenticated_title">已连接到 %1$s</string>
<string name="authenticated_description">登录后入口已经就绪,设备导航将由对应模块负责人接入。</string>
</resources>
+24 -5
View File
@@ -1,8 +1,27 @@
<resources>
<string name="app_name">ABox Manager</string>
<string name="foundation_title">Project foundation ready</string>
<string name="foundation_description">The shared domain contracts and local development providers are available.</string>
<string name="continue_action">Continue</string>
<string name="placeholder_title">Feature development can start</string>
<string name="placeholder_description">Device, infrared and scene screens will be added by their module owners.</string>
<string name="provider_selection_title">Choose a provider</string>
<string name="provider_selection_description">Choose how this profile connects. Sessions, credentials, and local data stay isolated between providers.</string>
<string name="provider_abox_name">ABox</string>
<string name="provider_abox_description">Connect to the ABox smart-home gateway through ABSDK on the current local network.</string>
<string name="provider_local_name">Local experience</string>
<string name="provider_local_description">Explore the app with simulated local devices and no network connection.</string>
<string name="login_title">Sign in to %1$s</string>
<string name="login_provider_context">Selected provider: %1$s</string>
<string name="login_back_to_providers">Back to providers</string>
<string name="login_username">Username</string>
<string name="login_password">Password</string>
<string name="login_remember_username">Remember username</string>
<string name="login_auto_login">Sign in automatically</string>
<string name="login_error_required">Username and password are required.</string>
<string name="login_error_invalid_credentials">Invalid username or password.</string>
<string name="login_error_network">Network unavailable. Try again.</string>
<string name="login_error_session_expired">Session expired. Sign in again.</string>
<string name="login_error_preferences">Unable to load saved login settings.</string>
<string name="login_error_failed">Sign-in failed. Try again.</string>
<string name="action_login">Sign in</string>
<string name="action_logout">Sign out</string>
<string name="action_switch_provider">Switch provider</string>
<string name="authenticated_title">Connected to %1$s</string>
<string name="authenticated_description">The authenticated route is ready. Device navigation will be added by its module owner.</string>
</resources>
@@ -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()
}
}
@@ -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)
}
}
@@ -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")
}
}