Secure backup

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

View file

@ -0,0 +1,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

@ -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.enable
import io.element.android.libraries.architecture.Async
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)
}
}