From d953c979e104fcb6b536fb134fc29ca2ffb4c6ca Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 4 Jan 2024 15:51:12 +0100 Subject: [PATCH] Introduce AsyncAction with a Confirmation state and use it for logout action. --- .../logout/api/direct/DirectLogoutState.kt | 5 +- .../features/logout/impl/LogoutPresenter.kt | 17 +- .../features/logout/impl/LogoutState.kt | 5 +- .../logout/impl/LogoutStateProvider.kt | 12 +- .../features/logout/impl/LogoutView.kt | 22 +-- .../direct/DefaultDirectLogoutPresenter.kt | 18 +- .../impl/direct/DefaultDirectLogoutView.kt | 16 +- .../logout/impl/ui/LogoutActionDialog.kt | 22 ++- .../logout/impl/LogoutPresenterTest.kt | 46 ++--- .../DefaultDirectLogoutPresenterTest.kt | 47 ++--- .../impl/root/PreferencesRootStateProvider.kt | 5 +- .../libraries/architecture/AsyncAction.kt | 162 ++++++++++++++++++ 12 files changed, 246 insertions(+), 131 deletions(-) create mode 100644 libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/AsyncAction.kt diff --git a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutState.kt b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutState.kt index 71e97a856d..e5b6af99e4 100644 --- a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutState.kt +++ b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutState.kt @@ -16,11 +16,10 @@ package io.element.android.features.logout.api.direct -import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.AsyncAction data class DirectLogoutState( val canDoDirectSignOut: Boolean, - val showConfirmationDialog: Boolean, - val logoutAction: Async, + val logoutAction: AsyncAction, val eventSink: (DirectLogoutEvents) -> Unit, ) diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutPresenter.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutPresenter.kt index b8d8772104..e6a372397c 100644 --- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutPresenter.kt +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutPresenter.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.core.bool.orTrue @@ -50,8 +51,8 @@ class LogoutPresenter @Inject constructor( @Composable override fun present(): LogoutState { val localCoroutineScope = rememberCoroutineScope() - val logoutAction: MutableState> = remember { - mutableStateOf(Async.Uninitialized) + val logoutAction: MutableState> = remember { + mutableStateOf(AsyncAction.Uninitialized) } val secureStorageFlag by featureFlagService.isFeatureEnabledFlow(FeatureFlags.SecureStorage) @@ -66,7 +67,6 @@ class LogoutPresenter @Inject constructor( } .collectAsState(initial = BackupUploadState.Unknown) - var showLogoutDialog by remember { mutableStateOf(false) } var isLastSession by remember { mutableStateOf(false) } LaunchedEffect(Unit) { isLastSession = encryptionService.isLastDevice().getOrNull() ?: false @@ -88,16 +88,14 @@ class LogoutPresenter @Inject constructor( fun handleEvents(event: LogoutEvents) { when (event) { is LogoutEvents.Logout -> { - if (showLogoutDialog || event.ignoreSdkError) { - showLogoutDialog = false + if (logoutAction.value.isConfirming() || event.ignoreSdkError) { localCoroutineScope.logout(logoutAction, event.ignoreSdkError) } else { - showLogoutDialog = true + logoutAction.value = AsyncAction.Confirming } } LogoutEvents.CloseDialogs -> { - logoutAction.value = Async.Uninitialized - showLogoutDialog = false + logoutAction.value = AsyncAction.Uninitialized } } } @@ -108,7 +106,6 @@ class LogoutPresenter @Inject constructor( doesBackupExistOnServer = doesBackupExistOnServerAction.value.dataOrNull().orTrue(), recoveryState = recoveryState, backupUploadState = backupUploadState, - showConfirmationDialog = showLogoutDialog, logoutAction = logoutAction.value, eventSink = ::handleEvents ) @@ -121,7 +118,7 @@ class LogoutPresenter @Inject constructor( } private fun CoroutineScope.logout( - logoutAction: MutableState>, + logoutAction: MutableState>, ignoreSdkError: Boolean, ) = launch { suspend { diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutState.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutState.kt index abade32b96..6da9df8e72 100644 --- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutState.kt +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutState.kt @@ -16,7 +16,7 @@ package io.element.android.features.logout.impl -import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.encryption.BackupState import io.element.android.libraries.matrix.api.encryption.BackupUploadState import io.element.android.libraries.matrix.api.encryption.RecoveryState @@ -27,7 +27,6 @@ data class LogoutState( val doesBackupExistOnServer: Boolean, val recoveryState: RecoveryState, val backupUploadState: BackupUploadState, - val showConfirmationDialog: Boolean, - val logoutAction: Async, + val logoutAction: AsyncAction, val eventSink: (LogoutEvents) -> Unit, ) diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStateProvider.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStateProvider.kt index 8c9482105d..33c60a851f 100644 --- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStateProvider.kt +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStateProvider.kt @@ -17,7 +17,7 @@ package io.element.android.features.logout.impl import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.encryption.BackupState import io.element.android.libraries.matrix.api.encryption.BackupUploadState import io.element.android.libraries.matrix.api.encryption.RecoveryState @@ -30,9 +30,9 @@ open class LogoutStateProvider : PreviewParameterProvider { aLogoutState(isLastSession = true), aLogoutState(isLastSession = false, backupUploadState = BackupUploadState.Uploading(66, 200)), aLogoutState(isLastSession = true, backupUploadState = BackupUploadState.Done), - aLogoutState(showConfirmationDialog = true), - aLogoutState(logoutAction = Async.Loading()), - aLogoutState(logoutAction = Async.Failure(Exception("Failed to logout"))), + aLogoutState(logoutAction = AsyncAction.Confirming), + aLogoutState(logoutAction = AsyncAction.Loading), + aLogoutState(logoutAction = AsyncAction.Failure(Exception("Failed to logout"))), aLogoutState(backupUploadState = BackupUploadState.SteadyException(SteadyStateException.Connection("No network"))), // Last session no recovery aLogoutState(isLastSession = true, recoveryState = RecoveryState.DISABLED), @@ -47,15 +47,13 @@ fun aLogoutState( doesBackupExistOnServer: Boolean = true, recoveryState: RecoveryState = RecoveryState.ENABLED, backupUploadState: BackupUploadState = BackupUploadState.Unknown, - showConfirmationDialog: Boolean = false, - logoutAction: Async = Async.Uninitialized, + logoutAction: AsyncAction = AsyncAction.Uninitialized, ) = LogoutState( isLastSession = isLastSession, backupState = backupState, doesBackupExistOnServer = doesBackupExistOnServer, recoveryState = recoveryState, backupUploadState = backupUploadState, - showConfirmationDialog = showConfirmationDialog, logoutAction = logoutAction, eventSink = {} ) diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt index e025f4825a..ecec90cf06 100644 --- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt @@ -32,8 +32,7 @@ import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.features.logout.impl.tools.isBackingUp import io.element.android.features.logout.impl.ui.LogoutActionDialog -import io.element.android.features.logout.impl.ui.LogoutConfirmationDialog -import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -79,20 +78,11 @@ fun LogoutView( }, ) - // Log out confirmation dialog - if (state.showConfirmationDialog) { - LogoutConfirmationDialog( - onSubmitClicked = { - eventSink(LogoutEvents.Logout(ignoreSdkError = false)) - }, - onDismiss = { - eventSink(LogoutEvents.CloseDialogs) - } - ) - } - LogoutActionDialog( state.logoutAction, + onConfirmClicked = { + eventSink(LogoutEvents.Logout(ignoreSdkError = false)) + }, onForceLogoutClicked = { eventSink(LogoutEvents.Logout(ignoreSdkError = true)) }, @@ -148,13 +138,13 @@ private fun ColumnScope.Buttons( ) } val signOutSubmitRes = when { - logoutAction is Async.Loading -> R.string.screen_signout_in_progress_dialog_content + logoutAction is AsyncAction.Loading -> R.string.screen_signout_in_progress_dialog_content state.backupUploadState.isBackingUp() -> CommonStrings.action_signout_anyway else -> CommonStrings.action_signout } Button( text = stringResource(id = signOutSubmitRes), - showProgress = logoutAction is Async.Loading, + showProgress = logoutAction is AsyncAction.Loading, destructive = true, modifier = Modifier .fillMaxWidth() diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutPresenter.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutPresenter.kt index 70827e8ab0..8ee46b63ac 100644 --- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutPresenter.kt +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutPresenter.kt @@ -30,7 +30,7 @@ import io.element.android.features.logout.api.direct.DirectLogoutEvents import io.element.android.features.logout.api.direct.DirectLogoutPresenter import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.features.logout.impl.tools.isBackingUp -import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.di.SessionScope import io.element.android.libraries.featureflag.api.FeatureFlagService @@ -54,8 +54,8 @@ class DefaultDirectLogoutPresenter @Inject constructor( override fun present(): DirectLogoutState { val localCoroutineScope = rememberCoroutineScope() - val logoutAction: MutableState> = remember { - mutableStateOf(Async.Uninitialized) + val logoutAction: MutableState> = remember { + mutableStateOf(AsyncAction.Uninitialized) } val secureStorageFlag by featureFlagService.isFeatureEnabledFlow(FeatureFlags.SecureStorage) @@ -70,7 +70,6 @@ class DefaultDirectLogoutPresenter @Inject constructor( } .collectAsState(initial = BackupUploadState.Unknown) - var showLogoutDialog by remember { mutableStateOf(false) } var isLastSession by remember { mutableStateOf(false) } LaunchedEffect(Unit) { isLastSession = encryptionService.isLastDevice().getOrNull() ?: false @@ -79,16 +78,14 @@ class DefaultDirectLogoutPresenter @Inject constructor( fun handleEvents(event: DirectLogoutEvents) { when (event) { is DirectLogoutEvents.Logout -> { - if (showLogoutDialog || event.ignoreSdkError) { - showLogoutDialog = false + if (logoutAction.value.isConfirming() || event.ignoreSdkError) { localCoroutineScope.logout(logoutAction, event.ignoreSdkError) } else { - showLogoutDialog = true + logoutAction.value = AsyncAction.Confirming } } DirectLogoutEvents.CloseDialogs -> { - logoutAction.value = Async.Uninitialized - showLogoutDialog = false + logoutAction.value = AsyncAction.Uninitialized } } } @@ -96,14 +93,13 @@ class DefaultDirectLogoutPresenter @Inject constructor( return DirectLogoutState( canDoDirectSignOut = !isLastSession && !backupUploadState.isBackingUp(), - showConfirmationDialog = showLogoutDialog, logoutAction = logoutAction.value, eventSink = ::handleEvents ) } private fun CoroutineScope.logout( - logoutAction: MutableState>, + logoutAction: MutableState>, ignoreSdkError: Boolean, ) = launch { suspend { diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutView.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutView.kt index bd00975864..f646d86c9e 100644 --- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutView.kt +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutView.kt @@ -22,7 +22,6 @@ import io.element.android.features.logout.api.direct.DirectLogoutEvents import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.features.logout.api.direct.DirectLogoutView import io.element.android.features.logout.impl.ui.LogoutActionDialog -import io.element.android.features.logout.impl.ui.LogoutConfirmationDialog import io.element.android.libraries.di.SessionScope import javax.inject.Inject @@ -34,20 +33,11 @@ class DefaultDirectLogoutView @Inject constructor() : DirectLogoutView { onSuccessLogout: (logoutUrlResult: String?) -> Unit, ) { val eventSink = state.eventSink - // Log out confirmation dialog - if (state.showConfirmationDialog) { - LogoutConfirmationDialog( - onSubmitClicked = { - eventSink(DirectLogoutEvents.Logout(ignoreSdkError = false)) - }, - onDismiss = { - eventSink(DirectLogoutEvents.CloseDialogs) - } - ) - } - LogoutActionDialog( state.logoutAction, + onConfirmClicked = { + eventSink(DirectLogoutEvents.Logout(ignoreSdkError = false)) + }, onForceLogoutClicked = { eventSink(DirectLogoutEvents.Logout(ignoreSdkError = true)) }, diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/LogoutActionDialog.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/LogoutActionDialog.kt index 9baad1d1f3..6a1891fd23 100644 --- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/LogoutActionDialog.kt +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/LogoutActionDialog.kt @@ -20,22 +20,30 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.res.stringResource import io.element.android.features.logout.impl.R -import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.dialogs.RetryDialog import io.element.android.libraries.ui.strings.CommonStrings @Composable fun LogoutActionDialog( - state: Async, + state: AsyncAction, + onConfirmClicked: () -> Unit, onForceLogoutClicked: () -> Unit, - onDismissError: () -> Unit, + onDismissError: () -> Unit, // TODO Rename onSuccessLogout: (String?) -> Unit, ) { when (state) { - is Async.Loading -> + AsyncAction.Uninitialized -> + Unit + AsyncAction.Confirming -> + LogoutConfirmationDialog( + onSubmitClicked = onConfirmClicked, + onDismiss = onDismissError + ) + is AsyncAction.Loading -> ProgressDialog(text = stringResource(id = R.string.screen_signout_in_progress_dialog_content)) - is Async.Failure -> + is AsyncAction.Failure -> RetryDialog( title = stringResource(id = CommonStrings.dialog_title_error), content = stringResource(id = CommonStrings.error_unknown), @@ -43,9 +51,7 @@ fun LogoutActionDialog( onRetry = onForceLogoutClicked, onDismiss = onDismissError, ) - Async.Uninitialized -> - Unit - is Async.Success -> + is AsyncAction.Success -> LaunchedEffect(state) { onSuccessLogout(state.data) } diff --git a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt index c486e76312..89db94cf1d 100644 --- a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt +++ b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt @@ -20,7 +20,7 @@ import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat -import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.MatrixClient @@ -56,8 +56,7 @@ class LogoutPresenterTest { assertThat(initialState.doesBackupExistOnServer).isTrue() assertThat(initialState.recoveryState).isEqualTo(RecoveryState.UNKNOWN) assertThat(initialState.backupUploadState).isEqualTo(BackupUploadState.Unknown) - assertThat(initialState.showConfirmationDialog).isFalse() - assertThat(initialState.logoutAction).isEqualTo(Async.Uninitialized) + assertThat(initialState.logoutAction).isEqualTo(AsyncAction.Uninitialized) } } @@ -75,8 +74,7 @@ class LogoutPresenterTest { val initialState = awaitItem() assertThat(initialState.isLastSession).isTrue() assertThat(initialState.backupUploadState).isEqualTo(BackupUploadState.Unknown) - assertThat(initialState.showConfirmationDialog).isFalse() - assertThat(initialState.logoutAction).isEqualTo(Async.Uninitialized) + assertThat(initialState.logoutAction).isEqualTo(AsyncAction.Uninitialized) } } @@ -101,8 +99,7 @@ class LogoutPresenterTest { val initialState = awaitItem() assertThat(initialState.isLastSession).isFalse() assertThat(initialState.backupUploadState).isEqualTo(BackupUploadState.Unknown) - assertThat(initialState.showConfirmationDialog).isFalse() - assertThat(initialState.logoutAction).isEqualTo(Async.Uninitialized) + assertThat(initialState.logoutAction).isEqualTo(AsyncAction.Uninitialized) skipItems(1) val waitingState = awaitItem() assertThat(waitingState.backupUploadState).isEqualTo(BackupUploadState.Waiting) @@ -123,10 +120,10 @@ class LogoutPresenterTest { val initialState = awaitLastSequentialItem() initialState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false)) val confirmationState = awaitItem() - assertThat(confirmationState.showConfirmationDialog).isTrue() + assertThat(confirmationState.logoutAction).isEqualTo(AsyncAction.Confirming) initialState.eventSink.invoke(LogoutEvents.CloseDialogs) val finalState = awaitItem() - assertThat(finalState.showConfirmationDialog).isFalse() + assertThat(finalState.logoutAction).isEqualTo(AsyncAction.Uninitialized) } } @@ -139,14 +136,12 @@ class LogoutPresenterTest { val initialState = awaitLastSequentialItem() initialState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false)) val confirmationState = awaitItem() - assertThat(confirmationState.showConfirmationDialog).isTrue() + assertThat(confirmationState.logoutAction).isEqualTo(AsyncAction.Confirming) confirmationState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false)) - skipItems(1) val loadingState = awaitItem() - assertThat(loadingState.showConfirmationDialog).isFalse() - assertThat(loadingState.logoutAction).isInstanceOf(Async.Loading::class.java) + assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java) val successState = awaitItem() - assertThat(successState.logoutAction).isInstanceOf(Async.Success::class.java) + assertThat(successState.logoutAction).isInstanceOf(AsyncAction.Success::class.java) } } @@ -165,18 +160,16 @@ class LogoutPresenterTest { val initialState = awaitItem() initialState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false)) val confirmationState = awaitItem() - assertThat(confirmationState.showConfirmationDialog).isTrue() + assertThat(confirmationState.logoutAction).isEqualTo(AsyncAction.Confirming) confirmationState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false)) - skipItems(1) val loadingState = awaitItem() - assertThat(loadingState.showConfirmationDialog).isFalse() - assertThat(loadingState.logoutAction).isInstanceOf(Async.Loading::class.java) + assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java) skipItems(1) val errorState = awaitItem() - assertThat(errorState.logoutAction).isEqualTo(Async.Failure(A_THROWABLE)) + assertThat(errorState.logoutAction).isEqualTo(AsyncAction.Failure(A_THROWABLE)) errorState.eventSink.invoke(LogoutEvents.CloseDialogs) val finalState = awaitItem() - assertThat(finalState.logoutAction).isEqualTo(Async.Uninitialized) + assertThat(finalState.logoutAction).isEqualTo(AsyncAction.Uninitialized) } } @@ -195,21 +188,18 @@ class LogoutPresenterTest { val initialState = awaitItem() initialState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false)) val confirmationState = awaitItem() - assertThat(confirmationState.showConfirmationDialog).isTrue() + assertThat(confirmationState.logoutAction).isEqualTo(AsyncAction.Confirming) confirmationState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false)) - skipItems(1) val loadingState = awaitItem() - assertThat(loadingState.showConfirmationDialog).isFalse() - assertThat(loadingState.logoutAction).isInstanceOf(Async.Loading::class.java) + assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java) skipItems(1) val errorState = awaitItem() - assertThat(errorState.logoutAction).isEqualTo(Async.Failure(A_THROWABLE)) + assertThat(errorState.logoutAction).isEqualTo(AsyncAction.Failure(A_THROWABLE)) errorState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = true)) val loadingState2 = awaitItem() - assertThat(loadingState2.showConfirmationDialog).isFalse() - assertThat(loadingState2.logoutAction).isInstanceOf(Async.Loading::class.java) + assertThat(loadingState2.logoutAction).isInstanceOf(AsyncAction.Loading::class.java) val successState = awaitItem() - assertThat(successState.logoutAction).isInstanceOf(Async.Success::class.java) + assertThat(successState.logoutAction).isInstanceOf(AsyncAction.Success::class.java) } } diff --git a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutPresenterTest.kt b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutPresenterTest.kt index 5341a8c19e..b2cef2f28d 100644 --- a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutPresenterTest.kt +++ b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutPresenterTest.kt @@ -21,8 +21,7 @@ import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.features.logout.api.direct.DirectLogoutEvents -import io.element.android.features.logout.api.direct.DirectLogoutState -import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.MatrixClient @@ -51,8 +50,7 @@ class DefaultDirectLogoutPresenterTest { }.test { val initialState = awaitLastSequentialItem() assertThat(initialState.canDoDirectSignOut).isTrue() - assertThat(initialState.showConfirmationDialog).isFalse() - assertThat(initialState.logoutAction).isEqualTo(Async.Uninitialized) + assertThat(initialState.logoutAction).isEqualTo(AsyncAction.Uninitialized) } } @@ -69,8 +67,7 @@ class DefaultDirectLogoutPresenterTest { skipItems(2) val initialState = awaitItem() assertThat(initialState.canDoDirectSignOut).isFalse() - assertThat(initialState.showConfirmationDialog).isFalse() - assertThat(initialState.logoutAction).isEqualTo(Async.Uninitialized) + assertThat(initialState.logoutAction).isEqualTo(AsyncAction.Uninitialized) } } @@ -91,8 +88,7 @@ class DefaultDirectLogoutPresenterTest { skipItems(2) val initialState = awaitItem() assertThat(initialState.canDoDirectSignOut).isFalse() - assertThat(initialState.showConfirmationDialog).isFalse() - assertThat(initialState.logoutAction).isEqualTo(Async.Uninitialized) + assertThat(initialState.logoutAction).isEqualTo(AsyncAction.Uninitialized) } } @@ -105,10 +101,10 @@ class DefaultDirectLogoutPresenterTest { val initialState = awaitLastSequentialItem() initialState.eventSink.invoke(DirectLogoutEvents.Logout(ignoreSdkError = false)) val confirmationState = awaitItem() - assertThat(confirmationState.showConfirmationDialog).isTrue() + assertThat(confirmationState.logoutAction).isEqualTo(AsyncAction.Confirming) initialState.eventSink.invoke(DirectLogoutEvents.CloseDialogs) val finalState = awaitItem() - assertThat(finalState.showConfirmationDialog).isFalse() + assertThat(finalState.logoutAction).isEqualTo(AsyncAction.Uninitialized) } } @@ -121,14 +117,12 @@ class DefaultDirectLogoutPresenterTest { val initialState = awaitLastSequentialItem() initialState.eventSink.invoke(DirectLogoutEvents.Logout(ignoreSdkError = false)) val confirmationState = awaitItem() - assertThat(confirmationState.showConfirmationDialog).isTrue() + assertThat(confirmationState.logoutAction).isEqualTo(AsyncAction.Confirming) confirmationState.eventSink.invoke(DirectLogoutEvents.Logout(ignoreSdkError = false)) - skipItems(1) val loadingState = awaitItem() - assertThat(loadingState.showConfirmationDialog).isFalse() - assertThat(loadingState.logoutAction).isInstanceOf(Async.Loading::class.java) + assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java) val successState = awaitItem() - assertThat(successState.logoutAction).isInstanceOf(Async.Success::class.java) + assertThat(successState.logoutAction).isInstanceOf(AsyncAction.Success::class.java) } } @@ -147,17 +141,15 @@ class DefaultDirectLogoutPresenterTest { val initialState = awaitItem() initialState.eventSink.invoke(DirectLogoutEvents.Logout(ignoreSdkError = false)) val confirmationState = awaitItem() - assertThat(confirmationState.showConfirmationDialog).isTrue() + assertThat(confirmationState.logoutAction).isEqualTo(AsyncAction.Confirming) confirmationState.eventSink.invoke(DirectLogoutEvents.Logout(ignoreSdkError = false)) - skipItems(1) val loadingState = awaitItem() - assertThat(loadingState.showConfirmationDialog).isFalse() - assertThat(loadingState.logoutAction).isInstanceOf(Async.Loading::class.java) + assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java) val errorState = awaitItem() - assertThat(errorState.logoutAction).isEqualTo(Async.Failure(A_THROWABLE)) + assertThat(errorState.logoutAction).isEqualTo(AsyncAction.Failure(A_THROWABLE)) errorState.eventSink.invoke(DirectLogoutEvents.CloseDialogs) val finalState = awaitItem() - assertThat(finalState.logoutAction).isEqualTo(Async.Uninitialized) + assertThat(finalState.logoutAction).isEqualTo(AsyncAction.Uninitialized) } } @@ -176,20 +168,17 @@ class DefaultDirectLogoutPresenterTest { val initialState = awaitItem() initialState.eventSink.invoke(DirectLogoutEvents.Logout(ignoreSdkError = false)) val confirmationState = awaitItem() - assertThat(confirmationState.showConfirmationDialog).isTrue() + assertThat(confirmationState.logoutAction).isEqualTo(AsyncAction.Confirming) confirmationState.eventSink.invoke(DirectLogoutEvents.Logout(ignoreSdkError = false)) - skipItems(1) val loadingState = awaitItem() - assertThat(loadingState.showConfirmationDialog).isFalse() - assertThat(loadingState.logoutAction).isInstanceOf(Async.Loading::class.java) + assertThat(loadingState.logoutAction).isInstanceOf(AsyncAction.Loading::class.java) val errorState = awaitItem() - assertThat(errorState.logoutAction).isEqualTo(Async.Failure(A_THROWABLE)) + assertThat(errorState.logoutAction).isEqualTo(AsyncAction.Failure(A_THROWABLE)) errorState.eventSink.invoke(DirectLogoutEvents.Logout(ignoreSdkError = true)) val loadingState2 = awaitItem() - assertThat(loadingState2.showConfirmationDialog).isFalse() - assertThat(loadingState2.logoutAction).isInstanceOf(Async.Loading::class.java) + assertThat(loadingState2.logoutAction).isInstanceOf(AsyncAction.Loading::class.java) val successState = awaitItem() - assertThat(successState.logoutAction).isInstanceOf(Async.Success::class.java) + assertThat(successState.logoutAction).isInstanceOf(AsyncAction.Success::class.java) } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt index db8c5b97b9..b99c197d69 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt @@ -17,7 +17,7 @@ package io.element.android.features.preferences.impl.root import io.element.android.features.logout.api.direct.DirectLogoutState -import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.ui.strings.CommonStrings @@ -39,7 +39,6 @@ fun aPreferencesRootState() = PreferencesRootState( fun aDirectLogoutState() = DirectLogoutState( canDoDirectSignOut = true, - showConfirmationDialog = false, - logoutAction = Async.Uninitialized, + logoutAction = AsyncAction.Uninitialized, eventSink = {}, ) diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/AsyncAction.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/AsyncAction.kt new file mode 100644 index 0000000000..ef64c973df --- /dev/null +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/AsyncAction.kt @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.architecture + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.Stable +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract + +/** + * Sealed type that allows to model an asynchronous operation triggered by the user. + */ +@Stable +sealed interface AsyncAction { + + /** + * Represents an uninitialized operation (i.e. yet to be run by the user). + */ + data object Uninitialized : AsyncAction + + /** + * Represents an operation that is currently waiting for user confirmation. + */ + data object Confirming : AsyncAction + + /** + * Represents an operation that is currently ongoing. + */ + data object Loading : AsyncAction + + /** + * Represents a failed operation. + * + * @property error the error that caused the operation to fail. + */ + data class Failure( + val error: Throwable, + ) : AsyncAction + + /** + * Represents a successful operation. + * + * @param T the type of data returned by the operation. + * @property data the data returned by the operation. + */ + data class Success( + val data: T, + ) : AsyncAction + + /** + * Returns the data returned by the operation, or null otherwise. + */ + fun dataOrNull(): T? = when (this) { + is Success -> data + else -> null + } + + /** + * Returns the error that caused the operation to fail, or null otherwise. + */ + fun errorOrNull(): Throwable? = when (this) { + is Failure -> error + else -> null + } + + fun isUninitialized(): Boolean = this == Uninitialized + + fun isConfirming(): Boolean = this is Confirming + + fun isLoading(): Boolean = this is Loading + + fun isFailure(): Boolean = this is Failure + + fun isSuccess(): Boolean = this is Success +} + +suspend inline fun MutableState>.runCatchingUpdatingState( + errorTransform: (Throwable) -> Throwable = { it }, + block: () -> T, +): Result = runUpdatingState( + state = this, + errorTransform = errorTransform, + resultBlock = { + runCatching { + block() + } + }, +) + +suspend inline fun (suspend () -> T).runCatchingUpdatingState( + state: MutableState>, + errorTransform: (Throwable) -> Throwable = { it }, +): Result = runUpdatingState( + state = state, + errorTransform = errorTransform, + resultBlock = { + runCatching { + this() + } + }, +) + +suspend inline fun MutableState>.runUpdatingState( + errorTransform: (Throwable) -> Throwable = { it }, + resultBlock: () -> Result, +): Result = runUpdatingState( + state = this, + errorTransform = errorTransform, + resultBlock = resultBlock, +) + +/** + * Calls the specified [Result]-returning function [resultBlock] + * encapsulating its progress and return value into an [AsyncAction] while + * posting its updates to the MutableState [state]. + * + * @param T the type of data returned by the operation. + * @param state the [MutableState] to post updates to. + * @param errorTransform a function to transform the error before posting it. + * @param resultBlock a suspending function that returns a [Result]. + * @return the [Result] returned by [resultBlock]. + */ +@OptIn(ExperimentalContracts::class) +@Suppress("REDUNDANT_INLINE_SUSPEND_FUNCTION_TYPE") +suspend inline fun runUpdatingState( + state: MutableState>, + errorTransform: (Throwable) -> Throwable = { it }, + resultBlock: suspend () -> Result, +): Result { + contract { + callsInPlace(resultBlock, InvocationKind.EXACTLY_ONCE) + } + state.value = AsyncAction.Loading + return resultBlock().fold( + onSuccess = { + state.value = AsyncAction.Success(it) + Result.success(it) + }, + onFailure = { + val error = errorTransform(it) + state.value = AsyncAction.Failure( + error = error, + ) + Result.failure(error) + } + ) +}