Merge pull request #2111 from element-hq/feature/bma/directLogout

Direct logout
This commit is contained in:
Benoit Marty
2023-12-26 17:58:23 +01:00
committed by GitHub
19 changed files with 695 additions and 58 deletions

View File

@@ -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

1
changelog.d/2072.misc Normal file
View File

@@ -0,0 +1 @@
Remove extra logout screen.

View File

@@ -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
}

View File

@@ -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<DirectLogoutState>

View File

@@ -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<String?>,
val eventSink: (DirectLogoutEvents) -> Unit,
)

View File

@@ -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
)
}

View File

@@ -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,

View File

@@ -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<Async<String?>> = 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<Async<String?>>,
ignoreSdkError: Boolean,
) = launch {
suspend {
matrixClient.logout(ignoreSdkError)
}.runCatchingUpdatingState(logoutAction)
}
}

View File

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

View File

@@ -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
}
}

View File

@@ -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<String?>,
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)
}
}
}

View File

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

View File

@@ -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<DirectLogoutState>(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<DirectLogoutState>(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)),
)
}

View File

@@ -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<Plugin>,
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<Callback>().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)
}
)
}
}

View File

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

View File

@@ -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?,
)

View File

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

View File

@@ -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,
)

View File

@@ -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()
}
}
}