feat: add provider login flow
This commit is contained in:
@@ -54,12 +54,16 @@ kapt {
|
||||
dependencies {
|
||||
implementation(project(":domain"))
|
||||
implementation(project(":android:core:designsystem"))
|
||||
implementation(project(":android:core:session"))
|
||||
debugImplementation(project(":testing:fakes"))
|
||||
|
||||
implementation(platform(libs.compose.bom))
|
||||
implementation(libs.androidx.activity.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.datastore.preferences)
|
||||
implementation(libs.androidx.hilt.navigation.compose)
|
||||
implementation(libs.compose.ui)
|
||||
implementation(libs.compose.ui.tooling.preview)
|
||||
@@ -68,8 +72,10 @@ dependencies {
|
||||
kapt(libs.hilt.compiler)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
androidTestImplementation(platform(libs.compose.bom))
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.test.runner)
|
||||
androidTestImplementation(libs.espresso.core)
|
||||
androidTestImplementation(libs.compose.ui.test.junit4)
|
||||
debugImplementation(libs.compose.ui.tooling)
|
||||
|
||||
+76
@@ -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()
|
||||
}
|
||||
}
|
||||
+52
@@ -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.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
|
||||
@@ -21,7 +20,7 @@ import javax.inject.Singleton
|
||||
object FoundationModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSessionGateway(): SessionGateway = FakeSessionGateway()
|
||||
fun provideFakeSessionGateway(): FakeSessionGateway = FakeSessionGateway()
|
||||
|
||||
@Provides
|
||||
@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
|
||||
|
||||
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.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.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(),
|
||||
) {
|
||||
val actionState = sessionActionsViewModel.uiState.collectAsStateWithLifecycle().value
|
||||
ABoxTheme {
|
||||
ABoxSurface(modifier = Modifier.fillMaxSize()) {
|
||||
val navController = rememberNavController()
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = FOUNDATION_ROUTE,
|
||||
when (
|
||||
val state = appSessionViewModel.uiState.collectAsStateWithLifecycle().value
|
||||
) {
|
||||
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),
|
||||
)
|
||||
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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+76
@@ -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"
|
||||
}
|
||||
+28
@@ -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,
|
||||
}
|
||||
+225
@@ -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
|
||||
}
|
||||
+69
@@ -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,
|
||||
)
|
||||
+64
@@ -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
|
||||
}
|
||||
+44
@@ -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_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>
|
||||
|
||||
@@ -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 through the ABSDK-compatible ABox service.</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>
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+27
@@ -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 />
|
||||
+68
@@ -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
|
||||
}
|
||||
}
|
||||
+9
@@ -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
|
||||
}
|
||||
+102
@@ -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
|
||||
}
|
||||
+38
@@ -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) }
|
||||
}
|
||||
+31
@@ -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"),
|
||||
)
|
||||
}
|
||||
+120
@@ -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")
|
||||
}
|
||||
}
|
||||
+12
@@ -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,
|
||||
)
|
||||
+47
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -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
@@ -108,11 +108,27 @@ Fake 只用于开发和测试,不能作为真实 ABox 或真实硬件验收证
|
||||
|
||||
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 工作保持为独立短期分支:
|
||||
|
||||
- `feat/provider-login`:提供方入口、安全凭据、自动登录、重新登录和真实 Bridge 绑定;
|
||||
- 在获批传输控制明确后,单独评审真实 ABox Bridge 的 App 绑定;
|
||||
- `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>
|
||||
}
|
||||
|
||||
interface ProviderProfileStore {
|
||||
fun observeProfiles(): Flow<List<ProviderProfile>>
|
||||
|
||||
fun observeActiveProfile(): Flow<ProviderProfile?>
|
||||
|
||||
fun observeLoginPreferences(profileId: ProviderProfileId): Flow<LoginPreferences>
|
||||
|
||||
suspend fun profiles(): List<ProviderProfile>
|
||||
|
||||
suspend fun activeProfile(): ProviderProfile?
|
||||
|
||||
suspend fun loginPreferences(profileId: ProviderProfileId): LoginPreferences
|
||||
|
||||
suspend fun saveProfile(profile: ProviderProfile): DomainResult<Unit>
|
||||
|
||||
suspend fun setActiveProfile(profileId: ProviderProfileId?): DomainResult<Unit>
|
||||
|
||||
suspend fun saveLoginPreferences(preferences: LoginPreferences): DomainResult<Unit>
|
||||
}
|
||||
|
||||
interface CredentialStore {
|
||||
suspend fun saveCredentials(
|
||||
profileId: ProviderProfileId,
|
||||
credentials: LoginCredentials,
|
||||
): DomainResult<Unit>
|
||||
|
||||
suspend fun loadCredentials(
|
||||
profileId: ProviderProfileId,
|
||||
): DomainResult<LoginCredentials?>
|
||||
|
||||
suspend fun clearCredentials(profileId: ProviderProfileId): DomainResult<Unit>
|
||||
}
|
||||
|
||||
interface LocalSessionGateway : SessionGateway
|
||||
|
||||
interface DeviceCatalog {
|
||||
fun observeDevices(
|
||||
profileId: ProviderProfileId,
|
||||
|
||||
@@ -53,6 +53,13 @@ data class LoginCredentials(
|
||||
val password: Password,
|
||||
)
|
||||
|
||||
data class LoginPreferences(
|
||||
val profileId: ProviderProfileId,
|
||||
val rememberedUsername: Username? = null,
|
||||
val rememberUsername: Boolean = false,
|
||||
val autoLoginEnabled: Boolean = false,
|
||||
)
|
||||
|
||||
data class Session(
|
||||
val profileId: ProviderProfileId,
|
||||
val username: Username,
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
package com.flagship.abox.manager.domain
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SessionCoordinator(
|
||||
private val profileStore: ProviderProfileStore,
|
||||
private val credentialStore: CredentialStore,
|
||||
private val remoteGateway: SessionGateway,
|
||||
private val localGateway: LocalSessionGateway,
|
||||
) {
|
||||
fun observeActiveSession(): Flow<Session?> = profileStore.observeActiveProfile()
|
||||
.flatMapLatest { profile ->
|
||||
profile?.gateway()?.observeSession(profile.id) ?: flowOf(null)
|
||||
}
|
||||
|
||||
suspend fun bootstrap(): DomainResult<Session?> = storeOperation {
|
||||
val profile = profileStore.activeProfile() ?: return@storeOperation DomainResult.Success(null)
|
||||
if (profile.type == ProviderType.LOCAL_EXPERIENCE) {
|
||||
return@storeOperation localGateway.login(profile.id, localCredentials())
|
||||
.mapSuccess { it }
|
||||
}
|
||||
val preferences = profileStore.loginPreferences(profile.id)
|
||||
if (!preferences.autoLoginEnabled) return@storeOperation DomainResult.Success(null)
|
||||
when (val credentials = credentialStore.loadCredentials(profile.id)) {
|
||||
is DomainResult.Failure -> credentials
|
||||
is DomainResult.Success -> {
|
||||
val savedCredentials = credentials.value ?: return@storeOperation DomainResult.Success(null)
|
||||
if (profileStore.activeProfile()?.id != profile.id) {
|
||||
return@storeOperation DomainResult.Success(null)
|
||||
}
|
||||
profile.gateway().login(profile.id, savedCredentials).mapSuccess { it }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun selectProvider(profile: ProviderProfile): DomainResult<Session?> = storeOperation {
|
||||
val current = profileStore.activeProfile()
|
||||
if (current?.id != profile.id && current != null) {
|
||||
when (val switched = switchProvider()) {
|
||||
is DomainResult.Failure -> return@storeOperation switched
|
||||
is DomainResult.Success -> Unit
|
||||
}
|
||||
}
|
||||
when (val saved = profileStore.saveProfile(profile)) {
|
||||
is DomainResult.Failure -> return@storeOperation saved
|
||||
is DomainResult.Success -> Unit
|
||||
}
|
||||
when (val activated = profileStore.setActiveProfile(profile.id)) {
|
||||
is DomainResult.Failure -> return@storeOperation activated
|
||||
is DomainResult.Success -> Unit
|
||||
}
|
||||
if (profile.type == ProviderType.LOCAL_EXPERIENCE) {
|
||||
localGateway.login(profile.id, localCredentials()).mapSuccess { it }
|
||||
} else {
|
||||
DomainResult.Success(null)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun login(
|
||||
profileId: ProviderProfileId,
|
||||
credentials: LoginCredentials,
|
||||
rememberUsername: Boolean,
|
||||
autoLogin: Boolean,
|
||||
): DomainResult<Session> = storeOperation {
|
||||
val profile = profileStore.activeProfile()
|
||||
?: return@storeOperation DomainResult.Failure(
|
||||
DomainError.InvalidRequest(detail = "No active provider profile"),
|
||||
)
|
||||
if (profile.id != profileId || profile.type == ProviderType.LOCAL_EXPERIENCE) {
|
||||
return@storeOperation DomainResult.Failure(
|
||||
DomainError.InvalidRequest(detail = "The active provider does not require this login"),
|
||||
)
|
||||
}
|
||||
when (val result = remoteGateway.login(profileId, credentials)) {
|
||||
is DomainResult.Failure -> result
|
||||
is DomainResult.Success -> {
|
||||
val previousPreferences = profileStore.loginPreferences(profileId)
|
||||
val previousCredentials = credentialStore.loadCredentials(profileId)
|
||||
if (previousCredentials is DomainResult.Failure) {
|
||||
remoteGateway.logout(profileId)
|
||||
return@storeOperation previousCredentials
|
||||
}
|
||||
val preferences = LoginPreferences(
|
||||
profileId = profileId,
|
||||
rememberedUsername = credentials.username.takeIf { rememberUsername },
|
||||
rememberUsername = rememberUsername,
|
||||
autoLoginEnabled = autoLogin,
|
||||
)
|
||||
val credentialResult = if (autoLogin) {
|
||||
credentialStore.saveCredentials(profileId, credentials)
|
||||
} else {
|
||||
credentialStore.clearCredentials(profileId)
|
||||
}
|
||||
if (credentialResult is DomainResult.Failure) {
|
||||
remoteGateway.logout(profileId)
|
||||
return@storeOperation credentialResult
|
||||
}
|
||||
when (val preferencesResult = profileStore.saveLoginPreferences(preferences)) {
|
||||
is DomainResult.Success -> result
|
||||
is DomainResult.Failure -> {
|
||||
when (previousCredentials) {
|
||||
is DomainResult.Success -> previousCredentials.value?.let {
|
||||
credentialStore.saveCredentials(profileId, it)
|
||||
} ?: credentialStore.clearCredentials(profileId)
|
||||
is DomainResult.Failure -> Unit
|
||||
}
|
||||
profileStore.saveLoginPreferences(previousPreferences)
|
||||
remoteGateway.logout(profileId)
|
||||
preferencesResult
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun recoverSession(profileId: ProviderProfileId): DomainResult<Session?> = storeOperation {
|
||||
val profile = profileStore.activeProfile()
|
||||
if (profile?.id != profileId || profile.type == ProviderType.LOCAL_EXPERIENCE) {
|
||||
return@storeOperation DomainResult.Success(null)
|
||||
}
|
||||
val preferences = profileStore.loginPreferences(profileId)
|
||||
if (!preferences.autoLoginEnabled) return@storeOperation DomainResult.Success(null)
|
||||
when (val credentials = credentialStore.loadCredentials(profileId)) {
|
||||
is DomainResult.Failure -> credentials
|
||||
is DomainResult.Success -> {
|
||||
val savedCredentials = credentials.value ?: return@storeOperation DomainResult.Success(null)
|
||||
when (val result = remoteGateway.login(profileId, savedCredentials)) {
|
||||
is DomainResult.Success -> result.mapSuccess { it }
|
||||
is DomainResult.Failure -> {
|
||||
if (result.error is DomainError.Authentication ||
|
||||
result.error is DomainError.TokenInvalid
|
||||
) {
|
||||
credentialStore.clearCredentials(profileId)
|
||||
profileStore.saveLoginPreferences(
|
||||
preferences.copy(autoLoginEnabled = false),
|
||||
)
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun logout(): DomainResult<Unit> = storeOperation {
|
||||
val profile = profileStore.activeProfile() ?: return@storeOperation DomainResult.Success(Unit)
|
||||
when (val credentialResult = credentialStore.clearCredentials(profile.id)) {
|
||||
is DomainResult.Failure -> return@storeOperation credentialResult
|
||||
is DomainResult.Success -> Unit
|
||||
}
|
||||
val preferences = profileStore.loginPreferences(profile.id)
|
||||
when (
|
||||
val preferencesResult = profileStore.saveLoginPreferences(
|
||||
preferences.copy(autoLoginEnabled = false),
|
||||
)
|
||||
) {
|
||||
is DomainResult.Failure -> return@storeOperation preferencesResult
|
||||
is DomainResult.Success -> Unit
|
||||
}
|
||||
when (val logoutResult = profile.gateway().logout(profile.id)) {
|
||||
is DomainResult.Failure -> return@storeOperation logoutResult
|
||||
is DomainResult.Success -> Unit
|
||||
}
|
||||
if (profile.type == ProviderType.LOCAL_EXPERIENCE) {
|
||||
profileStore.setActiveProfile(null)
|
||||
} else {
|
||||
DomainResult.Success(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun switchProvider(): DomainResult<Unit> = storeOperation {
|
||||
when (val logoutResult = logout()) {
|
||||
is DomainResult.Failure -> logoutResult
|
||||
is DomainResult.Success -> profileStore.setActiveProfile(null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ProviderProfile.gateway(): SessionGateway =
|
||||
if (type == ProviderType.LOCAL_EXPERIENCE) localGateway else remoteGateway
|
||||
|
||||
private fun localCredentials(): LoginCredentials = LoginCredentials(
|
||||
username = Username("local"),
|
||||
password = Password(""),
|
||||
)
|
||||
|
||||
private suspend fun <T> storeOperation(block: suspend () -> DomainResult<T>): DomainResult<T> =
|
||||
try {
|
||||
block()
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (error: Exception) {
|
||||
DomainResult.Failure(
|
||||
DomainError.Unknown(detail = error.message ?: error::class.java.simpleName),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <T, R> DomainResult<T>.mapSuccess(transform: (T) -> R): DomainResult<R> = when (this) {
|
||||
is DomainResult.Failure -> this
|
||||
is DomainResult.Success -> DomainResult.Success(transform(value))
|
||||
}
|
||||
@@ -17,6 +17,8 @@ espresso = "3.6.1"
|
||||
[libraries]
|
||||
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-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-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.2.0" }
|
||||
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" }
|
||||
junit = { module = "junit:junit", version.ref = "junit" }
|
||||
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" }
|
||||
|
||||
[plugins]
|
||||
|
||||
@@ -26,4 +26,5 @@ include(":domain")
|
||||
include(":absdk-bridge")
|
||||
include(":testing:fakes")
|
||||
include(":android:core:designsystem")
|
||||
include(":android:core:session")
|
||||
include(":android:app")
|
||||
|
||||
+28
@@ -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)
|
||||
}
|
||||
}
|
||||
+38
@@ -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) }
|
||||
}
|
||||
+57
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
+29
@@ -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)
|
||||
}
|
||||
}
|
||||
+30
@@ -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())
|
||||
}
|
||||
}
|
||||
+129
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user