diff --git a/.idea/dictionaries/shared.xml b/.idea/dictionaries/shared.xml index c3aa70ef23..7afe540d22 100644 --- a/.idea/dictionaries/shared.xml +++ b/.idea/dictionaries/shared.xml @@ -10,6 +10,7 @@ onboarding placeables posthog + securebackup showkase snackbar swipeable diff --git a/.maestro/tests/account/logout.yaml b/.maestro/tests/account/logout.yaml index a06ac25e2d..b0e8eda762 100644 --- a/.maestro/tests/account/logout.yaml +++ b/.maestro/tests/account/logout.yaml @@ -3,11 +3,12 @@ appId: ${APP_ID} - tapOn: id: "home_screen-settings" - tapOn: "Sign out" -- takeScreenshot: build/maestro/900-SignOutDialg +- takeScreenshot: build/maestro/900-SignOutScreen +- back +- tapOn: "Sign out" +- tapOn: "Sign out" # Ensure cancel cancels - tapOn: "Cancel" - tapOn: "Sign out" -- tapOn: - text: "Sign out" - index: 1 +- tapOn: "Sign out" - runFlow: ../assertions/assertInitDisplayed.yaml diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index c1eb17dec1..ed286c71b9 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -55,6 +55,7 @@ import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.preferences.api.PreferencesEntryPoint import io.element.android.features.roomlist.api.RoomListEntryPoint +import io.element.android.features.securebackup.api.SecureBackupEntryPoint import io.element.android.features.verifysession.api.VerifySessionEntryPoint import io.element.android.libraries.architecture.BackstackNode import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler @@ -87,6 +88,7 @@ class LoggedInFlowNode @AssistedInject constructor( private val createRoomEntryPoint: CreateRoomEntryPoint, private val appNavigationStateService: AppNavigationStateService, private val verifySessionEntryPoint: VerifySessionEntryPoint, + private val secureBackupEntryPoint: SecureBackupEntryPoint, private val inviteListEntryPoint: InviteListEntryPoint, private val ftueEntryPoint: FtueEntryPoint, private val coroutineScope: CoroutineScope, @@ -197,6 +199,9 @@ class LoggedInFlowNode @AssistedInject constructor( @Parcelize data object VerifySession : NavTarget + @Parcelize + data object SecureBackup : NavTarget + @Parcelize data object InviteList : NavTarget @@ -272,6 +277,10 @@ class LoggedInFlowNode @AssistedInject constructor( backstack.push(NavTarget.VerifySession) } + override fun onSecureBackupClicked() { + backstack.push(NavTarget.SecureBackup) + } + override fun onOpenRoomNotificationSettings(roomId: RoomId) { backstack.push(NavTarget.Room(roomId, initialElement = RoomLoadedFlowNode.NavTarget.RoomNotificationSettings)) } @@ -297,6 +306,9 @@ class LoggedInFlowNode @AssistedInject constructor( NavTarget.VerifySession -> { verifySessionEntryPoint.createNode(this, buildContext) } + NavTarget.SecureBackup -> { + secureBackupEntryPoint.createNode(this, buildContext) + } NavTarget.InviteList -> { val callback = object : InviteListEntryPoint.Callback { override fun onBackClicked() { diff --git a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutEntryPoint.kt b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutEntryPoint.kt new file mode 100644 index 0000000000..b750a47e6d --- /dev/null +++ b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutEntryPoint.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.logout.api + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import io.element.android.libraries.architecture.FeatureEntryPoint + +interface LogoutEntryPoint : FeatureEntryPoint { + fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder + + interface NodeBuilder { + fun callback(callback: Callback): NodeBuilder + fun build(): Node + } + + interface Callback : Plugin { + fun onChangeRecoveryKeyClicked() + } +} diff --git a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferenceView.kt b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferenceView.kt deleted file mode 100644 index 24b7c8a266..0000000000 --- a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferenceView.kt +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright (c) 2022 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.features.logout.api - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.ui.res.stringResource -import io.element.android.libraries.architecture.Async -import io.element.android.libraries.designsystem.components.ProgressDialog -import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog -import io.element.android.libraries.designsystem.components.preferences.PreferenceText -import io.element.android.libraries.designsystem.preview.PreviewsDayNight -import io.element.android.libraries.designsystem.preview.ElementPreview -import io.element.android.libraries.designsystem.utils.CommonDrawables - -@Composable -fun LogoutPreferenceView( - state: LogoutPreferenceState, - onSuccessLogout: (logoutUrlResult: String?) -> Unit -) { - val eventSink = state.eventSink - if (state.logoutAction is Async.Success) { - LaunchedEffect(state.logoutAction) { - onSuccessLogout(state.logoutAction.data) - } - return - } - val openDialog = remember { mutableStateOf(false) } - - LogoutPreferenceContent( - onClick = { - openDialog.value = true - } - ) - - // Log out confirmation dialog - if (openDialog.value) { - ConfirmationDialog( - title = stringResource(id = R.string.screen_signout_confirmation_dialog_title), - content = stringResource(id = R.string.screen_signout_confirmation_dialog_content), - submitText = stringResource(id = R.string.screen_signout_confirmation_dialog_submit), - onCancelClicked = { - openDialog.value = false - }, - onSubmitClicked = { - openDialog.value = false - eventSink(LogoutPreferenceEvents.Logout) - }, - onDismiss = { - openDialog.value = false - } - ) - } - - if (state.logoutAction is Async.Loading) { - ProgressDialog(text = stringResource(id = R.string.screen_signout_in_progress_dialog_content)) - } -} - -@Composable -private fun LogoutPreferenceContent( - onClick: () -> Unit = {}, -) { - PreferenceText( - title = stringResource(id = R.string.screen_signout_preference_item), - iconResourceId = CommonDrawables.ic_compound_leave, - onClick = onClick - ) -} - -@PreviewsDayNight -@Composable -internal fun LogoutPreferenceViewPreview() = ElementPreview { - LogoutPreferenceView( - aLogoutPreferenceState(), - onSuccessLogout = {} - ) -} diff --git a/features/logout/impl/build.gradle.kts b/features/logout/impl/build.gradle.kts index f5ee8dd951..8680b355ae 100644 --- a/features/logout/impl/build.gradle.kts +++ b/features/logout/impl/build.gradle.kts @@ -31,6 +31,7 @@ anvil { dependencies { implementation(projects.anvilannotations) anvil(projects.anvilcodegen) + implementation(projects.libraries.androidutils) implementation(projects.libraries.core) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultLogoutEntryPoint.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultLogoutEntryPoint.kt new file mode 100644 index 0000000000..4928850245 --- /dev/null +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultLogoutEntryPoint.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.logout.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.features.logout.api.LogoutEntryPoint +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.AppScope +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultLogoutEntryPoint @Inject constructor() : LogoutEntryPoint { + override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): LogoutEntryPoint.NodeBuilder { + val plugins = ArrayList() + + return object : LogoutEntryPoint.NodeBuilder { + + override fun callback(callback: LogoutEntryPoint.Callback): LogoutEntryPoint.NodeBuilder { + plugins += callback + return this + } + + override fun build(): Node { + return parentNode.createNode(buildContext, plugins) + } + } + } +} + diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultLogoutPreferencePresenter.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultLogoutPreferencePresenter.kt deleted file mode 100644 index 49d0606633..0000000000 --- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultLogoutPreferencePresenter.kt +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright (c) 2023 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.features.logout.impl - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import com.squareup.anvil.annotations.ContributesBinding -import io.element.android.features.logout.api.LogoutPreferenceEvents -import io.element.android.features.logout.api.LogoutPreferencePresenter -import io.element.android.features.logout.api.LogoutPreferenceState -import io.element.android.libraries.architecture.Async -import io.element.android.libraries.architecture.runCatchingUpdatingState -import io.element.android.libraries.di.SessionScope -import io.element.android.libraries.matrix.api.MatrixClient -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import javax.inject.Inject - -@ContributesBinding(SessionScope::class) -class DefaultLogoutPreferencePresenter @Inject constructor(private val matrixClient: MatrixClient) : - LogoutPreferencePresenter { - - @Composable - override fun present(): LogoutPreferenceState { - val localCoroutineScope = rememberCoroutineScope() - val logoutAction: MutableState> = remember { - mutableStateOf(Async.Uninitialized) - } - - fun handleEvents(event: LogoutPreferenceEvents) { - when (event) { - LogoutPreferenceEvents.Logout -> localCoroutineScope.logout(logoutAction) - } - } - - return LogoutPreferenceState( - logoutAction = logoutAction.value, - eventSink = ::handleEvents - ) - } - - private fun CoroutineScope.logout(logoutAction: MutableState>) = launch { - suspend { - matrixClient.logout(false /* TODO */) - }.runCatchingUpdatingState(logoutAction) - } -} diff --git a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferenceEvents.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutEvents.kt similarity index 76% rename from features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferenceEvents.kt rename to features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutEvents.kt index 50dad213fd..2a8ee322a1 100644 --- a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferenceEvents.kt +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutEvents.kt @@ -14,8 +14,9 @@ * limitations under the License. */ -package io.element.android.features.logout.api +package io.element.android.features.logout.impl -sealed interface LogoutPreferenceEvents { - data object Logout : LogoutPreferenceEvents +sealed interface LogoutEvents { + data class Logout(val ignoreSdkError: Boolean) : LogoutEvents + data object CloseDialogs : LogoutEvents } diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutNode.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutNode.kt new file mode 100644 index 0000000000..8c4a605222 --- /dev/null +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutNode.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.logout.impl + +import android.app.Activity +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.core.plugin.plugins +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import io.element.android.anvilannotations.ContributesNode +import io.element.android.features.logout.api.LogoutEntryPoint +import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab +import io.element.android.libraries.di.SessionScope +import timber.log.Timber + +@ContributesNode(SessionScope::class) +class LogoutNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: LogoutPresenter, +) : Node(buildContext, plugins = plugins) { + + private fun onChangeRecoveryKeyClicked() { + plugins().forEach { it.onChangeRecoveryKeyClicked() } + } + + private fun onSuccessLogout(activity: Activity, url: String?) { + Timber.d("Success logout with result url: $url") + url?.let { + activity.openUrlInChromeCustomTab(null, false, it) + } + } + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + val activity = LocalContext.current as Activity + LogoutView( + state = state, + onChangeRecoveryKeyClicked = ::onChangeRecoveryKeyClicked, + onSuccessLogout = { onSuccessLogout(activity, it) }, + modifier = modifier, + ) + } +} diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutPresenter.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutPresenter.kt new file mode 100644 index 0000000000..8d97df3801 --- /dev/null +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutPresenter.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.logout.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runCatchingUpdatingState +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.encryption.EncryptionService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +class LogoutPresenter @Inject constructor( + private val matrixClient: MatrixClient, + private val encryptionService: EncryptionService, +) : Presenter { + + @Composable + override fun present(): LogoutState { + val localCoroutineScope = rememberCoroutineScope() + val logoutAction: MutableState> = remember { + mutableStateOf(Async.Uninitialized) + } + + val backupUploadState by encryptionService.backupUploadStateStateFlow.collectAsState() + + var showLogoutDialog by remember { mutableStateOf(false) } + var isLastSession by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + isLastSession = encryptionService.isLastDevice().getOrNull() ?: false + encryptionService.waitForBackupUploadSteadyState() + } + + fun handleEvents(event: LogoutEvents) { + when (event) { + is LogoutEvents.Logout -> { + if (showLogoutDialog || event.ignoreSdkError) { + showLogoutDialog = false + localCoroutineScope.logout(logoutAction, event.ignoreSdkError) + } else { + showLogoutDialog = true + } + } + LogoutEvents.CloseDialogs -> { + logoutAction.value = Async.Uninitialized + showLogoutDialog = false + } + } + } + + return LogoutState( + isLastSession = isLastSession, + backupUploadState = backupUploadState, + showConfirmationDialog = showLogoutDialog, + logoutAction = logoutAction.value, + eventSink = ::handleEvents + ) + } + + private fun CoroutineScope.logout( + logoutAction: MutableState>, + ignoreSdkError: Boolean, + ) = launch { + suspend { + matrixClient.logout(ignoreSdkError) + }.runCatchingUpdatingState(logoutAction) + } +} diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutState.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutState.kt new file mode 100644 index 0000000000..1672640ab7 --- /dev/null +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutState.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.logout.impl + +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.api.encryption.BackupUploadState + +data class LogoutState( + val isLastSession: Boolean, + val backupUploadState: BackupUploadState, + val showConfirmationDialog: Boolean, + val logoutAction: Async, + val eventSink: (LogoutEvents) -> Unit, +) diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStateProvider.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStateProvider.kt new file mode 100644 index 0000000000..c0ceb5302b --- /dev/null +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStateProvider.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.logout.impl + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.api.encryption.BackupUploadState + +open class LogoutStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aLogoutState(), + aLogoutState(isLastSession = true), + aLogoutState(isLastSession = false, backupUploadState = BackupUploadState.Uploading(66, 200)), + aLogoutState(isLastSession = true, backupUploadState = BackupUploadState.Done), + aLogoutState(showConfirmationDialog = true), + aLogoutState(logoutAction = Async.Loading()), + aLogoutState(logoutAction = Async.Failure(Exception("Failed to logout"))), + ) +} + +fun aLogoutState( + isLastSession: Boolean = false, + backupUploadState: BackupUploadState = BackupUploadState.Unknown, + showConfirmationDialog: Boolean = false, + logoutAction: Async = Async.Uninitialized, +) = LogoutState( + isLastSession = isLastSession, + backupUploadState = backupUploadState, + showConfirmationDialog = showConfirmationDialog, + logoutAction = logoutAction, + eventSink = {} +) diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt new file mode 100644 index 0000000000..d0635af31e --- /dev/null +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt @@ -0,0 +1,224 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.logout.impl + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule +import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule +import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage +import io.element.android.libraries.designsystem.components.ProgressDialog +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator +import io.element.android.libraries.designsystem.theme.components.OutlinedButton +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.progressIndicatorTrackColor +import io.element.android.libraries.designsystem.utils.CommonDrawables +import io.element.android.libraries.matrix.api.encryption.BackupUploadState +import io.element.android.libraries.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun LogoutView( + state: LogoutState, + onChangeRecoveryKeyClicked: () -> Unit, + onSuccessLogout: (logoutUrlResult: String?) -> Unit, + modifier: Modifier = Modifier, +) { + val eventSink = state.eventSink + + HeaderFooterPage( + modifier = modifier, + header = { + HeaderContent(state = state) + }, + footer = { + BottomMenu( + state = state, + onChangeRecoveryKeyClicked = onChangeRecoveryKeyClicked, + onLogoutClicked = { + eventSink(LogoutEvents.Logout(ignoreSdkError = false)) + }, + ) + } + ) { + Content(state = state) + } + + // Log out confirmation dialog + if (state.showConfirmationDialog) { + ConfirmationDialog( + title = stringResource(id = CommonStrings.action_signout), + content = stringResource(id = R.string.screen_signout_confirmation_dialog_content), + submitText = stringResource(id = CommonStrings.action_signout), + onCancelClicked = { + eventSink(LogoutEvents.CloseDialogs) + }, + onSubmitClicked = { + eventSink(LogoutEvents.Logout(ignoreSdkError = false)) + }, + onDismiss = { + eventSink(LogoutEvents.CloseDialogs) + } + ) + } + + when (state.logoutAction) { + is Async.Loading -> + ProgressDialog(text = stringResource(id = R.string.screen_signout_in_progress_dialog_content)) + is Async.Failure -> + ConfirmationDialog( + title = stringResource(id = CommonStrings.dialog_title_error), + content = stringResource(id = CommonStrings.error_unknown), + submitText = stringResource(id = CommonStrings.action_signout_anyway), + onCancelClicked = { + eventSink(LogoutEvents.CloseDialogs) + }, + onSubmitClicked = { + eventSink(LogoutEvents.Logout(ignoreSdkError = true)) + }, + onDismiss = { + eventSink(LogoutEvents.CloseDialogs) + } + ) + Async.Uninitialized -> + Unit + is Async.Success -> + LaunchedEffect(state.logoutAction) { + onSuccessLogout(state.logoutAction.data) + } + } +} + +// TODO i18n +@Composable +private fun HeaderContent( + state: LogoutState, + modifier: Modifier = Modifier, +) { + val title = when { + state.backupUploadState.isBackingUp() -> stringResource(id = R.string.screen_signout_key_backup_ongoing_title) + state.isLastSession -> stringResource(id = R.string.screen_signout_key_backup_disabled_title) + else -> "Sign out of Element" // TODO + } + val subtitle = when { + state.backupUploadState.isBackingUp() -> stringResource(id = R.string.screen_signout_key_backup_ongoing_subtitle) + state.isLastSession -> stringResource(id = R.string.screen_signout_key_backup_disabled_subtitle) + else -> null + } + + val paddingTop = 60.dp + IconTitleSubtitleMolecule( + modifier = modifier.padding(top = paddingTop), + iconResourceId = CommonDrawables.ic_key, + title = title, + subTitle = subtitle, + // iconComposable = iconComposable, + ) +} + +private fun BackupUploadState.isBackingUp(): Boolean { + return when (this) { + BackupUploadState.Unknown, + BackupUploadState.Waiting, + is BackupUploadState.Uploading, + is BackupUploadState.CheckingIfUploadNeeded -> true + BackupUploadState.Done -> false + } +} + +@Composable +private fun BottomMenu( + state: LogoutState, + onLogoutClicked: () -> Unit, + onChangeRecoveryKeyClicked: () -> Unit, +) { + val logoutAction = state.logoutAction + ButtonColumnMolecule( + modifier = Modifier.padding(bottom = 20.dp) + ) { + if (state.isLastSession) { + OutlinedButton( + text = stringResource(id = CommonStrings.common_settings), + modifier = Modifier.fillMaxWidth(), + onClick = onChangeRecoveryKeyClicked, + ) + } + val signOutSubmitRes = when { + logoutAction is Async.Loading -> R.string.screen_signout_in_progress_dialog_content + state.backupUploadState.isBackingUp() -> CommonStrings.action_signout_anyway + else -> CommonStrings.action_signout + } + Button( + text = stringResource(id = signOutSubmitRes), + showProgress = logoutAction is Async.Loading, + destructive = true, + modifier = Modifier.fillMaxWidth(), + onClick = onLogoutClicked, + ) + } +} + +@Composable +private fun Content( + state: LogoutState, +) { + if (state.backupUploadState is BackupUploadState.Uploading) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 60.dp, start = 20.dp, end = 20.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + progress = state.backupUploadState.backedUpCount.toFloat() / state.backupUploadState.totalCount.toFloat(), + trackColor = ElementTheme.colors.progressIndicatorTrackColor, + ) + Text( + modifier = Modifier.align(Alignment.End), + text = "${state.backupUploadState.backedUpCount} / ${state.backupUploadState.totalCount}", + style = ElementTheme.typography.fontBodySmRegular, + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun LogoutViewPreview( + @PreviewParameter(LogoutStateProvider::class) state: LogoutState, +) = ElementPreview { + LogoutView( + state, + onChangeRecoveryKeyClicked = {}, + onSuccessLogout = {} + ) +} diff --git a/features/logout/api/src/main/res/values-cs/translations.xml b/features/logout/impl/src/main/res/values-cs/translations.xml similarity index 100% rename from features/logout/api/src/main/res/values-cs/translations.xml rename to features/logout/impl/src/main/res/values-cs/translations.xml diff --git a/features/logout/api/src/main/res/values-de/translations.xml b/features/logout/impl/src/main/res/values-de/translations.xml similarity index 100% rename from features/logout/api/src/main/res/values-de/translations.xml rename to features/logout/impl/src/main/res/values-de/translations.xml diff --git a/features/logout/api/src/main/res/values-es/translations.xml b/features/logout/impl/src/main/res/values-es/translations.xml similarity index 100% rename from features/logout/api/src/main/res/values-es/translations.xml rename to features/logout/impl/src/main/res/values-es/translations.xml diff --git a/features/logout/api/src/main/res/values-fr/translations.xml b/features/logout/impl/src/main/res/values-fr/translations.xml similarity index 100% rename from features/logout/api/src/main/res/values-fr/translations.xml rename to features/logout/impl/src/main/res/values-fr/translations.xml diff --git a/features/logout/api/src/main/res/values-it/translations.xml b/features/logout/impl/src/main/res/values-it/translations.xml similarity index 100% rename from features/logout/api/src/main/res/values-it/translations.xml rename to features/logout/impl/src/main/res/values-it/translations.xml diff --git a/features/logout/api/src/main/res/values-ro/translations.xml b/features/logout/impl/src/main/res/values-ro/translations.xml similarity index 100% rename from features/logout/api/src/main/res/values-ro/translations.xml rename to features/logout/impl/src/main/res/values-ro/translations.xml diff --git a/features/logout/api/src/main/res/values-ru/translations.xml b/features/logout/impl/src/main/res/values-ru/translations.xml similarity index 100% rename from features/logout/api/src/main/res/values-ru/translations.xml rename to features/logout/impl/src/main/res/values-ru/translations.xml diff --git a/features/logout/api/src/main/res/values-sk/translations.xml b/features/logout/impl/src/main/res/values-sk/translations.xml similarity index 100% rename from features/logout/api/src/main/res/values-sk/translations.xml rename to features/logout/impl/src/main/res/values-sk/translations.xml diff --git a/features/logout/api/src/main/res/values-zh-rTW/translations.xml b/features/logout/impl/src/main/res/values-zh-rTW/translations.xml similarity index 100% rename from features/logout/api/src/main/res/values-zh-rTW/translations.xml rename to features/logout/impl/src/main/res/values-zh-rTW/translations.xml diff --git a/features/logout/api/src/main/res/values/localazy.xml b/features/logout/impl/src/main/res/values/localazy.xml similarity index 100% rename from features/logout/api/src/main/res/values/localazy.xml rename to features/logout/impl/src/main/res/values/localazy.xml diff --git a/features/logout/impl/src/main/res/values/tmp.xml b/features/logout/impl/src/main/res/values/tmp.xml new file mode 100644 index 0000000000..e9e1e376c6 --- /dev/null +++ b/features/logout/impl/src/main/res/values/tmp.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPreferencePresenterTest.kt b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPreferencePresenterTest.kt deleted file mode 100644 index 52e673cba6..0000000000 --- a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPreferencePresenterTest.kt +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright (c) 2023 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.features.logout.impl - -import app.cash.molecule.RecompositionMode -import app.cash.molecule.moleculeFlow -import app.cash.turbine.test -import com.google.common.truth.Truth.assertThat -import io.element.android.features.logout.api.LogoutPreferenceEvents -import io.element.android.features.logout.api.LogoutPreferenceState -import io.element.android.libraries.architecture.Async -import io.element.android.libraries.matrix.test.A_THROWABLE -import io.element.android.libraries.matrix.test.FakeMatrixClient -import io.element.android.tests.testutils.WarmUpRule -import kotlinx.coroutines.test.runTest -import org.junit.Rule -import org.junit.Test - -class LogoutPreferencePresenterTest { - - @get:Rule - val warmUpRule = WarmUpRule() - - @Test - fun `present - initial state`() = runTest { - val presenter = DefaultLogoutPreferencePresenter( - FakeMatrixClient(), - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - val initialState = awaitItem() - assertThat(initialState.logoutAction).isEqualTo(Async.Uninitialized) - } - } - - @Test - fun `present - logout`() = runTest { - val presenter = DefaultLogoutPreferencePresenter( - FakeMatrixClient(), - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - val initialState = awaitItem() - initialState.eventSink.invoke(LogoutPreferenceEvents.Logout) - val loadingState = awaitItem() - assertThat(loadingState.logoutAction).isInstanceOf(Async.Loading::class.java) - val successState = awaitItem() - assertThat(successState.logoutAction).isInstanceOf(Async.Success::class.java) - } - } - - @Test - fun `present - logout with error`() = runTest { - val matrixClient = FakeMatrixClient() - val presenter = DefaultLogoutPreferencePresenter( - matrixClient, - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - val initialState = awaitItem() - matrixClient.givenLogoutError(A_THROWABLE) - initialState.eventSink.invoke(LogoutPreferenceEvents.Logout) - val loadingState = awaitItem() - assertThat(loadingState.logoutAction).isInstanceOf(Async.Loading::class.java) - val successState = awaitItem() - assertThat(successState.logoutAction).isEqualTo(Async.Failure(A_THROWABLE)) - } - } -} - diff --git a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt new file mode 100644 index 0000000000..fe703780ba --- /dev/null +++ b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt @@ -0,0 +1,199 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.logout.impl + +import app.cash.molecule.RecompositionMode +import app.cash.molecule.moleculeFlow +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.Async +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.encryption.BackupUploadState +import io.element.android.libraries.matrix.api.encryption.EncryptionService +import io.element.android.libraries.matrix.test.A_THROWABLE +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService +import io.element.android.tests.testutils.WarmUpRule +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class LogoutPresenterTest { + + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val presenter = createLogoutPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.isLastSession).isFalse() + assertThat(initialState.backupUploadState).isEqualTo(BackupUploadState.Unknown) + assertThat(initialState.showConfirmationDialog).isFalse() + assertThat(initialState.logoutAction).isEqualTo(Async.Uninitialized) + } + } + + @Test + fun `present - initial state - last session`() = runTest { + val presenter = createLogoutPresenter( + encryptionService = FakeEncryptionService().apply { + givenIsLastDevice(true) + } + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.isLastSession).isTrue() + assertThat(initialState.backupUploadState).isEqualTo(BackupUploadState.Unknown) + assertThat(initialState.showConfirmationDialog).isFalse() + assertThat(initialState.logoutAction).isEqualTo(Async.Uninitialized) + } + } + + @Test + fun `present - initial state - backing up`() = runTest { + val encryptionService = FakeEncryptionService() + val presenter = createLogoutPresenter( + encryptionService = encryptionService + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.isLastSession).isFalse() + assertThat(initialState.backupUploadState).isEqualTo(BackupUploadState.Unknown) + assertThat(initialState.showConfirmationDialog).isFalse() + assertThat(initialState.logoutAction).isEqualTo(Async.Uninitialized) + encryptionService.emitBackupUploadState(BackupUploadState.Uploading(backedUpCount = 1, totalCount = 2)) + val state = awaitItem() + assertThat(state.backupUploadState).isEqualTo(BackupUploadState.Uploading(backedUpCount = 1, totalCount = 2)) + encryptionService.emitBackupUploadState(BackupUploadState.Done) + val doneState = awaitItem() + assertThat(doneState.backupUploadState).isEqualTo(BackupUploadState.Done) + } + } + + @Test + fun `present - logout then cancel`() = runTest { + val presenter = createLogoutPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false)) + val confirmationState = awaitItem() + assertThat(confirmationState.showConfirmationDialog).isTrue() + initialState.eventSink.invoke(LogoutEvents.CloseDialogs) + val finalState = awaitItem() + assertThat(finalState.showConfirmationDialog).isFalse() + } + } + + @Test + fun `present - logout then confirm`() = runTest { + val presenter = createLogoutPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false)) + val confirmationState = awaitItem() + assertThat(confirmationState.showConfirmationDialog).isTrue() + confirmationState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false)) + skipItems(1) + val loadingState = awaitItem() + assertThat(loadingState.showConfirmationDialog).isFalse() + assertThat(loadingState.logoutAction).isInstanceOf(Async.Loading::class.java) + val successState = awaitItem() + assertThat(successState.logoutAction).isInstanceOf(Async.Success::class.java) + } + } + + @Test + fun `present - logout with error then cancel`() = runTest { + val matrixClient = FakeMatrixClient().apply { + givenLogoutError(A_THROWABLE) + } + val presenter = createLogoutPresenter( + matrixClient, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false)) + val confirmationState = awaitItem() + assertThat(confirmationState.showConfirmationDialog).isTrue() + confirmationState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false)) + skipItems(1) + val loadingState = awaitItem() + assertThat(loadingState.showConfirmationDialog).isFalse() + assertThat(loadingState.logoutAction).isInstanceOf(Async.Loading::class.java) + val errorState = awaitItem() + assertThat(errorState.logoutAction).isEqualTo(Async.Failure(A_THROWABLE)) + errorState.eventSink.invoke(LogoutEvents.CloseDialogs) + val finalState = awaitItem() + assertThat(finalState.logoutAction).isEqualTo(Async.Uninitialized) + } + } + + @Test + fun `present - logout with error then force`() = runTest { + val matrixClient = FakeMatrixClient().apply { + givenLogoutError(A_THROWABLE) + } + val presenter = createLogoutPresenter( + matrixClient, + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false)) + val confirmationState = awaitItem() + assertThat(confirmationState.showConfirmationDialog).isTrue() + confirmationState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false)) + skipItems(1) + val loadingState = awaitItem() + assertThat(loadingState.showConfirmationDialog).isFalse() + assertThat(loadingState.logoutAction).isInstanceOf(Async.Loading::class.java) + val errorState = awaitItem() + assertThat(errorState.logoutAction).isEqualTo(Async.Failure(A_THROWABLE)) + errorState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = true)) + val loadingState2 = awaitItem() + assertThat(loadingState2.showConfirmationDialog).isFalse() + assertThat(loadingState2.logoutAction).isInstanceOf(Async.Loading::class.java) + val successState = awaitItem() + assertThat(successState.logoutAction).isInstanceOf(Async.Success::class.java) + } + } + + private fun createLogoutPresenter( + matrixClient: MatrixClient = FakeMatrixClient(), + encryptionService: EncryptionService = FakeEncryptionService(), + ): LogoutPresenter = LogoutPresenter( + matrixClient = matrixClient, + encryptionService = encryptionService, + ) +} + diff --git a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt index a0d2b8e057..9d087e254e 100644 --- a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt +++ b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt @@ -47,6 +47,7 @@ interface PreferencesEntryPoint : FeatureEntryPoint { interface Callback : Plugin { fun onOpenBugReport() fun onVerifyClicked() + fun onSecureBackupClicked() fun onOpenRoomNotificationSettings(roomId: RoomId) } } diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts index e4ec04c263..406d6a2ff4 100644 --- a/features/preferences/impl/build.gradle.kts +++ b/features/preferences/impl/build.gradle.kts @@ -42,6 +42,7 @@ dependencies { implementation(projects.libraries.featureflag.ui) implementation(projects.libraries.network) implementation(projects.libraries.pushstore.api) + implementation(projects.libraries.indicator.api) implementation(projects.libraries.preferences.api) implementation(projects.libraries.testtags) implementation(projects.libraries.uiStrings) @@ -78,6 +79,7 @@ dependencies { testImplementation(projects.libraries.pushstore.test) testImplementation(projects.features.rageshake.test) testImplementation(projects.features.rageshake.impl) + testImplementation(projects.libraries.indicator.impl) testImplementation(projects.features.logout.impl) testImplementation(projects.services.analytics.test) testImplementation(projects.features.analytics.impl) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt index 2b46fa0746..bd7d961123 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt @@ -30,6 +30,7 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode import io.element.android.features.lockscreen.api.LockScreenEntryPoint +import io.element.android.features.logout.api.LogoutEntryPoint import io.element.android.features.preferences.api.PreferencesEntryPoint import io.element.android.features.preferences.impl.about.AboutNode import io.element.android.features.preferences.impl.advanced.AdvancedSettingsNode @@ -53,6 +54,7 @@ class PreferencesFlowNode @AssistedInject constructor( @Assisted buildContext: BuildContext, @Assisted plugins: List, private val lockScreenEntryPoint: LockScreenEntryPoint, + private val logoutEntryPoint: LogoutEntryPoint, ) : BackstackNode( backstack = BackStack( initialElement = plugins.filterIsInstance().first().initialElement.toNavTarget(), @@ -92,6 +94,9 @@ class PreferencesFlowNode @AssistedInject constructor( @Parcelize data class UserProfile(val matrixUser: MatrixUser) : NavTarget + + @Parcelize + data object SignOut : NavTarget } override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { @@ -106,6 +111,10 @@ class PreferencesFlowNode @AssistedInject constructor( plugins().forEach { it.onVerifyClicked() } } + override fun onSecureBackupClicked() { + plugins().forEach { it.onSecureBackupClicked() } + } + override fun onOpenAnalytics() { backstack.push(NavTarget.AnalyticsSettings) } @@ -133,6 +142,10 @@ class PreferencesFlowNode @AssistedInject constructor( override fun onOpenUserProfile(matrixUser: MatrixUser) { backstack.push(NavTarget.UserProfile(matrixUser)) } + + override fun onSignOutClicked() { + backstack.push(NavTarget.SignOut) + } } createNode(buildContext, plugins = listOf(callback)) } @@ -182,6 +195,16 @@ class PreferencesFlowNode @AssistedInject constructor( .target(LockScreenEntryPoint.Target.Settings) .build() } + NavTarget.SignOut -> { + val callBack: LogoutEntryPoint.Callback = object : LogoutEntryPoint.Callback { + override fun onChangeRecoveryKeyClicked() { + plugins().forEach { it.onSecureBackupClicked() } + } + } + logoutEntryPoint.nodeBuilder(this, buildContext) + .callback(callBack) + .build() + } } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt index 7ea1fa2e8a..43ceb427b9 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt @@ -31,7 +31,6 @@ import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTa import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.theme.ElementTheme -import timber.log.Timber @ContributesNode(SessionScope::class) class PreferencesRootNode @AssistedInject constructor( @@ -43,6 +42,7 @@ class PreferencesRootNode @AssistedInject constructor( interface Callback : Plugin { fun onOpenBugReport() fun onVerifyClicked() + fun onSecureBackupClicked() fun onOpenAnalytics() fun onOpenAbout() fun onOpenDeveloperSettings() @@ -50,6 +50,7 @@ class PreferencesRootNode @AssistedInject constructor( fun onOpenLockScreenSettings() fun onOpenAdvancedSettings() fun onOpenUserProfile(matrixUser: MatrixUser) + fun onSignOutClicked() } private fun onOpenBugReport() { @@ -60,6 +61,10 @@ class PreferencesRootNode @AssistedInject constructor( plugins().forEach { it.onVerifyClicked() } } + private fun onSecureBackupClicked() { + plugins().forEach { it.onSecureBackupClicked() } + } + private fun onOpenDeveloperSettings() { plugins().forEach { it.onOpenDeveloperSettings() } } @@ -102,6 +107,10 @@ class PreferencesRootNode @AssistedInject constructor( plugins().forEach { it.onOpenUserProfile(matrixUser) } } + private fun onSignOutClicked() { + plugins().forEach { it.onSignOutClicked() } + } + @Composable override fun View(modifier: Modifier) { val state = presenter.present() @@ -115,20 +124,14 @@ class PreferencesRootNode @AssistedInject constructor( onOpenAnalytics = this::onOpenAnalytics, onOpenAbout = this::onOpenAbout, onVerifyClicked = this::onVerifyClicked, + onSecureBackupClicked = this::onSecureBackupClicked, onOpenDeveloperSettings = this::onOpenDeveloperSettings, onOpenAdvancedSettings = this::onOpenAdvancedSettings, - onSuccessLogout = { onSuccessLogout(activity, it) }, onManageAccountClicked = { onManageAccountClicked(activity, it, isDark) }, onOpenNotificationSettings = this::onOpenNotificationSettings, onOpenLockScreenSettings = this::onOpenLockScreenSettings, onOpenUserProfile = this::onOpenUserProfile, + onSignOutClicked = this::onSignOutClicked, ) } - - private fun onSuccessLogout(activity: Activity, url: String?) { - Timber.d("Success logout with result url: $url") - url?.let { - activity.openUrlInChromeCustomTab(null, false, it) - } - } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt index 64132db982..32e85ffb35 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt @@ -24,13 +24,13 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable -import io.element.android.features.logout.api.LogoutPreferencePresenter import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.meta.BuildType import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.indicator.api.IndicatorService import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.oidc.AccountManagementAction import io.element.android.libraries.matrix.api.user.MatrixUser @@ -42,7 +42,6 @@ import kotlinx.coroutines.launch import javax.inject.Inject class PreferencesRootPresenter @Inject constructor( - private val logoutPresenter: LogoutPreferencePresenter, private val matrixClient: MatrixClient, private val sessionVerificationService: SessionVerificationService, private val analyticsService: AnalyticsService, @@ -50,6 +49,7 @@ class PreferencesRootPresenter @Inject constructor( private val versionFormatter: VersionFormatter, private val snackbarDispatcher: SnackbarDispatcher, private val featureFlagService: FeatureFlagService, + private val indicatorService: IndicatorService, ) : Presenter { @Composable @@ -76,6 +76,8 @@ class PreferencesRootPresenter @Inject constructor( // We should display the 'complete verification' option if the current session can be verified val showCompleteVerification by sessionVerificationService.canVerifySessionFlow.collectAsState(false) + val showSecureBackupIndicator by indicatorService.showSettingChatBackupIndicator() + val accountManagementUrl: MutableState = remember { mutableStateOf(null) } @@ -87,13 +89,13 @@ class PreferencesRootPresenter @Inject constructor( initAccountManagementUrl(accountManagementUrl, devicesManagementUrl) } - val logoutState = logoutPresenter.present() val showDeveloperSettings = buildType != BuildType.RELEASE return PreferencesRootState( - logoutState = logoutState, myUser = matrixUser.value, version = versionFormatter.get(), showCompleteVerification = showCompleteVerification, + showSecureBackup = !showCompleteVerification, + showSecureBackupBadge = showSecureBackupIndicator, accountManagementUrl = accountManagementUrl.value, devicesManagementUrl = devicesManagementUrl.value, showAnalyticsSettings = hasAnalyticsProviders, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt index d6ff4855de..fec09f150f 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt @@ -16,15 +16,15 @@ package io.element.android.features.preferences.impl.root -import io.element.android.features.logout.api.LogoutPreferenceState import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.matrix.api.user.MatrixUser data class PreferencesRootState( - val logoutState: LogoutPreferenceState, val myUser: MatrixUser?, val version: String, val showCompleteVerification: Boolean, + val showSecureBackup: Boolean, + val showSecureBackupBadge: Boolean, val accountManagementUrl: String?, val devicesManagementUrl: String?, val showAnalyticsSettings: Boolean, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt index 07dd6240bf..74d8b0f0c9 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt @@ -16,15 +16,15 @@ package io.element.android.features.preferences.impl.root -import io.element.android.features.logout.api.aLogoutPreferenceState import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.ui.strings.CommonStrings fun aPreferencesRootState() = PreferencesRootState( - logoutState = aLogoutPreferenceState(), myUser = null, version = "Version 1.1 (1)", showCompleteVerification = true, + showSecureBackup = true, + showSecureBackupBadge = true, accountManagementUrl = "aUrl", devicesManagementUrl = "anOtherUrl", showAnalyticsSettings = true, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt index 34a4890348..e25428f724 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt @@ -29,10 +29,9 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp -import io.element.android.features.logout.api.LogoutPreferenceView import io.element.android.features.preferences.impl.user.UserPreferences -import io.element.android.libraries.designsystem.components.preferences.PreferenceText import io.element.android.libraries.designsystem.components.preferences.PreferencePage +import io.element.android.libraries.designsystem.components.preferences.PreferenceText import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight @@ -51,6 +50,7 @@ fun PreferencesRootView( state: PreferencesRootState, onBackPressed: () -> Unit, onVerifyClicked: () -> Unit, + onSecureBackupClicked: () -> Unit, onManageAccountClicked: (url: String) -> Unit, onOpenAnalytics: () -> Unit, onOpenRageShake: () -> Unit, @@ -58,9 +58,9 @@ fun PreferencesRootView( onOpenAbout: () -> Unit, onOpenDeveloperSettings: () -> Unit, onOpenAdvancedSettings: () -> Unit, - onSuccessLogout: (logoutUrlResult: String?) -> Unit, onOpenNotificationSettings: () -> Unit, onOpenUserProfile: (MatrixUser) -> Unit, + onSignOutClicked: () -> Unit, modifier: Modifier = Modifier, ) { val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) @@ -84,6 +84,16 @@ fun PreferencesRootView( icon = Icons.Outlined.VerifiedUser, onClick = onVerifyClicked, ) + } + if (state.showSecureBackup) { + PreferenceText( + title = stringResource(id = CommonStrings.common_chat_backup), + iconResourceId = CommonDrawables.ic_key_filled, + showEndBadge = state.showSecureBackupBadge, + onClick = onSecureBackupClicked, + ) + } + if (state.showCompleteVerification || state.showSecureBackup) { HorizontalDivider() } if (state.accountManagementUrl != null) { @@ -143,9 +153,10 @@ fun PreferencesRootView( DeveloperPreferencesView(onOpenDeveloperSettings) } HorizontalDivider() - LogoutPreferenceView( - state = state.logoutState, - onSuccessLogout = onSuccessLogout, + PreferenceText( + title = stringResource(id = CommonStrings.action_signout), + iconResourceId = CommonDrawables.ic_compound_leave, + onClick = onSignOutClicked, ) Text( modifier = Modifier @@ -189,10 +200,11 @@ private fun ContentToPreview(matrixUser: MatrixUser) { onOpenAdvancedSettings = {}, onOpenAbout = {}, onVerifyClicked = {}, - onSuccessLogout = {}, + onSecureBackupClicked = {}, onManageAccountClicked = {}, onOpenNotificationSettings = {}, onOpenLockScreenSettings = {}, onOpenUserProfile = {}, + onSignOutClicked = {}, ) } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt index e9eaf5ef13..c9ef8b5560 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt @@ -20,15 +20,15 @@ import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat -import io.element.android.features.logout.impl.DefaultLogoutPreferencePresenter -import io.element.android.libraries.architecture.Async import io.element.android.libraries.core.meta.BuildType import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.indicator.impl.DefaultIndicatorService import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.WarmUpRule @@ -44,16 +44,19 @@ class PreferencesRootPresenterTest { @Test fun `present - initial state`() = runTest { val matrixClient = FakeMatrixClient() - val logoutPresenter = DefaultLogoutPreferencePresenter(matrixClient) + val sessionVerificationService = FakeSessionVerificationService() val presenter = PreferencesRootPresenter( - logoutPresenter, matrixClient, - FakeSessionVerificationService(), + sessionVerificationService, FakeAnalyticsService(), BuildType.DEBUG, FakeVersionFormatter(), SnackbarDispatcher(), - FakeFeatureFlagService() + FakeFeatureFlagService(), + DefaultIndicatorService( + sessionVerificationService = sessionVerificationService, + encryptionService = FakeEncryptionService(), + ), ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -62,7 +65,6 @@ class PreferencesRootPresenterTest { assertThat(initialState.myUser).isNull() assertThat(initialState.version).isEqualTo("A Version") val loadedState = awaitItem() - assertThat(loadedState.logoutState.logoutAction).isEqualTo(Async.Uninitialized) assertThat(loadedState.myUser).isEqualTo( MatrixUser( userId = matrixClient.sessionId, diff --git a/features/roomlist/impl/build.gradle.kts b/features/roomlist/impl/build.gradle.kts index 17817ebc91..5b52a05c2e 100644 --- a/features/roomlist/impl/build.gradle.kts +++ b/features/roomlist/impl/build.gradle.kts @@ -48,6 +48,7 @@ dependencies { implementation(projects.libraries.uiStrings) implementation(projects.libraries.dateformatter.api) implementation(projects.libraries.eventformatter.api) + implementation(projects.libraries.indicator.api) implementation(projects.libraries.deeplink) implementation(projects.features.invitelist.api) implementation(projects.features.networkmonitor.api) @@ -65,6 +66,7 @@ dependencies { testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.dateformatter.test) testImplementation(projects.libraries.eventformatter.test) + testImplementation(projects.libraries.indicator.impl) testImplementation(projects.libraries.permissions.noop) testImplementation(projects.features.invitelist.test) testImplementation(projects.features.networkmonitor.test) diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListEvents.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListEvents.kt index e377764942..d54bc604cf 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListEvents.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListEvents.kt @@ -23,6 +23,7 @@ sealed interface RoomListEvents { data class UpdateFilter(val newFilter: String) : RoomListEvents data class UpdateVisibleRange(val range: IntRange) : RoomListEvents data object DismissRequestVerificationPrompt : RoomListEvents + data object DismissRecoveryKeyPrompt : RoomListEvents data object ToggleSearchResults : RoomListEvents data class ShowContextMenu(val roomListRoomSummary: RoomListRoomSummary) : RoomListEvents data object HideContextMenu : RoomListEvents diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt index 5238144755..4a1e10d318 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt @@ -35,7 +35,10 @@ import io.element.android.features.roomlist.impl.datasource.RoomListDataSource import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState +import io.element.android.libraries.indicator.api.IndicatorService import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.encryption.EncryptionService +import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.getCurrentUser import io.element.android.libraries.matrix.api.verification.SessionVerificationService @@ -53,6 +56,8 @@ class RoomListPresenter @Inject constructor( private val inviteStateDataSource: InviteStateDataSource, private val leaveRoomPresenter: LeaveRoomPresenter, private val roomListDataSource: RoomListDataSource, + private val encryptionService: EncryptionService, + private val indicatorService: IndicatorService, ) : Presenter { @Composable @@ -78,6 +83,16 @@ class RoomListPresenter @Inject constructor( val displayVerificationPrompt by remember { derivedStateOf { canVerifySession && !verificationPromptDismissed } } + val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState() + var recoveryKeyPromptDismissed by rememberSaveable { mutableStateOf(false) } + val displayRecoveryKeyPrompt by remember { + derivedStateOf { + recoveryState == RecoveryState.INCOMPLETE && !recoveryKeyPromptDismissed + } + } + + // Avatar indicator + val showAvatarIndicator by indicatorService.showRoomListTopBarIndicator() var displaySearchResults by rememberSaveable { mutableStateOf(false) } @@ -88,6 +103,7 @@ class RoomListPresenter @Inject constructor( is RoomListEvents.UpdateFilter -> roomListDataSource.updateFilter(event.newFilter) is RoomListEvents.UpdateVisibleRange -> updateVisibleRange(event.range) RoomListEvents.DismissRequestVerificationPrompt -> verificationPromptDismissed = true + RoomListEvents.DismissRecoveryKeyPrompt -> recoveryKeyPromptDismissed = true RoomListEvents.ToggleSearchResults -> { if (displaySearchResults) { roomListDataSource.updateFilter("") @@ -109,10 +125,12 @@ class RoomListPresenter @Inject constructor( return RoomListState( matrixUser = matrixUser.value, + showAvatarIndicator = showAvatarIndicator, roomList = roomList, filter = filter, filteredRoomList = filteredRoomList, displayVerificationPrompt = displayVerificationPrompt, + displayRecoveryKeyPrompt = displayRecoveryKeyPrompt, snackbarMessage = snackbarMessage, hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online, invitesState = inviteStateDataSource.inviteState(), diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt index f0dfe8e3a9..b0c87b88aa 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt @@ -27,10 +27,12 @@ import kotlinx.collections.immutable.ImmutableList @Immutable data class RoomListState( val matrixUser: MatrixUser?, + val showAvatarIndicator: Boolean, val roomList: ImmutableList, val filter: String?, val filteredRoomList: ImmutableList, val displayVerificationPrompt: Boolean, + val displayRecoveryKeyPrompt: Boolean, val hasNetworkConnection: Boolean, val snackbarMessage: SnackbarMessage?, val invitesState: InvitesState, diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt index 7199bfd227..6610136f5d 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt @@ -41,20 +41,25 @@ open class RoomListStateProvider : PreviewParameterProvider { aRoomListState().copy(invitesState = InvitesState.NewInvites), aRoomListState().copy(displaySearchResults = true, filter = "", filteredRoomList = persistentListOf()), aRoomListState().copy(displaySearchResults = true), - aRoomListState().copy(contextMenu = RoomListState.ContextMenu.Shown( - roomId = RoomId("!aRoom:aDomain"), roomName = "A nice room name" - )) + aRoomListState().copy( + contextMenu = RoomListState.ContextMenu.Shown( + roomId = RoomId("!aRoom:aDomain"), roomName = "A nice room name" + ) + ), + aRoomListState().copy(displayRecoveryKeyPrompt = true), ) } internal fun aRoomListState() = RoomListState( matrixUser = MatrixUser(userId = UserId("@id:domain"), displayName = "User#1"), + showAvatarIndicator = false, roomList = aRoomListRoomSummaryList(), filter = "filter", filteredRoomList = aRoomListRoomSummaryList(), hasNetworkConnection = true, snackbarMessage = null, displayVerificationPrompt = false, + displayRecoveryKeyPrompt = false, invitesState = InvitesState.NoInvites, displaySearchResults = false, contextMenu = RoomListState.ContextMenu.Hidden, diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt index b01c5fba4d..2d7ea1e4b1 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import io.element.android.features.leaveroom.api.LeaveRoomView import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorContainer +import io.element.android.features.roomlist.impl.components.ConfirmRecoveryKeyBanner import io.element.android.features.roomlist.impl.components.RequestVerificationHeader import io.element.android.features.roomlist.impl.components.RoomListMenuAction import io.element.android.features.roomlist.impl.components.RoomListTopBar @@ -172,6 +173,7 @@ private fun RoomListContent( topBar = { RoomListTopBar( matrixUser = state.matrixUser, + showAvatarIndicator = state.showAvatarIndicator, areSearchResultsDisplayed = state.displaySearchResults, onFilterChanged = { state.eventSink(RoomListEvents.UpdateFilter(it)) }, onToggleSearch = { state.eventSink(RoomListEvents.ToggleSearchResults) }, @@ -195,6 +197,13 @@ private fun RoomListContent( onDismissClicked = { state.eventSink(RoomListEvents.DismissRequestVerificationPrompt) } ) } + } else if (state.displayRecoveryKeyPrompt) { + item { + ConfirmRecoveryKeyBanner( + onContinueClicked = onOpenSettings, + onDismissClicked = { state.eventSink(RoomListEvents.DismissRecoveryKeyPrompt) } + ) + } } if (state.invitesState != InvitesState.NoInvites) { diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/ConfirmRecoveryKeyBanner.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/ConfirmRecoveryKeyBanner.kt new file mode 100644 index 0000000000..71f9c78530 --- /dev/null +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/ConfirmRecoveryKeyBanner.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.roomlist.impl.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import io.element.android.features.roomlist.impl.R +import io.element.android.libraries.designsystem.atomic.molecules.DialogLikeBannerMolecule +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight + +@Composable +internal fun ConfirmRecoveryKeyBanner( + onContinueClicked: () -> Unit, + onDismissClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + DialogLikeBannerMolecule( + modifier = modifier, + title = stringResource(R.string.confirm_recovery_key_banner_title), + content = stringResource(R.string.confirm_recovery_key_banner_message), + onSubmitClicked = onContinueClicked, + onDismissClicked = onDismissClicked, + ) +} + +@PreviewsDayNight +@Composable +internal fun ConfirmRecoveryKeyBannerPreview() = ElementPreview { + ConfirmRecoveryKeyBanner( + onContinueClicked = {}, + onDismissClicked = {}, + ) +} diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBar.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBar.kt index 6c2f07bbac..57bfccabb7 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBar.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBar.kt @@ -20,6 +20,7 @@ import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material3.ExperimentalMaterial3Api @@ -46,11 +47,12 @@ import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import io.element.android.features.roomlist.impl.R +import io.element.android.libraries.designsystem.atomic.atoms.RedIndicatorAtom import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatarBloom -import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.text.applyScaleDown import io.element.android.libraries.designsystem.text.roundToPx import io.element.android.libraries.designsystem.text.toDp @@ -79,6 +81,7 @@ private val avatarBloomSize = 430.dp @Composable fun RoomListTopBar( matrixUser: MatrixUser?, + showAvatarIndicator: Boolean, areSearchResultsDisplayed: Boolean, onFilterChanged: (String) -> Unit, onToggleSearch: () -> Unit, @@ -103,6 +106,7 @@ fun RoomListTopBar( DefaultRoomListTopBar( matrixUser = matrixUser, + showAvatarIndicator = showAvatarIndicator, areSearchResultsDisplayed = areSearchResultsDisplayed, onOpenSettings = onOpenSettings, onSearchClicked = onToggleSearch, @@ -116,6 +120,7 @@ fun RoomListTopBar( @Composable private fun DefaultRoomListTopBar( matrixUser: MatrixUser?, + showAvatarIndicator: Boolean, areSearchResultsDisplayed: Boolean, scrollBehavior: TopAppBarScrollBehavior, onOpenSettings: () -> Unit, @@ -198,6 +203,13 @@ private fun DefaultRoomListTopBar( avatarData = it, contentDescription = stringResource(CommonStrings.common_settings), ) + if (showAvatarIndicator) { + RedIndicatorAtom( + modifier = Modifier + .padding(4.5.dp) + .align(Alignment.TopEnd) + ) + } } } }, @@ -273,6 +285,22 @@ private fun DefaultRoomListTopBar( internal fun DefaultRoomListTopBarPreview() = ElementPreview { DefaultRoomListTopBar( matrixUser = MatrixUser(UserId("@id:domain"), "Alice"), + showAvatarIndicator = false, + areSearchResultsDisplayed = false, + scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()), + onOpenSettings = {}, + onSearchClicked = {}, + onMenuActionClicked = {}, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@PreviewsDayNight +@Composable +internal fun DefaultRoomListTopBarWithIndicatorPreview() = ElementPreview { + DefaultRoomListTopBar( + matrixUser = MatrixUser(UserId("@id:domain"), "Alice"), + showAvatarIndicator = true, areSearchResultsDisplayed = false, scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()), onOpenSettings = {}, diff --git a/features/roomlist/impl/src/main/res/values/localazy.xml b/features/roomlist/impl/src/main/res/values/localazy.xml index 26b0bbee9e..08a630c6b5 100644 --- a/features/roomlist/impl/src/main/res/values/localazy.xml +++ b/features/roomlist/impl/src/main/res/values/localazy.xml @@ -1,5 +1,7 @@ + "Your chat backup is currently out of sync. You need to confirm your recovery key to maintain access to your chat backup." + "Confirm your recovery key" "Create a new conversation or room" "Get started by messaging someone." "No chats yet." diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt index fd5c0160fc..66e0548215 100644 --- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt @@ -37,7 +37,10 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter import io.element.android.libraries.eventformatter.test.FakeRoomLastMessageFormatter +import io.element.android.libraries.indicator.impl.DefaultIndicatorService import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.encryption.BackupState +import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus @@ -48,6 +51,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_NAME import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService @@ -83,6 +87,32 @@ class RoomListPresenterTests { Truth.assertThat(withUserState.matrixUser!!.userId).isEqualTo(A_USER_ID) Truth.assertThat(withUserState.matrixUser!!.displayName).isEqualTo(A_USER_NAME) Truth.assertThat(withUserState.matrixUser!!.avatarUrl).isEqualTo(AN_AVATAR_URL) + Truth.assertThat(withUserState.showAvatarIndicator).isFalse() + scope.cancel() + } + } + + @Test + fun `present - show avatar indicator`() = runTest { + val scope = CoroutineScope(coroutineContext + SupervisorJob()) + val encryptionService = FakeEncryptionService() + val sessionVerificationService = FakeSessionVerificationService() + val presenter = createRoomListPresenter( + encryptionService = encryptionService, + sessionVerificationService = sessionVerificationService, + coroutineScope = scope + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + Truth.assertThat(initialState.showAvatarIndicator).isFalse() + sessionVerificationService.givenCanVerifySession(false) + Truth.assertThat(awaitItem().showAvatarIndicator).isFalse() + encryptionService.emitBackupState(BackupState.UNKNOWN) + val finalState = awaitItem() + Truth.assertThat(finalState.showAvatarIndicator).isTrue() scope.cancel() } } @@ -131,7 +161,7 @@ class RoomListPresenterTests { roomListService = roomListService ) val scope = CoroutineScope(coroutineContext + SupervisorJob()) - val presenter = createRoomListPresenter(client = matrixClient, coroutineScope = scope) + val presenter = createRoomListPresenter(client = matrixClient, coroutineScope = scope) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -341,7 +371,7 @@ class RoomListPresenterTests { notificationSettingsService = notificationSettingsService ) val scope = CoroutineScope(coroutineContext + SupervisorJob()) - val presenter = createRoomListPresenter(client = matrixClient , coroutineScope = scope) + val presenter = createRoomListPresenter(client = matrixClient, coroutineScope = scope) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -369,7 +399,8 @@ class RoomListPresenterTests { givenFormat(A_FORMATTED_DATE) }, roomLastMessageFormatter: RoomLastMessageFormatter = FakeRoomLastMessageFormatter(), - coroutineScope: CoroutineScope = this, + encryptionService: EncryptionService = FakeEncryptionService(), + coroutineScope: CoroutineScope, ) = RoomListPresenter( client = client, sessionVerificationService = sessionVerificationService, @@ -384,7 +415,12 @@ class RoomListPresenterTests { coroutineDispatchers = testCoroutineDispatchers(), notificationSettingsService = client.notificationSettingsService(), appScope = coroutineScope - ) + ), + encryptionService = encryptionService, + indicatorService = DefaultIndicatorService( + sessionVerificationService = sessionVerificationService, + encryptionService = encryptionService, + ), ) } diff --git a/features/securebackup/api/build.gradle.kts b/features/securebackup/api/build.gradle.kts new file mode 100644 index 0000000000..c9117d1d39 --- /dev/null +++ b/features/securebackup/api/build.gradle.kts @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.securebackup.api" +} + +dependencies { + implementation(projects.libraries.architecture) +} diff --git a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferencePresenter.kt b/features/securebackup/api/src/main/kotlin/io/element/android/features/securebackup/api/SecureBackupEntryPoint.kt similarity index 76% rename from features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferencePresenter.kt rename to features/securebackup/api/src/main/kotlin/io/element/android/features/securebackup/api/SecureBackupEntryPoint.kt index b42744f912..8824fdf84b 100644 --- a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferencePresenter.kt +++ b/features/securebackup/api/src/main/kotlin/io/element/android/features/securebackup/api/SecureBackupEntryPoint.kt @@ -14,8 +14,8 @@ * limitations under the License. */ -package io.element.android.features.logout.api +package io.element.android.features.securebackup.api -import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.SimpleFeatureEntryPoint -interface LogoutPreferencePresenter : Presenter +interface SecureBackupEntryPoint : SimpleFeatureEntryPoint diff --git a/features/securebackup/impl/build.gradle.kts b/features/securebackup/impl/build.gradle.kts new file mode 100644 index 0000000000..68e8dc066f --- /dev/null +++ b/features/securebackup/impl/build.gradle.kts @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.ksp) + alias(libs.plugins.anvil) + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.securebackup.impl" +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + anvil(projects.anvilcodegen) + implementation(projects.anvilannotations) + + implementation(projects.libraries.androidutils) + implementation(projects.libraries.core) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) + api(libs.statemachine) + api(projects.features.securebackup.api) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.tests.testutils) + + ksp(libs.showkase.processor) +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupEntryPoint.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupEntryPoint.kt new file mode 100644 index 0000000000..a870255a9a --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/DefaultSecureBackupEntryPoint.kt @@ -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(buildContext) + } +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/LoggerTag.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/LoggerTag.kt new file mode 100644 index 0000000000..b8b56f5ace --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/LoggerTag.kt @@ -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) + diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupConfig.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupConfig.kt new file mode 100644 index 0000000000..045a320e4a --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupConfig.kt @@ -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" +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt new file mode 100644 index 0000000000..0ce510336c --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt @@ -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, +) : BackstackNode( + 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(buildContext, listOf(callback)) + } + NavTarget.Setup -> { + val inputs = SecureBackupSetupNode.Inputs( + isChangeRecoveryKeyUserStory = false, + ) + createNode(buildContext, listOf(inputs)) + } + NavTarget.Change -> { + val inputs = SecureBackupSetupNode.Inputs( + isChangeRecoveryKeyUserStory = true, + ) + createNode(buildContext, listOf(inputs)) + } + NavTarget.Disable -> { + createNode(buildContext) + } + NavTarget.Enable -> { + createNode(buildContext) + } + NavTarget.EnterRecoveryKey -> { + createNode(buildContext) + } + } + } + + @Composable + override fun View(modifier: Modifier) { + Children( + navModel = backstack, + modifier = modifier, + transitionHandler = rememberDefaultTransitionHandler() + ) + } +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableEvents.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableEvents.kt new file mode 100644 index 0000000000..4cda13f7af --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableEvents.kt @@ -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 +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableNode.kt new file mode 100644 index 0000000000..42f1c81f57 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableNode.kt @@ -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, + 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, + ) + } +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisablePresenter.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisablePresenter.kt new file mode 100644 index 0000000000..60b4691661 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisablePresenter.kt @@ -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 { + + @Composable + override fun present(): SecureBackupDisableState { + val backupState by encryptionService.backupStateStateFlow.collectAsState() + Timber.tag(loggerTagDisable.value).d("backupState: $backupState") + val disableAction = remember { mutableStateOf>(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>) = launch { + suspend { + Timber.tag(loggerTagDisable.value).d("Calling encryptionService.disableRecovery()") + encryptionService.disableRecovery().getOrThrow() + }.runCatchingUpdatingState(disableAction) + } +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableState.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableState.kt new file mode 100644 index 0000000000..41e39c398e --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableState.kt @@ -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, + val showConfirmationDialog: Boolean, + val appName: String, + val eventSink: (SecureBackupDisableEvents) -> Unit +) diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableStateProvider.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableStateProvider.kt new file mode 100644 index 0000000000..8c22e36968 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableStateProvider.kt @@ -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 { + override val values: Sequence + 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 = Async.Uninitialized, + showConfirmationDialog: Boolean = false, +) = SecureBackupDisableState( + backupState = backupState, + disableAction = disableAction, + showConfirmationDialog = showConfirmationDialog, + appName = "Element", + eventSink = {} +) diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableView.kt new file mode 100644 index 0000000000..290086849f --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableView.kt @@ -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 = {}, + ) +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableEvents.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableEvents.kt new file mode 100644 index 0000000000..2695e875f9 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableEvents.kt @@ -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 +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableNode.kt new file mode 100644 index 0000000000..34d03881d6 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableNode.kt @@ -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, + 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, + ) + } +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnablePresenter.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnablePresenter.kt new file mode 100644 index 0000000000..dfceb16dea --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnablePresenter.kt @@ -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 { + + @Composable + override fun present(): SecureBackupEnableState { + val enableAction = remember { mutableStateOf>(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>) = launch { + suspend { + Timber.tag(loggerTagDisable.value).d("Calling encryptionService.enableBackups()") + encryptionService.enableBackups().getOrThrow() + }.runCatchingUpdatingState(action) + } +} diff --git a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferenceState.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableState.kt similarity index 73% rename from features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferenceState.kt rename to features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableState.kt index 95550ef4c8..7c3aaf839b 100644 --- a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferenceState.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableState.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * Copyright (c) 2023 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,11 +14,11 @@ * limitations under the License. */ -package io.element.android.features.logout.api +package io.element.android.features.securebackup.impl.enable import io.element.android.libraries.architecture.Async -data class LogoutPreferenceState( - val logoutAction: Async, - val eventSink: (LogoutPreferenceEvents) -> Unit, +data class SecureBackupEnableState( + val enableAction: Async, + val eventSink: (SecureBackupEnableEvents) -> Unit ) diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableStateProvider.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableStateProvider.kt new file mode 100644 index 0000000000..494c45b5b4 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableStateProvider.kt @@ -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 { + override val values: Sequence + get() = sequenceOf( + aSecureBackupEnableState(), + aSecureBackupEnableState(enableAction = Async.Loading()), + aSecureBackupEnableState(enableAction = Async.Failure(Exception("Failed to enable"))), + // Add other states here + ) +} + +fun aSecureBackupEnableState( + enableAction: Async = Async.Uninitialized, +) = SecureBackupEnableState( + enableAction = enableAction, + eventSink = {} +) diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableView.kt new file mode 100644 index 0000000000..feaeb01d02 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableView.kt @@ -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 = {}, + ) +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyEvents.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyEvents.kt new file mode 100644 index 0000000000..d0d6eccc7d --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyEvents.kt @@ -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 +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyNode.kt new file mode 100644 index 0000000000..dcea7dfdb0 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyNode.kt @@ -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, + 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 + ) + ) + } +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyPresenter.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyPresenter.kt new file mode 100644 index 0000000000..fd32c0066d --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyPresenter.kt @@ -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 { + + @Composable + override fun present(): SecureBackupEnterRecoveryKeyState { + val coroutineScope = rememberCoroutineScope() + var recoveryKey by rememberSaveable { + mutableStateOf("") + } + val submitAction = remember { + mutableStateOf>(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> + ) = launch { + suspend { + encryptionService.fixRecoveryIssues(recoveryKey).getOrThrow() + }.runCatchingUpdatingState(action) + } +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyState.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyState.kt new file mode 100644 index 0000000000..c9a631c11c --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyState.kt @@ -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, + val eventSink: (SecureBackupEnterRecoveryKeyEvents) -> Unit +) diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyStateProvider.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyStateProvider.kt new file mode 100644 index 0000000000..375eafef73 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyStateProvider.kt @@ -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 { + override val values: Sequence + 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 = Async.Uninitialized, +) = SecureBackupEnterRecoveryKeyState( + recoveryKeyViewState = RecoveryKeyViewState( + recoveryKeyUserStory = RecoveryKeyUserStory.Enter, + formattedRecoveryKey = recoveryKey, + inProgress = submitAction.isLoading(), + ), + isSubmitEnabled = isSubmitEnabled, + submitAction = submitAction, + eventSink = {} +) diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyView.kt new file mode 100644 index 0000000000..3fb95e7310 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyView.kt @@ -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 = {}, + ) +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootNode.kt new file mode 100644 index 0000000000..676214e698 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootNode.kt @@ -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, + 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().forEach { it.onSetupClicked() } + } + + private fun onChangeClicked() { + plugins().forEach { it.onChangeClicked() } + } + + private fun onDisableClicked() { + plugins().forEach { it.onDisableClicked() } + } + + private fun onEnableClicked() { + plugins().forEach { it.onEnableClicked() } + } + + private fun onConfirmRecoveryKeyClicked() { + plugins().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, + ) + } +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootPresenter.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootPresenter.kt new file mode 100644 index 0000000000..8a8fa6075c --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootPresenter.kt @@ -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 { + + @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, + ) + } +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootState.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootState.kt new file mode 100644 index 0000000000..1eacd9e81a --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootState.kt @@ -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?, +) diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootStateProvider.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootStateProvider.kt new file mode 100644 index 0000000000..ee15a40f50 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootStateProvider.kt @@ -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 { + override val values: Sequence + 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, +) diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootView.kt new file mode 100644 index 0000000000..dd352ca99e --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootView.kt @@ -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 = {}, + ) +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupEvents.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupEvents.kt new file mode 100644 index 0000000000..746060f8e1 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupEvents.kt @@ -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 +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupNode.kt new file mode 100644 index 0000000000..15effd9045 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupNode.kt @@ -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, + presenterFactory: SecureBackupSetupPresenter.Factory, + private val snackbarDispatcher: SnackbarDispatcher, +) : Node(buildContext, plugins = plugins) { + + data class Inputs( + val isChangeRecoveryKeyUserStory: Boolean, + ) : NodeInputs + + private val 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 + ) + ) + } +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenter.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenter.kt new file mode 100644 index 0000000000..1bba716bb1 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenter.kt @@ -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 { + + @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 + ) = 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 + ) = 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)) + } + } + } +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupState.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupState.kt new file mode 100644 index 0000000000..918d52da54 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupState.kt @@ -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 +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupStateMachine.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupStateMachine.kt new file mode 100644 index 0000000000..7e2c633a61 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupStateMachine.kt @@ -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( + initialState = State.Initial +) { + + init { + spec { + inState { + on { _: Event.UserCreatesKey, state: MachineState -> + state.override { State.CreatingKey } + } + } + inState { + on { _: Event.SdkError, state: MachineState -> + state.override { State.Initial } + } + on { event: Event.SdkHasCreatedKey, state: MachineState -> + state.override { State.KeyCreated(event.key) } + } + } + inState { + on { _: Event.UserSavedKey, state: MachineState -> + state.override { State.KeyCreatedAndSaved(state.snapshot.key) } + } + } + inState { + } + } + } + + 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 + } +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupStateProvider.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupStateProvider.kt new file mode 100644 index 0000000000..3aa1f0a7c3 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupStateProvider.kt @@ -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 { + override val values: Sequence + 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, + ) +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupView.kt new file mode 100644 index 0000000000..0e1fa3b824 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupView.kt @@ -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 = {}, + ) +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupViewChangePreview.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupViewChangePreview.kt new file mode 100644 index 0000000000..0f7408700f --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupViewChangePreview.kt @@ -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 = {}, + ) +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/views/RecoveryKeyView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/views/RecoveryKeyView.kt new file mode 100644 index 0000000000..24e70d3b8c --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/views/RecoveryKeyView.kt @@ -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 = {}, + ) +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/views/RecoveryKeyViewState.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/views/RecoveryKeyViewState.kt new file mode 100644 index 0000000000..fee10d9f48 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/views/RecoveryKeyViewState.kt @@ -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, +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/views/RecoveryKeyViewStateProvider.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/views/RecoveryKeyViewStateProvider.kt new file mode 100644 index 0000000000..f3cc6764c0 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/views/RecoveryKeyViewStateProvider.kt @@ -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 { + override val values: Sequence + 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" +} diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/tools/RecoveryKeyVisualTransformation.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/tools/RecoveryKeyVisualTransformation.kt new file mode 100644 index 0000000000..f15acaa458 --- /dev/null +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/tools/RecoveryKeyVisualTransformation.kt @@ -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 + } + } +} diff --git a/features/securebackup/impl/src/main/res/values-sk/translations.xml b/features/securebackup/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 0000000000..7c702d0be2 --- /dev/null +++ b/features/securebackup/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,42 @@ + + + "Vypnúť zálohovanie" + "Zapnúť zálohovanie" + "Zálohovanie zaisťuje, že nestratíte históriu správ. %1$s." + "Zálohovanie" + "Zmeniť kľúč na obnovenie" + "Potvrdiť kľúč na obnovenie" + "Vaša záloha konverzácie nie je momentálne synchronizovaná." + "Nastaviť obnovovanie" + "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í." + "Vypnúť" + "Stratíte prístup k svojim zašifrovaným správam, ak sa odhlásite zo všetkých zariadení" + "Ste si istí, že chcete vypnúť zálohovanie?" + "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:" + "Na nových zariadeniach nebudete mať zašifrovanú históriu správ" + "Stratíte prístup k svojim zašifrovaným správam, ak sa odhlásite zo všetkých %1$s zariadení" + "Ste si istí, že chcete vypnúť zálohovanie?" + "Získajte nový kľúč na obnovenie, ak ste stratili svoj existujúci. Po zmene kľúča na obnovenie už starý kľúč nebude fungovať." + "Vygenerovať nový kľúč na obnovenie" + "Uistite sa, že kľúč na obnovenie môžete uložiť niekde v bezpečí" + "Kľúč na obnovenie bol zmenený" + "Zmeniť kľúč na obnovenie?" + "Zadajte kľúč na obnovenie a potvrďte prístup k zálohe konverzácie." + "Zadajte 48-znakový kód." + "Zadať…" + "Kľúč na obnovu potvrdený" + "Potvrďte kľúč na obnovenie" + "Skopírovaný kľúč na obnovenie" + "Generovanie…" + "Uložiť kľúč na obnovenie" + "Zapíšte si kľúč na obnovenie na bezpečné miesto alebo ho uložte do správcu hesiel." + "Ťuknutím skopírujte kľúč na obnovenie" + "Uložte svoj kľúč na obnovenie" + "Po tomto kroku nebudete mať prístup k novému kľúču na obnovenie." + "Uložili ste kľúč na obnovenie?" + "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“." + "Vygenerujte si váš kľúč na obnovenie" + "Uistite sa, že kľúč na obnovenie môžete uložiť niekde v bezpečí" + "Úspešné nastavenie obnovy" + "Nastaviť obnovenie" + diff --git a/features/securebackup/impl/src/main/res/values/localazy.xml b/features/securebackup/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..67dd2ec045 --- /dev/null +++ b/features/securebackup/impl/src/main/res/values/localazy.xml @@ -0,0 +1,42 @@ + + + "Turn off backup" + "Turn on backup" + "Backup ensures that you don\'t lose your message history. %1$s." + "Backup" + "Change recovery key" + "Confirm recovery key" + "Your chat backup is currently out of sync." + "Set up recovery" + "Get access to your encrypted messages if you lose all your devices or are signed out of %1$s everywhere." + "Turn off" + "You will lose your encrypted messages if you are signed out of all devices." + "Are you sure you want to turn off backup?" + "Turning off backup will remove your current encryption key backup and turn off other security features. In this case, you will:" + "Not have encrypted message history on new devices" + "Lose access to your encrypted messages if you are signed out of %1$s everywhere" + "Are you sure you want to turn off backup?" + "Get a new recovery key if you\'ve lost your existing one. After changing your recovery key, your old one will no longer work." + "Generate a new recovery key" + "Make sure you can store your recovery key somewhere safe" + "Recovery key changed" + "Change recovery key?" + "Enter your recovery key to confirm access to your chat backup." + "Enter the 48 character code." + "Enter…" + "Recovery key confirmed" + "Confirm your recovery key" + "Copied recovery key" + "Generating…" + "Save recovery key" + "Write down your recovery key somewhere safe or save it in a password manager." + "Tap to copy recovery key" + "Save your recovery key" + "You will not be able to access your new recovery key after this step." + "Have you saved your recovery key?" + "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’." + "Generate your recovery key" + "Make sure you can store your recovery key somewhere safe" + "Recovery setup successful" + "Set up recovery" + diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisablePresenterTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisablePresenterTest.kt new file mode 100644 index 0000000000..8e42ee7efe --- /dev/null +++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisablePresenterTest.kt @@ -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, + ) + ) + } +} diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnablePresenterTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnablePresenterTest.kt new file mode 100644 index 0000000000..5aa223cf93 --- /dev/null +++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnablePresenterTest.kt @@ -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, + ) +} diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyPresenterTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyPresenterTest.kt new file mode 100644 index 0000000000..bcdbb6bbe7 --- /dev/null +++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyPresenterTest.kt @@ -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()) + assertThat(loadingState.isSubmitEnabled).isFalse() + val errorState = awaitItem() + assertThat(errorState.submitAction).isEqualTo(Async.Failure(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()) + 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, + ) +} diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootPresenterTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootPresenterTest.kt new file mode 100644 index 0000000000..93031b6d2a --- /dev/null +++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootPresenterTest.kt @@ -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(), + ) + } +} diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenterTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenterTest.kt new file mode 100644 index 0000000000..f4071c6645 --- /dev/null +++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupPresenterTest.kt @@ -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, + ) + } +} diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/tools/RecoveryKeyVisualTransformationTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/tools/RecoveryKeyVisualTransformationTest.kt new file mode 100644 index 0000000000..cfe2ba32a6 --- /dev/null +++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/tools/RecoveryKeyVisualTransformationTest.kt @@ -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) + } +} diff --git a/libraries/androidutils/build.gradle.kts b/libraries/androidutils/build.gradle.kts index 57e4ca3569..87382a2c3e 100644 --- a/libraries/androidutils/build.gradle.kts +++ b/libraries/androidutils/build.gradle.kts @@ -40,5 +40,5 @@ dependencies { implementation(libs.androidx.recyclerview) implementation(libs.androidx.exifinterface) implementation(libs.androidx.security.crypto) - implementation(libs.androidx.browser) + api(libs.androidx.browser) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceText.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceText.kt index e0b8ea7926..20146e6e24 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceText.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceText.kt @@ -31,10 +31,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import io.element.android.libraries.designsystem.atomic.atoms.RedIndicatorAtom import io.element.android.libraries.designsystem.components.preferences.components.PreferenceIcon -import io.element.android.libraries.designsystem.preview.ElementThemedPreview +import io.element.android.libraries.designsystem.preview.ElementPreviewDark +import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.PreviewGroup import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.Text @@ -52,16 +55,18 @@ fun PreferenceText( modifier: Modifier = Modifier, enabled: Boolean = true, subtitle: String? = null, + subtitleAnnotated: AnnotatedString? = null, currentValue: String? = null, loadingCurrentValue: Boolean = false, icon: ImageVector? = null, @DrawableRes iconResourceId: Int? = null, showIconAreaIfNoIcon: Boolean = false, showIconBadge: Boolean = false, + showEndBadge: Boolean = false, tintColor: Color? = null, onClick: () -> Unit = {}, ) { - val minHeight = if (subtitle == null) preferenceMinHeightOnlyTitle else preferenceMinHeight + val minHeight = if (subtitle == null && subtitleAnnotated == null) preferenceMinHeightOnlyTitle else preferenceMinHeight Row( modifier = modifier @@ -95,6 +100,12 @@ fun PreferenceText( text = subtitle, color = tintColor ?: enabled.toSecondaryEnabledColor(), ) + } else if (subtitleAnnotated != null) { + Text( + style = ElementTheme.typography.fontBodyMdRegular, + text = subtitleAnnotated, + color = tintColor ?: enabled.toSecondaryEnabledColor(), + ) } } if (currentValue != null) { @@ -116,32 +127,63 @@ fun PreferenceText( strokeWidth = 2.dp ) } + if (showEndBadge) { + val endBadgeStartPadding = if (currentValue != null || loadingCurrentValue) 8.dp else 16.dp + RedIndicatorAtom( + modifier = Modifier + .align(Alignment.CenterVertically) + .padding(start = endBadgeStartPadding) + ) + } } } @Preview(group = PreviewGroup.Preferences) @Composable -internal fun PreferenceTextPreview() = ElementThemedPreview { ContentToPreview() } +internal fun PreferenceTextLightPreview() = ElementPreviewLight { + ContentToPreview(showEndBadge = false) +} + +@Preview(group = PreviewGroup.Preferences) +@Composable +internal fun PreferenceTextDarkPreview() = ElementPreviewDark { + ContentToPreview(showEndBadge = false) +} + +@Preview(group = PreviewGroup.Preferences) +@Composable +internal fun PreferenceTextWithEndBadgeLightPreview() = ElementPreviewLight { + ContentToPreview(showEndBadge = true) +} + +@Preview(group = PreviewGroup.Preferences) +@Composable +internal fun PreferenceTextWithEndBadgeDarkPreview() = ElementPreviewDark { + ContentToPreview(showEndBadge = true) +} @Composable -private fun ContentToPreview() { +private fun ContentToPreview(showEndBadge: Boolean) { Column( verticalArrangement = Arrangement.spacedBy(2.dp) ) { PreferenceText( title = "Title", iconResourceId = CommonDrawables.ic_compound_chat_problem, + showEndBadge = showEndBadge, ) PreferenceText( title = "Title", subtitle = "Some content", iconResourceId = CommonDrawables.ic_compound_chat_problem, + showEndBadge = showEndBadge, ) PreferenceText( title = "Title", subtitle = "Some content", iconResourceId = CommonDrawables.ic_compound_chat_problem, currentValue = "123", + showEndBadge = showEndBadge, ) PreferenceText( title = "Title", @@ -149,31 +191,37 @@ private fun ContentToPreview() { iconResourceId = CommonDrawables.ic_compound_chat_problem, currentValue = "123", enabled = false, + showEndBadge = showEndBadge, ) PreferenceText( title = "Title", subtitle = "Some content", iconResourceId = CommonDrawables.ic_compound_chat_problem, loadingCurrentValue = true, + showEndBadge = showEndBadge, ) PreferenceText( title = "Title", iconResourceId = CommonDrawables.ic_compound_chat_problem, currentValue = "123", + showEndBadge = showEndBadge, ) PreferenceText( title = "Title", iconResourceId = CommonDrawables.ic_compound_chat_problem, loadingCurrentValue = true, + showEndBadge = showEndBadge, ) PreferenceText( title = "Title no icon with icon area", showIconAreaIfNoIcon = true, loadingCurrentValue = true, + showEndBadge = showEndBadge, ) PreferenceText( title = "Title no icon", loadingCurrentValue = true, + showEndBadge = showEndBadge, ) } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Clickable.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Clickable.kt new file mode 100644 index 0000000000..2a74ee6fa9 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/Clickable.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.designsystem.modifiers + +import androidx.compose.foundation.clickable +import androidx.compose.ui.Modifier + +fun Modifier.clickableIfNotNull(onClick: (() -> Unit)? = null): Modifier = then( + if (onClick != null) { + Modifier.clickable { onClick() } + } else { + Modifier + } +) diff --git a/libraries/designsystem/src/main/res/drawable/ic_key.xml b/libraries/designsystem/src/main/res/drawable/ic_key.xml new file mode 100644 index 0000000000..b863406d24 --- /dev/null +++ b/libraries/designsystem/src/main/res/drawable/ic_key.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/designsystem/src/main/res/drawable/ic_key_filled.xml b/libraries/designsystem/src/main/res/drawable/ic_key_filled.xml new file mode 100644 index 0000000000..d1bee744f7 --- /dev/null +++ b/libraries/designsystem/src/main/res/drawable/ic_key_filled.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/libraries/designsystem/src/main/res/drawable/ic_key_off.xml b/libraries/designsystem/src/main/res/drawable/ic_key_off.xml new file mode 100644 index 0000000000..91ec467dce --- /dev/null +++ b/libraries/designsystem/src/main/res/drawable/ic_key_off.xml @@ -0,0 +1,9 @@ + + + diff --git a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferenceStateProvider.kt b/libraries/indicator/api/build.gradle.kts similarity index 73% rename from features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferenceStateProvider.kt rename to libraries/indicator/api/build.gradle.kts index 3e729298b0..3dd6fae101 100644 --- a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferenceStateProvider.kt +++ b/libraries/indicator/api/build.gradle.kts @@ -14,11 +14,14 @@ * limitations under the License. */ -package io.element.android.features.logout.api +plugins { + id("io.element.android-compose-library") +} -import io.element.android.libraries.architecture.Async +android { + namespace = "io.element.android.libraries.indicator.api" +} -fun aLogoutPreferenceState() = LogoutPreferenceState( - logoutAction = Async.Uninitialized, - eventSink = {} -) +dependencies { + implementation(libs.coroutines.core) +} diff --git a/libraries/indicator/api/src/main/kotlin/io/element/android/libraries/indicator/api/IndicatorService.kt b/libraries/indicator/api/src/main/kotlin/io/element/android/libraries/indicator/api/IndicatorService.kt new file mode 100644 index 0000000000..27653a42ae --- /dev/null +++ b/libraries/indicator/api/src/main/kotlin/io/element/android/libraries/indicator/api/IndicatorService.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.indicator.api + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State + +/** + * A set of State to observe to display or not the indicators in the UI. + */ +interface IndicatorService { + @Composable + fun showRoomListTopBarIndicator(): State + + @Composable + fun showSettingChatBackupIndicator(): State +} diff --git a/libraries/indicator/impl/build.gradle.kts b/libraries/indicator/impl/build.gradle.kts new file mode 100644 index 0000000000..cb7c6cf9f0 --- /dev/null +++ b/libraries/indicator/impl/build.gradle.kts @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.anvil) +} + +anvil { + generateDaggerFactories.set(true) +} + +android { + namespace = "io.element.android.libraries.indicator.impl" +} + +dependencies { + anvil(projects.anvilcodegen) + implementation(libs.dagger) + implementation(projects.libraries.di) + implementation(projects.libraries.matrix.api) + implementation(projects.anvilannotations) + + implementation(libs.coroutines.core) + + api(projects.libraries.indicator.api) + + testImplementation(projects.libraries.matrix.test) + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.test.turbine) + testImplementation(libs.test.truth) +} diff --git a/libraries/indicator/impl/src/main/kotlin/io/element/android/libraries/indicator/impl/DefaultIndicatorService.kt b/libraries/indicator/impl/src/main/kotlin/io/element/android/libraries/indicator/impl/DefaultIndicatorService.kt new file mode 100644 index 0000000000..96ec986534 --- /dev/null +++ b/libraries/indicator/impl/src/main/kotlin/io/element/android/libraries/indicator/impl/DefaultIndicatorService.kt @@ -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. + */ + +package io.element.android.libraries.indicator.impl + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.indicator.api.IndicatorService +import io.element.android.libraries.matrix.api.encryption.BackupState +import io.element.android.libraries.matrix.api.encryption.EncryptionService +import io.element.android.libraries.matrix.api.encryption.RecoveryState +import io.element.android.libraries.matrix.api.verification.SessionVerificationService +import javax.inject.Inject + +@ContributesBinding(SessionScope::class) +class DefaultIndicatorService @Inject constructor( + private val sessionVerificationService: SessionVerificationService, + private val encryptionService: EncryptionService, +) : IndicatorService { + + @Composable + override fun showRoomListTopBarIndicator(): State { + val canVerifySession by sessionVerificationService.canVerifySessionFlow.collectAsState(initial = false) + val settingChatBackupIndicator = showSettingChatBackupIndicator() + + return remember { + derivedStateOf { + !canVerifySession && settingChatBackupIndicator.value + } + } + } + + @Composable + override fun showSettingChatBackupIndicator(): State { + val backupState by encryptionService.backupStateStateFlow.collectAsState() + val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState() + + return remember { + derivedStateOf { + val showForBackup = backupState in listOf( + BackupState.UNKNOWN, + ) + val showForRecovery = recoveryState in listOf( + RecoveryState.DISABLED, + RecoveryState.INCOMPLETE, + ) + showForBackup || showForRecovery + } + } + } +} diff --git a/libraries/ui-strings/src/main/res/values-cs/translations.xml b/libraries/ui-strings/src/main/res/values-cs/translations.xml index e5f46af0e6..689f88cdee 100644 --- a/libraries/ui-strings/src/main/res/values-cs/translations.xml +++ b/libraries/ui-strings/src/main/res/values-cs/translations.xml @@ -245,6 +245,7 @@ Pokud budete pokračovat, některá nastavení se mohou změnit." "en" "Chyba" "Úspěch" + "Všichni" "Sdílejte anonymní údaje o používání, které nám pomohou identifikovat problémy." "Můžete si přečíst všechny naše podmínky %1$s." "zde" diff --git a/libraries/ui-strings/src/main/res/values-ru/translations.xml b/libraries/ui-strings/src/main/res/values-ru/translations.xml index 64d233e598..db0c685397 100644 --- a/libraries/ui-strings/src/main/res/values-ru/translations.xml +++ b/libraries/ui-strings/src/main/res/values-ru/translations.xml @@ -245,6 +245,7 @@ "en" "Ошибка" "Успешно" + "Для всех" "Предоставлять анонимные данные об использовании, чтобы помочь нам выявить проблемы." "Вы можете ознакомиться со всеми нашими условиями %1$s." "здесь" diff --git a/libraries/ui-strings/src/main/res/values-sk/translations.xml b/libraries/ui-strings/src/main/res/values-sk/translations.xml index ccefa660f2..b24831294a 100644 --- a/libraries/ui-strings/src/main/res/values-sk/translations.xml +++ b/libraries/ui-strings/src/main/res/values-sk/translations.xml @@ -218,22 +218,6 @@ "Toto je začiatok tejto konverzácie." "Nové" "Zdieľať analytické údaje" - "Vypnúť zálohovanie" - "Zapnúť zálohovanie" - "Zálohovanie zaisťuje, že nestratíte históriu správ. %1$s." - "Zálohovanie" - "Zmeniť kľúč na obnovenie" - "Potvrdiť kľúč na obnovenie" - "Vaša záloha konverzácie nie je momentálne synchronizovaná." - "Nastaviť obnovovanie" - "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í." - "Vypnúť" - "Stratíte prístup k svojim zašifrovaným správam, ak sa odhlásite zo všetkých zariadení" - "Ste si istí, že chcete vypnúť zálohovanie?" - "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:" - "Na nových zariadeniach nebudete mať zašifrovanú históriu správ" - "Stratíte prístup k svojim zašifrovaným správam, ak sa odhlásite zo všetkých %1$s zariadení" - "Ste si istí, že chcete vypnúť zálohovanie?" "Nepodarilo sa vybrať médium, skúste to prosím znova." "Nepodarilo sa spracovať médiá na odoslanie, skúste to prosím znova." "Nepodarilo sa nahrať médiá, skúste to prosím znova." @@ -264,29 +248,6 @@ Ak budete pokračovať, niektoré z vašich nastavení sa môžu zmeniť.""nastavenia systému" "Systémové oznámenia sú vypnuté" "Oznámenia" - "Získajte nový kľúč na obnovenie, ak ste stratili svoj existujúci. Po zmene kľúča na obnovenie už starý kľúč nebude fungovať." - "Vygenerovať nový kľúč na obnovenie" - "Uistite sa, že kľúč na obnovenie môžete uložiť niekde v bezpečí" - "Kľúč na obnovenie bol zmenený" - "Zmeniť kľúč na obnovenie?" - "Zadajte kľúč na obnovenie a potvrďte prístup k zálohe konverzácie." - "Zadajte 48-znakový kód." - "Zadať…" - "Kľúč na obnovu potvrdený" - "Potvrďte kľúč na obnovenie" - "Skopírovaný kľúč na obnovenie" - "Generovanie…" - "Uložiť kľúč na obnovenie" - "Zapíšte si kľúč na obnovenie na bezpečné miesto alebo ho uložte do správcu hesiel." - "Ťuknutím skopírujte kľúč na obnovenie" - "Uložte svoj kľúč na obnovenie" - "Po tomto kroku nebudete mať prístup k novému kľúču na obnovenie." - "Uložili ste kľúč na obnovenie?" - "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“." - "Vygenerujte si váš kľúč na obnovenie" - "Uistite sa, že kľúč na obnovenie môžete uložiť niekde v bezpečí" - "Úspešné nastavenie obnovy" - "Nastaviť obnovenie" "Označte, či chcete skryť všetky aktuálne a budúce správy od tohto používateľa" "Zdieľať polohu" "Zdieľať moju polohu" @@ -301,6 +262,7 @@ Ak budete pokračovať, niektoré z vašich nastavení sa môžu zmeniť.""sk" "Chyba" "Úspech" + "Všetci" "Zdieľajte anonymné údaje o používaní, aby sme mohli identifikovať problémy." "Môžete si prečítať všetky naše podmienky %1$s." "tu" diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 5a5815c602..8ff9fc54b9 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -136,6 +136,7 @@ "Report a bug" "Report submitted" "Rich text editor" + "Room" "Room name" "e.g. your project name" "Screen lock" @@ -173,8 +174,6 @@ "Waiting for decryption key" "Are you sure you want to end this poll?" "Poll: %1$s" - "Your chat backup is currently out of sync. You need to confirm your recovery key to maintain access to your chat backup." - "Confirm your recovery key" "Confirmation" "Warning" "Activities" @@ -219,23 +218,8 @@ "This is the beginning of %1$s." "This is the beginning of this conversation." "New" + "Notify the whole room" "Share analytics data" - "Turn off backup" - "Turn on backup" - "Backup ensures that you don\'t lose your message history. %1$s." - "Backup" - "Change recovery key" - "Confirm recovery key" - "Your chat backup is currently out of sync." - "Set up recovery" - "Get access to your encrypted messages if you lose all your devices or are signed out of %1$s everywhere." - "Turn off" - "You will lose your encrypted messages if you are signed out of all devices." - "Are you sure you want to turn off backup?" - "Turning off backup will remove your current encryption key backup and turn off other security features. In this case, you will:" - "Not have encrypted message history on new devices" - "Lose access to your encrypted messages if you are signed out of %1$s everywhere" - "Are you sure you want to turn off backup?" "Failed selecting media, please try again." "Failed processing media to upload, please try again." "Failed uploading media, please try again." @@ -264,29 +248,6 @@ If you proceed, some of your settings may change." "system settings" "System notifications turned off" "Notifications" - "Get a new recovery key if you\'ve lost your existing one. After changing your recovery key, your old one will no longer work." - "Generate a new recovery key" - "Make sure you can store your recovery key somewhere safe" - "Recovery key changed" - "Change recovery key?" - "Enter your recovery key to confirm access to your chat backup." - "Enter the 48 character code." - "Enter…" - "Recovery key confirmed" - "Confirm your recovery key" - "Copied recovery key" - "Generating…" - "Save recovery key" - "Write down your recovery key somewhere safe or save it in a password manager." - "Tap to copy recovery key" - "Save your recovery key" - "You will not be able to access your new recovery key after this step." - "Have you saved your recovery key?" - "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’." - "Generate your recovery key" - "Make sure you can store your recovery key somewhere safe" - "Recovery setup successful" - "Set up recovery" "Check if you want to hide all current and future messages from this user" "Share location" "Share my location" @@ -302,6 +263,7 @@ If you proceed, some of your settings may change." "en" "Error" "Success" + "Everyone" "Share anonymous usage data to help us identify issues." "You can read all our terms %1$s." "here" diff --git a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt index 28069b932c..c6786ef8b3 100644 --- a/plugins/src/main/kotlin/extension/DependencyHandleScope.kt +++ b/plugins/src/main/kotlin/extension/DependencyHandleScope.kt @@ -83,6 +83,7 @@ fun DependencyHandlerScope.allLibrariesImpl() { implementation(project(":libraries:network")) implementation(project(":libraries:core")) implementation(project(":libraries:eventformatter:impl")) + implementation(project(":libraries:indicator:impl")) implementation(project(":libraries:permissions:impl")) implementation(project(":libraries:push:impl")) implementation(project(":libraries:push:impl")) diff --git a/samples/minimal/build.gradle.kts b/samples/minimal/build.gradle.kts index 016989a2d6..150a718e44 100644 --- a/samples/minimal/build.gradle.kts +++ b/samples/minimal/build.gradle.kts @@ -57,6 +57,7 @@ dependencies { implementation(projects.libraries.network) implementation(projects.libraries.dateformatter.impl) implementation(projects.libraries.eventformatter.impl) + implementation(projects.libraries.indicator.impl) implementation(projects.features.invitelist.impl) implementation(projects.features.roomlist.impl) implementation(projects.features.leaveroom.impl) diff --git a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt index 0c43619f39..b43e7d940a 100644 --- a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt +++ b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt @@ -36,6 +36,7 @@ import io.element.android.libraries.eventformatter.impl.DefaultRoomLastMessageFo import io.element.android.libraries.eventformatter.impl.ProfileChangeContentFormatter import io.element.android.libraries.eventformatter.impl.RoomMembershipContentFormatter import io.element.android.libraries.eventformatter.impl.StateContentFormatter +import io.element.android.libraries.indicator.impl.DefaultIndicatorService import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.room.RoomMembershipObserver @@ -59,6 +60,7 @@ class RoomListScreen( private val dateTimeProvider = LocalDateTimeProvider(clock, timeZone) private val dateFormatters = DateFormatters(locale, clock, timeZone) private val sessionVerificationService = matrixClient.sessionVerificationService() + private val encryptionService = matrixClient.encryptionService() private val stringProvider = AndroidStringProvider(context.resources) private val presenter = RoomListPresenter( client = matrixClient, @@ -80,6 +82,11 @@ class RoomListScreen( coroutineDispatchers = coroutineDispatchers, notificationSettingsService = matrixClient.notificationSettingsService(), appScope = Singleton.appScope + ), + encryptionService = encryptionService, + indicatorService = DefaultIndicatorService( + sessionVerificationService = sessionVerificationService, + encryptionService = encryptionService, ) ) diff --git a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistTestTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistTestTest.kt index 32fd6dd624..5f7acf8d39 100644 --- a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistTestTest.kt +++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistTestTest.kt @@ -40,7 +40,9 @@ class KonsistTestTest { .functions() .withReturnType { it.name.endsWith("Presenter") } .withoutOverrideModifier() - .assertTrue { functionDeclaration -> + .assertTrue( + additionalMessage = "The function can also be named 'createPresenter'. To please Konsist in this case, just remove the return type." + ) { functionDeclaration -> functionDeclaration.name == "create${functionDeclaration.returnType?.name}" } } diff --git a/tools/localazy/config.json b/tools/localazy/config.json index 5144dc191d..6b7f5202d8 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -14,7 +14,7 @@ ] }, { - "name": ":features:logout:api", + "name": ":features:logout:impl", "includeRegex": [ "screen_signout_.*" ] @@ -96,7 +96,8 @@ "name": ":features:roomlist:impl", "includeRegex": [ "screen_roomlist_.*", - "session_verification_banner_.*" + "session_verification_banner_.*", + "confirm_recovery_key_banner_.*" ] }, { @@ -141,6 +142,14 @@ "screen_create_poll_.*" ] }, + { + "name": ":features:securebackup:impl", + "includeRegex": [ + "screen_chat_backup_.*", + "screen_key_backup_disable_.*", + "screen_recovery_key_.*" + ] + }, { "name": ":features:preferences:impl", "includeRegex": [