Secure backup

This commit is contained in:
Benoit Marty 2023-10-27 11:14:00 +02:00 committed by Benoit Marty
parent bf905dd79b
commit 9807ebf649
115 changed files with 4698 additions and 393 deletions

View file

@ -0,0 +1,46 @@
/*
* 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
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.logout.api.LogoutEntryPoint
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultLogoutEntryPoint @Inject constructor() : LogoutEntryPoint {
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): LogoutEntryPoint.NodeBuilder {
val plugins = ArrayList<Plugin>()
return object : LogoutEntryPoint.NodeBuilder {
override fun callback(callback: LogoutEntryPoint.Callback): LogoutEntryPoint.NodeBuilder {
plugins += callback
return this
}
override fun build(): Node {
return parentNode.createNode<LogoutNode>(buildContext, plugins)
}
}
}
}

View file

@ -1,64 +0,0 @@
/*
* 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
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.logout.api.LogoutPreferenceEvents
import io.element.android.features.logout.api.LogoutPreferencePresenter
import io.element.android.features.logout.api.LogoutPreferenceState
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.matrix.api.MatrixClient
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
@ContributesBinding(SessionScope::class)
class DefaultLogoutPreferencePresenter @Inject constructor(private val matrixClient: MatrixClient) :
LogoutPreferencePresenter {
@Composable
override fun present(): LogoutPreferenceState {
val localCoroutineScope = rememberCoroutineScope()
val logoutAction: MutableState<Async<String?>> = remember {
mutableStateOf(Async.Uninitialized)
}
fun handleEvents(event: LogoutPreferenceEvents) {
when (event) {
LogoutPreferenceEvents.Logout -> localCoroutineScope.logout(logoutAction)
}
}
return LogoutPreferenceState(
logoutAction = logoutAction.value,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.logout(logoutAction: MutableState<Async<String?>>) = launch {
suspend {
matrixClient.logout(false /* TODO */)
}.runCatchingUpdatingState(logoutAction)
}
}

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.impl
sealed interface LogoutEvents {
data class Logout(val ignoreSdkError: Boolean) : LogoutEvents
data object CloseDialogs : LogoutEvents
}

View file

