Secure backup
This commit is contained in:
parent
bf905dd79b
commit
9807ebf649
115 changed files with 4698 additions and 393 deletions
|
|
@ -16,6 +16,20 @@
|
|||
|
||||
package io.element.android.features.logout.api
|
||||
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import io.element.android.libraries.architecture.FeatureEntryPoint
|
||||
|
||||
interface LogoutPreferencePresenter : Presenter<LogoutPreferenceState>
|
||||
interface LogoutEntryPoint : FeatureEntryPoint {
|
||||
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
|
||||
|
||||
interface NodeBuilder {
|
||||
fun callback(callback: Callback): NodeBuilder
|
||||
fun build(): Node
|
||||
}
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun onChangeRecoveryKeyClicked()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2022 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
|
||||
|
||||
import io.element.android.libraries.architecture.Async
|
||||
|
||||
fun aLogoutPreferenceState() = LogoutPreferenceState(
|
||||
logoutAction = Async.Uninitialized,
|
||||
eventSink = {}
|
||||
)
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2022 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
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||
import io.element.android.libraries.designsystem.components.preferences.PreferenceText
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
|
||||
@Composable
|
||||
fun LogoutPreferenceView(
|
||||
state: LogoutPreferenceState,
|
||||
onSuccessLogout: (logoutUrlResult: String?) -> Unit
|
||||
) {
|
||||
val eventSink = state.eventSink
|
||||
if (state.logoutAction is Async.Success) {
|
||||
LaunchedEffect(state.logoutAction) {
|
||||
onSuccessLogout(state.logoutAction.data)
|
||||
}
|
||||
return
|
||||
}
|
||||
val openDialog = remember { mutableStateOf(false) }
|
||||
|
||||
LogoutPreferenceContent(
|
||||
onClick = {
|
||||
openDialog.value = true
|
||||
}
|
||||
)
|
||||
|
||||
// Log out confirmation dialog
|
||||
if (openDialog.value) {
|
||||
ConfirmationDialog(
|
||||
title = stringResource(id = R.string.screen_signout_confirmation_dialog_title),
|
||||
content = stringResource(id = R.string.screen_signout_confirmation_dialog_content),
|
||||
submitText = stringResource(id = R.string.screen_signout_confirmation_dialog_submit),
|
||||
onCancelClicked = {
|
||||
openDialog.value = false
|
||||
},
|
||||
onSubmitClicked = {
|
||||
openDialog.value = false
|
||||
eventSink(LogoutPreferenceEvents.Logout)
|
||||
},
|
||||
onDismiss = {
|
||||
openDialog.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (state.logoutAction is Async.Loading) {
|
||||
ProgressDialog(text = stringResource(id = R.string.screen_signout_in_progress_dialog_content))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LogoutPreferenceContent(
|
||||
onClick: () -> Unit = {},
|
||||
) {
|
||||
PreferenceText(
|
||||
title = stringResource(id = R.string.screen_signout_preference_item),
|
||||
iconResourceId = CommonDrawables.ic_compound_leave,
|
||||
onClick = onClick
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun LogoutPreferenceViewPreview() = ElementPreview {
|
||||
LogoutPreferenceView(
|
||||
aLogoutPreferenceState(),
|
||||
onSuccessLogout = {}
|
||||
)
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ anvil {
|
|||
dependencies {
|
||||
implementation(projects.anvilannotations)
|
||||
anvil(projects.anvilcodegen)
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -14,8 +14,9 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.logout.api
|
||||
package io.element.android.features.logout.impl
|
||||
|
||||
sealed interface LogoutPreferenceEvents {
|
||||
data object Logout : LogoutPreferenceEvents
|
||||
sealed interface LogoutEvents {
|
||||
data class Logout(val ignoreSdkError: Boolean) : LogoutEvents
|
||||
data object CloseDialogs : LogoutEvents
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2022 New Vector Ltd
|
||||
* 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.
|
||||
|
|
@ -14,11 +14,15 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.logout.api
|
||||
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 LogoutPreferenceState(
|
||||
data class LogoutState(
|
||||
val isLastSession: Boolean,
|
||||
val backupUploadState: BackupUploadState,
|
||||
val showConfirmationDialog: Boolean,
|
||||
val logoutAction: Async<String?>,
|
||||
val eventSink: (LogoutPreferenceEvents) -> Unit,
|
||||
val eventSink: (LogoutEvents) -> Unit,
|
||||
)
|
||||
|
|
@ -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 = {}
|
||||
)
|
||||
|
|
@ -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 = {}
|
||||
)
|
||||
}
|
||||
6
features/logout/impl/src/main/res/values/tmp.xml
Normal file
6
features/logout/impl/src/main/res/values/tmp.xml
Normal 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>
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue