feat: separate session and credential actions
This commit is contained in:
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+1
-8
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+39
-3
@@ -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<SessionActionsUiState> = _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
|
||||
}
|
||||
|
||||
+197
@@ -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<SessionValidity>,
|
||||
) {
|
||||
delegate.setValidationResult(profileId, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> com.flagship.abox.manager.domain.DomainResult<T>.successValue(): T? =
|
||||
(this as? com.flagship.abox.manager.domain.DomainResult.Success)?.value
|
||||
Reference in New Issue
Block a user