feat: add provider login flow #4
@@ -54,12 +54,16 @@ kapt {
|
|||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":domain"))
|
implementation(project(":domain"))
|
||||||
implementation(project(":android:core:designsystem"))
|
implementation(project(":android:core:designsystem"))
|
||||||
|
implementation(project(":android:core:session"))
|
||||||
debugImplementation(project(":testing:fakes"))
|
debugImplementation(project(":testing:fakes"))
|
||||||
|
|
||||||
implementation(platform(libs.compose.bom))
|
implementation(platform(libs.compose.bom))
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
implementation(libs.androidx.navigation.compose)
|
implementation(libs.androidx.navigation.compose)
|
||||||
|
implementation(libs.datastore.preferences)
|
||||||
implementation(libs.androidx.hilt.navigation.compose)
|
implementation(libs.androidx.hilt.navigation.compose)
|
||||||
implementation(libs.compose.ui)
|
implementation(libs.compose.ui)
|
||||||
implementation(libs.compose.ui.tooling.preview)
|
implementation(libs.compose.ui.tooling.preview)
|
||||||
@@ -68,8 +72,10 @@ dependencies {
|
|||||||
kapt(libs.hilt.compiler)
|
kapt(libs.hilt.compiler)
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
androidTestImplementation(platform(libs.compose.bom))
|
androidTestImplementation(platform(libs.compose.bom))
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
|
androidTestImplementation(libs.androidx.test.runner)
|
||||||
androidTestImplementation(libs.espresso.core)
|
androidTestImplementation(libs.espresso.core)
|
||||||
androidTestImplementation(libs.compose.ui.test.junit4)
|
androidTestImplementation(libs.compose.ui.test.junit4)
|
||||||
debugImplementation(libs.compose.ui.tooling)
|
debugImplementation(libs.compose.ui.tooling)
|
||||||
|
|||||||
+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.DeviceCatalog
|
||||||
import com.flagship.abox.manager.domain.InfraredGateway
|
import com.flagship.abox.manager.domain.InfraredGateway
|
||||||
import com.flagship.abox.manager.domain.SessionGateway
|
|
||||||
import com.flagship.abox.manager.domain.SocketGateway
|
import com.flagship.abox.manager.domain.SocketGateway
|
||||||
import com.flagship.abox.manager.domain.TemperatureHumidityGateway
|
import com.flagship.abox.manager.domain.TemperatureHumidityGateway
|
||||||
import com.flagship.abox.manager.testing.fakes.FakeDeviceCatalog
|
import com.flagship.abox.manager.testing.fakes.FakeDeviceCatalog
|
||||||
@@ -21,7 +20,7 @@ import javax.inject.Singleton
|
|||||||
object FoundationModule {
|
object FoundationModule {
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideSessionGateway(): SessionGateway = FakeSessionGateway()
|
fun provideFakeSessionGateway(): FakeSessionGateway = FakeSessionGateway()
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package com.flagship.abox.manager.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import com.flagship.abox.manager.domain.CredentialStore
|
||||||
|
import com.flagship.abox.manager.domain.LocalSessionGateway
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileStore
|
||||||
|
import com.flagship.abox.manager.domain.SessionCoordinator
|
||||||
|
import com.flagship.abox.manager.domain.SessionGateway
|
||||||
|
import com.flagship.abox.manager.session.credential.AndroidKeystoreCredentialCipher
|
||||||
|
import com.flagship.abox.manager.session.credential.CredentialCipher
|
||||||
|
import com.flagship.abox.manager.session.credential.KeystoreCredentialStore
|
||||||
|
import com.flagship.abox.manager.session.gateway.LocalSessionGatewayImpl
|
||||||
|
import com.flagship.abox.manager.session.profile.ProviderProfileStoreImpl
|
||||||
|
import com.flagship.abox.manager.session.profile.sessionDataStore
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object SessionModule {
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideSessionDataStore(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
): DataStore<Preferences> = context.sessionDataStore
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideProviderProfileStore(
|
||||||
|
dataStore: DataStore<Preferences>,
|
||||||
|
): ProviderProfileStore = ProviderProfileStoreImpl(dataStore)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideCredentialCipher(): CredentialCipher = AndroidKeystoreCredentialCipher()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideCredentialStore(
|
||||||
|
dataStore: DataStore<Preferences>,
|
||||||
|
cipher: CredentialCipher,
|
||||||
|
): CredentialStore = KeystoreCredentialStore(dataStore, cipher)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideLocalSessionGateway(): LocalSessionGateway = LocalSessionGatewayImpl()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideSessionCoordinator(
|
||||||
|
profileStore: ProviderProfileStore,
|
||||||
|
credentialStore: CredentialStore,
|
||||||
|
remoteGateway: SessionGateway,
|
||||||
|
localGateway: LocalSessionGateway,
|
||||||
|
): SessionCoordinator = SessionCoordinator(
|
||||||
|
profileStore,
|
||||||
|
credentialStore,
|
||||||
|
remoteGateway,
|
||||||
|
localGateway,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,94 +1,60 @@
|
|||||||
package com.flagship.abox.manager.ui
|
package com.flagship.abox.manager.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.compose.ui.unit.dp
|
import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator
|
||||||
import androidx.navigation.compose.NavHost
|
|
||||||
import androidx.navigation.compose.composable
|
|
||||||
import androidx.navigation.compose.rememberNavController
|
|
||||||
import com.flagship.abox.manager.R
|
|
||||||
import com.flagship.abox.manager.designsystem.ABoxButton
|
|
||||||
import com.flagship.abox.manager.designsystem.ABoxSurface
|
import com.flagship.abox.manager.designsystem.ABoxSurface
|
||||||
import com.flagship.abox.manager.designsystem.ABoxTheme
|
import com.flagship.abox.manager.designsystem.ABoxTheme
|
||||||
|
import com.flagship.abox.manager.ui.authenticated.AuthenticatedPlaceholderScreen
|
||||||
private const val FOUNDATION_ROUTE = "foundation"
|
import com.flagship.abox.manager.ui.login.LoginRoute
|
||||||
private const val PLACEHOLDER_ROUTE = "placeholder"
|
import com.flagship.abox.manager.ui.provider.ProviderSelectionRoute
|
||||||
|
import com.flagship.abox.manager.ui.session.AppSessionUiState
|
||||||
|
import com.flagship.abox.manager.ui.session.AppSessionViewModel
|
||||||
|
import com.flagship.abox.manager.ui.session.SessionActionsViewModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ABoxManagerApp() {
|
fun ABoxManagerApp(
|
||||||
|
appSessionViewModel: AppSessionViewModel = hiltViewModel(),
|
||||||
|
sessionActionsViewModel: SessionActionsViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val actionState = sessionActionsViewModel.uiState.collectAsStateWithLifecycle().value
|
||||||
ABoxTheme {
|
ABoxTheme {
|
||||||
ABoxSurface(modifier = Modifier.fillMaxSize()) {
|
ABoxSurface(modifier = Modifier.fillMaxSize()) {
|
||||||
val navController = rememberNavController()
|
when (
|
||||||
NavHost(
|
val state = appSessionViewModel.uiState.collectAsStateWithLifecycle().value
|
||||||
navController = navController,
|
|
||||||
startDestination = FOUNDATION_ROUTE,
|
|
||||||
) {
|
) {
|
||||||
composable(FOUNDATION_ROUTE) {
|
AppSessionUiState.Loading -> Box(
|
||||||
FoundationScreen(onContinue = { navController.navigate(PLACEHOLDER_ROUTE) })
|
|
||||||
}
|
|
||||||
composable(PLACEHOLDER_ROUTE) {
|
|
||||||
MessageScreen(
|
|
||||||
title = stringResource(R.string.placeholder_title),
|
|
||||||
description = stringResource(R.string.placeholder_description),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun FoundationScreen(onContinue: () -> Unit) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(32.dp),
|
.windowInsetsPadding(WindowInsets.safeDrawing),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
contentAlignment = Alignment.Center,
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
) {
|
) {
|
||||||
MessageScreen(
|
ABoxLoadingIndicator()
|
||||||
title = stringResource(R.string.foundation_title),
|
}
|
||||||
description = stringResource(R.string.foundation_description),
|
AppSessionUiState.ProviderSelection -> ProviderSelectionRoute()
|
||||||
|
is AppSessionUiState.Login -> LoginRoute(
|
||||||
|
profile = state.profile,
|
||||||
|
bootstrapError = state.bootstrapError,
|
||||||
|
actionError = actionState.errorMessage,
|
||||||
|
actionInProgress = actionState.inProgress,
|
||||||
|
onSwitchProvider = sessionActionsViewModel::switchProvider,
|
||||||
)
|
)
|
||||||
ABoxButton(
|
is AppSessionUiState.Authenticated -> AuthenticatedPlaceholderScreen(
|
||||||
onClick = onContinue,
|
profile = state.profile,
|
||||||
modifier = Modifier
|
actionInProgress = actionState.inProgress,
|
||||||
.fillMaxWidth()
|
errorMessage = actionState.errorMessage,
|
||||||
.padding(top = 24.dp),
|
onLogout = sessionActionsViewModel::logout,
|
||||||
) {
|
onSwitchProvider = sessionActionsViewModel::switchProvider,
|
||||||
Text(stringResource(R.string.continue_action))
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun MessageScreen(
|
|
||||||
title: String,
|
|
||||||
description: String,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(24.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = androidx.compose.material3.MaterialTheme.typography.headlineMedium,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = description,
|
|
||||||
style = androidx.compose.material3.MaterialTheme.typography.bodyLarge,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+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>
|
<resources>
|
||||||
<string name="app_name">ABox Manager</string>
|
<string name="app_name">ABox Manager</string>
|
||||||
<string name="foundation_title">工程基础已就绪</string>
|
<string name="provider_selection_title">选择提供方</string>
|
||||||
<string name="foundation_description">公共领域接口和本地开发提供方已经可用。</string>
|
<string name="provider_selection_description">选择当前配置的连接方式。不同提供方之间的会话、凭据和本地数据相互隔离。</string>
|
||||||
<string name="continue_action">继续</string>
|
<string name="provider_abox_name">ABox</string>
|
||||||
<string name="placeholder_title">功能模块可以开始开发</string>
|
<string name="provider_abox_description">通过兼容 ABSDK 的 ABox 服务连接设备。</string>
|
||||||
<string name="placeholder_description">设备、红外和情景页面将由对应模块负责人实现。</string>
|
<string name="provider_compatible_name">自有兼容后端</string>
|
||||||
|
<string name="provider_compatible_description">在协议准备完成后连接自行部署的 ABSDK 兼容服务。</string>
|
||||||
|
<string name="provider_local_name">本地体验</string>
|
||||||
|
<string name="provider_local_description">无需网络,通过本地模拟设备体验完整应用流程。</string>
|
||||||
|
<string name="provider_status_available">可用</string>
|
||||||
|
<string name="provider_status_simulation">开发模拟</string>
|
||||||
|
<string name="provider_status_protocol_pending">协议待确认</string>
|
||||||
|
<string name="provider_status_transport_pending">等待传输安全方案批准</string>
|
||||||
|
<string name="login_title">登录 %1$s</string>
|
||||||
|
<string name="login_provider_context">当前提供方:%1$s</string>
|
||||||
|
<string name="login_back_to_providers">返回提供方选择</string>
|
||||||
|
<string name="login_username">用户名</string>
|
||||||
|
<string name="login_password">密码</string>
|
||||||
|
<string name="login_remember_username">记住用户名</string>
|
||||||
|
<string name="login_auto_login">自动登录</string>
|
||||||
|
<string name="action_login">登录</string>
|
||||||
|
<string name="action_logout">退出登录</string>
|
||||||
|
<string name="action_switch_provider">切换提供方</string>
|
||||||
|
<string name="authenticated_title">已连接到 %1$s</string>
|
||||||
|
<string name="authenticated_description">登录后入口已经就绪,设备导航将由对应模块负责人接入。</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,8 +1,27 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">ABox Manager</string>
|
<string name="app_name">ABox Manager</string>
|
||||||
<string name="foundation_title">Project foundation ready</string>
|
<string name="provider_selection_title">Choose a provider</string>
|
||||||
<string name="foundation_description">The shared domain contracts and local development providers are available.</string>
|
<string name="provider_selection_description">Choose how this profile connects. Sessions, credentials, and local data stay isolated between providers.</string>
|
||||||
<string name="continue_action">Continue</string>
|
<string name="provider_abox_name">ABox</string>
|
||||||
<string name="placeholder_title">Feature development can start</string>
|
<string name="provider_abox_description">Connect through the ABSDK-compatible ABox service.</string>
|
||||||
<string name="placeholder_description">Device, infrared and scene screens will be added by their module owners.</string>
|
<string name="provider_compatible_name">Compatible backend</string>
|
||||||
|
<string name="provider_compatible_description">Use a self-hosted ABSDK-compatible service when its protocol is ready.</string>
|
||||||
|
<string name="provider_local_name">Local experience</string>
|
||||||
|
<string name="provider_local_description">Explore the complete app flow with local simulated devices and no network.</string>
|
||||||
|
<string name="provider_status_available">Available</string>
|
||||||
|
<string name="provider_status_simulation">Development simulation</string>
|
||||||
|
<string name="provider_status_protocol_pending">Protocol pending</string>
|
||||||
|
<string name="provider_status_transport_pending">Transport security approval required</string>
|
||||||
|
<string name="login_title">Sign in to %1$s</string>
|
||||||
|
<string name="login_provider_context">Selected provider: %1$s</string>
|
||||||
|
<string name="login_back_to_providers">Back to providers</string>
|
||||||
|
<string name="login_username">Username</string>
|
||||||
|
<string name="login_password">Password</string>
|
||||||
|
<string name="login_remember_username">Remember username</string>
|
||||||
|
<string name="login_auto_login">Sign in automatically</string>
|
||||||
|
<string name="action_login">Sign in</string>
|
||||||
|
<string name="action_logout">Sign out</string>
|
||||||
|
<string name="action_switch_provider">Switch provider</string>
|
||||||
|
<string name="authenticated_title">Connected to %1$s</string>
|
||||||
|
<string name="authenticated_description">The authenticated route is ready. Device navigation will be added by its module owner.</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.flagship.abox.manager.di
|
||||||
|
|
||||||
|
import com.flagship.abox.manager.domain.SessionGateway
|
||||||
|
import com.flagship.abox.manager.session.gateway.UnavailableRemoteSessionGateway
|
||||||
|
import com.flagship.abox.manager.ui.provider.Availability
|
||||||
|
import com.flagship.abox.manager.ui.provider.ProviderAvailability
|
||||||
|
import com.flagship.abox.manager.ui.provider.UnavailableReason
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object ReleaseSessionModule {
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideRemoteSessionGateway(): SessionGateway = UnavailableRemoteSessionGateway()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideProviderAvailability(): ProviderAvailability = ProviderAvailability(
|
||||||
|
abox = Availability.Disabled(UnavailableReason.TRANSPORT_SECURITY_PENDING),
|
||||||
|
compatible = Availability.Disabled(UnavailableReason.PROTOCOL_PENDING),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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`。
|
Bridge 本轮尚未加入 `android/app` 依赖,App debug 仍绑定 Fake。提供方选择、安全凭据和真实 Bridge 绑定由后续 `feat/provider-login` 完成;在获批的传输控制方案明确前,不得在 App 中启用真实 Bridge。协议证据与限制记录在 `docs/protocol/absdk-0630.md`。
|
||||||
|
|
||||||
## 9. 后续边界
|
## 9. Provider Session
|
||||||
|
|
||||||
|
`:android:core:session` 负责提供方登录的持久化与 Android 安全边界:
|
||||||
|
|
||||||
|
- `ProviderProfileStore` 使用 DataStore 保存 profile、当前活动 profile、记住用户名和自动登录开关;
|
||||||
|
- `CredentialStore` 使用 Android Keystore 中的 AES-256-GCM 密钥加密自动登录凭据,DataStore 只保存带随机 IV 的密文;
|
||||||
|
- `ProviderProfileId` 作为 GCM AAD,防止不同 profile 的密文互换;
|
||||||
|
- `LocalSessionGateway` 只维护本地内存 Session,不调用 HTTP 或 ABSDK;
|
||||||
|
- `UnavailableRemoteSessionGateway` 在远端入口未获批准时明确拒绝登录。
|
||||||
|
|
||||||
|
纯 Kotlin `SessionCoordinator` 统一处理启动恢复、提供方选择、登录偏好、自动登录、Token 失效重登、退出和切换提供方。远端退出清除密码和 Session,但保留 profile 与 remembered username;本地体验退出后回到提供方选择。
|
||||||
|
|
||||||
|
App 按构建变体声明远端可用性:debug 的 ABox 入口明确是开发模拟,release 的 ABox 因传输安全待确认而禁用;兼容后端在协议明确前始终禁用;本地体验始终可用。App 没有依赖或创建 `AbsdkBridge`,也没有对 `@CleartextAbsdkTransport` opt-in。
|
||||||
|
|
||||||
|
登录完成后进入 `authenticated` 协作路由。当前仅有占位页面,设备列表与主导航由 alZerNest 的设备导航模块接管。
|
||||||
|
|
||||||
|
## 10. 后续边界
|
||||||
|
|
||||||
后续 KremeCN 工作保持为独立短期分支:
|
后续 KremeCN 工作保持为独立短期分支:
|
||||||
|
|
||||||
- `feat/provider-login`:提供方入口、安全凭据、自动登录、重新登录和真实 Bridge 绑定;
|
- 在获批传输控制明确后,单独评审真实 ABox Bridge 的 App 绑定;
|
||||||
- `feat/compatible-server`:在协议与连接方式确认后建立服务端、CLI 和 Docker 基础。
|
- `feat/compatible-server`:在协议与连接方式确认后建立服务端、CLI 和 Docker 基础。
|
||||||
|
|
||||||
Room、DataStore 和 Android Keystore 的具体 schema/key 只在对应数据所有权明确后创建。历史示例中的 AsyncTask、明文密码存储、全局明文网络和散落 SDK 调用不得进入正式架构。
|
Room 与设备业务 DataStore 的具体 schema/key 只在对应数据所有权明确后创建。历史示例中的 AsyncTask、明文密码存储、全局明文网络和散落 SDK 调用不得进入正式架构。
|
||||||
|
|||||||
@@ -15,6 +15,41 @@ interface SessionGateway {
|
|||||||
suspend fun logout(profileId: ProviderProfileId): DomainResult<Unit>
|
suspend fun logout(profileId: ProviderProfileId): DomainResult<Unit>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ProviderProfileStore {
|
||||||
|
fun observeProfiles(): Flow<List<ProviderProfile>>
|
||||||
|
|
||||||
|
fun observeActiveProfile(): Flow<ProviderProfile?>
|
||||||
|
|
||||||
|
fun observeLoginPreferences(profileId: ProviderProfileId): Flow<LoginPreferences>
|
||||||
|
|
||||||
|
suspend fun profiles(): List<ProviderProfile>
|
||||||
|
|
||||||
|
suspend fun activeProfile(): ProviderProfile?
|
||||||
|
|
||||||
|
suspend fun loginPreferences(profileId: ProviderProfileId): LoginPreferences
|
||||||
|
|
||||||
|
suspend fun saveProfile(profile: ProviderProfile): DomainResult<Unit>
|
||||||
|
|
||||||
|
suspend fun setActiveProfile(profileId: ProviderProfileId?): DomainResult<Unit>
|
||||||
|
|
||||||
|
suspend fun saveLoginPreferences(preferences: LoginPreferences): DomainResult<Unit>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CredentialStore {
|
||||||
|
suspend fun saveCredentials(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
credentials: LoginCredentials,
|
||||||
|
): DomainResult<Unit>
|
||||||
|
|
||||||
|
suspend fun loadCredentials(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
): DomainResult<LoginCredentials?>
|
||||||
|
|
||||||
|
suspend fun clearCredentials(profileId: ProviderProfileId): DomainResult<Unit>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LocalSessionGateway : SessionGateway
|
||||||
|
|
||||||
interface DeviceCatalog {
|
interface DeviceCatalog {
|
||||||
fun observeDevices(
|
fun observeDevices(
|
||||||
profileId: ProviderProfileId,
|
profileId: ProviderProfileId,
|
||||||
|
|||||||
@@ -53,6 +53,13 @@ data class LoginCredentials(
|
|||||||
val password: Password,
|
val password: Password,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class LoginPreferences(
|
||||||
|
val profileId: ProviderProfileId,
|
||||||
|
val rememberedUsername: Username? = null,
|
||||||
|
val rememberUsername: Boolean = false,
|
||||||
|
val autoLoginEnabled: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
data class Session(
|
data class Session(
|
||||||
val profileId: ProviderProfileId,
|
val profileId: ProviderProfileId,
|
||||||
val username: Username,
|
val username: Username,
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package com.flagship.abox.manager.domain
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class SessionCoordinator(
|
||||||
|
private val profileStore: ProviderProfileStore,
|
||||||
|
private val credentialStore: CredentialStore,
|
||||||
|
private val remoteGateway: SessionGateway,
|
||||||
|
private val localGateway: LocalSessionGateway,
|
||||||
|
) {
|
||||||
|
fun observeActiveSession(): Flow<Session?> = profileStore.observeActiveProfile()
|
||||||
|
.flatMapLatest { profile ->
|
||||||
|
profile?.gateway()?.observeSession(profile.id) ?: flowOf(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun bootstrap(): DomainResult<Session?> = storeOperation {
|
||||||
|
val profile = profileStore.activeProfile() ?: return@storeOperation DomainResult.Success(null)
|
||||||
|
if (profile.type == ProviderType.LOCAL_EXPERIENCE) {
|
||||||
|
return@storeOperation localGateway.login(profile.id, localCredentials())
|
||||||
|
.mapSuccess { it }
|
||||||
|
}
|
||||||
|
val preferences = profileStore.loginPreferences(profile.id)
|
||||||
|
if (!preferences.autoLoginEnabled) return@storeOperation DomainResult.Success(null)
|
||||||
|
when (val credentials = credentialStore.loadCredentials(profile.id)) {
|
||||||
|
is DomainResult.Failure -> credentials
|
||||||
|
is DomainResult.Success -> {
|
||||||
|
val savedCredentials = credentials.value ?: return@storeOperation DomainResult.Success(null)
|
||||||
|
if (profileStore.activeProfile()?.id != profile.id) {
|
||||||
|
return@storeOperation DomainResult.Success(null)
|
||||||
|
}
|
||||||
|
profile.gateway().login(profile.id, savedCredentials).mapSuccess { it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun selectProvider(profile: ProviderProfile): DomainResult<Session?> = storeOperation {
|
||||||
|
val current = profileStore.activeProfile()
|
||||||
|
if (current?.id != profile.id && current != null) {
|
||||||
|
when (val switched = switchProvider()) {
|
||||||
|
is DomainResult.Failure -> return@storeOperation switched
|
||||||
|
is DomainResult.Success -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
when (val saved = profileStore.saveProfile(profile)) {
|
||||||
|
is DomainResult.Failure -> return@storeOperation saved
|
||||||
|
is DomainResult.Success -> Unit
|
||||||
|
}
|
||||||
|
when (val activated = profileStore.setActiveProfile(profile.id)) {
|
||||||
|
is DomainResult.Failure -> return@storeOperation activated
|
||||||
|
is DomainResult.Success -> Unit
|
||||||
|
}
|
||||||
|
if (profile.type == ProviderType.LOCAL_EXPERIENCE) {
|
||||||
|
localGateway.login(profile.id, localCredentials()).mapSuccess { it }
|
||||||
|
} else {
|
||||||
|
DomainResult.Success(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun login(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
credentials: LoginCredentials,
|
||||||
|
rememberUsername: Boolean,
|
||||||
|
autoLogin: Boolean,
|
||||||
|
): DomainResult<Session> = storeOperation {
|
||||||
|
val profile = profileStore.activeProfile()
|
||||||
|
?: return@storeOperation DomainResult.Failure(
|
||||||
|
DomainError.InvalidRequest(detail = "No active provider profile"),
|
||||||
|
)
|
||||||
|
if (profile.id != profileId || profile.type == ProviderType.LOCAL_EXPERIENCE) {
|
||||||
|
return@storeOperation DomainResult.Failure(
|
||||||
|
DomainError.InvalidRequest(detail = "The active provider does not require this login"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
when (val result = remoteGateway.login(profileId, credentials)) {
|
||||||
|
is DomainResult.Failure -> result
|
||||||
|
is DomainResult.Success -> {
|
||||||
|
val previousPreferences = profileStore.loginPreferences(profileId)
|
||||||
|
val previousCredentials = credentialStore.loadCredentials(profileId)
|
||||||
|
if (previousCredentials is DomainResult.Failure) {
|
||||||
|
remoteGateway.logout(profileId)
|
||||||
|
return@storeOperation previousCredentials
|
||||||
|
}
|
||||||
|
val preferences = LoginPreferences(
|
||||||
|
profileId = profileId,
|
||||||
|
rememberedUsername = credentials.username.takeIf { rememberUsername },
|
||||||
|
rememberUsername = rememberUsername,
|
||||||
|
autoLoginEnabled = autoLogin,
|
||||||
|
)
|
||||||
|
val credentialResult = if (autoLogin) {
|
||||||
|
credentialStore.saveCredentials(profileId, credentials)
|
||||||
|
} else {
|
||||||
|
credentialStore.clearCredentials(profileId)
|
||||||
|
}
|
||||||
|
if (credentialResult is DomainResult.Failure) {
|
||||||
|
remoteGateway.logout(profileId)
|
||||||
|
return@storeOperation credentialResult
|
||||||
|
}
|
||||||
|
when (val preferencesResult = profileStore.saveLoginPreferences(preferences)) {
|
||||||
|
is DomainResult.Success -> result
|
||||||
|
is DomainResult.Failure -> {
|
||||||
|
when (previousCredentials) {
|
||||||
|
is DomainResult.Success -> previousCredentials.value?.let {
|
||||||
|
credentialStore.saveCredentials(profileId, it)
|
||||||
|
} ?: credentialStore.clearCredentials(profileId)
|
||||||
|
is DomainResult.Failure -> Unit
|
||||||
|
}
|
||||||
|
profileStore.saveLoginPreferences(previousPreferences)
|
||||||
|
remoteGateway.logout(profileId)
|
||||||
|
preferencesResult
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun recoverSession(profileId: ProviderProfileId): DomainResult<Session?> = storeOperation {
|
||||||
|
val profile = profileStore.activeProfile()
|
||||||
|
if (profile?.id != profileId || profile.type == ProviderType.LOCAL_EXPERIENCE) {
|
||||||
|
return@storeOperation DomainResult.Success(null)
|
||||||
|
}
|
||||||
|
val preferences = profileStore.loginPreferences(profileId)
|
||||||
|
if (!preferences.autoLoginEnabled) return@storeOperation DomainResult.Success(null)
|
||||||
|
when (val credentials = credentialStore.loadCredentials(profileId)) {
|
||||||
|
is DomainResult.Failure -> credentials
|
||||||
|
is DomainResult.Success -> {
|
||||||
|
val savedCredentials = credentials.value ?: return@storeOperation DomainResult.Success(null)
|
||||||
|
when (val result = remoteGateway.login(profileId, savedCredentials)) {
|
||||||
|
is DomainResult.Success -> result.mapSuccess { it }
|
||||||
|
is DomainResult.Failure -> {
|
||||||
|
if (result.error is DomainError.Authentication ||
|
||||||
|
result.error is DomainError.TokenInvalid
|
||||||
|
) {
|
||||||
|
credentialStore.clearCredentials(profileId)
|
||||||
|
profileStore.saveLoginPreferences(
|
||||||
|
preferences.copy(autoLoginEnabled = false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun logout(): DomainResult<Unit> = storeOperation {
|
||||||
|
val profile = profileStore.activeProfile() ?: return@storeOperation DomainResult.Success(Unit)
|
||||||
|
when (val credentialResult = credentialStore.clearCredentials(profile.id)) {
|
||||||
|
is DomainResult.Failure -> return@storeOperation credentialResult
|
||||||
|
is DomainResult.Success -> Unit
|
||||||
|
}
|
||||||
|
val preferences = profileStore.loginPreferences(profile.id)
|
||||||
|
when (
|
||||||
|
val preferencesResult = profileStore.saveLoginPreferences(
|
||||||
|
preferences.copy(autoLoginEnabled = false),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
is DomainResult.Failure -> return@storeOperation preferencesResult
|
||||||
|
is DomainResult.Success -> Unit
|
||||||
|
}
|
||||||
|
when (val logoutResult = profile.gateway().logout(profile.id)) {
|
||||||
|
is DomainResult.Failure -> return@storeOperation logoutResult
|
||||||
|
is DomainResult.Success -> Unit
|
||||||
|
}
|
||||||
|
if (profile.type == ProviderType.LOCAL_EXPERIENCE) {
|
||||||
|
profileStore.setActiveProfile(null)
|
||||||
|
} else {
|
||||||
|
DomainResult.Success(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun switchProvider(): DomainResult<Unit> = storeOperation {
|
||||||
|
when (val logoutResult = logout()) {
|
||||||
|
is DomainResult.Failure -> logoutResult
|
||||||
|
is DomainResult.Success -> profileStore.setActiveProfile(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ProviderProfile.gateway(): SessionGateway =
|
||||||
|
if (type == ProviderType.LOCAL_EXPERIENCE) localGateway else remoteGateway
|
||||||
|
|
||||||
|
private fun localCredentials(): LoginCredentials = LoginCredentials(
|
||||||
|
username = Username("local"),
|
||||||
|
password = Password(""),
|
||||||
|
)
|
||||||
|
|
||||||
|
private suspend fun <T> storeOperation(block: suspend () -> DomainResult<T>): DomainResult<T> =
|
||||||
|
try {
|
||||||
|
block()
|
||||||
|
} catch (error: CancellationException) {
|
||||||
|
throw error
|
||||||
|
} catch (error: Exception) {
|
||||||
|
DomainResult.Failure(
|
||||||
|
DomainError.Unknown(detail = error.message ?: error::class.java.simpleName),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <T, R> DomainResult<T>.mapSuccess(transform: (T) -> R): DomainResult<R> = when (this) {
|
||||||
|
is DomainResult.Failure -> this
|
||||||
|
is DomainResult.Success -> DomainResult.Success(transform(value))
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ espresso = "3.6.1"
|
|||||||
[libraries]
|
[libraries]
|
||||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
|
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
|
||||||
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
|
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
|
||||||
|
androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
|
||||||
|
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||||
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
|
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
|
||||||
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.2.0" }
|
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.2.0" }
|
||||||
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
|
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
|
||||||
@@ -35,6 +37,7 @@ room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
|
|||||||
datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
|
datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
|
||||||
junit = { module = "junit:junit", version.ref = "junit" }
|
junit = { module = "junit:junit", version.ref = "junit" }
|
||||||
androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidxJunit" }
|
androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidxJunit" }
|
||||||
|
androidx-test-runner = { module = "androidx.test:runner", version = "1.6.1" }
|
||||||
espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" }
|
espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
|
|||||||
@@ -26,4 +26,5 @@ include(":domain")
|
|||||||
include(":absdk-bridge")
|
include(":absdk-bridge")
|
||||||
include(":testing:fakes")
|
include(":testing:fakes")
|
||||||
include(":android:core:designsystem")
|
include(":android:core:designsystem")
|
||||||
|
include(":android:core:session")
|
||||||
include(":android:app")
|
include(":android:app")
|
||||||
|
|||||||
+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