Secure backup

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

View File

@@ -10,6 +10,7 @@
<w>onboarding</w>
<w>placeables</w>
<w>posthog</w>
<w>securebackup</w>
<w>showkase</w>
<w>snackbar</w>
<w>swipeable</w>

View File

@@ -3,11 +3,12 @@ appId: ${APP_ID}
- tapOn:
id: "home_screen-settings"
- tapOn: "Sign out"
- takeScreenshot: build/maestro/900-SignOutDialg
- takeScreenshot: build/maestro/900-SignOutScreen
- back
- tapOn: "Sign out"
- tapOn: "Sign out"
# Ensure cancel cancels
- tapOn: "Cancel"
- tapOn: "Sign out"
- tapOn:
text: "Sign out"
index: 1
- tapOn: "Sign out"
- runFlow: ../assertions/assertInitDisplayed.yaml

View File

@@ -55,6 +55,7 @@ import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.features.roomlist.api.RoomListEntryPoint
import io.element.android.features.securebackup.api.SecureBackupEntryPoint
import io.element.android.features.verifysession.api.VerifySessionEntryPoint
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
@@ -87,6 +88,7 @@ class LoggedInFlowNode @AssistedInject constructor(
private val createRoomEntryPoint: CreateRoomEntryPoint,
private val appNavigationStateService: AppNavigationStateService,
private val verifySessionEntryPoint: VerifySessionEntryPoint,
private val secureBackupEntryPoint: SecureBackupEntryPoint,
private val inviteListEntryPoint: InviteListEntryPoint,
private val ftueEntryPoint: FtueEntryPoint,
private val coroutineScope: CoroutineScope,
@@ -197,6 +199,9 @@ class LoggedInFlowNode @AssistedInject constructor(
@Parcelize
data object VerifySession : NavTarget
@Parcelize
data object SecureBackup : NavTarget
@Parcelize
data object InviteList : NavTarget
@@ -272,6 +277,10 @@ class LoggedInFlowNode @AssistedInject constructor(
backstack.push(NavTarget.VerifySession)
}
override fun onSecureBackupClicked() {
backstack.push(NavTarget.SecureBackup)
}
override fun onOpenRoomNotificationSettings(roomId: RoomId) {
backstack.push(NavTarget.Room(roomId, initialElement = RoomLoadedFlowNode.NavTarget.RoomNotificationSettings))
}
@@ -297,6 +306,9 @@ class LoggedInFlowNode @AssistedInject constructor(
NavTarget.VerifySession -> {
verifySessionEntryPoint.createNode(this, buildContext)
}
NavTarget.SecureBackup -> {
secureBackupEntryPoint.createNode(this, buildContext)
}
NavTarget.InviteList -> {
val callback = object : InviteListEntryPoint.Callback {
override fun onBackClicked() {

View File

@@ -0,0 +1,35 @@
/*
* 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
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 LogoutEntryPoint : FeatureEntryPoint {
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
interface NodeBuilder {
fun callback(callback: Callback): NodeBuilder
fun build(): Node
}
interface Callback : Plugin {
fun onChangeRecoveryKeyClicked()
}
}

View File

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

View File

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

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

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

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

View File

@@ -47,6 +47,7 @@ interface PreferencesEntryPoint : FeatureEntryPoint {
interface Callback : Plugin {
fun onOpenBugReport()
fun onVerifyClicked()
fun onSecureBackupClicked()
fun onOpenRoomNotificationSettings(roomId: RoomId)
}
}

View File

@@ -42,6 +42,7 @@ dependencies {
implementation(projects.libraries.featureflag.ui)
implementation(projects.libraries.network)
implementation(projects.libraries.pushstore.api)
implementation(projects.libraries.indicator.api)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
@@ -78,6 +79,7 @@ dependencies {
testImplementation(projects.libraries.pushstore.test)
testImplementation(projects.features.rageshake.test)
testImplementation(projects.features.rageshake.impl)
testImplementation(projects.libraries.indicator.impl)
testImplementation(projects.features.logout.impl)
testImplementation(projects.services.analytics.test)
testImplementation(projects.features.analytics.impl)

View File

@@ -30,6 +30,7 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
import io.element.android.features.logout.api.LogoutEntryPoint
import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.features.preferences.impl.about.AboutNode
import io.element.android.features.preferences.impl.advanced.AdvancedSettingsNode
@@ -53,6 +54,7 @@ class PreferencesFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val lockScreenEntryPoint: LockScreenEntryPoint,
private val logoutEntryPoint: LogoutEntryPoint,
) : BackstackNode<PreferencesFlowNode.NavTarget>(
backstack = BackStack(
initialElement = plugins.filterIsInstance<PreferencesEntryPoint.Params>().first().initialElement.toNavTarget(),
@@ -92,6 +94,9 @@ class PreferencesFlowNode @AssistedInject constructor(
@Parcelize
data class UserProfile(val matrixUser: MatrixUser) : NavTarget
@Parcelize
data object SignOut : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@@ -106,6 +111,10 @@ class PreferencesFlowNode @AssistedInject constructor(
plugins<PreferencesEntryPoint.Callback>().forEach { it.onVerifyClicked() }
}
override fun onSecureBackupClicked() {
plugins<PreferencesEntryPoint.Callback>().forEach { it.onSecureBackupClicked() }
}
override fun onOpenAnalytics() {
backstack.push(NavTarget.AnalyticsSettings)
}
@@ -133,6 +142,10 @@ class PreferencesFlowNode @AssistedInject constructor(
override fun onOpenUserProfile(matrixUser: MatrixUser) {
backstack.push(NavTarget.UserProfile(matrixUser))
}
override fun onSignOutClicked() {
backstack.push(NavTarget.SignOut)
}
}
createNode<PreferencesRootNode>(buildContext, plugins = listOf(callback))
}
@@ -182,6 +195,16 @@ class PreferencesFlowNode @AssistedInject constructor(
.target(LockScreenEntryPoint.Target.Settings)
.build()
}
NavTarget.SignOut -> {
val callBack: LogoutEntryPoint.Callback = object : LogoutEntryPoint.Callback {
override fun onChangeRecoveryKeyClicked() {
plugins<PreferencesEntryPoint.Callback>().forEach { it.onSecureBackupClicked() }
}
}
logoutEntryPoint.nodeBuilder(this, buildContext)
.callback(callBack)
.build()
}
}
}

View File

@@ -31,7 +31,6 @@ import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTa
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.theme.ElementTheme
import timber.log.Timber
@ContributesNode(SessionScope::class)
class PreferencesRootNode @AssistedInject constructor(
@@ -43,6 +42,7 @@ class PreferencesRootNode @AssistedInject constructor(
interface Callback : Plugin {
fun onOpenBugReport()
fun onVerifyClicked()
fun onSecureBackupClicked()
fun onOpenAnalytics()
fun onOpenAbout()
fun onOpenDeveloperSettings()
@@ -50,6 +50,7 @@ class PreferencesRootNode @AssistedInject constructor(
fun onOpenLockScreenSettings()
fun onOpenAdvancedSettings()
fun onOpenUserProfile(matrixUser: MatrixUser)
fun onSignOutClicked()
}
private fun onOpenBugReport() {
@@ -60,6 +61,10 @@ class PreferencesRootNode @AssistedInject constructor(
plugins<Callback>().forEach { it.onVerifyClicked() }
}
private fun onSecureBackupClicked() {
plugins<Callback>().forEach { it.onSecureBackupClicked() }
}
private fun onOpenDeveloperSettings() {
plugins<Callback>().forEach { it.onOpenDeveloperSettings() }
}
@@ -102,6 +107,10 @@ class PreferencesRootNode @AssistedInject constructor(
plugins<Callback>().forEach { it.onOpenUserProfile(matrixUser) }
}
private fun onSignOutClicked() {
plugins<Callback>().forEach { it.onSignOutClicked() }
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
@@ -115,20 +124,14 @@ class PreferencesRootNode @AssistedInject constructor(
onOpenAnalytics = this::onOpenAnalytics,
onOpenAbout = this::onOpenAbout,
onVerifyClicked = this::onVerifyClicked,
onSecureBackupClicked = this::onSecureBackupClicked,
onOpenDeveloperSettings = this::onOpenDeveloperSettings,
onOpenAdvancedSettings = this::onOpenAdvancedSettings,
onSuccessLogout = { onSuccessLogout(activity, it) },
onManageAccountClicked = { onManageAccountClicked(activity, it, isDark) },
onOpenNotificationSettings = this::onOpenNotificationSettings,
onOpenLockScreenSettings = this::onOpenLockScreenSettings,
onOpenUserProfile = this::onOpenUserProfile,
onSignOutClicked = this::onSignOutClicked,
)
}
private fun onSuccessLogout(activity: Activity, url: String?) {
Timber.d("Success logout with result url: $url")
url?.let {
activity.openUrlInChromeCustomTab(null, false, it)
}
}
}

View File

@@ -24,13 +24,13 @@ 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.LogoutPreferencePresenter
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.indicator.api.IndicatorService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
import io.element.android.libraries.matrix.api.user.MatrixUser
@@ -42,7 +42,6 @@ import kotlinx.coroutines.launch
import javax.inject.Inject
class PreferencesRootPresenter @Inject constructor(
private val logoutPresenter: LogoutPreferencePresenter,
private val matrixClient: MatrixClient,
private val sessionVerificationService: SessionVerificationService,
private val analyticsService: AnalyticsService,
@@ -50,6 +49,7 @@ class PreferencesRootPresenter @Inject constructor(
private val versionFormatter: VersionFormatter,
private val snackbarDispatcher: SnackbarDispatcher,
private val featureFlagService: FeatureFlagService,
private val indicatorService: IndicatorService,
) : Presenter<PreferencesRootState> {
@Composable
@@ -76,6 +76,8 @@ class PreferencesRootPresenter @Inject constructor(
// We should display the 'complete verification' option if the current session can be verified
val showCompleteVerification by sessionVerificationService.canVerifySessionFlow.collectAsState(false)
val showSecureBackupIndicator by indicatorService.showSettingChatBackupIndicator()
val accountManagementUrl: MutableState<String?> = remember {
mutableStateOf(null)
}
@@ -87,13 +89,13 @@ class PreferencesRootPresenter @Inject constructor(
initAccountManagementUrl(accountManagementUrl, devicesManagementUrl)
}
val logoutState = logoutPresenter.present()
val showDeveloperSettings = buildType != BuildType.RELEASE
return PreferencesRootState(
logoutState = logoutState,
myUser = matrixUser.value,
version = versionFormatter.get(),
showCompleteVerification = showCompleteVerification,
showSecureBackup = !showCompleteVerification,
showSecureBackupBadge = showSecureBackupIndicator,
accountManagementUrl = accountManagementUrl.value,
devicesManagementUrl = devicesManagementUrl.value,
showAnalyticsSettings = hasAnalyticsProviders,

View File

@@ -16,15 +16,15 @@
package io.element.android.features.preferences.impl.root
import io.element.android.features.logout.api.LogoutPreferenceState
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.user.MatrixUser
data class PreferencesRootState(
val logoutState: LogoutPreferenceState,
val myUser: MatrixUser?,
val version: String,
val showCompleteVerification: Boolean,
val showSecureBackup: Boolean,
val showSecureBackupBadge: Boolean,
val accountManagementUrl: String?,
val devicesManagementUrl: String?,
val showAnalyticsSettings: Boolean,

View File

@@ -16,15 +16,15 @@
package io.element.android.features.preferences.impl.root
import io.element.android.features.logout.api.aLogoutPreferenceState
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.ui.strings.CommonStrings
fun aPreferencesRootState() = PreferencesRootState(
logoutState = aLogoutPreferenceState(),
myUser = null,
version = "Version 1.1 (1)",
showCompleteVerification = true,
showSecureBackup = true,
showSecureBackupBadge = true,
accountManagementUrl = "aUrl",
devicesManagementUrl = "anOtherUrl",
showAnalyticsSettings = true,

View File

@@ -29,10 +29,9 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.logout.api.LogoutPreferenceView
import io.element.android.features.preferences.impl.user.UserPreferences
import io.element.android.libraries.designsystem.components.preferences.PreferenceText
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
import io.element.android.libraries.designsystem.components.preferences.PreferenceText
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight
@@ -51,6 +50,7 @@ fun PreferencesRootView(
state: PreferencesRootState,
onBackPressed: () -> Unit,
onVerifyClicked: () -> Unit,
onSecureBackupClicked: () -> Unit,
onManageAccountClicked: (url: String) -> Unit,
onOpenAnalytics: () -> Unit,
onOpenRageShake: () -> Unit,
@@ -58,9 +58,9 @@ fun PreferencesRootView(
onOpenAbout: () -> Unit,
onOpenDeveloperSettings: () -> Unit,
onOpenAdvancedSettings: () -> Unit,
onSuccessLogout: (logoutUrlResult: String?) -> Unit,
onOpenNotificationSettings: () -> Unit,
onOpenUserProfile: (MatrixUser) -> Unit,
onSignOutClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
@@ -84,6 +84,16 @@ fun PreferencesRootView(
icon = Icons.Outlined.VerifiedUser,
onClick = onVerifyClicked,
)
}
if (state.showSecureBackup) {
PreferenceText(
title = stringResource(id = CommonStrings.common_chat_backup),
iconResourceId = CommonDrawables.ic_key_filled,
showEndBadge = state.showSecureBackupBadge,
onClick = onSecureBackupClicked,
)
}
if (state.showCompleteVerification || state.showSecureBackup) {
HorizontalDivider()
}
if (state.accountManagementUrl != null) {
@@ -143,9 +153,10 @@ fun PreferencesRootView(
DeveloperPreferencesView(onOpenDeveloperSettings)
}
HorizontalDivider()
LogoutPreferenceView(
state = state.logoutState,
onSuccessLogout = onSuccessLogout,
PreferenceText(
title = stringResource(id = CommonStrings.action_signout),
iconResourceId = CommonDrawables.ic_compound_leave,
onClick = onSignOutClicked,
)
Text(
modifier = Modifier
@@ -189,10 +200,11 @@ private fun ContentToPreview(matrixUser: MatrixUser) {
onOpenAdvancedSettings = {},
onOpenAbout = {},
onVerifyClicked = {},
onSuccessLogout = {},
onSecureBackupClicked = {},
onManageAccountClicked = {},
onOpenNotificationSettings = {},
onOpenLockScreenSettings = {},
onOpenUserProfile = {},
onSignOutClicked = {},
)
}

View File

@@ -20,15 +20,15 @@ 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.impl.DefaultLogoutPreferencePresenter
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
import io.element.android.libraries.indicator.impl.DefaultIndicatorService
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
@@ -44,16 +44,19 @@ class PreferencesRootPresenterTest {
@Test
fun `present - initial state`() = runTest {
val matrixClient = FakeMatrixClient()
val logoutPresenter = DefaultLogoutPreferencePresenter(matrixClient)
val sessionVerificationService = FakeSessionVerificationService()
val presenter = PreferencesRootPresenter(
logoutPresenter,
matrixClient,
FakeSessionVerificationService(),
sessionVerificationService,
FakeAnalyticsService(),
BuildType.DEBUG,
FakeVersionFormatter(),
SnackbarDispatcher(),
FakeFeatureFlagService()
FakeFeatureFlagService(),
DefaultIndicatorService(
sessionVerificationService = sessionVerificationService,
encryptionService = FakeEncryptionService(),
),
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
@@ -62,7 +65,6 @@ class PreferencesRootPresenterTest {
assertThat(initialState.myUser).isNull()
assertThat(initialState.version).isEqualTo("A Version")
val loadedState = awaitItem()
assertThat(loadedState.logoutState.logoutAction).isEqualTo(Async.Uninitialized)
assertThat(loadedState.myUser).isEqualTo(
MatrixUser(
userId = matrixClient.sessionId,

View File

@@ -48,6 +48,7 @@ dependencies {
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.dateformatter.api)
implementation(projects.libraries.eventformatter.api)
implementation(projects.libraries.indicator.api)
implementation(projects.libraries.deeplink)
implementation(projects.features.invitelist.api)
implementation(projects.features.networkmonitor.api)
@@ -65,6 +66,7 @@ dependencies {
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.dateformatter.test)
testImplementation(projects.libraries.eventformatter.test)
testImplementation(projects.libraries.indicator.impl)
testImplementation(projects.libraries.permissions.noop)
testImplementation(projects.features.invitelist.test)
testImplementation(projects.features.networkmonitor.test)

View File

@@ -23,6 +23,7 @@ sealed interface RoomListEvents {
data class UpdateFilter(val newFilter: String) : RoomListEvents
data class UpdateVisibleRange(val range: IntRange) : RoomListEvents
data object DismissRequestVerificationPrompt : RoomListEvents
data object DismissRecoveryKeyPrompt : RoomListEvents
data object ToggleSearchResults : RoomListEvents
data class ShowContextMenu(val roomListRoomSummary: RoomListRoomSummary) : RoomListEvents
data object HideContextMenu : RoomListEvents

View File

@@ -35,7 +35,10 @@ import io.element.android.features.roomlist.impl.datasource.RoomListDataSource
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.indicator.api.IndicatorService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.user.getCurrentUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
@@ -53,6 +56,8 @@ class RoomListPresenter @Inject constructor(
private val inviteStateDataSource: InviteStateDataSource,
private val leaveRoomPresenter: LeaveRoomPresenter,
private val roomListDataSource: RoomListDataSource,
private val encryptionService: EncryptionService,
private val indicatorService: IndicatorService,
) : Presenter<RoomListState> {
@Composable
@@ -78,6 +83,16 @@ class RoomListPresenter @Inject constructor(
val displayVerificationPrompt by remember {
derivedStateOf { canVerifySession && !verificationPromptDismissed }
}
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
var recoveryKeyPromptDismissed by rememberSaveable { mutableStateOf(false) }
val displayRecoveryKeyPrompt by remember {
derivedStateOf {
recoveryState == RecoveryState.INCOMPLETE && !recoveryKeyPromptDismissed
}
}
// Avatar indicator
val showAvatarIndicator by indicatorService.showRoomListTopBarIndicator()
var displaySearchResults by rememberSaveable { mutableStateOf(false) }
@@ -88,6 +103,7 @@ class RoomListPresenter @Inject constructor(
is RoomListEvents.UpdateFilter -> roomListDataSource.updateFilter(event.newFilter)
is RoomListEvents.UpdateVisibleRange -> updateVisibleRange(event.range)
RoomListEvents.DismissRequestVerificationPrompt -> verificationPromptDismissed = true
RoomListEvents.DismissRecoveryKeyPrompt -> recoveryKeyPromptDismissed = true
RoomListEvents.ToggleSearchResults -> {
if (displaySearchResults) {
roomListDataSource.updateFilter("")
@@ -109,10 +125,12 @@ class RoomListPresenter @Inject constructor(
return RoomListState(
matrixUser = matrixUser.value,
showAvatarIndicator = showAvatarIndicator,
roomList = roomList,
filter = filter,
filteredRoomList = filteredRoomList,
displayVerificationPrompt = displayVerificationPrompt,
displayRecoveryKeyPrompt = displayRecoveryKeyPrompt,
snackbarMessage = snackbarMessage,
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
invitesState = inviteStateDataSource.inviteState(),

View File

@@ -27,10 +27,12 @@ import kotlinx.collections.immutable.ImmutableList
@Immutable
data class RoomListState(
val matrixUser: MatrixUser?,
val showAvatarIndicator: Boolean,
val roomList: ImmutableList<RoomListRoomSummary>,
val filter: String?,
val filteredRoomList: ImmutableList<RoomListRoomSummary>,
val displayVerificationPrompt: Boolean,
val displayRecoveryKeyPrompt: Boolean,
val hasNetworkConnection: Boolean,
val snackbarMessage: SnackbarMessage?,
val invitesState: InvitesState,

View File

@@ -41,20 +41,25 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
aRoomListState().copy(invitesState = InvitesState.NewInvites),
aRoomListState().copy(displaySearchResults = true, filter = "", filteredRoomList = persistentListOf()),
aRoomListState().copy(displaySearchResults = true),
aRoomListState().copy(contextMenu = RoomListState.ContextMenu.Shown(
aRoomListState().copy(
contextMenu = RoomListState.ContextMenu.Shown(
roomId = RoomId("!aRoom:aDomain"), roomName = "A nice room name"
))
)
),
aRoomListState().copy(displayRecoveryKeyPrompt = true),
)
}
internal fun aRoomListState() = RoomListState(
matrixUser = MatrixUser(userId = UserId("@id:domain"), displayName = "User#1"),
showAvatarIndicator = false,
roomList = aRoomListRoomSummaryList(),
filter = "filter",
filteredRoomList = aRoomListRoomSummaryList(),
hasNetworkConnection = true,
snackbarMessage = null,
displayVerificationPrompt = false,
displayRecoveryKeyPrompt = false,
invitesState = InvitesState.NoInvites,
displaySearchResults = false,
contextMenu = RoomListState.ContextMenu.Hidden,

View File

@@ -45,6 +45,7 @@ import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import io.element.android.features.leaveroom.api.LeaveRoomView
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorContainer
import io.element.android.features.roomlist.impl.components.ConfirmRecoveryKeyBanner
import io.element.android.features.roomlist.impl.components.RequestVerificationHeader
import io.element.android.features.roomlist.impl.components.RoomListMenuAction
import io.element.android.features.roomlist.impl.components.RoomListTopBar
@@ -172,6 +173,7 @@ private fun RoomListContent(
topBar = {
RoomListTopBar(
matrixUser = state.matrixUser,
showAvatarIndicator = state.showAvatarIndicator,
areSearchResultsDisplayed = state.displaySearchResults,
onFilterChanged = { state.eventSink(RoomListEvents.UpdateFilter(it)) },
onToggleSearch = { state.eventSink(RoomListEvents.ToggleSearchResults) },
@@ -195,6 +197,13 @@ private fun RoomListContent(
onDismissClicked = { state.eventSink(RoomListEvents.DismissRequestVerificationPrompt) }
)
}
} else if (state.displayRecoveryKeyPrompt) {
item {
ConfirmRecoveryKeyBanner(
onContinueClicked = onOpenSettings,
onDismissClicked = { state.eventSink(RoomListEvents.DismissRecoveryKeyPrompt) }
)
}
}
if (state.invitesState != InvitesState.NoInvites) {

View File

@@ -0,0 +1,49 @@
/*
* 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.roomlist.impl.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.features.roomlist.impl.R
import io.element.android.libraries.designsystem.atomic.molecules.DialogLikeBannerMolecule
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@Composable
internal fun ConfirmRecoveryKeyBanner(
onContinueClicked: () -> Unit,
onDismissClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
DialogLikeBannerMolecule(
modifier = modifier,
title = stringResource(R.string.confirm_recovery_key_banner_title),
content = stringResource(R.string.confirm_recovery_key_banner_message),
onSubmitClicked = onContinueClicked,
onDismissClicked = onDismissClicked,
)
}
@PreviewsDayNight
@Composable
internal fun ConfirmRecoveryKeyBannerPreview() = ElementPreview {
ConfirmRecoveryKeyBanner(
onContinueClicked = {},
onDismissClicked = {},
)
}

View File

@@ -20,6 +20,7 @@ import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -46,11 +47,12 @@ import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import io.element.android.features.roomlist.impl.R
import io.element.android.libraries.designsystem.atomic.atoms.RedIndicatorAtom
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatarBloom
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.applyScaleDown
import io.element.android.libraries.designsystem.text.roundToPx
import io.element.android.libraries.designsystem.text.toDp
@@ -79,6 +81,7 @@ private val avatarBloomSize = 430.dp
@Composable
fun RoomListTopBar(
matrixUser: MatrixUser?,
showAvatarIndicator: Boolean,
areSearchResultsDisplayed: Boolean,
onFilterChanged: (String) -> Unit,
onToggleSearch: () -> Unit,
@@ -103,6 +106,7 @@ fun RoomListTopBar(
DefaultRoomListTopBar(
matrixUser = matrixUser,
showAvatarIndicator = showAvatarIndicator,
areSearchResultsDisplayed = areSearchResultsDisplayed,
onOpenSettings = onOpenSettings,
onSearchClicked = onToggleSearch,
@@ -116,6 +120,7 @@ fun RoomListTopBar(
@Composable
private fun DefaultRoomListTopBar(
matrixUser: MatrixUser?,
showAvatarIndicator: Boolean,
areSearchResultsDisplayed: Boolean,
scrollBehavior: TopAppBarScrollBehavior,
onOpenSettings: () -> Unit,
@@ -198,6 +203,13 @@ private fun DefaultRoomListTopBar(
avatarData = it,
contentDescription = stringResource(CommonStrings.common_settings),
)
if (showAvatarIndicator) {
RedIndicatorAtom(
modifier = Modifier
.padding(4.5.dp)
.align(Alignment.TopEnd)
)
}
}
}
},
@@ -273,6 +285,22 @@ private fun DefaultRoomListTopBar(
internal fun DefaultRoomListTopBarPreview() = ElementPreview {
DefaultRoomListTopBar(
matrixUser = MatrixUser(UserId("@id:domain"), "Alice"),
showAvatarIndicator = false,
areSearchResultsDisplayed = false,
scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()),
onOpenSettings = {},
onSearchClicked = {},
onMenuActionClicked = {},
)
}
@OptIn(ExperimentalMaterial3Api::class)
@PreviewsDayNight
@Composable
internal fun DefaultRoomListTopBarWithIndicatorPreview() = ElementPreview {
DefaultRoomListTopBar(
matrixUser = MatrixUser(UserId("@id:domain"), "Alice"),
showAvatarIndicator = true,
areSearchResultsDisplayed = false,
scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()),
onOpenSettings = {},

View File

@@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="confirm_recovery_key_banner_message">"Your chat backup is currently out of sync. You need to confirm your recovery key to maintain access to your chat backup."</string>
<string name="confirm_recovery_key_banner_title">"Confirm your recovery key"</string>
<string name="screen_roomlist_a11y_create_message">"Create a new conversation or room"</string>
<string name="screen_roomlist_empty_message">"Get started by messaging someone."</string>
<string name="screen_roomlist_empty_title">"No chats yet."</string>

View File

@@ -37,7 +37,10 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
import io.element.android.libraries.eventformatter.test.FakeRoomLastMessageFormatter
import io.element.android.libraries.indicator.impl.DefaultIndicatorService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
@@ -48,6 +51,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
@@ -83,6 +87,32 @@ class RoomListPresenterTests {
Truth.assertThat(withUserState.matrixUser!!.userId).isEqualTo(A_USER_ID)
Truth.assertThat(withUserState.matrixUser!!.displayName).isEqualTo(A_USER_NAME)
Truth.assertThat(withUserState.matrixUser!!.avatarUrl).isEqualTo(AN_AVATAR_URL)
Truth.assertThat(withUserState.showAvatarIndicator).isFalse()
scope.cancel()
}
}
@Test
fun `present - show avatar indicator`() = runTest {
val scope = CoroutineScope(coroutineContext + SupervisorJob())
val encryptionService = FakeEncryptionService()
val sessionVerificationService = FakeSessionVerificationService()
val presenter = createRoomListPresenter(
encryptionService = encryptionService,
sessionVerificationService = sessionVerificationService,
coroutineScope = scope
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
skipItems(1)
val initialState = awaitItem()
Truth.assertThat(initialState.showAvatarIndicator).isFalse()
sessionVerificationService.givenCanVerifySession(false)
Truth.assertThat(awaitItem().showAvatarIndicator).isFalse()
encryptionService.emitBackupState(BackupState.UNKNOWN)
val finalState = awaitItem()
Truth.assertThat(finalState.showAvatarIndicator).isTrue()
scope.cancel()
}
}
@@ -341,7 +371,7 @@ class RoomListPresenterTests {
notificationSettingsService = notificationSettingsService
)
val scope = CoroutineScope(coroutineContext + SupervisorJob())
val presenter = createRoomListPresenter(client = matrixClient , coroutineScope = scope)
val presenter = createRoomListPresenter(client = matrixClient, coroutineScope = scope)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -369,7 +399,8 @@ class RoomListPresenterTests {
givenFormat(A_FORMATTED_DATE)
},
roomLastMessageFormatter: RoomLastMessageFormatter = FakeRoomLastMessageFormatter(),
coroutineScope: CoroutineScope = this,
encryptionService: EncryptionService = FakeEncryptionService(),
coroutineScope: CoroutineScope,
) = RoomListPresenter(
client = client,
sessionVerificationService = sessionVerificationService,
@@ -384,7 +415,12 @@ class RoomListPresenterTests {
coroutineDispatchers = testCoroutineDispatchers(),
notificationSettingsService = client.notificationSettingsService(),
appScope = coroutineScope
)
),
encryptionService = encryptionService,
indicatorService = DefaultIndicatorService(
sessionVerificationService = sessionVerificationService,
encryptionService = encryptionService,
),
)
}

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.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.features.securebackup.api"
}
dependencies {
implementation(projects.libraries.architecture)
}

View File

@@ -14,8 +14,8 @@
* limitations under the License.
*/
package io.element.android.features.logout.api
package io.element.android.features.securebackup.api
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.SimpleFeatureEntryPoint
interface LogoutPreferencePresenter : Presenter<LogoutPreferenceState>
interface SecureBackupEntryPoint : SimpleFeatureEntryPoint

View File

@@ -0,0 +1,56 @@
/*
* 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.
*/
plugins {
id("io.element.android-compose-library")
alias(libs.plugins.ksp)
alias(libs.plugins.anvil)
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.features.securebackup.impl"
}
anvil {
generateDaggerFactories.set(true)
}
dependencies {
anvil(projects.anvilcodegen)
implementation(projects.anvilannotations)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.core)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
api(libs.statemachine)
api(projects.features.securebackup.api)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils)
ksp(libs.showkase.processor)
}

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.securebackup.impl
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.securebackup.api.SecureBackupEntryPoint
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultSecureBackupEntryPoint @Inject constructor() : SecureBackupEntryPoint {
override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
return parentNode.createNode<SecureBackupFlowNode>(buildContext)
}
}

View File

@@ -0,0 +1,25 @@
/*
* 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.securebackup.impl
import io.element.android.libraries.core.log.logger.LoggerTag
private val loggerTag = LoggerTag("SecureBackup")
val loggerTagRoot = LoggerTag("Root", loggerTag)
val loggerTagSetup = LoggerTag("Setup", loggerTag)
val loggerTagDisable = LoggerTag("Disable", loggerTag)

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.securebackup.impl
// TODO Move to appconfig module when it will be available
object SecureBackupConfig {
const val LearnMoreUrl: String = "https://element.io/help#encryption5"
}

View File

@@ -0,0 +1,133 @@
/*
* 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.securebackup.impl
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.composable.Children
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.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.securebackup.impl.disable.SecureBackupDisableNode
import io.element.android.features.securebackup.impl.enable.SecureBackupEnableNode
import io.element.android.features.securebackup.impl.enter.SecureBackupEnterRecoveryKeyNode
import io.element.android.features.securebackup.impl.root.SecureBackupRootNode
import io.element.android.features.securebackup.impl.setup.SecureBackupSetupNode
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
import kotlinx.parcelize.Parcelize
@ContributesNode(SessionScope::class)
class SecureBackupFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
) : BackstackNode<SecureBackupFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Root,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins
) {
sealed interface NavTarget : Parcelable {
@Parcelize
data object Root : NavTarget
@Parcelize
data object Setup : NavTarget
@Parcelize
data object Change : NavTarget
@Parcelize
data object Disable : NavTarget
@Parcelize
data object Enable : NavTarget
@Parcelize
data object EnterRecoveryKey : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Root -> {
val callback = object : SecureBackupRootNode.Callback {
override fun onSetupClicked() {
backstack.push(NavTarget.Setup)
}
override fun onChangeClicked() {
backstack.push(NavTarget.Change)
}
override fun onDisableClicked() {
backstack.push(NavTarget.Disable)
}
override fun onEnableClicked() {
backstack.push(NavTarget.Enable)
}
override fun onConfirmRecoveryKeyClicked() {
backstack.push(NavTarget.EnterRecoveryKey)
}
}
createNode<SecureBackupRootNode>(buildContext, listOf(callback))
}
NavTarget.Setup -> {
val inputs = SecureBackupSetupNode.Inputs(
isChangeRecoveryKeyUserStory = false,
)
createNode<SecureBackupSetupNode>(buildContext, listOf(inputs))
}
NavTarget.Change -> {
val inputs = SecureBackupSetupNode.Inputs(
isChangeRecoveryKeyUserStory = true,
)
createNode<SecureBackupSetupNode>(buildContext, listOf(inputs))
}
NavTarget.Disable -> {
createNode<SecureBackupDisableNode>(buildContext)
}
NavTarget.Enable -> {
createNode<SecureBackupEnableNode>(buildContext)
}
NavTarget.EnterRecoveryKey -> {
createNode<SecureBackupEnterRecoveryKeyNode>(buildContext)
}
}
}
@Composable
override fun View(modifier: Modifier) {
Children(
navModel = backstack,
modifier = modifier,
transitionHandler = rememberDefaultTransitionHandler()
)
}
}

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.securebackup.impl.disable
sealed interface SecureBackupDisableEvents {
data class DisableBackup(val force: Boolean) : SecureBackupDisableEvents
data object DismissDialogs : SecureBackupDisableEvents
}

View File

@@ -0,0 +1,45 @@
/*
* 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.securebackup.impl.disable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
class SecureBackupDisableNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: SecureBackupDisablePresenter,
) : Node(buildContext, plugins = plugins) {
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
SecureBackupDisableView(
state = state,
modifier = modifier,
onDone = ::navigateUp,
)
}
}

View File

@@ -0,0 +1,80 @@
/*
* 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.securebackup.impl.disable
import androidx.compose.runtime.Composable
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.features.securebackup.impl.loggerTagDisable
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.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
class SecureBackupDisablePresenter @Inject constructor(
private val encryptionService: EncryptionService,
private val buildMeta: BuildMeta,
) : Presenter<SecureBackupDisableState> {
@Composable
override fun present(): SecureBackupDisableState {
val backupState by encryptionService.backupStateStateFlow.collectAsState()
Timber.tag(loggerTagDisable.value).d("backupState: $backupState")
val disableAction = remember { mutableStateOf<Async<Unit>>(Async.Uninitialized) }
val coroutineScope = rememberCoroutineScope()
var showDialog by remember { mutableStateOf(false) }
fun handleEvents(event: SecureBackupDisableEvents) {
when (event) {
is SecureBackupDisableEvents.DisableBackup -> if (event.force) {
showDialog = false
coroutineScope.disableBackup(disableAction)
} else {
showDialog = true
}
SecureBackupDisableEvents.DismissDialogs -> {
showDialog = false
disableAction.value = Async.Uninitialized
}
}
}
return SecureBackupDisableState(
backupState = backupState,
disableAction = disableAction.value,
showConfirmationDialog = showDialog,
appName = buildMeta.applicationName,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.disableBackup(disableAction: MutableState<Async<Unit>>) = launch {
suspend {
Timber.tag(loggerTagDisable.value).d("Calling encryptionService.disableRecovery()")
encryptionService.disableRecovery().getOrThrow()
}.runCatchingUpdatingState(disableAction)
}
}

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.securebackup.impl.disable
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.encryption.BackupState
data class SecureBackupDisableState(
val backupState: BackupState,
val disableAction: Async<Unit>,
val showConfirmationDialog: Boolean,
val appName: String,
val eventSink: (SecureBackupDisableEvents) -> Unit
)

View File

@@ -0,0 +1,44 @@
/*
* 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.securebackup.impl.disable
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.encryption.BackupState
open class SecureBackupDisableStateProvider : PreviewParameterProvider<SecureBackupDisableState> {
override val values: Sequence<SecureBackupDisableState>
get() = sequenceOf(
aSecureBackupDisableState(),
aSecureBackupDisableState(showConfirmationDialog = true),
aSecureBackupDisableState(disableAction = Async.Loading()),
aSecureBackupDisableState(disableAction = Async.Failure(Exception("Failed to disable"))),
// Add other states here
)
}
fun aSecureBackupDisableState(
backupState: BackupState = BackupState.UNKNOWN,
disableAction: Async<Unit> = Async.Uninitialized,
showConfirmationDialog: Boolean = false,
) = SecureBackupDisableState(
backupState = backupState,
disableAction = disableAction,
showConfirmationDialog = showConfirmationDialog,
appName = "Element",
eventSink = {}
)

View File

@@ -0,0 +1,162 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.securebackup.impl.disable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.features.securebackup.impl.R
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.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
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.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.theme.ElementTheme
@Composable
fun SecureBackupDisableView(
state: SecureBackupDisableState,
onDone: () -> Unit,
modifier: Modifier = Modifier,
) {
LaunchedEffect(state.disableAction) {
if (state.disableAction is Async.Success) {
onDone()
}
}
HeaderFooterPage(
modifier = modifier,
header = {
HeaderContent()
},
footer = {
BottomMenu(state = state)
}
) {
Content(state = state)
}
if (state.showConfirmationDialog) {
SecureBackupDisableConfirmationDialog(
onConfirm = { state.eventSink.invoke(SecureBackupDisableEvents.DisableBackup(force = true)) },
onDismiss = { state.eventSink.invoke(SecureBackupDisableEvents.DismissDialogs) },
)
} else if (state.disableAction is Async.Failure) {
ErrorDialog(
content = state.disableAction.error.let { it.message ?: it.toString() },
onDismiss = { state.eventSink.invoke(SecureBackupDisableEvents.DismissDialogs) },
)
}
}
@Composable
private fun SecureBackupDisableConfirmationDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
ConfirmationDialog(
title = stringResource(id = R.string.screen_key_backup_disable_confirmation_title),
content = stringResource(id = R.string.screen_key_backup_disable_confirmation_description),
submitText = stringResource(id = R.string.screen_key_backup_disable_confirmation_action_turn_off),
destructiveSubmit = true,
onSubmitClicked = onConfirm,
onDismiss = onDismiss,
)
}
@Composable
private fun HeaderContent(
modifier: Modifier = Modifier,
) {
IconTitleSubtitleMolecule(
modifier = modifier.padding(top = 60.dp),
iconResourceId = CommonDrawables.ic_key_off,
title = stringResource(id = R.string.screen_key_backup_disable_title),
subTitle = stringResource(id = R.string.screen_key_backup_disable_description),
)
}
@Composable
private fun BottomMenu(
state: SecureBackupDisableState,
) {
ButtonColumnMolecule(
modifier = Modifier.padding(bottom = 20.dp)
) {
Button(
text = stringResource(id = R.string.screen_chat_backup_key_backup_action_disable),
showProgress = state.disableAction.isLoading(),
destructive = true,
modifier = Modifier.fillMaxWidth(),
onClick = { state.eventSink.invoke(SecureBackupDisableEvents.DisableBackup(force = false)) }
)
}
}
@Composable
private fun Content(state: SecureBackupDisableState) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 18.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
SecureBackupDisableItem(stringResource(id = R.string.screen_key_backup_disable_description_point_1))
SecureBackupDisableItem(stringResource(id = R.string.screen_key_backup_disable_description_point_2, state.appName))
}
}
@Composable
private fun SecureBackupDisableItem(text: String) {
Row(modifier = Modifier.fillMaxWidth()) {
Icon(
resourceId = CommonDrawables.ic_compound_close,
contentDescription = null,
tint = ElementTheme.colors.iconCriticalPrimary,
modifier = Modifier.size(20.dp)
)
Text(
modifier = Modifier.padding(start = 8.dp, end = 4.dp),
text = text,
color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodyMdRegular,
)
}
}
@PreviewsDayNight
@Composable
internal fun SecureBackupDisableViewPreview(
@PreviewParameter(SecureBackupDisableStateProvider::class) state: SecureBackupDisableState
) = ElementPreview {
SecureBackupDisableView(
state = state,
onDone = {},
)
}

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.securebackup.impl.enable
sealed interface SecureBackupEnableEvents {
data object EnableBackup : SecureBackupEnableEvents
data object DismissDialog : SecureBackupEnableEvents
}

View File

@@ -0,0 +1,45 @@
/*
* 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.securebackup.impl.enable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
class SecureBackupEnableNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: SecureBackupEnablePresenter,
) : Node(buildContext, plugins = plugins) {
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
SecureBackupEnableView(
state = state,
modifier = modifier,
onDone = ::navigateUp,
)
}
}

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.securebackup.impl.enable
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 io.element.android.features.securebackup.impl.loggerTagDisable
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.encryption.EncryptionService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
class SecureBackupEnablePresenter @Inject constructor(
private val encryptionService: EncryptionService,
) : Presenter<SecureBackupEnableState> {
@Composable
override fun present(): SecureBackupEnableState {
val enableAction = remember { mutableStateOf<Async<Unit>>(Async.Uninitialized) }
val coroutineScope = rememberCoroutineScope()
fun handleEvents(event: SecureBackupEnableEvents) {
when (event) {
is SecureBackupEnableEvents.EnableBackup ->
coroutineScope.enableBackup(enableAction)
SecureBackupEnableEvents.DismissDialog -> {
enableAction.value = Async.Uninitialized
}
}
}
return SecureBackupEnableState(
enableAction = enableAction.value,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.enableBackup(action: MutableState<Async<Unit>>) = launch {
suspend {
Timber.tag(loggerTagDisable.value).d("Calling encryptionService.enableBackups()")
encryptionService.enableBackups().getOrThrow()
}.runCatchingUpdatingState(action)
}
}

View File

@@ -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,11 @@
* limitations under the License.
*/
package io.element.android.features.logout.api
package io.element.android.features.securebackup.impl.enable
import io.element.android.libraries.architecture.Async
data class LogoutPreferenceState(
val logoutAction: Async<String?>,
val eventSink: (LogoutPreferenceEvents) -> Unit,
data class SecureBackupEnableState(
val enableAction: Async<Unit>,
val eventSink: (SecureBackupEnableEvents) -> Unit
)

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.securebackup.impl.enable
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.Async
open class SecureBackupEnableStateProvider : PreviewParameterProvider<SecureBackupEnableState> {
override val values: Sequence<SecureBackupEnableState>
get() = sequenceOf(
aSecureBackupEnableState(),
aSecureBackupEnableState(enableAction = Async.Loading()),
aSecureBackupEnableState(enableAction = Async.Failure(Exception("Failed to enable"))),
// Add other states here
)
}
fun aSecureBackupEnableState(
enableAction: Async<Unit> = Async.Uninitialized,
) = SecureBackupEnableState(
enableAction = enableAction,
eventSink = {}
)

View File

@@ -0,0 +1,103 @@
/*
* 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.securebackup.impl.enable
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.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.securebackup.impl.R
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.dialogs.ErrorDialog
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.utils.CommonDrawables
@Composable
fun SecureBackupEnableView(
state: SecureBackupEnableState,
onDone: () -> Unit,
modifier: Modifier = Modifier,
) {
LaunchedEffect(state.enableAction) {
if (state.enableAction is Async.Success) {
onDone()
}
}
HeaderFooterPage(
modifier = modifier,
header = {
HeaderContent()
},
footer = {
BottomMenu(state = state)
}
)
if (state.enableAction is Async.Failure) {
ErrorDialog(
content = state.enableAction.error.let { it.message ?: it.toString() },
onDismiss = { state.eventSink.invoke(SecureBackupEnableEvents.DismissDialog) },
)
}
}
@Composable
private fun HeaderContent(
modifier: Modifier = Modifier,
) {
IconTitleSubtitleMolecule(
modifier = modifier.padding(top = 60.dp),
iconResourceId = CommonDrawables.ic_key,
title = stringResource(id = R.string.screen_chat_backup_key_backup_action_enable),
subTitle = null,
)
}
@Composable
private fun BottomMenu(
state: SecureBackupEnableState,
) {
ButtonColumnMolecule(
modifier = Modifier.padding(bottom = 20.dp)
) {
Button(
text = stringResource(id = R.string.screen_chat_backup_key_backup_action_enable),
showProgress = state.enableAction.isLoading(),
modifier = Modifier.fillMaxWidth(),
onClick = { state.eventSink.invoke(SecureBackupEnableEvents.EnableBackup) }
)
}
}
@PreviewsDayNight
@Composable
internal fun SecureBackupEnableViewPreview(
@PreviewParameter(SecureBackupEnableStateProvider::class) state: SecureBackupEnableState
) = ElementPreview {
SecureBackupEnableView(
state = state,
onDone = {},
)
}

View File

@@ -0,0 +1,23 @@
/*
* 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.securebackup.impl.enter
sealed interface SecureBackupEnterRecoveryKeyEvents {
data class OnRecoveryKeyChange(val recoveryKey: String) : SecureBackupEnterRecoveryKeyEvents
data object Submit : SecureBackupEnterRecoveryKeyEvents
data object ClearDialog : SecureBackupEnterRecoveryKeyEvents
}

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.securebackup.impl.enter
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.securebackup.impl.R
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.di.SessionScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@ContributesNode(SessionScope::class)
class SecureBackupEnterRecoveryKeyNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: SecureBackupEnterRecoveryKeyPresenter,
private val snackbarDispatcher: SnackbarDispatcher,
) : Node(buildContext, plugins = plugins) {
@Composable
override fun View(modifier: Modifier) {
val coroutineScope = rememberCoroutineScope()
val state = presenter.present()
SecureBackupEnterRecoveryKeyView(
state = state,
modifier = modifier,
onDone = {
coroutineScope.postSuccessSnackbar()
navigateUp()
}
)
}
private fun CoroutineScope.postSuccessSnackbar() = launch {
snackbarDispatcher.post(
SnackbarMessage(
messageResId = R.string.screen_recovery_key_confirm_success
)
)
}
}

View File

@@ -0,0 +1,86 @@
/*
* 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.securebackup.impl.enter
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyUserStory
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState
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.encryption.EncryptionService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
class SecureBackupEnterRecoveryKeyPresenter @Inject constructor(
private val encryptionService: EncryptionService,
) : Presenter<SecureBackupEnterRecoveryKeyState> {
@Composable
override fun present(): SecureBackupEnterRecoveryKeyState {
val coroutineScope = rememberCoroutineScope()
var recoveryKey by rememberSaveable {
mutableStateOf("")
}
val submitAction = remember {
mutableStateOf<Async<Unit>>(Async.Uninitialized)
}
fun handleEvents(event: SecureBackupEnterRecoveryKeyEvents) {
when (event) {
SecureBackupEnterRecoveryKeyEvents.ClearDialog -> {
submitAction.value = Async.Uninitialized
}
is SecureBackupEnterRecoveryKeyEvents.OnRecoveryKeyChange -> {
recoveryKey = event.recoveryKey.replace("\\s+".toRegex(), "")
}
SecureBackupEnterRecoveryKeyEvents.Submit -> {
// No need to remove the spaces, the SDK will do it.
coroutineScope.submitRecoveryKey(recoveryKey, submitAction)
}
}
}
return SecureBackupEnterRecoveryKeyState(
recoveryKeyViewState = RecoveryKeyViewState(
recoveryKeyUserStory = RecoveryKeyUserStory.Enter,
formattedRecoveryKey = recoveryKey,
inProgress = submitAction.value.isLoading(),
),
isSubmitEnabled = recoveryKey.isNotEmpty() && submitAction.value.isUninitialized(),
submitAction = submitAction.value,
eventSink = ::handleEvents
)
}
private fun CoroutineScope.submitRecoveryKey(
recoveryKey: String,
action: MutableState<Async<Unit>>
) = launch {
suspend {
encryptionService.fixRecoveryIssues(recoveryKey).getOrThrow()
}.runCatchingUpdatingState(action)
}
}

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.securebackup.impl.enter
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState
import io.element.android.libraries.architecture.Async
// Do not use default value, so no member get forgotten in the presenters.
data class SecureBackupEnterRecoveryKeyState(
val recoveryKeyViewState: RecoveryKeyViewState,
val isSubmitEnabled: Boolean,
val submitAction: Async<Unit>,
val eventSink: (SecureBackupEnterRecoveryKeyEvents) -> Unit
)

View File

@@ -0,0 +1,48 @@
/*
* 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.securebackup.impl.enter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyUserStory
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState
import io.element.android.features.securebackup.impl.setup.views.aFormattedRecoveryKey
import io.element.android.libraries.architecture.Async
open class SecureBackupEnterRecoveryKeyStateProvider : PreviewParameterProvider<SecureBackupEnterRecoveryKeyState> {
override val values: Sequence<SecureBackupEnterRecoveryKeyState>
get() = sequenceOf(
aSecureBackupEnterRecoveryKeyState(recoveryKey = ""),
aSecureBackupEnterRecoveryKeyState(),
aSecureBackupEnterRecoveryKeyState(submitAction = Async.Loading()),
aSecureBackupEnterRecoveryKeyState(submitAction = Async.Failure(Exception("A Failure"))),
)
}
fun aSecureBackupEnterRecoveryKeyState(
recoveryKey: String = aFormattedRecoveryKey(),
isSubmitEnabled: Boolean = recoveryKey.isNotEmpty(),
submitAction: Async<Unit> = Async.Uninitialized,
) = SecureBackupEnterRecoveryKeyState(
recoveryKeyViewState = RecoveryKeyViewState(
recoveryKeyUserStory = RecoveryKeyUserStory.Enter,
formattedRecoveryKey = recoveryKey,
inProgress = submitAction.isLoading(),
),
isSubmitEnabled = isSubmitEnabled,
submitAction = submitAction,
eventSink = {}
)

View File

@@ -0,0 +1,134 @@
/*
* 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.securebackup.impl.enter
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.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.securebackup.impl.R
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyView
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.dialogs.ErrorDialog
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.utils.CommonDrawables
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun SecureBackupEnterRecoveryKeyView(
state: SecureBackupEnterRecoveryKeyState,
onDone: () -> Unit,
modifier: Modifier = Modifier,
) {
when (state.submitAction) {
Async.Uninitialized -> Unit
is Async.Failure -> ErrorDialog(
content = state.submitAction.error.message ?: state.submitAction.error.toString(),
onDismiss = {
state.eventSink(SecureBackupEnterRecoveryKeyEvents.ClearDialog)
}
)
is Async.Loading -> Unit
is Async.Success -> LaunchedEffect(state.submitAction) {
onDone()
}
}
HeaderFooterPage(
modifier = modifier,
header = {
HeaderContent()
},
footer = {
BottomMenu(
state = state,
onSubmit = {
state.eventSink.invoke(SecureBackupEnterRecoveryKeyEvents.Submit)
},
)
}
) {
Content(
state = state,
onChange = {
state.eventSink.invoke(SecureBackupEnterRecoveryKeyEvents.OnRecoveryKeyChange(it))
})
}
}
@Composable
private fun HeaderContent(
modifier: Modifier = Modifier,
) {
IconTitleSubtitleMolecule(
modifier = modifier.padding(top = 60.dp),
iconResourceId = CommonDrawables.ic_key,
title = stringResource(id = R.string.screen_recovery_key_confirm_title),
subTitle = stringResource(id = R.string.screen_recovery_key_confirm_description),
)
}
@Composable
private fun BottomMenu(
state: SecureBackupEnterRecoveryKeyState,
onSubmit: () -> Unit,
) {
ButtonColumnMolecule(
modifier = Modifier.padding(bottom = 20.dp)
) {
Button(
text = stringResource(id = CommonStrings.action_confirm),
enabled = state.isSubmitEnabled,
showProgress = state.submitAction.isLoading(),
modifier = Modifier.fillMaxWidth(),
onClick = onSubmit
)
}
}
@Composable
private fun Content(
state: SecureBackupEnterRecoveryKeyState,
onChange: ((String) -> Unit)?,
) {
RecoveryKeyView(
modifier = Modifier.padding(top = 52.dp),
state = state.recoveryKeyViewState,
onClick = null,
onChange = onChange,
)
}
@PreviewsDayNight
@Composable
internal fun SecureBackupEnterRecoveryKeyViewPreview(
@PreviewParameter(SecureBackupEnterRecoveryKeyStateProvider::class) state: SecureBackupEnterRecoveryKeyState
) = ElementPreview {
SecureBackupEnterRecoveryKeyView(
state = state,
onDone = {},
)
}

View File

@@ -0,0 +1,91 @@
/*
* 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.securebackup.impl.root
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.UriHandler
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.securebackup.impl.SecureBackupConfig
import io.element.android.libraries.di.SessionScope
@ContributesNode(SessionScope::class)
class SecureBackupRootNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
private val presenter: SecureBackupRootPresenter,
) : Node(
buildContext = buildContext,
plugins = plugins
) {
interface Callback : Plugin {
fun onSetupClicked()
fun onChangeClicked()
fun onDisableClicked()
fun onEnableClicked()
fun onConfirmRecoveryKeyClicked()
}
private fun onSetupClicked() {
plugins<Callback>().forEach { it.onSetupClicked() }
}
private fun onChangeClicked() {
plugins<Callback>().forEach { it.onChangeClicked() }
}
private fun onDisableClicked() {
plugins<Callback>().forEach { it.onDisableClicked() }
}
private fun onEnableClicked() {
plugins<Callback>().forEach { it.onEnableClicked() }
}
private fun onConfirmRecoveryKeyClicked() {
plugins<Callback>().forEach { it.onConfirmRecoveryKeyClicked() }
}
private fun onLearnMoreClicked(uriHandler: UriHandler) {
uriHandler.openUri(SecureBackupConfig.LearnMoreUrl)
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
val uriHandler = LocalUriHandler.current
SecureBackupRootView(
state = state,
onBackPressed = ::navigateUp,
onSetupClicked = ::onSetupClicked,
onChangeClicked = ::onChangeClicked,
onEnableClicked = ::onEnableClicked,
onDisableClicked = ::onDisableClicked,
onConfirmRecoveryKeyClicked = ::onConfirmRecoveryKeyClicked,
onLearnMoreClicked = { onLearnMoreClicked(uriHandler) },
modifier = modifier,
)
}
}

View File

@@ -0,0 +1,54 @@
/*
* 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.securebackup.impl.root
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import io.element.android.features.securebackup.impl.loggerTagRoot
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import timber.log.Timber
import javax.inject.Inject
class SecureBackupRootPresenter @Inject constructor(
private val encryptionService: EncryptionService,
private val buildMeta: BuildMeta,
private val snackbarDispatcher: SnackbarDispatcher,
) : Presenter<SecureBackupRootState> {
@Composable
override fun present(): SecureBackupRootState {
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
val backupState by encryptionService.backupStateStateFlow.collectAsState()
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
Timber.tag(loggerTagRoot.value).d("backupState: $backupState")
Timber.tag(loggerTagRoot.value).d("recoveryState: $recoveryState")
return SecureBackupRootState(
backupState = backupState,
recoveryState = recoveryState,
appName = buildMeta.applicationName,
snackbarMessage = snackbarMessage,
)
}
}

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.securebackup.impl.root
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.RecoveryState
data class SecureBackupRootState(
val backupState: BackupState,
val recoveryState: RecoveryState,
val appName: String,
val snackbarMessage: SnackbarMessage?,
)

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.securebackup.impl.root
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.RecoveryState
open class SecureBackupRootStateProvider : PreviewParameterProvider<SecureBackupRootState> {
override val values: Sequence<SecureBackupRootState>
get() = sequenceOf(
aSecureBackupRootState(backupState = BackupState.UNKNOWN),
aSecureBackupRootState(backupState = BackupState.ENABLED),
aSecureBackupRootState(backupState = BackupState.DISABLED),
aSecureBackupRootState(recoveryState = RecoveryState.UNKNOWN),
aSecureBackupRootState(recoveryState = RecoveryState.ENABLED),
aSecureBackupRootState(recoveryState = RecoveryState.DISABLED),
aSecureBackupRootState(recoveryState = RecoveryState.INCOMPLETE),
// Add other states here
)
}
fun aSecureBackupRootState(
backupState: BackupState = BackupState.UNKNOWN,
recoveryState: RecoveryState = RecoveryState.UNKNOWN,
snackbarMessage: SnackbarMessage? = null,
) = SecureBackupRootState(
backupState = backupState,
recoveryState = recoveryState,
appName = "Element",
snackbarMessage = snackbarMessage,
)

View File

@@ -0,0 +1,141 @@
/*
* 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.securebackup.impl.root
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.securebackup.impl.R
import io.element.android.libraries.designsystem.components.async.AsyncLoading
import io.element.android.libraries.designsystem.components.preferences.PreferenceDivider
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
import io.element.android.libraries.designsystem.components.preferences.PreferenceText
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.buildAnnotatedStringWithStyledPart
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun SecureBackupRootView(
state: SecureBackupRootState,
onBackPressed: () -> Unit,
onSetupClicked: () -> Unit,
onChangeClicked: () -> Unit,
onEnableClicked: () -> Unit,
onDisableClicked: () -> Unit,
onConfirmRecoveryKeyClicked: () -> Unit,
onLearnMoreClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
PreferencePage(
modifier = modifier,
onBackPressed = onBackPressed,
title = stringResource(id = CommonStrings.common_chat_backup),
snackbarHost = { SnackbarHost(snackbarHostState) },
) {
val text = buildAnnotatedStringWithStyledPart(
fullTextRes = R.string.screen_chat_backup_key_backup_description,
coloredTextRes = CommonStrings.action_learn_more,
color = ElementTheme.colors.textPrimary,
underline = false,
bold = true,
)
PreferenceText(
title = stringResource(id = R.string.screen_chat_backup_key_backup_title),
subtitleAnnotated = text,
onClick = onLearnMoreClicked,
)
// Disable / Enable backup
when (state.backupState) {
BackupState.UNKNOWN -> Unit
BackupState.DISABLED -> {
PreferenceText(
title = stringResource(id = R.string.screen_chat_backup_key_backup_action_enable),
onClick = onEnableClicked,
)
}
BackupState.CREATING,
BackupState.ENABLING,
BackupState.RESUMING,
BackupState.ENABLED,
BackupState.DOWNLOADING -> {
PreferenceText(
title = stringResource(id = R.string.screen_chat_backup_key_backup_action_disable),
tintColor = ElementTheme.colors.textCriticalPrimary,
onClick = onDisableClicked,
)
}
BackupState.DISABLING -> {
AsyncLoading()
}
}
PreferenceDivider()
// Setup recovery
when (state.recoveryState) {
RecoveryState.UNKNOWN,
RecoveryState.DISABLED -> {
PreferenceText(
title = stringResource(id = R.string.screen_chat_backup_recovery_action_setup),
subtitle = stringResource(id = R.string.screen_chat_backup_recovery_action_setup_description, state.appName),
onClick = onSetupClicked,
showEndBadge = true,
)
}
RecoveryState.ENABLED -> {
PreferenceText(
title = stringResource(id = R.string.screen_chat_backup_recovery_action_change),
onClick = onChangeClicked,
)
}
RecoveryState.INCOMPLETE ->
PreferenceText(
title = stringResource(id = R.string.screen_chat_backup_recovery_action_confirm),
subtitle = stringResource(id = R.string.screen_chat_backup_recovery_action_confirm_description),
showEndBadge = true,
onClick = onConfirmRecoveryKeyClicked,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun SecureBackupRootViewPreview(
@PreviewParameter(SecureBackupRootStateProvider::class) state: SecureBackupRootState
) = ElementPreview {
SecureBackupRootView(
state = state,
onBackPressed = {},
onSetupClicked = {},
onChangeClicked = {},
onEnableClicked = {},
onDisableClicked = {},
onConfirmRecoveryKeyClicked = {},
onLearnMoreClicked = {},
)
}

View File

@@ -0,0 +1,24 @@
/*
* 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.securebackup.impl.setup
sealed interface SecureBackupSetupEvents {
data object CreateRecoveryKey : SecureBackupSetupEvents
data object RecoveryKeyHasBeenSaved : SecureBackupSetupEvents
data object Done : SecureBackupSetupEvents
data object DismissDialog : SecureBackupSetupEvents
}

View File

@@ -0,0 +1,77 @@
/*
* 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.securebackup.impl.setup
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.securebackup.impl.R
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.di.SessionScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@ContributesNode(SessionScope::class)
class SecureBackupSetupNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: SecureBackupSetupPresenter.Factory,
private val snackbarDispatcher: SnackbarDispatcher,
) : Node(buildContext, plugins = plugins) {
data class Inputs(
val isChangeRecoveryKeyUserStory: Boolean,
) : NodeInputs
private val inputs = inputs<Inputs>()
private val presenter = presenterFactory.create(inputs.isChangeRecoveryKeyUserStory)
@Composable
override fun View(modifier: Modifier) {
val coroutineScope = rememberCoroutineScope()
val state = presenter.present()
SecureBackupSetupView(
state = state,
onDone = {
coroutineScope.postSuccessSnackbar()
navigateUp()
},
modifier = modifier,
)
}
private fun CoroutineScope.postSuccessSnackbar() = launch {
snackbarDispatcher.post(
SnackbarMessage(
messageResId = if (inputs.isChangeRecoveryKeyUserStory)
R.string.screen_recovery_key_change_success
else
R.string.screen_recovery_key_setup_success
)
)
}
}

View File

@@ -0,0 +1,142 @@
/*
* 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.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.securebackup.impl.setup
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
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.freeletics.flowredux.compose.StateAndDispatch
import com.freeletics.flowredux.compose.rememberStateAndDispatch
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.securebackup.impl.loggerTagSetup
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyUserStory
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import timber.log.Timber
class SecureBackupSetupPresenter @AssistedInject constructor(
@Assisted private val isChangeRecoveryKeyUserStory: Boolean,
private val stateMachine: SecureBackupSetupStateMachine,
private val encryptionService: EncryptionService,
) : Presenter<SecureBackupSetupState> {
@AssistedFactory
interface Factory {
fun create(isChangeRecoveryKeyUserStory: Boolean): SecureBackupSetupPresenter
}
@Composable
override fun present(): SecureBackupSetupState {
val coroutineScope = rememberCoroutineScope()
val stateAndDispatch = stateMachine.rememberStateAndDispatch()
val setupState by remember {
derivedStateOf { stateAndDispatch.state.value.toSetupState() }
}
var showSaveConfirmationDialog by remember { mutableStateOf(false) }
fun handleEvents(event: SecureBackupSetupEvents) {
when (event) {
SecureBackupSetupEvents.CreateRecoveryKey -> {
coroutineScope.createOrChangeRecoveryKey(stateAndDispatch)
}
SecureBackupSetupEvents.RecoveryKeyHasBeenSaved ->
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserSavedKey)
SecureBackupSetupEvents.DismissDialog -> {
showSaveConfirmationDialog = false
}
SecureBackupSetupEvents.Done -> {
showSaveConfirmationDialog = true
}
}
}
val recoveryKeyViewState = RecoveryKeyViewState(
recoveryKeyUserStory = if (isChangeRecoveryKeyUserStory) RecoveryKeyUserStory.Change else RecoveryKeyUserStory.Setup,
formattedRecoveryKey = setupState.recoveryKey(),
inProgress = setupState is SetupState.Creating,
)
return SecureBackupSetupState(
isChangeRecoveryKeyUserStory = isChangeRecoveryKeyUserStory,
recoveryKeyViewState = recoveryKeyViewState,
setupState = setupState,
showSaveConfirmationDialog = showSaveConfirmationDialog,
eventSink = ::handleEvents
)
}
private fun SecureBackupSetupStateMachine.State?.toSetupState(): SetupState {
return when (this) {
null,
SecureBackupSetupStateMachine.State.Initial -> SetupState.Init
SecureBackupSetupStateMachine.State.CreatingKey -> SetupState.Creating
is SecureBackupSetupStateMachine.State.KeyCreated -> SetupState.Created(formattedRecoveryKey = key)
is SecureBackupSetupStateMachine.State.KeyCreatedAndSaved -> SetupState.CreatedAndSaved(formattedRecoveryKey = key)
}
}
private fun CoroutineScope.createOrChangeRecoveryKey(
stateAndDispatch: StateAndDispatch<SecureBackupSetupStateMachine.State, SecureBackupSetupStateMachine.Event>
) = launch {
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserCreatesKey)
if (isChangeRecoveryKeyUserStory) {
Timber.tag(loggerTagSetup.value).d("Calling encryptionService.resetRecoveryKey()")
encryptionService.resetRecoveryKey().fold(
onSuccess = {
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkHasCreatedKey(it))
},
onFailure = {
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkError(it))
}
)
} else {
observeEncryptionService(stateAndDispatch)
Timber.tag(loggerTagSetup.value).d("Calling encryptionService.enableRecovery()")
encryptionService.enableRecovery(waitForBackupsToUpload = false)
}
}
private fun CoroutineScope.observeEncryptionService(
stateAndDispatch: StateAndDispatch<SecureBackupSetupStateMachine.State, SecureBackupSetupStateMachine.Event>
) = launch {
encryptionService.enableRecoveryProgressStateFlow.collect { enableRecoveryProgress ->
Timber.tag(loggerTagSetup.value).d("New enableRecoveryProgress: ${enableRecoveryProgress.javaClass.simpleName}")
when (enableRecoveryProgress) {
EnableRecoveryProgress.Unknown,
is EnableRecoveryProgress.BackingUp,
EnableRecoveryProgress.CreatingBackup,
EnableRecoveryProgress.CreatingRecoveryKey ->
Unit
is EnableRecoveryProgress.Done ->
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkHasCreatedKey(enableRecoveryProgress.recoveryKey))
}
}
}
}

View File

@@ -0,0 +1,41 @@
/*
* 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.securebackup.impl.setup
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState
// Do not use default value, so no member get forgotten in the presenters.
data class SecureBackupSetupState(
val isChangeRecoveryKeyUserStory: Boolean,
val recoveryKeyViewState: RecoveryKeyViewState,
val showSaveConfirmationDialog: Boolean,
val setupState: SetupState,
val eventSink: (SecureBackupSetupEvents) -> Unit
)
sealed interface SetupState {
data object Init : SetupState
data object Creating : SetupState
data class Created(val formattedRecoveryKey: String) : SetupState
data class CreatedAndSaved(val formattedRecoveryKey: String) : SetupState
}
fun SetupState.recoveryKey(): String? = when (this) {
is SetupState.Created -> formattedRecoveryKey
is SetupState.CreatedAndSaved -> formattedRecoveryKey
else -> null
}

View File

@@ -0,0 +1,70 @@
/*
* 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.
*/
@file:Suppress("WildcardImport")
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.securebackup.impl.setup
import com.freeletics.flowredux.dsl.FlowReduxStateMachine
import kotlinx.coroutines.ExperimentalCoroutinesApi
import javax.inject.Inject
import com.freeletics.flowredux.dsl.State as MachineState
class SecureBackupSetupStateMachine @Inject constructor(
) : FlowReduxStateMachine<SecureBackupSetupStateMachine.State, SecureBackupSetupStateMachine.Event>(
initialState = State.Initial
) {
init {
spec {
inState<State.Initial> {
on { _: Event.UserCreatesKey, state: MachineState<State.Initial> ->
state.override { State.CreatingKey }
}
}
inState<State.CreatingKey> {
on { _: Event.SdkError, state: MachineState<State.CreatingKey> ->
state.override { State.Initial }
}
on { event: Event.SdkHasCreatedKey, state: MachineState<State.CreatingKey> ->
state.override { State.KeyCreated(event.key) }
}
}
inState<State.KeyCreated> {
on { _: Event.UserSavedKey, state: MachineState<State.KeyCreated> ->
state.override { State.KeyCreatedAndSaved(state.snapshot.key) }
}
}
inState<State.KeyCreatedAndSaved> {
}
}
}
sealed interface State {
data object Initial : State
data object CreatingKey : State
data class KeyCreated(val key: String) : State
data class KeyCreatedAndSaved(val key: String) : State
}
sealed interface Event {
data object UserCreatesKey : Event
data class SdkHasCreatedKey(val key: String) : Event
data class SdkError(val throwable: Throwable) : Event
data object UserSavedKey : Event
}
}

View File

@@ -0,0 +1,56 @@
/*
* 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.securebackup.impl.setup
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyUserStory
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState
import io.element.android.features.securebackup.impl.setup.views.aFormattedRecoveryKey
open class SecureBackupSetupStateProvider : PreviewParameterProvider<SecureBackupSetupState> {
override val values: Sequence<SecureBackupSetupState>
get() = sequenceOf(
aSecureBackupSetupState(setupState = SetupState.Init),
aSecureBackupSetupState(setupState = SetupState.Creating),
aSecureBackupSetupState(setupState = SetupState.Created(aFormattedRecoveryKey())),
aSecureBackupSetupState(setupState = SetupState.CreatedAndSaved(aFormattedRecoveryKey())),
aSecureBackupSetupState(
setupState = SetupState.CreatedAndSaved(aFormattedRecoveryKey()),
showSaveConfirmationDialog = true,
),
// Add other states here
)
}
fun aSecureBackupSetupState(
setupState: SetupState = SetupState.Init,
showSaveConfirmationDialog: Boolean = false,
) = SecureBackupSetupState(
isChangeRecoveryKeyUserStory = false,
setupState = setupState,
showSaveConfirmationDialog = showSaveConfirmationDialog,
recoveryKeyViewState = setupState.toRecoveryKeyViewState(),
eventSink = {}
)
private fun SetupState.toRecoveryKeyViewState(): RecoveryKeyViewState {
return RecoveryKeyViewState(
recoveryKeyUserStory = RecoveryKeyUserStory.Setup,
formattedRecoveryKey = recoveryKey(),
inProgress = this is SetupState.Creating,
)
}

View File

@@ -0,0 +1,207 @@
/*
* 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.securebackup.impl.setup
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.securebackup.impl.R
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyView
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState
import io.element.android.libraries.androidutils.system.copyToClipboard
import io.element.android.libraries.androidutils.system.startSharePlainTextIntent
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.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.IconSource
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun SecureBackupSetupView(
state: SecureBackupSetupState,
onDone: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
HeaderFooterPage(
modifier = modifier,
header = {
HeaderContent(state = state)
},
footer = {
val chooserTitle = stringResource(id = R.string.screen_recovery_key_save_action)
BottomMenu(
state = state,
onSaveClicked = { key ->
context.startSharePlainTextIntent(
activityResultLauncher = null,
chooserTitle = chooserTitle,
text = key,
)
state.eventSink.invoke(SecureBackupSetupEvents.RecoveryKeyHasBeenSaved)
},
onDone = {
if (state.setupState is SetupState.CreatedAndSaved) {
onDone()
} else {
state.eventSink.invoke(SecureBackupSetupEvents.Done)
}
},
)
}
) {
val formattedRecoveryKey = state.recoveryKeyViewState.formattedRecoveryKey
val clickLambda = if (formattedRecoveryKey != null) {
{
context.copyToClipboard(
formattedRecoveryKey,
context.getString(R.string.screen_recovery_key_copied_to_clipboard)
)
state.eventSink.invoke(SecureBackupSetupEvents.RecoveryKeyHasBeenSaved)
}
} else {
if (!state.recoveryKeyViewState.inProgress) {
{
state.eventSink.invoke(SecureBackupSetupEvents.CreateRecoveryKey)
}
} else {
null
}
}
Content(state = state.recoveryKeyViewState, onClick = clickLambda)
}
if (state.showSaveConfirmationDialog) {
ConfirmationDialog(
title = stringResource(id = R.string.screen_recovery_key_setup_confirmation_title),
content = stringResource(id = R.string.screen_recovery_key_setup_confirmation_description),
submitText = stringResource(id = CommonStrings.action_continue),
onSubmitClicked = onDone,
onDismiss = {
state.eventSink.invoke(SecureBackupSetupEvents.DismissDialog)
}
)
}
}
@Composable
private fun HeaderContent(
state: SecureBackupSetupState,
modifier: Modifier = Modifier,
) {
val setupState = state.setupState
val title = when (setupState) {
SetupState.Init,
SetupState.Creating -> if (state.isChangeRecoveryKeyUserStory)
stringResource(id = R.string.screen_recovery_key_change_title)
else
stringResource(id = R.string.screen_recovery_key_setup_title)
is SetupState.Created,
is SetupState.CreatedAndSaved ->
stringResource(id = R.string.screen_recovery_key_save_title)
}
val subTitle = when (setupState) {
SetupState.Init,
SetupState.Creating -> if (state.isChangeRecoveryKeyUserStory)
stringResource(id = R.string.screen_recovery_key_change_description)
else
stringResource(id = R.string.screen_recovery_key_setup_description)
is SetupState.Created,
is SetupState.CreatedAndSaved ->
stringResource(id = R.string.screen_recovery_key_save_description)
}
IconTitleSubtitleMolecule(
modifier = modifier.padding(top = 60.dp),
iconResourceId = CommonDrawables.ic_key,
title = title,
subTitle = subTitle,
)
}
@Composable
private fun BottomMenu(
state: SecureBackupSetupState,
onSaveClicked: (String) -> Unit,
onDone: () -> Unit,
) {
val setupState = state.setupState
ButtonColumnMolecule(
modifier = Modifier.padding(bottom = 20.dp)
) {
when (setupState) {
SetupState.Init,
SetupState.Creating -> {
Button(
text = stringResource(id = CommonStrings.action_done),
enabled = false,
modifier = Modifier.fillMaxWidth(),
onClick = onDone
)
}
is SetupState.Created,
is SetupState.CreatedAndSaved -> {
OutlinedButton(
text = stringResource(id = R.string.screen_recovery_key_save_action),
leadingIcon = IconSource.Resource(CommonDrawables.ic_compound_download),
modifier = Modifier.fillMaxWidth(),
onClick = { onSaveClicked(setupState.recoveryKey()!!) },
)
Button(
text = stringResource(id = CommonStrings.action_done),
modifier = Modifier.fillMaxWidth(),
onClick = onDone,
)
}
}
}
}
@Composable
private fun Content(
state: RecoveryKeyViewState,
onClick: (() -> Unit)?,
) {
val modifier = Modifier.padding(top = 52.dp)
RecoveryKeyView(
modifier = modifier,
state = state,
onClick = onClick,
onChange = null,
)
}
@PreviewsDayNight
@Composable
internal fun SecureBackupSetupViewPreview(
@PreviewParameter(SecureBackupSetupStateProvider::class) state: SecureBackupSetupState
) = ElementPreview {
SecureBackupSetupView(
state = state,
onDone = {},
)
}

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.securebackup.impl.setup
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyUserStory
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@PreviewsDayNight
@Composable
internal fun SecureBackupSetupViewChangePreview(
@PreviewParameter(SecureBackupSetupStateProvider::class) state: SecureBackupSetupState
) = ElementPreview {
SecureBackupSetupView(
state = state.copy(
isChangeRecoveryKeyUserStory = true,
recoveryKeyViewState = state.recoveryKeyViewState.copy(recoveryKeyUserStory = RecoveryKeyUserStory.Change),
),
onDone = {},
)
}

View File

@@ -0,0 +1,221 @@
/*
* 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.securebackup.impl.setup.views
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.progressSemantics
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.securebackup.impl.R
import io.element.android.features.securebackup.impl.tools.RecoveryKeyVisualTransformation
import io.element.android.libraries.designsystem.modifiers.clickableIfNotNull
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.OutlinedTextField
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun RecoveryKeyView(
state: RecoveryKeyViewState,
onClick: (() -> Unit)?,
onChange: ((String) -> Unit)?,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = stringResource(id = CommonStrings.common_recovery_key),
modifier = Modifier.padding(start = 16.dp),
style = ElementTheme.typography.fontBodyMdRegular,
)
RecoveryKeyContent(state, onClick, onChange)
RecoveryKeyFooter(state)
}
}
@Composable
private fun RecoveryKeyContent(
state: RecoveryKeyViewState,
onClick: (() -> Unit)?,
onChange: ((String) -> Unit)?,
) {
when (state.recoveryKeyUserStory) {
RecoveryKeyUserStory.Setup,
RecoveryKeyUserStory.Change -> RecoveryKeyStaticContent(state, onClick)
RecoveryKeyUserStory.Enter -> RecoveryKeyFormContent(state, onChange)
}
}
@Composable
private fun RecoveryKeyStaticContent(
state: RecoveryKeyViewState,
onClick: (() -> Unit)?,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(
color = ElementTheme.colors.bgSubtleSecondary,
shape = RoundedCornerShape(14.dp)
)
.clickableIfNotNull(onClick)
.padding(horizontal = 16.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
if (state.formattedRecoveryKey != null) {
Text(
text = state.formattedRecoveryKey,
modifier = Modifier.weight(1f),
)
Icon(
resourceId = CommonDrawables.ic_september_copy,
contentDescription = stringResource(id = CommonStrings.action_copy),
tint = ElementTheme.colors.iconSecondary,
)
} else {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 11.dp)
) {
if (state.inProgress) {
CircularProgressIndicator(
modifier = Modifier
.progressSemantics()
.padding(end = 8.dp)
.size(16.dp),
color = ElementTheme.colors.textPrimary,
strokeWidth = 1.5.dp,
)
}
Text(
text = stringResource(
id = when {
state.inProgress -> R.string.screen_recovery_key_generating_key
state.recoveryKeyUserStory == RecoveryKeyUserStory.Change -> R.string.screen_recovery_key_change_generate_key
else -> R.string.screen_recovery_key_setup_generate_key
}
),
textAlign = TextAlign.Center,
style = ElementTheme.typography.fontBodyLgMedium,
)
}
}
}
}
@Composable
private fun RecoveryKeyFormContent(state: RecoveryKeyViewState, onChange: ((String) -> Unit)?) {
onChange ?: error("onChange should not be null")
val recoveryKeyVisualTransformation = remember {
RecoveryKeyVisualTransformation()
}
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
minLines = 2,
value = state.formattedRecoveryKey.orEmpty(),
onValueChange = onChange,
enabled = state.inProgress.not(),
visualTransformation = recoveryKeyVisualTransformation,
label = { Text(text = stringResource(id = R.string.screen_recovery_key_confirm_key_placeholder)) }
)
}
@Composable
private fun RecoveryKeyFooter(state: RecoveryKeyViewState) {
when (state.recoveryKeyUserStory) {
RecoveryKeyUserStory.Setup,
RecoveryKeyUserStory.Change -> {
if (state.formattedRecoveryKey == null) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
resourceId = CommonDrawables.ic_compound_info,
contentDescription = null,
tint = ElementTheme.colors.iconSecondary,
modifier = Modifier
.padding(start = 16.dp)
.size(20.dp),
)
Text(
text = stringResource(
id = if (state.recoveryKeyUserStory == RecoveryKeyUserStory.Change)
R.string.screen_recovery_key_change_generate_key_description
else
R.string.screen_recovery_key_setup_generate_key_description
),
color = ElementTheme.colors.textSecondary,
modifier = Modifier.padding(start = 8.dp),
style = ElementTheme.typography.fontBodySmRegular,
)
}
} else {
Text(
text = stringResource(id = R.string.screen_recovery_key_save_key_description),
color = ElementTheme.colors.textSecondary,
modifier = Modifier.padding(start = 16.dp),
style = ElementTheme.typography.fontBodySmRegular,
)
}
}
RecoveryKeyUserStory.Enter -> {
Text(
text = stringResource(id = R.string.screen_recovery_key_confirm_key_description),
color = ElementTheme.colors.textSecondary,
modifier = Modifier.padding(start = 16.dp),
style = ElementTheme.typography.fontBodySmRegular,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun RecoveryKeyViewPreview(
@PreviewParameter(RecoveryKeyViewStateProvider::class) state: RecoveryKeyViewState
) = ElementPreview {
RecoveryKeyView(
state = state,
onClick = {},
onChange = {},
)
}

View File

@@ -0,0 +1,29 @@
/*
* 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.securebackup.impl.setup.views
data class RecoveryKeyViewState(
val recoveryKeyUserStory: RecoveryKeyUserStory,
val formattedRecoveryKey: String?,
val inProgress: Boolean,
)
enum class RecoveryKeyUserStory {
Setup,
Change,
Enter,
}

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.securebackup.impl.setup.views
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
open class RecoveryKeyViewStateProvider : PreviewParameterProvider<RecoveryKeyViewState> {
override val values: Sequence<RecoveryKeyViewState>
get() = sequenceOf(RecoveryKeyUserStory.Setup, RecoveryKeyUserStory.Change, RecoveryKeyUserStory.Enter)
.flatMap {
sequenceOf(
aRecoveryKeyViewState(recoveryKeyUserStory = it),
aRecoveryKeyViewState(recoveryKeyUserStory = it, inProgress = true),
aRecoveryKeyViewState(recoveryKeyUserStory = it, formattedRecoveryKey = aFormattedRecoveryKey()),
aRecoveryKeyViewState(recoveryKeyUserStory = it, formattedRecoveryKey = aFormattedRecoveryKey(), inProgress = true),
// Add other states here
)
}
}
fun aRecoveryKeyViewState(
recoveryKeyUserStory: RecoveryKeyUserStory = RecoveryKeyUserStory.Setup,
formattedRecoveryKey: String? = null,
inProgress: Boolean = false,
) = RecoveryKeyViewState(
recoveryKeyUserStory = recoveryKeyUserStory,
formattedRecoveryKey = formattedRecoveryKey,
inProgress = inProgress,
)
internal fun aFormattedRecoveryKey(): String {
return "Estm dfyU adhD h8y6 Estm dfyU adhD h8y6 Estm dfyU adhD h8y6"
}

View File

@@ -0,0 +1,51 @@
/*
* 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.securebackup.impl.tools
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
class RecoveryKeyVisualTransformation : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
return TransformedText(
text = AnnotatedString(
text.text
.chunked(4)
.joinToString(separator = " ")
),
offsetMapping = RecoveryKeyOffsetMapping(text.text),
)
}
class RecoveryKeyOffsetMapping(private val text: String) : OffsetMapping {
override fun originalToTransformed(offset: Int): Int {
if (offset == 0) return 0
val numberOfChunks = offset / 4
return if (offset == text.length && offset % 4 == 0)
offset + numberOfChunks - 1
else
offset + numberOfChunks
}
override fun transformedToOriginal(offset: Int): Int {
val numberOfChunks = offset / 5
return offset - numberOfChunks
}
}
}

View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_chat_backup_key_backup_action_disable">"Vypnúť zálohovanie"</string>
<string name="screen_chat_backup_key_backup_action_enable">"Zapnúť zálohovanie"</string>
<string name="screen_chat_backup_key_backup_description">"Zálohovanie zaisťuje, že nestratíte históriu správ. %1$s."</string>
<string name="screen_chat_backup_key_backup_title">"Zálohovanie"</string>
<string name="screen_chat_backup_recovery_action_change">"Zmeniť kľúč na obnovenie"</string>
<string name="screen_chat_backup_recovery_action_confirm">"Potvrdiť kľúč na obnovenie"</string>
<string name="screen_chat_backup_recovery_action_confirm_description">"Vaša záloha konverzácie nie je momentálne synchronizovaná."</string>
<string name="screen_chat_backup_recovery_action_setup">"Nastaviť obnovovanie"</string>
<string name="screen_chat_backup_recovery_action_setup_description">"Získajte prístup k vašim šifrovaným správam aj keď stratíte všetky svoje zariadenia alebo sa odhlásite zo všetkých %1$s zariadení."</string>
<string name="screen_key_backup_disable_confirmation_action_turn_off">"Vypnúť"</string>
<string name="screen_key_backup_disable_confirmation_description">"Stratíte prístup k svojim zašifrovaným správam, ak sa odhlásite zo všetkých zariadení"</string>
<string name="screen_key_backup_disable_confirmation_title">"Ste si istí, že chcete vypnúť zálohovanie?"</string>
<string name="screen_key_backup_disable_description">"Vypnutím zálohovania sa odstráni aktuálna záloha šifrovacích kľúčov a vypnú sa ďalšie bezpečnostné funkcie. V tomto prípade:"</string>
<string name="screen_key_backup_disable_description_point_1">"Na nových zariadeniach nebudete mať zašifrovanú históriu správ"</string>
<string name="screen_key_backup_disable_description_point_2">"Stratíte prístup k svojim zašifrovaným správam, ak sa odhlásite zo všetkých %1$s zariadení"</string>
<string name="screen_key_backup_disable_title">"Ste si istí, že chcete vypnúť zálohovanie?"</string>
<string name="screen_recovery_key_change_description">"Získajte nový kľúč na obnovenie, ak ste stratili svoj existujúci. Po zmene kľúča na obnovenie už starý kľúč nebude fungovať."</string>
<string name="screen_recovery_key_change_generate_key">"Vygenerovať nový kľúč na obnovenie"</string>
<string name="screen_recovery_key_change_generate_key_description">"Uistite sa, že kľúč na obnovenie môžete uložiť niekde v bezpečí"</string>
<string name="screen_recovery_key_change_success">"Kľúč na obnovenie bol zmenený"</string>
<string name="screen_recovery_key_change_title">"Zmeniť kľúč na obnovenie?"</string>
<string name="screen_recovery_key_confirm_description">"Zadajte kľúč na obnovenie a potvrďte prístup k zálohe konverzácie."</string>
<string name="screen_recovery_key_confirm_key_description">"Zadajte 48-znakový kód."</string>
<string name="screen_recovery_key_confirm_key_placeholder">"Zadať…"</string>
<string name="screen_recovery_key_confirm_success">"Kľúč na obnovu potvrdený"</string>
<string name="screen_recovery_key_confirm_title">"Potvrďte kľúč na obnovenie"</string>
<string name="screen_recovery_key_copied_to_clipboard">"Skopírovaný kľúč na obnovenie"</string>
<string name="screen_recovery_key_generating_key">"Generovanie…"</string>
<string name="screen_recovery_key_save_action">"Uložiť kľúč na obnovenie"</string>
<string name="screen_recovery_key_save_description">"Zapíšte si kľúč na obnovenie na bezpečné miesto alebo ho uložte do správcu hesiel."</string>
<string name="screen_recovery_key_save_key_description">"Ťuknutím skopírujte kľúč na obnovenie"</string>
<string name="screen_recovery_key_save_title">"Uložte svoj kľúč na obnovenie"</string>
<string name="screen_recovery_key_setup_confirmation_description">"Po tomto kroku nebudete mať prístup k novému kľúču na obnovenie."</string>
<string name="screen_recovery_key_setup_confirmation_title">"Uložili ste kľúč na obnovenie?"</string>
<string name="screen_recovery_key_setup_description">"Vaša záloha konverzácie je chránená kľúčom na obnovenie. Ak potrebujete nový kľúč na obnovenie, po nastavení si ho môžete znova vytvoriť výberom položky „Zmeniť kľúč na obnovenie“."</string>
<string name="screen_recovery_key_setup_generate_key">"Vygenerujte si váš kľúč na obnovenie"</string>
<string name="screen_recovery_key_setup_generate_key_description">"Uistite sa, že kľúč na obnovenie môžete uložiť niekde v bezpečí"</string>
<string name="screen_recovery_key_setup_success">"Úspešné nastavenie obnovy"</string>
<string name="screen_recovery_key_setup_title">"Nastaviť obnovenie"</string>
</resources>

View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_chat_backup_key_backup_action_disable">"Turn off backup"</string>
<string name="screen_chat_backup_key_backup_action_enable">"Turn on backup"</string>
<string name="screen_chat_backup_key_backup_description">"Backup ensures that you don\'t lose your message history. %1$s."</string>
<string name="screen_chat_backup_key_backup_title">"Backup"</string>
<string name="screen_chat_backup_recovery_action_change">"Change recovery key"</string>
<string name="screen_chat_backup_recovery_action_confirm">"Confirm recovery key"</string>
<string name="screen_chat_backup_recovery_action_confirm_description">"Your chat backup is currently out of sync."</string>
<string name="screen_chat_backup_recovery_action_setup">"Set up recovery"</string>
<string name="screen_chat_backup_recovery_action_setup_description">"Get access to your encrypted messages if you lose all your devices or are signed out of %1$s everywhere."</string>
<string name="screen_key_backup_disable_confirmation_action_turn_off">"Turn off"</string>
<string name="screen_key_backup_disable_confirmation_description">"You will lose your encrypted messages if you are signed out of all devices."</string>
<string name="screen_key_backup_disable_confirmation_title">"Are you sure you want to turn off backup?"</string>
<string name="screen_key_backup_disable_description">"Turning off backup will remove your current encryption key backup and turn off other security features. In this case, you will:"</string>
<string name="screen_key_backup_disable_description_point_1">"Not have encrypted message history on new devices"</string>
<string name="screen_key_backup_disable_description_point_2">"Lose access to your encrypted messages if you are signed out of %1$s everywhere"</string>
<string name="screen_key_backup_disable_title">"Are you sure you want to turn off backup?"</string>
<string name="screen_recovery_key_change_description">"Get a new recovery key if you\'ve lost your existing one. After changing your recovery key, your old one will no longer work."</string>
<string name="screen_recovery_key_change_generate_key">"Generate a new recovery key"</string>
<string name="screen_recovery_key_change_generate_key_description">"Make sure you can store your recovery key somewhere safe"</string>
<string name="screen_recovery_key_change_success">"Recovery key changed"</string>
<string name="screen_recovery_key_change_title">"Change recovery key?"</string>
<string name="screen_recovery_key_confirm_description">"Enter your recovery key to confirm access to your chat backup."</string>
<string name="screen_recovery_key_confirm_key_description">"Enter the 48 character code."</string>
<string name="screen_recovery_key_confirm_key_placeholder">"Enter…"</string>
<string name="screen_recovery_key_confirm_success">"Recovery key confirmed"</string>
<string name="screen_recovery_key_confirm_title">"Confirm your recovery key"</string>
<string name="screen_recovery_key_copied_to_clipboard">"Copied recovery key"</string>
<string name="screen_recovery_key_generating_key">"Generating…"</string>
<string name="screen_recovery_key_save_action">"Save recovery key"</string>
<string name="screen_recovery_key_save_description">"Write down your recovery key somewhere safe or save it in a password manager."</string>
<string name="screen_recovery_key_save_key_description">"Tap to copy recovery key"</string>
<string name="screen_recovery_key_save_title">"Save your recovery key"</string>
<string name="screen_recovery_key_setup_confirmation_description">"You will not be able to access your new recovery key after this step."</string>
<string name="screen_recovery_key_setup_confirmation_title">"Have you saved your recovery key?"</string>
<string name="screen_recovery_key_setup_description">"Your chat backup is protected by a recovery key. If you need a new recovery key after setup you can recreate by selecting Change recovery key."</string>
<string name="screen_recovery_key_setup_generate_key">"Generate your recovery key"</string>
<string name="screen_recovery_key_setup_generate_key_description">"Make sure you can store your recovery key somewhere safe"</string>
<string name="screen_recovery_key_setup_success">"Recovery setup successful"</string>
<string name="screen_recovery_key_setup_title">"Set up recovery"</string>
</resources>

View File

@@ -0,0 +1,129 @@
/*
* 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.securebackup.impl.disable
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.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.test.core.aBuildMeta
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 SecureBackupDisablePresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val presenter = createSecureBackupDisablePresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.backupState).isEqualTo(BackupState.UNKNOWN)
assertThat(initialState.disableAction).isEqualTo(Async.Uninitialized)
assertThat(initialState.showConfirmationDialog).isFalse()
assertThat(initialState.appName).isEqualTo("Element")
}
}
@Test
fun `present - user delete backup and cancel`() = runTest {
val presenter = createSecureBackupDisablePresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.showConfirmationDialog).isFalse()
initialState.eventSink(SecureBackupDisableEvents.DisableBackup(force = false))
val state = awaitItem()
assertThat(state.showConfirmationDialog).isTrue()
initialState.eventSink(SecureBackupDisableEvents.DismissDialogs)
val finalState = awaitItem()
assertThat(finalState.showConfirmationDialog).isFalse()
}
}
@Test
fun `present - user delete backup success`() = runTest {
val presenter = createSecureBackupDisablePresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.showConfirmationDialog).isFalse()
initialState.eventSink(SecureBackupDisableEvents.DisableBackup(force = false))
val state = awaitItem()
assertThat(state.showConfirmationDialog).isTrue()
initialState.eventSink(SecureBackupDisableEvents.DisableBackup(force = true))
skipItems(1)
val loadingState = awaitItem()
assertThat(loadingState.showConfirmationDialog).isFalse()
assertThat(loadingState.disableAction).isInstanceOf(Async.Loading::class.java)
val finalState = awaitItem()
assertThat(finalState.disableAction).isEqualTo(Async.Success(Unit))
}
}
@Test
fun `present - user delete backup error`() = runTest {
val encryptionService = FakeEncryptionService().apply {
givenDisableRecoveryFailure(Exception("failure"))
}
val presenter = createSecureBackupDisablePresenter(
encryptionService = encryptionService
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.showConfirmationDialog).isFalse()
initialState.eventSink(SecureBackupDisableEvents.DisableBackup(force = false))
val state = awaitItem()
assertThat(state.showConfirmationDialog).isTrue()
initialState.eventSink(SecureBackupDisableEvents.DisableBackup(force = true))
skipItems(1)
val loadingState = awaitItem()
assertThat(loadingState.showConfirmationDialog).isFalse()
assertThat(loadingState.disableAction).isInstanceOf(Async.Loading::class.java)
val errorState = awaitItem()
assertThat(errorState.disableAction).isInstanceOf(Async.Failure::class.java)
errorState.eventSink(SecureBackupDisableEvents.DismissDialogs)
val finalState = awaitItem()
assertThat(finalState.disableAction).isEqualTo(Async.Uninitialized)
}
}
private fun createSecureBackupDisablePresenter(
encryptionService: EncryptionService = FakeEncryptionService(),
appName: String = "Element",
): SecureBackupDisablePresenter {
return SecureBackupDisablePresenter(
encryptionService = encryptionService,
buildMeta = aBuildMeta(
applicationName = appName,
)
)
}
}

View File

@@ -0,0 +1,66 @@
/*
* 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.securebackup.impl.enable
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.encryption.EncryptionService
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 SecureBackupEnablePresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val presenter = createPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.enableAction).isEqualTo(Async.Uninitialized)
}
}
@Test
fun `present - user enable backup`() = runTest {
val presenter = createPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(SecureBackupEnableEvents.EnableBackup)
val loadingState = awaitItem()
assertThat(loadingState.enableAction).isInstanceOf(Async.Loading::class.java)
val finalState = awaitItem()
assertThat(finalState.enableAction).isEqualTo(Async.Success(Unit))
}
}
private fun createPresenter(
encryptionService: EncryptionService = FakeEncryptionService(),
) = SecureBackupEnablePresenter(
encryptionService = encryptionService,
)
}

View File

@@ -0,0 +1,103 @@
/*
* 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.securebackup.impl.enter
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.securebackup.impl.setup.views.RecoveryKeyUserStory
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.test.AN_EXCEPTION
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 SecureBackupEnterRecoveryKeyPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val presenter = createPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.isSubmitEnabled).isFalse()
assertThat(initialState.submitAction).isEqualTo(Async.Uninitialized)
assertThat(initialState.recoveryKeyViewState).isEqualTo(
RecoveryKeyViewState(
recoveryKeyUserStory = RecoveryKeyUserStory.Enter,
formattedRecoveryKey = "",
inProgress = false,
)
)
}
}
@Test
fun `present - enter recovery key`() = runTest {
val encryptionService = FakeEncryptionService()
val presenter = createPresenter(encryptionService)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(SecureBackupEnterRecoveryKeyEvents.OnRecoveryKeyChange("1234"))
val withRecoveryKeyState = awaitItem()
assertThat(withRecoveryKeyState.isSubmitEnabled).isTrue()
assertThat(withRecoveryKeyState.recoveryKeyViewState).isEqualTo(
RecoveryKeyViewState(
recoveryKeyUserStory = RecoveryKeyUserStory.Enter,
formattedRecoveryKey = "1234",
inProgress = false,
)
)
encryptionService.givenFixRecoveryIssuesFailure(AN_EXCEPTION)
withRecoveryKeyState.eventSink(SecureBackupEnterRecoveryKeyEvents.Submit)
val loadingState = awaitItem()
assertThat(loadingState.submitAction).isEqualTo(Async.Loading<Unit>())
assertThat(loadingState.isSubmitEnabled).isFalse()
val errorState = awaitItem()
assertThat(errorState.submitAction).isEqualTo(Async.Failure<Unit>(AN_EXCEPTION))
assertThat(errorState.isSubmitEnabled).isFalse()
errorState.eventSink(SecureBackupEnterRecoveryKeyEvents.ClearDialog)
val clearedState = awaitItem()
assertThat(clearedState.submitAction).isEqualTo(Async.Uninitialized)
assertThat(clearedState.isSubmitEnabled).isTrue()
encryptionService.givenFixRecoveryIssuesFailure(null)
clearedState.eventSink(SecureBackupEnterRecoveryKeyEvents.Submit)
val loadingState2 = awaitItem()
assertThat(loadingState2.submitAction).isEqualTo(Async.Loading<Unit>())
assertThat(loadingState2.isSubmitEnabled).isFalse()
val finalState = awaitItem()
assertThat(finalState.submitAction).isEqualTo(Async.Success(Unit))
assertThat(finalState.isSubmitEnabled).isFalse()
}
}
private fun createPresenter(
encryptionService: EncryptionService = FakeEncryptionService(),
) = SecureBackupEnterRecoveryKeyPresenter(
encryptionService = encryptionService,
)
}

View File

@@ -0,0 +1,59 @@
/*
* 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.securebackup.impl.root
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.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.matrix.api.encryption.BackupState
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.test.core.aBuildMeta
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 SecureBackupRootPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val presenter = createSecureBackupRootPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.backupState).isEqualTo(BackupState.UNKNOWN)
assertThat(initialState.appName).isEqualTo("Element")
}
}
private fun createSecureBackupRootPresenter(
encryptionService: EncryptionService = FakeEncryptionService(),
appName: String = "Element",
): SecureBackupRootPresenter {
return SecureBackupRootPresenter(
encryptionService = encryptionService,
buildMeta = aBuildMeta(applicationName = appName),
snackbarDispatcher = SnackbarDispatcher(),
)
}
}

View File

@@ -0,0 +1,173 @@
/*
* 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.securebackup.impl.setup
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.securebackup.impl.setup.views.RecoveryKeyUserStory
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState
import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.test.A_RECOVERY_KEY
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 SecureBackupSetupPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val presenter = createSecureBackupSetupPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.isChangeRecoveryKeyUserStory).isFalse()
assertThat(initialState.setupState).isEqualTo(SetupState.Init)
assertThat(initialState.showSaveConfirmationDialog).isFalse()
assertThat(initialState.recoveryKeyViewState).isEqualTo(
RecoveryKeyViewState(
recoveryKeyUserStory = RecoveryKeyUserStory.Setup,
formattedRecoveryKey = null,
inProgress = false,
)
)
}
}
@Test
fun `present - create recovery key and save it`() = runTest {
val encryptionService = FakeEncryptionService()
val presenter = createSecureBackupSetupPresenter(
encryptionService = encryptionService
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(SecureBackupSetupEvents.CreateRecoveryKey)
val creatingState = awaitItem()
assertThat(creatingState.setupState).isEqualTo(SetupState.Creating)
assertThat(creatingState.recoveryKeyViewState).isEqualTo(
RecoveryKeyViewState(
recoveryKeyUserStory = RecoveryKeyUserStory.Setup,
formattedRecoveryKey = null,
inProgress = true,
)
)
encryptionService.emitEnableRecoveryProgress(EnableRecoveryProgress.Done(A_RECOVERY_KEY))
val createdState = awaitItem()
assertThat(createdState.setupState).isEqualTo(SetupState.Created(A_RECOVERY_KEY))
assertThat(createdState.recoveryKeyViewState).isEqualTo(
RecoveryKeyViewState(
recoveryKeyUserStory = RecoveryKeyUserStory.Setup,
formattedRecoveryKey = A_RECOVERY_KEY,
inProgress = false,
)
)
createdState.eventSink.invoke(SecureBackupSetupEvents.RecoveryKeyHasBeenSaved)
val createdAndSaveState = awaitItem()
assertThat(createdAndSaveState.setupState).isInstanceOf(SetupState.CreatedAndSaved::class.java)
createdAndSaveState.eventSink.invoke(SecureBackupSetupEvents.Done)
val doneState = awaitItem()
assertThat(doneState.showSaveConfirmationDialog).isTrue()
doneState.eventSink.invoke(SecureBackupSetupEvents.DismissDialog)
val doneStateCancelled = awaitItem()
assertThat(doneStateCancelled.showSaveConfirmationDialog).isFalse()
}
}
@Test
fun `present - initial state change key`() = runTest {
val presenter = createSecureBackupSetupPresenter(
isChangeRecoveryKeyUserStory = true,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.isChangeRecoveryKeyUserStory).isTrue()
assertThat(initialState.setupState).isEqualTo(SetupState.Init)
assertThat(initialState.recoveryKeyViewState).isEqualTo(
RecoveryKeyViewState(
recoveryKeyUserStory = RecoveryKeyUserStory.Change,
formattedRecoveryKey = null,
inProgress = false,
)
)
}
}
@Test
fun `present - change recovery key and save it`() = runTest {
val encryptionService = FakeEncryptionService()
val presenter = createSecureBackupSetupPresenter(
isChangeRecoveryKeyUserStory = true,
encryptionService = encryptionService
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink.invoke(SecureBackupSetupEvents.CreateRecoveryKey)
val creatingState = awaitItem()
assertThat(creatingState.setupState).isEqualTo(SetupState.Creating)
assertThat(creatingState.recoveryKeyViewState).isEqualTo(
RecoveryKeyViewState(
recoveryKeyUserStory = RecoveryKeyUserStory.Change,
formattedRecoveryKey = null,
inProgress = true,
)
)
val createdState = awaitItem()
assertThat(createdState.setupState).isEqualTo(SetupState.Created(FakeEncryptionService.fakeRecoveryKey))
assertThat(createdState.recoveryKeyViewState).isEqualTo(
RecoveryKeyViewState(
recoveryKeyUserStory = RecoveryKeyUserStory.Change,
formattedRecoveryKey = FakeEncryptionService.fakeRecoveryKey,
inProgress = false,
)
)
createdState.eventSink.invoke(SecureBackupSetupEvents.RecoveryKeyHasBeenSaved)
val createdAndSaveState = awaitItem()
assertThat(createdAndSaveState.setupState).isInstanceOf(SetupState.CreatedAndSaved::class.java)
createdAndSaveState.eventSink.invoke(SecureBackupSetupEvents.Done)
val doneState = awaitItem()
assertThat(doneState.showSaveConfirmationDialog).isTrue()
doneState.eventSink.invoke(SecureBackupSetupEvents.DismissDialog)
val doneStateCancelled = awaitItem()
assertThat(doneStateCancelled.showSaveConfirmationDialog).isFalse()
}
}
private fun createSecureBackupSetupPresenter(
isChangeRecoveryKeyUserStory: Boolean = false,
encryptionService: EncryptionService = FakeEncryptionService(),
): SecureBackupSetupPresenter {
return SecureBackupSetupPresenter(
isChangeRecoveryKeyUserStory = isChangeRecoveryKeyUserStory,
stateMachine = SecureBackupSetupStateMachine(),
encryptionService = encryptionService,
)
}
}

View File

@@ -0,0 +1,74 @@
/*
* 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.securebackup.impl.tools
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class RecoveryKeyVisualTransformationTest {
@Test
fun `RecoveryKeyOffsetMapping computes correct originalToTransformed values`() {
var sut = RecoveryKeyVisualTransformation.RecoveryKeyOffsetMapping("")
assertThat(sut.originalToTransformed(0)).isEqualTo(0)
sut = RecoveryKeyVisualTransformation.RecoveryKeyOffsetMapping("a")
assertThat(sut.originalToTransformed(0)).isEqualTo(0)
assertThat(sut.originalToTransformed(1)).isEqualTo(1)
sut = RecoveryKeyVisualTransformation.RecoveryKeyOffsetMapping("ab")
assertThat(sut.originalToTransformed(0)).isEqualTo(0)
assertThat(sut.originalToTransformed(1)).isEqualTo(1)
assertThat(sut.originalToTransformed(2)).isEqualTo(2)
sut = RecoveryKeyVisualTransformation.RecoveryKeyOffsetMapping("abc")
assertThat(sut.originalToTransformed(0)).isEqualTo(0)
assertThat(sut.originalToTransformed(1)).isEqualTo(1)
assertThat(sut.originalToTransformed(2)).isEqualTo(2)
assertThat(sut.originalToTransformed(3)).isEqualTo(3)
sut = RecoveryKeyVisualTransformation.RecoveryKeyOffsetMapping("abcd")
assertThat(sut.originalToTransformed(0)).isEqualTo(0)
assertThat(sut.originalToTransformed(1)).isEqualTo(1)
assertThat(sut.originalToTransformed(2)).isEqualTo(2)
assertThat(sut.originalToTransformed(3)).isEqualTo(3)
assertThat(sut.originalToTransformed(4)).isEqualTo(4)
sut = RecoveryKeyVisualTransformation.RecoveryKeyOffsetMapping("abcde")
assertThat(sut.originalToTransformed(0)).isEqualTo(0)
assertThat(sut.originalToTransformed(1)).isEqualTo(1)
assertThat(sut.originalToTransformed(2)).isEqualTo(2)
assertThat(sut.originalToTransformed(3)).isEqualTo(3)
assertThat(sut.originalToTransformed(4)).isEqualTo(5)
assertThat(sut.originalToTransformed(5)).isEqualTo(6)
}
@Test
fun `RecoveryKeyOffsetMapping computes correct transformedToOriginal values`() {
val sut = RecoveryKeyVisualTransformation.RecoveryKeyOffsetMapping("" /* Not used by transformedToOriginal */)
assertThat(sut.transformedToOriginal(0)).isEqualTo(0)
assertThat(sut.transformedToOriginal(1)).isEqualTo(1)
assertThat(sut.transformedToOriginal(2)).isEqualTo(2)
assertThat(sut.transformedToOriginal(3)).isEqualTo(3)
assertThat(sut.transformedToOriginal(4)).isEqualTo(4)
assertThat(sut.transformedToOriginal(5)).isEqualTo(4)
assertThat(sut.transformedToOriginal(6)).isEqualTo(5)
assertThat(sut.transformedToOriginal(7)).isEqualTo(6)
assertThat(sut.transformedToOriginal(8)).isEqualTo(7)
assertThat(sut.transformedToOriginal(9)).isEqualTo(8)
assertThat(sut.transformedToOriginal(10)).isEqualTo(8)
}
}

View File

@@ -40,5 +40,5 @@ dependencies {
implementation(libs.androidx.recyclerview)
implementation(libs.androidx.exifinterface)
implementation(libs.androidx.security.crypto)
implementation(libs.androidx.browser)
api(libs.androidx.browser)
}

View File

@@ -31,10 +31,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.atomic.atoms.RedIndicatorAtom
import io.element.android.libraries.designsystem.components.preferences.components.PreferenceIcon
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Text
@@ -52,16 +55,18 @@ fun PreferenceText(
modifier: Modifier = Modifier,
enabled: Boolean = true,
subtitle: String? = null,
subtitleAnnotated: AnnotatedString? = null,
currentValue: String? = null,
loadingCurrentValue: Boolean = false,
icon: ImageVector? = null,
@DrawableRes iconResourceId: Int? = null,
showIconAreaIfNoIcon: Boolean = false,
showIconBadge: Boolean = false,
showEndBadge: Boolean = false,
tintColor: Color? = null,
onClick: () -> Unit = {},
) {
val minHeight = if (subtitle == null) preferenceMinHeightOnlyTitle else preferenceMinHeight
val minHeight = if (subtitle == null && subtitleAnnotated == null) preferenceMinHeightOnlyTitle else preferenceMinHeight
Row(
modifier = modifier
@@ -95,6 +100,12 @@ fun PreferenceText(
text = subtitle,
color = tintColor ?: enabled.toSecondaryEnabledColor(),
)
} else if (subtitleAnnotated != null) {
Text(
style = ElementTheme.typography.fontBodyMdRegular,
text = subtitleAnnotated,
color = tintColor ?: enabled.toSecondaryEnabledColor(),
)
}
}
if (currentValue != null) {
@@ -116,32 +127,63 @@ fun PreferenceText(
strokeWidth = 2.dp
)
}
if (showEndBadge) {
val endBadgeStartPadding = if (currentValue != null || loadingCurrentValue) 8.dp else 16.dp
RedIndicatorAtom(
modifier = Modifier
.align(Alignment.CenterVertically)
.padding(start = endBadgeStartPadding)
)
}
}
}
@Preview(group = PreviewGroup.Preferences)
@Composable
internal fun PreferenceTextPreview() = ElementThemedPreview { ContentToPreview() }
internal fun PreferenceTextLightPreview() = ElementPreviewLight {
ContentToPreview(showEndBadge = false)
}
@Preview(group = PreviewGroup.Preferences)
@Composable
internal fun PreferenceTextDarkPreview() = ElementPreviewDark {
ContentToPreview(showEndBadge = false)
}
@Preview(group = PreviewGroup.Preferences)
@Composable
internal fun PreferenceTextWithEndBadgeLightPreview() = ElementPreviewLight {
ContentToPreview(showEndBadge = true)
}
@Preview(group = PreviewGroup.Preferences)
@Composable
internal fun PreferenceTextWithEndBadgeDarkPreview() = ElementPreviewDark {
ContentToPreview(showEndBadge = true)
}
@Composable
private fun ContentToPreview() {
private fun ContentToPreview(showEndBadge: Boolean) {
Column(
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
PreferenceText(
title = "Title",
iconResourceId = CommonDrawables.ic_compound_chat_problem,
showEndBadge = showEndBadge,
)
PreferenceText(
title = "Title",
subtitle = "Some content",
iconResourceId = CommonDrawables.ic_compound_chat_problem,
showEndBadge = showEndBadge,
)
PreferenceText(
title = "Title",
subtitle = "Some content",
iconResourceId = CommonDrawables.ic_compound_chat_problem,
currentValue = "123",
showEndBadge = showEndBadge,
)
PreferenceText(
title = "Title",
@@ -149,31 +191,37 @@ private fun ContentToPreview() {
iconResourceId = CommonDrawables.ic_compound_chat_problem,
currentValue = "123",
enabled = false,
showEndBadge = showEndBadge,
)
PreferenceText(
title = "Title",
subtitle = "Some content",
iconResourceId = CommonDrawables.ic_compound_chat_problem,
loadingCurrentValue = true,
showEndBadge = showEndBadge,
)
PreferenceText(
title = "Title",
iconResourceId = CommonDrawables.ic_compound_chat_problem,
currentValue = "123",
showEndBadge = showEndBadge,
)
PreferenceText(
title = "Title",
iconResourceId = CommonDrawables.ic_compound_chat_problem,
loadingCurrentValue = true,
showEndBadge = showEndBadge,
)
PreferenceText(
title = "Title no icon with icon area",
showIconAreaIfNoIcon = true,
loadingCurrentValue = true,
showEndBadge = showEndBadge,
)
PreferenceText(
title = "Title no icon",
loadingCurrentValue = true,
showEndBadge = showEndBadge,
)
}
}

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.libraries.designsystem.modifiers
import androidx.compose.foundation.clickable
import androidx.compose.ui.Modifier
fun Modifier.clickableIfNotNull(onClick: (() -> Unit)? = null): Modifier = then(
if (onClick != null) {
Modifier.clickable { onClick() }
} else {
Modifier
}
)

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="42dp"
android:height="42dp"
android:viewportWidth="42"
android:viewportHeight="42">
<path
android:pathData="M12.25,26.644C10.675,26.644 9.341,26.097 8.247,25.003C7.153,23.909 6.606,22.575 6.606,21C6.606,19.425 7.153,18.091 8.247,16.997C9.341,15.903 10.675,15.356 12.25,15.356C13.825,15.356 15.159,15.903 16.253,16.997C17.347,18.091 17.894,19.425 17.894,21C17.894,22.575 17.347,23.909 16.253,25.003C15.159,26.097 13.825,26.644 12.25,26.644ZM12.25,31.5C14.613,31.5 16.691,30.829 18.484,29.487C20.278,28.146 21.452,26.381 22.006,24.194H23.406L25.594,26.381C25.74,26.527 25.885,26.629 26.031,26.688C26.177,26.746 26.337,26.775 26.513,26.775C26.688,26.775 26.848,26.746 26.994,26.688C27.14,26.629 27.285,26.527 27.431,26.381L30.188,23.625L32.944,26.381C33.09,26.527 33.235,26.629 33.381,26.688C33.527,26.746 33.688,26.775 33.862,26.775C34.037,26.775 34.198,26.746 34.344,26.688C34.49,26.629 34.635,26.527 34.781,26.381L39.331,21.831C39.477,21.685 39.579,21.54 39.638,21.394C39.696,21.248 39.725,21.087 39.725,20.913C39.725,20.737 39.696,20.577 39.638,20.431C39.579,20.285 39.477,20.14 39.331,19.994L37.45,18.112C37.304,17.967 37.158,17.865 37.013,17.806C36.867,17.748 36.706,17.719 36.531,17.719H22.006C21.335,15.619 20.162,13.891 18.484,12.534C16.807,11.178 14.729,10.5 12.25,10.5C9.333,10.5 6.854,11.521 4.813,13.563C2.771,15.604 1.75,18.083 1.75,21C1.75,23.917 2.771,26.396 4.813,28.438C6.854,30.479 9.333,31.5 12.25,31.5Z"
android:fillColor="@android:color/white"/>
</vector>

Some files were not shown because too many files have changed in this diff Show More