Browse Source

Introduce AsyncAction with a Confirmation state and use it for logout action.

pull/2166/head
Benoit Marty 9 months ago
parent
commit
d953c979e1
  1. 5
      features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutState.kt
  2. 17
      features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutPresenter.kt
  3. 5
      features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutState.kt
  4. 12
      features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStateProvider.kt
  5. 22
      features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt
  6. 18
      features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutPresenter.kt
  7. 16
      features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutView.kt
  8. 22
      features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/LogoutActionDialog.kt
  9. 46
      features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt
  10. 47
      features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutPresenterTest.kt
  11. 5
      features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt
  12. 162
      libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/AsyncAction.kt

5
features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutState.kt

@ -16,11 +16,10 @@ @@ -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<String?>,
val logoutAction: AsyncAction<String?>,
val eventSink: (DirectLogoutEvents) -> Unit,
)

17
features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutPresenter.kt

@ -26,6 +26,7 @@ import androidx.compose.runtime.remember @@ -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( @@ -50,8 +51,8 @@ class LogoutPresenter @Inject constructor(
@Composable
override fun present(): LogoutState {
val localCoroutineScope = rememberCoroutineScope()
val logoutAction: MutableState<Async<String?>> = remember {
mutableStateOf(Async.Uninitialized)
val logoutAction: MutableState<AsyncAction<String?>> = remember {
mutableStateOf(AsyncAction.Uninitialized)
}
val secureStorageFlag by featureFlagService.isFeatureEnabledFlow(FeatureFlags.SecureStorage)
@ -66,7 +67,6 @@ class LogoutPresenter @Inject constructor( @@ -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( @@ -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( @@ -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( @@ -121,7 +118,7 @@ class LogoutPresenter @Inject constructor(
}
private fun CoroutineScope.logout(
logoutAction: MutableState<Async<String?>>,
logoutAction: MutableState<AsyncAction<String?>>,
ignoreSdkError: Boolean,
) = launch {
suspend {

5
features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutState.kt

@ -16,7 +16,7 @@ @@ -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( @@ -27,7 +27,6 @@ data class LogoutState(
val doesBackupExistOnServer: Boolean,
val recoveryState: RecoveryState,
val backupUploadState: BackupUploadState,
val showConfirmationDialog: Boolean,
val logoutAction: Async<String?>,
val logoutAction: AsyncAction<String?>,
val eventSink: (LogoutEvents) -> Unit,
)

12
features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStateProvider.kt

@ -17,7 +17,7 @@ @@ -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<LogoutState> { @@ -30,9 +30,9 @@ open class LogoutStateProvider : PreviewParameterProvider<LogoutState> {
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( @@ -47,15 +47,13 @@ fun aLogoutState(
doesBackupExistOnServer: Boolean = true,
recoveryState: RecoveryState = RecoveryState.ENABLED,
backupUploadState: BackupUploadState = BackupUploadState.Unknown,
showConfirmationDialog: Boolean = false,
logoutAction: Async<String?> = Async.Uninitialized,
logoutAction: AsyncAction<String?> = AsyncAction.Uninitialized,
) = LogoutState(
isLastSession = isLastSession,
backupState = backupState,
doesBackupExistOnServer = doesBackupExistOnServer,
recoveryState = recoveryState,
backupUploadState = backupUploadState,
showConfirmationDialog = showConfirmationDialog,
logoutAction = logoutAction,
eventSink = {}
)

22
features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt

@ -32,8 +32,7 @@ import androidx.compose.ui.unit.dp @@ -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( @@ -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( @@ -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()

18
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 @@ -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( @@ -54,8 +54,8 @@ class DefaultDirectLogoutPresenter @Inject constructor(
override fun present(): DirectLogoutState {
val localCoroutineScope = rememberCoroutineScope()
val logoutAction: MutableState<Async<String?>> = remember {
mutableStateOf(Async.Uninitialized)
val logoutAction: MutableState<AsyncAction<String?>> = remember {
mutableStateOf(AsyncAction.Uninitialized)
}
val secureStorageFlag by featureFlagService.isFeatureEnabledFlow(FeatureFlags.SecureStorage)
@ -70,7 +70,6 @@ class DefaultDirectLogoutPresenter @Inject constructor( @@ -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( @@ -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( @@ -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<Async<String?>>,
logoutAction: MutableState<AsyncAction<String?>>,
ignoreSdkError: Boolean,
) = launch {
suspend {

16
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 @@ -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 { @@ -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))
},

22
features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/LogoutActionDialog.kt

@ -20,22 +20,30 @@ import androidx.compose.runtime.Composable @@ -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<String?>,
state: AsyncAction<String?>,
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( @@ -43,9 +51,7 @@ fun LogoutActionDialog(
onRetry = onForceLogoutClicked,
onDismiss = onDismissError,
)
Async.Uninitialized ->
Unit
is Async.Success ->
is AsyncAction.Success ->
LaunchedEffect(state) {
onSuccessLogout(state.data)
}

46
features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt

@ -20,7 +20,7 @@ import app.cash.molecule.RecompositionMode @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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<LogoutState>(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 { @@ -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<LogoutState>(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)
}
}

47
features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutPresenterTest.kt

@ -21,8 +21,7 @@ import app.cash.molecule.moleculeFlow @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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<DirectLogoutState>(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 { @@ -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<DirectLogoutState>(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)
}
}

5
features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt

@ -17,7 +17,7 @@ @@ -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( @@ -39,7 +39,6 @@ fun aPreferencesRootState() = PreferencesRootState(
fun aDirectLogoutState() = DirectLogoutState(
canDoDirectSignOut = true,
showConfirmationDialog = false,
logoutAction = Async.Uninitialized,
logoutAction = AsyncAction.Uninitialized,
eventSink = {},
)

162
libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/AsyncAction.kt

@ -0,0 +1,162 @@ @@ -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<out T> {
/**
* Represents an uninitialized operation (i.e. yet to be run by the user).
*/
data object Uninitialized : AsyncAction<Nothing>
/**
* Represents an operation that is currently waiting for user confirmation.
*/
data object Confirming : AsyncAction<Nothing>
/**
* Represents an operation that is currently ongoing.
*/
data object Loading : AsyncAction<Nothing>
/**
* Represents a failed operation.
*
* @property error the error that caused the operation to fail.
*/
data class Failure(
val error: Throwable,
) : AsyncAction<Nothing>
/**
* 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<out T>(
val data: T,
) : AsyncAction<T>
/**
* 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 <T> MutableState<AsyncAction<T>>.runCatchingUpdatingState(
errorTransform: (Throwable) -> Throwable = { it },
block: () -> T,
): Result<T> = runUpdatingState(
state = this,
errorTransform = errorTransform,
resultBlock = {
runCatching {
block()
}
},
)
suspend inline fun <T> (suspend () -> T).runCatchingUpdatingState(
state: MutableState<AsyncAction<T>>,
errorTransform: (Throwable) -> Throwable = { it },
): Result<T> = runUpdatingState(
state = state,
errorTransform = errorTransform,
resultBlock = {
runCatching {
this()
}
},
)
suspend inline fun <T> MutableState<AsyncAction<T>>.runUpdatingState(
errorTransform: (Throwable) -> Throwable = { it },
resultBlock: () -> Result<T>,
): Result<T> = 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 <T> runUpdatingState(
state: MutableState<AsyncAction<T>>,
errorTransform: (Throwable) -> Throwable = { it },
resultBlock: suspend () -> Result<T>,
): Result<T> {
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)
}
)
}
Loading…
Cancel
Save