diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 9991fb1..cac7fec 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -54,12 +54,16 @@ kapt { dependencies { implementation(project(":domain")) implementation(project(":android:core:designsystem")) + implementation(project(":android:core:session")) debugImplementation(project(":testing:fakes")) implementation(platform(libs.compose.bom)) implementation(libs.androidx.activity.compose) implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.navigation.compose) + implementation(libs.datastore.preferences) implementation(libs.androidx.hilt.navigation.compose) implementation(libs.compose.ui) implementation(libs.compose.ui.tooling.preview) @@ -68,8 +72,10 @@ dependencies { kapt(libs.hilt.compiler) testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) androidTestImplementation(platform(libs.compose.bom)) androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.espresso.core) androidTestImplementation(libs.compose.ui.test.junit4) debugImplementation(libs.compose.ui.tooling) diff --git a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/login/LoginScreenTest.kt b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/login/LoginScreenTest.kt new file mode 100644 index 0000000..49f928c --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/login/LoginScreenTest.kt @@ -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() + + 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() + } +} diff --git a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreenTest.kt b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreenTest.kt new file mode 100644 index 0000000..b5fb6f9 --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreenTest.kt @@ -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() + + @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() + } +} diff --git a/android/app/src/debug/kotlin/com/flagship/abox/manager/di/DebugSessionModule.kt b/android/app/src/debug/kotlin/com/flagship/abox/manager/di/DebugSessionModule.kt new file mode 100644 index 0000000..ec992e9 --- /dev/null +++ b/android/app/src/debug/kotlin/com/flagship/abox/manager/di/DebugSessionModule.kt @@ -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), + ) +} diff --git a/android/app/src/debug/kotlin/com/flagship/abox/manager/di/FoundationModule.kt b/android/app/src/debug/kotlin/com/flagship/abox/manager/di/FoundationModule.kt index 4c1e6c9..2052c9e 100644 --- a/android/app/src/debug/kotlin/com/flagship/abox/manager/di/FoundationModule.kt +++ b/android/app/src/debug/kotlin/com/flagship/abox/manager/di/FoundationModule.kt @@ -2,7 +2,6 @@ package com.flagship.abox.manager.di import com.flagship.abox.manager.domain.DeviceCatalog import com.flagship.abox.manager.domain.InfraredGateway -import com.flagship.abox.manager.domain.SessionGateway import com.flagship.abox.manager.domain.SocketGateway import com.flagship.abox.manager.domain.TemperatureHumidityGateway import com.flagship.abox.manager.testing.fakes.FakeDeviceCatalog @@ -21,7 +20,7 @@ import javax.inject.Singleton object FoundationModule { @Provides @Singleton - fun provideSessionGateway(): SessionGateway = FakeSessionGateway() + fun provideFakeSessionGateway(): FakeSessionGateway = FakeSessionGateway() @Provides @Singleton diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/di/SessionModule.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/di/SessionModule.kt new file mode 100644 index 0000000..2c123f8 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/di/SessionModule.kt @@ -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 = context.sessionDataStore + + @Provides + @Singleton + fun provideProviderProfileStore( + dataStore: DataStore, + ): ProviderProfileStore = ProviderProfileStoreImpl(dataStore) + + @Provides + @Singleton + fun provideCredentialCipher(): CredentialCipher = AndroidKeystoreCredentialCipher() + + @Provides + @Singleton + fun provideCredentialStore( + dataStore: DataStore, + 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, + ) +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt index e31f830..0a1b959 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt @@ -1,94 +1,60 @@ package com.flagship.abox.manager.ui -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Text +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.rememberNavController -import com.flagship.abox.manager.R -import com.flagship.abox.manager.designsystem.ABoxButton +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator import com.flagship.abox.manager.designsystem.ABoxSurface import com.flagship.abox.manager.designsystem.ABoxTheme - -private const val FOUNDATION_ROUTE = "foundation" -private const val PLACEHOLDER_ROUTE = "placeholder" +import com.flagship.abox.manager.ui.authenticated.AuthenticatedPlaceholderScreen +import com.flagship.abox.manager.ui.login.LoginRoute +import com.flagship.abox.manager.ui.provider.ProviderSelectionRoute +import com.flagship.abox.manager.ui.session.AppSessionUiState +import com.flagship.abox.manager.ui.session.AppSessionViewModel +import com.flagship.abox.manager.ui.session.SessionActionsViewModel @Composable -fun ABoxManagerApp() { +fun ABoxManagerApp( + appSessionViewModel: AppSessionViewModel = hiltViewModel(), + sessionActionsViewModel: SessionActionsViewModel = hiltViewModel(), +) { + val actionState = sessionActionsViewModel.uiState.collectAsStateWithLifecycle().value ABoxTheme { ABoxSurface(modifier = Modifier.fillMaxSize()) { - val navController = rememberNavController() - NavHost( - navController = navController, - startDestination = FOUNDATION_ROUTE, + when ( + val state = appSessionViewModel.uiState.collectAsStateWithLifecycle().value ) { - composable(FOUNDATION_ROUTE) { - FoundationScreen(onContinue = { navController.navigate(PLACEHOLDER_ROUTE) }) - } - composable(PLACEHOLDER_ROUTE) { - MessageScreen( - title = stringResource(R.string.placeholder_title), - description = stringResource(R.string.placeholder_description), - ) + AppSessionUiState.Loading -> Box( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing), + contentAlignment = Alignment.Center, + ) { + ABoxLoadingIndicator() } + AppSessionUiState.ProviderSelection -> ProviderSelectionRoute() + is AppSessionUiState.Login -> LoginRoute( + profile = state.profile, + bootstrapError = state.bootstrapError, + actionError = actionState.errorMessage, + actionInProgress = actionState.inProgress, + onSwitchProvider = sessionActionsViewModel::switchProvider, + ) + is AppSessionUiState.Authenticated -> AuthenticatedPlaceholderScreen( + profile = state.profile, + actionInProgress = actionState.inProgress, + errorMessage = actionState.errorMessage, + onLogout = sessionActionsViewModel::logout, + onSwitchProvider = sessionActionsViewModel::switchProvider, + ) } } } } - -@Composable -private fun FoundationScreen(onContinue: () -> Unit) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - MessageScreen( - title = stringResource(R.string.foundation_title), - description = stringResource(R.string.foundation_description), - ) - ABoxButton( - onClick = onContinue, - modifier = Modifier - .fillMaxWidth() - .padding(top = 24.dp), - ) { - Text(stringResource(R.string.continue_action)) - } - } -} - -@Composable -private fun MessageScreen( - title: String, - description: String, -) { - Column( - modifier = Modifier.padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - text = title, - style = androidx.compose.material3.MaterialTheme.typography.headlineMedium, - textAlign = TextAlign.Center, - ) - Text( - text = description, - style = androidx.compose.material3.MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - ) - } -} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/authenticated/AuthenticatedScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/authenticated/AuthenticatedScreen.kt new file mode 100644 index 0000000..3015930 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/authenticated/AuthenticatedScreen.kt @@ -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)) + } + } + } + } +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt new file mode 100644 index 0000000..b65ae1d --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginScreen.kt @@ -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 + }, +) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt new file mode 100644 index 0000000..eecd8ab --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/login/LoginViewModel.kt @@ -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 = _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, +) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/Routes.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/Routes.kt new file mode 100644 index 0000000..ee3cb43 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/Routes.kt @@ -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" +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderAvailability.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderAvailability.kt new file mode 100644 index 0000000..c5e59a7 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderAvailability.kt @@ -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, +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreen.kt new file mode 100644 index 0000000..aaeabdb --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionScreen.kt @@ -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 +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionViewModel.kt new file mode 100644 index 0000000..1debcd7 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/provider/ProviderSelectionViewModel.kt @@ -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 = _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, + val isSelecting: Boolean = false, + val errorMessage: String? = null, +) + +data class ProviderOption( + val profile: ProviderProfile, + val availability: Availability, +) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt new file mode 100644 index 0000000..a123d38 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/AppSessionViewModel.kt @@ -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?>(null) + + val uiState: StateFlow = 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 +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/SessionActionsViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/SessionActionsViewModel.kt new file mode 100644 index 0000000..f5adb34 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/SessionActionsViewModel.kt @@ -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 = _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) { + 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, +) diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml index bc378ad..3a812f4 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -1,8 +1,27 @@ ABox Manager - 工程基础已就绪 - 公共领域接口和本地开发提供方已经可用。 - 继续 - 功能模块可以开始开发 - 设备、红外和情景页面将由对应模块负责人实现。 + 选择提供方 + 选择当前配置的连接方式。不同提供方之间的会话、凭据和本地数据相互隔离。 + ABox + 通过兼容 ABSDK 的 ABox 服务连接设备。 + 自有兼容后端 + 在协议准备完成后连接自行部署的 ABSDK 兼容服务。 + 本地体验 + 无需网络,通过本地模拟设备体验完整应用流程。 + 可用 + 开发模拟 + 协议待确认 + 等待传输安全方案批准 + 登录 %1$s + 当前提供方:%1$s + 返回提供方选择 + 用户名 + 密码 + 记住用户名 + 自动登录 + 登录 + 退出登录 + 切换提供方 + 已连接到 %1$s + 登录后入口已经就绪,设备导航将由对应模块负责人接入。 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index ec5c8d7..0a63752 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,8 +1,27 @@ ABox Manager - Project foundation ready - The shared domain contracts and local development providers are available. - Continue - Feature development can start - Device, infrared and scene screens will be added by their module owners. + Choose a provider + Choose how this profile connects. Sessions, credentials, and local data stay isolated between providers. + ABox + Connect through the ABSDK-compatible ABox service. + Compatible backend + Use a self-hosted ABSDK-compatible service when its protocol is ready. + Local experience + Explore the complete app flow with local simulated devices and no network. + Available + Development simulation + Protocol pending + Transport security approval required + Sign in to %1$s + Selected provider: %1$s + Back to providers + Username + Password + Remember username + Sign in automatically + Sign in + Sign out + Switch provider + Connected to %1$s + The authenticated route is ready. Device navigation will be added by its module owner. diff --git a/android/app/src/release/kotlin/com/flagship/abox/manager/di/ReleaseSessionModule.kt b/android/app/src/release/kotlin/com/flagship/abox/manager/di/ReleaseSessionModule.kt new file mode 100644 index 0000000..ff92f2a --- /dev/null +++ b/android/app/src/release/kotlin/com/flagship/abox/manager/di/ReleaseSessionModule.kt @@ -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), + ) +} diff --git a/android/core/session/build.gradle.kts b/android/core/session/build.gradle.kts new file mode 100644 index 0000000..2fb4697 --- /dev/null +++ b/android/core/session/build.gradle.kts @@ -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) +} diff --git a/android/core/session/src/androidTest/kotlin/com/flagship/abox/manager/session/credential/AndroidKeystoreCredentialCipherTest.kt b/android/core/session/src/androidTest/kotlin/com/flagship/abox/manager/session/credential/AndroidKeystoreCredentialCipherTest.kt new file mode 100644 index 0000000..e67e936 --- /dev/null +++ b/android/core/session/src/androidTest/kotlin/com/flagship/abox/manager/session/credential/AndroidKeystoreCredentialCipherTest.kt @@ -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)) + } +} diff --git a/android/core/session/src/main/AndroidManifest.xml b/android/core/session/src/main/AndroidManifest.xml new file mode 100644 index 0000000..cc947c5 --- /dev/null +++ b/android/core/session/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/AndroidKeystoreCredentialCipher.kt b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/AndroidKeystoreCredentialCipher.kt new file mode 100644 index 0000000..e865be9 --- /dev/null +++ b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/AndroidKeystoreCredentialCipher.kt @@ -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 + } +} diff --git a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/CredentialCipher.kt b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/CredentialCipher.kt new file mode 100644 index 0000000..ea3fbab --- /dev/null +++ b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/CredentialCipher.kt @@ -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 +} diff --git a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/KeystoreCredentialStore.kt b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/KeystoreCredentialStore.kt new file mode 100644 index 0000000..02f801f --- /dev/null +++ b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/credential/KeystoreCredentialStore.kt @@ -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, + private val cipher: CredentialCipher, +) : CredentialStore { + override suspend fun saveCredentials( + profileId: ProviderProfileId, + credentials: LoginCredentials, + ): DomainResult = operation { + val encrypted = cipher.encrypt(profileId, encode(credentials)) + dataStore.edit { it[credentialKey(profileId)] = Base64.getEncoder().encodeToString(encrypted) } + } + + override suspend fun loadCredentials( + profileId: ProviderProfileId, + ): DomainResult { + 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 = 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 = 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 +} diff --git a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/gateway/LocalSessionGatewayImpl.kt b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/gateway/LocalSessionGatewayImpl.kt new file mode 100644 index 0000000..1fbfb01 --- /dev/null +++ b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/gateway/LocalSessionGatewayImpl.kt @@ -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>() + + override fun observeSession(profileId: ProviderProfileId): Flow = flow(profileId) + + override suspend fun login( + profileId: ProviderProfileId, + credentials: LoginCredentials, + ): DomainResult = DomainResult.Success( + Session(profileId, credentials.username).also { flow(profileId).value = it }, + ) + + override suspend fun validateSession( + profileId: ProviderProfileId, + ): DomainResult = DomainResult.Success( + if (flow(profileId).value == null) SessionValidity.INVALID else SessionValidity.VALID, + ) + + override suspend fun logout(profileId: ProviderProfileId): DomainResult { + flow(profileId).value = null + return DomainResult.Success(Unit) + } + + private fun flow(profileId: ProviderProfileId): MutableStateFlow = + sessions.getOrPut(profileId) { MutableStateFlow(null) } +} diff --git a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/gateway/UnavailableRemoteSessionGateway.kt b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/gateway/UnavailableRemoteSessionGateway.kt new file mode 100644 index 0000000..5a8111c --- /dev/null +++ b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/gateway/UnavailableRemoteSessionGateway.kt @@ -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 = flowOf(null) + + override suspend fun login( + profileId: ProviderProfileId, + credentials: LoginCredentials, + ): DomainResult = unavailable() + + override suspend fun validateSession( + profileId: ProviderProfileId, + ): DomainResult = unavailable() + + override suspend fun logout(profileId: ProviderProfileId): DomainResult = + DomainResult.Success(Unit) + + private fun unavailable(): DomainResult = DomainResult.Failure( + DomainError.InvalidRequest(detail = "Remote providers are not enabled in this build"), + ) +} diff --git a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt new file mode 100644 index 0000000..2ddadcc --- /dev/null +++ b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/ProviderProfileStoreImpl.kt @@ -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, +) : ProviderProfileStore { + override fun observeProfiles(): Flow> = dataStore.data.map(::profilesFrom) + + override fun observeActiveProfile(): Flow = dataStore.data.map(::activeProfileFrom) + + override fun observeLoginPreferences(profileId: ProviderProfileId): Flow = + dataStore.data.map { preferencesFrom(it, profileId) } + + override suspend fun profiles(): List = 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 = 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 = 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 = 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 = + 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 = 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") + } +} diff --git a/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/SessionDataStore.kt b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/SessionDataStore.kt new file mode 100644 index 0000000..19d9c14 --- /dev/null +++ b/android/core/session/src/main/kotlin/com/flagship/abox/manager/session/profile/SessionDataStore.kt @@ -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 by preferencesDataStore( + name = SESSION_DATA_STORE_NAME, +) diff --git a/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialStoreTest.kt b/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialStoreTest.kt new file mode 100644 index 0000000..b185166 --- /dev/null +++ b/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialStoreTest.kt @@ -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) + } + } +} diff --git a/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreTest.kt b/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreTest.kt new file mode 100644 index 0000000..8616cd5 --- /dev/null +++ b/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreTest.kt @@ -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()) + } +} diff --git a/docs/architecture.md b/docs/architecture.md index c00b4c7..1f01750 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -108,11 +108,27 @@ Fake 只用于开发和测试,不能作为真实 ABox 或真实硬件验收证 Bridge 本轮尚未加入 `android/app` 依赖,App debug 仍绑定 Fake。提供方选择、安全凭据和真实 Bridge 绑定由后续 `feat/provider-login` 完成;在获批的传输控制方案明确前,不得在 App 中启用真实 Bridge。协议证据与限制记录在 `docs/protocol/absdk-0630.md`。 -## 9. 后续边界 +## 9. Provider Session + +`:android:core:session` 负责提供方登录的持久化与 Android 安全边界: + +- `ProviderProfileStore` 使用 DataStore 保存 profile、当前活动 profile、记住用户名和自动登录开关; +- `CredentialStore` 使用 Android Keystore 中的 AES-256-GCM 密钥加密自动登录凭据,DataStore 只保存带随机 IV 的密文; +- `ProviderProfileId` 作为 GCM AAD,防止不同 profile 的密文互换; +- `LocalSessionGateway` 只维护本地内存 Session,不调用 HTTP 或 ABSDK; +- `UnavailableRemoteSessionGateway` 在远端入口未获批准时明确拒绝登录。 + +纯 Kotlin `SessionCoordinator` 统一处理启动恢复、提供方选择、登录偏好、自动登录、Token 失效重登、退出和切换提供方。远端退出清除密码和 Session,但保留 profile 与 remembered username;本地体验退出后回到提供方选择。 + +App 按构建变体声明远端可用性:debug 的 ABox 入口明确是开发模拟,release 的 ABox 因传输安全待确认而禁用;兼容后端在协议明确前始终禁用;本地体验始终可用。App 没有依赖或创建 `AbsdkBridge`,也没有对 `@CleartextAbsdkTransport` opt-in。 + +登录完成后进入 `authenticated` 协作路由。当前仅有占位页面,设备列表与主导航由 alZerNest 的设备导航模块接管。 + +## 10. 后续边界 后续 KremeCN 工作保持为独立短期分支: -- `feat/provider-login`:提供方入口、安全凭据、自动登录、重新登录和真实 Bridge 绑定; +- 在获批传输控制明确后,单独评审真实 ABox Bridge 的 App 绑定; - `feat/compatible-server`:在协议与连接方式确认后建立服务端、CLI 和 Docker 基础。 -Room、DataStore 和 Android Keystore 的具体 schema/key 只在对应数据所有权明确后创建。历史示例中的 AsyncTask、明文密码存储、全局明文网络和散落 SDK 调用不得进入正式架构。 +Room 与设备业务 DataStore 的具体 schema/key 只在对应数据所有权明确后创建。历史示例中的 AsyncTask、明文密码存储、全局明文网络和散落 SDK 调用不得进入正式架构。 diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/Gateways.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Gateways.kt index 6fe9ee7..4108621 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/Gateways.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Gateways.kt @@ -15,6 +15,41 @@ interface SessionGateway { suspend fun logout(profileId: ProviderProfileId): DomainResult } +interface ProviderProfileStore { + fun observeProfiles(): Flow> + + fun observeActiveProfile(): Flow + + fun observeLoginPreferences(profileId: ProviderProfileId): Flow + + suspend fun profiles(): List + + suspend fun activeProfile(): ProviderProfile? + + suspend fun loginPreferences(profileId: ProviderProfileId): LoginPreferences + + suspend fun saveProfile(profile: ProviderProfile): DomainResult + + suspend fun setActiveProfile(profileId: ProviderProfileId?): DomainResult + + suspend fun saveLoginPreferences(preferences: LoginPreferences): DomainResult +} + +interface CredentialStore { + suspend fun saveCredentials( + profileId: ProviderProfileId, + credentials: LoginCredentials, + ): DomainResult + + suspend fun loadCredentials( + profileId: ProviderProfileId, + ): DomainResult + + suspend fun clearCredentials(profileId: ProviderProfileId): DomainResult +} + +interface LocalSessionGateway : SessionGateway + interface DeviceCatalog { fun observeDevices( profileId: ProviderProfileId, diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/Models.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Models.kt index fe0828d..d27c801 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/Models.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Models.kt @@ -53,6 +53,13 @@ data class LoginCredentials( val password: Password, ) +data class LoginPreferences( + val profileId: ProviderProfileId, + val rememberedUsername: Username? = null, + val rememberUsername: Boolean = false, + val autoLoginEnabled: Boolean = false, +) + data class Session( val profileId: ProviderProfileId, val username: Username, diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/SessionCoordinator.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/SessionCoordinator.kt new file mode 100644 index 0000000..4923c23 --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/SessionCoordinator.kt @@ -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 = profileStore.observeActiveProfile() + .flatMapLatest { profile -> + profile?.gateway()?.observeSession(profile.id) ?: flowOf(null) + } + + suspend fun bootstrap(): DomainResult = 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 = 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 = 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 = 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 = 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 = 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 storeOperation(block: suspend () -> DomainResult): DomainResult = + try { + block() + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + DomainResult.Failure( + DomainError.Unknown(detail = error.message ?: error::class.java.simpleName), + ) + } +} + +private inline fun DomainResult.mapSuccess(transform: (T) -> R): DomainResult = when (this) { + is DomainResult.Failure -> this + is DomainResult.Success -> DomainResult.Success(transform(value)) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d6f33fb..2b00e05 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,8 @@ espresso = "3.6.1" [libraries] androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" } +androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" } +androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.2.0" } compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } @@ -35,6 +37,7 @@ room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } junit = { module = "junit:junit", version.ref = "junit" } androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidxJunit" } +androidx-test-runner = { module = "androidx.test:runner", version = "1.6.1" } espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" } [plugins] diff --git a/settings.gradle.kts b/settings.gradle.kts index 6a69ab0..398735c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,4 +26,5 @@ include(":domain") include(":absdk-bridge") include(":testing:fakes") include(":android:core:designsystem") +include(":android:core:session") include(":android:app") diff --git a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeCredentialStore.kt b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeCredentialStore.kt new file mode 100644 index 0000000..0883791 --- /dev/null +++ b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeCredentialStore.kt @@ -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() + + override suspend fun saveCredentials( + profileId: ProviderProfileId, + credentials: LoginCredentials, + ): DomainResult { + values[profileId] = credentials + return DomainResult.Success(Unit) + } + + override suspend fun loadCredentials( + profileId: ProviderProfileId, + ): DomainResult = DomainResult.Success(values[profileId]) + + override suspend fun clearCredentials(profileId: ProviderProfileId): DomainResult { + values.remove(profileId) + return DomainResult.Success(Unit) + } +} diff --git a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeLocalSessionGateway.kt b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeLocalSessionGateway.kt new file mode 100644 index 0000000..2ac6f3d --- /dev/null +++ b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeLocalSessionGateway.kt @@ -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>() + + override fun observeSession(profileId: ProviderProfileId): Flow = flow(profileId) + + override suspend fun login( + profileId: ProviderProfileId, + credentials: LoginCredentials, + ): DomainResult = DomainResult.Success( + Session(profileId, credentials.username).also { flow(profileId).value = it }, + ) + + override suspend fun validateSession( + profileId: ProviderProfileId, + ): DomainResult = DomainResult.Success( + if (flow(profileId).value == null) SessionValidity.INVALID else SessionValidity.VALID, + ) + + override suspend fun logout(profileId: ProviderProfileId): DomainResult { + flow(profileId).value = null + return DomainResult.Success(Unit) + } + + private fun flow(profileId: ProviderProfileId): MutableStateFlow = + sessions.getOrPut(profileId) { MutableStateFlow(null) } +} diff --git a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeProviderProfileStore.kt b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeProviderProfileStore.kt new file mode 100644 index 0000000..1e3b86e --- /dev/null +++ b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeProviderProfileStore.kt @@ -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() + private val preferenceValues = ConcurrentHashMap() + private val profilesFlow = MutableStateFlow>(emptyList()) + private val activeProfileFlow = MutableStateFlow(null) + private val preferenceFlows = ConcurrentHashMap>() + + override fun observeProfiles(): Flow> = profilesFlow + + override fun observeActiveProfile(): Flow = activeProfileFlow + + override fun observeLoginPreferences(profileId: ProviderProfileId): Flow = + preferenceFlow(profileId) + + override suspend fun profiles(): List = 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 { + 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 { + activeProfileFlow.value = profileId?.let(profileValues::get) + return DomainResult.Success(Unit) + } + + override suspend fun saveLoginPreferences(preferences: LoginPreferences): DomainResult { + preferenceValues[preferences.profileId] = preferences + preferenceFlow(preferences.profileId).value = preferences + return DomainResult.Success(Unit) + } + + private fun preferenceFlow(profileId: ProviderProfileId): MutableStateFlow = + preferenceFlows.getOrPut(profileId) { + MutableStateFlow( + preferenceValues[profileId] ?: LoginPreferences(profileId), + ) + } +} diff --git a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeCredentialStoreTest.kt b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeCredentialStoreTest.kt new file mode 100644 index 0000000..4679831 --- /dev/null +++ b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeCredentialStoreTest.kt @@ -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) + } +} diff --git a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeProviderProfileStoreTest.kt b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeProviderProfileStoreTest.kt new file mode 100644 index 0000000..e340ec1 --- /dev/null +++ b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeProviderProfileStoreTest.kt @@ -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()) + } +} diff --git a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/SessionCoordinatorTest.kt b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/SessionCoordinatorTest.kt new file mode 100644 index 0000000..89b3e94 --- /dev/null +++ b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/SessionCoordinatorTest.kt @@ -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) + } +}