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 ce67fc0..73d42ba 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 @@ -52,6 +52,7 @@ fun ABoxManagerApp( val actionState = sessionActionsViewModel.uiState.collectAsStateWithLifecycle().value val sessionState = appSessionViewModel.uiState.collectAsStateWithLifecycle().value val settingsState = settingsViewModel.uiState.collectAsStateWithLifecycle().value + val actionError = actionState.errorMessage?.asString() val screenKey = sessionState.screenKey() LaunchedEffect(screenKey) { sessionActionsViewModel.dismissError() @@ -74,7 +75,7 @@ fun ABoxManagerApp( is AppSessionUiState.Login -> LoginRoute( profile = state.profile, bootstrapError = state.bootstrapError, - actionError = actionState.errorMessage, + actionError = actionError, actionInProgress = actionState.inProgress, onSwitchProvider = sessionActionsViewModel::switchProvider, viewModel = loginViewModel, @@ -83,7 +84,7 @@ fun ABoxManagerApp( username = state.username, profile = state.profile, actionInProgress = actionState.inProgress, - errorMessage = actionState.errorMessage, + errorMessage = actionError, onLogout = sessionActionsViewModel::logout, ) } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/common/DomainErrorMapper.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/common/DomainErrorMapper.kt index fe37e14..9e47752 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/common/DomainErrorMapper.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/common/DomainErrorMapper.kt @@ -13,13 +13,6 @@ object DomainErrorMapper { is DomainError.InvalidRequest -> UiText.StringResource(R.string.error_invalid_request) is DomainError.OperationFailed -> UiText.StringResource(R.string.error_operation_failed) is DomainError.NoData -> UiText.StringResource(R.string.error_no_data) - is DomainError.Unknown -> { - val detail = error.detail - if (!detail.isNullOrBlank()) { - UiText.DynamicString(detail) - } else { - UiText.StringResource(R.string.error_unknown) - } - } + is DomainError.Unknown -> UiText.StringResource(R.string.error_unknown) } } 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 index f5adb34..d713dde 100644 --- 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 @@ -4,6 +4,9 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.SessionCoordinator +import com.flagship.abox.manager.domain.SessionValidity +import com.flagship.abox.manager.ui.common.DomainErrorMapper +import com.flagship.abox.manager.ui.common.UiText import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow @@ -18,9 +21,21 @@ class SessionActionsViewModel @Inject constructor( private val _uiState = MutableStateFlow(SessionActionsUiState()) val uiState: StateFlow = _uiState.asStateFlow() + fun onAction(action: SessionUiAction) { + when (action) { + SessionUiAction.EndSession -> runAction(coordinator::endActiveSession) + SessionUiAction.ForgetSavedCredentials -> runAction(coordinator::forgetSavedCredentials) + SessionUiAction.SwitchProvider -> runAction(coordinator::switchProvider) + SessionUiAction.ValidateConnection -> runValidation() + SessionUiAction.DismissError -> dismissError() + } + } + + fun dispatch(action: SessionUiAction) = onAction(action) + fun logout() = runAction(coordinator::logout) - fun switchProvider() = runAction(coordinator::switchProvider) + fun switchProvider() = onAction(SessionUiAction.SwitchProvider) fun dismissError() { _uiState.value = _uiState.value.copy(errorMessage = null) @@ -32,7 +47,19 @@ class SessionActionsViewModel @Inject constructor( _uiState.value = SessionActionsUiState(inProgress = true) val result = action() _uiState.value = SessionActionsUiState( - errorMessage = (result as? DomainResult.Failure)?.error?.detail, + errorMessage = (result as? DomainResult.Failure)?.error?.let(DomainErrorMapper::map), + ) + } + } + + private fun runValidation() { + if (_uiState.value.inProgress) return + viewModelScope.launch { + _uiState.value = SessionActionsUiState(inProgress = true) + val result = coordinator.validateActiveSession() + _uiState.value = SessionActionsUiState( + validity = (result as? DomainResult.Success)?.value, + errorMessage = (result as? DomainResult.Failure)?.error?.let(DomainErrorMapper::map), ) } } @@ -40,5 +67,14 @@ class SessionActionsViewModel @Inject constructor( data class SessionActionsUiState( val inProgress: Boolean = false, - val errorMessage: String? = null, + val validity: SessionValidity? = null, + val errorMessage: UiText? = null, ) + +sealed interface SessionUiAction { + data object EndSession : SessionUiAction + data object ForgetSavedCredentials : SessionUiAction + data object SwitchProvider : SessionUiAction + data object ValidateConnection : SessionUiAction + data object DismissError : SessionUiAction +} diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/session/SessionActionsViewModelTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/session/SessionActionsViewModelTest.kt new file mode 100644 index 0000000..4d3586b --- /dev/null +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/session/SessionActionsViewModelTest.kt @@ -0,0 +1,197 @@ +package com.flagship.abox.manager.ui.session + +import com.flagship.abox.manager.R +import com.flagship.abox.manager.domain.DomainError +import com.flagship.abox.manager.domain.LoginCredentials +import com.flagship.abox.manager.domain.Password +import com.flagship.abox.manager.domain.ProviderProfile +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.ProviderType +import com.flagship.abox.manager.domain.SessionCoordinator +import com.flagship.abox.manager.domain.SessionValidity +import com.flagship.abox.manager.domain.Username +import com.flagship.abox.manager.testing.MainDispatcherRule +import com.flagship.abox.manager.testing.fakes.FakeCredentialStore +import com.flagship.abox.manager.testing.fakes.FakeLocalSessionGateway +import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore +import com.flagship.abox.manager.testing.fakes.FakeSessionGateway +import com.flagship.abox.manager.ui.common.UiText +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SessionActionsViewModelTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val profile = ProviderProfile( + id = ProviderProfileId("abox"), + type = ProviderType.ABOX, + displayName = "ABox", + ) + private val credentials = LoginCredentials(Username("demo"), Password("secret")) + + @Test + fun endSessionActionEndsSessionWithoutForgettingCredentials() = runTest { + val fixture = Fixture() + fixture.activateAuthenticatedProfile() + fixture.coordinator.login(profile.id, credentials, rememberUsername = true, autoLogin = true) + val viewModel = SessionActionsViewModel(fixture.coordinator) + + viewModel.onAction(SessionUiAction.EndSession) + advanceUntilIdle() + + assertTrue(fixture.remote.session(profile.id) == null) + assertEquals( + credentials, + fixture.credentials.loadCredentials(profile.id).successValue(), + ) + assertNull(viewModel.uiState.value.errorMessage) + } + + @Test + fun forgetCredentialsActionKeepsSessionAndDisablesAutoLogin() = runTest { + val fixture = Fixture() + fixture.activateAuthenticatedProfile() + fixture.coordinator.login(profile.id, credentials, rememberUsername = true, autoLogin = true) + val viewModel = SessionActionsViewModel(fixture.coordinator) + + viewModel.onAction(SessionUiAction.ForgetSavedCredentials) + advanceUntilIdle() + + assertEquals(profile.id, fixture.remote.session(profile.id)?.profileId) + assertNull(fixture.credentials.loadCredentials(profile.id).successValue()) + assertTrue(!fixture.profiles.loginPreferences(profile.id).autoLoginEnabled) + } + + @Test + fun switchProviderActionClearsProfileOnlyAfterLogout() = runTest { + val fixture = Fixture() + fixture.activateAuthenticatedProfile() + fixture.remote.login(profile.id, credentials) + val viewModel = SessionActionsViewModel(fixture.coordinator) + + viewModel.onAction(SessionUiAction.SwitchProvider) + advanceUntilIdle() + + assertEquals(profile.id, fixture.remote.logoutProfileId) + assertNull(fixture.profiles.activeProfile()) + } + + @Test + fun validateConnectionActionDelegatesWithoutRelogin() = runTest { + val fixture = Fixture() + fixture.activateAuthenticatedProfile() + fixture.remote.setValidationResult( + profile.id, + com.flagship.abox.manager.domain.DomainResult.Success(SessionValidity.INVALID), + ) + val viewModel = SessionActionsViewModel(fixture.coordinator) + + viewModel.onAction(SessionUiAction.ValidateConnection) + advanceUntilIdle() + + assertEquals(SessionValidity.INVALID, fixture.remote.lastValidation) + assertEquals(0, fixture.remote.loginCalls) + } + + @Test + fun mappedActionErrorNeverExposesDomainDetail() = runTest { + val fixture = Fixture() + fixture.activateAuthenticatedProfile() + fixture.remote.logoutError = DomainError.Unknown(detail = "keystore secret detail") + val viewModel = SessionActionsViewModel(fixture.coordinator) + + viewModel.dispatch(SessionUiAction.EndSession) + advanceUntilIdle() + + assertEquals( + UiText.StringResource(R.string.error_unknown), + viewModel.uiState.value.errorMessage, + ) + } + + @Test + fun dismissErrorActionClearsMappedError() = runTest { + val fixture = Fixture() + fixture.activateAuthenticatedProfile() + fixture.remote.logoutError = DomainError.Network(detail = "private network detail") + val viewModel = SessionActionsViewModel(fixture.coordinator) + + viewModel.onAction(SessionUiAction.EndSession) + advanceUntilIdle() + viewModel.onAction(SessionUiAction.DismissError) + + assertNull(viewModel.uiState.value.errorMessage) + } + + private class Fixture { + val profiles = FakeProviderProfileStore() + val credentials = FakeCredentialStore() + val remote = RecordingSessionGateway(profiles) + val local = FakeLocalSessionGateway() + val coordinator = SessionCoordinator(profiles, credentials, remote, local) + + suspend fun activateAuthenticatedProfile() { + profiles.saveProfile( + ProviderProfile( + id = ProviderProfileId("abox"), + type = ProviderType.ABOX, + displayName = "ABox", + ), + ) + profiles.setActiveProfile(ProviderProfileId("abox")) + } + } + + private class RecordingSessionGateway( + private val profiles: FakeProviderProfileStore, + private val delegate: FakeSessionGateway = FakeSessionGateway(), + ) : com.flagship.abox.manager.domain.SessionGateway by delegate { + var loginCalls = 0 + private set + var logoutProfileId: ProviderProfileId? = null + private set + var lastValidation: SessionValidity? = null + private set + var logoutError: DomainError? = null + + override suspend fun login( + profileId: ProviderProfileId, + credentials: LoginCredentials, + ) = delegate.login(profileId, credentials).also { loginCalls++ } + + override suspend fun validateSession(profileId: ProviderProfileId) = delegate + .validateSession(profileId) + .also { result -> + lastValidation = (result as? com.flagship.abox.manager.domain.DomainResult.Success)?.value + } + + override suspend fun logout(profileId: ProviderProfileId) = logoutError?.let { + logoutProfileId = profiles.activeProfile()?.id + com.flagship.abox.manager.domain.DomainResult.Failure(it) + } ?: delegate.logout(profileId).also { + logoutProfileId = profiles.activeProfile()?.id + } + + suspend fun session(profileId: ProviderProfileId) = delegate.observeSession(profileId) + .let { flow -> flow.first() } + + fun setValidationResult( + profileId: ProviderProfileId, + result: com.flagship.abox.manager.domain.DomainResult, + ) { + delegate.setValidationResult(profileId, result) + } + } +} + +private fun com.flagship.abox.manager.domain.DomainResult.successValue(): T? = + (this as? com.flagship.abox.manager.domain.DomainResult.Success)?.value 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 index 4923c23..7ab6494 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/SessionCoordinator.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/SessionCoordinator.kt @@ -147,6 +147,35 @@ class SessionCoordinator( } } + suspend fun endActiveSession(): DomainResult = storeOperation { + val profile = profileStore.activeProfile() ?: return@storeOperation 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 forgetSavedCredentials(): 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) + profileStore.saveLoginPreferences(preferences.copy(autoLoginEnabled = false)) + } + + suspend fun validateActiveSession(): DomainResult = storeOperation { + val profile = profileStore.activeProfile() + ?: return@storeOperation DomainResult.Success(SessionValidity.INVALID) + profile.gateway().validateSession(profile.id) + } + suspend fun logout(): DomainResult = storeOperation { val profile = profileStore.activeProfile() ?: return@storeOperation DomainResult.Success(Unit) when (val credentialResult = credentialStore.clearCredentials(profile.id)) { 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 index 43f291a..953124b 100644 --- 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 @@ -12,6 +12,7 @@ import com.flagship.abox.manager.domain.ProviderType import com.flagship.abox.manager.domain.SessionCoordinator import com.flagship.abox.manager.domain.SessionGateway import com.flagship.abox.manager.domain.Session +import com.flagship.abox.manager.domain.SessionValidity import com.flagship.abox.manager.domain.Username import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest @@ -133,6 +134,101 @@ class SessionCoordinatorTest { ) } + @Test + fun endActiveSessionKeepsAboxCredentials() = runTest { + val fixture = Fixture() + fixture.coordinator.selectProvider(remoteProfile) + fixture.coordinator.login( + remoteProfile.id, + credentials, + rememberUsername = true, + autoLogin = true, + ) + + val result = fixture.coordinator.endActiveSession() + + assertTrue(result is DomainResult.Success) + assertEquals(remoteProfile, fixture.profiles.activeProfile()) + assertNull(fixture.remote.observeSession(remoteProfile.id).first()) + assertEquals( + credentials, + (fixture.credentials.loadCredentials(remoteProfile.id) as DomainResult.Success).value, + ) + assertTrue(fixture.profiles.loginPreferences(remoteProfile.id).autoLoginEnabled) + } + + @Test + fun forgetSavedCredentialsDisablesAutoLoginAndKeepsSessionStateExplicit() = runTest { + val fixture = Fixture() + fixture.coordinator.selectProvider(remoteProfile) + fixture.coordinator.login( + remoteProfile.id, + credentials, + rememberUsername = true, + autoLogin = true, + ) + + val result = fixture.coordinator.forgetSavedCredentials() + + assertTrue(result is DomainResult.Success) + assertEquals(remoteProfile, fixture.profiles.activeProfile()) + assertEquals( + remoteProfile.id, + fixture.remote.observeSession(remoteProfile.id).first()?.profileId, + ) + assertNull( + (fixture.credentials.loadCredentials(remoteProfile.id) as DomainResult.Success).value, + ) + val preferences = fixture.profiles.loginPreferences(remoteProfile.id) + assertEquals(credentials.username, preferences.rememberedUsername) + assertTrue(!preferences.autoLoginEnabled) + } + + @Test + fun switchProviderClearsActiveProfileAfterGatewayLogout() = runTest { + val profiles = FakeProviderProfileStore() + val remote = RecordingSessionGateway(profiles) + val coordinator = SessionCoordinator( + profileStore = profiles, + credentialStore = FakeCredentialStore(), + remoteGateway = remote, + localGateway = FakeLocalSessionGateway(), + ) + coordinator.selectProvider(remoteProfile) + remote.login(remoteProfile.id, credentials) + + val result = coordinator.switchProvider() + + assertTrue(result is DomainResult.Success) + assertEquals(remoteProfile.id, remote.activeProfileAtLogout) + assertNull(profiles.activeProfile()) + } + + @Test + fun validateActiveSessionDoesNotRelogin() = runTest { + val remote = CountingSessionGateway() + val credentialsStore = CountingCredentialStore() + val profiles = FakeProviderProfileStore() + val coordinator = SessionCoordinator( + profileStore = profiles, + credentialStore = credentialsStore, + remoteGateway = remote, + localGateway = FakeLocalSessionGateway(), + ) + profiles.saveProfile(remoteProfile) + profiles.setActiveProfile(remoteProfile.id) + remote.setValidationResult( + remoteProfile.id, + DomainResult.Success(SessionValidity.INVALID), + ) + + val result = coordinator.validateActiveSession() + + assertEquals(DomainResult.Success(SessionValidity.INVALID), result) + assertEquals(0, remote.loginCalls) + assertEquals(0, credentialsStore.loadCalls) + } + @Test fun authenticationFailureDuringRecoveryClearsSavedCredential() = runTest { val fixture = Fixture() @@ -189,6 +285,18 @@ class SessionCoordinatorTest { assertTrue(!fixture.profiles.loginPreferences(remoteProfile.id).autoLoginEnabled) } + @Test + fun endActiveSessionClearsLocalActiveProfile() = runTest { + val fixture = Fixture() + fixture.coordinator.selectProvider(localProfile) + + val result = fixture.coordinator.endActiveSession() + + assertTrue(result is DomainResult.Success) + assertNull(fixture.profiles.activeProfile()) + assertNull(fixture.local.observeSession(localProfile.id).first()) + } + private class Fixture { val profiles = FakeProviderProfileStore() val credentials = FakeCredentialStore() @@ -197,6 +305,19 @@ class SessionCoordinatorTest { val coordinator = SessionCoordinator(profiles, credentials, remote, local) } + private class RecordingSessionGateway( + private val profileStore: FakeProviderProfileStore, + private val delegate: FakeSessionGateway = FakeSessionGateway(), + ) : SessionGateway by delegate { + var activeProfileAtLogout: ProviderProfileId? = null + private set + + override suspend fun logout(profileId: ProviderProfileId): DomainResult { + activeProfileAtLogout = profileStore.activeProfile()?.id + return delegate.logout(profileId) + } + } + private class CountingSessionGateway( private val delegate: FakeSessionGateway = FakeSessionGateway(), ) : SessionGateway by delegate { @@ -214,6 +335,13 @@ class SessionCoordinatorTest { fun failLogin(profileId: ProviderProfileId, error: DomainError) { delegate.failLogin(profileId, error) } + + fun setValidationResult( + profileId: ProviderProfileId, + result: DomainResult, + ) { + delegate.setValidationResult(profileId, result) + } } private class CountingCredentialStore(