Secure backup
This commit is contained in:
committed by
Benoit Marty
parent
61d59913bb
commit
bb55a5676c
1
.idea/dictionaries/shared.xml
generated
1
.idea/dictionaries/shared.xml
generated
@@ -10,6 +10,7 @@
|
||||
<w>onboarding</w>
|
||||
<w>placeables</w>
|
||||
<w>posthog</w>
|
||||
<w>securebackup</w>
|
||||
<w>showkase</w>
|
||||
<w>snackbar</w>
|
||||
<w>swipeable</w>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2022 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.logout.api
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||
import io.element.android.libraries.designsystem.components.preferences.PreferenceText
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
|
||||
@Composable
|
||||
fun LogoutPreferenceView(
|
||||
state: LogoutPreferenceState,
|
||||
onSuccessLogout: (logoutUrlResult: String?) -> Unit
|
||||
) {
|
||||
val eventSink = state.eventSink
|
||||
if (state.logoutAction is Async.Success) {
|
||||
LaunchedEffect(state.logoutAction) {
|
||||
onSuccessLogout(state.logoutAction.data)
|
||||
}
|
||||
return
|
||||
}
|
||||
val openDialog = remember { mutableStateOf(false) }
|
||||
|
||||
LogoutPreferenceContent(
|
||||
onClick = {
|
||||
openDialog.value = true
|
||||
}
|
||||
)
|
||||
|
||||
// Log out confirmation dialog
|
||||
if (openDialog.value) {
|
||||
ConfirmationDialog(
|
||||
title = stringResource(id = R.string.screen_signout_confirmation_dialog_title),
|
||||
content = stringResource(id = R.string.screen_signout_confirmation_dialog_content),
|
||||
submitText = stringResource(id = R.string.screen_signout_confirmation_dialog_submit),
|
||||
onCancelClicked = {
|
||||
openDialog.value = false
|
||||
},
|
||||
onSubmitClicked = {
|
||||
openDialog.value = false
|
||||
eventSink(LogoutPreferenceEvents.Logout)
|
||||
},
|
||||
onDismiss = {
|
||||
openDialog.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (state.logoutAction is Async.Loading) {
|
||||
ProgressDialog(text = stringResource(id = R.string.screen_signout_in_progress_dialog_content))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LogoutPreferenceContent(
|
||||
onClick: () -> Unit = {},
|
||||
) {
|
||||
PreferenceText(
|
||||
title = stringResource(id = R.string.screen_signout_preference_item),
|
||||
iconResourceId = CommonDrawables.ic_compound_leave,
|
||||
onClick = onClick
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun LogoutPreferenceViewPreview() = ElementPreview {
|
||||
LogoutPreferenceView(
|
||||
aLogoutPreferenceState(),
|
||||
onSuccessLogout = {}
|
||||
)
|
||||
}
|
||||
@@ -31,6 +31,7 @@ anvil {
|
||||
dependencies {
|
||||
implementation(projects.anvilannotations)
|
||||
anvil(projects.anvilcodegen)
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.logout.impl
|
||||
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.logout.api.LogoutEntryPoint
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultLogoutEntryPoint @Inject constructor() : LogoutEntryPoint {
|
||||
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): LogoutEntryPoint.NodeBuilder {
|
||||
val plugins = ArrayList<Plugin>()
|
||||
|
||||
return object : LogoutEntryPoint.NodeBuilder {
|
||||
|
||||
override fun callback(callback: LogoutEntryPoint.Callback): LogoutEntryPoint.NodeBuilder {
|
||||
plugins += callback
|
||||
return this
|
||||
}
|
||||
|
||||
override fun build(): Node {
|
||||
return parentNode.createNode<LogoutNode>(buildContext, plugins)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.logout.impl
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.logout.api.LogoutPreferenceEvents
|
||||
import io.element.android.features.logout.api.LogoutPreferencePresenter
|
||||
import io.element.android.features.logout.api.LogoutPreferenceState
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(SessionScope::class)
|
||||
class DefaultLogoutPreferencePresenter @Inject constructor(private val matrixClient: MatrixClient) :
|
||||
LogoutPreferencePresenter {
|
||||
|
||||
@Composable
|
||||
override fun present(): LogoutPreferenceState {
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
val logoutAction: MutableState<Async<String?>> = remember {
|
||||
mutableStateOf(Async.Uninitialized)
|
||||
}
|
||||
|
||||
fun handleEvents(event: LogoutPreferenceEvents) {
|
||||
when (event) {
|
||||
LogoutPreferenceEvents.Logout -> localCoroutineScope.logout(logoutAction)
|
||||
}
|
||||
}
|
||||
|
||||
return LogoutPreferenceState(
|
||||
logoutAction = logoutAction.value,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.logout(logoutAction: MutableState<Async<String?>>) = launch {
|
||||
suspend {
|
||||
matrixClient.logout(false /* TODO */)
|
||||
}.runCatchingUpdatingState(logoutAction)
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,9 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.logout.api
|
||||
package io.element.android.features.logout.impl
|
||||
|
||||
sealed interface LogoutPreferenceEvents {
|
||||
data object Logout : LogoutPreferenceEvents
|
||||
sealed interface LogoutEvents {
|
||||
data class Logout(val ignoreSdkError: Boolean) : LogoutEvents
|
||||
data object CloseDialogs : LogoutEvents
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.logout.impl
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.bumble.appyx.core.plugin.plugins
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.logout.api.LogoutEntryPoint
|
||||
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import timber.log.Timber
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
class LogoutNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val presenter: LogoutPresenter,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
|
||||
private fun onChangeRecoveryKeyClicked() {
|
||||
plugins<LogoutEntryPoint.Callback>().forEach { it.onChangeRecoveryKeyClicked() }
|
||||
}
|
||||
|
||||
private fun onSuccessLogout(activity: Activity, url: String?) {
|
||||
Timber.d("Success logout with result url: $url")
|
||||
url?.let {
|
||||
activity.openUrlInChromeCustomTab(null, false, it)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
val activity = LocalContext.current as Activity
|
||||
LogoutView(
|
||||
state = state,
|
||||
onChangeRecoveryKeyClicked = ::onChangeRecoveryKeyClicked,
|
||||
onSuccessLogout = { onSuccessLogout(activity, it) },
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.logout.impl
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class LogoutPresenter @Inject constructor(
|
||||
private val matrixClient: MatrixClient,
|
||||
private val encryptionService: EncryptionService,
|
||||
) : Presenter<LogoutState> {
|
||||
|
||||
@Composable
|
||||
override fun present(): LogoutState {
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
val logoutAction: MutableState<Async<String?>> = remember {
|
||||
mutableStateOf(Async.Uninitialized)
|
||||
}
|
||||
|
||||
val backupUploadState by encryptionService.backupUploadStateStateFlow.collectAsState()
|
||||
|
||||
var showLogoutDialog by remember { mutableStateOf(false) }
|
||||
var isLastSession by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
isLastSession = encryptionService.isLastDevice().getOrNull() ?: false
|
||||
encryptionService.waitForBackupUploadSteadyState()
|
||||
}
|
||||
|
||||
fun handleEvents(event: LogoutEvents) {
|
||||
when (event) {
|
||||
is LogoutEvents.Logout -> {
|
||||
if (showLogoutDialog || event.ignoreSdkError) {
|
||||
showLogoutDialog = false
|
||||
localCoroutineScope.logout(logoutAction, event.ignoreSdkError)
|
||||
} else {
|
||||
showLogoutDialog = true
|
||||
}
|
||||
}
|
||||
LogoutEvents.CloseDialogs -> {
|
||||
logoutAction.value = Async.Uninitialized
|
||||
showLogoutDialog = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return LogoutState(
|
||||
isLastSession = isLastSession,
|
||||
backupUploadState = backupUploadState,
|
||||
showConfirmationDialog = showLogoutDialog,
|
||||
logoutAction = logoutAction.value,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.logout(
|
||||
logoutAction: MutableState<Async<String?>>,
|
||||
ignoreSdkError: Boolean,
|
||||
) = launch {
|
||||
suspend {
|
||||
matrixClient.logout(ignoreSdkError)
|
||||
}.runCatchingUpdatingState(logoutAction)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.logout.impl
|
||||
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
|
||||
|
||||
data class LogoutState(
|
||||
val isLastSession: Boolean,
|
||||
val backupUploadState: BackupUploadState,
|
||||
val showConfirmationDialog: Boolean,
|
||||
val logoutAction: Async<String?>,
|
||||
val eventSink: (LogoutEvents) -> Unit,
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.logout.impl
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
|
||||
|
||||
open class LogoutStateProvider : PreviewParameterProvider<LogoutState> {
|
||||
override val values: Sequence<LogoutState>
|
||||
get() = sequenceOf(
|
||||
aLogoutState(),
|
||||
aLogoutState(isLastSession = true),
|
||||
aLogoutState(isLastSession = false, backupUploadState = BackupUploadState.Uploading(66, 200)),
|
||||
aLogoutState(isLastSession = true, backupUploadState = BackupUploadState.Done),
|
||||
aLogoutState(showConfirmationDialog = true),
|
||||
aLogoutState(logoutAction = Async.Loading()),
|
||||
aLogoutState(logoutAction = Async.Failure(Exception("Failed to logout"))),
|
||||
)
|
||||
}
|
||||
|
||||
fun aLogoutState(
|
||||
isLastSession: Boolean = false,
|
||||
backupUploadState: BackupUploadState = BackupUploadState.Unknown,
|
||||
showConfirmationDialog: Boolean = false,
|
||||
logoutAction: Async<String?> = Async.Uninitialized,
|
||||
) = LogoutState(
|
||||
isLastSession = isLastSession,
|
||||
backupUploadState = backupUploadState,
|
||||
showConfirmationDialog = showConfirmationDialog,
|
||||
logoutAction = logoutAction,
|
||||
eventSink = {}
|
||||
)
|
||||
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.logout.impl
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
|
||||
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
|
||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
|
||||
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.progressIndicatorTrackColor
|
||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun LogoutView(
|
||||
state: LogoutState,
|
||||
onChangeRecoveryKeyClicked: () -> Unit,
|
||||
onSuccessLogout: (logoutUrlResult: String?) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val eventSink = state.eventSink
|
||||
|
||||
HeaderFooterPage(
|
||||
modifier = modifier,
|
||||
header = {
|
||||
HeaderContent(state = state)
|
||||
},
|
||||
footer = {
|
||||
BottomMenu(
|
||||
state = state,
|
||||
onChangeRecoveryKeyClicked = onChangeRecoveryKeyClicked,
|
||||
onLogoutClicked = {
|
||||
eventSink(LogoutEvents.Logout(ignoreSdkError = false))
|
||||
},
|
||||
)
|
||||
}
|
||||
) {
|
||||
Content(state = state)
|
||||
}
|
||||
|
||||
// Log out confirmation dialog
|
||||
if (state.showConfirmationDialog) {
|
||||
ConfirmationDialog(
|
||||
title = stringResource(id = CommonStrings.action_signout),
|
||||
content = stringResource(id = R.string.screen_signout_confirmation_dialog_content),
|
||||
submitText = stringResource(id = CommonStrings.action_signout),
|
||||
onCancelClicked = {
|
||||
eventSink(LogoutEvents.CloseDialogs)
|
||||
},
|
||||
onSubmitClicked = {
|
||||
eventSink(LogoutEvents.Logout(ignoreSdkError = false))
|
||||
},
|
||||
onDismiss = {
|
||||
eventSink(LogoutEvents.CloseDialogs)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
when (state.logoutAction) {
|
||||
is Async.Loading ->
|
||||
ProgressDialog(text = stringResource(id = R.string.screen_signout_in_progress_dialog_content))
|
||||
is Async.Failure ->
|
||||
ConfirmationDialog(
|
||||
title = stringResource(id = CommonStrings.dialog_title_error),
|
||||
content = stringResource(id = CommonStrings.error_unknown),
|
||||
submitText = stringResource(id = CommonStrings.action_signout_anyway),
|
||||
onCancelClicked = {
|
||||
eventSink(LogoutEvents.CloseDialogs)
|
||||
},
|
||||
onSubmitClicked = {
|
||||
eventSink(LogoutEvents.Logout(ignoreSdkError = true))
|
||||
},
|
||||
onDismiss = {
|
||||
eventSink(LogoutEvents.CloseDialogs)
|
||||
}
|
||||
)
|
||||
Async.Uninitialized ->
|
||||
Unit
|
||||
is Async.Success ->
|
||||
LaunchedEffect(state.logoutAction) {
|
||||
onSuccessLogout(state.logoutAction.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO i18n
|
||||
@Composable
|
||||
private fun HeaderContent(
|
||||
state: LogoutState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val title = when {
|
||||
state.backupUploadState.isBackingUp() -> stringResource(id = R.string.screen_signout_key_backup_ongoing_title)
|
||||
state.isLastSession -> stringResource(id = R.string.screen_signout_key_backup_disabled_title)
|
||||
else -> "Sign out of Element" // TODO
|
||||
}
|
||||
val subtitle = when {
|
||||
state.backupUploadState.isBackingUp() -> stringResource(id = R.string.screen_signout_key_backup_ongoing_subtitle)
|
||||
state.isLastSession -> stringResource(id = R.string.screen_signout_key_backup_disabled_subtitle)
|
||||
else -> null
|
||||
}
|
||||
|
||||
val paddingTop = 60.dp
|
||||
IconTitleSubtitleMolecule(
|
||||
modifier = modifier.padding(top = paddingTop),
|
||||
iconResourceId = CommonDrawables.ic_key,
|
||||
title = title,
|
||||
subTitle = subtitle,
|
||||
// iconComposable = iconComposable,
|
||||
)
|
||||
}
|
||||
|
||||
private fun BackupUploadState.isBackingUp(): Boolean {
|
||||
return when (this) {
|
||||
BackupUploadState.Unknown,
|
||||
BackupUploadState.Waiting,
|
||||
is BackupUploadState.Uploading,
|
||||
is BackupUploadState.CheckingIfUploadNeeded -> true
|
||||
BackupUploadState.Done -> false
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BottomMenu(
|
||||
state: LogoutState,
|
||||
onLogoutClicked: () -> Unit,
|
||||
onChangeRecoveryKeyClicked: () -> Unit,
|
||||
) {
|
||||
val logoutAction = state.logoutAction
|
||||
ButtonColumnMolecule(
|
||||
modifier = Modifier.padding(bottom = 20.dp)
|
||||
) {
|
||||
if (state.isLastSession) {
|
||||
OutlinedButton(
|
||||
text = stringResource(id = CommonStrings.common_settings),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = onChangeRecoveryKeyClicked,
|
||||
)
|
||||
}
|
||||
val signOutSubmitRes = when {
|
||||
logoutAction is Async.Loading -> R.string.screen_signout_in_progress_dialog_content
|
||||
state.backupUploadState.isBackingUp() -> CommonStrings.action_signout_anyway
|
||||
else -> CommonStrings.action_signout
|
||||
}
|
||||
Button(
|
||||
text = stringResource(id = signOutSubmitRes),
|
||||
showProgress = logoutAction is Async.Loading,
|
||||
destructive = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = onLogoutClicked,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Content(
|
||||
state: LogoutState,
|
||||
) {
|
||||
if (state.backupUploadState is BackupUploadState.Uploading) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 60.dp, start = 20.dp, end = 20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
progress = state.backupUploadState.backedUpCount.toFloat() / state.backupUploadState.totalCount.toFloat(),
|
||||
trackColor = ElementTheme.colors.progressIndicatorTrackColor,
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
text = "${state.backupUploadState.backedUpCount} / ${state.backupUploadState.totalCount}",
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun LogoutViewPreview(
|
||||
@PreviewParameter(LogoutStateProvider::class) state: LogoutState,
|
||||
) = ElementPreview {
|
||||
LogoutView(
|
||||
state,
|
||||
onChangeRecoveryKeyClicked = {},
|
||||
onSuccessLogout = {}
|
||||
)
|
||||
}
|
||||
6
features/logout/impl/src/main/res/values/tmp.xml
Normal file
6
features/logout/impl/src/main/res/values/tmp.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<!-- TODO Delete those 2 strings (replaced by action_sign_out) -->
|
||||
<!--string name="screen_signout_confirmation_dialog_submit">"Sign out"</string>
|
||||
<string name="screen_signout_preference_item">"Sign out"</string-->
|
||||
</resources>
|
||||
@@ -1,87 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.logout.impl
|
||||
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.logout.api.LogoutPreferenceEvents
|
||||
import io.element.android.features.logout.api.LogoutPreferenceState
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.matrix.test.A_THROWABLE
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class LogoutPreferencePresenterTest {
|
||||
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
val presenter = DefaultLogoutPreferencePresenter(
|
||||
FakeMatrixClient(),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.logoutAction).isEqualTo(Async.Uninitialized)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - logout`() = runTest {
|
||||
val presenter = DefaultLogoutPreferencePresenter(
|
||||
FakeMatrixClient(),
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(LogoutPreferenceEvents.Logout)
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.logoutAction).isInstanceOf(Async.Loading::class.java)
|
||||
val successState = awaitItem()
|
||||
assertThat(successState.logoutAction).isInstanceOf(Async.Success::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - logout with error`() = runTest {
|
||||
val matrixClient = FakeMatrixClient()
|
||||
val presenter = DefaultLogoutPreferencePresenter(
|
||||
matrixClient,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
matrixClient.givenLogoutError(A_THROWABLE)
|
||||
initialState.eventSink.invoke(LogoutPreferenceEvents.Logout)
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.logoutAction).isInstanceOf(Async.Loading::class.java)
|
||||
val successState = awaitItem()
|
||||
assertThat(successState.logoutAction).isEqualTo(Async.Failure<LogoutPreferenceState>(A_THROWABLE))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.logout.impl
|
||||
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
|
||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
import io.element.android.libraries.matrix.test.A_THROWABLE
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class LogoutPresenterTest {
|
||||
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
val presenter = createLogoutPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.isLastSession).isFalse()
|
||||
assertThat(initialState.backupUploadState).isEqualTo(BackupUploadState.Unknown)
|
||||
assertThat(initialState.showConfirmationDialog).isFalse()
|
||||
assertThat(initialState.logoutAction).isEqualTo(Async.Uninitialized)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - initial state - last session`() = runTest {
|
||||
val presenter = createLogoutPresenter(
|
||||
encryptionService = FakeEncryptionService().apply {
|
||||
givenIsLastDevice(true)
|
||||
}
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.isLastSession).isTrue()
|
||||
assertThat(initialState.backupUploadState).isEqualTo(BackupUploadState.Unknown)
|
||||
assertThat(initialState.showConfirmationDialog).isFalse()
|
||||
assertThat(initialState.logoutAction).isEqualTo(Async.Uninitialized)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - initial state - backing up`() = runTest {
|
||||
val encryptionService = FakeEncryptionService()
|
||||
val presenter = createLogoutPresenter(
|
||||
encryptionService = encryptionService
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.isLastSession).isFalse()
|
||||
assertThat(initialState.backupUploadState).isEqualTo(BackupUploadState.Unknown)
|
||||
assertThat(initialState.showConfirmationDialog).isFalse()
|
||||
assertThat(initialState.logoutAction).isEqualTo(Async.Uninitialized)
|
||||
encryptionService.emitBackupUploadState(BackupUploadState.Uploading(backedUpCount = 1, totalCount = 2))
|
||||
val state = awaitItem()
|
||||
assertThat(state.backupUploadState).isEqualTo(BackupUploadState.Uploading(backedUpCount = 1, totalCount = 2))
|
||||
encryptionService.emitBackupUploadState(BackupUploadState.Done)
|
||||
val doneState = awaitItem()
|
||||
assertThat(doneState.backupUploadState).isEqualTo(BackupUploadState.Done)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - logout then cancel`() = runTest {
|
||||
val presenter = createLogoutPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false))
|
||||
val confirmationState = awaitItem()
|
||||
assertThat(confirmationState.showConfirmationDialog).isTrue()
|
||||
initialState.eventSink.invoke(LogoutEvents.CloseDialogs)
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.showConfirmationDialog).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - logout then confirm`() = runTest {
|
||||
val presenter = createLogoutPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false))
|
||||
val confirmationState = awaitItem()
|
||||
assertThat(confirmationState.showConfirmationDialog).isTrue()
|
||||
confirmationState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false))
|
||||
skipItems(1)
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.showConfirmationDialog).isFalse()
|
||||
assertThat(loadingState.logoutAction).isInstanceOf(Async.Loading::class.java)
|
||||
val successState = awaitItem()
|
||||
assertThat(successState.logoutAction).isInstanceOf(Async.Success::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - logout with error then cancel`() = runTest {
|
||||
val matrixClient = FakeMatrixClient().apply {
|
||||
givenLogoutError(A_THROWABLE)
|
||||
}
|
||||
val presenter = createLogoutPresenter(
|
||||
matrixClient,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false))
|
||||
val confirmationState = awaitItem()
|
||||
assertThat(confirmationState.showConfirmationDialog).isTrue()
|
||||
confirmationState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false))
|
||||
skipItems(1)
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.showConfirmationDialog).isFalse()
|
||||
assertThat(loadingState.logoutAction).isInstanceOf(Async.Loading::class.java)
|
||||
val errorState = awaitItem()
|
||||
assertThat(errorState.logoutAction).isEqualTo(Async.Failure<LogoutState>(A_THROWABLE))
|
||||
errorState.eventSink.invoke(LogoutEvents.CloseDialogs)
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.logoutAction).isEqualTo(Async.Uninitialized)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - logout with error then force`() = runTest {
|
||||
val matrixClient = FakeMatrixClient().apply {
|
||||
givenLogoutError(A_THROWABLE)
|
||||
}
|
||||
val presenter = createLogoutPresenter(
|
||||
matrixClient,
|
||||
)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false))
|
||||
val confirmationState = awaitItem()
|
||||
assertThat(confirmationState.showConfirmationDialog).isTrue()
|
||||
confirmationState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false))
|
||||
skipItems(1)
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.showConfirmationDialog).isFalse()
|
||||
assertThat(loadingState.logoutAction).isInstanceOf(Async.Loading::class.java)
|
||||
val errorState = awaitItem()
|
||||
assertThat(errorState.logoutAction).isEqualTo(Async.Failure<LogoutState>(A_THROWABLE))
|
||||
errorState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = true))
|
||||
val loadingState2 = awaitItem()
|
||||
assertThat(loadingState2.showConfirmationDialog).isFalse()
|
||||
assertThat(loadingState2.logoutAction).isInstanceOf(Async.Loading::class.java)
|
||||
val successState = awaitItem()
|
||||
assertThat(successState.logoutAction).isInstanceOf(Async.Success::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createLogoutPresenter(
|
||||
matrixClient: MatrixClient = FakeMatrixClient(),
|
||||
encryptionService: EncryptionService = FakeEncryptionService(),
|
||||
): LogoutPresenter = LogoutPresenter(
|
||||
matrixClient = matrixClient,
|
||||
encryptionService = encryptionService,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ interface PreferencesEntryPoint : FeatureEntryPoint {
|
||||
interface Callback : Plugin {
|
||||
fun onOpenBugReport()
|
||||
fun onVerifyClicked()
|
||||
fun onSecureBackupClicked()
|
||||
fun onOpenRoomNotificationSettings(roomId: RoomId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -30,6 +30,7 @@ import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
|
||||
import io.element.android.features.logout.api.LogoutEntryPoint
|
||||
import io.element.android.features.preferences.api.PreferencesEntryPoint
|
||||
import io.element.android.features.preferences.impl.about.AboutNode
|
||||
import io.element.android.features.preferences.impl.advanced.AdvancedSettingsNode
|
||||
@@ -53,6 +54,7 @@ class PreferencesFlowNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val lockScreenEntryPoint: LockScreenEntryPoint,
|
||||
private val logoutEntryPoint: LogoutEntryPoint,
|
||||
) : BackstackNode<PreferencesFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = plugins.filterIsInstance<PreferencesEntryPoint.Params>().first().initialElement.toNavTarget(),
|
||||
@@ -92,6 +94,9 @@ class PreferencesFlowNode @AssistedInject constructor(
|
||||
|
||||
@Parcelize
|
||||
data class UserProfile(val matrixUser: MatrixUser) : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object SignOut : NavTarget
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
@@ -106,6 +111,10 @@ class PreferencesFlowNode @AssistedInject constructor(
|
||||
plugins<PreferencesEntryPoint.Callback>().forEach { it.onVerifyClicked() }
|
||||
}
|
||||
|
||||
override fun onSecureBackupClicked() {
|
||||
plugins<PreferencesEntryPoint.Callback>().forEach { it.onSecureBackupClicked() }
|
||||
}
|
||||
|
||||
override fun onOpenAnalytics() {
|
||||
backstack.push(NavTarget.AnalyticsSettings)
|
||||
}
|
||||
@@ -133,6 +142,10 @@ class PreferencesFlowNode @AssistedInject constructor(
|
||||
override fun onOpenUserProfile(matrixUser: MatrixUser) {
|
||||
backstack.push(NavTarget.UserProfile(matrixUser))
|
||||
}
|
||||
|
||||
override fun onSignOutClicked() {
|
||||
backstack.push(NavTarget.SignOut)
|
||||
}
|
||||
}
|
||||
createNode<PreferencesRootNode>(buildContext, plugins = listOf(callback))
|
||||
}
|
||||
@@ -182,6 +195,16 @@ class PreferencesFlowNode @AssistedInject constructor(
|
||||
.target(LockScreenEntryPoint.Target.Settings)
|
||||
.build()
|
||||
}
|
||||
NavTarget.SignOut -> {
|
||||
val callBack: LogoutEntryPoint.Callback = object : LogoutEntryPoint.Callback {
|
||||
override fun onChangeRecoveryKeyClicked() {
|
||||
plugins<PreferencesEntryPoint.Callback>().forEach { it.onSecureBackupClicked() }
|
||||
}
|
||||
}
|
||||
logoutEntryPoint.nodeBuilder(this, buildContext)
|
||||
.callback(callBack)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTa
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import timber.log.Timber
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
class PreferencesRootNode @AssistedInject constructor(
|
||||
@@ -43,6 +42,7 @@ class PreferencesRootNode @AssistedInject constructor(
|
||||
interface Callback : Plugin {
|
||||
fun onOpenBugReport()
|
||||
fun onVerifyClicked()
|
||||
fun onSecureBackupClicked()
|
||||
fun onOpenAnalytics()
|
||||
fun onOpenAbout()
|
||||
fun onOpenDeveloperSettings()
|
||||
@@ -50,6 +50,7 @@ class PreferencesRootNode @AssistedInject constructor(
|
||||
fun onOpenLockScreenSettings()
|
||||
fun onOpenAdvancedSettings()
|
||||
fun onOpenUserProfile(matrixUser: MatrixUser)
|
||||
fun onSignOutClicked()
|
||||
}
|
||||
|
||||
private fun onOpenBugReport() {
|
||||
@@ -60,6 +61,10 @@ class PreferencesRootNode @AssistedInject constructor(
|
||||
plugins<Callback>().forEach { it.onVerifyClicked() }
|
||||
}
|
||||
|
||||
private fun onSecureBackupClicked() {
|
||||
plugins<Callback>().forEach { it.onSecureBackupClicked() }
|
||||
}
|
||||
|
||||
private fun onOpenDeveloperSettings() {
|
||||
plugins<Callback>().forEach { it.onOpenDeveloperSettings() }
|
||||
}
|
||||
@@ -102,6 +107,10 @@ class PreferencesRootNode @AssistedInject constructor(
|
||||
plugins<Callback>().forEach { it.onOpenUserProfile(matrixUser) }
|
||||
}
|
||||
|
||||
private fun onSignOutClicked() {
|
||||
plugins<Callback>().forEach { it.onSignOutClicked() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
@@ -115,20 +124,14 @@ class PreferencesRootNode @AssistedInject constructor(
|
||||
onOpenAnalytics = this::onOpenAnalytics,
|
||||
onOpenAbout = this::onOpenAbout,
|
||||
onVerifyClicked = this::onVerifyClicked,
|
||||
onSecureBackupClicked = this::onSecureBackupClicked,
|
||||
onOpenDeveloperSettings = this::onOpenDeveloperSettings,
|
||||
onOpenAdvancedSettings = this::onOpenAdvancedSettings,
|
||||
onSuccessLogout = { onSuccessLogout(activity, it) },
|
||||
onManageAccountClicked = { onManageAccountClicked(activity, it, isDark) },
|
||||
onOpenNotificationSettings = this::onOpenNotificationSettings,
|
||||
onOpenLockScreenSettings = this::onOpenLockScreenSettings,
|
||||
onOpenUserProfile = this::onOpenUserProfile,
|
||||
onSignOutClicked = this::onSignOutClicked,
|
||||
)
|
||||
}
|
||||
|
||||
private fun onSuccessLogout(activity: Activity, url: String?) {
|
||||
Timber.d("Success logout with result url: $url")
|
||||
url?.let {
|
||||
activity.openUrlInChromeCustomTab(null, false, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,13 +24,13 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import io.element.android.features.logout.api.LogoutPreferencePresenter
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.meta.BuildType
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.indicator.api.IndicatorService
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
@@ -42,7 +42,6 @@ import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class PreferencesRootPresenter @Inject constructor(
|
||||
private val logoutPresenter: LogoutPreferencePresenter,
|
||||
private val matrixClient: MatrixClient,
|
||||
private val sessionVerificationService: SessionVerificationService,
|
||||
private val analyticsService: AnalyticsService,
|
||||
@@ -50,6 +49,7 @@ class PreferencesRootPresenter @Inject constructor(
|
||||
private val versionFormatter: VersionFormatter,
|
||||
private val snackbarDispatcher: SnackbarDispatcher,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val indicatorService: IndicatorService,
|
||||
) : Presenter<PreferencesRootState> {
|
||||
|
||||
@Composable
|
||||
@@ -76,6 +76,8 @@ class PreferencesRootPresenter @Inject constructor(
|
||||
// We should display the 'complete verification' option if the current session can be verified
|
||||
val showCompleteVerification by sessionVerificationService.canVerifySessionFlow.collectAsState(false)
|
||||
|
||||
val showSecureBackupIndicator by indicatorService.showSettingChatBackupIndicator()
|
||||
|
||||
val accountManagementUrl: MutableState<String?> = remember {
|
||||
mutableStateOf(null)
|
||||
}
|
||||
@@ -87,13 +89,13 @@ class PreferencesRootPresenter @Inject constructor(
|
||||
initAccountManagementUrl(accountManagementUrl, devicesManagementUrl)
|
||||
}
|
||||
|
||||
val logoutState = logoutPresenter.present()
|
||||
val showDeveloperSettings = buildType != BuildType.RELEASE
|
||||
return PreferencesRootState(
|
||||
logoutState = logoutState,
|
||||
myUser = matrixUser.value,
|
||||
version = versionFormatter.get(),
|
||||
showCompleteVerification = showCompleteVerification,
|
||||
showSecureBackup = !showCompleteVerification,
|
||||
showSecureBackupBadge = showSecureBackupIndicator,
|
||||
accountManagementUrl = accountManagementUrl.value,
|
||||
devicesManagementUrl = devicesManagementUrl.value,
|
||||
showAnalyticsSettings = hasAnalyticsProviders,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -35,7 +35,10 @@ import io.element.android.features.roomlist.impl.datasource.RoomListDataSource
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
|
||||
import io.element.android.libraries.indicator.api.IndicatorService
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
import io.element.android.libraries.matrix.api.encryption.RecoveryState
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.api.user.getCurrentUser
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
@@ -53,6 +56,8 @@ class RoomListPresenter @Inject constructor(
|
||||
private val inviteStateDataSource: InviteStateDataSource,
|
||||
private val leaveRoomPresenter: LeaveRoomPresenter,
|
||||
private val roomListDataSource: RoomListDataSource,
|
||||
private val encryptionService: EncryptionService,
|
||||
private val indicatorService: IndicatorService,
|
||||
) : Presenter<RoomListState> {
|
||||
|
||||
@Composable
|
||||
@@ -78,6 +83,16 @@ class RoomListPresenter @Inject constructor(
|
||||
val displayVerificationPrompt by remember {
|
||||
derivedStateOf { canVerifySession && !verificationPromptDismissed }
|
||||
}
|
||||
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
|
||||
var recoveryKeyPromptDismissed by rememberSaveable { mutableStateOf(false) }
|
||||
val displayRecoveryKeyPrompt by remember {
|
||||
derivedStateOf {
|
||||
recoveryState == RecoveryState.INCOMPLETE && !recoveryKeyPromptDismissed
|
||||
}
|
||||
}
|
||||
|
||||
// Avatar indicator
|
||||
val showAvatarIndicator by indicatorService.showRoomListTopBarIndicator()
|
||||
|
||||
var displaySearchResults by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
@@ -88,6 +103,7 @@ class RoomListPresenter @Inject constructor(
|
||||
is RoomListEvents.UpdateFilter -> roomListDataSource.updateFilter(event.newFilter)
|
||||
is RoomListEvents.UpdateVisibleRange -> updateVisibleRange(event.range)
|
||||
RoomListEvents.DismissRequestVerificationPrompt -> verificationPromptDismissed = true
|
||||
RoomListEvents.DismissRecoveryKeyPrompt -> recoveryKeyPromptDismissed = true
|
||||
RoomListEvents.ToggleSearchResults -> {
|
||||
if (displaySearchResults) {
|
||||
roomListDataSource.updateFilter("")
|
||||
@@ -109,10 +125,12 @@ class RoomListPresenter @Inject constructor(
|
||||
|
||||
return RoomListState(
|
||||
matrixUser = matrixUser.value,
|
||||
showAvatarIndicator = showAvatarIndicator,
|
||||
roomList = roomList,
|
||||
filter = filter,
|
||||
filteredRoomList = filteredRoomList,
|
||||
displayVerificationPrompt = displayVerificationPrompt,
|
||||
displayRecoveryKeyPrompt = displayRecoveryKeyPrompt,
|
||||
snackbarMessage = snackbarMessage,
|
||||
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
|
||||
invitesState = inviteStateDataSource.inviteState(),
|
||||
|
||||
@@ -27,10 +27,12 @@ import kotlinx.collections.immutable.ImmutableList
|
||||
@Immutable
|
||||
data class RoomListState(
|
||||
val matrixUser: MatrixUser?,
|
||||
val showAvatarIndicator: Boolean,
|
||||
val roomList: ImmutableList<RoomListRoomSummary>,
|
||||
val filter: String?,
|
||||
val filteredRoomList: ImmutableList<RoomListRoomSummary>,
|
||||
val displayVerificationPrompt: Boolean,
|
||||
val displayRecoveryKeyPrompt: Boolean,
|
||||
val hasNetworkConnection: Boolean,
|
||||
val snackbarMessage: SnackbarMessage?,
|
||||
val invitesState: InvitesState,
|
||||
|
||||
@@ -41,20 +41,25 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
|
||||
aRoomListState().copy(invitesState = InvitesState.NewInvites),
|
||||
aRoomListState().copy(displaySearchResults = true, filter = "", filteredRoomList = persistentListOf()),
|
||||
aRoomListState().copy(displaySearchResults = true),
|
||||
aRoomListState().copy(contextMenu = RoomListState.ContextMenu.Shown(
|
||||
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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
@@ -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 = {},
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="confirm_recovery_key_banner_message">"Your chat backup is currently out of sync. You need to confirm your recovery key to maintain access to your chat backup."</string>
|
||||
<string name="confirm_recovery_key_banner_title">"Confirm your recovery key"</string>
|
||||
<string name="screen_roomlist_a11y_create_message">"Create a new conversation or room"</string>
|
||||
<string name="screen_roomlist_empty_message">"Get started by messaging someone."</string>
|
||||
<string name="screen_roomlist_empty_title">"No chats yet."</string>
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
27
features/securebackup/api/build.gradle.kts
Normal file
27
features/securebackup/api/build.gradle.kts
Normal file
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
id("io.element.android-library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.features.securebackup.api"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.libraries.architecture)
|
||||
}
|
||||
@@ -14,8 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.logout.api
|
||||
package io.element.android.features.securebackup.api
|
||||
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.SimpleFeatureEntryPoint
|
||||
|
||||
interface LogoutPreferencePresenter : Presenter<LogoutPreferenceState>
|
||||
interface SecureBackupEntryPoint : SimpleFeatureEntryPoint
|
||||
56
features/securebackup/impl/build.gradle.kts
Normal file
56
features/securebackup/impl/build.gradle.kts
Normal file
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.anvil)
|
||||
id("kotlin-parcelize")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.features.securebackup.impl"
|
||||
}
|
||||
|
||||
anvil {
|
||||
generateDaggerFactories.set(true)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
anvil(projects.anvilcodegen)
|
||||
implementation(projects.anvilannotations)
|
||||
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.matrixui)
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
api(libs.statemachine)
|
||||
api(projects.features.securebackup.api)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.coroutines.test)
|
||||
testImplementation(libs.molecule.runtime)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
|
||||
ksp(libs.showkase.processor)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.securebackup.impl
|
||||
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.securebackup.api.SecureBackupEntryPoint
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultSecureBackupEntryPoint @Inject constructor() : SecureBackupEntryPoint {
|
||||
override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
|
||||
return parentNode.createNode<SecureBackupFlowNode>(buildContext)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.securebackup.impl
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.composable.Children
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||
import com.bumble.appyx.navmodel.backstack.operation.push
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.securebackup.impl.disable.SecureBackupDisableNode
|
||||
import io.element.android.features.securebackup.impl.enable.SecureBackupEnableNode
|
||||
import io.element.android.features.securebackup.impl.enter.SecureBackupEnterRecoveryKeyNode
|
||||
import io.element.android.features.securebackup.impl.root.SecureBackupRootNode
|
||||
import io.element.android.features.securebackup.impl.setup.SecureBackupSetupNode
|
||||
import io.element.android.libraries.architecture.BackstackNode
|
||||
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
class SecureBackupFlowNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
) : BackstackNode<SecureBackupFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = NavTarget.Root,
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
buildContext = buildContext,
|
||||
plugins = plugins
|
||||
) {
|
||||
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
data object Root : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object Setup : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object Change : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object Disable : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object Enable : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object EnterRecoveryKey : NavTarget
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
return when (navTarget) {
|
||||
NavTarget.Root -> {
|
||||
val callback = object : SecureBackupRootNode.Callback {
|
||||
override fun onSetupClicked() {
|
||||
backstack.push(NavTarget.Setup)
|
||||
}
|
||||
|
||||
override fun onChangeClicked() {
|
||||
backstack.push(NavTarget.Change)
|
||||
}
|
||||
|
||||
override fun onDisableClicked() {
|
||||
backstack.push(NavTarget.Disable)
|
||||
}
|
||||
|
||||
override fun onEnableClicked() {
|
||||
backstack.push(NavTarget.Enable)
|
||||
}
|
||||
|
||||
override fun onConfirmRecoveryKeyClicked() {
|
||||
backstack.push(NavTarget.EnterRecoveryKey)
|
||||
}
|
||||
}
|
||||
createNode<SecureBackupRootNode>(buildContext, listOf(callback))
|
||||
}
|
||||
NavTarget.Setup -> {
|
||||
val inputs = SecureBackupSetupNode.Inputs(
|
||||
isChangeRecoveryKeyUserStory = false,
|
||||
)
|
||||
createNode<SecureBackupSetupNode>(buildContext, listOf(inputs))
|
||||
}
|
||||
NavTarget.Change -> {
|
||||
val inputs = SecureBackupSetupNode.Inputs(
|
||||
isChangeRecoveryKeyUserStory = true,
|
||||
)
|
||||
createNode<SecureBackupSetupNode>(buildContext, listOf(inputs))
|
||||
}
|
||||
NavTarget.Disable -> {
|
||||
createNode<SecureBackupDisableNode>(buildContext)
|
||||
}
|
||||
NavTarget.Enable -> {
|
||||
createNode<SecureBackupEnableNode>(buildContext)
|
||||
}
|
||||
NavTarget.EnterRecoveryKey -> {
|
||||
createNode<SecureBackupEnterRecoveryKeyNode>(buildContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
Children(
|
||||
navModel = backstack,
|
||||
modifier = modifier,
|
||||
transitionHandler = rememberDefaultTransitionHandler()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.securebackup.impl.disable
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
class SecureBackupDisableNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val presenter: SecureBackupDisablePresenter,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
SecureBackupDisableView(
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
onDone = ::navigateUp,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.securebackup.impl.disable
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import io.element.android.features.securebackup.impl.loggerTagDisable
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
class SecureBackupDisablePresenter @Inject constructor(
|
||||
private val encryptionService: EncryptionService,
|
||||
private val buildMeta: BuildMeta,
|
||||
) : Presenter<SecureBackupDisableState> {
|
||||
|
||||
@Composable
|
||||
override fun present(): SecureBackupDisableState {
|
||||
val backupState by encryptionService.backupStateStateFlow.collectAsState()
|
||||
Timber.tag(loggerTagDisable.value).d("backupState: $backupState")
|
||||
val disableAction = remember { mutableStateOf<Async<Unit>>(Async.Uninitialized) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
fun handleEvents(event: SecureBackupDisableEvents) {
|
||||
when (event) {
|
||||
is SecureBackupDisableEvents.DisableBackup -> if (event.force) {
|
||||
showDialog = false
|
||||
coroutineScope.disableBackup(disableAction)
|
||||
} else {
|
||||
showDialog = true
|
||||
}
|
||||
SecureBackupDisableEvents.DismissDialogs -> {
|
||||
showDialog = false
|
||||
disableAction.value = Async.Uninitialized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return SecureBackupDisableState(
|
||||
backupState = backupState,
|
||||
disableAction = disableAction.value,
|
||||
showConfirmationDialog = showDialog,
|
||||
appName = buildMeta.applicationName,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.disableBackup(disableAction: MutableState<Async<Unit>>) = launch {
|
||||
suspend {
|
||||
Timber.tag(loggerTagDisable.value).d("Calling encryptionService.disableRecovery()")
|
||||
encryptionService.disableRecovery().getOrThrow()
|
||||
}.runCatchingUpdatingState(disableAction)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.securebackup.impl.disable
|
||||
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.matrix.api.encryption.BackupState
|
||||
|
||||
data class SecureBackupDisableState(
|
||||
val backupState: BackupState,
|
||||
val disableAction: Async<Unit>,
|
||||
val showConfirmationDialog: Boolean,
|
||||
val appName: String,
|
||||
val eventSink: (SecureBackupDisableEvents) -> Unit
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.securebackup.impl.disable
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.matrix.api.encryption.BackupState
|
||||
|
||||
open class SecureBackupDisableStateProvider : PreviewParameterProvider<SecureBackupDisableState> {
|
||||
override val values: Sequence<SecureBackupDisableState>
|
||||
get() = sequenceOf(
|
||||
aSecureBackupDisableState(),
|
||||
aSecureBackupDisableState(showConfirmationDialog = true),
|
||||
aSecureBackupDisableState(disableAction = Async.Loading()),
|
||||
aSecureBackupDisableState(disableAction = Async.Failure(Exception("Failed to disable"))),
|
||||
// Add other states here
|
||||
)
|
||||
}
|
||||
|
||||
fun aSecureBackupDisableState(
|
||||
backupState: BackupState = BackupState.UNKNOWN,
|
||||
disableAction: Async<Unit> = Async.Uninitialized,
|
||||
showConfirmationDialog: Boolean = false,
|
||||
) = SecureBackupDisableState(
|
||||
backupState = backupState,
|
||||
disableAction = disableAction,
|
||||
showConfirmationDialog = showConfirmationDialog,
|
||||
appName = "Element",
|
||||
eventSink = {}
|
||||
)
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.securebackup.impl.enable
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
class SecureBackupEnableNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val presenter: SecureBackupEnablePresenter,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
SecureBackupEnableView(
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
onDone = ::navigateUp,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.securebackup.impl.enable
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import io.element.android.features.securebackup.impl.loggerTagDisable
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
class SecureBackupEnablePresenter @Inject constructor(
|
||||
private val encryptionService: EncryptionService,
|
||||
) : Presenter<SecureBackupEnableState> {
|
||||
|
||||
@Composable
|
||||
override fun present(): SecureBackupEnableState {
|
||||
val enableAction = remember { mutableStateOf<Async<Unit>>(Async.Uninitialized) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
fun handleEvents(event: SecureBackupEnableEvents) {
|
||||
when (event) {
|
||||
is SecureBackupEnableEvents.EnableBackup ->
|
||||
coroutineScope.enableBackup(enableAction)
|
||||
SecureBackupEnableEvents.DismissDialog -> {
|
||||
enableAction.value = Async.Uninitialized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return SecureBackupEnableState(
|
||||
enableAction = enableAction.value,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.enableBackup(action: MutableState<Async<Unit>>) = launch {
|
||||
suspend {
|
||||
Timber.tag(loggerTagDisable.value).d("Calling encryptionService.enableBackups()")
|
||||
encryptionService.enableBackups().getOrThrow()
|
||||
}.runCatchingUpdatingState(action)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2022 New Vector Ltd
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
@@ -14,11 +14,11 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.logout.api
|
||||
package io.element.android.features.securebackup.impl.enable
|
||||
|
||||
import io.element.android.libraries.architecture.Async
|
||||
|
||||
data class LogoutPreferenceState(
|
||||
val logoutAction: Async<String?>,
|
||||
val eventSink: (LogoutPreferenceEvents) -> Unit,
|
||||
data class SecureBackupEnableState(
|
||||
val enableAction: Async<Unit>,
|
||||
val eventSink: (SecureBackupEnableEvents) -> Unit
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.securebackup.impl.enable
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.architecture.Async
|
||||
|
||||
open class SecureBackupEnableStateProvider : PreviewParameterProvider<SecureBackupEnableState> {
|
||||
override val values: Sequence<SecureBackupEnableState>
|
||||
get() = sequenceOf(
|
||||
aSecureBackupEnableState(),
|
||||
aSecureBackupEnableState(enableAction = Async.Loading()),
|
||||
aSecureBackupEnableState(enableAction = Async.Failure(Exception("Failed to enable"))),
|
||||
// Add other states here
|
||||
)
|
||||
}
|
||||
|
||||
fun aSecureBackupEnableState(
|
||||
enableAction: Async<Unit> = Async.Uninitialized,
|
||||
) = SecureBackupEnableState(
|
||||
enableAction = enableAction,
|
||||
eventSink = {}
|
||||
)
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.securebackup.impl.enter
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.securebackup.impl.R
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
class SecureBackupEnterRecoveryKeyNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val presenter: SecureBackupEnterRecoveryKeyPresenter,
|
||||
private val snackbarDispatcher: SnackbarDispatcher,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val state = presenter.present()
|
||||
SecureBackupEnterRecoveryKeyView(
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
onDone = {
|
||||
coroutineScope.postSuccessSnackbar()
|
||||
navigateUp()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.postSuccessSnackbar() = launch {
|
||||
snackbarDispatcher.post(
|
||||
SnackbarMessage(
|
||||
messageResId = R.string.screen_recovery_key_confirm_success
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.securebackup.impl.enter
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyUserStory
|
||||
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class SecureBackupEnterRecoveryKeyPresenter @Inject constructor(
|
||||
private val encryptionService: EncryptionService,
|
||||
) : Presenter<SecureBackupEnterRecoveryKeyState> {
|
||||
|
||||
@Composable
|
||||
override fun present(): SecureBackupEnterRecoveryKeyState {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var recoveryKey by rememberSaveable {
|
||||
mutableStateOf("")
|
||||
}
|
||||
val submitAction = remember {
|
||||
mutableStateOf<Async<Unit>>(Async.Uninitialized)
|
||||
}
|
||||
|
||||
fun handleEvents(event: SecureBackupEnterRecoveryKeyEvents) {
|
||||
when (event) {
|
||||
SecureBackupEnterRecoveryKeyEvents.ClearDialog -> {
|
||||
submitAction.value = Async.Uninitialized
|
||||
}
|
||||
is SecureBackupEnterRecoveryKeyEvents.OnRecoveryKeyChange -> {
|
||||
recoveryKey = event.recoveryKey.replace("\\s+".toRegex(), "")
|
||||
}
|
||||
SecureBackupEnterRecoveryKeyEvents.Submit -> {
|
||||
// No need to remove the spaces, the SDK will do it.
|
||||
coroutineScope.submitRecoveryKey(recoveryKey, submitAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return SecureBackupEnterRecoveryKeyState(
|
||||
recoveryKeyViewState = RecoveryKeyViewState(
|
||||
recoveryKeyUserStory = RecoveryKeyUserStory.Enter,
|
||||
formattedRecoveryKey = recoveryKey,
|
||||
inProgress = submitAction.value.isLoading(),
|
||||
),
|
||||
isSubmitEnabled = recoveryKey.isNotEmpty() && submitAction.value.isUninitialized(),
|
||||
submitAction = submitAction.value,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.submitRecoveryKey(
|
||||
recoveryKey: String,
|
||||
action: MutableState<Async<Unit>>
|
||||
) = launch {
|
||||
suspend {
|
||||
encryptionService.fixRecoveryIssues(recoveryKey).getOrThrow()
|
||||
}.runCatchingUpdatingState(action)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.securebackup.impl.enter
|
||||
|
||||
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState
|
||||
import io.element.android.libraries.architecture.Async
|
||||
|
||||
// Do not use default value, so no member get forgotten in the presenters.
|
||||
data class SecureBackupEnterRecoveryKeyState(
|
||||
val recoveryKeyViewState: RecoveryKeyViewState,
|
||||
val isSubmitEnabled: Boolean,
|
||||
val submitAction: Async<Unit>,
|
||||
val eventSink: (SecureBackupEnterRecoveryKeyEvents) -> Unit
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.securebackup.impl.enter
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyUserStory
|
||||
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState
|
||||
import io.element.android.features.securebackup.impl.setup.views.aFormattedRecoveryKey
|
||||
import io.element.android.libraries.architecture.Async
|
||||
|
||||
open class SecureBackupEnterRecoveryKeyStateProvider : PreviewParameterProvider<SecureBackupEnterRecoveryKeyState> {
|
||||
override val values: Sequence<SecureBackupEnterRecoveryKeyState>
|
||||
get() = sequenceOf(
|
||||
aSecureBackupEnterRecoveryKeyState(recoveryKey = ""),
|
||||
aSecureBackupEnterRecoveryKeyState(),
|
||||
aSecureBackupEnterRecoveryKeyState(submitAction = Async.Loading()),
|
||||
aSecureBackupEnterRecoveryKeyState(submitAction = Async.Failure(Exception("A Failure"))),
|
||||
)
|
||||
}
|
||||
|
||||
fun aSecureBackupEnterRecoveryKeyState(
|
||||
recoveryKey: String = aFormattedRecoveryKey(),
|
||||
isSubmitEnabled: Boolean = recoveryKey.isNotEmpty(),
|
||||
submitAction: Async<Unit> = Async.Uninitialized,
|
||||
) = SecureBackupEnterRecoveryKeyState(
|
||||
recoveryKeyViewState = RecoveryKeyViewState(
|
||||
recoveryKeyUserStory = RecoveryKeyUserStory.Enter,
|
||||
formattedRecoveryKey = recoveryKey,
|
||||
inProgress = submitAction.isLoading(),
|
||||
),
|
||||
isSubmitEnabled = isSubmitEnabled,
|
||||
submitAction = submitAction,
|
||||
eventSink = {}
|
||||
)
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.securebackup.impl.root
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.platform.UriHandler
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.bumble.appyx.core.plugin.plugins
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.securebackup.impl.SecureBackupConfig
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
class SecureBackupRootNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val presenter: SecureBackupRootPresenter,
|
||||
) : Node(
|
||||
buildContext = buildContext,
|
||||
plugins = plugins
|
||||
) {
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun onSetupClicked()
|
||||
fun onChangeClicked()
|
||||
fun onDisableClicked()
|
||||
fun onEnableClicked()
|
||||
fun onConfirmRecoveryKeyClicked()
|
||||
}
|
||||
|
||||
private fun onSetupClicked() {
|
||||
plugins<Callback>().forEach { it.onSetupClicked() }
|
||||
}
|
||||
|
||||
private fun onChangeClicked() {
|
||||
plugins<Callback>().forEach { it.onChangeClicked() }
|
||||
}
|
||||
|
||||
private fun onDisableClicked() {
|
||||
plugins<Callback>().forEach { it.onDisableClicked() }
|
||||
}
|
||||
|
||||
private fun onEnableClicked() {
|
||||
plugins<Callback>().forEach { it.onEnableClicked() }
|
||||
}
|
||||
|
||||
private fun onConfirmRecoveryKeyClicked() {
|
||||
plugins<Callback>().forEach { it.onConfirmRecoveryKeyClicked() }
|
||||
}
|
||||
|
||||
private fun onLearnMoreClicked(uriHandler: UriHandler) {
|
||||
uriHandler.openUri(SecureBackupConfig.LearnMoreUrl)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
val uriHandler = LocalUriHandler.current
|
||||
SecureBackupRootView(
|
||||
state = state,
|
||||
onBackPressed = ::navigateUp,
|
||||
onSetupClicked = ::onSetupClicked,
|
||||
onChangeClicked = ::onChangeClicked,
|
||||
onEnableClicked = ::onEnableClicked,
|
||||
onDisableClicked = ::onDisableClicked,
|
||||
onConfirmRecoveryKeyClicked = ::onConfirmRecoveryKeyClicked,
|
||||
onLearnMoreClicked = { onLearnMoreClicked(uriHandler) },
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.securebackup.impl.root
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import io.element.android.features.securebackup.impl.loggerTagRoot
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
|
||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
class SecureBackupRootPresenter @Inject constructor(
|
||||
private val encryptionService: EncryptionService,
|
||||
private val buildMeta: BuildMeta,
|
||||
private val snackbarDispatcher: SnackbarDispatcher,
|
||||
) : Presenter<SecureBackupRootState> {
|
||||
|
||||
@Composable
|
||||
override fun present(): SecureBackupRootState {
|
||||
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
|
||||
|
||||
val backupState by encryptionService.backupStateStateFlow.collectAsState()
|
||||
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
|
||||
|
||||
Timber.tag(loggerTagRoot.value).d("backupState: $backupState")
|
||||
Timber.tag(loggerTagRoot.value).d("recoveryState: $recoveryState")
|
||||
|
||||
return SecureBackupRootState(
|
||||
backupState = backupState,
|
||||
recoveryState = recoveryState,
|
||||
appName = buildMeta.applicationName,
|
||||
snackbarMessage = snackbarMessage,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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?,
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.securebackup.impl.root
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
import io.element.android.libraries.matrix.api.encryption.BackupState
|
||||
import io.element.android.libraries.matrix.api.encryption.RecoveryState
|
||||
|
||||
open class SecureBackupRootStateProvider : PreviewParameterProvider<SecureBackupRootState> {
|
||||
override val values: Sequence<SecureBackupRootState>
|
||||
get() = sequenceOf(
|
||||
aSecureBackupRootState(backupState = BackupState.UNKNOWN),
|
||||
aSecureBackupRootState(backupState = BackupState.ENABLED),
|
||||
aSecureBackupRootState(backupState = BackupState.DISABLED),
|
||||
aSecureBackupRootState(recoveryState = RecoveryState.UNKNOWN),
|
||||
aSecureBackupRootState(recoveryState = RecoveryState.ENABLED),
|
||||
aSecureBackupRootState(recoveryState = RecoveryState.DISABLED),
|
||||
aSecureBackupRootState(recoveryState = RecoveryState.INCOMPLETE),
|
||||
// Add other states here
|
||||
)
|
||||
}
|
||||
|
||||
fun aSecureBackupRootState(
|
||||
backupState: BackupState = BackupState.UNKNOWN,
|
||||
recoveryState: RecoveryState = RecoveryState.UNKNOWN,
|
||||
snackbarMessage: SnackbarMessage? = null,
|
||||
) = SecureBackupRootState(
|
||||
backupState = backupState,
|
||||
recoveryState = recoveryState,
|
||||
appName = "Element",
|
||||
snackbarMessage = snackbarMessage,
|
||||
)
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.securebackup.impl.setup
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.securebackup.impl.R
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
class SecureBackupSetupNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
presenterFactory: SecureBackupSetupPresenter.Factory,
|
||||
private val snackbarDispatcher: SnackbarDispatcher,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
|
||||
data class Inputs(
|
||||
val isChangeRecoveryKeyUserStory: Boolean,
|
||||
) : NodeInputs
|
||||
|
||||
private val inputs = inputs<Inputs>()
|
||||
|
||||
private val presenter = presenterFactory.create(inputs.isChangeRecoveryKeyUserStory)
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val state = presenter.present()
|
||||
SecureBackupSetupView(
|
||||
state = state,
|
||||
onDone = {
|
||||
coroutineScope.postSuccessSnackbar()
|
||||
navigateUp()
|
||||
},
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.postSuccessSnackbar() = launch {
|
||||
snackbarDispatcher.post(
|
||||
SnackbarMessage(
|
||||
messageResId = if (inputs.isChangeRecoveryKeyUserStory)
|
||||
R.string.screen_recovery_key_change_success
|
||||
else
|
||||
R.string.screen_recovery_key_setup_success
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
||||
package io.element.android.features.securebackup.impl.setup
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.freeletics.flowredux.compose.StateAndDispatch
|
||||
import com.freeletics.flowredux.compose.rememberStateAndDispatch
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.features.securebackup.impl.loggerTagSetup
|
||||
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyUserStory
|
||||
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress
|
||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
class SecureBackupSetupPresenter @AssistedInject constructor(
|
||||
@Assisted private val isChangeRecoveryKeyUserStory: Boolean,
|
||||
private val stateMachine: SecureBackupSetupStateMachine,
|
||||
private val encryptionService: EncryptionService,
|
||||
) : Presenter<SecureBackupSetupState> {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(isChangeRecoveryKeyUserStory: Boolean): SecureBackupSetupPresenter
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun present(): SecureBackupSetupState {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val stateAndDispatch = stateMachine.rememberStateAndDispatch()
|
||||
val setupState by remember {
|
||||
derivedStateOf { stateAndDispatch.state.value.toSetupState() }
|
||||
}
|
||||
var showSaveConfirmationDialog by remember { mutableStateOf(false) }
|
||||
|
||||
fun handleEvents(event: SecureBackupSetupEvents) {
|
||||
when (event) {
|
||||
SecureBackupSetupEvents.CreateRecoveryKey -> {
|
||||
coroutineScope.createOrChangeRecoveryKey(stateAndDispatch)
|
||||
}
|
||||
SecureBackupSetupEvents.RecoveryKeyHasBeenSaved ->
|
||||
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserSavedKey)
|
||||
SecureBackupSetupEvents.DismissDialog -> {
|
||||
showSaveConfirmationDialog = false
|
||||
}
|
||||
SecureBackupSetupEvents.Done -> {
|
||||
showSaveConfirmationDialog = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val recoveryKeyViewState = RecoveryKeyViewState(
|
||||
recoveryKeyUserStory = if (isChangeRecoveryKeyUserStory) RecoveryKeyUserStory.Change else RecoveryKeyUserStory.Setup,
|
||||
formattedRecoveryKey = setupState.recoveryKey(),
|
||||
inProgress = setupState is SetupState.Creating,
|
||||
)
|
||||
|
||||
return SecureBackupSetupState(
|
||||
isChangeRecoveryKeyUserStory = isChangeRecoveryKeyUserStory,
|
||||
recoveryKeyViewState = recoveryKeyViewState,
|
||||
setupState = setupState,
|
||||
showSaveConfirmationDialog = showSaveConfirmationDialog,
|
||||
eventSink = ::handleEvents
|
||||
)
|
||||
}
|
||||
|
||||
private fun SecureBackupSetupStateMachine.State?.toSetupState(): SetupState {
|
||||
return when (this) {
|
||||
null,
|
||||
SecureBackupSetupStateMachine.State.Initial -> SetupState.Init
|
||||
SecureBackupSetupStateMachine.State.CreatingKey -> SetupState.Creating
|
||||
is SecureBackupSetupStateMachine.State.KeyCreated -> SetupState.Created(formattedRecoveryKey = key)
|
||||
is SecureBackupSetupStateMachine.State.KeyCreatedAndSaved -> SetupState.CreatedAndSaved(formattedRecoveryKey = key)
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.createOrChangeRecoveryKey(
|
||||
stateAndDispatch: StateAndDispatch<SecureBackupSetupStateMachine.State, SecureBackupSetupStateMachine.Event>
|
||||
) = launch {
|
||||
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.UserCreatesKey)
|
||||
if (isChangeRecoveryKeyUserStory) {
|
||||
Timber.tag(loggerTagSetup.value).d("Calling encryptionService.resetRecoveryKey()")
|
||||
encryptionService.resetRecoveryKey().fold(
|
||||
onSuccess = {
|
||||
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkHasCreatedKey(it))
|
||||
},
|
||||
onFailure = {
|
||||
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkError(it))
|
||||
}
|
||||
)
|
||||
} else {
|
||||
observeEncryptionService(stateAndDispatch)
|
||||
Timber.tag(loggerTagSetup.value).d("Calling encryptionService.enableRecovery()")
|
||||
encryptionService.enableRecovery(waitForBackupsToUpload = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.observeEncryptionService(
|
||||
stateAndDispatch: StateAndDispatch<SecureBackupSetupStateMachine.State, SecureBackupSetupStateMachine.Event>
|
||||
) = launch {
|
||||
encryptionService.enableRecoveryProgressStateFlow.collect { enableRecoveryProgress ->
|
||||
Timber.tag(loggerTagSetup.value).d("New enableRecoveryProgress: ${enableRecoveryProgress.javaClass.simpleName}")
|
||||
when (enableRecoveryProgress) {
|
||||
EnableRecoveryProgress.Unknown,
|
||||
is EnableRecoveryProgress.BackingUp,
|
||||
EnableRecoveryProgress.CreatingBackup,
|
||||
EnableRecoveryProgress.CreatingRecoveryKey ->
|
||||
Unit
|
||||
is EnableRecoveryProgress.Done ->
|
||||
stateAndDispatch.dispatchAction(SecureBackupSetupStateMachine.Event.SdkHasCreatedKey(enableRecoveryProgress.recoveryKey))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
@file:Suppress("WildcardImport")
|
||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
||||
package io.element.android.features.securebackup.impl.setup
|
||||
|
||||
import com.freeletics.flowredux.dsl.FlowReduxStateMachine
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import javax.inject.Inject
|
||||
import com.freeletics.flowredux.dsl.State as MachineState
|
||||
|
||||
class SecureBackupSetupStateMachine @Inject constructor(
|
||||
) : FlowReduxStateMachine<SecureBackupSetupStateMachine.State, SecureBackupSetupStateMachine.Event>(
|
||||
initialState = State.Initial
|
||||
) {
|
||||
|
||||
init {
|
||||
spec {
|
||||
inState<State.Initial> {
|
||||
on { _: Event.UserCreatesKey, state: MachineState<State.Initial> ->
|
||||
state.override { State.CreatingKey }
|
||||
}
|
||||
}
|
||||
inState<State.CreatingKey> {
|
||||
on { _: Event.SdkError, state: MachineState<State.CreatingKey> ->
|
||||
state.override { State.Initial }
|
||||
}
|
||||
on { event: Event.SdkHasCreatedKey, state: MachineState<State.CreatingKey> ->
|
||||
state.override { State.KeyCreated(event.key) }
|
||||
}
|
||||
}
|
||||
inState<State.KeyCreated> {
|
||||
on { _: Event.UserSavedKey, state: MachineState<State.KeyCreated> ->
|
||||
state.override { State.KeyCreatedAndSaved(state.snapshot.key) }
|
||||
}
|
||||
}
|
||||
inState<State.KeyCreatedAndSaved> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface State {
|
||||
data object Initial : State
|
||||
data object CreatingKey : State
|
||||
data class KeyCreated(val key: String) : State
|
||||
data class KeyCreatedAndSaved(val key: String) : State
|
||||
}
|
||||
|
||||
sealed interface Event {
|
||||
data object UserCreatesKey : Event
|
||||
data class SdkHasCreatedKey(val key: String) : Event
|
||||
data class SdkError(val throwable: Throwable) : Event
|
||||
data object UserSavedKey : Event
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.securebackup.impl.setup
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyUserStory
|
||||
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState
|
||||
import io.element.android.features.securebackup.impl.setup.views.aFormattedRecoveryKey
|
||||
|
||||
open class SecureBackupSetupStateProvider : PreviewParameterProvider<SecureBackupSetupState> {
|
||||
override val values: Sequence<SecureBackupSetupState>
|
||||
get() = sequenceOf(
|
||||
aSecureBackupSetupState(setupState = SetupState.Init),
|
||||
aSecureBackupSetupState(setupState = SetupState.Creating),
|
||||
aSecureBackupSetupState(setupState = SetupState.Created(aFormattedRecoveryKey())),
|
||||
aSecureBackupSetupState(setupState = SetupState.CreatedAndSaved(aFormattedRecoveryKey())),
|
||||
aSecureBackupSetupState(
|
||||
setupState = SetupState.CreatedAndSaved(aFormattedRecoveryKey()),
|
||||
showSaveConfirmationDialog = true,
|
||||
),
|
||||
// Add other states here
|
||||
)
|
||||
}
|
||||
|
||||
fun aSecureBackupSetupState(
|
||||
setupState: SetupState = SetupState.Init,
|
||||
showSaveConfirmationDialog: Boolean = false,
|
||||
) = SecureBackupSetupState(
|
||||
isChangeRecoveryKeyUserStory = false,
|
||||
setupState = setupState,
|
||||
showSaveConfirmationDialog = showSaveConfirmationDialog,
|
||||
recoveryKeyViewState = setupState.toRecoveryKeyViewState(),
|
||||
eventSink = {}
|
||||
)
|
||||
|
||||
private fun SetupState.toRecoveryKeyViewState(): RecoveryKeyViewState {
|
||||
return RecoveryKeyViewState(
|
||||
recoveryKeyUserStory = RecoveryKeyUserStory.Setup,
|
||||
formattedRecoveryKey = recoveryKey(),
|
||||
inProgress = this is SetupState.Creating,
|
||||
)
|
||||
}
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.securebackup.impl.setup.views
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
|
||||
open class RecoveryKeyViewStateProvider : PreviewParameterProvider<RecoveryKeyViewState> {
|
||||
override val values: Sequence<RecoveryKeyViewState>
|
||||
get() = sequenceOf(RecoveryKeyUserStory.Setup, RecoveryKeyUserStory.Change, RecoveryKeyUserStory.Enter)
|
||||
.flatMap {
|
||||
sequenceOf(
|
||||
aRecoveryKeyViewState(recoveryKeyUserStory = it),
|
||||
aRecoveryKeyViewState(recoveryKeyUserStory = it, inProgress = true),
|
||||
aRecoveryKeyViewState(recoveryKeyUserStory = it, formattedRecoveryKey = aFormattedRecoveryKey()),
|
||||
aRecoveryKeyViewState(recoveryKeyUserStory = it, formattedRecoveryKey = aFormattedRecoveryKey(), inProgress = true),
|
||||
// Add other states here
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun aRecoveryKeyViewState(
|
||||
recoveryKeyUserStory: RecoveryKeyUserStory = RecoveryKeyUserStory.Setup,
|
||||
formattedRecoveryKey: String? = null,
|
||||
inProgress: Boolean = false,
|
||||
) = RecoveryKeyViewState(
|
||||
recoveryKeyUserStory = recoveryKeyUserStory,
|
||||
formattedRecoveryKey = formattedRecoveryKey,
|
||||
inProgress = inProgress,
|
||||
)
|
||||
|
||||
internal fun aFormattedRecoveryKey(): String {
|
||||
return "Estm dfyU adhD h8y6 Estm dfyU adhD h8y6 Estm dfyU adhD h8y6"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_chat_backup_key_backup_action_disable">"Vypnúť zálohovanie"</string>
|
||||
<string name="screen_chat_backup_key_backup_action_enable">"Zapnúť zálohovanie"</string>
|
||||
<string name="screen_chat_backup_key_backup_description">"Zálohovanie zaisťuje, že nestratíte históriu správ. %1$s."</string>
|
||||
<string name="screen_chat_backup_key_backup_title">"Zálohovanie"</string>
|
||||
<string name="screen_chat_backup_recovery_action_change">"Zmeniť kľúč na obnovenie"</string>
|
||||
<string name="screen_chat_backup_recovery_action_confirm">"Potvrdiť kľúč na obnovenie"</string>
|
||||
<string name="screen_chat_backup_recovery_action_confirm_description">"Vaša záloha konverzácie nie je momentálne synchronizovaná."</string>
|
||||
<string name="screen_chat_backup_recovery_action_setup">"Nastaviť obnovovanie"</string>
|
||||
<string name="screen_chat_backup_recovery_action_setup_description">"Získajte prístup k vašim šifrovaným správam aj keď stratíte všetky svoje zariadenia alebo sa odhlásite zo všetkých %1$s zariadení."</string>
|
||||
<string name="screen_key_backup_disable_confirmation_action_turn_off">"Vypnúť"</string>
|
||||
<string name="screen_key_backup_disable_confirmation_description">"Stratíte prístup k svojim zašifrovaným správam, ak sa odhlásite zo všetkých zariadení"</string>
|
||||
<string name="screen_key_backup_disable_confirmation_title">"Ste si istí, že chcete vypnúť zálohovanie?"</string>
|
||||
<string name="screen_key_backup_disable_description">"Vypnutím zálohovania sa odstráni aktuálna záloha šifrovacích kľúčov a vypnú sa ďalšie bezpečnostné funkcie. V tomto prípade:"</string>
|
||||
<string name="screen_key_backup_disable_description_point_1">"Na nových zariadeniach nebudete mať zašifrovanú históriu správ"</string>
|
||||
<string name="screen_key_backup_disable_description_point_2">"Stratíte prístup k svojim zašifrovaným správam, ak sa odhlásite zo všetkých %1$s zariadení"</string>
|
||||
<string name="screen_key_backup_disable_title">"Ste si istí, že chcete vypnúť zálohovanie?"</string>
|
||||
<string name="screen_recovery_key_change_description">"Získajte nový kľúč na obnovenie, ak ste stratili svoj existujúci. Po zmene kľúča na obnovenie už starý kľúč nebude fungovať."</string>
|
||||
<string name="screen_recovery_key_change_generate_key">"Vygenerovať nový kľúč na obnovenie"</string>
|
||||
<string name="screen_recovery_key_change_generate_key_description">"Uistite sa, že kľúč na obnovenie môžete uložiť niekde v bezpečí"</string>
|
||||
<string name="screen_recovery_key_change_success">"Kľúč na obnovenie bol zmenený"</string>
|
||||
<string name="screen_recovery_key_change_title">"Zmeniť kľúč na obnovenie?"</string>
|
||||
<string name="screen_recovery_key_confirm_description">"Zadajte kľúč na obnovenie a potvrďte prístup k zálohe konverzácie."</string>
|
||||
<string name="screen_recovery_key_confirm_key_description">"Zadajte 48-znakový kód."</string>
|
||||
<string name="screen_recovery_key_confirm_key_placeholder">"Zadať…"</string>
|
||||
<string name="screen_recovery_key_confirm_success">"Kľúč na obnovu potvrdený"</string>
|
||||
<string name="screen_recovery_key_confirm_title">"Potvrďte kľúč na obnovenie"</string>
|
||||
<string name="screen_recovery_key_copied_to_clipboard">"Skopírovaný kľúč na obnovenie"</string>
|
||||
<string name="screen_recovery_key_generating_key">"Generovanie…"</string>
|
||||
<string name="screen_recovery_key_save_action">"Uložiť kľúč na obnovenie"</string>
|
||||
<string name="screen_recovery_key_save_description">"Zapíšte si kľúč na obnovenie na bezpečné miesto alebo ho uložte do správcu hesiel."</string>
|
||||
<string name="screen_recovery_key_save_key_description">"Ťuknutím skopírujte kľúč na obnovenie"</string>
|
||||
<string name="screen_recovery_key_save_title">"Uložte svoj kľúč na obnovenie"</string>
|
||||
<string name="screen_recovery_key_setup_confirmation_description">"Po tomto kroku nebudete mať prístup k novému kľúču na obnovenie."</string>
|
||||
<string name="screen_recovery_key_setup_confirmation_title">"Uložili ste kľúč na obnovenie?"</string>
|
||||
<string name="screen_recovery_key_setup_description">"Vaša záloha konverzácie je chránená kľúčom na obnovenie. Ak potrebujete nový kľúč na obnovenie, po nastavení si ho môžete znova vytvoriť výberom položky „Zmeniť kľúč na obnovenie“."</string>
|
||||
<string name="screen_recovery_key_setup_generate_key">"Vygenerujte si váš kľúč na obnovenie"</string>
|
||||
<string name="screen_recovery_key_setup_generate_key_description">"Uistite sa, že kľúč na obnovenie môžete uložiť niekde v bezpečí"</string>
|
||||
<string name="screen_recovery_key_setup_success">"Úspešné nastavenie obnovy"</string>
|
||||
<string name="screen_recovery_key_setup_title">"Nastaviť obnovenie"</string>
|
||||
</resources>
|
||||
42
features/securebackup/impl/src/main/res/values/localazy.xml
Normal file
42
features/securebackup/impl/src/main/res/values/localazy.xml
Normal file
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_chat_backup_key_backup_action_disable">"Turn off backup"</string>
|
||||
<string name="screen_chat_backup_key_backup_action_enable">"Turn on backup"</string>
|
||||
<string name="screen_chat_backup_key_backup_description">"Backup ensures that you don\'t lose your message history. %1$s."</string>
|
||||
<string name="screen_chat_backup_key_backup_title">"Backup"</string>
|
||||
<string name="screen_chat_backup_recovery_action_change">"Change recovery key"</string>
|
||||
<string name="screen_chat_backup_recovery_action_confirm">"Confirm recovery key"</string>
|
||||
<string name="screen_chat_backup_recovery_action_confirm_description">"Your chat backup is currently out of sync."</string>
|
||||
<string name="screen_chat_backup_recovery_action_setup">"Set up recovery"</string>
|
||||
<string name="screen_chat_backup_recovery_action_setup_description">"Get access to your encrypted messages if you lose all your devices or are signed out of %1$s everywhere."</string>
|
||||
<string name="screen_key_backup_disable_confirmation_action_turn_off">"Turn off"</string>
|
||||
<string name="screen_key_backup_disable_confirmation_description">"You will lose your encrypted messages if you are signed out of all devices."</string>
|
||||
<string name="screen_key_backup_disable_confirmation_title">"Are you sure you want to turn off backup?"</string>
|
||||
<string name="screen_key_backup_disable_description">"Turning off backup will remove your current encryption key backup and turn off other security features. In this case, you will:"</string>
|
||||
<string name="screen_key_backup_disable_description_point_1">"Not have encrypted message history on new devices"</string>
|
||||
<string name="screen_key_backup_disable_description_point_2">"Lose access to your encrypted messages if you are signed out of %1$s everywhere"</string>
|
||||
<string name="screen_key_backup_disable_title">"Are you sure you want to turn off backup?"</string>
|
||||
<string name="screen_recovery_key_change_description">"Get a new recovery key if you\'ve lost your existing one. After changing your recovery key, your old one will no longer work."</string>
|
||||
<string name="screen_recovery_key_change_generate_key">"Generate a new recovery key"</string>
|
||||
<string name="screen_recovery_key_change_generate_key_description">"Make sure you can store your recovery key somewhere safe"</string>
|
||||
<string name="screen_recovery_key_change_success">"Recovery key changed"</string>
|
||||
<string name="screen_recovery_key_change_title">"Change recovery key?"</string>
|
||||
<string name="screen_recovery_key_confirm_description">"Enter your recovery key to confirm access to your chat backup."</string>
|
||||
<string name="screen_recovery_key_confirm_key_description">"Enter the 48 character code."</string>
|
||||
<string name="screen_recovery_key_confirm_key_placeholder">"Enter…"</string>
|
||||
<string name="screen_recovery_key_confirm_success">"Recovery key confirmed"</string>
|
||||
<string name="screen_recovery_key_confirm_title">"Confirm your recovery key"</string>
|
||||
<string name="screen_recovery_key_copied_to_clipboard">"Copied recovery key"</string>
|
||||
<string name="screen_recovery_key_generating_key">"Generating…"</string>
|
||||
<string name="screen_recovery_key_save_action">"Save recovery key"</string>
|
||||
<string name="screen_recovery_key_save_description">"Write down your recovery key somewhere safe or save it in a password manager."</string>
|
||||
<string name="screen_recovery_key_save_key_description">"Tap to copy recovery key"</string>
|
||||
<string name="screen_recovery_key_save_title">"Save your recovery key"</string>
|
||||
<string name="screen_recovery_key_setup_confirmation_description">"You will not be able to access your new recovery key after this step."</string>
|
||||
<string name="screen_recovery_key_setup_confirmation_title">"Have you saved your recovery key?"</string>
|
||||
<string name="screen_recovery_key_setup_description">"Your chat backup is protected by a recovery key. If you need a new recovery key after setup you can recreate by selecting ‘Change recovery key’."</string>
|
||||
<string name="screen_recovery_key_setup_generate_key">"Generate your recovery key"</string>
|
||||
<string name="screen_recovery_key_setup_generate_key_description">"Make sure you can store your recovery key somewhere safe"</string>
|
||||
<string name="screen_recovery_key_setup_success">"Recovery setup successful"</string>
|
||||
<string name="screen_recovery_key_setup_title">"Set up recovery"</string>
|
||||
</resources>
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* Copyright (c) 2023 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.securebackup.impl.enter
|
||||
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyUserStory
|
||||
import io.element.android.features.securebackup.impl.setup.views.RecoveryKeyViewState
|
||||
import io.element.android.libraries.architecture.Async
|
||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class SecureBackupEnterRecoveryKeyPresenterTest {
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
val presenter = createPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.isSubmitEnabled).isFalse()
|
||||
assertThat(initialState.submitAction).isEqualTo(Async.Uninitialized)
|
||||
assertThat(initialState.recoveryKeyViewState).isEqualTo(
|
||||
RecoveryKeyViewState(
|
||||
recoveryKeyUserStory = RecoveryKeyUserStory.Enter,
|
||||
formattedRecoveryKey = "",
|
||||
inProgress = false,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - enter recovery key`() = runTest {
|
||||
val encryptionService = FakeEncryptionService()
|
||||
val presenter = createPresenter(encryptionService)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(SecureBackupEnterRecoveryKeyEvents.OnRecoveryKeyChange("1234"))
|
||||
val withRecoveryKeyState = awaitItem()
|
||||
assertThat(withRecoveryKeyState.isSubmitEnabled).isTrue()
|
||||
assertThat(withRecoveryKeyState.recoveryKeyViewState).isEqualTo(
|
||||
RecoveryKeyViewState(
|
||||
recoveryKeyUserStory = RecoveryKeyUserStory.Enter,
|
||||
formattedRecoveryKey = "1234",
|
||||
inProgress = false,
|
||||
)
|
||||
)
|
||||
encryptionService.givenFixRecoveryIssuesFailure(AN_EXCEPTION)
|
||||
withRecoveryKeyState.eventSink(SecureBackupEnterRecoveryKeyEvents.Submit)
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.submitAction).isEqualTo(Async.Loading<Unit>())
|
||||
assertThat(loadingState.isSubmitEnabled).isFalse()
|
||||
val errorState = awaitItem()
|
||||
assertThat(errorState.submitAction).isEqualTo(Async.Failure<Unit>(AN_EXCEPTION))
|
||||
assertThat(errorState.isSubmitEnabled).isFalse()
|
||||
errorState.eventSink(SecureBackupEnterRecoveryKeyEvents.ClearDialog)
|
||||
val clearedState = awaitItem()
|
||||
assertThat(clearedState.submitAction).isEqualTo(Async.Uninitialized)
|
||||
assertThat(clearedState.isSubmitEnabled).isTrue()
|
||||
encryptionService.givenFixRecoveryIssuesFailure(null)
|
||||
clearedState.eventSink(SecureBackupEnterRecoveryKeyEvents.Submit)
|
||||
val loadingState2 = awaitItem()
|
||||
assertThat(loadingState2.submitAction).isEqualTo(Async.Loading<Unit>())
|
||||
assertThat(loadingState2.isSubmitEnabled).isFalse()
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.submitAction).isEqualTo(Async.Success(Unit))
|
||||
assertThat(finalState.isSubmitEnabled).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createPresenter(
|
||||
encryptionService: EncryptionService = FakeEncryptionService(),
|
||||
) = SecureBackupEnterRecoveryKeyPresenter(
|
||||
encryptionService = encryptionService,
|
||||
)
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
9
libraries/designsystem/src/main/res/drawable/ic_key.xml
Normal file
9
libraries/designsystem/src/main/res/drawable/ic_key.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="42dp"
|
||||
android:height="42dp"
|
||||
android:viewportWidth="42"
|
||||
android:viewportHeight="42">
|
||||
<path
|
||||
android:pathData="M12.25,26.644C10.675,26.644 9.341,26.097 8.247,25.003C7.153,23.909 6.606,22.575 6.606,21C6.606,19.425 7.153,18.091 8.247,16.997C9.341,15.903 10.675,15.356 12.25,15.356C13.825,15.356 15.159,15.903 16.253,16.997C17.347,18.091 17.894,19.425 17.894,21C17.894,22.575 17.347,23.909 16.253,25.003C15.159,26.097 13.825,26.644 12.25,26.644ZM12.25,31.5C14.613,31.5 16.691,30.829 18.484,29.487C20.278,28.146 21.452,26.381 22.006,24.194H23.406L25.594,26.381C25.74,26.527 25.885,26.629 26.031,26.688C26.177,26.746 26.337,26.775 26.513,26.775C26.688,26.775 26.848,26.746 26.994,26.688C27.14,26.629 27.285,26.527 27.431,26.381L30.188,23.625L32.944,26.381C33.09,26.527 33.235,26.629 33.381,26.688C33.527,26.746 33.688,26.775 33.862,26.775C34.037,26.775 34.198,26.746 34.344,26.688C34.49,26.629 34.635,26.527 34.781,26.381L39.331,21.831C39.477,21.685 39.579,21.54 39.638,21.394C39.696,21.248 39.725,21.087 39.725,20.913C39.725,20.737 39.696,20.577 39.638,20.431C39.579,20.285 39.477,20.14 39.331,19.994L37.45,18.112C37.304,17.967 37.158,17.865 37.013,17.806C36.867,17.748 36.706,17.719 36.531,17.719H22.006C21.335,15.619 20.162,13.891 18.484,12.534C16.807,11.178 14.729,10.5 12.25,10.5C9.333,10.5 6.854,11.521 4.813,13.563C2.771,15.604 1.75,18.083 1.75,21C1.75,23.917 2.771,26.396 4.813,28.438C6.854,30.479 9.333,31.5 12.25,31.5Z"
|
||||
android:fillColor="@android:color/white"/>
|
||||
</vector>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user