diff --git a/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountEvent.kt b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountEvent.kt deleted file mode 100644 index df36a6a190..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountEvent.kt +++ /dev/null @@ -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 - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountFragment.java b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountFragment.java deleted file mode 100644 index 33f7949eb7..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountFragment.java +++ /dev/null @@ -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 formattedBalance) { - bullets.setText(buildBulletsText(formattedBalance)); - } - - private @NonNull CharSequence buildBulletsText(@NonNull Optional 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()); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountFragment.kt new file mode 100644 index 0000000000..53f2bf5ba9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountFragment.kt @@ -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() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountProgressDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountProgressDialog.kt deleted file mode 100644 index 0fc5b39a22..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountProgressDialog.kt +++ /dev/null @@ -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() - ) - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountRepository.java b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountRepository.java deleted file mode 100644 index 0a8a278c6e..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountRepository.java +++ /dev/null @@ -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 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 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); - } - }); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountRepository.kt new file mode 100644 index 0000000000..2c12978992 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountRepository.kt @@ -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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountViewModel.java deleted file mode 100644 index 12396cf5f6..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountViewModel.java +++ /dev/null @@ -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 regionCode; - private final LiveData countryDisplayName; - private final MutableLiveData nationalNumber; - private final SingleLiveEvent events; - private final LiveData> 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> getWalletBalance() { - return walletBalance; - } - - @NonNull LiveData getCountryDisplayName() { - return Transformations.distinctUntilChanged(countryDisplayName); - } - - @NonNull LiveData getRegionCode() { - return Transformations.distinctUntilChanged(regionCode); - } - - @NonNull SingleLiveEvent 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 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 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 create(@NonNull Class modelClass) { - return modelClass.cast(new DeleteAccountViewModel(repository)); - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountViewModel.kt new file mode 100644 index 0000000000..ad94938404 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountViewModel.kt @@ -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(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(Channel.BUFFERED) + + val state: StateFlow = _state.asStateFlow() + val actions: Flow = _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 + } +} diff --git a/app/src/main/res/layout/delete_account_country_code_text.xml b/app/src/main/res/layout/delete_account_country_code_text.xml deleted file mode 100644 index 946d65be52..0000000000 --- a/app/src/main/res/layout/delete_account_country_code_text.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/layout/delete_account_fragment.xml b/app/src/main/res/layout/delete_account_fragment.xml deleted file mode 100644 index 89ceb1a497..0000000000 --- a/app/src/main/res/layout/delete_account_fragment.xml +++ /dev/null @@ -1,145 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/delete_account_progress_dialog.xml b/app/src/main/res/layout/delete_account_progress_dialog.xml deleted file mode 100644 index 259ba0cc16..0000000000 --- a/app/src/main/res/layout/delete_account_progress_dialog.xml +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/navigation/app_settings_with_change_number.xml b/app/src/main/res/navigation/app_settings_with_change_number.xml index a21f90b599..cbc83fa41b 100644 --- a/app/src/main/res/navigation/app_settings_with_change_number.xml +++ b/app/src/main/res/navigation/app_settings_with_change_number.xml @@ -323,8 +323,7 @@ + android:label="delete_account_fragment"> Weak Wi-Fi. Switched to cellular. - - Deleting your account will: - Enter your phone number - Delete account - Delete your account info and profile photo - Delete all your messages - Delete %1$s in your payments account - No country code specified - No number specified - The phone number you entered doesn\'t match your account\'s. - Are you sure you want to delete your account? - This will delete your Signal account and reset the application. The app will close after the process is complete. - Failed to delete local data. You can manually clear it in the system application settings. - Launch App Settings - - Leaving groups… - - Deleting account… - - Canceling your subscription… - - Depending on the number of groups you\'re in, this might take a few minutes - - Deleting user data and resetting the app - - Account Not Deleted - - There was a problem completing the deletion process. Check your network connection and try again. - Search Countries diff --git a/app/src/test/java/org/thoughtcrime/securesms/delete/DeleteAccountViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/delete/DeleteAccountViewModelTest.kt new file mode 100644 index 0000000000..b340679151 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/delete/DeleteAccountViewModelTest.kt @@ -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(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() + + 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): List { + val collected = mutableListOf() + backgroundScope.launch { actions.toList(collected) } + return collected + } +} diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/deleteaccount/DeleteAccountAction.kt b/feature/app-settings/src/main/java/org/signal/appsettings/deleteaccount/DeleteAccountAction.kt new file mode 100644 index 0000000000..c5ccc86d1b --- /dev/null +++ b/feature/app-settings/src/main/java/org/signal/appsettings/deleteaccount/DeleteAccountAction.kt @@ -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 +} diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/deleteaccount/DeleteAccountEvent.kt b/feature/app-settings/src/main/java/org/signal/appsettings/deleteaccount/DeleteAccountEvent.kt new file mode 100644 index 0000000000..ebae8112ce --- /dev/null +++ b/feature/app-settings/src/main/java/org/signal/appsettings/deleteaccount/DeleteAccountEvent.kt @@ -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 +} diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/deleteaccount/DeleteAccountScreen.kt b/feature/app-settings/src/main/java/org/signal/appsettings/deleteaccount/DeleteAccountScreen.kt new file mode 100644 index 0000000000..253da59728 --- /dev/null +++ b/feature/app-settings/src/main/java/org/signal/appsettings/deleteaccount/DeleteAccountScreen.kt @@ -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 = {} + ) + } +} diff --git a/feature/app-settings/src/main/java/org/signal/appsettings/deleteaccount/DeleteAccountState.kt b/feature/app-settings/src/main/java/org/signal/appsettings/deleteaccount/DeleteAccountState.kt new file mode 100644 index 0000000000..7489c1779e --- /dev/null +++ b/feature/app-settings/src/main/java/org/signal/appsettings/deleteaccount/DeleteAccountState.kt @@ -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" + } +} diff --git a/feature/app-settings/src/main/res/drawable/ic_delete_account_warning_40.xml b/feature/app-settings/src/main/res/drawable/ic_delete_account_warning_40.xml new file mode 100644 index 0000000000..ee05474a03 --- /dev/null +++ b/feature/app-settings/src/main/res/drawable/ic_delete_account_warning_40.xml @@ -0,0 +1,9 @@ + + + diff --git a/feature/app-settings/src/main/res/values/strings.xml b/feature/app-settings/src/main/res/values/strings.xml index 165aa0aad0..d4cdce166e 100644 --- a/feature/app-settings/src/main/res/values/strings.xml +++ b/feature/app-settings/src/main/res/values/strings.xml @@ -167,4 +167,38 @@ Authenticator app renamed Couldn\'t save name. Check your connection and try again. + + + + Deleting your account will: + Enter your phone number + Delete account + Delete your account info and profile photo + Delete all your messages + Delete %1$s in your payments account + No country code specified + No number specified + The phone number you entered doesn\'t match your account\'s. + Are you sure you want to delete your account? + This will delete your Signal account and reset the application. The app will close after the process is complete. + Failed to delete local data. You can manually clear it in the system application settings. + Launch App Settings + + Leaving groups… + + Deleting account… + + Canceling your subscription… + + Depending on the number of groups you\'re in, this might take a few minutes + + Deleting user data and resetting the app + + Account Not Deleted + + There was a problem completing the deletion process. Check your network connection and try again. + + + Select your country + Phone number diff --git a/feature/app-settings/src/test/java/org/signal/appsettings/deleteaccount/DeleteAccountScreenTest.kt b/feature/app-settings/src/test/java/org/signal/appsettings/deleteaccount/DeleteAccountScreenTest.kt new file mode 100644 index 0000000000..72d3ab1ff2 --- /dev/null +++ b/feature/app-settings/src/test/java/org/signal/appsettings/deleteaccount/DeleteAccountScreenTest.kt @@ -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() + + @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 + ) + } +}