feat: add provider login flow

This commit is contained in:
2026-08-23 21:58:04 +08:00
parent 6f7d3248b0
commit cc8eeebdc4
43 changed files with 2277 additions and 91 deletions
+6
View File
@@ -54,12 +54,16 @@ kapt {
dependencies { dependencies {
implementation(project(":domain")) implementation(project(":domain"))
implementation(project(":android:core:designsystem")) implementation(project(":android:core:designsystem"))
implementation(project(":android:core:session"))
debugImplementation(project(":testing:fakes")) debugImplementation(project(":testing:fakes"))
implementation(platform(libs.compose.bom)) implementation(platform(libs.compose.bom))
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
implementation(libs.datastore.preferences)
implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.compose.ui) implementation(libs.compose.ui)
implementation(libs.compose.ui.tooling.preview) implementation(libs.compose.ui.tooling.preview)
@@ -68,8 +72,10 @@ dependencies {
kapt(libs.hilt.compiler) kapt(libs.hilt.compiler)
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(platform(libs.compose.bom)) androidTestImplementation(platform(libs.compose.bom))
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.espresso.core) androidTestImplementation(libs.espresso.core)
androidTestImplementation(libs.compose.ui.test.junit4) androidTestImplementation(libs.compose.ui.test.junit4)
debugImplementation(libs.compose.ui.tooling) debugImplementation(libs.compose.ui.tooling)
@@ -0,0 +1,76 @@
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 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,52 @@
package com.flagship.abox.manager.ui.provider
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.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 enabledOptionCanBeSelectedAndDisabledOptionCannot() {
val local = ProviderOption(
ProviderProfile(ProviderProfileId("local"), ProviderType.LOCAL_EXPERIENCE, "Local"),
Availability.Enabled,
)
val compatible = ProviderOption(
ProviderProfile(
ProviderProfileId("compatible"),
ProviderType.COMPATIBLE_BACKEND,
"Compatible",
),
Availability.Disabled(UnavailableReason.PROTOCOL_PENDING),
)
var selected: ProviderOption? = null
composeRule.setContent {
ABoxTheme {
ProviderSelectionScreen(
state = ProviderSelectionUiState(listOf(local, compatible)),
onSelect = { selected = it },
)
}
}
composeRule.onNodeWithText("Local experience").assertIsEnabled().performClick()
assertEquals(local, selected)
composeRule.onNodeWithText("Compatible backend").assertIsNotEnabled()
}
}
@@ -0,0 +1,27 @@
package com.flagship.abox.manager.di
import com.flagship.abox.manager.domain.SessionGateway
import com.flagship.abox.manager.testing.fakes.FakeSessionGateway
import com.flagship.abox.manager.ui.provider.Availability
import com.flagship.abox.manager.ui.provider.ProviderAvailability
import com.flagship.abox.manager.ui.provider.UnavailableReason
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DebugSessionModule {
@Provides
@Singleton
fun provideRemoteSessionGateway(fake: FakeSessionGateway): SessionGateway = fake
@Provides
@Singleton
fun provideProviderAvailability(): ProviderAvailability = ProviderAvailability(
abox = Availability.Simulation,
compatible = Availability.Disabled(UnavailableReason.PROTOCOL_PENDING),
)
}
@@ -2,7 +2,6 @@ package com.flagship.abox.manager.di
import com.flagship.abox.manager.domain.DeviceCatalog import com.flagship.abox.manager.domain.DeviceCatalog
import com.flagship.abox.manager.domain.InfraredGateway 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.SocketGateway
import com.flagship.abox.manager.domain.TemperatureHumidityGateway import com.flagship.abox.manager.domain.TemperatureHumidityGateway
import com.flagship.abox.manager.testing.fakes.FakeDeviceCatalog import com.flagship.abox.manager.testing.fakes.FakeDeviceCatalog
@@ -21,7 +20,7 @@ import javax.inject.Singleton
object FoundationModule { object FoundationModule {
@Provides @Provides
@Singleton @Singleton
fun provideSessionGateway(): SessionGateway = FakeSessionGateway() fun provideFakeSessionGateway(): FakeSessionGateway = FakeSessionGateway()
@Provides @Provides
@Singleton @Singleton
@@ -0,0 +1,67 @@
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.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.gateway.LocalSessionGatewayImpl
import com.flagship.abox.manager.session.profile.ProviderProfileStoreImpl
import com.flagship.abox.manager.session.profile.sessionDataStore
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
fun provideLocalSessionGateway(): LocalSessionGateway = LocalSessionGatewayImpl()
@Provides
@Singleton
fun provideSessionCoordinator(
profileStore: ProviderProfileStore,
credentialStore: CredentialStore,
remoteGateway: SessionGateway,
localGateway: LocalSessionGateway,
): SessionCoordinator = SessionCoordinator(
profileStore,
credentialStore,
remoteGateway,
localGateway,
)
}
@@ -1,94 +1,60 @@
package com.flagship.abox.manager.ui package com.flagship.abox.manager.ui
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel
import androidx.compose.ui.text.style.TextAlign import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.ui.unit.dp import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator
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 com.flagship.abox.manager.designsystem.ABoxSurface import com.flagship.abox.manager.designsystem.ABoxSurface
import com.flagship.abox.manager.designsystem.ABoxTheme import com.flagship.abox.manager.designsystem.ABoxTheme
import com.flagship.abox.manager.ui.authenticated.AuthenticatedPlaceholderScreen
private const val FOUNDATION_ROUTE = "foundation" import com.flagship.abox.manager.ui.login.LoginRoute
private const val PLACEHOLDER_ROUTE = "placeholder" 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 @Composable
fun ABoxManagerApp() { fun ABoxManagerApp(
appSessionViewModel: AppSessionViewModel = hiltViewModel(),
sessionActionsViewModel: SessionActionsViewModel = hiltViewModel(),
) {
val actionState = sessionActionsViewModel.uiState.collectAsStateWithLifecycle().value
ABoxTheme { ABoxTheme {
ABoxSurface(modifier = Modifier.fillMaxSize()) { ABoxSurface(modifier = Modifier.fillMaxSize()) {
val navController = rememberNavController() when (
NavHost( val state = appSessionViewModel.uiState.collectAsStateWithLifecycle().value
navController = navController,
startDestination = FOUNDATION_ROUTE,
) { ) {
composable(FOUNDATION_ROUTE) { AppSessionUiState.Loading -> Box(
FoundationScreen(onContinue = { navController.navigate(PLACEHOLDER_ROUTE) })
}
composable(PLACEHOLDER_ROUTE) {
MessageScreen(
title = stringResource(R.string.placeholder_title),
description = stringResource(R.string.placeholder_description),
)
}
}
}
}
}
@Composable
private fun FoundationScreen(onContinue: () -> Unit) {
Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(32.dp), .windowInsetsPadding(WindowInsets.safeDrawing),
horizontalAlignment = Alignment.CenterHorizontally, contentAlignment = Alignment.Center,
verticalArrangement = Arrangement.Center,
) { ) {
MessageScreen( ABoxLoadingIndicator()
title = stringResource(R.string.foundation_title), }
description = stringResource(R.string.foundation_description), AppSessionUiState.ProviderSelection -> ProviderSelectionRoute()
is AppSessionUiState.Login -> LoginRoute(
profile = state.profile,
bootstrapError = state.bootstrapError,
actionError = actionState.errorMessage,
actionInProgress = actionState.inProgress,
onSwitchProvider = sessionActionsViewModel::switchProvider,
) )
ABoxButton( is AppSessionUiState.Authenticated -> AuthenticatedPlaceholderScreen(
onClick = onContinue, profile = state.profile,
modifier = Modifier actionInProgress = actionState.inProgress,
.fillMaxWidth() errorMessage = actionState.errorMessage,
.padding(top = 24.dp), onLogout = sessionActionsViewModel::logout,
) { onSwitchProvider = sessionActionsViewModel::switchProvider,
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,
) )
} }
} }
}
}
@@ -0,0 +1,76 @@
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.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
.fillMaxWidth()
.widthIn(max = 520.dp)
.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,212 @@
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.PasswordVisualTransformation
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
LoginScreen(
profile = profile,
state = state.copy(
errorMessage = state.errorMessage ?: actionError ?: bootstrapError,
),
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,
onUsernameChange: (String) -> Unit,
onPasswordChange: (String) -> Unit,
onRememberUsernameChange: (Boolean) -> Unit,
onAutoLoginChange: (Boolean) -> Unit,
onSubmit: () -> Unit,
onSwitchProvider: () -> Unit,
switchingProvider: Boolean = false,
) {
val navigationEnabled = !state.isSubmitting && !switchingProvider
BackHandler(enabled = navigationEnabled, onBack = 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
.fillMaxWidth()
.widthIn(max = 520.dp)
.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 = !state.isSubmitting,
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.password,
onValueChange = onPasswordChange,
label = { Text(stringResource(R.string.login_password)) },
visualTransformation = PasswordVisualTransformation(),
enabled = !state.isSubmitting,
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
ToggleRow(
label = stringResource(R.string.login_remember_username),
checked = state.rememberUsername,
enabled = !state.isSubmitting,
onCheckedChange = onRememberUsernameChange,
)
ToggleRow(
label = stringResource(R.string.login_auto_login),
checked = state.autoLogin,
enabled = !state.isSubmitting,
onCheckedChange = onAutoLoginChange,
)
state.errorMessage?.let { Text(it, color = MaterialTheme.colorScheme.error) }
ABoxButton(
onClick = onSubmit,
enabled = !state.isSubmitting && !switchingProvider,
modifier = Modifier.fillMaxWidth(),
) {
if (state.isSubmitting || 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)
}
}
@Composable
private fun providerName(type: ProviderType): String = stringResource(
when (type) {
ProviderType.ABOX -> R.string.provider_abox_name
ProviderType.COMPATIBLE_BACKEND -> R.string.provider_compatible_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.COMPATIBLE_BACKEND -> R.string.provider_compatible_description
ProviderType.LOCAL_EXPERIENCE -> R.string.provider_local_description
},
)
@@ -0,0 +1,98 @@
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.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
fun loadProfile(value: ProviderProfile) {
if (profile?.id == value.id && loadingJob != null) return
profile = value
loadingJob = viewModelScope.launch {
val preferences = profileStore.loginPreferences(value.id)
_uiState.value = _uiState.value.copy(
username = preferences.rememberedUsername?.value.orEmpty(),
rememberUsername = preferences.rememberUsername,
autoLogin = preferences.autoLoginEnabled,
)
}
}
fun setUsername(value: String) {
_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) {
_uiState.value = _uiState.value.copy(rememberUsername = value)
}
fun setAutoLogin(value: Boolean) {
_uiState.value = _uiState.value.copy(autoLogin = value)
}
fun submit() {
val activeProfile = profile ?: return
val state = _uiState.value
if (state.username.isBlank() || state.password.isEmpty() || state.isSubmitting) {
_uiState.value = state.copy(errorMessage = "Username and password are required")
return
}
viewModelScope.launch {
_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(),
)
}
}
private fun DomainError.toUiMessage(): String = when (this) {
is DomainError.Authentication -> "Invalid username or password"
is DomainError.Network -> "Network unavailable"
is DomainError.TokenInvalid -> "Session expired"
else -> detail ?: "Login failed"
}
}
data class LoginUiState(
val username: String = "",
val password: String = "",
val rememberUsername: Boolean = false,
val autoLogin: Boolean = false,
val isSubmitting: Boolean = false,
val errorMessage: String? = 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,28 @@
package com.flagship.abox.manager.ui.provider
import com.flagship.abox.manager.domain.ProviderType
data class ProviderAvailability(
val abox: Availability,
val compatible: Availability,
val local: Availability = Availability.Enabled,
) {
fun forType(type: ProviderType): Availability = when (type) {
ProviderType.ABOX -> abox
ProviderType.COMPATIBLE_BACKEND -> compatible
ProviderType.LOCAL_EXPERIENCE -> local
}
}
sealed interface Availability {
data object Enabled : Availability
data object Simulation : Availability
data class Disabled(val reason: UnavailableReason) : Availability
}
enum class UnavailableReason {
PROTOCOL_PENDING,
TRANSPORT_SECURITY_PENDING,
}
@@ -0,0 +1,225 @@
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.alpha
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.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: (ProviderOption) -> 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
.fillMaxWidth()
.widthIn(max = 640.dp)
.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 { option ->
ProviderOptionCard(
option = option,
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(
option: ProviderOption,
selecting: Boolean,
onSelect: (ProviderOption) -> Unit,
) {
val enabled = option.availability !is Availability.Disabled && !selecting
val displayName = providerName(option.profile.type)
val description = providerDescription(option.profile.type)
val status = availabilityLabel(option.availability)
val statusColor = availabilityColor(option.availability)
OutlinedCard(
onClick = { onSelect(option) },
enabled = enabled,
shape = MaterialTheme.shapes.medium,
colors = CardDefaults.outlinedCardColors(
containerColor = when (option.availability) {
Availability.Enabled -> MaterialTheme.colorScheme.surface
Availability.Simulation -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.35f)
is Availability.Disabled -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f)
},
),
modifier = Modifier
.fillMaxWidth()
.alpha(if (enabled || selecting) 1f else 0.72f)
.semantics {
role = Role.Button
contentDescription = "$displayName, $status"
},
) {
Row(
modifier = Modifier.padding(18.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(statusColor.copy(alpha = 0.16f)),
contentAlignment = Alignment.Center,
) {
Text(
text = providerMark(option.profile.type),
style = MaterialTheme.typography.titleMedium,
color = statusColor,
)
}
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,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
Modifier
.size(8.dp)
.clip(CircleShape)
.background(statusColor),
)
Text(
status,
style = MaterialTheme.typography.labelLarge,
color = statusColor,
)
}
}
Text("›", style = MaterialTheme.typography.headlineSmall, color = statusColor)
}
}
}
@Composable
private fun providerName(type: ProviderType): String = stringResource(
when (type) {
ProviderType.ABOX -> R.string.provider_abox_name
ProviderType.COMPATIBLE_BACKEND -> R.string.provider_compatible_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.COMPATIBLE_BACKEND -> R.string.provider_compatible_description
ProviderType.LOCAL_EXPERIENCE -> R.string.provider_local_description
},
)
private fun providerMark(type: ProviderType): String = when (type) {
ProviderType.ABOX -> "A"
ProviderType.COMPATIBLE_BACKEND -> "C"
ProviderType.LOCAL_EXPERIENCE -> "L"
}
@Composable
private fun availabilityLabel(availability: Availability): String = stringResource(
when (availability) {
Availability.Enabled -> R.string.provider_status_available
Availability.Simulation -> R.string.provider_status_simulation
is Availability.Disabled -> when (availability.reason) {
UnavailableReason.PROTOCOL_PENDING -> R.string.provider_status_protocol_pending
UnavailableReason.TRANSPORT_SECURITY_PENDING -> R.string.provider_status_transport_pending
}
},
)
@Composable
private fun availabilityColor(availability: Availability) = when (availability) {
Availability.Enabled -> MaterialTheme.colorScheme.primary
Availability.Simulation -> MaterialTheme.colorScheme.tertiary
is Availability.Disabled -> MaterialTheme.colorScheme.error
}
@@ -0,0 +1,69 @@
package com.flagship.abox.manager.ui.provider
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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,
availability: ProviderAvailability,
) : ViewModel() {
private val _uiState = MutableStateFlow(
ProviderSelectionUiState(
providers = PROFILES.map { profile ->
ProviderOption(profile, availability.forType(profile.type))
},
),
)
val uiState: StateFlow<ProviderSelectionUiState> = _uiState.asStateFlow()
fun select(option: ProviderOption) {
if (option.availability is Availability.Disabled || _uiState.value.isSelecting) return
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isSelecting = true, errorMessage = null)
val result = coordinator.selectProvider(option.profile)
_uiState.value = _uiState.value.copy(
isSelecting = false,
errorMessage = (result as? com.flagship.abox.manager.domain.DomainResult.Failure)
?.error?.detail,
)
}
}
companion object {
private val PROFILES = listOf(
ProviderProfile(ProviderProfileId("abox-default"), ProviderType.ABOX, "ABox"),
ProviderProfile(
ProviderProfileId("compatible-default"),
ProviderType.COMPATIBLE_BACKEND,
"Compatible backend",
),
ProviderProfile(
ProviderProfileId("local-experience"),
ProviderType.LOCAL_EXPERIENCE,
"Local experience",
),
)
}
}
data class ProviderSelectionUiState(
val providers: List<ProviderOption>,
val isSelecting: Boolean = false,
val errorMessage: String? = null,
)
data class ProviderOption(
val profile: ProviderProfile,
val availability: Availability,
)
@@ -0,0 +1,64 @@
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.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.stateIn
import kotlinx.coroutines.launch
@HiltViewModel
class AppSessionViewModel @Inject constructor(
private val coordinator: SessionCoordinator,
profileStore: ProviderProfileStore,
) : ViewModel() {
private val bootstrapResult = MutableStateFlow<DomainResult<Session?>?>(null)
val uiState: StateFlow<AppSessionUiState> = combine(
bootstrapResult,
profileStore.observeActiveProfile(),
coordinator.observeActiveSession(),
) { result, profile, session ->
if (result == null) AppSessionUiState.Loading
else stateFor(profile, session, (result as? DomainResult.Failure)?.error?.detail)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = AppSessionUiState.Loading,
)
init {
viewModelScope.launch {
bootstrapResult.value = coordinator.bootstrap()
}
}
private fun stateFor(
profile: ProviderProfile?,
session: Session?,
bootstrapError: String?,
): AppSessionUiState = when {
profile == null -> AppSessionUiState.ProviderSelection
session != null -> AppSessionUiState.Authenticated(profile)
else -> AppSessionUiState.Login(profile, bootstrapError)
}
}
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> <resources>
<string name="app_name">ABox Manager</string> <string name="app_name">ABox Manager</string>
<string name="foundation_title">工程基础已就绪</string> <string name="provider_selection_title">选择提供方</string>
<string name="foundation_description">公共领域接口和本地开发提供方已经可用。</string> <string name="provider_selection_description">选择当前配置的连接方式。不同提供方之间的会话、凭据和本地数据相互隔离。</string>
<string name="continue_action">继续</string> <string name="provider_abox_name">ABox</string>
<string name="placeholder_title">功能模块可以开始开发</string> <string name="provider_abox_description">通过兼容 ABSDK 的 ABox 服务连接设备。</string>
<string name="placeholder_description">设备、红外和情景页面将由对应模块负责人实现。</string> <string name="provider_compatible_name">自有兼容后端</string>
<string name="provider_compatible_description">在协议准备完成后连接自行部署的 ABSDK 兼容服务。</string>
<string name="provider_local_name">本地体验</string>
<string name="provider_local_description">无需网络,通过本地模拟设备体验完整应用流程。</string>
<string name="provider_status_available">可用</string>
<string name="provider_status_simulation">开发模拟</string>
<string name="provider_status_protocol_pending">协议待确认</string>
<string name="provider_status_transport_pending">等待传输安全方案批准</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="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> </resources>
+24 -5
View File
@@ -1,8 +1,27 @@
<resources> <resources>
<string name="app_name">ABox Manager</string> <string name="app_name">ABox Manager</string>
<string name="foundation_title">Project foundation ready</string> <string name="provider_selection_title">Choose a provider</string>
<string name="foundation_description">The shared domain contracts and local development providers are available.</string> <string name="provider_selection_description">Choose how this profile connects. Sessions, credentials, and local data stay isolated between providers.</string>
<string name="continue_action">Continue</string> <string name="provider_abox_name">ABox</string>
<string name="placeholder_title">Feature development can start</string> <string name="provider_abox_description">Connect through the ABSDK-compatible ABox service.</string>
<string name="placeholder_description">Device, infrared and scene screens will be added by their module owners.</string> <string name="provider_compatible_name">Compatible backend</string>
<string name="provider_compatible_description">Use a self-hosted ABSDK-compatible service when its protocol is ready.</string>
<string name="provider_local_name">Local experience</string>
<string name="provider_local_description">Explore the complete app flow with local simulated devices and no network.</string>
<string name="provider_status_available">Available</string>
<string name="provider_status_simulation">Development simulation</string>
<string name="provider_status_protocol_pending">Protocol pending</string>
<string name="provider_status_transport_pending">Transport security approval required</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="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> </resources>
@@ -0,0 +1,27 @@
package com.flagship.abox.manager.di
import com.flagship.abox.manager.domain.SessionGateway
import com.flagship.abox.manager.session.gateway.UnavailableRemoteSessionGateway
import com.flagship.abox.manager.ui.provider.Availability
import com.flagship.abox.manager.ui.provider.ProviderAvailability
import com.flagship.abox.manager.ui.provider.UnavailableReason
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object ReleaseSessionModule {
@Provides
@Singleton
fun provideRemoteSessionGateway(): SessionGateway = UnavailableRemoteSessionGateway()
@Provides
@Singleton
fun provideProviderAvailability(): ProviderAvailability = ProviderAvailability(
abox = Availability.Disabled(UnavailableReason.TRANSPORT_SECURITY_PENDING),
compatible = Availability.Disabled(UnavailableReason.PROTOCOL_PENDING),
)
}
+34
View File
@@ -0,0 +1,34 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
}
android {
namespace = "com.flagship.abox.manager.session"
compileSdk = 34
defaultConfig {
minSdk = 26
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
api(project(":domain"))
implementation(libs.kotlinx.coroutines.core)
implementation(libs.datastore.preferences)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.test.runner)
}
@@ -0,0 +1,27 @@
package com.flagship.abox.manager.session.credential
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.domain.ProviderProfileId
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertFalse
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class AndroidKeystoreCredentialCipherTest {
@Test
fun encryptsWithRandomIvAndProfileBoundAad() {
val cipher = AndroidKeystoreCredentialCipher(
keyAlias = "abox_manager_test_${System.nanoTime()}",
)
val profile = ProviderProfileId("profile")
val plaintext = "secret".toByteArray()
val first = cipher.encrypt(profile, plaintext)
val second = cipher.encrypt(profile, plaintext)
assertFalse(first.contentEquals(second))
assertArrayEquals(plaintext, cipher.decrypt(profile, first))
assertArrayEquals(plaintext, cipher.decrypt(profile, second))
}
}
@@ -0,0 +1 @@
<manifest />
@@ -0,0 +1,68 @@
package com.flagship.abox.manager.session.credential
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import com.flagship.abox.manager.domain.ProviderProfileId
import java.nio.ByteBuffer
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
class AndroidKeystoreCredentialCipher(
private val keyAlias: String = KEY_ALIAS,
) : CredentialCipher {
override fun encrypt(profileId: ProviderProfileId, plaintext: ByteArray): ByteArray {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, secretKey())
cipher.updateAAD(profileId.value.toByteArray(Charsets.UTF_8))
val ciphertext = cipher.doFinal(plaintext)
return ByteBuffer.allocate(2 + cipher.iv.size + ciphertext.size)
.put(VERSION)
.put(cipher.iv.size.toByte())
.put(cipher.iv)
.put(ciphertext)
.array()
}
override fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray {
val buffer = ByteBuffer.wrap(encrypted)
require(buffer.get() == VERSION) { "Unsupported credential format" }
val ivLength = buffer.get().toInt() and 0xFF
require(ivLength in 12..16 && buffer.remaining() > ivLength) { "Invalid credential payload" }
val iv = ByteArray(ivLength).also(buffer::get)
val ciphertext = ByteArray(buffer.remaining()).also(buffer::get)
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, secretKey(), GCMParameterSpec(GCM_TAG_BITS, iv))
cipher.updateAAD(profileId.value.toByteArray(Charsets.UTF_8))
return cipher.doFinal(ciphertext)
}
private fun secretKey(): SecretKey {
val keyStore = KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) }
(keyStore.getKey(keyAlias, null) as? SecretKey)?.let { return it }
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEY_STORE).run {
init(
KeyGenParameterSpec.Builder(
keyAlias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setKeySize(KEY_SIZE_BITS)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build(),
)
generateKey()
}
}
companion object {
private const val ANDROID_KEY_STORE = "AndroidKeyStore"
private const val KEY_ALIAS = "abox_manager_credentials_v1"
private const val TRANSFORMATION = "AES/GCM/NoPadding"
private const val KEY_SIZE_BITS = 256
private const val GCM_TAG_BITS = 128
private const val VERSION: Byte = 1
}
}
@@ -0,0 +1,9 @@
package com.flagship.abox.manager.session.credential
import com.flagship.abox.manager.domain.ProviderProfileId
interface CredentialCipher {
fun encrypt(profileId: ProviderProfileId, plaintext: ByteArray): ByteArray
fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray
}
@@ -0,0 +1,102 @@
package com.flagship.abox.manager.session.credential
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.flagship.abox.manager.domain.CredentialStore
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.LoginCredentials
import com.flagship.abox.manager.domain.Password
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.Username
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.DataInputStream
import java.io.DataOutputStream
import java.security.InvalidKeyException
import java.security.KeyStoreException
import java.security.UnrecoverableKeyException
import java.util.Base64
import javax.crypto.AEADBadTagException
import kotlinx.coroutines.flow.first
class KeystoreCredentialStore(
private val dataStore: DataStore<Preferences>,
private val cipher: CredentialCipher,
) : CredentialStore {
override suspend fun saveCredentials(
profileId: ProviderProfileId,
credentials: LoginCredentials,
): DomainResult<Unit> = operation {
val encrypted = cipher.encrypt(profileId, encode(credentials))
dataStore.edit { it[credentialKey(profileId)] = Base64.getEncoder().encodeToString(encrypted) }
}
override suspend fun loadCredentials(
profileId: ProviderProfileId,
): DomainResult<LoginCredentials?> {
return try {
val value = dataStore.data.first()[credentialKey(profileId)]
?: return DomainResult.Success(null)
val encrypted = Base64.getDecoder().decode(value)
DomainResult.Success(decode(cipher.decrypt(profileId, encrypted)))
} catch (error: Exception) {
if (error.isPermanentCredentialFailure()) {
dataStore.edit { it.remove(credentialKey(profileId)) }
DomainResult.Success(null)
} else {
DomainResult.Failure(
DomainError.Unknown(detail = error.message ?: error::class.java.simpleName),
)
}
}
}
override suspend fun clearCredentials(profileId: ProviderProfileId): DomainResult<Unit> = operation {
dataStore.edit { it.remove(credentialKey(profileId)) }
}
private fun encode(credentials: LoginCredentials): ByteArray = ByteArrayOutputStream().use { output ->
DataOutputStream(output).use { data ->
data.writeUTF(credentials.username.value)
data.writeUTF(credentials.password.value)
}
output.toByteArray()
}
private fun decode(value: ByteArray): LoginCredentials = DataInputStream(
ByteArrayInputStream(value),
).use { data ->
val username = data.readUTF()
val password = data.readUTF()
require(data.available() == 0) { "Unexpected credential data" }
LoginCredentials(Username(username), Password(password))
}
private suspend fun operation(block: suspend () -> Unit): DomainResult<Unit> = try {
block()
DomainResult.Success(Unit)
} catch (error: Exception) {
DomainResult.Failure(
DomainError.Unknown(detail = error.message ?: error::class.java.simpleName),
)
}
companion object {
private fun credentialKey(profileId: ProviderProfileId) =
stringPreferencesKey("profile_${profileId.value}_credential")
}
}
private fun Throwable.isPermanentCredentialFailure(): Boolean = when (this) {
is AEADBadTagException,
is InvalidKeyException,
is KeyStoreException,
is UnrecoverableKeyException,
is IllegalArgumentException,
-> true
else -> cause?.isPermanentCredentialFailure() == true
}
@@ -0,0 +1,38 @@
package com.flagship.abox.manager.session.gateway
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.LocalSessionGateway
import com.flagship.abox.manager.domain.LoginCredentials
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.Session
import com.flagship.abox.manager.domain.SessionValidity
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
class LocalSessionGatewayImpl : LocalSessionGateway {
private val sessions = ConcurrentHashMap<ProviderProfileId, MutableStateFlow<Session?>>()
override fun observeSession(profileId: ProviderProfileId): Flow<Session?> = flow(profileId)
override suspend fun login(
profileId: ProviderProfileId,
credentials: LoginCredentials,
): DomainResult<Session> = DomainResult.Success(
Session(profileId, credentials.username).also { flow(profileId).value = it },
)
override suspend fun validateSession(
profileId: ProviderProfileId,
): DomainResult<SessionValidity> = DomainResult.Success(
if (flow(profileId).value == null) SessionValidity.INVALID else SessionValidity.VALID,
)
override suspend fun logout(profileId: ProviderProfileId): DomainResult<Unit> {
flow(profileId).value = null
return DomainResult.Success(Unit)
}
private fun flow(profileId: ProviderProfileId): MutableStateFlow<Session?> =
sessions.getOrPut(profileId) { MutableStateFlow(null) }
}
@@ -0,0 +1,31 @@
package com.flagship.abox.manager.session.gateway
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.ProviderProfileId
import com.flagship.abox.manager.domain.Session
import com.flagship.abox.manager.domain.SessionGateway
import com.flagship.abox.manager.domain.SessionValidity
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
class UnavailableRemoteSessionGateway : SessionGateway {
override fun observeSession(profileId: ProviderProfileId): Flow<Session?> = flowOf(null)
override suspend fun login(
profileId: ProviderProfileId,
credentials: LoginCredentials,
): DomainResult<Session> = unavailable()
override suspend fun validateSession(
profileId: ProviderProfileId,
): DomainResult<SessionValidity> = unavailable()
override suspend fun logout(profileId: ProviderProfileId): DomainResult<Unit> =
DomainResult.Success(Unit)
private fun <T> unavailable(): DomainResult<T> = DomainResult.Failure(
DomainError.InvalidRequest(detail = "Remote providers are not enabled in this build"),
)
}
@@ -0,0 +1,120 @@
package com.flagship.abox.manager.session.profile
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.LoginPreferences
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.Username
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
class ProviderProfileStoreImpl(
private val dataStore: DataStore<Preferences>,
) : ProviderProfileStore {
override fun observeProfiles(): Flow<List<ProviderProfile>> = dataStore.data.map(::profilesFrom)
override fun observeActiveProfile(): Flow<ProviderProfile?> = dataStore.data.map(::activeProfileFrom)
override fun observeLoginPreferences(profileId: ProviderProfileId): Flow<LoginPreferences> =
dataStore.data.map { preferencesFrom(it, profileId) }
override suspend fun profiles(): List<ProviderProfile> = profilesFrom(dataStore.data.first())
override suspend fun activeProfile(): ProviderProfile? = activeProfileFrom(dataStore.data.first())
override suspend fun loginPreferences(profileId: ProviderProfileId): LoginPreferences =
preferencesFrom(dataStore.data.first(), profileId)
override suspend fun saveProfile(profile: ProviderProfile): DomainResult<Unit> = operation {
dataStore.edit { preferences ->
preferences[PROFILE_IDS] = preferences[PROFILE_IDS].orEmpty() + profile.id.value
preferences[profileTypeKey(profile.id)] = profile.type.name
preferences[profileNameKey(profile.id)] = profile.displayName
}
}
override suspend fun setActiveProfile(profileId: ProviderProfileId?): DomainResult<Unit> = operation {
dataStore.edit { preferences ->
if (profileId == null) preferences.remove(ACTIVE_PROFILE_ID)
else preferences[ACTIVE_PROFILE_ID] = profileId.value
}
}
override suspend fun saveLoginPreferences(
preferences: LoginPreferences,
): DomainResult<Unit> = operation {
dataStore.edit { values ->
val profileId = preferences.profileId
values[rememberUsernameKey(profileId)] = preferences.rememberUsername
values[autoLoginKey(profileId)] = preferences.autoLoginEnabled
val username = preferences.rememberedUsername?.value
if (username == null) values.remove(usernameKey(profileId))
else values[usernameKey(profileId)] = username
}
}
private fun profilesFrom(preferences: Preferences): List<ProviderProfile> =
preferences[PROFILE_IDS].orEmpty().mapNotNull { idValue ->
val id = ProviderProfileId(idValue)
val type = preferences[profileTypeKey(id)]
?.let { runCatching { ProviderType.valueOf(it) }.getOrNull() }
?: return@mapNotNull null
val displayName = preferences[profileNameKey(id)] ?: return@mapNotNull null
ProviderProfile(id, type, displayName)
}.sortedBy { it.id.value }
private fun activeProfileFrom(preferences: Preferences): ProviderProfile? {
val activeId = preferences[ACTIVE_PROFILE_ID] ?: return null
return profilesFrom(preferences).firstOrNull { it.id.value == activeId }
}
private fun preferencesFrom(
preferences: Preferences,
profileId: ProviderProfileId,
): LoginPreferences = LoginPreferences(
profileId = profileId,
rememberedUsername = preferences[usernameKey(profileId)]?.let(::Username),
rememberUsername = preferences[rememberUsernameKey(profileId)] ?: false,
autoLoginEnabled = preferences[autoLoginKey(profileId)] ?: false,
)
private suspend fun operation(block: suspend () -> Unit): DomainResult<Unit> = try {
block()
DomainResult.Success(Unit)
} catch (error: Exception) {
DomainResult.Failure(
com.flagship.abox.manager.domain.DomainError.Unknown(
detail = error.message ?: error::class.java.simpleName,
),
)
}
companion object {
private val PROFILE_IDS = stringSetPreferencesKey("profile_ids")
private val ACTIVE_PROFILE_ID = stringPreferencesKey("active_profile_id")
private fun profileTypeKey(id: ProviderProfileId) =
stringPreferencesKey("profile_${id.value}_type")
private fun profileNameKey(id: ProviderProfileId) =
stringPreferencesKey("profile_${id.value}_name")
private fun usernameKey(id: ProviderProfileId) =
stringPreferencesKey("profile_${id.value}_username")
private fun rememberUsernameKey(id: ProviderProfileId) =
booleanPreferencesKey("profile_${id.value}_remember_username")
private fun autoLoginKey(id: ProviderProfileId) =
booleanPreferencesKey("profile_${id.value}_auto_login")
}
}
@@ -0,0 +1,12 @@
package com.flagship.abox.manager.session.profile
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
private const val SESSION_DATA_STORE_NAME = "provider_session"
val Context.sessionDataStore: DataStore<Preferences> by preferencesDataStore(
name = SESSION_DATA_STORE_NAME,
)
@@ -0,0 +1,47 @@
package com.flagship.abox.manager.session
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.LoginCredentials
import com.flagship.abox.manager.domain.Password
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.Username
import com.flagship.abox.manager.session.credential.CredentialCipher
import com.flagship.abox.manager.session.credential.KeystoreCredentialStore
import java.io.File
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class CredentialStoreTest {
@Test
fun storesClearsAndIsolatesCredentials() = runTest {
val file = File.createTempFile("credentials", ".preferences_pb").apply { delete() }
val dataStore = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
val store = KeystoreCredentialStore(dataStore, ReversingCipher())
val first = ProviderProfileId("alpha")
val second = ProviderProfileId("bravo")
val firstCredentials = LoginCredentials(Username("one"), Password("secret-one"))
val secondCredentials = LoginCredentials(Username("two"), Password("secret-two"))
store.saveCredentials(first, firstCredentials)
store.saveCredentials(second, secondCredentials)
store.clearCredentials(first)
assertNull((store.loadCredentials(first) as DomainResult.Success).value)
assertEquals(secondCredentials, (store.loadCredentials(second) as DomainResult.Success).value)
}
private class ReversingCipher : CredentialCipher {
override fun encrypt(profileId: ProviderProfileId, plaintext: ByteArray): ByteArray =
(profileId.value.toByteArray() + byteArrayOf(0) + plaintext).reversedArray()
override fun decrypt(profileId: ProviderProfileId, encrypted: ByteArray): ByteArray {
val decoded = encrypted.reversedArray()
val delimiter = decoded.indexOf(0)
require(decoded.copyOfRange(0, delimiter).decodeToString() == profileId.value)
return decoded.copyOfRange(delimiter + 1, decoded.size)
}
}
}
@@ -0,0 +1,34 @@
package com.flagship.abox.manager.session
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.LoginPreferences
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.Username
import com.flagship.abox.manager.session.profile.ProviderProfileStoreImpl
import java.io.File
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
class ProviderProfileStoreTest {
@Test
fun persistsActiveProfileAndLoginPreferences() = runTest {
val file = File.createTempFile("profiles", ".preferences_pb").apply { delete() }
val dataStore = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
val store = ProviderProfileStoreImpl(dataStore)
val id = ProviderProfileId("abox")
val profile = ProviderProfile(id, ProviderType.ABOX, "ABox")
val preferences = LoginPreferences(id, Username("demo"), true, true)
assertEquals(DomainResult.Success(Unit), store.saveProfile(profile))
store.setActiveProfile(id)
store.saveLoginPreferences(preferences)
assertEquals(profile, store.observeActiveProfile().first())
assertEquals(preferences, store.observeLoginPreferences(id).first())
}
}
+19 -3
View File
@@ -108,11 +108,27 @@ Fake 只用于开发和测试,不能作为真实 ABox 或真实硬件验收证
Bridge 本轮尚未加入 `android/app` 依赖,App debug 仍绑定 Fake。提供方选择、安全凭据和真实 Bridge 绑定由后续 `feat/provider-login` 完成;在获批的传输控制方案明确前,不得在 App 中启用真实 Bridge。协议证据与限制记录在 `docs/protocol/absdk-0630.md`。 Bridge 本轮尚未加入 `android/app` 依赖,App debug 仍绑定 Fake。提供方选择、安全凭据和真实 Bridge 绑定由后续 `feat/provider-login` 完成;在获批的传输控制方案明确前,不得在 App 中启用真实 Bridge。协议证据与限制记录在 `docs/protocol/absdk-0630.md`。
## 9. 后续边界 ## 9. Provider Session
`:android:core:session` 负责提供方登录的持久化与 Android 安全边界:
- `ProviderProfileStore` 使用 DataStore 保存 profile、当前活动 profile、记住用户名和自动登录开关;
- `CredentialStore` 使用 Android Keystore 中的 AES-256-GCM 密钥加密自动登录凭据,DataStore 只保存带随机 IV 的密文;
- `ProviderProfileId` 作为 GCM AAD,防止不同 profile 的密文互换;
- `LocalSessionGateway` 只维护本地内存 Session,不调用 HTTP 或 ABSDK;
- `UnavailableRemoteSessionGateway` 在远端入口未获批准时明确拒绝登录。
纯 Kotlin `SessionCoordinator` 统一处理启动恢复、提供方选择、登录偏好、自动登录、Token 失效重登、退出和切换提供方。远端退出清除密码和 Session,但保留 profile 与 remembered username;本地体验退出后回到提供方选择。
App 按构建变体声明远端可用性:debug 的 ABox 入口明确是开发模拟,release 的 ABox 因传输安全待确认而禁用;兼容后端在协议明确前始终禁用;本地体验始终可用。App 没有依赖或创建 `AbsdkBridge`,也没有对 `@CleartextAbsdkTransport` opt-in。
登录完成后进入 `authenticated` 协作路由。当前仅有占位页面,设备列表与主导航由 alZerNest 的设备导航模块接管。
## 10. 后续边界
后续 KremeCN 工作保持为独立短期分支: 后续 KremeCN 工作保持为独立短期分支:
- `feat/provider-login`:提供方入口、安全凭据、自动登录、重新登录和真实 Bridge 绑定; - 在获批传输控制明确后,单独评审真实 ABox Bridge 的 App 绑定;
- `feat/compatible-server`:在协议与连接方式确认后建立服务端、CLI 和 Docker 基础。 - `feat/compatible-server`:在协议与连接方式确认后建立服务端、CLI 和 Docker 基础。
Room、DataStore 和 Android Keystore 的具体 schema/key 只在对应数据所有权明确后创建。历史示例中的 AsyncTask、明文密码存储、全局明文网络和散落 SDK 调用不得进入正式架构。 Room 与设备业务 DataStore 的具体 schema/key 只在对应数据所有权明确后创建。历史示例中的 AsyncTask、明文密码存储、全局明文网络和散落 SDK 调用不得进入正式架构。
@@ -15,6 +15,41 @@ interface SessionGateway {
suspend fun logout(profileId: ProviderProfileId): DomainResult<Unit> 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 { interface DeviceCatalog {
fun observeDevices( fun observeDevices(
profileId: ProviderProfileId, profileId: ProviderProfileId,
@@ -53,6 +53,13 @@ data class LoginCredentials(
val password: Password, val password: Password,
) )
data class LoginPreferences(
val profileId: ProviderProfileId,
val rememberedUsername: Username? = null,
val rememberUsername: Boolean = false,
val autoLoginEnabled: Boolean = false,
)
data class Session( data class Session(
val profileId: ProviderProfileId, val profileId: ProviderProfileId,
val username: Username, 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))
}
+3
View File
@@ -17,6 +17,8 @@ espresso = "3.6.1"
[libraries] [libraries]
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.2.0" } androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.2.0" }
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
@@ -35,6 +37,7 @@ room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
junit = { module = "junit:junit", version.ref = "junit" } junit = { module = "junit:junit", version.ref = "junit" }
androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidxJunit" } androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidxJunit" }
androidx-test-runner = { module = "androidx.test:runner", version = "1.6.1" }
espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" } espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" }
[plugins] [plugins]
+1
View File
@@ -26,4 +26,5 @@ include(":domain")
include(":absdk-bridge") include(":absdk-bridge")
include(":testing:fakes") include(":testing:fakes")
include(":android:core:designsystem") include(":android:core:designsystem")
include(":android:core:session")
include(":android:app") include(":android:app")
@@ -0,0 +1,28 @@
package com.flagship.abox.manager.testing.fakes
import com.flagship.abox.manager.domain.CredentialStore
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.LoginCredentials
import com.flagship.abox.manager.domain.ProviderProfileId
import java.util.concurrent.ConcurrentHashMap
class FakeCredentialStore : CredentialStore {
private val values = ConcurrentHashMap<ProviderProfileId, LoginCredentials>()
override suspend fun saveCredentials(
profileId: ProviderProfileId,
credentials: LoginCredentials,
): DomainResult<Unit> {
values[profileId] = credentials
return DomainResult.Success(Unit)
}
override suspend fun loadCredentials(
profileId: ProviderProfileId,
): DomainResult<LoginCredentials?> = DomainResult.Success(values[profileId])
override suspend fun clearCredentials(profileId: ProviderProfileId): DomainResult<Unit> {
values.remove(profileId)
return DomainResult.Success(Unit)
}
}
@@ -0,0 +1,38 @@
package com.flagship.abox.manager.testing.fakes
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.LocalSessionGateway
import com.flagship.abox.manager.domain.LoginCredentials
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.Session
import com.flagship.abox.manager.domain.SessionValidity
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
class FakeLocalSessionGateway : LocalSessionGateway {
private val sessions = ConcurrentHashMap<ProviderProfileId, MutableStateFlow<Session?>>()
override fun observeSession(profileId: ProviderProfileId): Flow<Session?> = flow(profileId)
override suspend fun login(
profileId: ProviderProfileId,
credentials: LoginCredentials,
): DomainResult<Session> = DomainResult.Success(
Session(profileId, credentials.username).also { flow(profileId).value = it },
)
override suspend fun validateSession(
profileId: ProviderProfileId,
): DomainResult<SessionValidity> = DomainResult.Success(
if (flow(profileId).value == null) SessionValidity.INVALID else SessionValidity.VALID,
)
override suspend fun logout(profileId: ProviderProfileId): DomainResult<Unit> {
flow(profileId).value = null
return DomainResult.Success(Unit)
}
private fun flow(profileId: ProviderProfileId): MutableStateFlow<Session?> =
sessions.getOrPut(profileId) { MutableStateFlow(null) }
}
@@ -0,0 +1,57 @@
package com.flagship.abox.manager.testing.fakes
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.LoginPreferences
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderProfileStore
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
class FakeProviderProfileStore : ProviderProfileStore {
private val profileValues = ConcurrentHashMap<ProviderProfileId, ProviderProfile>()
private val preferenceValues = ConcurrentHashMap<ProviderProfileId, LoginPreferences>()
private val profilesFlow = MutableStateFlow<List<ProviderProfile>>(emptyList())
private val activeProfileFlow = MutableStateFlow<ProviderProfile?>(null)
private val preferenceFlows = ConcurrentHashMap<ProviderProfileId, MutableStateFlow<LoginPreferences>>()
override fun observeProfiles(): Flow<List<ProviderProfile>> = profilesFlow
override fun observeActiveProfile(): Flow<ProviderProfile?> = activeProfileFlow
override fun observeLoginPreferences(profileId: ProviderProfileId): Flow<LoginPreferences> =
preferenceFlow(profileId)
override suspend fun profiles(): List<ProviderProfile> = profilesFlow.value
override suspend fun activeProfile(): ProviderProfile? = activeProfileFlow.value
override suspend fun loginPreferences(profileId: ProviderProfileId): LoginPreferences =
preferenceFlow(profileId).value
override suspend fun saveProfile(profile: ProviderProfile): DomainResult<Unit> {
profileValues[profile.id] = profile
profilesFlow.value = profileValues.values.sortedBy { it.id.value }
if (activeProfileFlow.value?.id == profile.id) activeProfileFlow.value = profile
return DomainResult.Success(Unit)
}
override suspend fun setActiveProfile(profileId: ProviderProfileId?): DomainResult<Unit> {
activeProfileFlow.value = profileId?.let(profileValues::get)
return DomainResult.Success(Unit)
}
override suspend fun saveLoginPreferences(preferences: LoginPreferences): DomainResult<Unit> {
preferenceValues[preferences.profileId] = preferences
preferenceFlow(preferences.profileId).value = preferences
return DomainResult.Success(Unit)
}
private fun preferenceFlow(profileId: ProviderProfileId): MutableStateFlow<LoginPreferences> =
preferenceFlows.getOrPut(profileId) {
MutableStateFlow(
preferenceValues[profileId] ?: LoginPreferences(profileId),
)
}
}
@@ -0,0 +1,29 @@
package com.flagship.abox.manager.testing.fakes
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.LoginCredentials
import com.flagship.abox.manager.domain.Password
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.Username
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class FakeCredentialStoreTest {
@Test
fun credentialsAreIsolatedByProfileAndClearIndependently() = runTest {
val store = FakeCredentialStore()
val first = ProviderProfileId("first")
val second = ProviderProfileId("second")
val firstCredentials = LoginCredentials(Username("one"), Password("secret-one"))
val secondCredentials = LoginCredentials(Username("two"), Password("secret-two"))
store.saveCredentials(first, firstCredentials)
store.saveCredentials(second, secondCredentials)
store.clearCredentials(first)
assertNull((store.loadCredentials(first) as DomainResult.Success).value)
assertEquals(secondCredentials, (store.loadCredentials(second) as DomainResult.Success).value)
}
}
@@ -0,0 +1,30 @@
package com.flagship.abox.manager.testing.fakes
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.LoginPreferences
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.Username
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
class FakeProviderProfileStoreTest {
@Test
fun storesProfilesActiveSelectionAndPreferences() = runTest {
val store = FakeProviderProfileStore()
val id = ProviderProfileId("local")
val profile = ProviderProfile(id, ProviderType.LOCAL_EXPERIENCE, "Local")
val preferences = LoginPreferences(id, Username("demo"), true, false)
assertEquals(DomainResult.Success(Unit), store.saveProfile(profile))
store.setActiveProfile(id)
store.saveLoginPreferences(preferences)
assertEquals(listOf(profile), store.observeProfiles().first())
assertEquals(profile, store.observeActiveProfile().first())
assertEquals(preferences, store.observeLoginPreferences(id).first())
}
}
@@ -0,0 +1,129 @@
package com.flagship.abox.manager.testing.fakes
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.LoginCredentials
import com.flagship.abox.manager.domain.LoginPreferences
import com.flagship.abox.manager.domain.Password
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.SessionCoordinator
import com.flagship.abox.manager.domain.Username
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class SessionCoordinatorTest {
private val remoteProfile = ProviderProfile(
ProviderProfileId("abox"),
ProviderType.ABOX,
"ABox",
)
private val localProfile = ProviderProfile(
ProviderProfileId("local"),
ProviderType.LOCAL_EXPERIENCE,
"Local",
)
private val credentials = LoginCredentials(Username("demo"), Password("secret"))
@Test
fun localProviderCreatesSessionWithoutRemoteLogin() = runTest {
val fixture = Fixture()
val result = fixture.coordinator.selectProvider(localProfile)
assertTrue(result is DomainResult.Success)
assertEquals(localProfile.id, fixture.coordinator.observeActiveSession().first()?.profileId)
assertNull(fixture.remote.observeSession(localProfile.id).first())
}
@Test
fun loginPersistsIndependentUsernameAndAutoLoginChoices() = runTest {
val fixture = Fixture()
fixture.coordinator.selectProvider(remoteProfile)
fixture.coordinator.login(
remoteProfile.id,
credentials,
rememberUsername = false,
autoLogin = true,
)
val preferences = fixture.profiles.loginPreferences(remoteProfile.id)
assertNull(preferences.rememberedUsername)
assertTrue(preferences.autoLoginEnabled)
assertEquals(
credentials,
(fixture.credentials.loadCredentials(remoteProfile.id) as DomainResult.Success).value,
)
}
@Test
fun bootstrapUsesSavedAutoLoginCredentials() = runTest {
val fixture = Fixture()
fixture.profiles.saveProfile(remoteProfile)
fixture.profiles.setActiveProfile(remoteProfile.id)
fixture.profiles.saveLoginPreferences(
LoginPreferences(remoteProfile.id, autoLoginEnabled = true),
)
fixture.credentials.saveCredentials(remoteProfile.id, credentials)
val result = fixture.coordinator.bootstrap()
assertEquals(remoteProfile.id, (result as DomainResult.Success).value?.profileId)
}
@Test
fun remoteLogoutClearsPasswordButKeepsProfileAndUsername() = runTest {
val fixture = Fixture()
fixture.coordinator.selectProvider(remoteProfile)
fixture.coordinator.login(
remoteProfile.id,
credentials,
rememberUsername = true,
autoLogin = true,
)
fixture.coordinator.logout()
assertEquals(remoteProfile, fixture.profiles.activeProfile())
val preferences = fixture.profiles.loginPreferences(remoteProfile.id)
assertEquals(credentials.username, preferences.rememberedUsername)
assertTrue(!preferences.autoLoginEnabled)
assertNull(
(fixture.credentials.loadCredentials(remoteProfile.id) as DomainResult.Success).value,
)
}
@Test
fun authenticationFailureDuringRecoveryClearsSavedCredential() = runTest {
val fixture = Fixture()
fixture.profiles.saveProfile(remoteProfile)
fixture.profiles.setActiveProfile(remoteProfile.id)
fixture.profiles.saveLoginPreferences(
LoginPreferences(remoteProfile.id, autoLoginEnabled = true),
)
fixture.credentials.saveCredentials(remoteProfile.id, credentials)
fixture.remote.failLogin(remoteProfile.id, DomainError.Authentication())
val result = fixture.coordinator.recoverSession(remoteProfile.id)
assertTrue(result is DomainResult.Failure)
assertNull(
(fixture.credentials.loadCredentials(remoteProfile.id) as DomainResult.Success).value,
)
assertTrue(!fixture.profiles.loginPreferences(remoteProfile.id).autoLoginEnabled)
}
private class Fixture {
val profiles = FakeProviderProfileStore()
val credentials = FakeCredentialStore()
val remote = FakeSessionGateway()
private val local = FakeLocalSessionGateway()
val coordinator = SessionCoordinator(profiles, credentials, remote, local)
}
}