mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-19 16:24:41 +01:00
Refactor account deletion screen.
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+30
@@ -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
|
||||
}
|
||||
+43
@@ -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
|
||||
}
|
||||
+493
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
+61
@@ -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>
|
||||
|
||||
+153
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user