diff --git a/.maestro/tests/account/logout.yaml b/.maestro/tests/account/logout.yaml index f10d5ef364..3019f1d2c3 100644 --- a/.maestro/tests/account/logout.yaml +++ b/.maestro/tests/account/logout.yaml @@ -6,12 +6,10 @@ appId: ${APP_ID} - takeScreenshot: build/maestro/900-SignOutScreen - back - tapOn: "Sign out" -- tapOn: - id: "sign-out-submit" # Ensure cancel cancels -- tapOn: "Cancel" - tapOn: - id: "sign-out-submit" + id: "dialog-negative" +- tapOn: "Sign out" - tapOn: id: "dialog-positive" - runFlow: ../assertions/assertInitDisplayed.yaml diff --git a/changelog.d/2072.misc b/changelog.d/2072.misc new file mode 100644 index 0000000000..7ae9d0be44 --- /dev/null +++ b/changelog.d/2072.misc @@ -0,0 +1 @@ + Remove extra logout screen. diff --git a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutEvents.kt b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutEvents.kt new file mode 100644 index 0000000000..ab5f40c321 --- /dev/null +++ b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutEvents.kt @@ -0,0 +1,22 @@ +/* + * 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.features.logout.api.direct + +sealed interface DirectLogoutEvents { + data class Logout(val ignoreSdkError: Boolean) : DirectLogoutEvents + data object CloseDialogs : DirectLogoutEvents +} diff --git a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutPresenter.kt b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutPresenter.kt new file mode 100644 index 0000000000..bdd501cde6 --- /dev/null +++ b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutPresenter.kt @@ -0,0 +1,21 @@ +/* + * 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.features.logout.api.direct + +import io.element.android.libraries.architecture.Presenter + +interface DirectLogoutPresenter : Presenter 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 new file mode 100644 index 0000000000..71e97a856d --- /dev/null +++ b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutState.kt @@ -0,0 +1,26 @@ +/* + * 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.features.logout.api.direct + +import io.element.android.libraries.architecture.Async + +data class DirectLogoutState( + val canDoDirectSignOut: Boolean, + val showConfirmationDialog: Boolean, + val logoutAction: Async, + val eventSink: (DirectLogoutEvents) -> Unit, +) diff --git a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutView.kt b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutView.kt new file mode 100644 index 0000000000..0fd4c04cf2 --- /dev/null +++ b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/direct/DirectLogoutView.kt @@ -0,0 +1,27 @@ +/* + * 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.features.logout.api.direct + +import androidx.compose.runtime.Composable + +interface DirectLogoutView { + @Composable + fun Render( + state: DirectLogoutState, + onSuccessLogout: (logoutUrlResult: String?) -> Unit + ) +} 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 d3d4805f82..e025f4825a 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 @@ -22,7 +22,6 @@ import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector @@ -31,10 +30,11 @@ import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.PreviewParameter 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.designsystem.atomic.pages.FlowStepPage -import io.element.android.libraries.designsystem.components.ProgressDialog -import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Button @@ -81,10 +81,7 @@ fun LogoutView( // Log out confirmation dialog if (state.showConfirmationDialog) { - ConfirmationDialog( - title = stringResource(id = CommonStrings.action_signout), - content = stringResource(id = R.string.screen_signout_confirmation_dialog_content), - submitText = stringResource(id = CommonStrings.action_signout), + LogoutConfirmationDialog( onSubmitClicked = { eventSink(LogoutEvents.Logout(ignoreSdkError = false)) }, @@ -94,28 +91,18 @@ fun LogoutView( ) } - when (state.logoutAction) { - is Async.Loading -> - ProgressDialog(text = stringResource(id = R.string.screen_signout_in_progress_dialog_content)) - is Async.Failure -> - ConfirmationDialog( - title = stringResource(id = CommonStrings.dialog_title_error), - content = stringResource(id = CommonStrings.error_unknown), - submitText = stringResource(id = CommonStrings.action_signout_anyway), - onSubmitClicked = { - eventSink(LogoutEvents.Logout(ignoreSdkError = true)) - }, - onDismiss = { - eventSink(LogoutEvents.CloseDialogs) - } - ) - Async.Uninitialized -> - Unit - is Async.Success -> - LaunchedEffect(state.logoutAction) { - onSuccessLogout(state.logoutAction.data) - } - } + LogoutActionDialog( + state.logoutAction, + onForceLogoutClicked = { + eventSink(LogoutEvents.Logout(ignoreSdkError = true)) + }, + onDismissError = { + eventSink(LogoutEvents.CloseDialogs) + }, + onSuccessLogout = { + onSuccessLogout(it) + }, + ) } @Composable @@ -146,17 +133,6 @@ private fun subtitle(state: LogoutState): String? { } } -private fun BackupUploadState.isBackingUp(): Boolean { - return when (this) { - BackupUploadState.Waiting, - is BackupUploadState.Uploading -> true - is BackupUploadState.SteadyException -> exception is SteadyStateException.Connection - BackupUploadState.Unknown, - BackupUploadState.Done, - BackupUploadState.Error -> false - } -} - @Composable private fun ColumnScope.Buttons( state: LogoutState, 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 new file mode 100644 index 0000000000..70827e8ab0 --- /dev/null +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutPresenter.kt @@ -0,0 +1,113 @@ +/* + * 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.features.logout.impl.direct + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import com.squareup.anvil.annotations.ContributesBinding +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.runCatchingUpdatingState +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.encryption.BackupUploadState +import io.element.android.libraries.matrix.api.encryption.EncryptionService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import javax.inject.Inject + +@ContributesBinding(SessionScope::class) +class DefaultDirectLogoutPresenter @Inject constructor( + private val matrixClient: MatrixClient, + private val encryptionService: EncryptionService, + private val featureFlagService: FeatureFlagService, +) : DirectLogoutPresenter { + @Composable + override fun present(): DirectLogoutState { + val localCoroutineScope = rememberCoroutineScope() + + val logoutAction: MutableState> = remember { + mutableStateOf(Async.Uninitialized) + } + + val secureStorageFlag by featureFlagService.isFeatureEnabledFlow(FeatureFlags.SecureStorage) + .collectAsState(initial = null) + + val backupUploadState: BackupUploadState by remember(secureStorageFlag) { + when (secureStorageFlag) { + true -> encryptionService.waitForBackupUploadSteadyState() + false -> flowOf(BackupUploadState.Done) + else -> emptyFlow() + } + } + .collectAsState(initial = BackupUploadState.Unknown) + + var showLogoutDialog by remember { mutableStateOf(false) } + var isLastSession by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + isLastSession = encryptionService.isLastDevice().getOrNull() ?: false + } + + fun handleEvents(event: DirectLogoutEvents) { + when (event) { + is DirectLogoutEvents.Logout -> { + if (showLogoutDialog || event.ignoreSdkError) { + showLogoutDialog = false + localCoroutineScope.logout(logoutAction, event.ignoreSdkError) + } else { + showLogoutDialog = true + } + } + DirectLogoutEvents.CloseDialogs -> { + logoutAction.value = Async.Uninitialized + showLogoutDialog = false + } + } + } + + return DirectLogoutState( + canDoDirectSignOut = !isLastSession && + !backupUploadState.isBackingUp(), + showConfirmationDialog = showLogoutDialog, + logoutAction = logoutAction.value, + eventSink = ::handleEvents + ) + } + + private fun CoroutineScope.logout( + logoutAction: MutableState>, + ignoreSdkError: Boolean, + ) = launch { + suspend { + matrixClient.logout(ignoreSdkError) + }.runCatchingUpdatingState(logoutAction) + } +} 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 new file mode 100644 index 0000000000..bd00975864 --- /dev/null +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutView.kt @@ -0,0 +1,62 @@ +/* + * 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.features.logout.impl.direct + +import androidx.compose.runtime.Composable +import com.squareup.anvil.annotations.ContributesBinding +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 + +@ContributesBinding(SessionScope::class) +class DefaultDirectLogoutView @Inject constructor() : DirectLogoutView { + @Composable + override fun Render( + state: DirectLogoutState, + 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, + onForceLogoutClicked = { + eventSink(DirectLogoutEvents.Logout(ignoreSdkError = true)) + }, + onDismissError = { + eventSink(DirectLogoutEvents.CloseDialogs) + }, + onSuccessLogout = { + onSuccessLogout(it) + }, + ) + } +} diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/tools/Extensions.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/tools/Extensions.kt new file mode 100644 index 0000000000..5d9858782d --- /dev/null +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/tools/Extensions.kt @@ -0,0 +1,32 @@ +/* + * 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.features.logout.impl.tools + +import io.element.android.libraries.matrix.api.encryption.BackupUploadState +import io.element.android.libraries.matrix.api.encryption.SteadyStateException + +internal fun BackupUploadState.isBackingUp(): Boolean { + return when (this) { + BackupUploadState.Waiting, + is BackupUploadState.Uploading -> true + // The backup is in progress, but there have been a network issue, so we have to warn the user. + is BackupUploadState.SteadyException -> exception is SteadyStateException.Connection + BackupUploadState.Unknown, + BackupUploadState.Done, + BackupUploadState.Error -> false + } +} 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 new file mode 100644 index 0000000000..9baad1d1f3 --- /dev/null +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/LogoutActionDialog.kt @@ -0,0 +1,53 @@ +/* + * 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.features.logout.impl.ui + +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.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, + onForceLogoutClicked: () -> Unit, + onDismissError: () -> Unit, + onSuccessLogout: (String?) -> Unit, +) { + when (state) { + is Async.Loading -> + ProgressDialog(text = stringResource(id = R.string.screen_signout_in_progress_dialog_content)) + is Async.Failure -> + RetryDialog( + title = stringResource(id = CommonStrings.dialog_title_error), + content = stringResource(id = CommonStrings.error_unknown), + retryText = stringResource(id = CommonStrings.action_signout_anyway), + onRetry = onForceLogoutClicked, + onDismiss = onDismissError, + ) + Async.Uninitialized -> + Unit + is Async.Success -> + LaunchedEffect(state) { + onSuccessLogout(state.data) + } + } +} diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/LogoutConfirmationDialog.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/LogoutConfirmationDialog.kt new file mode 100644 index 0000000000..caf04a2752 --- /dev/null +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/LogoutConfirmationDialog.kt @@ -0,0 +1,37 @@ +/* + * 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.features.logout.impl.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import io.element.android.features.logout.impl.R +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun LogoutConfirmationDialog( + onSubmitClicked: () -> Unit, + onDismiss: () -> Unit, +) { + ConfirmationDialog( + title = stringResource(id = CommonStrings.action_signout), + content = stringResource(id = R.string.screen_signout_confirmation_dialog_content), + submitText = stringResource(id = CommonStrings.action_signout), + onSubmitClicked = onSubmitClicked, + onDismiss = onDismiss, + ) +} 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 new file mode 100644 index 0000000000..5341a8c19e --- /dev/null +++ b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutPresenterTest.kt @@ -0,0 +1,205 @@ +/* + * 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.features.logout.impl.direct + +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.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.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.encryption.BackupUploadState +import io.element.android.libraries.matrix.api.encryption.EncryptionService +import io.element.android.libraries.matrix.test.A_THROWABLE +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.awaitLastSequentialItem +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class DefaultDirectLogoutPresenterTest { + + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = createDefaultDirectLogoutPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitLastSequentialItem() + assertThat(initialState.canDoDirectSignOut).isTrue() + assertThat(initialState.showConfirmationDialog).isFalse() + assertThat(initialState.logoutAction).isEqualTo(Async.Uninitialized) + } + } + + @Test + fun `present - initial state - last session`() = runTest { + val presenter = createDefaultDirectLogoutPresenter( + encryptionService = FakeEncryptionService().apply { + givenIsLastDevice(true) + } + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(2) + val initialState = awaitItem() + assertThat(initialState.canDoDirectSignOut).isFalse() + assertThat(initialState.showConfirmationDialog).isFalse() + assertThat(initialState.logoutAction).isEqualTo(Async.Uninitialized) + } + } + + @Test + fun `present - initial state - backing up`() = runTest { + val encryptionService = FakeEncryptionService() + encryptionService.givenWaitForBackupUploadSteadyStateFlow( + flow { + emit(BackupUploadState.Waiting) + } + ) + val presenter = createDefaultDirectLogoutPresenter( + encryptionService = encryptionService + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(2) + val initialState = awaitItem() + assertThat(initialState.canDoDirectSignOut).isFalse() + assertThat(initialState.showConfirmationDialog).isFalse() + assertThat(initialState.logoutAction).isEqualTo(Async.Uninitialized) + } + } + + @Test + fun `present - logout then cancel`() = runTest { + val presenter = createDefaultDirectLogoutPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitLastSequentialItem() + initialState.eventSink.invoke(DirectLogoutEvents.Logout(ignoreSdkError = false)) + val confirmationState = awaitItem() + assertThat(confirmationState.showConfirmationDialog).isTrue() + initialState.eventSink.invoke(DirectLogoutEvents.CloseDialogs) + val finalState = awaitItem() + assertThat(finalState.showConfirmationDialog).isFalse() + } + } + + @Test + fun `present - logout then confirm`() = runTest { + val presenter = createDefaultDirectLogoutPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitLastSequentialItem() + initialState.eventSink.invoke(DirectLogoutEvents.Logout(ignoreSdkError = false)) + val confirmationState = awaitItem() + assertThat(confirmationState.showConfirmationDialog).isTrue() + confirmationState.eventSink.invoke(DirectLogoutEvents.Logout(ignoreSdkError = false)) + skipItems(1) + val loadingState = awaitItem() + assertThat(loadingState.showConfirmationDialog).isFalse() + assertThat(loadingState.logoutAction).isInstanceOf(Async.Loading::class.java) + val successState = awaitItem() + assertThat(successState.logoutAction).isInstanceOf(Async.Success::class.java) + } + } + + @Test + fun `present - logout with error then cancel`() = runTest { + val matrixClient = FakeMatrixClient().apply { + givenLogoutError(A_THROWABLE) + } + val presenter = createDefaultDirectLogoutPresenter( + matrixClient, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink.invoke(DirectLogoutEvents.Logout(ignoreSdkError = false)) + val confirmationState = awaitItem() + assertThat(confirmationState.showConfirmationDialog).isTrue() + confirmationState.eventSink.invoke(DirectLogoutEvents.Logout(ignoreSdkError = false)) + skipItems(1) + val loadingState = awaitItem() + assertThat(loadingState.showConfirmationDialog).isFalse() + assertThat(loadingState.logoutAction).isInstanceOf(Async.Loading::class.java) + val errorState = awaitItem() + assertThat(errorState.logoutAction).isEqualTo(Async.Failure(A_THROWABLE)) + errorState.eventSink.invoke(DirectLogoutEvents.CloseDialogs) + val finalState = awaitItem() + assertThat(finalState.logoutAction).isEqualTo(Async.Uninitialized) + } + } + + @Test + fun `present - logout with error then force`() = runTest { + val matrixClient = FakeMatrixClient().apply { + givenLogoutError(A_THROWABLE) + } + val presenter = createDefaultDirectLogoutPresenter( + matrixClient, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink.invoke(DirectLogoutEvents.Logout(ignoreSdkError = false)) + val confirmationState = awaitItem() + assertThat(confirmationState.showConfirmationDialog).isTrue() + confirmationState.eventSink.invoke(DirectLogoutEvents.Logout(ignoreSdkError = false)) + skipItems(1) + val loadingState = awaitItem() + assertThat(loadingState.showConfirmationDialog).isFalse() + assertThat(loadingState.logoutAction).isInstanceOf(Async.Loading::class.java) + val errorState = awaitItem() + assertThat(errorState.logoutAction).isEqualTo(Async.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) + val successState = awaitItem() + assertThat(successState.logoutAction).isInstanceOf(Async.Success::class.java) + } + } + + private fun createDefaultDirectLogoutPresenter( + matrixClient: MatrixClient = FakeMatrixClient(), + encryptionService: EncryptionService = FakeEncryptionService(), + ): DefaultDirectLogoutPresenter = DefaultDirectLogoutPresenter( + matrixClient = matrixClient, + encryptionService = encryptionService, + featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SecureStorage.key to true)), + ) +} + diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt index ecb2f7f070..05d11b85f1 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt @@ -27,16 +27,20 @@ import com.bumble.appyx.core.plugin.plugins import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.logout.api.direct.DirectLogoutEvents +import io.element.android.features.logout.api.direct.DirectLogoutView import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.user.MatrixUser -import io.element.android.compound.theme.ElementTheme +import timber.log.Timber @ContributesNode(SessionScope::class) class PreferencesRootNode @AssistedInject constructor( @Assisted buildContext: BuildContext, @Assisted plugins: List, private val presenter: PreferencesRootPresenter, + private val directLogoutView: DirectLogoutView, ) : Node(buildContext, plugins = plugins) { interface Callback : Plugin { @@ -95,6 +99,13 @@ class PreferencesRootNode @AssistedInject constructor( } } + private fun onSuccessLogout(activity: Activity, url: String?) { + Timber.d("Success (direct) logout with result url: $url") + url?.let { + activity.openUrlInChromeCustomTab(null, false, it) + } + } + private fun onOpenNotificationSettings() { plugins().forEach { it.onOpenNotificationSettings() } } @@ -131,7 +142,20 @@ class PreferencesRootNode @AssistedInject constructor( onOpenNotificationSettings = this::onOpenNotificationSettings, onOpenLockScreenSettings = this::onOpenLockScreenSettings, onOpenUserProfile = this::onOpenUserProfile, - onSignOutClicked = this::onSignOutClicked, + onSignOutClicked = { + if (state.directLogoutState.canDoDirectSignOut) { + state.directLogoutState.eventSink(DirectLogoutEvents.Logout(ignoreSdkError = false)) + } else { + onSignOutClicked() + } + }, + ) + + directLogoutView.Render( + state = state.directLogoutState, + onSuccessLogout = { + onSuccessLogout(activity, it) + } ) } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt index 3295e3a59a..7472f6df7a 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable +import io.element.android.features.logout.api.direct.DirectLogoutPresenter import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.meta.BuildType import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher @@ -50,6 +51,7 @@ class PreferencesRootPresenter @Inject constructor( private val snackbarDispatcher: SnackbarDispatcher, private val featureFlagService: FeatureFlagService, private val indicatorService: IndicatorService, + private val directLogoutPresenter: DirectLogoutPresenter, ) : Presenter { @Composable @@ -88,6 +90,8 @@ class PreferencesRootPresenter @Inject constructor( mutableStateOf(null) } + val directLogoutState = directLogoutPresenter.present() + LaunchedEffect(Unit) { initAccountManagementUrl(accountManagementUrl, devicesManagementUrl) } @@ -105,6 +109,7 @@ class PreferencesRootPresenter @Inject constructor( showDeveloperSettings = showDeveloperSettings, showNotificationSettings = showNotificationSettings.value, showLockScreenSettings = showLockScreenSettings.value, + directLogoutState = directLogoutState, snackbarMessage = snackbarMessage, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt index fec09f150f..0da537f26a 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt @@ -16,6 +16,7 @@ package io.element.android.features.preferences.impl.root +import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.matrix.api.user.MatrixUser @@ -31,5 +32,6 @@ data class PreferencesRootState( val showDeveloperSettings: Boolean, val showLockScreenSettings: Boolean, val showNotificationSettings: Boolean, + val directLogoutState: DirectLogoutState, val snackbarMessage: SnackbarMessage?, ) 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 74d8b0f0c9..db8c5b97b9 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 @@ -16,6 +16,8 @@ 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.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.ui.strings.CommonStrings @@ -32,4 +34,12 @@ fun aPreferencesRootState() = PreferencesRootState( showNotificationSettings = true, showLockScreenSettings = true, snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete), + directLogoutState = aDirectLogoutState(), +) + +fun aDirectLogoutState() = DirectLogoutState( + canDoDirectSignOut = true, + showConfirmationDialog = false, + logoutAction = Async.Uninitialized, + eventSink = {}, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt index 230648a96a..f25cb515e9 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt @@ -57,7 +57,7 @@ fun PreferencesRootView( onManageAccountClicked: (url: String) -> Unit, onOpenAnalytics: () -> Unit, onOpenRageShake: () -> Unit, - onOpenLockScreenSettings: ()->Unit, + onOpenLockScreenSettings: () -> Unit, onOpenAbout: () -> Unit, onOpenDeveloperSettings: () -> Unit, onOpenAdvancedSettings: () -> Unit, @@ -91,7 +91,7 @@ fun PreferencesRootView( if (state.showSecureBackup) { ListItem( headlineContent = { Text(stringResource(id = CommonStrings.common_chat_backup)) }, - leadingContent = ListItemContent.Icon(IconSource.Resource(CommonDrawables.ic_key_filled),), + leadingContent = ListItemContent.Icon(IconSource.Resource(CommonDrawables.ic_key_filled)), trailingContent = ListItemContent.Badge.takeIf { state.showSecureBackupBadge }, onClick = onSecureBackupClicked, ) diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt index f914c21100..fc657bfbd7 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt @@ -16,10 +16,14 @@ package io.element.android.features.preferences.impl.root +import androidx.compose.runtime.Composable 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.features.logout.api.direct.DirectLogoutPresenter +import io.element.android.features.logout.api.direct.DirectLogoutState +import io.element.android.libraries.architecture.Async import io.element.android.libraries.core.meta.BuildType import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.featureflag.test.FakeFeatureFlagService @@ -41,23 +45,34 @@ class PreferencesRootPresenterTest { @get:Rule val warmUpRule = WarmUpRule() + private val aDirectLogoutState = DirectLogoutState( + canDoDirectSignOut = true, + showConfirmationDialog = false, + logoutAction = Async.Uninitialized, + eventSink = {}, + ) + @Test fun `present - initial state`() = runTest { val matrixClient = FakeMatrixClient() val sessionVerificationService = FakeSessionVerificationService() val presenter = PreferencesRootPresenter( - matrixClient, - sessionVerificationService, - FakeAnalyticsService(), - BuildType.DEBUG, - FakeVersionFormatter(), - SnackbarDispatcher(), - FakeFeatureFlagService(), - DefaultIndicatorService( + matrixClient = matrixClient, + sessionVerificationService = sessionVerificationService, + analyticsService = FakeAnalyticsService(), + buildType = BuildType.DEBUG, + versionFormatter = FakeVersionFormatter(), + snackbarDispatcher = SnackbarDispatcher(), + featureFlagService = FakeFeatureFlagService(), + indicatorService = DefaultIndicatorService( sessionVerificationService = sessionVerificationService, encryptionService = FakeEncryptionService(), featureFlagService = FakeFeatureFlagService(), ), + directLogoutPresenter = object : DirectLogoutPresenter { + @Composable + override fun present() = aDirectLogoutState + } ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -73,10 +88,18 @@ class PreferencesRootPresenterTest { avatarUrl = AN_AVATAR_URL ) ) - assertThat(loadedState.showDeveloperSettings).isTrue() - assertThat(loadedState.showAnalyticsSettings).isFalse() + assertThat(initialState.version).isEqualTo("A Version") + assertThat(loadedState.showCompleteVerification).isTrue() + assertThat(loadedState.showSecureBackup).isFalse() + assertThat(loadedState.showSecureBackupBadge).isTrue() assertThat(loadedState.accountManagementUrl).isNull() assertThat(loadedState.devicesManagementUrl).isNull() + assertThat(loadedState.showAnalyticsSettings).isFalse() + assertThat(loadedState.showDeveloperSettings).isTrue() + assertThat(loadedState.showLockScreenSettings).isTrue() + assertThat(loadedState.showNotificationSettings).isTrue() + assertThat(loadedState.directLogoutState).isEqualTo(aDirectLogoutState) + assertThat(loadedState.snackbarMessage).isNull() } } }