From ea355d29ed276357fce612bffb5b02ee5fc43ed1 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 17 Sep 2024 13:19:46 +0200 Subject: [PATCH 1/6] Account deactivation. --- features/deactivation/api/build.gradle.kts | 17 + .../api/AccountDeactivationEntryPoint.kt | 12 + features/deactivation/impl/build.gradle.kts | 49 +++ .../logout/impl/AccountDeactivationEvents.kt | 15 + .../logout/impl/AccountDeactivationNode.kt | 35 ++ .../impl/AccountDeactivationPresenter.kt | 84 +++++ .../logout/impl/AccountDeactivationState.kt | 32 ++ .../impl/AccountDeactivationStateProvider.kt | 52 +++ .../logout/impl/AccountDeactivationView.kt | 324 ++++++++++++++++++ .../DefaultAccountDeactivationEntryPoint.kt | 23 ++ .../ui/AccountDeactivationActionDialog.kt | 43 +++ .../AccountDeactivationConfirmationDialog.kt | 29 ++ .../impl/src/main/res/values/localazy.xml | 12 + .../impl/AccountDeactivationPresenterTest.kt | 157 +++++++++ .../impl/AccountDeactivationViewTest.kt | 52 +++ features/preferences/impl/build.gradle.kts | 1 + .../preferences/impl/PreferencesFlowNode.kt | 12 + .../impl/root/PreferencesRootNode.kt | 6 + .../impl/root/PreferencesRootPresenter.kt | 8 + .../impl/root/PreferencesRootState.kt | 1 + .../impl/root/PreferencesRootStateProvider.kt | 1 + .../impl/root/PreferencesRootView.kt | 12 + .../impl/root/PreferencesRootPresenterTest.kt | 34 +- .../atomic/organisms/InfoListOrganism.kt | 3 +- .../libraries/matrix/api/MatrixClient.kt | 3 + .../libraries/matrix/impl/RustMatrixClient.kt | 43 +++ .../libraries/matrix/test/FakeMatrixClient.kt | 8 + .../src/main/res/values/localazy.xml | 6 + tools/localazy/config.json | 6 + 29 files changed, 1071 insertions(+), 9 deletions(-) create mode 100644 features/deactivation/api/build.gradle.kts create mode 100644 features/deactivation/api/src/main/kotlin/io/element/android/features/deactivation/api/AccountDeactivationEntryPoint.kt create mode 100644 features/deactivation/impl/build.gradle.kts create mode 100644 features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationEvents.kt create mode 100644 features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationNode.kt create mode 100644 features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationPresenter.kt create mode 100644 features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationState.kt create mode 100644 features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationStateProvider.kt create mode 100644 features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt create mode 100644 features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultAccountDeactivationEntryPoint.kt create mode 100644 features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/AccountDeactivationActionDialog.kt create mode 100644 features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/AccountDeactivationConfirmationDialog.kt create mode 100644 features/deactivation/impl/src/main/res/values/localazy.xml create mode 100644 features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/AccountDeactivationPresenterTest.kt create mode 100644 features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/AccountDeactivationViewTest.kt diff --git a/features/deactivation/api/build.gradle.kts b/features/deactivation/api/build.gradle.kts new file mode 100644 index 0000000000..25d59790e5 --- /dev/null +++ b/features/deactivation/api/build.gradle.kts @@ -0,0 +1,17 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ +plugins { + id("io.element.android-compose-library") +} + +android { + namespace = "io.element.android.features.deactivation.api" +} + +dependencies { + implementation(projects.libraries.architecture) +} diff --git a/features/deactivation/api/src/main/kotlin/io/element/android/features/deactivation/api/AccountDeactivationEntryPoint.kt b/features/deactivation/api/src/main/kotlin/io/element/android/features/deactivation/api/AccountDeactivationEntryPoint.kt new file mode 100644 index 0000000000..5865f1a2b8 --- /dev/null +++ b/features/deactivation/api/src/main/kotlin/io/element/android/features/deactivation/api/AccountDeactivationEntryPoint.kt @@ -0,0 +1,12 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.deactivation.api + +import io.element.android.libraries.architecture.SimpleFeatureEntryPoint + +interface AccountDeactivationEntryPoint : SimpleFeatureEntryPoint diff --git a/features/deactivation/impl/build.gradle.kts b/features/deactivation/impl/build.gradle.kts new file mode 100644 index 0000000000..6e19a485d4 --- /dev/null +++ b/features/deactivation/impl/build.gradle.kts @@ -0,0 +1,49 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +plugins { + id("io.element.android-compose-library") + alias(libs.plugins.anvil) + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.deactivation.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +anvil { + generateDaggerFactories.set(true) +} + +dependencies { + implementation(projects.anvilannotations) + anvil(projects.anvilcodegen) + implementation(projects.libraries.androidutils) + implementation(projects.libraries.core) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) + api(projects.features.deactivation.api) + + testImplementation(libs.test.junit) + testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) + testImplementation(libs.test.robolectric) + testImplementation(libs.androidx.compose.ui.test.junit) + testReleaseImplementation(libs.androidx.compose.ui.test.manifest) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.tests.testutils) +} diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationEvents.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationEvents.kt new file mode 100644 index 0000000000..35be3f4bf2 --- /dev/null +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationEvents.kt @@ -0,0 +1,15 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.logout.impl + +sealed interface AccountDeactivationEvents { + data class SetEraseData(val eraseData: Boolean) : AccountDeactivationEvents + data class SetPassword(val password: String) : AccountDeactivationEvents + data class DeactivateAccount(val isRetry: Boolean) : AccountDeactivationEvents + data object CloseDialogs : AccountDeactivationEvents +} diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationNode.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationNode.kt new file mode 100644 index 0000000000..0faad3ad20 --- /dev/null +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationNode.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.logout.impl + +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 AccountDeactivationNode @AssistedInject constructor( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: AccountDeactivationPresenter, +) : Node(buildContext, plugins = plugins) { + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + AccountDeactivationView( + state = state, + onBackClick = ::navigateUp, + modifier = modifier, + ) + } +} diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationPresenter.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationPresenter.kt new file mode 100644 index 0000000000..8f0e895524 --- /dev/null +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationPresenter.kt @@ -0,0 +1,84 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +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 io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.architecture.runCatchingUpdatingState +import io.element.android.libraries.matrix.api.MatrixClient +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +class AccountDeactivationPresenter @Inject constructor( + private val matrixClient: MatrixClient, +) : Presenter { + @Composable + override fun present(): AccountDeactivationState { + val localCoroutineScope = rememberCoroutineScope() + val action: MutableState> = remember { + mutableStateOf(AsyncAction.Uninitialized) + } + + val formState = remember { mutableStateOf(DeactivateFormState.Default) } + + fun handleEvents(event: AccountDeactivationEvents) { + when (event) { + is AccountDeactivationEvents.SetEraseData -> { + updateFormState(formState) { + copy(eraseData = event.eraseData) + } + } + is AccountDeactivationEvents.SetPassword -> { + updateFormState(formState) { + copy(password = event.password) + } + } + is AccountDeactivationEvents.DeactivateAccount -> + if (action.value.isConfirming() || event.isRetry) { + localCoroutineScope.deactivateAccount( + formState = formState.value, + action + ) + } else { + action.value = AsyncAction.Confirming + } + AccountDeactivationEvents.CloseDialogs -> { + action.value = AsyncAction.Uninitialized + } + } + } + + return AccountDeactivationState( + deactivateFormState = formState.value, + accountDeactivationAction = action.value, + eventSink = ::handleEvents + ) + } + + private fun updateFormState(formState: MutableState, updateLambda: DeactivateFormState.() -> DeactivateFormState) { + formState.value = updateLambda(formState.value) + } + + private fun CoroutineScope.deactivateAccount( + formState: DeactivateFormState, + action: MutableState>, + ) = launch { + suspend { + matrixClient.deactivateAccount( + password = formState.password, + eraseData = formState.eraseData, + ).getOrThrow() + }.runCatchingUpdatingState(action) + } +} diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationState.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationState.kt new file mode 100644 index 0000000000..5504c3b0bf --- /dev/null +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationState.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.logout.impl + +import android.os.Parcelable +import io.element.android.libraries.architecture.AsyncAction +import kotlinx.parcelize.Parcelize + +data class AccountDeactivationState( + val deactivateFormState: DeactivateFormState, + val accountDeactivationAction: AsyncAction, + val eventSink: (AccountDeactivationEvents) -> Unit, +) { + val submitEnabled: Boolean + get() = accountDeactivationAction is AsyncAction.Uninitialized && + deactivateFormState.password.isNotEmpty() +} + +@Parcelize +data class DeactivateFormState( + val eraseData: Boolean, + val password: String +) : Parcelable { + companion object { + val Default = DeactivateFormState(false, "") + } +} diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationStateProvider.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationStateProvider.kt new file mode 100644 index 0000000000..07ef6590bb --- /dev/null +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationStateProvider.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.logout.impl + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction + +open class AccountDeactivationStateProvider : PreviewParameterProvider { + private val filledForm = aDeactivateFormState(eraseData = true, password = "password") + override val values: Sequence + get() = sequenceOf( + anAccountDeactivationState(), + anAccountDeactivationState( + deactivateFormState = filledForm + ), + anAccountDeactivationState( + deactivateFormState = filledForm, + accountDeactivationAction = AsyncAction.Confirming, + ), + anAccountDeactivationState( + deactivateFormState = filledForm, + accountDeactivationAction = AsyncAction.Loading + ), + anAccountDeactivationState( + deactivateFormState = filledForm, + accountDeactivationAction = AsyncAction.Failure(Exception("Failed to deactivate account")) + ), + ) +} + +internal fun aDeactivateFormState( + eraseData: Boolean = false, + password: String = "", +) = DeactivateFormState( + eraseData = eraseData, + password = password, +) + +internal fun anAccountDeactivationState( + deactivateFormState: DeactivateFormState = aDeactivateFormState(), + accountDeactivationAction: AsyncAction = AsyncAction.Uninitialized, + eventSink: (AccountDeactivationEvents) -> Unit = {}, +) = AccountDeactivationState( + deactivateFormState = deactivateFormState, + accountDeactivationAction = accountDeactivationAction, + eventSink = eventSink, +) diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt new file mode 100644 index 0000000000..c46461580b --- /dev/null +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt @@ -0,0 +1,324 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +@file:OptIn(ExperimentalComposeUiApi::class) + +package io.element.android.features.logout.impl + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +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.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.autofill.AutofillType +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.deactivation.impl.R +import io.element.android.features.logout.impl.ui.AccountDeactivationActionDialog +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.designsystem.atomic.organisms.InfoListItem +import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism +import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.form.textFieldState +import io.element.android.libraries.designsystem.components.list.SwitchListItem +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.aliasScreenTitle +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.IconButton +import io.element.android.libraries.designsystem.theme.components.OutlinedTextField +import io.element.android.libraries.designsystem.theme.components.Scaffold +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.designsystem.theme.components.autofill +import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.persistentListOf + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AccountDeactivationView( + state: AccountDeactivationState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val eventSink = state.eventSink + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + navigationIcon = { + BackButton(onClick = onBackClick) + }, + title = { + Text( + text = stringResource(R.string.screen_deactivate_account_title), + style = ElementTheme.typography.aliasScreenTitle, + ) + }, + ) + }, + ) { padding -> + val scrollState = rememberScrollState() + Column( + modifier = Modifier + .imePadding() + .padding(padding) + .consumeWindowInsets(padding) + .verticalScroll(state = scrollState) + .padding(vertical = 16.dp, horizontal = 20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Content( + state = state, + onSubmitClick = { + eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false)) + } + ) + Spacer(modifier = Modifier.height(32.dp)) + Buttons( + state = state, + onSubmitClick = { + eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false)) + } + ) + } + } + AccountDeactivationActionDialog( + state.accountDeactivationAction, + onConfirmClick = { + eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false)) + }, + onRetryClick = { + eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = true)) + }, + onDismissDialog = { + eventSink(AccountDeactivationEvents.CloseDialogs) + }, + ) +} + +@Composable +private fun ColumnScope.Buttons( + state: AccountDeactivationState, + onSubmitClick: () -> Unit, +) { + val logoutAction = state.accountDeactivationAction + Button( + text = stringResource(CommonStrings.action_deactivate), + showProgress = logoutAction is AsyncAction.Loading, + destructive = true, + enabled = state.submitEnabled, + modifier = Modifier.fillMaxWidth(), + onClick = onSubmitClick, + ) +} + +@Composable +private fun Content( + state: AccountDeactivationState, + onSubmitClick: () -> Unit, +) { + val isLoading by remember(state.deactivateFormState) { + derivedStateOf { + state.accountDeactivationAction is AsyncAction.Loading + } + } + val eraseData = state.deactivateFormState.eraseData + var passwordFieldState by textFieldState(stateValue = state.deactivateFormState.password) + + val focusManager = LocalFocusManager.current + val eventSink = state.eventSink + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(R.string.screen_deactivate_account_description), + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + ) + InfoListOrganism( + items = persistentListOf( + InfoListItem( + message = stringResource(R.string.screen_deactivate_account_list_item_1), + iconComposable = { + Icon( + modifier = Modifier.size(20.dp), + imageVector = CompoundIcons.Close(), + contentDescription = null, + tint = ElementTheme.colors.iconCriticalPrimary, + ) + }, + iconVector = CompoundIcons.Close(), + ), + InfoListItem( + message = stringResource(R.string.screen_deactivate_account_list_item_2), + iconComposable = { + Icon( + modifier = Modifier.size(20.dp), + imageVector = CompoundIcons.Close(), + contentDescription = null, + tint = ElementTheme.colors.iconCriticalPrimary, + ) + }, + ), + InfoListItem( + message = stringResource(R.string.screen_deactivate_account_list_item_3), + iconComposable = { + Icon( + modifier = Modifier.size(20.dp), + imageVector = CompoundIcons.Close(), + contentDescription = null, + tint = ElementTheme.colors.iconCriticalPrimary, + ) + }, + ), + InfoListItem( + message = stringResource(R.string.screen_deactivate_account_list_item_4), + iconComposable = { + Icon( + modifier = Modifier.size(20.dp), + imageVector = CompoundIcons.Check(), + contentDescription = null, + tint = ElementTheme.colors.iconSuccessPrimary, + ) + }, + ), + ), + textStyle = ElementTheme.typography.fontBodyMdRegular, + textColor = ElementTheme.colors.textSecondary, + iconTint = ElementTheme.colors.iconSuccessPrimary, + backgroundColor = Color.Transparent, + ) + + Column { + SwitchListItem( + headline = stringResource(R.string.screen_deactivate_account_delete_all_messages), + value = eraseData, + onChange = { + eventSink(AccountDeactivationEvents.SetEraseData(it)) + }, + enabled = !isLoading, + ) + Text( + modifier = Modifier.padding(start = 16.dp), + text = stringResource(R.string.screen_deactivate_account_delete_all_messages_notice), + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), + ) { + Text( + text = stringResource(CommonStrings.action_confirm_password), + style = ElementTheme.typography.fontBodySmMedium, + color = ElementTheme.colors.textSecondary, + ) + var passwordVisible by remember { mutableStateOf(false) } + if (isLoading) { + // Ensure password is hidden when user submits the form + passwordVisible = false + } + OutlinedTextField( + value = passwordFieldState, + readOnly = isLoading, + modifier = Modifier + .padding(top = 8.dp) + .fillMaxWidth() + .onTabOrEnterKeyFocusNext(focusManager) + .autofill( + autofillTypes = listOf(AutofillType.Password), + onFill = { + val sanitized = it.sanitize() + passwordFieldState = sanitized + eventSink(AccountDeactivationEvents.SetPassword(sanitized)) + } + ), + onValueChange = { + val sanitized = it.sanitize() + passwordFieldState = sanitized + eventSink(AccountDeactivationEvents.SetPassword(sanitized)) + }, + placeholder = { + Text(text = stringResource(CommonStrings.common_password)) + }, + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + val image = + if (passwordVisible) CompoundIcons.VisibilityOn() else CompoundIcons.VisibilityOff() + val description = + if (passwordVisible) stringResource(CommonStrings.a11y_hide_password) else stringResource(CommonStrings.a11y_show_password) + + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon(imageVector = image, description) + } + }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { onSubmitClick() } + ), + singleLine = true, + ) + } + } +} + +/** + * Ensure that the string does not contain any new line characters, which can happen when pasting values. + */ +private fun String.sanitize(): String { + return replace("\n", "") +} + +@PreviewsDayNight +@Composable +internal fun AccountDeactivationViewPreview( + @PreviewParameter(AccountDeactivationStateProvider::class) state: AccountDeactivationState, +) = ElementPreview { + AccountDeactivationView( + state, + onBackClick = {}, + ) +} diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultAccountDeactivationEntryPoint.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultAccountDeactivationEntryPoint.kt new file mode 100644 index 0000000000..dd9197684c --- /dev/null +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultAccountDeactivationEntryPoint.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.logout.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.deactivation.api.AccountDeactivationEntryPoint +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.AppScope +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultAccountDeactivationEntryPoint @Inject constructor() : AccountDeactivationEntryPoint { + override fun createNode(parentNode: Node, buildContext: BuildContext): Node { + return parentNode.createNode(buildContext) + } +} diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/AccountDeactivationActionDialog.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/AccountDeactivationActionDialog.kt new file mode 100644 index 0000000000..8fcd9557cf --- /dev/null +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/AccountDeactivationActionDialog.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.logout.impl.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.designsystem.components.ProgressDialog +import io.element.android.libraries.designsystem.components.dialogs.RetryDialog +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun AccountDeactivationActionDialog( + state: AsyncAction, + onConfirmClick: () -> Unit, + onRetryClick: () -> Unit, + onDismissDialog: () -> Unit, +) { + when (state) { + AsyncAction.Uninitialized -> + Unit + AsyncAction.Confirming -> + AccountDeactivationConfirmationDialog( + onSubmitClick = onConfirmClick, + onDismiss = onDismissDialog + ) + is AsyncAction.Loading -> + ProgressDialog(text = stringResource(CommonStrings.common_please_wait)) + is AsyncAction.Failure -> + RetryDialog( + title = stringResource(id = CommonStrings.dialog_title_error), + content = stringResource(id = CommonStrings.error_unknown), + onRetry = onRetryClick, + onDismiss = onDismissDialog, + ) + is AsyncAction.Success -> Unit + } +} diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/AccountDeactivationConfirmationDialog.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/AccountDeactivationConfirmationDialog.kt new file mode 100644 index 0000000000..18a79c8ed5 --- /dev/null +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/AccountDeactivationConfirmationDialog.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.logout.impl.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import io.element.android.features.deactivation.impl.R +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun AccountDeactivationConfirmationDialog( + onSubmitClick: () -> Unit, + onDismiss: () -> Unit, +) { + ConfirmationDialog( + title = stringResource(id = R.string.screen_deactivate_account_title), + content = stringResource(R.string.screen_deactivate_account_confirmation_dialog_content), + submitText = stringResource(id = CommonStrings.action_deactivate), + onSubmitClick = onSubmitClick, + onDismiss = onDismiss, + destructiveSubmit = true, + ) +} diff --git a/features/deactivation/impl/src/main/res/values/localazy.xml b/features/deactivation/impl/src/main/res/values/localazy.xml new file mode 100644 index 0000000000..5088a41c61 --- /dev/null +++ b/features/deactivation/impl/src/main/res/values/localazy.xml @@ -0,0 +1,12 @@ + + + "Please confirm that you want to deactivate your account. This action cannot be undone." + "Delete all my messages" + "Warning: Future users may see incomplete conversations." + "Deactivating your account is irreversible, it will:" + "Permanently disable your account (you can\'t log back in, and your ID can\'t be reused)." + "Remove you from all chat rooms." + "Delete your account information from our identity server." + "Your messages will still be visible to registered users but won’t be available to new or unregistered users if you choose to delete them." + "Account deactivation" + diff --git a/features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/AccountDeactivationPresenterTest.kt b/features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/AccountDeactivationPresenterTest.kt new file mode 100644 index 0000000000..b34ac2f391 --- /dev/null +++ b/features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/AccountDeactivationPresenterTest.kt @@ -0,0 +1,157 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +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.AsyncAction +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class AccountDeactivationPresenterTest { + @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.accountDeactivationAction).isEqualTo(AsyncAction.Uninitialized) + assertThat(initialState.deactivateFormState).isEqualTo(DeactivateFormState.Default) + } + } + + @Test + fun `present - form update`() = runTest { + val presenter = createPresenter() + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + assertThat(initialState.deactivateFormState).isEqualTo(DeactivateFormState.Default) + initialState.eventSink(AccountDeactivationEvents.SetEraseData(true)) + val updatedState = awaitItem() + assertThat(updatedState.deactivateFormState).isEqualTo(DeactivateFormState.Default.copy(eraseData = true)) + assertThat(updatedState.submitEnabled).isFalse() + updatedState.eventSink(AccountDeactivationEvents.SetPassword("password")) + val updatedState2 = awaitItem() + assertThat(updatedState2.deactivateFormState).isEqualTo(DeactivateFormState(password = "password", eraseData = true)) + assertThat(updatedState2.submitEnabled).isTrue() + } + } + + @Test + fun `present - submit`() = runTest { + val recorder = lambdaRecorder> { _, _ -> + Result.success(Unit) + } + val matrixClient = FakeMatrixClient( + deactivateAccountResult = recorder + ) + val presenter = createPresenter(matrixClient) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(AccountDeactivationEvents.SetPassword("password")) + skipItems(1) + initialState.eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false)) + val updatedState = awaitItem() + assertThat(updatedState.accountDeactivationAction).isEqualTo(AsyncAction.Confirming) + updatedState.eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false)) + val updatedState2 = awaitItem() + assertThat(updatedState2.accountDeactivationAction).isEqualTo(AsyncAction.Loading) + val finalState = awaitItem() + assertThat(finalState.accountDeactivationAction).isEqualTo(AsyncAction.Success(Unit)) + recorder.assertions().isCalledOnce().with(value("password"), value(false)) + } + } + + @Test + fun `present - submit with error and retry`() = runTest { + val recorder = lambdaRecorder> { _, _ -> + Result.failure(AN_EXCEPTION) + } + val matrixClient = FakeMatrixClient( + deactivateAccountResult = recorder + ) + val presenter = createPresenter(matrixClient) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(AccountDeactivationEvents.SetPassword("password")) + initialState.eventSink(AccountDeactivationEvents.SetEraseData(true)) + skipItems(2) + initialState.eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false)) + val updatedState = awaitItem() + assertThat(updatedState.accountDeactivationAction).isEqualTo(AsyncAction.Confirming) + updatedState.eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false)) + val updatedState2 = awaitItem() + assertThat(updatedState2.accountDeactivationAction).isEqualTo(AsyncAction.Loading) + val finalState = awaitItem() + assertThat(finalState.accountDeactivationAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION)) + recorder.assertions().isCalledOnce().with(value("password"), value(true)) + // Retry + finalState.eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = true)) + val finalState2 = awaitItem() + assertThat(finalState2.accountDeactivationAction).isEqualTo(AsyncAction.Loading) + assertThat(awaitItem().accountDeactivationAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION)) + } + } + + @Test + fun `present - submit with error and cancel`() = runTest { + val recorder = lambdaRecorder> { _, _ -> + Result.failure(AN_EXCEPTION) + } + val matrixClient = FakeMatrixClient( + deactivateAccountResult = recorder + ) + val presenter = createPresenter(matrixClient) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + initialState.eventSink(AccountDeactivationEvents.SetPassword("password")) + initialState.eventSink(AccountDeactivationEvents.SetEraseData(true)) + skipItems(2) + initialState.eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false)) + val updatedState = awaitItem() + assertThat(updatedState.accountDeactivationAction).isEqualTo(AsyncAction.Confirming) + updatedState.eventSink(AccountDeactivationEvents.DeactivateAccount(isRetry = false)) + val updatedState2 = awaitItem() + assertThat(updatedState2.accountDeactivationAction).isEqualTo(AsyncAction.Loading) + val finalState = awaitItem() + assertThat(finalState.accountDeactivationAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION)) + recorder.assertions().isCalledOnce().with(value("password"), value(true)) + // Cancel + finalState.eventSink(AccountDeactivationEvents.CloseDialogs) + val finalState2 = awaitItem() + assertThat(finalState2.accountDeactivationAction).isEqualTo(AsyncAction.Uninitialized) + } + } + + private fun createPresenter( + matrixClient: MatrixClient = FakeMatrixClient(), + ) = AccountDeactivationPresenter( + matrixClient = matrixClient, + ) +} diff --git a/features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/AccountDeactivationViewTest.kt b/features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/AccountDeactivationViewTest.kt new file mode 100644 index 0000000000..97a1b34751 --- /dev/null +++ b/features/deactivation/impl/src/test/kotlin/io/element/android/features/logout/impl/AccountDeactivationViewTest.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.features.logout.impl + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AccountDeactivationViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `clicking on back invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + rule.setAccountDeactivationView( + state = anAccountDeactivationState(eventSink = eventsRecorder), + onBackClick = it, + ) + rule.pressBack() + } + } + + // TODO Add more tests +} + +private fun AndroidComposeTestRule.setAccountDeactivationView( + state: AccountDeactivationState, + onBackClick: () -> Unit = EnsureNeverCalled(), +) { + setContent { + AccountDeactivationView( + state = state, + onBackClick = onBackClick, + ) + } +} diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts index a75b920b16..44a792a625 100644 --- a/features/preferences/impl/build.gradle.kts +++ b/features/preferences/impl/build.gradle.kts @@ -55,6 +55,7 @@ dependencies { implementation(projects.features.ftue.api) implementation(projects.features.licenses.api) implementation(projects.features.logout.api) + implementation(projects.features.deactivation.api) implementation(projects.features.roomlist.api) implementation(projects.services.analytics.api) implementation(projects.services.toolbox.api) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt index ad3673bc0e..9a4c5d490e 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt @@ -20,6 +20,7 @@ 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.deactivation.api.AccountDeactivationEntryPoint import io.element.android.features.licenses.api.OpenSourceLicensesEntryPoint import io.element.android.features.lockscreen.api.LockScreenEntryPoint import io.element.android.features.logout.api.LogoutEntryPoint @@ -52,6 +53,7 @@ class PreferencesFlowNode @AssistedInject constructor( private val notificationTroubleShootEntryPoint: NotificationTroubleShootEntryPoint, private val logoutEntryPoint: LogoutEntryPoint, private val openSourceLicensesEntryPoint: OpenSourceLicensesEntryPoint, + private val accountDeactivationEntryPoint: AccountDeactivationEntryPoint, ) : BaseFlowNode( backstack = BackStack( initialElement = plugins.filterIsInstance().first().initialElement.toNavTarget(), @@ -100,6 +102,9 @@ class PreferencesFlowNode @AssistedInject constructor( @Parcelize data object SignOut : NavTarget + @Parcelize + data object AccountDeactivation : NavTarget + @Parcelize data object OssLicenses : NavTarget } @@ -151,6 +156,10 @@ class PreferencesFlowNode @AssistedInject constructor( override fun onSignOutClick() { backstack.push(NavTarget.SignOut) } + + override fun onOpenAccountDeactivation() { + backstack.push(NavTarget.AccountDeactivation) + } } createNode(buildContext, plugins = listOf(callback)) } @@ -236,6 +245,9 @@ class PreferencesFlowNode @AssistedInject constructor( is NavTarget.OssLicenses -> { openSourceLicensesEntryPoint.getNode(this, buildContext) } + NavTarget.AccountDeactivation -> { + accountDeactivationEntryPoint.createNode(this, buildContext) + } } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt index 266fddbca0..9dfa53f4e6 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt @@ -45,6 +45,7 @@ class PreferencesRootNode @AssistedInject constructor( fun onOpenUserProfile(matrixUser: MatrixUser) fun onOpenBlockedUsers() fun onSignOutClick() + fun onOpenAccountDeactivation() } private fun onOpenBugReport() { @@ -105,6 +106,10 @@ class PreferencesRootNode @AssistedInject constructor( plugins().forEach { it.onSignOutClick() } } + private fun onOpenAccountDeactivation() { + plugins().forEach { it.onOpenAccountDeactivation() } + } + @Composable override fun View(modifier: Modifier) { val state = presenter.present() @@ -132,6 +137,7 @@ class PreferencesRootNode @AssistedInject constructor( onSignOutClick() } }, + onDeactivateClick = this::onOpenAccountDeactivation ) directLogoutView.Render( diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt index 500570412d..f1507c45f4 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import io.element.android.features.logout.api.direct.DirectLogoutPresenter import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider import io.element.android.libraries.architecture.Presenter @@ -75,6 +76,12 @@ class PreferencesRootPresenter @Inject constructor( val devicesManagementUrl: MutableState = remember { mutableStateOf(null) } + var canDeactivateAccount by remember { + mutableStateOf(false) + } + LaunchedEffect(Unit) { + canDeactivateAccount = matrixClient.canDeactivateAccount() + } val showBlockedUsersItem by produceState(initialValue = false) { matrixClient.ignoredUsersFlow @@ -108,6 +115,7 @@ class PreferencesRootPresenter @Inject constructor( devicesManagementUrl = devicesManagementUrl.value, showAnalyticsSettings = hasAnalyticsProviders, showDeveloperSettings = showDeveloperSettings, + canDeactivateAccount = canDeactivateAccount, showNotificationSettings = showNotificationSettings.value, showLockScreenSettings = showLockScreenSettings.value, showBlockedUsersItem = showBlockedUsersItem, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt index f6db1cbec1..4ece77a55c 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt @@ -22,6 +22,7 @@ data class PreferencesRootState( val devicesManagementUrl: String?, val showAnalyticsSettings: Boolean, val showDeveloperSettings: Boolean, + val canDeactivateAccount: Boolean, val showLockScreenSettings: Boolean, val showNotificationSettings: Boolean, val showBlockedUsersItem: Boolean, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt index 464288fd9b..c91a7e1adc 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt @@ -29,6 +29,7 @@ fun aPreferencesRootState( showNotificationSettings = true, showLockScreenSettings = true, showBlockedUsersItem = true, + canDeactivateAccount = true, snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete), directLogoutState = aDirectLogoutState(), eventSink = eventSink, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt index 4f47c7eab9..10e59bf334 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt @@ -57,6 +57,7 @@ fun PreferencesRootView( onOpenUserProfile: (MatrixUser) -> Unit, onOpenBlockedUsers: () -> Unit, onSignOutClick: () -> Unit, + onDeactivateClick: () -> Unit, modifier: Modifier = Modifier, ) { val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) @@ -99,6 +100,7 @@ fun PreferencesRootView( onOpenAdvancedSettings = onOpenAdvancedSettings, onOpenDeveloperSettings = onOpenDeveloperSettings, onSignOutClick = onSignOutClick, + onDeactivateClick = onDeactivateClick, ) Footer( @@ -193,6 +195,7 @@ private fun ColumnScope.GeneralSection( onOpenAdvancedSettings: () -> Unit, onOpenDeveloperSettings: () -> Unit, onSignOutClick: () -> Unit, + onDeactivateClick: () -> Unit, ) { ListItem( headlineContent = { Text(stringResource(id = CommonStrings.common_about)) }, @@ -225,6 +228,14 @@ private fun ColumnScope.GeneralSection( style = ListItemStyle.Destructive, onClick = onSignOutClick, ) + if (state.canDeactivateAccount) { + ListItem( + headlineContent = { Text(stringResource(id = CommonStrings.action_deactivate_account)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Warning())), + style = ListItemStyle.Destructive, + onClick = onDeactivateClick, + ) + } } @Composable @@ -292,5 +303,6 @@ private fun ContentToPreview(matrixUser: MatrixUser) { onOpenUserProfile = {}, onOpenBlockedUsers = {}, onSignOutClick = {}, + onDeactivateClick = {}, ) } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt index 4e276701f4..774d52076a 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt @@ -10,6 +10,7 @@ package io.element.android.features.preferences.impl.root import androidx.compose.runtime.Composable import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow +import app.cash.turbine.ReceiveTurbine import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.features.logout.api.direct.DirectLogoutPresenter @@ -45,7 +46,7 @@ class PreferencesRootPresenterTest { @Test fun `present - initial state`() = runTest { - val matrixClient = FakeMatrixClient() + val matrixClient = FakeMatrixClient(canDeactivateAccountResult = { true }) val presenter = createPresenter(matrixClient = matrixClient) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -76,11 +77,27 @@ class PreferencesRootPresenterTest { assertThat(loadedState.showDeveloperSettings).isTrue() assertThat(loadedState.showLockScreenSettings).isTrue() assertThat(loadedState.showNotificationSettings).isTrue() + assertThat(loadedState.canDeactivateAccount).isTrue() assertThat(loadedState.directLogoutState).isEqualTo(aDirectLogoutState) assertThat(loadedState.snackbarMessage).isNull() } } + @Test + fun `present - can deactivate account is false if the Matrix client say so`() = runTest { + val presenter = createPresenter( + matrixClient = FakeMatrixClient( + canDeactivateAccountResult = { false } + ) + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val loadedState = awaitFirstItem() + assertThat(loadedState.canDeactivateAccount).isFalse() + } + } + @Test fun `present - developer settings is hidden by default in release builds`() = runTest { val presenter = createPresenter( @@ -89,8 +106,7 @@ class PreferencesRootPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - skipItems(1) - val loadedState = awaitItem() + val loadedState = awaitFirstItem() assertThat(loadedState.showDeveloperSettings).isFalse() } } @@ -103,20 +119,22 @@ class PreferencesRootPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - skipItems(1) - val loadedState = awaitItem() - + val loadedState = awaitFirstItem() repeat(times = ShowDeveloperSettingsProvider.DEVELOPER_SETTINGS_COUNTER) { assertThat(loadedState.showDeveloperSettings).isFalse() loadedState.eventSink(PreferencesRootEvents.OnVersionInfoClick) } - assertThat(awaitItem().showDeveloperSettings).isTrue() } } + private suspend fun ReceiveTurbine.awaitFirstItem(): T { + skipItems(1) + return awaitItem() + } + private fun createPresenter( - matrixClient: FakeMatrixClient = FakeMatrixClient(), + matrixClient: FakeMatrixClient = FakeMatrixClient(canDeactivateAccountResult = { true }), sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(), showDeveloperSettingsProvider: ShowDeveloperSettingsProvider = ShowDeveloperSettingsProvider(aBuildMeta(BuildType.DEBUG)), ) = PreferencesRootPresenter( diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/organisms/InfoListOrganism.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/organisms/InfoListOrganism.kt index fec66c6f96..dedcd75af0 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/organisms/InfoListOrganism.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/organisms/InfoListOrganism.kt @@ -38,6 +38,7 @@ fun InfoListOrganism( iconTint: Color = LocalContentColor.current, iconSize: Dp = 20.dp, textStyle: TextStyle = LocalTextStyle.current, + textColor: Color = ElementTheme.colors.textPrimary, verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(4.dp), ) { Column( @@ -56,7 +57,7 @@ fun InfoListOrganism( Text( text = item.message, style = textStyle, - color = ElementTheme.colors.textPrimary, + color = textColor, ) }, icon = { diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index ca44838553..6dacb03dfb 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -138,4 +138,7 @@ interface MatrixClient : Closeable { /** Returns `true` if the current session is using native sliding sync, `false` if it's using a proxy. */ fun isUsingNativeSlidingSync(): Boolean + + fun canDeactivateAccount(): Boolean + suspend fun deactivateAccount(password: String, eraseData: Boolean): Result } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 9e77dcff4c..a5c5f1f442 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -9,6 +9,7 @@ package io.element.android.libraries.matrix.impl import io.element.android.libraries.androidutils.file.getSizeOfFiles import io.element.android.libraries.androidutils.file.safeDelete +import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.childScope import io.element.android.libraries.matrix.api.MatrixClient @@ -89,6 +90,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout +import org.matrix.rustcomponents.sdk.AuthData +import org.matrix.rustcomponents.sdk.AuthDataPasswordDetails import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.ClientException import org.matrix.rustcomponents.sdk.IgnoredUsersListener @@ -493,6 +496,46 @@ class RustMatrixClient( return result } + override fun canDeactivateAccount(): Boolean { + return runCatching { + client.canDeactivateAccount() + } + .getOrNull() + .orFalse() + } + + override suspend fun deactivateAccount(password: String, eraseData: Boolean): Result = withContext(sessionDispatcher) { + Timber.w("Deactivating account") + syncService.stop() + runCatching { + // First call without AuthData, should fail + val firstAttempt = runCatching { + client.deactivateAccount( + authData = null, + eraseData = eraseData, + ) + } + if (firstAttempt.isFailure) { + Timber.w(firstAttempt.exceptionOrNull(), "Expected failure, try again") + // This is expected, try again with the password + client.deactivateAccount( + authData = AuthData.Password( + passwordDetails = AuthDataPasswordDetails( + identifier = sessionId.value, + password = password, + ), + ), + eraseData = eraseData, + ) + } + close() + deleteSessionDirectory(deleteCryptoDb = true) + sessionStore.removeSession(sessionId.value) + }.onFailure { + Timber.e(it, "Failed to deactivate account") + } + } + override suspend fun getAccountManagementUrl(action: AccountManagementAction?): Result = withContext(sessionDispatcher) { val rustAction = action?.toRustAction() runCatching { diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index 908cb443ea..2d5e29f064 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -79,6 +79,8 @@ class FakeMatrixClient( private val clearCacheLambda: () -> Unit = { lambdaError() }, private val userIdServerNameLambda: () -> String = { lambdaError() }, private val getUrlLambda: (String) -> Result = { lambdaError() }, + private val canDeactivateAccountResult: () -> Boolean = { lambdaError() }, + private val deactivateAccountResult: (String, Boolean) -> Result = { _, _ -> lambdaError() }, var isNativeSlidingSyncSupportedLambda: suspend () -> Boolean = { true }, var isSlidingSyncProxySupportedLambda: suspend () -> Boolean = { true }, var isUsingNativeSlidingSyncLambda: () -> Boolean = { true }, @@ -175,6 +177,12 @@ class FakeMatrixClient( return logoutLambda(ignoreSdkError, userInitiated) } + override fun canDeactivateAccount() = canDeactivateAccountResult() + + override suspend fun deactivateAccount(password: String, eraseData: Boolean): Result = simulateLongTask { + deactivateAccountResult(password, eraseData) + } + override fun close() = Unit override suspend fun getUserProfile(): Result = simulateLongTask { diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 8e2e7822bf..4ef598a33b 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -42,12 +42,15 @@ "Close" "Complete verification" "Confirm" + "Confirm password" "Continue" "Copy" "Copy link" "Copy link to message" "Create" "Create a room" + "Deactivate" + "Deactivate account" "Decline" "Delete Poll" "Disable" @@ -291,6 +294,8 @@ Reason: %1$s." "Send message anyway" "%1$s is using one or more unverified devices. You can send the message anyway, or you can cancel for now and try again later after %2$s has verified all their devices." "Your message was not sent because %1$s has not verified all devices" + "One or more of your devices are unverified. You can send the message anyway, or you can cancel for now and try again later after you have verified all of your devices." + "Your message was not sent because you have not verified one or more of your devices" "Pinned messages" "Failed processing media to upload, please try again." "Could not retrieve user details" @@ -314,6 +319,7 @@ Reason: %1$s." "Share this location" "Message not sent because %1$s’s verified identity has changed." "Message not sent because %1$s has not verified all devices." + "Message not sent because you have not verified one or more of your devices." "Location" "Version: %1$s (%2$s)" "en" diff --git a/tools/localazy/config.json b/tools/localazy/config.json index f463b6d621..f7555ea393 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -27,6 +27,12 @@ "screen_signout_.*" ] }, + { + "name" : ":features:deactivation:impl", + "includeRegex" : [ + "screen_deactivate_account_.*" + ] + }, { "name" : ":features:roomaliasresolver:impl", "includeRegex" : [ From 0d15be441a9c8a7542b04e883d8cb42bc51df472 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Tue, 17 Sep 2024 15:13:22 +0000 Subject: [PATCH 2/6] Update screenshots --- .../features.logout.impl_AccountDeactivationView_Day_0_en.png | 3 +++ .../features.logout.impl_AccountDeactivationView_Day_1_en.png | 3 +++ .../features.logout.impl_AccountDeactivationView_Day_2_en.png | 3 +++ .../features.logout.impl_AccountDeactivationView_Day_3_en.png | 3 +++ .../features.logout.impl_AccountDeactivationView_Day_4_en.png | 3 +++ ...eatures.logout.impl_AccountDeactivationView_Night_0_en.png | 3 +++ ...eatures.logout.impl_AccountDeactivationView_Night_1_en.png | 3 +++ ...eatures.logout.impl_AccountDeactivationView_Night_2_en.png | 3 +++ ...eatures.logout.impl_AccountDeactivationView_Night_3_en.png | 3 +++ ...eatures.logout.impl_AccountDeactivationView_Night_4_en.png | 3 +++ ...res.preferences.impl.root_PreferencesRootViewDark_0_en.png | 4 ++-- ...res.preferences.impl.root_PreferencesRootViewDark_1_en.png | 4 ++-- ...es.preferences.impl.root_PreferencesRootViewLight_0_en.png | 4 ++-- ...es.preferences.impl.root_PreferencesRootViewLight_1_en.png | 4 ++-- 14 files changed, 38 insertions(+), 8 deletions(-) create mode 100644 tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Day_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Day_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Day_2_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Day_3_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Day_4_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Night_0_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Night_1_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Night_2_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Night_3_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Night_4_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Day_0_en.png new file mode 100644 index 0000000000..44150a9cdc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Day_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ee6b7ffa3bb58fb9124960bc7ef91b496888f1d2e4e00b6f073422e5598baeec +size 76791 diff --git a/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Day_1_en.png new file mode 100644 index 0000000000..ae77faeecc --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Day_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:930cabf63ba6ea12db0b3339202df7553f03dd51eeadab3bf9c0cad2ae0b716d +size 75151 diff --git a/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Day_2_en.png new file mode 100644 index 0000000000..0a50ec8c06 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Day_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e230e408e90da51876f4e416d65e3a1f0bc01572df95789aac4a81c0b81d85e4 +size 60970 diff --git a/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Day_3_en.png new file mode 100644 index 0000000000..1d46dd4c3e --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Day_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b8f2ab416d3ec72b7033e9b930d50eb19c4ae8c21fbfae77c1ec91adca67b42f +size 55109 diff --git a/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Day_4_en.png new file mode 100644 index 0000000000..9d17632802 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Day_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1d060436e3171c6b8d2059067c568965b872ec8ddaff4ef53005e594069cef3f +size 51983 diff --git a/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Night_0_en.png new file mode 100644 index 0000000000..5aebe11211 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Night_0_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fa880b0b3f74fb3ec557645c02b5c932b7793520206568f88832aac4be466ec8 +size 74806 diff --git a/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Night_1_en.png new file mode 100644 index 0000000000..38dca4e570 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Night_1_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3acc8da7da5ac077b35e3ea321ab527a68f1cd193a0a609cc738080dae6eedd2 +size 73250 diff --git a/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Night_2_en.png new file mode 100644 index 0000000000..494d79db03 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Night_2_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8e3171efff5e2aa90ac148bfa9a46686bdbb8c4ba52270575d905e5ce9e9da45 +size 57661 diff --git a/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Night_3_en.png new file mode 100644 index 0000000000..e6a8e0d1b1 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Night_3_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d86d686303683355f6c3b0f621828311242aebac20893407f9fd4f9bbdd4a3a1 +size 52115 diff --git a/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Night_4_en.png new file mode 100644 index 0000000000..71ab4e1742 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Night_4_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:389c15a481d2ac06f283dddbda46e3032276c635e7ecca3446b81b435a63dd3b +size 48815 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_0_en.png index acddf62ed1..6e91a26672 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:297b638c5314b3ac97c164739799e299206f5c13c9b889b088f3db3ef5e0117f -size 35695 +oid sha256:f99630ffa68c56bcfa9f20b0c895869ccf285f9dca4b9f4d60518ab7f09944fa +size 37993 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_1_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_1_en.png index d95315c26a..a8fa281a84 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:58f464863c5294262427d3fb4b2e682140aa8805a43d12ea4dc9d41d3b976062 -size 35449 +oid sha256:7fc7114af573847a08d1d331a34f13ea2ee6d1b02e52995c3657b493eb2cb9fd +size 37747 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_0_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_0_en.png index 449d1ada38..ac15df98a7 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a14903e0fdc50efba1617758babec6a8ef3281b74a5eba7b8f17f41f6ae304a6 -size 36584 +oid sha256:32996754db75d663e65844ac5bcdfce45407154dce88afa5e6a6914d4717ad81 +size 39033 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_1_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_1_en.png index a33eaa62d5..1d6aa51b79 100644 --- a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bed3564666198684c099e4be89ff919a1e1820ff5f91d39080f547e05cbeea2c -size 36539 +oid sha256:14d283a64fe6070713fcc0fdac0435da664f4644143db758eeacf18fd9d5ed07 +size 38987 From effb1c57fe4f7beee706ccecb3b7d060a2e3e8e9 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 17 Sep 2024 17:30:38 +0200 Subject: [PATCH 3/6] Fix icon tint issue. --- .../android/features/logout/impl/AccountDeactivationView.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt index c46461580b..596bbb8412 100644 --- a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt @@ -184,7 +184,6 @@ private fun Content( tint = ElementTheme.colors.iconCriticalPrimary, ) }, - iconVector = CompoundIcons.Close(), ), InfoListItem( message = stringResource(R.string.screen_deactivate_account_list_item_2), From c878e9beb520eb6feba25b0f4b6d30074a6dde3c Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 17 Sep 2024 17:39:23 +0200 Subject: [PATCH 4/6] Make text parts in bold. --- .../logout/impl/AccountDeactivationView.kt | 17 +++++++++++++-- .../impl/src/main/res/values/localazy.xml | 6 ++++-- .../atomic/organisms/InfoListOrganism.kt | 21 +++++++++++++------ 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt index 596bbb8412..ec397393bb 100644 --- a/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt +++ b/features/deactivation/impl/src/main/kotlin/io/element/android/features/logout/impl/AccountDeactivationView.kt @@ -54,6 +54,7 @@ import io.element.android.libraries.designsystem.components.form.textFieldState import io.element.android.libraries.designsystem.components.list.SwitchListItem 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.theme.aliasScreenTitle import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.Icon @@ -168,14 +169,26 @@ private fun Content( verticalArrangement = Arrangement.spacedBy(16.dp), ) { Text( - text = stringResource(R.string.screen_deactivate_account_description), + text = buildAnnotatedStringWithStyledPart( + R.string.screen_deactivate_account_description, + R.string.screen_deactivate_account_description_bold_part, + color = ElementTheme.colors.textSecondary, + bold = true, + underline = false, + ), style = ElementTheme.typography.fontBodyMdRegular, color = ElementTheme.colors.textSecondary, ) InfoListOrganism( items = persistentListOf( InfoListItem( - message = stringResource(R.string.screen_deactivate_account_list_item_1), + message = buildAnnotatedStringWithStyledPart( + R.string.screen_deactivate_account_list_item_1, + R.string.screen_deactivate_account_list_item_1_bold_part, + color = ElementTheme.colors.textSecondary, + bold = true, + underline = false, + ), iconComposable = { Icon( modifier = Modifier.size(20.dp), diff --git a/features/deactivation/impl/src/main/res/values/localazy.xml b/features/deactivation/impl/src/main/res/values/localazy.xml index 5088a41c61..4a648996be 100644 --- a/features/deactivation/impl/src/main/res/values/localazy.xml +++ b/features/deactivation/impl/src/main/res/values/localazy.xml @@ -3,8 +3,10 @@ "Please confirm that you want to deactivate your account. This action cannot be undone." "Delete all my messages" "Warning: Future users may see incomplete conversations." - "Deactivating your account is irreversible, it will:" - "Permanently disable your account (you can\'t log back in, and your ID can\'t be reused)." + "Deactivating your account is %1$s, it will:" + "irreversible" + "%1$s your account (you can\'t log back in, and your ID can\'t be reused)." + "Permanently disable" "Remove you from all chat rooms." "Delete your account information from our identity server." "Your messages will still be visible to registered users but won’t be available to new or unregistered users if you choose to delete them." diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/organisms/InfoListOrganism.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/organisms/InfoListOrganism.kt index dedcd75af0..f3ff74909c 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/organisms/InfoListOrganism.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/organisms/InfoListOrganism.kt @@ -17,6 +17,7 @@ import androidx.compose.runtime.Composable 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.text.TextStyle import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -54,11 +55,19 @@ fun InfoListOrganism( } InfoListItemMolecule( message = { - Text( - text = item.message, - style = textStyle, - color = textColor, - ) + if (item.message is AnnotatedString) { + Text( + text = item.message, + style = textStyle, + color = textColor, + ) + } else { + Text( + text = item.message.toString(), + style = textStyle, + color = textColor, + ) + } }, icon = { if (item.iconId != null) { @@ -87,7 +96,7 @@ fun InfoListOrganism( } data class InfoListItem( - val message: String, + val message: CharSequence, @DrawableRes val iconId: Int? = null, val iconVector: ImageVector? = null, val iconComposable: @Composable () -> Unit = {}, From c1d5d36f68f500e16edac31545dd9c86e81566f6 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 17 Sep 2024 17:42:52 +0200 Subject: [PATCH 5/6] Fix screen title --- features/deactivation/impl/src/main/res/values/localazy.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/deactivation/impl/src/main/res/values/localazy.xml b/features/deactivation/impl/src/main/res/values/localazy.xml index 4a648996be..0380cf1c94 100644 --- a/features/deactivation/impl/src/main/res/values/localazy.xml +++ b/features/deactivation/impl/src/main/res/values/localazy.xml @@ -10,5 +10,5 @@ "Remove you from all chat rooms." "Delete your account information from our identity server." "Your messages will still be visible to registered users but won’t be available to new or unregistered users if you choose to delete them." - "Account deactivation" + "Deactivate account" From 9437d0650a01a89537300fa7f5bafa8744d19ab4 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Tue, 17 Sep 2024 15:54:22 +0000 Subject: [PATCH 6/6] Update screenshots --- .../features.logout.impl_AccountDeactivationView_Day_0_en.png | 4 ++-- .../features.logout.impl_AccountDeactivationView_Day_1_en.png | 4 ++-- .../features.logout.impl_AccountDeactivationView_Day_2_en.png | 4 ++-- .../features.logout.impl_AccountDeactivationView_Day_3_en.png | 4 ++-- .../features.logout.impl_AccountDeactivationView_Day_4_en.png | 4 ++-- ...eatures.logout.impl_AccountDeactivationView_Night_0_en.png | 4 ++-- ...eatures.logout.impl_AccountDeactivationView_Night_1_en.png | 4 ++-- ...eatures.logout.impl_AccountDeactivationView_Night_2_en.png | 4 ++-- ...eatures.logout.impl_AccountDeactivationView_Night_3_en.png | 4 ++-- ...eatures.logout.impl_AccountDeactivationView_Night_4_en.png | 4 ++-- 10 files changed, 20 insertions(+), 20 deletions(-) diff --git a/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Day_0_en.png index 44150a9cdc..d935111fb9 100644 --- a/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ee6b7ffa3bb58fb9124960bc7ef91b496888f1d2e4e00b6f073422e5598baeec -size 76791 +oid sha256:4c39aa8d3c17480057e87f5fbe1a490f2c287726b8223b28fdb3a199670e664a +size 76627 diff --git a/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Day_1_en.png index ae77faeecc..6e683cf2df 100644 --- a/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:930cabf63ba6ea12db0b3339202df7553f03dd51eeadab3bf9c0cad2ae0b716d -size 75151 +oid sha256:5676526f71b0e743ae5a9fa174186f07514fcd367a6d1dbd00f6e2c0ca8e3558 +size 74986 diff --git a/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Day_2_en.png index 0a50ec8c06..80534b9551 100644 --- a/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e230e408e90da51876f4e416d65e3a1f0bc01572df95789aac4a81c0b81d85e4 -size 60970 +oid sha256:db84da9b296e66a1968359da1c80ac546f4851d1f581e5e4d9100f16df4e80fc +size 60534 diff --git a/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Day_3_en.png index 1d46dd4c3e..0c830753d5 100644 --- a/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b8f2ab416d3ec72b7033e9b930d50eb19c4ae8c21fbfae77c1ec91adca67b42f -size 55109 +oid sha256:621f75bd069d08bf2ae1c6a51bc3022a32ba4c6eaf829b0731d294d462ce0bd7 +size 55043 diff --git a/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Day_4_en.png index 9d17632802..d97957903f 100644 --- a/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1d060436e3171c6b8d2059067c568965b872ec8ddaff4ef53005e594069cef3f -size 51983 +oid sha256:f83cd277b65f86c7f5ad85d92454be4ab8df730078a7fc3f030f17b0b61b10a1 +size 51928 diff --git a/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Night_0_en.png index 5aebe11211..8e57779361 100644 --- a/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fa880b0b3f74fb3ec557645c02b5c932b7793520206568f88832aac4be466ec8 -size 74806 +oid sha256:6b1b12d2b3f79c0c941b9b2d376d2d0e8e7684bdcba8b1fffe2d7f025b738678 +size 74846 diff --git a/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Night_1_en.png index 38dca4e570..89f4f18a30 100644 --- a/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3acc8da7da5ac077b35e3ea321ab527a68f1cd193a0a609cc738080dae6eedd2 -size 73250 +oid sha256:90ada7991629e3f1888aa187425bb90bc525f75aedb5f368c589fbb2fbfe897d +size 73288 diff --git a/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Night_2_en.png index 494d79db03..4cef2b643d 100644 --- a/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8e3171efff5e2aa90ac148bfa9a46686bdbb8c4ba52270575d905e5ce9e9da45 -size 57661 +oid sha256:685a1e99238808e83a4774bd139c14049f4dfc19dd8bac757207d1e697d9c716 +size 57240 diff --git a/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Night_3_en.png index e6a8e0d1b1..005c79045b 100644 --- a/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d86d686303683355f6c3b0f621828311242aebac20893407f9fd4f9bbdd4a3a1 -size 52115 +oid sha256:a6992f4794f325cbd35dae667f8cb7973bcc9470e61f8bedb600917bc15af44c +size 52087 diff --git a/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Night_4_en.png index 71ab4e1742..b25188b298 100644 --- a/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.logout.impl_AccountDeactivationView_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:389c15a481d2ac06f283dddbda46e3032276c635e7ecca3446b81b435a63dd3b -size 48815 +oid sha256:8594136f07338d981c1e9e395eb52cd7088af03565397ae416169388d721dca2 +size 48791