@ -0,0 +1,64 @@
/*
* 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
import android.app.Activity
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
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.features.logout.api.LogoutEntryPoint
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.di.SessionScope
import timber.log.Timber
@ContributesNode(SessionScope::class)
class LogoutNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: LogoutPresenter,
) : Node(buildContext, plugins = plugins) {
private fun onChangeRecoveryKeyClicked() {
plugins<LogoutEntryPoint.Callback>().forEach { it.onChangeRecoveryKeyClicked() }
}
private fun onSuccessLogout(activity: Activity, url: String?) {
Timber.d("Success logout with result url: $url")
url?.let {
activity.openUrlInChromeCustomTab(null, false, it)
}
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val activity = LocalContext.current as Activity
LogoutView(
state = state,
onChangeRecoveryKeyClicked = ::onChangeRecoveryKeyClicked,
onSuccessLogout = { onSuccessLogout(activity, it) },
modifier = modifier,
)
}
}

View file

@ -0,0 +1,92 @@
/*
* 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
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 io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
class LogoutPresenter @Inject constructor(
private val matrixClient: MatrixClient,
private val encryptionService: EncryptionService,
) : Presenter<LogoutState> {
@Composable
override fun present(): LogoutState {
val localCoroutineScope = rememberCoroutineScope()
val logoutAction: MutableState<Async<String?>> = remember {
mutableStateOf(Async.Uninitialized)
}
val backupUploadState by encryptionService.backupUploadStateStateFlow.collectAsState()
var showLogoutDialog by remember { mutableStateOf(false) }
var isLastSession by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
isLastSession = encryptionService.isLastDevice().getOrNull() ?: false
encryptionService.waitForBackupUploadSteadyState()
}
fun handleEvents(event: LogoutEvents) {
when (event) {
is LogoutEvents.Logout -> {
if (showLogoutDialog || event.ignoreSdkError) {
showLogoutDialog = false
localCoroutineScope.logout(logoutAction, event.ignoreSdkError)
} else {
showLogoutDialog = true
}
}
LogoutEvents.CloseDialogs -> {
logoutAction.value = Async.Uninitialized
showLogoutDialog = false
}
}
}
return LogoutState(
isLastSession = isLastSession,
backupUploadState = backupUploadState,
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,28 @@
/*
* 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
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
data class LogoutState(
val isLastSession: Boolean,
val backupUploadState: BackupUploadState,
val showConfirmationDialog: Boolean,
val logoutAction: Async<String?>,
val eventSink: (LogoutEvents) -> Unit,
)

View file

@ -0,0 +1,47 @@
/*
* 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
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
open class LogoutStateProvider : PreviewParameterProvider<LogoutState> {
override val values: Sequence<LogoutState>
get() = sequenceOf(
aLogoutState(),
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"))),
)
}
fun aLogoutState(
isLastSession: Boolean = false,
backupUploadState: BackupUploadState = BackupUploadState.Unknown,
showConfirmationDialog: Boolean = false,
logoutAction: Async<String?> = Async.Uninitialized,
) = LogoutState(
isLastSession = isLastSession,
backupUploadState = backupUploadState,
showConfirmationDialog = showConfirmationDialog,
logoutAction = logoutAction,
eventSink = {}
)

View file

@ -0,0 +1,224 @@
/*
* 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
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
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
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.progressIndicatorTrackColor
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun LogoutView(
state: LogoutState,
onChangeRecoveryKeyClicked: () -> Unit,
onSuccessLogout: (logoutUrlResult: String?) -> Unit,
modifier: Modifier = Modifier,
) {
val eventSink = state.eventSink
HeaderFooterPage(
modifier = modifier,
header = {
HeaderContent(state = state)
},
footer = {
BottomMenu(
state = state,
onChangeRecoveryKeyClicked = onChangeRecoveryKeyClicked,
onLogoutClicked = {
eventSink(LogoutEvents.Logout(ignoreSdkError = false))
},
)
}
) {
Content(state = state)
}
// 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),
onCancelClicked = {
eventSink(LogoutEvents.CloseDialogs)
},
onSubmitClicked = {
eventSink(LogoutEvents.Logout(ignoreSdkError = false))
},
onDismiss = {
eventSink(LogoutEvents.CloseDialogs)
}
)
}
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),
onCancelClicked = {
eventSink(LogoutEvents.CloseDialogs)
},
onSubmitClicked = {
eventSink(LogoutEvents.Logout(ignoreSdkError = true))
},
onDismiss = {
eventSink(LogoutEvents.CloseDialogs)
}
)
Async.Uninitialized ->
Unit
is Async.Success ->
LaunchedEffect(state.logoutAction) {
onSuccessLogout(state.logoutAction.data)
}
}
}
// TODO i18n
@Composable
private fun HeaderContent(
state: LogoutState,
modifier: Modifier = Modifier,
) {
val title = when {
state.backupUploadState.isBackingUp() -> stringResource(id = R.string.screen_signout_key_backup_ongoing_title)
state.isLastSession -> stringResource(id = R.string.screen_signout_key_backup_disabled_title)
else -> "Sign out of Element" // TODO
}
val subtitle = when {
state.backupUploadState.isBackingUp() -> stringResource(id = R.string.screen_signout_key_backup_ongoing_subtitle)
state.isLastSession -> stringResource(id = R.string.screen_signout_key_backup_disabled_subtitle)
else -> null
}
val paddingTop = 60.dp
IconTitleSubtitleMolecule(
modifier = modifier.padding(top = paddingTop),
iconResourceId = CommonDrawables.ic_key,
title = title,
subTitle = subtitle,
// iconComposable = iconComposable,
)
}
private fun BackupUploadState.isBackingUp(): Boolean {
return when (this) {
BackupUploadState.Unknown,
BackupUploadState.Waiting,
is BackupUploadState.Uploading,
is BackupUploadState.CheckingIfUploadNeeded -> true
BackupUploadState.Done -> false
}
}
@Composable
private fun BottomMenu(
state: LogoutState,
onLogoutClicked: () -> Unit,
onChangeRecoveryKeyClicked: () -> Unit,
) {
val logoutAction = state.logoutAction
ButtonColumnMolecule(
modifier = Modifier.padding(bottom = 20.dp)
) {
if (state.isLastSession) {
OutlinedButton(
text = stringResource(id = CommonStrings.common_settings),
modifier = Modifier.fillMaxWidth(),
onClick = onChangeRecoveryKeyClicked,
)
}
val signOutSubmitRes = when {
logoutAction is Async.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,
destructive = true,
modifier = Modifier.fillMaxWidth(),
onClick = onLogoutClicked,
)
}
}
@Composable
private fun Content(
state: LogoutState,
) {
if (state.backupUploadState is BackupUploadState.Uploading) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 60.dp, start = 20.dp, end = 20.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth(),
progress = state.backupUploadState.backedUpCount.toFloat() / state.backupUploadState.totalCount.toFloat(),
trackColor = ElementTheme.colors.progressIndicatorTrackColor,
)
Text(
modifier = Modifier.align(Alignment.End),
text = "${state.backupUploadState.backedUpCount} / ${state.backupUploadState.totalCount}",
style = ElementTheme.typography.fontBodySmRegular,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun LogoutViewPreview(
@PreviewParameter(LogoutStateProvider::class) state: LogoutState,
) = ElementPreview {
LogoutView(
state,
onChangeRecoveryKeyClicked = {},
onSuccessLogout = {}
)
}

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_confirmation_dialog_content">"Opravdu se chcete odhlásit?"</string>
<string name="screen_signout_confirmation_dialog_title">"Odhlásit se"</string>
<string name="screen_signout_in_progress_dialog_content">"Odhlašování…"</string>
<string name="screen_signout_confirmation_dialog_submit">"Odhlásit se"</string>
<string name="screen_signout_preference_item">"Odhlásit se"</string>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_confirmation_dialog_content">"Bist du sicher, dass du dich abmelden willst?"</string>
<string name="screen_signout_confirmation_dialog_title">"Abmelden"</string>
<string name="screen_signout_in_progress_dialog_content">"Abmelden…"</string>
<string name="screen_signout_confirmation_dialog_submit">"Abmelden"</string>
<string name="screen_signout_preference_item">"Abmelden"</string>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_confirmation_dialog_content">"¿Estás seguro de que quieres cerrar sesión?"</string>
<string name="screen_signout_confirmation_dialog_title">"Cerrar sesión"</string>
<string name="screen_signout_in_progress_dialog_content">"Cerrando sesión…"</string>
<string name="screen_signout_confirmation_dialog_submit">"Cerrar sesión"</string>
<string name="screen_signout_preference_item">"Cerrar sesión"</string>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_confirmation_dialog_content">"Êtes-vous sûr de vouloir vous déconnecter ?"</string>
<string name="screen_signout_confirmation_dialog_title">"Se déconnecter"</string>
<string name="screen_signout_in_progress_dialog_content">"Déconnexion…"</string>
<string name="screen_signout_confirmation_dialog_submit">"Se déconnecter"</string>
<string name="screen_signout_preference_item">"Se déconnecter"</string>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_confirmation_dialog_content">"Sei sicuro di voler uscire?"</string>
<string name="screen_signout_confirmation_dialog_title">"Esci"</string>
<string name="screen_signout_in_progress_dialog_content">"Uscita in corso…"</string>
<string name="screen_signout_confirmation_dialog_submit">"Esci"</string>
<string name="screen_signout_preference_item">"Esci"</string>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_confirmation_dialog_content">"Sunteți sigur că vreți să vă deconectați?"</string>
<string name="screen_signout_confirmation_dialog_title">"Deconectați-vă"</string>
<string name="screen_signout_in_progress_dialog_content">"Deconectare în curs…"</string>
<string name="screen_signout_confirmation_dialog_submit">"Deconectați-vă"</string>
<string name="screen_signout_preference_item">"Deconectați-vă"</string>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_confirmation_dialog_content">"Вы уверены, что вы хотите выйти?"</string>
<string name="screen_signout_confirmation_dialog_title">"Выйти"</string>
<string name="screen_signout_in_progress_dialog_content">"Выполняется выход…"</string>
<string name="screen_signout_confirmation_dialog_submit">"Выйти"</string>
<string name="screen_signout_preference_item">"Выйти"</string>
</resources>

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_confirmation_dialog_content">"Ste si istí, že sa chcete odhlásiť?"</string>
<string name="screen_signout_confirmation_dialog_title">"Odhlásiť sa"</string>
<string name="screen_signout_in_progress_dialog_content">"Prebieha odhlasovanie…"</string>
<string name="screen_signout_key_backup_disabled_subtitle">"Chystáte sa odhlásiť z vašej poslednej relácie. Ak sa teraz odhlásite, stratíte prístup k svojim šifrovaným správam."</string>
<string name="screen_signout_key_backup_disabled_title">"Vypli ste zálohovanie"</string>
<string name="screen_signout_key_backup_offline_subtitle">"Keď ste sa odpojili od internetu, vaše kľúče sa ešte stále zálohovali. Pripojte sa znova k internetu, aby sa vaše kľúče mohli zálohovať pred odhlásením."</string>
<string name="screen_signout_key_backup_offline_title">"Vaše kľúče sa ešte stále zálohujú"</string>
<string name="screen_signout_key_backup_ongoing_subtitle">"Pred odhlásením počkajte, kým sa to dokončí."</string>
<string name="screen_signout_key_backup_ongoing_title">"Vaše kľúče sa ešte stále zálohujú"</string>
<string name="screen_signout_recovery_disabled_subtitle">"Chystáte sa odhlásiť z vašej poslednej relácie. Ak sa teraz odhlásite, stratíte prístup k svojim šifrovaným správam."</string>
<string name="screen_signout_recovery_disabled_title">"Obnovenie nie je nastavené"</string>
<string name="screen_signout_save_recovery_key_subtitle">"Chystáte sa odhlásiť z vašej poslednej relácie. Ak sa teraz odhlásite, môžete stratiť prístup k svojim šifrovaným správam."</string>
<string name="screen_signout_save_recovery_key_title">"Uložili ste si kľúč na obnovenie?"</string>
<string name="screen_signout_confirmation_dialog_submit">"Odhlásiť sa"</string>
<string name="screen_signout_preference_item">"Odhlásiť sa"</string>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_confirmation_dialog_content">"您確定要登出嗎?"</string>
<string name="screen_signout_confirmation_dialog_title">"登出"</string>
<string name="screen_signout_in_progress_dialog_content">"正在登出…"</string>
<string name="screen_signout_confirmation_dialog_submit">"登出"</string>
<string name="screen_signout_preference_item">"登出"</string>
</resources>

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_signout_confirmation_dialog_content">"Are you sure you want to sign out?"</string>
<string name="screen_signout_confirmation_dialog_title">"Sign out"</string>
<string name="screen_signout_in_progress_dialog_content">"Signing out…"</string>
<string name="screen_signout_key_backup_disabled_subtitle">"You are about to sign out of your last session. If you sign out now, you will lose access to your encrypted messages."</string>
<string name="screen_signout_key_backup_disabled_title">"You have turned off backup"</string>
<string name="screen_signout_key_backup_offline_subtitle">"Your keys were still being backed up when you went offline. Reconnect so that your keys can be backed up before signing out."</string>
<string name="screen_signout_key_backup_offline_title">"Your keys are still being backed up"</string>
<string name="screen_signout_key_backup_ongoing_subtitle">"Please wait for this to complete before signing out."</string>
<string name="screen_signout_key_backup_ongoing_title">"Your keys are still being backed up"</string>
<string name="screen_signout_recovery_disabled_subtitle">"You are about to sign out of your last session. If you sign out now, you\'ll lose access to your encrypted messages."</string>
<string name="screen_signout_recovery_disabled_title">"Recovery not set up"</string>
<string name="screen_signout_save_recovery_key_subtitle">"You are about to sign out of your last session. If you sign out now, you might lose access to your encrypted messages."</string>
<string name="screen_signout_save_recovery_key_title">"Have you saved your recovery key?"</string>
<string name="screen_signout_confirmation_dialog_submit">"Sign out"</string>
<string name="screen_signout_preference_item">"Sign out"</string>
</resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<!-- TODO Delete those 2 strings (replaced by action_sign_out) -->
<!--string name="screen_signout_confirmation_dialog_submit">"Sign out"</string>
<string name="screen_signout_preference_item">"Sign out"</string-->
</resources>

View file

@ -1,87 +0,0 @@
/*
* 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
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.LogoutPreferenceEvents
import io.element.android.features.logout.api.LogoutPreferenceState
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.test.A_THROWABLE
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class LogoutPreferencePresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val presenter = DefaultLogoutPreferencePresenter(
FakeMatrixClient(),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.logoutAction).isEqualTo(Async.Uninitialized)
}
}
@Test
fun `present - logout`() = runTest {
val presenter = DefaultLogoutPreferencePresenter(
FakeMatrixClient(),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(LogoutPreferenceEvents.Logout)
val loadingState = awaitItem()
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`() = runTest {
val matrixClient = FakeMatrixClient()
val presenter = DefaultLogoutPreferencePresenter(
matrixClient,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
matrixClient.givenLogoutError(A_THROWABLE)
initialState.eventSink.invoke(LogoutPreferenceEvents.Logout)
val loadingState = awaitItem()
assertThat(loadingState.logoutAction).isInstanceOf(Async.Loading::class.java)
val successState = awaitItem()
assertThat(successState.logoutAction).isEqualTo(Async.Failure<LogoutPreferenceState>(A_THROWABLE))
}
}
}

View file

@ -0,0 +1,199 @@
/*
* 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
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.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 kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class LogoutPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val presenter = createLogoutPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.isLastSession).isFalse()
assertThat(initialState.backupUploadState).isEqualTo(BackupUploadState.Unknown)
assertThat(initialState.showConfirmationDialog).isFalse()
assertThat(initialState.logoutAction).isEqualTo(Async.Uninitialized)
}
}
@Test
fun `present - initial state - last session`() = runTest {
val presenter = createLogoutPresenter(
encryptionService = FakeEncryptionService().apply {
givenIsLastDevice(true)
}
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.isLastSession).isTrue()
assertThat(initialState.backupUploadState).isEqualTo(BackupUploadState.Unknown)
assertThat(initialState.showConfirmationDialog).isFalse()
assertThat(initialState.logoutAction).isEqualTo(Async.Uninitialized)
}
}
@Test
fun `present - initial state - backing up`() = runTest {
val encryptionService = FakeEncryptionService()
val presenter = createLogoutPresenter(
encryptionService = encryptionService
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.isLastSession).isFalse()
assertThat(initialState.backupUploadState).isEqualTo(BackupUploadState.Unknown)
assertThat(initialState.showConfirmationDialog).isFalse()
assertThat(initialState.logoutAction).isEqualTo(Async.Uninitialized)
encryptionService.emitBackupUploadState(BackupUploadState.Uploading(backedUpCount = 1, totalCount = 2))
val state = awaitItem()
assertThat(state.backupUploadState).isEqualTo(BackupUploadState.Uploading(backedUpCount = 1, totalCount = 2))
encryptionService.emitBackupUploadState(BackupUploadState.Done)
val doneState = awaitItem()
assertThat(doneState.backupUploadState).isEqualTo(BackupUploadState.Done)
}
}
@Test
fun `present - logout then cancel`() = runTest {
val presenter = createLogoutPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false))
val confirmationState = awaitItem()
assertThat(confirmationState.showConfirmationDialog).isTrue()
initialState.eventSink.invoke(LogoutEvents.CloseDialogs)
val finalState = awaitItem()
assertThat(finalState.showConfirmationDialog).isFalse()
}
}
@Test
fun `present - logout then confirm`() = runTest {
val presenter = createLogoutPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false))
val confirmationState = awaitItem()
assertThat(confirmationState.showConfirmationDialog).isTrue()
confirmationState.eventSink.invoke(LogoutEvents.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 = createLogoutPresenter(
matrixClient,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false))
val confirmationState = awaitItem()
assertThat(confirmationState.showConfirmationDialog).isTrue()
confirmationState.eventSink.invoke(LogoutEvents.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<LogoutState>(A_THROWABLE))
errorState.eventSink.invoke(LogoutEvents.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 = createLogoutPresenter(
matrixClient,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false))
val confirmationState = awaitItem()
assertThat(confirmationState.showConfirmationDialog).isTrue()
confirmationState.eventSink.invoke(LogoutEvents.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<LogoutState>(A_THROWABLE))
errorState.eventSink.invoke(LogoutEvents.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 createLogoutPresenter(
matrixClient: MatrixClient = FakeMatrixClient(),
encryptionService: EncryptionService = FakeEncryptionService(),
): LogoutPresenter = LogoutPresenter(
matrixClient = matrixClient,
encryptionService = encryptionService,
)
}