Account deactivation.
This commit is contained in:
17
features/deactivation/api/build.gradle.kts
Normal file
17
features/deactivation/api/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
49
features/deactivation/impl/build.gradle.kts
Normal file
49
features/deactivation/impl/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, "")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
12
features/deactivation/impl/src/main/res/values/localazy.xml
Normal file
12
features/deactivation/impl/src/main/res/values/localazy.xml
Normal 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 won’t be available to new or unregistered users if you choose to delete them."</string>
|
||||
<string name="screen_deactivate_account_title">"Account deactivation"</string>
|
||||
</resources>
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -29,6 +29,7 @@ fun aPreferencesRootState(
|
||||
showNotificationSettings = true,
|
||||
showLockScreenSettings = true,
|
||||
showBlockedUsersItem = true,
|
||||
canDeactivateAccount = true,
|
||||
snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete),
|
||||
directLogoutState = aDirectLogoutState(),
|
||||
eventSink = eventSink,
|
||||
|
||||
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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$s’s 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>
|
||||
|
||||
@@ -27,6 +27,12 @@
|
||||
"screen_signout_.*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name" : ":features:deactivation:impl",
|
||||
"includeRegex" : [
|
||||
"screen_deactivate_account_.*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name" : ":features:roomaliasresolver:impl",
|
||||
"includeRegex" : [
|
||||
|
||||
Reference in New Issue
Block a user