Refactor account deletion screen.

This commit is contained in:
Greyson Parrelli
2026-09-18 12:38:24 -04:00
parent 16e529de4e
commit dcb6db177f
21 changed files with 1582 additions and 953 deletions
@@ -1,57 +0,0 @@
package org.thoughtcrime.securesms.delete
/**
* Account deletion event.
*
* @param type Specifies what type of event this is. Each type maps to a single class. This exists in order to facilitate
* legacy Java switch statement.
*/
sealed class DeleteAccountEvent(val type: Type) {
object NoCountryCode : DeleteAccountEvent(Type.NO_COUNTRY_CODE)
object NoNationalNumber : DeleteAccountEvent(Type.NO_NATIONAL_NUMBER)
object NotAMatch : DeleteAccountEvent(Type.NOT_A_MATCH)
object ConfirmDeletion : DeleteAccountEvent(Type.CONFIRM_DELETION)
object PinDeletionFailed : DeleteAccountEvent(Type.PIN_DELETION_FAILED)
object CancelSubscriptionFailed : DeleteAccountEvent(Type.CANCEL_SUBSCRIPTION_FAILED)
object LeaveGroupsFailed : DeleteAccountEvent(Type.LEAVE_GROUPS_FAILED)
object ServerDeletionFailed : DeleteAccountEvent(Type.SERVER_DELETION_FAILED)
object LocalDataDeletionFailed : DeleteAccountEvent(Type.LOCAL_DATA_DELETION_FAILED)
object LeaveGroupsFinished : DeleteAccountEvent(Type.LEAVE_GROUPS_FINISHED)
object CancelingSubscription : DeleteAccountEvent(Type.CANCELING_SUBSCRIPTION)
/**
* Progress update for leaving groups
*
* @param totalCount The total number of groups we are attempting to leave
* @param leaveCount The number of groups we have left so far
*/
data class LeaveGroupsProgress(
val totalCount: Int,
val leaveCount: Int
) : DeleteAccountEvent(Type.LEAVE_GROUPS_PROGRESS)
enum class Type {
NO_COUNTRY_CODE,
NO_NATIONAL_NUMBER,
NOT_A_MATCH,
CONFIRM_DELETION,
LEAVE_GROUPS_FAILED,
PIN_DELETION_FAILED,
CANCELING_SUBSCRIPTION,
CANCEL_SUBSCRIPTION_FAILED,
SERVER_DELETION_FAILED,
LOCAL_DATA_DELETION_FAILED,
LEAVE_GROUPS_PROGRESS,
LEAVE_GROUPS_FINISHED
}
}
@@ -1,297 +0,0 @@
package org.thoughtcrime.securesms.delete;
import android.content.DialogInterface;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.provider.Settings;
import android.text.Editable;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.inputmethod.EditorInfo;
import android.widget.EditText;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.widget.Toolbar;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.Navigation;
import androidx.navigation.fragment.NavHostFragment;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.google.android.material.snackbar.Snackbar;
import com.google.i18n.phonenumbers.AsYouTypeFormatter;
import com.google.i18n.phonenumbers.PhoneNumberUtil;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.LabeledEditText;
import org.thoughtcrime.securesms.registration.ui.countrycode.Country;
import org.thoughtcrime.securesms.util.SpanUtil;
import org.thoughtcrime.securesms.util.SystemWindowInsetsSetter;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.navigation.SafeNavigation;
import org.thoughtcrime.securesms.util.text.AfterTextChanged;
import java.util.Optional;
public class DeleteAccountFragment extends Fragment {
private TextView countryPicker;
private TextView bullets;
private LabeledEditText countryCode;
private LabeledEditText number;
private AsYouTypeFormatter countryFormatter;
private DeleteAccountViewModel viewModel;
private DeleteAccountProgressDialog deletionProgressDialog;
@Override
public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
return inflater.inflate(R.layout.delete_account_fragment, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
View confirm = view.findViewById(R.id.delete_account_fragment_delete);
Toolbar toolbar = view.findViewById(R.id.toolbar);
SystemWindowInsetsSetter.attach(view, getViewLifecycleOwner(), SystemWindowInsetsSetter.SAFE_AREA_WITH_KEYBOARD);
bullets = view.findViewById(R.id.delete_account_fragment_bullets);
countryCode = view.findViewById(R.id.delete_account_fragment_country_code);
number = view.findViewById(R.id.delete_account_fragment_number);
countryPicker = view.findViewById(R.id.delete_account_fragment_country_picker);
viewModel = new ViewModelProvider(requireActivity(), new DeleteAccountViewModel.Factory(new DeleteAccountRepository())).get(DeleteAccountViewModel.class);
viewModel.getCountryDisplayName().observe(getViewLifecycleOwner(), this::setCountryDisplay);
viewModel.getRegionCode().observe(getViewLifecycleOwner(), this::handleRegionUpdated);
viewModel.getEvents().observe(getViewLifecycleOwner(), this::handleEvent);
viewModel.getWalletBalance().observe(getViewLifecycleOwner(), this::updateBullets);
initializeNumberInput();
countryCode.getInput().addTextChangedListener(new AfterTextChanged(this::afterCountryCodeChanged));
countryCode.getInput().setImeOptions(EditorInfo.IME_ACTION_NEXT);
confirm.setOnClickListener(unused -> viewModel.submit());
toolbar.setNavigationOnClickListener(v -> Navigation.findNavController(v).popBackStack());
countryPicker.setOnClickListener(v -> SafeNavigation.safeNavigate(NavHostFragment.findNavController(this), R.id.action_deleteAccountFragment_to_deleteAccountCountryFragment));
getParentFragmentManager().setFragmentResultListener(DeleteAccountCountryCodeFragment.RESULT_KEY, this, (key, bundle) -> {
Country country = bundle.getParcelable(DeleteAccountCountryCodeFragment.RESULT_COUNTRY);
if (country != null) {
viewModel.onRegionSelected(country.getRegionCode());
}
});
}
private void updateBullets(@NonNull Optional<String> formattedBalance) {
bullets.setText(buildBulletsText(formattedBalance));
}
private @NonNull CharSequence buildBulletsText(@NonNull Optional<String> formattedBalance) {
SpannableStringBuilder builder = new SpannableStringBuilder().append(SpanUtil.bullet(getString(R.string.DeleteAccountFragment__delete_your_account_info_and_profile_photo),8))
.append("\n")
.append(SpanUtil.bullet(getString(R.string.DeleteAccountFragment__delete_all_your_messages),8));
if (formattedBalance.isPresent()) {
builder.append("\n");
builder.append(SpanUtil.bullet(getString(R.string.DeleteAccountFragment__delete_s_in_your_payments_account, formattedBalance.get()),8));
}
return builder;
}
private void setCountryDisplay(@NonNull String regionDisplayName) {
if (TextUtils.isEmpty(regionDisplayName)) {
countryPicker.setText(requireContext().getString(R.string.RegistrationActivity_select_your_country));
} else {
countryPicker.setText(regionDisplayName);
}
}
private void handleRegionUpdated(@Nullable String regionCode) {
PhoneNumberUtil util = PhoneNumberUtil.getInstance();
countryFormatter = regionCode != null ? util.getAsYouTypeFormatter(regionCode) : null;
reformatText(number.getText());
if (!TextUtils.isEmpty(regionCode) && !"ZZ".equals(regionCode)) {
number.requestFocus();
int numberLength = number.getText().length();
number.getInput().setSelection(numberLength, numberLength);
countryCode.setText(String.valueOf(util.getCountryCodeForRegion(regionCode)));
}
}
private Long reformatText(Editable s) {
if (countryFormatter == null) {
return null;
}
if (TextUtils.isEmpty(s)) {
return null;
}
countryFormatter.clear();
String formattedNumber = null;
StringBuilder justDigits = new StringBuilder();
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (Character.isDigit(c)) {
formattedNumber = countryFormatter.inputDigit(c);
justDigits.append(c);
}
}
if (formattedNumber != null && !s.toString().equals(formattedNumber)) {
s.replace(0, s.length(), formattedNumber);
}
if (justDigits.length() == 0) {
return null;
}
return Long.parseLong(justDigits.toString());
}
private void initializeNumberInput() {
EditText numberInput = number.getInput();
Long nationalNumber = viewModel.getNationalNumber();
if (nationalNumber != null) {
number.setText(String.valueOf(nationalNumber));
} else {
number.setText("");
}
numberInput.addTextChangedListener(new AfterTextChanged(this::afterNumberChanged));
numberInput.setImeOptions(EditorInfo.IME_ACTION_DONE);
numberInput.setOnEditorActionListener((v, actionId, event) -> {
if (actionId == EditorInfo.IME_ACTION_DONE) {
ViewUtil.hideKeyboard(requireContext(), v);
viewModel.submit();
return true;
}
return false;
});
}
private void afterCountryCodeChanged(@Nullable Editable s) {
if (TextUtils.isEmpty(s) || !TextUtils.isDigitsOnly(s)) {
viewModel.onCountrySelected(0);
return;
}
viewModel.onCountrySelected(Integer.parseInt(s.toString()));
}
private void afterNumberChanged(@Nullable Editable s) {
Long number = reformatText(s);
if (number == null) return;
viewModel.setNationalNumber(number);
}
private void handleEvent(@NonNull DeleteAccountEvent deleteAccountEvent) {
switch (deleteAccountEvent.getType()) {
case NO_COUNTRY_CODE:
Snackbar.make(requireView(), R.string.DeleteAccountFragment__no_country_code, Snackbar.LENGTH_SHORT).show();
break;
case NO_NATIONAL_NUMBER:
Snackbar.make(requireView(), R.string.DeleteAccountFragment__no_number, Snackbar.LENGTH_SHORT).show();
break;
case NOT_A_MATCH:
new MaterialAlertDialogBuilder(requireContext())
.setMessage(R.string.DeleteAccountFragment__the_phone_number)
.setPositiveButton(android.R.string.ok, (dialog, which) -> dialog.dismiss())
.setCancelable(true)
.show();
break;
case CONFIRM_DELETION:
new MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.DeleteAccountFragment__are_you_sure)
.setMessage(R.string.DeleteAccountFragment__this_will_delete_your_signal_account)
.setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.dismiss())
.setPositiveButton(R.string.DeleteAccountFragment__delete_account, this::handleDeleteAccountConfirmation)
.setCancelable(true)
.show();
break;
case LEAVE_GROUPS_FAILED:
case PIN_DELETION_FAILED:
case SERVER_DELETION_FAILED:
case CANCEL_SUBSCRIPTION_FAILED:
dismissDeletionProgressDialog();
showNetworkDeletionFailedDialog();
break;
case LOCAL_DATA_DELETION_FAILED:
dismissDeletionProgressDialog();
showLocalDataDeletionFailedDialog();
break;
case LEAVE_GROUPS_PROGRESS:
ensureDeletionProgressDialog();
deletionProgressDialog.presentLeavingGroups((DeleteAccountEvent.LeaveGroupsProgress) deleteAccountEvent);
break;
case LEAVE_GROUPS_FINISHED:
ensureDeletionProgressDialog();
deletionProgressDialog.presentDeletingAccount();
break;
case CANCELING_SUBSCRIPTION:
ensureDeletionProgressDialog();
deletionProgressDialog.presentCancelingSubscription();
break;
default:
throw new IllegalStateException("Unknown error type: " + deleteAccountEvent);
}
}
private void dismissDeletionProgressDialog() {
if (deletionProgressDialog != null) {
deletionProgressDialog.dismiss();
deletionProgressDialog = null;
}
}
private void showNetworkDeletionFailedDialog() {
new MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.DeleteAccountFragment__account_not_deleted)
.setMessage(R.string.DeleteAccountFragment__there_was_a_problem)
.setPositiveButton(android.R.string.ok, this::handleDeleteAccountConfirmation)
.setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.dismiss())
.setCancelable(true)
.show();
}
private void showLocalDataDeletionFailedDialog() {
new MaterialAlertDialogBuilder(requireContext())
.setMessage(R.string.DeleteAccountFragment__failed_to_delete_local_data)
.setPositiveButton(R.string.DeleteAccountFragment__launch_app_settings, (dialog, which) -> {
Intent settingsIntent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
settingsIntent.setData(Uri.fromParts("package", requireActivity().getPackageName(), null));
startActivity(settingsIntent);
})
.setCancelable(false)
.show();
}
private void handleDeleteAccountConfirmation(DialogInterface dialog, int which) {
dialog.dismiss();
ensureDeletionProgressDialog();
viewModel.deleteAccount();
}
private void ensureDeletionProgressDialog() {
if (deletionProgressDialog == null) {
deletionProgressDialog = DeleteAccountProgressDialog.show(requireContext());
}
}
}
@@ -0,0 +1,78 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.delete
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.provider.Settings
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.core.ui.compose.CollectActions
import org.signal.core.ui.compose.ComposeFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.registration.ui.countrycode.Country
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.
*/
class DeleteAccountFragment : ComposeFragment() {
private val viewModel: DeleteAccountViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
parentFragmentManager.setFragmentResultListener(DeleteAccountCountryCodeFragment.RESULT_KEY, this) { _, bundle ->
val country: Country? = bundle.getParcelable(DeleteAccountCountryCodeFragment.RESULT_COUNTRY)
if (country != null) {
viewModel.onEvent(DeleteAccountEvent.CountrySelected(country.regionCode))
}
}
}
@Composable
override fun FragmentContent() {
val state by viewModel.state.collectAsStateWithLifecycle()
CollectActions(viewModel.actions) { action -> handleAction(action) }
DeleteAccountScreen(
state = state,
onEvent = viewModel::onEvent
)
}
private fun handleAction(action: DeleteAccountAction) {
when (action) {
DeleteAccountAction.NavigateBack -> findNavController().popBackStack()
DeleteAccountAction.NavigateToCountryPicker -> findNavController().safeNavigate(R.id.action_deleteAccountFragment_to_deleteAccountCountryFragment)
DeleteAccountAction.ShowNoCountryCode -> snackbar(AppSettingsR.string.DeleteAccountFragment__no_country_code)
DeleteAccountAction.ShowNoNationalNumber -> snackbar(AppSettingsR.string.DeleteAccountFragment__no_number)
DeleteAccountAction.LaunchAppSettings -> {
startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", requireActivity().packageName, null)
}
)
}
}
}
private fun snackbar(@StringRes message: Int) {
Snackbar.make(requireView(), message, Snackbar.LENGTH_SHORT).show()
}
}
@@ -1,56 +0,0 @@
package org.thoughtcrime.securesms.delete
import android.content.Context
import android.widget.ProgressBar
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.thoughtcrime.securesms.R
/**
* Dialog which shows one of two states:
*
* 1. A "Leaving Groups" state with a determinate progress bar which updates as we leave groups
* 1. A "Deleting Account" state with an indeterminate progress bar
*/
class DeleteAccountProgressDialog private constructor(private val alertDialog: AlertDialog) {
val title: TextView = alertDialog.findViewById(R.id.delete_account_progress_dialog_title)!!
val message: TextView = alertDialog.findViewById(R.id.delete_account_progress_dialog_message)!!
val progressBar: ProgressBar = alertDialog.findViewById(R.id.delete_account_progress_dialog_spinner)!!
fun presentCancelingSubscription() {
title.setText(R.string.DeleteAccountFragment__deleting_account)
message.setText(R.string.DeleteAccountFragment__canceling_your_subscription)
progressBar.isIndeterminate = true
}
fun presentLeavingGroups(leaveGroupsProgress: DeleteAccountEvent.LeaveGroupsProgress) {
title.setText(R.string.DeleteAccountFragment__leaving_groups)
message.setText(R.string.DeleteAccountFragment__depending_on_the_number_of_groups)
progressBar.isIndeterminate = false
progressBar.max = leaveGroupsProgress.totalCount
progressBar.progress = leaveGroupsProgress.leaveCount
}
fun presentDeletingAccount() {
title.setText(R.string.DeleteAccountFragment__deleting_account)
message.setText(R.string.DeleteAccountFragment__deleting_all_user_data_and_resetting)
progressBar.isIndeterminate = true
}
fun dismiss() {
alertDialog.dismiss()
}
companion object {
@JvmStatic
fun show(context: Context): DeleteAccountProgressDialog {
return DeleteAccountProgressDialog(
MaterialAlertDialogBuilder(context)
.setView(R.layout.delete_account_progress_dialog)
.show()
)
}
}
}
@@ -1,130 +0,0 @@
package org.thoughtcrime.securesms.delete;
import androidx.annotation.NonNull;
import androidx.core.util.Consumer;
import com.google.i18n.phonenumbers.PhoneNumberUtil;
import org.signal.core.util.E164Util;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository;
import org.thoughtcrime.securesms.database.GroupTable;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.GroupRecord;
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.groups.GroupChangeBusyException;
import org.thoughtcrime.securesms.groups.GroupChangeFailedException;
import org.thoughtcrime.securesms.groups.GroupManager;
import org.thoughtcrime.securesms.net.SignalNetwork;
import org.signal.core.util.ServiceUtil;
import org.whispersystems.signalservice.api.NetworkResultUtil;
import org.signal.network.exceptions.NonSuccessfulResponseCodeException;
import org.whispersystems.signalservice.internal.EmptyResponse;
import org.whispersystems.signalservice.internal.ServiceResponse;
import java.io.IOException;
class DeleteAccountRepository {
private static final String TAG = Log.tag(DeleteAccountRepository.class);
@NonNull String getRegionDisplayName(@NonNull String region) {
return E164Util.getRegionDisplayName(region).orElse("");
}
int getRegionCountryCode(@NonNull String region) {
return PhoneNumberUtil.getInstance().getCountryCodeForRegion(region);
}
void deleteAccount(@NonNull Consumer<DeleteAccountEvent> onDeleteAccountEvent) {
SignalExecutors.BOUNDED.execute(() -> {
if (InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION) != null) {
Log.i(TAG, "deleteAccount: attempting to cancel subscription");
onDeleteAccountEvent.accept(DeleteAccountEvent.CancelingSubscription.INSTANCE);
InAppPaymentSubscriberRecord subscriber = InAppPaymentsRepository.requireSubscriber(InAppPaymentSubscriberRecord.Type.DONATION);
ServiceResponse<EmptyResponse> cancelSubscriptionResponse = AppDependencies.getDonationsService()
.cancelSubscription(subscriber.getSubscriberId());
if (cancelSubscriptionResponse.getExecutionError().isPresent()) {
Log.w(TAG, "deleteAccount: failed attempt to cancel subscription");
onDeleteAccountEvent.accept(DeleteAccountEvent.CancelSubscriptionFailed.INSTANCE);
return;
}
switch (cancelSubscriptionResponse.getStatus()) {
case 404:
Log.i(TAG, "deleteAccount: subscription does not exist. Continuing deletion...");
break;
case 200:
Log.i(TAG, "deleteAccount: successfully cancelled subscription. Continuing deletion...");
break;
default:
Log.w(TAG, "deleteAccount: an unexpected error occurred. " + cancelSubscriptionResponse.getStatus());
onDeleteAccountEvent.accept(DeleteAccountEvent.CancelSubscriptionFailed.INSTANCE);
return;
}
}
Log.i(TAG, "deleteAccount: attempting to leave groups...");
int groupsProcessed = 0;
int groupsFailed = 0;
try (GroupTable.Reader groups = SignalDatabase.groups().getGroups()) {
GroupRecord groupRecord = groups.getNext();
onDeleteAccountEvent.accept(new DeleteAccountEvent.LeaveGroupsProgress(groups.getCount(), 0));
Log.i(TAG, "deleteAccount: found " + groups.getCount() + " groups to leave.");
while (groupRecord != null) {
if (groupRecord.getId().isPush() && groupRecord.isActive()) {
if (!groupRecord.isV1Group()) {
try {
GroupManager.leaveGroup(AppDependencies.getApplication(), groupRecord.getId().requirePush(), true);
} catch (IOException | GroupChangeBusyException | GroupChangeFailedException e) {
groupsFailed++;
Log.w(TAG, "deleteAccount: failed to leave a group, continuing with the rest.", e);
}
}
onDeleteAccountEvent.accept(new DeleteAccountEvent.LeaveGroupsProgress(groups.getCount(), ++groupsProcessed));
}
groupRecord = groups.getNext();
}
onDeleteAccountEvent.accept(DeleteAccountEvent.LeaveGroupsFinished.INSTANCE);
} catch (Exception e) {
Log.w(TAG, "deleteAccount: failed to leave groups", e);
onDeleteAccountEvent.accept(DeleteAccountEvent.LeaveGroupsFailed.INSTANCE);
return;
}
if (groupsFailed > 0) {
Log.w(TAG, "deleteAccount: failed to leave " + groupsFailed + " group(s). Continuing with deletion anyway.");
} else {
Log.i(TAG, "deleteAccount: successfully left all groups.");
}
Log.i(TAG, "deleteAccount: attempting to delete account from server...");
try {
NetworkResultUtil.toBasicLegacy(SignalNetwork.accountApi().deleteAccount());
} catch (IOException e) {
if (e instanceof NonSuccessfulResponseCodeException && ((NonSuccessfulResponseCodeException) e).code == 4401) {
Log.i(TAG, "deleteAccount: WebSocket closed with expected status after delete account, moving forward as delete was successful");
} else {
Log.w(TAG, "deleteAccount: failed to delete account from signal service, bail", e);
onDeleteAccountEvent.accept(DeleteAccountEvent.ServerDeletionFailed.INSTANCE);
return;
}
}
Log.i(TAG, "deleteAccount: successfully removed account from server");
Log.i(TAG, "deleteAccount: attempting to delete user data and close process...");
if (!ServiceUtil.getActivityManager(AppDependencies.getApplication()).clearApplicationUserData()) {
Log.w(TAG, "deleteAccount: failed to delete user data");
onDeleteAccountEvent.accept(DeleteAccountEvent.LocalDataDeletionFailed.INSTANCE);
}
});
}
}
@@ -0,0 +1,216 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.delete
import com.google.i18n.phonenumbers.PhoneNumberUtil
import com.google.i18n.phonenumbers.Phonenumber
import kotlinx.coroutines.withContext
import org.signal.core.util.E164Util
import org.signal.core.util.ServiceUtil
import org.signal.core.util.concurrent.SignalDispatchers
import org.signal.core.util.logging.Log
import org.signal.network.exceptions.NonSuccessfulResponseCodeException
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.groups.GroupChangeBusyException
import org.thoughtcrime.securesms.groups.GroupChangeFailedException
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.GroupManager
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.recipients.Recipient
import org.whispersystems.signalservice.api.NetworkResultUtil
import org.whispersystems.signalservice.api.payments.FormatterOptions
import java.io.IOException
/**
* All of the storage and network access behind [DeleteAccountViewModel].
*/
class DeleteAccountRepository {
companion object {
private val TAG = Log.tag(DeleteAccountRepository::class)
/** The WebSocket closes with this status once the account is gone, which is a success rather than a failure. */
private const val ACCOUNT_DELETED_WEBSOCKET_CLOSE_CODE = 4401
}
fun getRegionDisplayName(region: String): String = E164Util.getRegionDisplayName(region).orElse("")
fun getRegionCountryCode(region: String): Int = PhoneNumberUtil.getInstance().getCountryCodeForRegion(region)
/** 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
return if (amount.isPositive) amount.toString(FormatterOptions.defaults()) else null
}
/** Whether the given number is the one this account is registered to. */
fun isNumberMatch(countryCode: Int, nationalNumber: Long): Boolean {
val number = Phonenumber.PhoneNumber().apply {
setCountryCode(countryCode)
setNationalNumber(nationalNumber)
}
return when (PhoneNumberUtil.getInstance().isNumberMatch(number, Recipient.self().requireE164())) {
PhoneNumberUtil.MatchType.EXACT_MATCH, PhoneNumberUtil.MatchType.SHORT_NSN_MATCH, PhoneNumberUtil.MatchType.NSN_MATCH -> true
else -> false
}
}
/**
* Cancels any donation subscription, leaves every group, deletes the account from the service, and finally wipes
* this device. [onProgress] is called from a background thread as each part of that gets underway.
*/
suspend fun deleteAccount(onProgress: (Progress) -> Unit): DeletionResult = withContext(SignalDispatchers.IO) {
cancelSubscription(onProgress)?.let { return@withContext it }
leaveGroups(onProgress)?.let { return@withContext it }
deleteAccountFromServer()?.let { return@withContext it }
Log.i(TAG, "deleteAccount: attempting to delete user data and close process...")
if (!ServiceUtil.getActivityManager(AppDependencies.application).clearApplicationUserData()) {
Log.w(TAG, "deleteAccount: failed to delete user data")
return@withContext DeletionResult.LocalDataDeletionFailed
}
DeletionResult.Success
}
/** Returns the failure that should stop the deletion, or null to carry on. */
private fun cancelSubscription(onProgress: (Progress) -> Unit): DeletionResult? {
if (InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION) == null) {
return null
}
Log.i(TAG, "deleteAccount: attempting to cancel subscription")
onProgress(Progress.CancelingSubscription)
val subscriber = InAppPaymentsRepository.requireSubscriber(InAppPaymentSubscriberRecord.Type.DONATION)
val response = AppDependencies.donationsService.cancelSubscription(subscriber.subscriberId)
if (response.executionError.isPresent) {
Log.w(TAG, "deleteAccount: failed attempt to cancel subscription")
return DeletionResult.CancelSubscriptionFailed
}
return when (response.status) {
404 -> {
Log.i(TAG, "deleteAccount: subscription does not exist. Continuing deletion...")
null
}
200 -> {
Log.i(TAG, "deleteAccount: successfully cancelled subscription. Continuing deletion...")
null
}
else -> {
Log.w(TAG, "deleteAccount: an unexpected error occurred. ${response.status}")
DeletionResult.CancelSubscriptionFailed
}
}
}
/** Returns the failure that should stop the deletion, or null to carry on. */
private fun leaveGroups(onProgress: (Progress) -> Unit): DeletionResult? {
Log.i(TAG, "deleteAccount: attempting to leave groups...")
var groupsProcessed = 0
var groupsFailed = 0
try {
SignalDatabase.groups.getGroups().use { groups ->
onProgress(Progress.LeavingGroups(totalCount = groups.getCount(), leaveCount = 0))
Log.i(TAG, "deleteAccount: found ${groups.getCount()} groups to leave.")
var groupRecord = groups.getNext()
while (groupRecord != null) {
if (groupRecord.id.isPush && groupRecord.isActive) {
if (!groupRecord.isV1Group && !leaveGroup(groupRecord.id.requirePush())) {
groupsFailed++
}
onProgress(Progress.LeavingGroups(totalCount = groups.getCount(), leaveCount = ++groupsProcessed))
}
groupRecord = groups.getNext()
}
}
} catch (e: Exception) {
Log.w(TAG, "deleteAccount: failed to leave groups", e)
return DeletionResult.LeaveGroupsFailed
}
if (groupsFailed > 0) {
Log.w(TAG, "deleteAccount: failed to leave $groupsFailed group(s). Continuing with deletion anyway.")
} else {
Log.i(TAG, "deleteAccount: successfully left all groups.")
}
onProgress(Progress.DeletingAccount)
return null
}
/** Leaves [groupId], returning false if it couldn't be left, which isn't fatal to the deletion. */
private fun leaveGroup(groupId: GroupId.Push): Boolean {
return try {
GroupManager.leaveGroup(AppDependencies.application, groupId, true)
true
} catch (e: IOException) {
Log.w(TAG, "deleteAccount: failed to leave a group, continuing with the rest.", e)
false
} catch (e: GroupChangeBusyException) {
Log.w(TAG, "deleteAccount: failed to leave a group, continuing with the rest.", e)
false
} catch (e: GroupChangeFailedException) {
Log.w(TAG, "deleteAccount: failed to leave a group, continuing with the rest.", e)
false
}
}
/** Returns the failure that should stop the deletion, or null to carry on. */
private fun deleteAccountFromServer(): DeletionResult? {
Log.i(TAG, "deleteAccount: attempting to delete account from server...")
try {
NetworkResultUtil.toBasicLegacy(SignalNetwork.accountApi.deleteAccount())
} catch (e: IOException) {
if (e is NonSuccessfulResponseCodeException && e.code == ACCOUNT_DELETED_WEBSOCKET_CLOSE_CODE) {
Log.i(TAG, "deleteAccount: WebSocket closed with expected status after delete account, moving forward as delete was successful")
} else {
Log.w(TAG, "deleteAccount: failed to delete account from signal service, bail", e)
return DeletionResult.ServerDeletionFailed
}
}
Log.i(TAG, "deleteAccount: successfully removed account from server")
return null
}
/** Reported as the deletion runs so the user can see what's taking so long. */
sealed interface Progress {
data object CancelingSubscription : Progress
data class LeavingGroups(val totalCount: Int, val leaveCount: Int) : Progress
data object DeletingAccount : Progress
}
/** How the deletion ended. [Success] never really reaches the caller, since the process is wiped along with the data. */
sealed interface DeletionResult {
data object Success : DeletionResult
data object CancelSubscriptionFailed : DeletionResult
data object LeaveGroupsFailed : DeletionResult
data object ServerDeletionFailed : DeletionResult
data object LocalDataDeletionFailed : DeletionResult
}
}
@@ -1,147 +0,0 @@
package org.thoughtcrime.securesms.delete;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Transformations;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import com.google.i18n.phonenumbers.NumberParseException;
import com.google.i18n.phonenumbers.PhoneNumberUtil;
import com.google.i18n.phonenumbers.Phonenumber;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.payments.Balance;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.DefaultValueLiveData;
import org.thoughtcrime.securesms.util.SingleLiveEvent;
import org.whispersystems.signalservice.api.payments.FormatterOptions;
import org.whispersystems.signalservice.api.payments.Money;
import java.util.List;
import java.util.Optional;
public class DeleteAccountViewModel extends ViewModel {
private final DeleteAccountRepository repository;
private final MutableLiveData<String> regionCode;
private final LiveData<String> countryDisplayName;
private final MutableLiveData<Long> nationalNumber;
private final SingleLiveEvent<DeleteAccountEvent> events;
private final LiveData<Optional<String>> walletBalance;
public DeleteAccountViewModel(@NonNull DeleteAccountRepository repository) {
this.repository = repository;
this.regionCode = new DefaultValueLiveData<>("ZZ"); // PhoneNumberUtil private static final String UNKNOWN_REGION = "ZZ";
this.nationalNumber = new MutableLiveData<>();
this.countryDisplayName = Transformations.map(regionCode, repository::getRegionDisplayName);
this.events = new SingleLiveEvent<>();
this.walletBalance = Transformations.map(SignalStore.payments().liveMobileCoinBalance(),
DeleteAccountViewModel::getFormattedWalletBalance);
}
@NonNull LiveData<Optional<String>> getWalletBalance() {
return walletBalance;
}
@NonNull LiveData<String> getCountryDisplayName() {
return Transformations.distinctUntilChanged(countryDisplayName);
}
@NonNull LiveData<String> getRegionCode() {
return Transformations.distinctUntilChanged(regionCode);
}
@NonNull SingleLiveEvent<DeleteAccountEvent> getEvents() {
return events;
}
@Nullable Long getNationalNumber() {
return nationalNumber.getValue();
}
void deleteAccount() {
repository.deleteAccount(events::postValue);
}
void submit() {
String region = this.regionCode.getValue();
Integer countryCode = region != null ? repository.getRegionCountryCode(region) : null;
Long nationalNumber = this.nationalNumber.getValue();
if (countryCode == null || countryCode == 0) {
events.setValue(DeleteAccountEvent.NoCountryCode.INSTANCE);
return;
}
if (nationalNumber == null) {
events.setValue(DeleteAccountEvent.NoNationalNumber.INSTANCE);
return;
}
Phonenumber.PhoneNumber number = new Phonenumber.PhoneNumber();
number.setCountryCode(countryCode);
number.setNationalNumber(nationalNumber);
final PhoneNumberUtil.MatchType matchType = PhoneNumberUtil.getInstance().isNumberMatch(number, Recipient.self().requireE164());
if (matchType == PhoneNumberUtil.MatchType.EXACT_MATCH || matchType == PhoneNumberUtil.MatchType.SHORT_NSN_MATCH || matchType == PhoneNumberUtil.MatchType.NSN_MATCH) {
events.setValue(DeleteAccountEvent.ConfirmDeletion.INSTANCE);
} else {
events.setValue(DeleteAccountEvent.NotAMatch.INSTANCE);
}
}
void onCountrySelected(int countryCode) {
String region = this.regionCode.getValue();
List<String> regions = PhoneNumberUtil.getInstance().getRegionCodesForCountryCode(countryCode);
if (!regions.contains(region)) {
this.regionCode.setValue(PhoneNumberUtil.getInstance().getRegionCodeForCountryCode(countryCode));
}
}
void onRegionSelected(@NonNull String region) {
this.regionCode.setValue(region);
}
void setNationalNumber(long nationalNumber) {
this.nationalNumber.setValue(nationalNumber);
try {
String phoneNumberRegion = PhoneNumberUtil.getInstance()
.getRegionCodeForNumber(PhoneNumberUtil.getInstance().parse(String.valueOf(nationalNumber),
regionCode.getValue()));
if (phoneNumberRegion != null) {
regionCode.setValue(phoneNumberRegion);
}
} catch (NumberParseException ignored) {
}
}
private static @NonNull Optional<String> getFormattedWalletBalance(@NonNull Balance balance) {
Money amount = balance.getFullAmount();
if (amount.isPositive()) {
return Optional.of(amount.toString(FormatterOptions.defaults()));
} else {
return Optional.empty();
}
}
public static final class Factory implements ViewModelProvider.Factory {
private final DeleteAccountRepository repository;
public Factory(DeleteAccountRepository repository) {
this.repository = repository;
}
@Override
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
return modelClass.cast(new DeleteAccountViewModel(repository));
}
}
}
@@ -0,0 +1,215 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.delete
import com.google.i18n.phonenumbers.AsYouTypeFormatter
import com.google.i18n.phonenumbers.NumberParseException
import com.google.i18n.phonenumbers.PhoneNumberUtil
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import org.signal.appsettings.deleteaccount.DeleteAccountAction
import org.signal.appsettings.deleteaccount.DeleteAccountEvent
import org.signal.appsettings.deleteaccount.DeleteAccountState
import org.signal.appsettings.deleteaccount.DeleteAccountState.Companion.UNKNOWN_REGION
import org.signal.appsettings.deleteaccount.DeleteAccountState.Dialog
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.
*/
class DeleteAccountViewModel(
private val repository: DeleteAccountRepository = DeleteAccountRepository()
) : EventDrivenViewModel<DeleteAccountEvent>(TAG) {
companion object {
private val TAG = Log.tag(DeleteAccountViewModel::class)
private const val MAX_COUNTRY_CODE_LENGTH = 3
}
private val phoneNumberUtil = PhoneNumberUtil.getInstance()
private val _state = MutableStateFlow(DeleteAccountState(walletBalance = repository.getFormattedWalletBalance()))
private val _actions = Channel<DeleteAccountAction>(Channel.BUFFERED)
val state: StateFlow<DeleteAccountState> = _state.asStateFlow()
val actions: Flow<DeleteAccountAction> = _actions.receiveAsFlow()
private var formatter: AsYouTypeFormatter? = null
private var formatterRegion: String? = null
override suspend fun processEvent(event: DeleteAccountEvent) {
when (event) {
DeleteAccountEvent.NavigateBackClicked -> {
_actions.send(DeleteAccountAction.NavigateBack)
}
DeleteAccountEvent.CountryPickerClicked -> {
_actions.send(DeleteAccountAction.NavigateToCountryPicker)
}
is DeleteAccountEvent.CountrySelected -> {
applyRegionSelected(event.regionCode)
}
is DeleteAccountEvent.CountryCodeChanged -> {
applyCountryCodeChanged(event.countryCode)
}
is DeleteAccountEvent.NationalNumberChanged -> {
applyNationalNumberChanged(event.nationalNumber)
}
DeleteAccountEvent.DeleteAccountClicked -> {
applyDeleteAccountClicked()
}
DeleteAccountEvent.DeletionConfirmed -> {
applyDeletionConfirmed()
}
DeleteAccountEvent.LaunchAppSettingsClicked -> {
_actions.send(DeleteAccountAction.LaunchAppSettings)
}
DeleteAccountEvent.DialogDismissed -> {
_state.update { it.copy(dialog = Dialog.None) }
}
}
}
private fun applyRegionSelected(regionCode: String) {
val countryCode = repository.getRegionCountryCode(regionCode)
val countryDisplayName = repository.getRegionDisplayName(regionCode)
val formattedNumber = formatNumber(_state.value.nationalNumber, regionCode)
_state.update {
it.copy(
regionCode = regionCode,
countryDisplayName = countryDisplayName,
countryCode = if (countryCode > 0) countryCode.toString() else it.countryCode,
formattedNumber = formattedNumber
)
}
}
private fun applyCountryCodeChanged(countryCode: String) {
val sanitized = countryCode.filter { it.isDigit() }.take(MAX_COUNTRY_CODE_LENGTH)
val code = sanitized.toIntOrNull() ?: 0
val currentRegion = _state.value.regionCode
val regionCode = if (phoneNumberUtil.getRegionCodesForCountryCode(code).contains(currentRegion)) {
currentRegion
} else {
phoneNumberUtil.getRegionCodeForCountryCode(code)
}
val countryDisplayName = repository.getRegionDisplayName(regionCode)
val formattedNumber = formatNumber(_state.value.nationalNumber, regionCode)
_state.update {
it.copy(
countryCode = sanitized,
regionCode = regionCode,
countryDisplayName = countryDisplayName,
formattedNumber = formattedNumber
)
}
}
private fun applyNationalNumberChanged(nationalNumber: String) {
val digits = nationalNumber.filter { it.isDigit() }
val regionCode = regionCodeForNumber(digits, _state.value.regionCode)
val countryDisplayName = repository.getRegionDisplayName(regionCode)
val formattedNumber = formatNumber(digits, regionCode)
_state.update {
it.copy(
nationalNumber = digits,
regionCode = regionCode,
countryDisplayName = countryDisplayName,
formattedNumber = formattedNumber
)
}
}
private suspend fun applyDeleteAccountClicked() {
val state = _state.value
val countryCode = state.countryCode.toIntOrNull() ?: 0
if (countryCode == 0) {
_actions.send(DeleteAccountAction.ShowNoCountryCode)
return
}
val nationalNumber = state.nationalNumber.toLongOrNull()
if (nationalNumber == null) {
_actions.send(DeleteAccountAction.ShowNoNationalNumber)
return
}
val dialog = if (repository.isNumberMatch(countryCode, nationalNumber)) Dialog.ConfirmDeletion else Dialog.NumberDoesNotMatch
_state.update { it.copy(dialog = dialog) }
}
private suspend fun applyDeletionConfirmed() {
_state.update { it.copy(dialog = Dialog.DeletingAccount) }
val result = repository.deleteAccount { progress ->
val progressDialog = when (progress) {
DeleteAccountRepository.Progress.CancelingSubscription -> Dialog.CancelingSubscription
is DeleteAccountRepository.Progress.LeavingGroups -> Dialog.LeavingGroups(totalCount = progress.totalCount, leaveCount = progress.leaveCount)
DeleteAccountRepository.Progress.DeletingAccount -> Dialog.DeletingAccount
}
_state.update { it.copy(dialog = progressDialog) }
}
val dialog = when (result) {
DeleteAccountRepository.DeletionResult.Success -> Dialog.None
DeleteAccountRepository.DeletionResult.CancelSubscriptionFailed,
DeleteAccountRepository.DeletionResult.LeaveGroupsFailed,
DeleteAccountRepository.DeletionResult.ServerDeletionFailed -> Dialog.DeletionFailed
DeleteAccountRepository.DeletionResult.LocalDataDeletionFailed -> Dialog.LocalDataDeletionFailed
}
_state.update { it.copy(dialog = dialog) }
}
/**
* The region [nationalNumber] actually belongs to, which matters when several of them share a calling code. Falls
* back to [fallback] when the number doesn't say.
*/
private fun regionCodeForNumber(nationalNumber: String, fallback: String): String {
if (nationalNumber.isEmpty()) {
return fallback
}
return try {
phoneNumberUtil.getRegionCodeForNumber(phoneNumberUtil.parse(nationalNumber, fallback)) ?: fallback
} catch (_: NumberParseException) {
fallback
}
}
/** [nationalNumber] as the user should see it in the number field, formatted for [regionCode]. */
private fun formatNumber(nationalNumber: String, regionCode: String): String {
if (regionCode != formatterRegion) {
formatter = if (regionCode.isNotEmpty() && regionCode != UNKNOWN_REGION) phoneNumberUtil.getAsYouTypeFormatter(regionCode) else null
formatterRegion = regionCode
}
val formatter = this.formatter ?: return nationalNumber
formatter.clear()
var formatted = ""
for (digit in nationalNumber) {
formatted = formatter.inputDigit(digit)
}
return formatted
}
}
@@ -1,38 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
tools:viewBindingIgnore="true"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="11dp"
android:paddingEnd="11dp">
<androidx.appcompat.widget.AppCompatImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="3dp"
android:src="@drawable/symbol_plus_24"
android:tint="@color/core_grey_60"
android:contentDescription="+"
tools:ignore="HardcodedText" />
<EditText
android:id="@+id/input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="7dp"
android:paddingBottom="16dp"
android:background="@color/transparent"
android:singleLine="true"
android:inputType="number"
android:maxLength="3"
android:digits="1234567890"
android:saveEnabled="false"
android:contentDescription="@string/RegistrationActivity_country_code_description"
tools:text="123" />
</LinearLayout>
@@ -1,145 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:viewBindingIgnore="true"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:navigationIcon="@drawable/symbol_arrow_start_24"
app:title="@string/preferences__delete_account" />
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/delete_account_fragment_warning"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginTop="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar"
app:srcCompat="@drawable/ic_warning_40"
app:tint="@color/signal_alert_primary" />
<TextView
android:id="@+id/delete_account_fragment_notice"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:text="@string/DeleteAccountFragment__deleting_your_account_will"
android:textAppearance="@style/Signal.Text.Body"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/delete_account_fragment_warning" />
<TextView
android:id="@+id/delete_account_fragment_bullets"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
android:textAppearance="@style/TextAppearance.Signal.Body2"
app:layout_constraintTop_toBottomOf="@id/delete_account_fragment_notice"
tools:text="Some\nbullets\nhere" />
<TextView
android:id="@+id/delete_account_fragment_enter_phone_number"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="30dp"
android:text="@string/DeleteAccountFragment__enter_your_phone_number"
android:textAppearance="@style/TextAppearance.Signal.Body1.Bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/delete_account_fragment_bullets" />
<FrameLayout
android:id="@+id/delete_account_fragment_country_spinner_frame"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="32dp"
android:background="@drawable/labeled_edit_text_background_inactive"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/delete_account_fragment_enter_phone_number">
<TextView
android:id="@+id/delete_account_fragment_country_picker"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:textAppearance="@style/Signal.Text.BodyLarge"
android:drawableEnd="@drawable/ic_chevron_16"
android:drawableTint="@color/signal_colorOnSurface"
android:textAlignment="viewStart" />
</FrameLayout>
<LinearLayout
android:id="@+id/delete_account_fragment_linearLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="32dp"
android:layoutDirection="ltr"
android:orientation="horizontal"
android:weightSum="4"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/delete_account_fragment_country_spinner_frame">
<org.thoughtcrime.securesms.components.LabeledEditText
android:id="@+id/delete_account_fragment_country_code"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:layout_weight="1"
app:labeledEditText_background="@color/white"
app:labeledEditText_textLayout="@layout/delete_account_country_code_text" />
<org.thoughtcrime.securesms.components.LabeledEditText
android:id="@+id/delete_account_fragment_number"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="3"
app:labeledEditText_background="?android:windowBackground"
app:labeledEditText_label="@string/RegistrationActivity_phone_number_description"
app:labeledEditText_textLayout="@layout/phone_text" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/delete_account_fragment_delete"
style="@style/Signal.Widget.Button.Large.Danger"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="32dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="32dp"
android:text="@string/DeleteAccountFragment__delete_account"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/delete_account_fragment_linearLayout" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
@@ -1,52 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:viewBindingIgnore="true"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/delete_account_progress_dialog_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dsl_settings_gutter"
android:layout_marginTop="16dp"
android:layout_marginEnd="@dimen/dsl_settings_gutter"
android:text="@string/DeleteAccountFragment__leaving_groups"
android:textAlignment="center"
android:textAppearance="@style/TextAppearance.Signal.Body1.Bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/delete_account_progress_dialog_spinner" />
<TextView
android:id="@+id/delete_account_progress_dialog_message"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/dsl_settings_gutter"
android:layout_marginTop="8dp"
android:layout_marginEnd="@dimen/dsl_settings_gutter"
android:layout_marginBottom="48dp"
android:minLines="2"
android:text="@string/DeleteAccountFragment__depending_on_the_number_of_groups"
android:textAlignment="center"
android:textAppearance="@style/TextAppearance.Signal.Body2"
android:textColor="@color/signal_text_secondary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/delete_account_progress_dialog_title" />
<ProgressBar
android:id="@+id/delete_account_progress_dialog_spinner"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginStart="@dimen/dsl_settings_gutter"
android:layout_marginTop="58dp"
android:indeterminateBehavior="cycle"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -323,8 +323,7 @@
<fragment
android:id="@+id/deleteAccountFragment"
android:name="org.thoughtcrime.securesms.delete.DeleteAccountFragment"
android:label="delete_account_fragment"
tools:layout="@layout/delete_account_fragment">
android:label="delete_account_fragment">
<action
android:id="@+id/action_deleteAccountFragment_to_deleteAccountCountryFragment"
-29
View File
@@ -5118,35 +5118,6 @@
<!-- Message shown during a call when the WiFi network is unusable, and cellular data starts to be used for the call instead. -->
<string name="WifiToCellularPopupWindow__weak_wifi_switched_to_cellular">Weak Wi-Fi. Switched to cellular.</string>
<!-- DeleteAccountFragment -->
<string name="DeleteAccountFragment__deleting_your_account_will">Deleting your account will:</string>
<string name="DeleteAccountFragment__enter_your_phone_number">Enter your phone number</string>
<string name="DeleteAccountFragment__delete_account">Delete account</string>
<string name="DeleteAccountFragment__delete_your_account_info_and_profile_photo">Delete your account info and profile photo</string>
<string name="DeleteAccountFragment__delete_all_your_messages">Delete all your messages</string>
<string name="DeleteAccountFragment__delete_s_in_your_payments_account">Delete %1$s in your payments account</string>
<string name="DeleteAccountFragment__no_country_code">No country code specified</string>
<string name="DeleteAccountFragment__no_number">No number specified</string>
<string name="DeleteAccountFragment__the_phone_number">The phone number you entered doesn\'t match your account\'s.</string>
<string name="DeleteAccountFragment__are_you_sure">Are you sure you want to delete your account?</string>
<string name="DeleteAccountFragment__this_will_delete_your_signal_account">This will delete your Signal account and reset the application. The app will close after the process is complete.</string>
<string name="DeleteAccountFragment__failed_to_delete_local_data">Failed to delete local data. You can manually clear it in the system application settings.</string>
<string name="DeleteAccountFragment__launch_app_settings">Launch App Settings</string>
<!-- Title of progress dialog shown when a user deletes their account and the process is leaving all groups -->
<string name="DeleteAccountFragment__leaving_groups">Leaving groups…</string>
<!-- Title of progress dialog shown when a user deletes their account and the process has left all groups -->
<string name="DeleteAccountFragment__deleting_account">Deleting account…</string>
<!-- Message of progress dialog shown when a user deletes their account and the process is canceling their subscription -->
<string name="DeleteAccountFragment__canceling_your_subscription">Canceling your subscription…</string>
<!-- Message of progress dialog shown when a user deletes their account and the process is leaving groups -->
<string name="DeleteAccountFragment__depending_on_the_number_of_groups">Depending on the number of groups you\'re in, this might take a few minutes</string>
<!-- Message of progress dialog shown when a user deletes their account and the process has left all groups -->
<string name="DeleteAccountFragment__deleting_all_user_data_and_resetting">Deleting user data and resetting the app</string>
<!-- Title of error dialog shown when a network error occurs during account deletion -->
<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>
<!-- DeleteAccountCountryPickerFragment -->
<string name="DeleteAccountCountryPickerFragment__search_countries">Search Countries</string>
@@ -0,0 +1,249 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.delete
import assertk.assertThat
import assertk.assertions.contains
import assertk.assertions.containsExactly
import assertk.assertions.isEqualTo
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.signal.appsettings.deleteaccount.DeleteAccountAction
import org.signal.appsettings.deleteaccount.DeleteAccountEvent
import org.signal.appsettings.deleteaccount.DeleteAccountState.Dialog
import org.thoughtcrime.securesms.testing.CoroutineDispatcherRule
@OptIn(ExperimentalCoroutinesApi::class)
class DeleteAccountViewModelTest {
private val testDispatcher = UnconfinedTestDispatcher()
@get:Rule
val dispatcherRule = CoroutineDispatcherRule(testDispatcher)
private val repository = mockk<DeleteAccountRepository>(relaxUnitFun = true)
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
every { repository.getFormattedWalletBalance() } returns null
every { repository.getRegionDisplayName(any()) } returns ""
every { repository.getRegionDisplayName("US") } returns "United States"
every { repository.getRegionCountryCode("US") } returns 1
every { repository.isNumberMatch(any(), any()) } returns true
coEvery { repository.deleteAccount(any()) } returns DeleteAccountRepository.DeletionResult.Success
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `a wallet balance is read out of the repository up front`() = runTest(testDispatcher) {
every { repository.getFormattedWalletBalance() } returns "0.1000 MOB"
val viewModel = createViewModel()
assertThat(viewModel.state.value.walletBalance).isEqualTo("0.1000 MOB")
}
@Test
fun `CountrySelected fills in the calling code and display name for the region`() = runTest(testDispatcher) {
val viewModel = createViewModel()
viewModel.onEvent(DeleteAccountEvent.CountrySelected("US"))
assertThat(viewModel.state.value.regionCode).isEqualTo("US")
assertThat(viewModel.state.value.countryCode).isEqualTo("1")
assertThat(viewModel.state.value.countryDisplayName).isEqualTo("United States")
}
@Test
fun `CountryCodeChanged keeps only digits and picks the matching region`() = runTest(testDispatcher) {
val viewModel = createViewModel()
viewModel.onEvent(DeleteAccountEvent.CountryCodeChanged("+1a"))
assertThat(viewModel.state.value.countryCode).isEqualTo("1")
assertThat(viewModel.state.value.regionCode).isEqualTo("US")
}
@Test
fun `NationalNumberChanged keeps only digits and formats them for the region`() = runTest(testDispatcher) {
val viewModel = createViewModel()
viewModel.onEvent(DeleteAccountEvent.CountrySelected("US"))
viewModel.onEvent(DeleteAccountEvent.NationalNumberChanged("(610) 555-0103"))
assertThat(viewModel.state.value.nationalNumber).isEqualTo("6105550103")
assertThat(viewModel.state.value.formattedNumber).isEqualTo("(610) 555-0103")
}
@Test
fun `DeleteAccountClicked without a country code asks for one`() = runTest(testDispatcher) {
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(DeleteAccountEvent.DeleteAccountClicked)
assertThat(actions).contains(DeleteAccountAction.ShowNoCountryCode)
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.None)
}
@Test
fun `DeleteAccountClicked without a number asks for one`() = runTest(testDispatcher) {
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(DeleteAccountEvent.CountrySelected("US"))
viewModel.onEvent(DeleteAccountEvent.DeleteAccountClicked)
assertThat(actions).contains(DeleteAccountAction.ShowNoNationalNumber)
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.None)
}
@Test
fun `DeleteAccountClicked with a number that isn't ours says so`() = runTest(testDispatcher) {
every { repository.isNumberMatch(any(), any()) } returns false
val viewModel = enterNumber()
viewModel.onEvent(DeleteAccountEvent.DeleteAccountClicked)
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.NumberDoesNotMatch)
coVerify(exactly = 0) { repository.deleteAccount(any()) }
}
@Test
fun `DeleteAccountClicked with our own number asks for confirmation`() = runTest(testDispatcher) {
val viewModel = enterNumber()
viewModel.onEvent(DeleteAccountEvent.DeleteAccountClicked)
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.ConfirmDeletion)
coVerify(exactly = 0) { repository.deleteAccount(any()) }
}
@Test
fun `DeletionConfirmed reports the progress it's told about`() = runTest(testDispatcher) {
lateinit var viewModel: DeleteAccountViewModel
val dialogs = mutableListOf<Dialog>()
coEvery { repository.deleteAccount(any()) } answers {
val onProgress = firstArg<(DeleteAccountRepository.Progress) -> Unit>()
onProgress(DeleteAccountRepository.Progress.CancelingSubscription)
dialogs += viewModel.state.value.dialog
onProgress(DeleteAccountRepository.Progress.LeavingGroups(totalCount = 3, leaveCount = 1))
dialogs += viewModel.state.value.dialog
onProgress(DeleteAccountRepository.Progress.DeletingAccount)
dialogs += viewModel.state.value.dialog
DeleteAccountRepository.DeletionResult.Success
}
viewModel = createViewModel()
viewModel.onEvent(DeleteAccountEvent.DeletionConfirmed)
assertThat(dialogs).containsExactly(
Dialog.CancelingSubscription,
Dialog.LeavingGroups(totalCount = 3, leaveCount = 1),
Dialog.DeletingAccount
)
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.None)
}
@Test
fun `a deletion the network couldn't finish offers a retry`() = runTest(testDispatcher) {
coEvery { repository.deleteAccount(any()) } returns DeleteAccountRepository.DeletionResult.ServerDeletionFailed
val viewModel = createViewModel()
viewModel.onEvent(DeleteAccountEvent.DeletionConfirmed)
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.DeletionFailed)
}
@Test
fun `a deletion that couldn't wipe the device sends the user to the system settings`() = runTest(testDispatcher) {
coEvery { repository.deleteAccount(any()) } returns DeleteAccountRepository.DeletionResult.LocalDataDeletionFailed
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(DeleteAccountEvent.DeletionConfirmed)
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.LocalDataDeletionFailed)
viewModel.onEvent(DeleteAccountEvent.LaunchAppSettingsClicked)
assertThat(actions).contains(DeleteAccountAction.LaunchAppSettings)
}
@Test
fun `CountryPickerClicked opens the picker`() = runTest(testDispatcher) {
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(DeleteAccountEvent.CountryPickerClicked)
assertThat(actions).contains(DeleteAccountAction.NavigateToCountryPicker)
}
@Test
fun `NavigateBackClicked leaves the screen`() = runTest(testDispatcher) {
val viewModel = createViewModel()
val actions = collectActions(viewModel.actions)
viewModel.onEvent(DeleteAccountEvent.NavigateBackClicked)
assertThat(actions).contains(DeleteAccountAction.NavigateBack)
}
@Test
fun `DialogDismissed clears the dialog`() = runTest(testDispatcher) {
val viewModel = enterNumber()
viewModel.onEvent(DeleteAccountEvent.DeleteAccountClicked)
viewModel.onEvent(DeleteAccountEvent.DialogDismissed)
assertThat(viewModel.state.value.dialog).isEqualTo(Dialog.None)
}
private fun createViewModel(): DeleteAccountViewModel = DeleteAccountViewModel(repository)
private fun enterNumber(): DeleteAccountViewModel {
return createViewModel().apply {
onEvent(DeleteAccountEvent.CountrySelected("US"))
onEvent(DeleteAccountEvent.NationalNumberChanged("6105550103"))
}
}
private fun TestScope.collectActions(actions: Flow<DeleteAccountAction>): List<DeleteAccountAction> {
val collected = mutableListOf<DeleteAccountAction>()
backgroundScope.launch { actions.toList(collected) }
return collected
}
}
@@ -0,0 +1,30 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.appsettings.deleteaccount
/**
* One-shot side effects that need an Activity or the nav graph, and therefore have to be carried out by the host
* rather than the screen itself.
*
* Actions are logged, so be sure `toString()` contains nothing sensitive.
*/
sealed interface DeleteAccountAction {
/** Leave the screen. */
data object NavigateBack : DeleteAccountAction
/** Open the country picker. */
data object NavigateToCountryPicker : DeleteAccountAction
/** Tell the user they have to fill in a calling code before we can check their number. */
data object ShowNoCountryCode : DeleteAccountAction
/** Tell the user they have to fill in a phone number before we can check it. */
data object ShowNoNationalNumber : DeleteAccountAction
/** Open the system settings for this app, which is where the user can clear its data by hand. */
data object LaunchAppSettings : DeleteAccountAction
}
@@ -0,0 +1,43 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.appsettings.deleteaccount
import org.signal.core.util.censor
/**
* Reminder that these events are logged, so don't include anything sensitive in the toString.
*/
sealed interface DeleteAccountEvent {
/** The user tapped the navigation (back) icon. */
data object NavigateBackClicked : DeleteAccountEvent
/** The user tapped the country row, which opens the picker. */
data object CountryPickerClicked : DeleteAccountEvent
/** The user picked [regionCode] out of the country picker. */
data class CountrySelected(val regionCode: String) : DeleteAccountEvent
/** The user typed in the calling code field. */
data class CountryCodeChanged(val countryCode: String) : DeleteAccountEvent
/** The user typed in the phone number field. */
data class NationalNumberChanged(val nationalNumber: String) : DeleteAccountEvent {
override fun toString(): String = "NationalNumberChanged(nationalNumber=${nationalNumber.censor()})"
}
/** The user asked to delete their account, which we only act on once they've confirmed. */
data object DeleteAccountClicked : DeleteAccountEvent
/** The user confirmed the deletion, either from the confirmation dialog or by retrying a failed one. */
data object DeletionConfirmed : DeleteAccountEvent
/** The user asked to be taken to the system settings for this app so they can clear its data by hand. */
data object LaunchAppSettingsClicked : DeleteAccountEvent
/** Dismisses whatever is in [DeleteAccountState.dialog]. */
data object DialogDismissed : DeleteAccountEvent
}
@@ -0,0 +1,493 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
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
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.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.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
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
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.TextRange
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
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
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"
}
@Composable
fun DeleteAccountScreen(
state: DeleteAccountState,
onEvent: (DeleteAccountEvent) -> Unit,
modifier: Modifier = Modifier
) {
Scaffolds.Settings(
title = stringResource(R.string.preferences__delete_account),
onNavigationClick = { onEvent(DeleteAccountEvent.NavigateBackClicked) },
navigationIcon = SignalIcons.ArrowStart.imageVector,
modifier = modifier
) { contentPadding ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(contentPadding)
.imePadding()
.verticalScroll(rememberScrollState())
.testTag(DeleteAccountTestTags.SCROLLER)
) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.ic_delete_account_warning_40),
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier
.padding(top = 16.dp)
.size(40.dp)
)
Text(
text = stringResource(R.string.DeleteAccountFragment__deleting_your_account_will),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(top = 16.dp)
)
Bullets(
walletBalance = state.walletBalance,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
)
Text(
text = stringResource(R.string.DeleteAccountFragment__enter_your_phone_number),
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 30.dp)
)
CountryPickerRow(
countryDisplayName = state.countryDisplayName,
onClick = { onEvent(DeleteAccountEvent.CountryPickerClicked) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp)
.padding(top = 16.dp)
.testTag(DeleteAccountTestTags.ROW_COUNTRY_PICKER)
)
PhoneNumberInputFields(
state = state,
onEvent = onEvent,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp)
.padding(top = 12.dp)
)
DeleteButton(
onClick = { onEvent(DeleteAccountEvent.DeleteAccountClicked) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp)
.padding(top = 16.dp, bottom = 16.dp)
)
}
DeleteAccountDialogs(dialog = state.dialog, onEvent = onEvent)
}
}
@Composable
private fun Bullets(
walletBalance: String?,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
Bullet(text = 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))
}
}
}
@Composable
private fun Bullet(text: String) {
Row {
Text(
text = "",
style = MaterialTheme.typography.bodyMedium
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = text,
style = MaterialTheme.typography.bodyMedium
)
}
}
@Composable
private fun CountryPickerRow(
countryDisplayName: String,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable(onClick = onClick)
.padding(16.dp)
) {
Text(
text = countryDisplayName.ifEmpty { stringResource(R.string.RegistrationActivity_select_your_country) },
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f)
)
Icon(
imageVector = SignalIcons.ArrowDropDown.imageVector,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(24.dp)
)
}
}
@Composable
private fun PhoneNumberInputFields(
state: DeleteAccountState,
onEvent: (DeleteAccountEvent) -> Unit,
modifier: Modifier = Modifier
) {
var numberFieldValue by remember { mutableStateOf(TextFieldValue(state.formattedNumber)) }
val numberInteractionSource = remember { MutableInteractionSource() }
LaunchedEffect(state.formattedNumber) {
if (numberFieldValue.text != state.formattedNumber) {
numberFieldValue = TextFieldValue(text = state.formattedNumber, selection = TextRange(state.formattedNumber.length))
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top,
modifier = modifier
) {
TextField(
value = state.countryCode,
onValueChange = { onEvent(DeleteAccountEvent.CountryCodeChanged(it)) },
prefix = { Text(text = "+") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone, imeAction = ImeAction.Next),
colors = deleteAccountTextFieldColors(),
modifier = Modifier
.width(96.dp)
.testTag(DeleteAccountTestTags.FIELD_COUNTRY_CODE)
)
TextField(
value = numberFieldValue,
onValueChange = { newValue ->
numberFieldValue = newValue
onEvent(DeleteAccountEvent.NationalNumberChanged(newValue.text))
},
label = { TextFields.Label(stringResource(R.string.RegistrationActivity_phone_number_description), numberFieldValue.text.isNotEmpty(), numberInteractionSource) },
interactionSource = numberInteractionSource,
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { onEvent(DeleteAccountEvent.DeleteAccountClicked) }),
colors = deleteAccountTextFieldColors(),
modifier = Modifier
.weight(1f)
.testTag(DeleteAccountTestTags.FIELD_NUMBER)
)
}
}
@Composable
private fun deleteAccountTextFieldColors() = TextFieldDefaults.colors(
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant
)
@Composable
private fun DeleteButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Button(
onClick = onClick,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError
),
modifier = modifier.testTag(DeleteAccountTestTags.BUTTON_DELETE)
) {
Text(text = stringResource(R.string.DeleteAccountFragment__delete_account))
}
}
@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() {
Previews.Preview {
DeleteAccountScreen(
state = DeleteAccountState(),
onEvent = {}
)
}
}
@DayNightPreviews
@Composable
private fun DeleteAccountScreenFilledPreview() {
Previews.Preview {
DeleteAccountScreen(
state = DeleteAccountState(
regionCode = "US",
countryDisplayName = "United States",
countryCode = "1",
nationalNumber = "6105550103",
formattedNumber = "(610) 555-0103",
walletBalance = "0.1000 MOB"
),
onEvent = {}
)
}
}
@DayNightPreviews
@Composable
private fun DeleteAccountScreenConfirmDeletionPreview() {
Previews.Preview {
DeleteAccountScreen(
state = DeleteAccountState(dialog = Dialog.ConfirmDeletion),
onEvent = {}
)
}
}
@DayNightPreviews
@Composable
private fun DeleteAccountScreenLeavingGroupsPreview() {
Previews.Preview {
DeleteAccountScreen(
state = DeleteAccountState(dialog = Dialog.LeavingGroups(totalCount = 10, leaveCount = 3)),
onEvent = {}
)
}
}
@DayNightPreviews
@Composable
private fun DeleteAccountScreenDeletionFailedPreview() {
Previews.Preview {
DeleteAccountScreen(
state = DeleteAccountState(dialog = Dialog.DeletionFailed),
onEvent = {}
)
}
}
@@ -0,0 +1,61 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.appsettings.deleteaccount
import org.signal.core.util.censor
/**
* Everything [DeleteAccountScreen] needs to render.
*/
data class DeleteAccountState(
/** 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. */
val countryDisplayName: String = "",
/** The calling code, as digits, without the leading plus. */
val countryCode: String = "",
/** The national number as digits only, which is what we submit. */
val nationalNumber: String = "",
/** The national number as the user sees it, formatted for [regionCode]. */
val formattedNumber: String = "",
/** 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)"
/** Whichever dialog the screen is showing, if any. Only one is ever up at a time. */
sealed interface Dialog {
data object None : Dialog
/** The number the user entered isn't the one on this account, so there's nothing to confirm. */
data object NumberDoesNotMatch : Dialog
/** Asks the user to confirm that they really do want their account deleted. */
data object ConfirmDeletion : Dialog
/** Deletion is underway and we're canceling the user's donation subscription. */
data object CancelingSubscription : Dialog
/** Deletion is underway and we're leaving the user's groups, [leaveCount] of [totalCount] done. */
data class LeavingGroups(val totalCount: Int, val leaveCount: Int) : Dialog
/** Deletion is underway and we're removing the account itself along with everything on this device. */
data object DeletingAccount : Dialog
/** Something the network had to do didn't work, so the account is still there and the user can try again. */
data object DeletionFailed : Dialog
/** The account is gone but we couldn't wipe this device, which the user has to finish in system settings. */
data object LocalDataDeletionFailed : Dialog
}
companion object {
/** Matches PhoneNumberUtil's own private UNKNOWN_REGION. */
const val UNKNOWN_REGION = "ZZ"
}
}
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="40dp"
android:height="40dp"
android:viewportWidth="40"
android:viewportHeight="40">
<path
android:fillColor="#FF000000"
android:pathData="M18.5,14.92h3L21,25.5H19ZM22,30a2,2 0,1 0,-2 2A2,2 0,0 0,22 30ZM20,6.27 L3.6,34.59H36.4L20,6.27m0,-1.73a1.52,1.52 0,0 1,1.25 0.9l16.5,28.49c0.69,1.19 0.13,2.16 -1.25,2.16H3.5c-1.38,0 -1.94,-1 -1.25,-2.16L18.75,5.44A1.52,1.52 0,0 1,20 4.54Z"/>
</vector>
@@ -167,4 +167,38 @@
<string name="TotpNameEntryScreen__authenticator_app_renamed">Authenticator app renamed</string>
<!-- Toast shown when we couldn\'t reach the service to save the name -->
<string name="TotpNameEntryScreen__couldnt_save_name">Couldn\'t save name. Check your connection and try again.</string>
<!-- DeleteAccountScreen -->
<!-- DeleteAccountFragment -->
<string name="DeleteAccountFragment__deleting_your_account_will">Deleting your account will:</string>
<string name="DeleteAccountFragment__enter_your_phone_number">Enter your phone number</string>
<string name="DeleteAccountFragment__delete_account">Delete account</string>
<string name="DeleteAccountFragment__delete_your_account_info_and_profile_photo">Delete your account info and profile photo</string>
<string name="DeleteAccountFragment__delete_all_your_messages">Delete all your messages</string>
<string name="DeleteAccountFragment__delete_s_in_your_payments_account">Delete %1$s in your payments account</string>
<string name="DeleteAccountFragment__no_country_code">No country code specified</string>
<string name="DeleteAccountFragment__no_number">No number specified</string>
<string name="DeleteAccountFragment__the_phone_number">The phone number you entered doesn\'t match your account\'s.</string>
<string name="DeleteAccountFragment__are_you_sure">Are you sure you want to delete your account?</string>
<string name="DeleteAccountFragment__this_will_delete_your_signal_account">This will delete your Signal account and reset the application. The app will close after the process is complete.</string>
<string name="DeleteAccountFragment__failed_to_delete_local_data">Failed to delete local data. You can manually clear it in the system application settings.</string>
<string name="DeleteAccountFragment__launch_app_settings">Launch App Settings</string>
<!-- Title of progress dialog shown when a user deletes their account and the process is leaving all groups -->
<string name="DeleteAccountFragment__leaving_groups">Leaving groups…</string>
<!-- Title of progress dialog shown when a user deletes their account and the process has left all groups -->
<string name="DeleteAccountFragment__deleting_account">Deleting account…</string>
<!-- Message of progress dialog shown when a user deletes their account and the process is canceling their subscription -->
<string name="DeleteAccountFragment__canceling_your_subscription">Canceling your subscription…</string>
<!-- Message of progress dialog shown when a user deletes their account and the process is leaving groups -->
<string name="DeleteAccountFragment__depending_on_the_number_of_groups">Depending on the number of groups you\'re in, this might take a few minutes</string>
<!-- Message of progress dialog shown when a user deletes their account and the process has left all groups -->
<string name="DeleteAccountFragment__deleting_all_user_data_and_resetting">Deleting user data and resetting the app</string>
<!-- Title of error dialog shown when a network error occurs during account deletion -->
<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>
<!-- Shared with the registration screens -->
<string name="RegistrationActivity_select_your_country">Select your country</string>
<string name="RegistrationActivity_phone_number_description">Phone number</string>
</resources>
@@ -0,0 +1,153 @@
/*
* 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.hasAnyAncestor
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
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 androidx.compose.ui.test.performScrollToNode
import androidx.compose.ui.test.performTextReplacement
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 DeleteAccountScreenTest {
private val context: Application = RuntimeEnvironment.getApplication()
@get:Rule
val composeTestRule = createComposeRule()
private val events = mutableListOf<DeleteAccountEvent>()
@Test
fun givenTheScreen_whenIClickTheCountryRow_thenIExpectCountryPickerClickedEvent() {
setContent(createState())
composeTestRule.onNodeWithTag(DeleteAccountTestTags.ROW_COUNTRY_PICKER).performClick()
assertThat(events).contains(DeleteAccountEvent.CountryPickerClicked)
}
@Test
fun givenTheScreen_whenIClickDelete_thenIExpectDeleteAccountClickedEvent() {
setContent(createState())
scrollTo(DeleteAccountTestTags.BUTTON_DELETE)
composeTestRule.onNodeWithTag(DeleteAccountTestTags.BUTTON_DELETE).performClick()
assertThat(events).contains(DeleteAccountEvent.DeleteAccountClicked)
}
@Test
fun givenTheScreen_whenITypeACountryCode_thenIExpectCountryCodeChangedEvent() {
setContent(createState(countryCode = ""))
scrollTo(DeleteAccountTestTags.FIELD_COUNTRY_CODE)
composeTestRule.onNodeWithTag(DeleteAccountTestTags.FIELD_COUNTRY_CODE).performTextReplacement("1")
assertThat(events).contains(DeleteAccountEvent.CountryCodeChanged("1"))
}
@Test
fun givenTheScreen_whenITypeANumber_thenIExpectNationalNumberChangedEvent() {
setContent(createState(formattedNumber = ""))
scrollTo(DeleteAccountTestTags.FIELD_NUMBER)
composeTestRule.onNodeWithTag(DeleteAccountTestTags.FIELD_NUMBER).performTextReplacement("6105550103")
assertThat(events).contains(DeleteAccountEvent.NationalNumberChanged("6105550103"))
}
@Test
fun givenNoCountry_whenTheScreenIsShown_thenIExpectThePickerToPromptForOne() {
setContent(createState(countryDisplayName = ""))
composeTestRule.onNodeWithText(context.getString(R.string.RegistrationActivity_select_your_country)).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 givenTheConfirmationDialog_whenIConfirm_thenIExpectDeletionConfirmedEvent() {
setContent(createState(dialog = Dialog.ConfirmDeletion))
composeTestRule.onNode(hasText(context.getString(R.string.DeleteAccountFragment__delete_account)) and hasAnyAncestor(hasTestTag(DeleteAccountTestTags.DIALOG_CONFIRM_DELETION))).performClick()
assertThat(events).contains(DeleteAccountEvent.DeletionConfirmed)
}
@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)
}
@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()
}
private fun setContent(state: DeleteAccountState) {
composeTestRule.setContent {
DeleteAccountScreen(
state = state,
onEvent = { events += it }
)
}
}
private fun scrollTo(testTag: String) {
composeTestRule.onNodeWithTag(DeleteAccountTestTags.SCROLLER)
.performScrollToNode(hasTestTag(testTag))
}
private fun createState(
regionCode: String = "US",
countryDisplayName: String = "United States",
countryCode: String = "1",
nationalNumber: String = "6105550103",
formattedNumber: String = "(610) 555-0103",
walletBalance: String? = null,
dialog: Dialog = Dialog.None
): DeleteAccountState {
return DeleteAccountState(
regionCode = regionCode,
countryDisplayName = countryDisplayName,
countryCode = countryCode,
nationalNumber = nationalNumber,
formattedNumber = formattedNumber,
walletBalance = walletBalance,
dialog = dialog
)
}
}