Add numberless delete account screen.

This commit is contained in:
Greyson Parrelli
2026-09-18 13:59:10 -04:00
parent 8134c3b64f
commit 424ff4ad4d
13 changed files with 799 additions and 177 deletions
@@ -18,7 +18,8 @@ import androidx.navigation.fragment.findNavController
import com.google.android.material.snackbar.Snackbar
import org.signal.appsettings.deleteaccount.DeleteAccountAction
import org.signal.appsettings.deleteaccount.DeleteAccountEvent
import org.signal.appsettings.deleteaccount.DeleteAccountScreen
import org.signal.appsettings.deleteaccount.DeleteAccountWithNumberScreen
import org.signal.appsettings.deleteaccount.DeleteAccountWithoutNumberScreen
import org.signal.core.ui.compose.CollectActions
import org.signal.core.ui.compose.ComposeFragment
import org.thoughtcrime.securesms.R
@@ -27,7 +28,8 @@ import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.signal.appsettings.R as AppSettingsR
/**
* Lets a user delete their account. Carries out the [DeleteAccountAction]s that need an Activity or the nav graph.
* Lets a user delete their account, showing whichever of the two screens fits the account. Carries out the
* [DeleteAccountAction]s that need an Activity or the nav graph.
*/
class DeleteAccountFragment : ComposeFragment() {
@@ -50,10 +52,17 @@ class DeleteAccountFragment : ComposeFragment() {
CollectActions(viewModel.actions) { action -> handleAction(action) }
DeleteAccountScreen(
state = state,
onEvent = viewModel::onEvent
)
if (state.hasPhoneNumber) {
DeleteAccountWithNumberScreen(
state = state,
onEvent = viewModel::onEvent
)
} else {
DeleteAccountWithoutNumberScreen(
state = state,
onEvent = viewModel::onEvent
)
}
}
private fun handleAction(action: DeleteAccountAction) {
@@ -44,6 +44,12 @@ class DeleteAccountRepository {
fun getRegionCountryCode(region: String): Int = PhoneNumberUtil.getInstance().getCountryCodeForRegion(region)
/** Whether this account has no phone number, which changes how the user confirms the deletion. */
fun isPhoneNumberless(): Boolean = SignalStore.account.isPhoneNumberless
/** The user's username, or null if they haven't set one. */
fun getUsername(): String? = SignalStore.account.username
/** The user's payments balance, formatted for display, or null if there's nothing in there worth mentioning. */
fun getFormattedWalletBalance(): String? {
val amount = SignalStore.payments.mobileCoinLatestBalance().fullAmount
@@ -24,8 +24,8 @@ import org.signal.core.ui.compose.EventDrivenViewModel
import org.signal.core.util.logging.Log
/**
* Drives the screen that lets a user delete their account, which asks them to key in their own phone number before
* anything is torn down.
* Drives both screens that let a user delete their account. An account with a phone number has to have that number
* keyed back in before anything is torn down; a numberless one has a box ticked instead.
*/
class DeleteAccountViewModel(
private val repository: DeleteAccountRepository = DeleteAccountRepository()
@@ -39,7 +39,13 @@ class DeleteAccountViewModel(
private val phoneNumberUtil = PhoneNumberUtil.getInstance()
private val _state = MutableStateFlow(DeleteAccountState(walletBalance = repository.getFormattedWalletBalance()))
private val _state = MutableStateFlow(
DeleteAccountState(
hasPhoneNumber = !repository.isPhoneNumberless(),
username = repository.getUsername(),
walletBalance = repository.getFormattedWalletBalance()
)
)
private val _actions = Channel<DeleteAccountAction>(Channel.BUFFERED)
val state: StateFlow<DeleteAccountState> = _state.asStateFlow()
@@ -68,6 +74,9 @@ class DeleteAccountViewModel(
DeleteAccountEvent.DeleteAccountClicked -> {
applyDeleteAccountClicked()
}
is DeleteAccountEvent.ConfirmationCheckedChanged -> {
applyConfirmationCheckedChanged(event.checked)
}
DeleteAccountEvent.DeletionConfirmed -> {
applyDeletionConfirmed()
}
@@ -137,6 +146,12 @@ class DeleteAccountViewModel(
private suspend fun applyDeleteAccountClicked() {
val state = _state.value
if (!state.hasPhoneNumber) {
_state.update { it.copy(dialog = Dialog.ConfirmNumberlessDeletion()) }
return
}
val countryCode = state.countryCode.toIntOrNull() ?: 0
if (countryCode == 0) {
@@ -154,6 +169,12 @@ class DeleteAccountViewModel(
_state.update { it.copy(dialog = dialog) }
}
private fun applyConfirmationCheckedChanged(checked: Boolean) {
_state.update {
if (it.dialog is Dialog.ConfirmNumberlessDeletion) it.copy(dialog = Dialog.ConfirmNumberlessDeletion(checked)) else it
}
}
private suspend fun applyDeletionConfirmed() {
_state.update { it.copy(dialog = Dialog.DeletingAccount) }
@@ -8,7 +8,9 @@ package org.thoughtcrime.securesms.delete
import assertk.assertThat
import assertk.assertions.contains
import assertk.assertions.containsExactly
import assertk.assertions.isEmpty
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
@@ -46,6 +48,8 @@ class DeleteAccountViewModelTest {
fun setUp() {
Dispatchers.setMain(testDispatcher)
every { repository.isPhoneNumberless() } returns false
every { repository.getUsername() } returns null
every { repository.getFormattedWalletBalance() } returns null
every { repository.getRegionDisplayName(any()) } returns ""
every { repository.getRegionDisplayName("US") } returns "United States"
@@ -145,6 +149,68 @@ class DeleteAccountViewModelTest {
coVerify(exactly = 0) { repository.deleteAccount(any()) }
}
@Test
fun `a numberless account is set up to show the numberless screen, along with the username on it`() = runTest(testDispatcher) {
every { repository.isPhoneNumberless() } returns true
every { repository.getUsername() } returns "alice.01"
val viewModel = createViewModel()
assertThat(viewModel.state.value.hasPhoneNumber).isFalse()
assertThat(viewModel.state.value.username).isEqualTo("alice.01")
}
@Test
fun `DeleteAccountClicked on a numberless account asks for confirmation without any number`() = runTest(testDispatcher) {
every { repository.isPhoneNumberless() } returns true
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(DeleteAccountEvent.DeleteAccountClicked)
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.ConfirmNumberlessDeletion(confirmationChecked = false))
assertThat(actions).isEmpty()
coVerify(exactly = 0) { repository.deleteAccount(any()) }
}
@Test
fun `ConfirmationCheckedChanged ticks the box on the numberless confirmation`() = runTest(testDispatcher) {
every { repository.isPhoneNumberless() } returns true
val viewModel = createViewModel()
viewModel.onEvent(DeleteAccountEvent.DeleteAccountClicked)
viewModel.onEvent(DeleteAccountEvent.ConfirmationCheckedChanged(true))
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.ConfirmNumberlessDeletion(confirmationChecked = true))
}
@Test
fun `ConfirmationCheckedChanged is ignored when the numberless confirmation isn't up`() = runTest(testDispatcher) {
every { repository.isPhoneNumberless() } returns true
val viewModel = createViewModel()
viewModel.onEvent(DeleteAccountEvent.ConfirmationCheckedChanged(true))
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.None)
}
@Test
fun `a dismissed numberless confirmation comes back unticked`() = runTest(testDispatcher) {
every { repository.isPhoneNumberless() } returns true
val viewModel = createViewModel()
viewModel.onEvent(DeleteAccountEvent.DeleteAccountClicked)
viewModel.onEvent(DeleteAccountEvent.ConfirmationCheckedChanged(true))
viewModel.onEvent(DeleteAccountEvent.DialogDismissed)
viewModel.onEvent(DeleteAccountEvent.DeleteAccountClicked)
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.ConfirmNumberlessDeletion(confirmationChecked = false))
}
@Test
fun `DeletionConfirmed reports the progress it's told about`() = runTest(testDispatcher) {
lateinit var viewModel: DeleteAccountViewModel
@@ -0,0 +1,155 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.appsettings.deleteaccount
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import org.signal.appsettings.R
import org.signal.appsettings.deleteaccount.DeleteAccountState.Dialog
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.R as CoreUiR
/**
* Every dialog both delete account screens show the same way, which is all of them bar the confirmation prompt. That
* one differs between the two, so each screen renders its own.
*/
@Composable
internal fun DeleteAccountDialogs(
dialog: Dialog,
onEvent: (DeleteAccountEvent) -> Unit
) {
when (dialog) {
Dialog.None,
Dialog.ConfirmDeletion,
is Dialog.ConfirmNumberlessDeletion -> Unit
Dialog.NumberDoesNotMatch -> {
Dialogs.SimpleMessageDialog(
message = stringResource(R.string.DeleteAccountFragment__the_phone_number),
dismiss = stringResource(android.R.string.ok),
onDismiss = { onEvent(DeleteAccountEvent.DialogDismissed) },
modifier = Modifier.testTag(DeleteAccountTestTags.DIALOG_NUMBER_DOES_NOT_MATCH)
)
}
Dialog.DeletionFailed -> {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.DeleteAccountFragment__account_not_deleted),
body = stringResource(R.string.DeleteAccountFragment__there_was_a_problem),
confirm = stringResource(android.R.string.ok),
dismiss = stringResource(android.R.string.cancel),
onConfirm = { onEvent(DeleteAccountEvent.DeletionConfirmed) },
onDismiss = { onEvent(DeleteAccountEvent.DialogDismissed) },
modifier = Modifier.testTag(DeleteAccountTestTags.DIALOG_DELETION_FAILED)
)
}
Dialog.LocalDataDeletionFailed -> {
Dialogs.SimpleMessageDialog(
message = stringResource(R.string.DeleteAccountFragment__failed_to_delete_local_data),
dismiss = stringResource(R.string.DeleteAccountFragment__launch_app_settings),
onDismiss = { onEvent(DeleteAccountEvent.LaunchAppSettingsClicked) },
properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false),
modifier = Modifier.testTag(DeleteAccountTestTags.DIALOG_LOCAL_DATA_DELETION_FAILED)
)
}
Dialog.CancelingSubscription -> {
ProgressDialog(
title = stringResource(R.string.DeleteAccountFragment__deleting_account),
message = stringResource(R.string.DeleteAccountFragment__canceling_your_subscription),
progress = null
)
}
is Dialog.LeavingGroups -> {
ProgressDialog(
title = stringResource(R.string.DeleteAccountFragment__leaving_groups),
message = stringResource(R.string.DeleteAccountFragment__depending_on_the_number_of_groups),
progress = if (dialog.totalCount > 0) dialog.leaveCount.toFloat() / dialog.totalCount else null
)
}
Dialog.DeletingAccount -> {
ProgressDialog(
title = stringResource(R.string.DeleteAccountFragment__deleting_account),
message = stringResource(R.string.DeleteAccountFragment__deleting_all_user_data_and_resetting),
progress = null
)
}
}
}
/**
* Non-dismissable spinner shown for the length of the deletion, which reports what part of it is underway.
*/
@Composable
private fun ProgressDialog(
title: String,
message: String,
progress: Float?
) {
Dialogs.BaseAlertDialog(
onDismissRequest = {},
confirmButton = {},
properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false),
text = {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
) {
Spacer(modifier = Modifier.height(24.dp))
if (progress == null) {
CircularProgressIndicator(modifier = Modifier.size(48.dp))
} else {
CircularProgressIndicator(progress = { progress }, modifier = Modifier.size(48.dp))
}
Spacer(modifier = Modifier.height(24.dp))
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = dimensionResource(CoreUiR.dimen.gutter))
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = dimensionResource(CoreUiR.dimen.gutter))
)
Spacer(modifier = Modifier.height(24.dp))
}
},
modifier = Modifier.testTag(DeleteAccountTestTags.DIALOG_PROGRESS)
)
}
@@ -32,6 +32,9 @@ sealed interface DeleteAccountEvent {
/** The user asked to delete their account, which we only act on once they've confirmed. */
data object DeleteAccountClicked : DeleteAccountEvent
/** The user ticked or unticked the box that gates the deletion for a numberless account. */
data class ConfirmationCheckedChanged(val checked: Boolean) : DeleteAccountEvent
/** The user confirmed the deletion, either from the confirmation dialog or by retrying a failed one. */
data object DeletionConfirmed : DeleteAccountEvent
@@ -8,9 +8,12 @@ package org.signal.appsettings.deleteaccount
import org.signal.core.util.censor
/**
* Everything [DeleteAccountScreen] needs to render.
* Everything [DeleteAccountWithNumberScreen] and [DeleteAccountWithoutNumberScreen] need to render. Which of the two
* is shown comes down to [hasPhoneNumber], and the fields each one leans on are called out below.
*/
data class DeleteAccountState(
/** Whether this account has a phone number, which is what decides which of the two screens the user sees. */
val hasPhoneNumber: Boolean = true,
/** The region the phone number is being entered for, or "ZZ" while we don't know it. */
val regionCode: String = UNKNOWN_REGION,
/** The display name of [regionCode], which is empty until the user picks a country we recognize. */
@@ -21,12 +24,14 @@ data class DeleteAccountState(
val nationalNumber: String = "",
/** The national number as the user sees it, formatted for [regionCode]. */
val formattedNumber: String = "",
/** The user's username, or null when they don't have one. Only shown on [DeleteAccountWithoutNumberScreen]. */
val username: String? = null,
/** The user's payments balance, formatted for display, or null when they have nothing in there. */
val walletBalance: String? = null,
val dialog: Dialog = Dialog.None
) {
override fun toString(): String = "DeleteAccountState(regionCode=$regionCode, countryDisplayName=$countryDisplayName, countryCode=$countryCode, nationalNumber=${nationalNumber.censor()}, formattedNumber=${formattedNumber.censor()}, walletBalance=${walletBalance?.censor()}, dialog=$dialog)"
override fun toString(): String = "DeleteAccountState(hasPhoneNumber=$hasPhoneNumber, regionCode=$regionCode, countryDisplayName=$countryDisplayName, countryCode=$countryCode, nationalNumber=${nationalNumber.censor()}, formattedNumber=${formattedNumber.censor()}, username=${username?.censor()}, walletBalance=${walletBalance?.censor()}, dialog=$dialog)"
/** Whichever dialog the screen is showing, if any. Only one is ever up at a time. */
sealed interface Dialog {
@@ -38,6 +43,12 @@ data class DeleteAccountState(
/** Asks the user to confirm that they really do want their account deleted. */
data object ConfirmDeletion : Dialog
/**
* Asks a numberless user to confirm that they really do want their account deleted. They have no number to key in,
* so instead they have to tick a box before we'll let them go ahead.
*/
data class ConfirmNumberlessDeletion(val confirmationChecked: Boolean = false) : Dialog
/** Deletion is underway and we're canceling the user's donation subscription. */
data object CancelingSubscription : Dialog
@@ -0,0 +1,31 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.appsettings.deleteaccount
import androidx.annotation.VisibleForTesting
/**
* Tags for both delete account screens and the dialogs they share.
*/
@VisibleForTesting
object DeleteAccountTestTags {
const val SCROLLER = "scroller"
const val ROW_COUNTRY_PICKER = "row-country-picker"
const val FIELD_COUNTRY_CODE = "field-country-code"
const val FIELD_NUMBER = "field-number"
const val BUTTON_DELETE = "button-delete"
const val DIALOG_NUMBER_DOES_NOT_MATCH = "dialog-number-does-not-match"
const val DIALOG_CONFIRM_DELETION = "dialog-confirm-deletion"
const val DIALOG_DELETION_FAILED = "dialog-deletion-failed"
const val DIALOG_LOCAL_DATA_DELETION_FAILED = "dialog-local-data-deletion-failed"
const val DIALOG_PROGRESS = "dialog-progress"
const val NUMBERLESS_SCROLLER = "numberless-scroller"
const val NUMBERLESS_BUTTON_DELETE = "numberless-button-delete"
const val NUMBERLESS_DIALOG_CONFIRM_DELETION = "numberless-dialog-confirm-deletion"
const val NUMBERLESS_ROW_CONFIRMATION = "numberless-row-confirmation"
const val NUMBERLESS_BUTTON_CONFIRM = "numberless-button-confirm"
}
@@ -5,7 +5,6 @@
package org.signal.appsettings.deleteaccount
import androidx.annotation.VisibleForTesting
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -14,7 +13,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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
@@ -26,7 +24,6 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -43,7 +40,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.TextRange
@@ -51,9 +47,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import org.signal.appsettings.R
import org.signal.appsettings.deleteaccount.DeleteAccountState.Dialog
import org.signal.core.ui.compose.DayNightPreviews
@@ -62,24 +56,12 @@ import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.core.ui.compose.TextFields
import org.signal.core.ui.R as CoreUiR
@VisibleForTesting
object DeleteAccountTestTags {
const val SCROLLER = "scroller"
const val ROW_COUNTRY_PICKER = "row-country-picker"
const val FIELD_COUNTRY_CODE = "field-country-code"
const val FIELD_NUMBER = "field-number"
const val BUTTON_DELETE = "button-delete"
const val DIALOG_NUMBER_DOES_NOT_MATCH = "dialog-number-does-not-match"
const val DIALOG_CONFIRM_DELETION = "dialog-confirm-deletion"
const val DIALOG_DELETION_FAILED = "dialog-deletion-failed"
const val DIALOG_LOCAL_DATA_DELETION_FAILED = "dialog-local-data-deletion-failed"
const val DIALOG_PROGRESS = "dialog-progress"
}
/**
* Lets a user with a phone number delete their account, which they confirm by keying that number back in.
*/
@Composable
fun DeleteAccountScreen(
fun DeleteAccountWithNumberScreen(
state: DeleteAccountState,
onEvent: (DeleteAccountEvent) -> Unit,
modifier: Modifier = Modifier
@@ -159,6 +141,19 @@ fun DeleteAccountScreen(
}
DeleteAccountDialogs(dialog = state.dialog, onEvent = onEvent)
if (state.dialog == Dialog.ConfirmDeletion) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.DeleteAccountFragment__are_you_sure),
body = stringResource(R.string.DeleteAccountFragment__this_will_delete_your_signal_account),
confirm = stringResource(R.string.DeleteAccountFragment__delete_account),
dismiss = stringResource(android.R.string.cancel),
confirmColor = MaterialTheme.colorScheme.error,
onConfirm = { onEvent(DeleteAccountEvent.DeletionConfirmed) },
onDismiss = { onEvent(DeleteAccountEvent.DialogDismissed) },
modifier = Modifier.testTag(DeleteAccountTestTags.DIALOG_CONFIRM_DELETION)
)
}
}
}
@@ -298,143 +293,11 @@ private fun DeleteButton(
}
}
@Composable
private fun DeleteAccountDialogs(
dialog: Dialog,
onEvent: (DeleteAccountEvent) -> Unit
) {
when (dialog) {
Dialog.None -> Unit
Dialog.NumberDoesNotMatch -> {
Dialogs.SimpleMessageDialog(
message = stringResource(R.string.DeleteAccountFragment__the_phone_number),
dismiss = stringResource(android.R.string.ok),
onDismiss = { onEvent(DeleteAccountEvent.DialogDismissed) },
modifier = Modifier.testTag(DeleteAccountTestTags.DIALOG_NUMBER_DOES_NOT_MATCH)
)
}
Dialog.ConfirmDeletion -> {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.DeleteAccountFragment__are_you_sure),
body = stringResource(R.string.DeleteAccountFragment__this_will_delete_your_signal_account),
confirm = stringResource(R.string.DeleteAccountFragment__delete_account),
dismiss = stringResource(android.R.string.cancel),
confirmColor = MaterialTheme.colorScheme.error,
onConfirm = { onEvent(DeleteAccountEvent.DeletionConfirmed) },
onDismiss = { onEvent(DeleteAccountEvent.DialogDismissed) },
modifier = Modifier.testTag(DeleteAccountTestTags.DIALOG_CONFIRM_DELETION)
)
}
Dialog.DeletionFailed -> {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.DeleteAccountFragment__account_not_deleted),
body = stringResource(R.string.DeleteAccountFragment__there_was_a_problem),
confirm = stringResource(android.R.string.ok),
dismiss = stringResource(android.R.string.cancel),
onConfirm = { onEvent(DeleteAccountEvent.DeletionConfirmed) },
onDismiss = { onEvent(DeleteAccountEvent.DialogDismissed) },
modifier = Modifier.testTag(DeleteAccountTestTags.DIALOG_DELETION_FAILED)
)
}
Dialog.LocalDataDeletionFailed -> {
Dialogs.SimpleMessageDialog(
message = stringResource(R.string.DeleteAccountFragment__failed_to_delete_local_data),
dismiss = stringResource(R.string.DeleteAccountFragment__launch_app_settings),
onDismiss = { onEvent(DeleteAccountEvent.LaunchAppSettingsClicked) },
properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false),
modifier = Modifier.testTag(DeleteAccountTestTags.DIALOG_LOCAL_DATA_DELETION_FAILED)
)
}
Dialog.CancelingSubscription -> {
ProgressDialog(
title = stringResource(R.string.DeleteAccountFragment__deleting_account),
message = stringResource(R.string.DeleteAccountFragment__canceling_your_subscription),
progress = null
)
}
is Dialog.LeavingGroups -> {
ProgressDialog(
title = stringResource(R.string.DeleteAccountFragment__leaving_groups),
message = stringResource(R.string.DeleteAccountFragment__depending_on_the_number_of_groups),
progress = if (dialog.totalCount > 0) dialog.leaveCount.toFloat() / dialog.totalCount else null
)
}
Dialog.DeletingAccount -> {
ProgressDialog(
title = stringResource(R.string.DeleteAccountFragment__deleting_account),
message = stringResource(R.string.DeleteAccountFragment__deleting_all_user_data_and_resetting),
progress = null
)
}
}
}
/**
* Non-dismissable spinner shown for the length of the deletion, which reports what part of it is underway.
*/
@Composable
private fun ProgressDialog(
title: String,
message: String,
progress: Float?
) {
Dialogs.BaseAlertDialog(
onDismissRequest = {},
confirmButton = {},
properties = DialogProperties(dismissOnBackPress = false, dismissOnClickOutside = false),
text = {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
) {
Spacer(modifier = Modifier.height(24.dp))
if (progress == null) {
CircularProgressIndicator(modifier = Modifier.size(48.dp))
} else {
CircularProgressIndicator(progress = { progress }, modifier = Modifier.size(48.dp))
}
Spacer(modifier = Modifier.height(24.dp))
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(horizontal = dimensionResource(CoreUiR.dimen.gutter))
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = dimensionResource(CoreUiR.dimen.gutter))
)
Spacer(modifier = Modifier.height(24.dp))
}
},
modifier = Modifier.testTag(DeleteAccountTestTags.DIALOG_PROGRESS)
)
}
@DayNightPreviews
@Composable
private fun DeleteAccountScreenPreview() {
private fun DeleteAccountWithNumberScreenPreview() {
Previews.Preview {
DeleteAccountScreen(
DeleteAccountWithNumberScreen(
state = DeleteAccountState(),
onEvent = {}
)
@@ -443,9 +306,9 @@ private fun DeleteAccountScreenPreview() {
@DayNightPreviews
@Composable
private fun DeleteAccountScreenFilledPreview() {
private fun DeleteAccountWithNumberScreenFilledPreview() {
Previews.Preview {
DeleteAccountScreen(
DeleteAccountWithNumberScreen(
state = DeleteAccountState(
regionCode = "US",
countryDisplayName = "United States",
@@ -461,9 +324,9 @@ private fun DeleteAccountScreenFilledPreview() {
@DayNightPreviews
@Composable
private fun DeleteAccountScreenConfirmDeletionPreview() {
private fun DeleteAccountWithNumberScreenConfirmDeletionPreview() {
Previews.Preview {
DeleteAccountScreen(
DeleteAccountWithNumberScreen(
state = DeleteAccountState(dialog = Dialog.ConfirmDeletion),
onEvent = {}
)
@@ -472,9 +335,9 @@ private fun DeleteAccountScreenConfirmDeletionPreview() {
@DayNightPreviews
@Composable
private fun DeleteAccountScreenLeavingGroupsPreview() {
private fun DeleteAccountWithNumberScreenLeavingGroupsPreview() {
Previews.Preview {
DeleteAccountScreen(
DeleteAccountWithNumberScreen(
state = DeleteAccountState(dialog = Dialog.LeavingGroups(totalCount = 10, leaveCount = 3)),
onEvent = {}
)
@@ -483,9 +346,9 @@ private fun DeleteAccountScreenLeavingGroupsPreview() {
@DayNightPreviews
@Composable
private fun DeleteAccountScreenDeletionFailedPreview() {
private fun DeleteAccountWithNumberScreenDeletionFailedPreview() {
Previews.Preview {
DeleteAccountScreen(
DeleteAccountWithNumberScreen(
state = DeleteAccountState(dialog = Dialog.DeletionFailed),
onEvent = {}
)
@@ -0,0 +1,298 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.appsettings.deleteaccount
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import org.signal.appsettings.R
import org.signal.appsettings.deleteaccount.DeleteAccountState.Dialog
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
/**
* Lets a user with no phone number delete their account. There's no number for them to key in, so they confirm by
* ticking a box in the dialog instead. Their username is worked into the copy when they have one, since it goes away
* with the account.
*/
@Composable
fun DeleteAccountWithoutNumberScreen(
state: DeleteAccountState,
onEvent: (DeleteAccountEvent) -> Unit,
modifier: Modifier = Modifier
) {
Scaffolds.Settings(
title = "",
onNavigationClick = { onEvent(DeleteAccountEvent.NavigateBackClicked) },
navigationIcon = SignalIcons.ArrowStart.imageVector,
modifier = modifier
) { contentPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(contentPadding)
) {
Column(
modifier = Modifier
.weight(1f)
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp)
.testTag(DeleteAccountTestTags.NUMBERLESS_SCROLLER)
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_delete_account_warning_40),
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier
.padding(top = 24.dp)
.size(40.dp)
)
Text(
text = stringResource(R.string.DeleteAccountFragment__delete_account),
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(top = 16.dp)
)
Text(
text = if (state.username != null) {
stringResource(R.string.DeleteAccountScreen__deleting_your_account_with_username_s_will, state.username)
} else {
stringResource(R.string.DeleteAccountFragment__deleting_your_account_will)
},
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 12.dp)
)
Bullets(
username = state.username,
walletBalance = state.walletBalance,
modifier = Modifier.padding(top = 16.dp)
)
Spacer(modifier = Modifier.height(24.dp))
}
Buttons.LargePrimary(
onClick = { onEvent(DeleteAccountEvent.DeleteAccountClicked) },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError
),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 40.dp)
.padding(top = 16.dp, bottom = 32.dp)
.testTag(DeleteAccountTestTags.NUMBERLESS_BUTTON_DELETE)
) {
Text(text = stringResource(R.string.DeleteAccountFragment__delete_account))
}
}
DeleteAccountDialogs(dialog = state.dialog, onEvent = onEvent)
val dialog = state.dialog
if (dialog is Dialog.ConfirmNumberlessDeletion) {
ConfirmDeletionDialog(
confirmationChecked = dialog.confirmationChecked,
username = state.username,
onEvent = onEvent
)
}
}
}
@Composable
private fun Bullets(
username: String?,
walletBalance: String?,
modifier: Modifier = Modifier
) {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier
) {
Bullet(
text = if (username != null) {
stringResource(R.string.DeleteAccountScreen__delete_your_account_info_and_profile_photo_including_your_username_s, username)
} else {
stringResource(R.string.DeleteAccountFragment__delete_your_account_info_and_profile_photo)
}
)
Bullet(text = stringResource(R.string.DeleteAccountFragment__delete_all_your_messages))
if (walletBalance != null) {
Bullet(text = stringResource(R.string.DeleteAccountFragment__delete_s_in_your_payments_account, walletBalance))
}
Bullet(text = stringResource(R.string.DeleteAccountScreen__this_action_can_not_be_undone))
}
}
@Composable
private fun Bullet(text: String) {
Row {
Text(
text = "",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = text,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
/**
* Asks the user to confirm the deletion, which they can only go ahead with once they've ticked the box.
*/
@Composable
private fun ConfirmDeletionDialog(
confirmationChecked: Boolean,
username: String?,
onEvent: (DeleteAccountEvent) -> Unit
) {
Dialogs.BaseAlertDialog(
onDismissRequest = { onEvent(DeleteAccountEvent.DialogDismissed) },
title = { Text(text = stringResource(R.string.DeleteAccountFragment__are_you_sure)) },
text = {
Column {
Text(
text = if (username != null) {
stringResource(R.string.DeleteAccountScreen__this_will_delete_your_signal_account_with_username_s, username)
} else {
stringResource(R.string.DeleteAccountFragment__this_will_delete_your_signal_account)
}
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(top = 16.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.toggleable(
value = confirmationChecked,
role = Role.Checkbox,
onValueChange = { onEvent(DeleteAccountEvent.ConfirmationCheckedChanged(it)) }
)
.padding(vertical = 8.dp)
.testTag(DeleteAccountTestTags.NUMBERLESS_ROW_CONFIRMATION)
) {
Checkbox(
checked = confirmationChecked,
onCheckedChange = null
)
Text(
text = stringResource(R.string.DeleteAccountScreen__yes_delete_my_account),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(start = 16.dp)
)
}
}
},
confirmButton = {
TextButton(
enabled = confirmationChecked,
onClick = { onEvent(DeleteAccountEvent.DeletionConfirmed) },
modifier = Modifier.testTag(DeleteAccountTestTags.NUMBERLESS_BUTTON_CONFIRM)
) {
Text(text = stringResource(R.string.DeleteAccountFragment__delete_account))
}
},
dismissButton = {
TextButton(onClick = { onEvent(DeleteAccountEvent.DialogDismissed) }) {
Text(text = stringResource(android.R.string.cancel))
}
},
modifier = Modifier.testTag(DeleteAccountTestTags.NUMBERLESS_DIALOG_CONFIRM_DELETION)
)
}
@DayNightPreviews
@Composable
private fun DeleteAccountWithoutNumberScreenPreview() {
Previews.Preview {
DeleteAccountWithoutNumberScreen(
state = DeleteAccountState(hasPhoneNumber = false),
onEvent = {}
)
}
}
@DayNightPreviews
@Composable
private fun DeleteAccountWithoutNumberScreenUsernamePreview() {
Previews.Preview {
DeleteAccountWithoutNumberScreen(
state = DeleteAccountState(hasPhoneNumber = false, username = "alice.01"),
onEvent = {}
)
}
}
@DayNightPreviews
@Composable
private fun DeleteAccountWithoutNumberScreenConfirmDeletionPreview() {
Previews.Preview {
DeleteAccountWithoutNumberScreen(
state = DeleteAccountState(hasPhoneNumber = false, dialog = Dialog.ConfirmNumberlessDeletion(confirmationChecked = true)),
onEvent = {}
)
}
}
@DayNightPreviews
@Composable
private fun DeleteAccountWithoutNumberScreenConfirmDeletionUsernamePreview() {
Previews.Preview {
DeleteAccountWithoutNumberScreen(
state = DeleteAccountState(hasPhoneNumber = false, username = "alice.01", dialog = Dialog.ConfirmNumberlessDeletion()),
onEvent = {}
)
}
}
@@ -201,6 +201,16 @@
<string name="DeleteAccountFragment__account_not_deleted">Account Not Deleted</string>
<!-- Message of error dialog shown when a network error occurs during account deletion -->
<string name="DeleteAccountFragment__there_was_a_problem">There was a problem completing the deletion process. Check your network connection and try again.</string>
<!-- Body text above the bullet list on the delete account screen, shown when the account has a username. Placeholder is the username. -->
<string name="DeleteAccountScreen__deleting_your_account_with_username_s_will">Deleting your account with username \"%1$s\" will:</string>
<!-- Bullet point on the delete account screen, shown in place of the plain one when the account has a username. Placeholder is the username. -->
<string name="DeleteAccountScreen__delete_your_account_info_and_profile_photo_including_your_username_s">Delete your account info and profile photo including your username \"%1$s\"</string>
<!-- Bullet point on the delete account screen warning the user that the deletion is permanent -->
<string name="DeleteAccountScreen__this_action_can_not_be_undone">This action can not be undone</string>
<!-- Message of the deletion confirmation dialog, shown when the account has a username. Placeholder is the username. -->
<string name="DeleteAccountScreen__this_will_delete_your_signal_account_with_username_s">This will delete your Signal account with username \"%1$s\" and reset the application. The app will close after the process is complete.</string>
<!-- Label of the checkbox the user has to tick in the confirmation dialog before they can delete their account -->
<string name="DeleteAccountScreen__yes_delete_my_account">Yes, delete my account</string>
<!-- Shared with the registration screens -->
<string name="RegistrationActivity_select_your_country">Select your country</string>
@@ -29,7 +29,7 @@ import org.signal.appsettings.deleteaccount.DeleteAccountState.Dialog
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
class DeleteAccountScreenTest {
class DeleteAccountWithNumberScreenTest {
private val context: Application = RuntimeEnvironment.getApplication()
@@ -119,7 +119,7 @@ class DeleteAccountScreenTest {
private fun setContent(state: DeleteAccountState) {
composeTestRule.setContent {
DeleteAccountScreen(
DeleteAccountWithNumberScreen(
state = state,
onEvent = { events += it }
)
@@ -0,0 +1,149 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.appsettings.deleteaccount
import android.app.Application
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import assertk.assertThat
import assertk.assertions.contains
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import org.signal.appsettings.R
import org.signal.appsettings.deleteaccount.DeleteAccountState.Dialog
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
class DeleteAccountWithoutNumberScreenTest {
private val context: Application = RuntimeEnvironment.getApplication()
@get:Rule
val composeTestRule = createComposeRule()
private val events = mutableListOf<DeleteAccountEvent>()
@Test
fun givenTheScreen_whenIClickDelete_thenIExpectDeleteAccountClickedEvent() {
setContent(createState())
composeTestRule.onNodeWithTag(DeleteAccountTestTags.NUMBERLESS_BUTTON_DELETE).performClick()
assertThat(events).contains(DeleteAccountEvent.DeleteAccountClicked)
}
@Test
fun givenNoUsername_whenTheScreenIsShown_thenIExpectThePlainCopy() {
setContent(createState())
composeTestRule.onNodeWithText(context.getString(R.string.DeleteAccountFragment__deleting_your_account_will)).assertIsDisplayed()
composeTestRule.onNodeWithText(context.getString(R.string.DeleteAccountFragment__delete_your_account_info_and_profile_photo)).assertIsDisplayed()
}
@Test
fun givenAUsername_whenTheScreenIsShown_thenIExpectItInTheCopy() {
setContent(createState(username = "alice.01"))
composeTestRule.onNodeWithText(context.getString(R.string.DeleteAccountScreen__deleting_your_account_with_username_s_will, "alice.01")).assertIsDisplayed()
composeTestRule.onNodeWithText(context.getString(R.string.DeleteAccountScreen__delete_your_account_info_and_profile_photo_including_your_username_s, "alice.01")).assertIsDisplayed()
}
@Test
fun givenAWalletBalance_whenTheScreenIsShown_thenIExpectAPaymentsBullet() {
setContent(createState(walletBalance = "0.1000 MOB"))
composeTestRule.onNodeWithText(context.getString(R.string.DeleteAccountFragment__delete_s_in_your_payments_account, "0.1000 MOB")).assertIsDisplayed()
}
@Test
fun givenAnUncheckedConfirmationDialog_whenTheScreenIsShown_thenIExpectDeletionToBeBlocked() {
setContent(createState(dialog = Dialog.ConfirmNumberlessDeletion()))
composeTestRule.onNodeWithTag(DeleteAccountTestTags.NUMBERLESS_BUTTON_CONFIRM).assertIsNotEnabled()
}
@Test
fun givenAnUncheckedConfirmationDialog_whenIClickTheCheckbox_thenIExpectConfirmationCheckedChangedEvent() {
setContent(createState(dialog = Dialog.ConfirmNumberlessDeletion()))
composeTestRule.onNodeWithTag(DeleteAccountTestTags.NUMBERLESS_ROW_CONFIRMATION).performClick()
assertThat(events).contains(DeleteAccountEvent.ConfirmationCheckedChanged(true))
}
@Test
fun givenACheckedConfirmationDialog_whenIConfirm_thenIExpectDeletionConfirmedEvent() {
setContent(createState(dialog = Dialog.ConfirmNumberlessDeletion(confirmationChecked = true)))
composeTestRule.onNodeWithTag(DeleteAccountTestTags.NUMBERLESS_BUTTON_CONFIRM).assertIsEnabled().performClick()
assertThat(events).contains(DeleteAccountEvent.DeletionConfirmed)
}
@Test
fun givenACheckedConfirmationDialog_whenIUncheckTheCheckbox_thenIExpectConfirmationCheckedChangedEvent() {
setContent(createState(dialog = Dialog.ConfirmNumberlessDeletion(confirmationChecked = true)))
composeTestRule.onNodeWithTag(DeleteAccountTestTags.NUMBERLESS_ROW_CONFIRMATION).performClick()
assertThat(events).contains(DeleteAccountEvent.ConfirmationCheckedChanged(false))
}
@Test
fun givenAUsername_whenTheConfirmationDialogIsShown_thenIExpectItInTheMessage() {
setContent(createState(username = "alice.01", dialog = Dialog.ConfirmNumberlessDeletion()))
composeTestRule.onNodeWithText(context.getString(R.string.DeleteAccountScreen__this_will_delete_your_signal_account_with_username_s, "alice.01")).assertIsDisplayed()
}
@Test
fun givenGroupsAreBeingLeft_whenTheScreenIsShown_thenIExpectTheProgressDialog() {
setContent(createState(dialog = Dialog.LeavingGroups(totalCount = 10, leaveCount = 3)))
composeTestRule.onNodeWithTag(DeleteAccountTestTags.DIALOG_PROGRESS).assertIsDisplayed()
composeTestRule.onNodeWithText(context.getString(R.string.DeleteAccountFragment__leaving_groups)).assertIsDisplayed()
}
@Test
fun givenTheLocalDataFailureDialog_whenIClickLaunchAppSettings_thenIExpectLaunchAppSettingsClickedEvent() {
setContent(createState(dialog = Dialog.LocalDataDeletionFailed))
composeTestRule.onNodeWithText(context.getString(R.string.DeleteAccountFragment__launch_app_settings)).performClick()
assertThat(events).contains(DeleteAccountEvent.LaunchAppSettingsClicked)
}
private fun setContent(state: DeleteAccountState) {
composeTestRule.setContent {
DeleteAccountWithoutNumberScreen(
state = state,
onEvent = { events += it }
)
}
}
private fun createState(
username: String? = null,
walletBalance: String? = null,
dialog: Dialog = Dialog.None
): DeleteAccountState {
return DeleteAccountState(
hasPhoneNumber = false,
username = username,
walletBalance = walletBalance,
dialog = dialog
)
}
}