Account deactivation.

This commit is contained in:
Benoit Marty
2024-09-17 13:19:46 +02:00
parent 63b64a83aa
commit ea355d29ed
29 changed files with 1071 additions and 9 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<AccountDeactivationState> {
@Composable
override fun present(): AccountDeactivationState {
val localCoroutineScope = rememberCoroutineScope()
val action: MutableState<AsyncAction<Unit>> = 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<DeactivateFormState>, updateLambda: DeactivateFormState.() -> DeactivateFormState) {
formState.value = updateLambda(formState.value)
}
private fun CoroutineScope.deactivateAccount(
formState: DeactivateFormState,
action: MutableState<AsyncAction<Unit>>,
) = launch {
suspend {
matrixClient.deactivateAccount(
password = formState.password,
eraseData = formState.eraseData,
).getOrThrow()
}.runCatchingUpdatingState(action)
}
}

View File

@@ -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<Unit>,
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, "")
}
}

View File

@@ -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<AccountDeactivationState> {
private val filledForm = aDeactivateFormState(eraseData = true, password = "password")
override val values: Sequence<AccountDeactivationState>
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<Unit> = AsyncAction.Uninitialized,
eventSink: (AccountDeactivationEvents) -> Unit = {},
) = AccountDeactivationState(
deactivateFormState = deactivateFormState,
accountDeactivationAction = accountDeactivationAction,
eventSink = eventSink,
)

View File

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

View File

@@ -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<AccountDeactivationNode>(buildContext)
}
}

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_confirmation_dialog_content">"Please confirm that you want to deactivate your account. This action cannot be undone."</string>
<string name="screen_deactivate_account_delete_all_messages">"Delete all my messages"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"Warning: Future users may see incomplete conversations."</string>
<string name="screen_deactivate_account_description">"Deactivating your account is irreversible, it will:"</string>
<string name="screen_deactivate_account_list_item_1">"Permanently disable your account (you can\'t log back in, and your ID can\'t be reused)."</string>
<string name="screen_deactivate_account_list_item_2">"Remove you from all chat rooms."</string>
<string name="screen_deactivate_account_list_item_3">"Delete your account information from our identity server."</string>
<string name="screen_deactivate_account_list_item_4">"Your messages will still be visible to registered users but wont be available to new or unregistered users if you choose to delete them."</string>
<string name="screen_deactivate_account_title">"Account deactivation"</string>
</resources>

View File

@@ -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<String, Boolean, Result<Unit>> { _, _ ->
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<String, Boolean, Result<Unit>> { _, _ ->
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<String, Boolean, Result<Unit>> { _, _ ->
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,
)
}

View File

@@ -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<ComponentActivity>()
@Test
fun `clicking on back invokes the expected callback`() {
val eventsRecorder = EventsRecorder<AccountDeactivationEvents>(expectEvents = false)
ensureCalledOnce {
rule.setAccountDeactivationView(
state = anAccountDeactivationState(eventSink = eventsRecorder),
onBackClick = it,
)
rule.pressBack()
}
}
// TODO Add more tests
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setAccountDeactivationView(
state: AccountDeactivationState,
onBackClick: () -> Unit = EnsureNeverCalled(),
) {
setContent {
AccountDeactivationView(
state = state,
onBackClick = onBackClick,
)
}
}

View File

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

View File

@@ -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<PreferencesFlowNode.NavTarget>(
backstack = BackStack(
initialElement = plugins.filterIsInstance<PreferencesEntryPoint.Params>().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<PreferencesRootNode>(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)
}
}
}

View File

@@ -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<Callback>().forEach { it.onSignOutClick() }
}
private fun onOpenAccountDeactivation() {
plugins<Callback>().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(

View File

@@ -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<String?> = 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,

View File

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

View File

@@ -29,6 +29,7 @@ fun aPreferencesRootState(
showNotificationSettings = true,
showLockScreenSettings = true,
showBlockedUsersItem = true,
canDeactivateAccount = true,
snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete),
directLogoutState = aDirectLogoutState(),
eventSink = eventSink,

View File

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

View File

@@ -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 <T> ReceiveTurbine<T>.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(

View File

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

View File

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

View File

@@ -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<Unit> = 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<String?> = withContext(sessionDispatcher) {
val rustAction = action?.toRustAction()
runCatching {

View File

@@ -79,6 +79,8 @@ class FakeMatrixClient(
private val clearCacheLambda: () -> Unit = { lambdaError() },
private val userIdServerNameLambda: () -> String = { lambdaError() },
private val getUrlLambda: (String) -> Result<String> = { lambdaError() },
private val canDeactivateAccountResult: () -> Boolean = { lambdaError() },
private val deactivateAccountResult: (String, Boolean) -> Result<Unit> = { _, _ -> 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<Unit> = simulateLongTask {
deactivateAccountResult(password, eraseData)
}
override fun close() = Unit
override suspend fun getUserProfile(): Result<MatrixUser> = simulateLongTask {

View File

@@ -42,12 +42,15 @@
<string name="action_close">"Close"</string>
<string name="action_complete_verification">"Complete verification"</string>
<string name="action_confirm">"Confirm"</string>
<string name="action_confirm_password">"Confirm password"</string>
<string name="action_continue">"Continue"</string>
<string name="action_copy">"Copy"</string>
<string name="action_copy_link">"Copy link"</string>
<string name="action_copy_link_to_message">"Copy link to message"</string>
<string name="action_create">"Create"</string>
<string name="action_create_a_room">"Create a room"</string>
<string name="action_deactivate">"Deactivate"</string>
<string name="action_deactivate_account">"Deactivate account"</string>
<string name="action_decline">"Decline"</string>
<string name="action_delete_poll">"Delete Poll"</string>
<string name="action_disable">"Disable"</string>
@@ -291,6 +294,8 @@ Reason: %1$s."</string>
<string name="screen_resolve_send_failure_unsigned_device_primary_button_title">"Send message anyway"</string>
<string name="screen_resolve_send_failure_unsigned_device_subtitle">"%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."</string>
<string name="screen_resolve_send_failure_unsigned_device_title">"Your message was not sent because %1$s has not verified all devices"</string>
<string name="screen_resolve_send_failure_you_unsigned_device_subtitle">"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."</string>
<string name="screen_resolve_send_failure_you_unsigned_device_title">"Your message was not sent because you have not verified one or more of your devices"</string>
<string name="screen_room_details_pinned_events_row_title">"Pinned messages"</string>
<string name="screen_room_error_failed_processing_media">"Failed processing media to upload, please try again."</string>
<string name="screen_room_error_failed_retrieving_user_details">"Could not retrieve user details"</string>
@@ -314,6 +319,7 @@ Reason: %1$s."</string>
<string name="screen_share_this_location_action">"Share this location"</string>
<string name="screen_timeline_item_menu_send_failure_changed_identity">"Message not sent because %1$ss verified identity has changed."</string>
<string name="screen_timeline_item_menu_send_failure_unsigned_device">"Message not sent because %1$s has not verified all devices."</string>
<string name="screen_timeline_item_menu_send_failure_you_unsigned_device">"Message not sent because you have not verified one or more of your devices."</string>
<string name="screen_view_location_title">"Location"</string>
<string name="settings_version_number">"Version: %1$s (%2$s)"</string>
<string name="test_language_identifier">"en"</string>

View File

@@ -27,6 +27,12 @@
"screen_signout_.*"
]
},
{
"name" : ":features:deactivation:impl",
"includeRegex" : [
"screen_deactivate_account_.*"
]
},
{
"name" : ":features:roomaliasresolver:impl",
"includeRegex" : [