diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/FullScreenDialogFragment.java b/app/src/main/java/org/thoughtcrime/securesms/components/FullScreenDialogFragment.java index c7097e20cd..c0dd74c226 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/FullScreenDialogFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/FullScreenDialogFragment.java @@ -27,8 +27,7 @@ public abstract class FullScreenDialogFragment extends DialogFragment { public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); - setStyle(STYLE_NO_FRAME, ThemeUtil.isDarkTheme(requireActivity()) ? R.style.TextSecure_DarkTheme_FullScreenDialog - : R.style.TextSecure_LightTheme_FullScreenDialog); + setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_FullScreen); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/delete/Country.java b/app/src/main/java/org/thoughtcrime/securesms/delete/Country.java new file mode 100644 index 0000000000..a785e633f7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/delete/Country.java @@ -0,0 +1,43 @@ +package org.thoughtcrime.securesms.delete; + +import androidx.annotation.NonNull; + +import java.util.Objects; + +final class Country { + private final String displayName; + private final int code; + private final String normalized; + + Country(@NonNull String displayName, int code) { + this.displayName = displayName; + this.code = code; + this.normalized = displayName.toLowerCase(); + } + + int getCode() { + return code; + } + + @NonNull String getDisplayName() { + return displayName; + } + + public String getNormalizedDisplayName() { + return normalized; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Country country = (Country) o; + return displayName.equals(country.displayName) && + code == country.code; + } + + @Override + public int hashCode() { + return Objects.hash(displayName, code); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountCountryPickerAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountCountryPickerAdapter.java new file mode 100644 index 0000000000..eafaa4f6f8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountCountryPickerAdapter.java @@ -0,0 +1,72 @@ +package org.thoughtcrime.securesms.delete; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.core.util.Consumer; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; + +import java.util.Objects; + +class DeleteAccountCountryPickerAdapter extends ListAdapter { + + private final Callback callback; + + protected DeleteAccountCountryPickerAdapter(@NonNull Callback callback) { + super(new CountryDiffCallback()); + this.callback = callback; + } + + @Override + public @NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.delete_account_country_adapter_item, parent, false); + + return new ViewHolder(view, position -> callback.onItemSelected(getItem(position))); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + holder.textView.setText(getItem(position).getDisplayName()); + } + + static class ViewHolder extends RecyclerView.ViewHolder { + + private final TextView textView; + + public ViewHolder(@NonNull View itemView, @NonNull Consumer onItemClickedConsumer) { + super(itemView); + textView = itemView.findViewById(android.R.id.text1); + + itemView.setOnClickListener(unused -> { + if (getAdapterPosition() != RecyclerView.NO_POSITION) { + onItemClickedConsumer.accept(getAdapterPosition()); + } + }); + } + } + + private static class CountryDiffCallback extends DiffUtil.ItemCallback { + + @Override + public boolean areItemsTheSame(@NonNull Country oldItem, @NonNull Country newItem) { + return Objects.equals(oldItem.getCode(), newItem.getCode()); + } + + @Override + public boolean areContentsTheSame(@NonNull Country oldItem, @NonNull Country newItem) { + return Objects.equals(oldItem, newItem); + } + } + + interface Callback { + void onItemSelected(@NonNull Country country); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountCountryPickerFragment.java b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountCountryPickerFragment.java new file mode 100644 index 0000000000..9dd0655801 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountCountryPickerFragment.java @@ -0,0 +1,67 @@ +package org.thoughtcrime.securesms.delete; + +import android.os.Bundle; +import android.text.Editable; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.EditText; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.Toolbar; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.ViewModelProviders; +import androidx.recyclerview.widget.RecyclerView; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.util.ThemeUtil; +import org.thoughtcrime.securesms.util.text.AfterTextChanged; + +public class DeleteAccountCountryPickerFragment extends DialogFragment { + + private DeleteAccountViewModel viewModel; + + public static void show(@NonNull FragmentManager fragmentManager) { + new DeleteAccountCountryPickerFragment().show(fragmentManager, null); + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_FullScreen); + } + + @Override + public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.delete_account_country_picker, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + Toolbar toolbar = view.findViewById(R.id.delete_account_country_picker_toolbar); + EditText searchFilter = view.findViewById(R.id.delete_account_country_picker_filter); + RecyclerView recycler = view.findViewById(R.id.delete_account_country_picker_recycler); + DeleteAccountCountryPickerAdapter adapter = new DeleteAccountCountryPickerAdapter(this::onCountryPicked); + + recycler.setAdapter(adapter); + + toolbar.setNavigationOnClickListener(unused -> dismiss()); + + viewModel = ViewModelProviders.of(requireActivity()).get(DeleteAccountViewModel.class); + viewModel.getFilteredCountries().observe(getViewLifecycleOwner(), adapter::submitList); + + searchFilter.addTextChangedListener(new AfterTextChanged(this::onQueryChanged)); + } + + private void onQueryChanged(@NonNull Editable e) { + viewModel.onQueryChanged(e.toString()); + } + + private void onCountryPicked(@NonNull Country country) { + viewModel.onCountrySelected(country.getDisplayName(), country.getCode()); + dismissAllowingStateLoss(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountFragment.java b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountFragment.java new file mode 100644 index 0000000000..7426e10170 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountFragment.java @@ -0,0 +1,288 @@ +package org.thoughtcrime.securesms.delete; + +import android.annotation.SuppressLint; +import android.app.AlertDialog; +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.KeyEvent; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.inputmethod.EditorInfo; +import android.widget.ArrayAdapter; +import android.widget.EditText; +import android.widget.Spinner; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProviders; + +import com.google.android.material.snackbar.Snackbar; +import com.google.i18n.phonenumbers.AsYouTypeFormatter; +import com.google.i18n.phonenumbers.PhoneNumberUtil; + +import org.thoughtcrime.securesms.ApplicationPreferencesActivity; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.components.LabeledEditText; +import org.thoughtcrime.securesms.util.SpanUtil; +import org.thoughtcrime.securesms.util.ViewUtil; +import org.thoughtcrime.securesms.util.text.AfterTextChanged; +import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; + +public class DeleteAccountFragment extends Fragment { + + private ArrayAdapter countrySpinnerAdapter; + private LabeledEditText countryCode; + private LabeledEditText number; + private AsYouTypeFormatter countryFormatter; + private DeleteAccountViewModel viewModel; + private DialogInterface 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) { + TextView bullets = view.findViewById(R.id.delete_account_fragment_bullets); + Spinner countrySpinner = view.findViewById(R.id.delete_account_fragment_country_spinner); + View confirm = view.findViewById(R.id.delete_account_fragment_delete); + + countryCode = view.findViewById(R.id.delete_account_fragment_country_code); + number = view.findViewById(R.id.delete_account_fragment_number); + + viewModel = ViewModelProviders.of(requireActivity(), new DeleteAccountViewModel.Factory(new DeleteAccountRepository())) + .get(DeleteAccountViewModel.class); + viewModel.getCountryDisplayName().observe(getViewLifecycleOwner(), this::setCountryDisplay); + viewModel.getRegionCode().observe(getViewLifecycleOwner(), this::setCountryFormatter); + viewModel.getCountryCode().observe(getViewLifecycleOwner(), this::setCountryCode); + viewModel.getEvents().observe(getViewLifecycleOwner(), this::handleEvent); + + initializeNumberInput(); + + countryCode.getInput().addTextChangedListener(new AfterTextChanged(this::afterCountryCodeChanged)); + countryCode.getInput().setImeOptions(EditorInfo.IME_ACTION_NEXT); + confirm.setOnClickListener(unused -> viewModel.submit()); + + bullets.setText(buildBulletsText()); + initializeSpinner(countrySpinner); + } + + @Override + public void onResume() { + super.onResume(); + ((ApplicationPreferencesActivity) getActivity()).getSupportActionBar().setTitle(R.string.preferences__delete_account); + } + + private @NonNull CharSequence buildBulletsText() { + return new SpannableStringBuilder().append(SpanUtil.bullet(getString(R.string.DeleteAccountFragment__delete_your_account_info_and_profile_photo))) + .append("\n") + .append(SpanUtil.bullet(getString(R.string.DeleteAccountFragment__delete_all_your_messages))) + .append("\n") + .append(SpanUtil.bullet(getString(R.string.DeleteAccountFragment__remove_you_from_all_signal_groups))); + } + + @SuppressLint("ClickableViewAccessibility") + private void initializeSpinner(@NonNull Spinner countrySpinner) { + countrySpinnerAdapter = new ArrayAdapter<>(requireContext(), android.R.layout.simple_spinner_item); + countrySpinnerAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + + countrySpinner.setAdapter(countrySpinnerAdapter); + countrySpinner.setOnTouchListener((view, event) -> { + if (event.getAction() == MotionEvent.ACTION_UP) { + pickCountry(); + } + return true; + }); + countrySpinner.setOnKeyListener((view, keyCode, event) -> { + if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER && event.getAction() == KeyEvent.ACTION_UP) { + pickCountry(); + return true; + } + return false; + }); + } + + private void pickCountry() { + DeleteAccountCountryPickerFragment.show(requireFragmentManager()); + } + + private void setCountryCode(int countryCode) { + this.countryCode.setText(String.valueOf(countryCode)); + } + + private void setCountryDisplay(@NonNull String regionDisplayName) { + countrySpinnerAdapter.clear(); + if (TextUtils.isEmpty(regionDisplayName)) { + countrySpinnerAdapter.add(requireContext().getString(R.string.RegistrationActivity_select_your_country)); + } else { + countrySpinnerAdapter.add(regionDisplayName); + } + } + + private void setCountryFormatter(@Nullable String regionCode) { + PhoneNumberUtil util = PhoneNumberUtil.getInstance(); + + countryFormatter = regionCode != null ? util.getAsYouTypeFormatter(regionCode) : null; + + reformatText(number.getText()); + + if (!TextUtils.isEmpty(regionCode) && !regionCode.equals("ZZ")) { + number.requestFocus(); + + int numberLength = number.getText().length(); + number.getInput().setSelection(numberLength, numberLength); + } + } + + 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(null, 0); + return; + } + + viewModel.onCountrySelected(null, 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 DeleteAccountViewModel.EventType eventType) { + switch (eventType) { + 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 AlertDialog.Builder(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 AlertDialog.Builder(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, (dialog, which) -> { + dialog.dismiss(); + deletionProgressDialog = SimpleProgressDialog.show(requireContext()); + viewModel.deleteAccount(); + }) + .setCancelable(true) + .show(); + break; + case PIN_DELETION_FAILED: + case SERVER_DELETION_FAILED: + dismissDeletionProgressDialog(); + showNetworkDeletionFailedDialog(); + break; + case LOCAL_DATA_DELETION_FAILED: + dismissDeletionProgressDialog(); + showLocalDataDeletionFailedDialog(); + break; + default: + throw new IllegalStateException("Unknown error type: " + eventType); + } + } + + private void dismissDeletionProgressDialog() { + if (deletionProgressDialog != null) { + deletionProgressDialog.dismiss(); + deletionProgressDialog = null; + } + } + + private void showNetworkDeletionFailedDialog() { + new AlertDialog.Builder(requireContext()) + .setMessage(R.string.DeleteAccountFragment__failed_to_delete_account) + .setPositiveButton(android.R.string.ok, (dialog, which) -> dialog.dismiss()) + .setCancelable(true) + .show(); + } + + private void showLocalDataDeletionFailedDialog() { + new AlertDialog.Builder(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(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountRepository.java b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountRepository.java new file mode 100644 index 0000000000..393796a497 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountRepository.java @@ -0,0 +1,86 @@ +package org.thoughtcrime.securesms.delete; + +import androidx.annotation.NonNull; + +import com.annimon.stream.Stream; +import com.google.i18n.phonenumbers.PhoneNumberUtil; + +import org.signal.core.util.concurrent.SignalExecutors; +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.pin.KbsEnclaves; +import org.thoughtcrime.securesms.util.ServiceUtil; +import org.whispersystems.signalservice.api.util.PhoneNumberFormatter; +import org.whispersystems.signalservice.internal.contacts.crypto.UnauthenticatedResponseException; + +import java.io.IOException; +import java.text.Collator; +import java.util.Comparator; +import java.util.List; + +class DeleteAccountRepository { + private static final String TAG = Log.tag(DeleteAccountRepository.class); + + @NonNull List getAllCountries() { + return Stream.of(PhoneNumberUtil.getInstance().getSupportedRegions()) + .map(DeleteAccountRepository::getCountryForRegion) + .sorted(new RegionComparator()) + .toList(); + } + + void deleteAccount(@NonNull Runnable onFailureToRemovePin, + @NonNull Runnable onFailureToDeleteFromService, + @NonNull Runnable onFailureToDeleteLocalData) + { + SignalExecutors.BOUNDED.execute(() -> { + Log.i(TAG, "deleteAccount: attempting to remove pin..."); + + try { + ApplicationDependencies.getKeyBackupService(KbsEnclaves.current()).newPinChangeSession().removePin(); + } catch (UnauthenticatedResponseException | IOException e) { + Log.w(TAG, "deleteAccount: failed to remove PIN", e); + onFailureToRemovePin.run(); + return; + } + + Log.i(TAG, "deleteAccount: successfully removed pin."); + Log.i(TAG, "deleteAccount: attempting to delete account from server..."); + + try { + ApplicationDependencies.getSignalServiceAccountManager().deleteAccount(); + } catch (IOException e) { + Log.w(TAG, "deleteAccount: failed to delete account from signal service", e); + onFailureToDeleteFromService.run(); + 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(ApplicationDependencies.getApplication()).clearApplicationUserData()) { + Log.w(TAG, "deleteAccount: failed to delete user data"); + onFailureToDeleteLocalData.run(); + } + }); + } + + private static @NonNull Country getCountryForRegion(@NonNull String region) { + return new Country(PhoneNumberFormatter.getRegionDisplayName(region), + PhoneNumberUtil.getInstance().getCountryCodeForRegion(region)); + } + + private static class RegionComparator implements Comparator { + + private final Collator collator; + + RegionComparator() { + collator = Collator.getInstance(); + collator.setStrength(Collator.PRIMARY); + } + + @Override + public int compare(Country lhs, Country rhs) { + return collator.compare(lhs.getDisplayName(), rhs.getDisplayName()); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountViewModel.java new file mode 100644 index 0000000000..80622383d2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/delete/DeleteAccountViewModel.java @@ -0,0 +1,175 @@ +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.annimon.stream.Stream; +import com.google.i18n.phonenumbers.PhoneNumberUtil; +import com.google.i18n.phonenumbers.Phonenumber; + +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.registration.viewmodel.NumberViewState; +import org.thoughtcrime.securesms.util.DefaultValueLiveData; +import org.thoughtcrime.securesms.util.SingleLiveEvent; +import org.thoughtcrime.securesms.util.livedata.LiveDataUtil; + +import java.util.List; + +public class DeleteAccountViewModel extends ViewModel { + + private final DeleteAccountRepository repository; + private final List allCountries; + private final LiveData> filteredCountries; + private final LiveData regionCode; + private final MutableLiveData countryCode; + private final MutableLiveData countryDisplayName; + private final MutableLiveData nationalNumber; + private final MutableLiveData query; + private final SingleLiveEvent events; + private final LiveData numberViewState; + + public DeleteAccountViewModel(@NonNull DeleteAccountRepository repository) { + this.repository = repository; + this.allCountries = repository.getAllCountries(); + this.countryCode = new DefaultValueLiveData<>(NumberViewState.INITIAL.getCountryCode()); + this.nationalNumber = new DefaultValueLiveData<>(NumberViewState.INITIAL.getNationalNumber()); + this.countryDisplayName = new DefaultValueLiveData<>(NumberViewState.INITIAL.getCountryDisplayName()); + this.query = new DefaultValueLiveData<>(""); + this.regionCode = Transformations.map(countryCode, this::mapCountryCodeToRegionCode); + this.filteredCountries = Transformations.map(query, q -> Stream.of(allCountries).filter(country -> isMatch(q, country)).toList()); + + LiveData partialViewState = LiveDataUtil.combineLatest(countryCode, + countryDisplayName, + DeleteAccountViewModel::getPartialNumberViewState); + + this.numberViewState = LiveDataUtil.combineLatest(partialViewState, nationalNumber, DeleteAccountViewModel::getCompleteNumberViewState); + this.events = new SingleLiveEvent<>(); + } + + @NonNull LiveData> getFilteredCountries() { + return filteredCountries; + } + + @NonNull LiveData getCountryDisplayName() { + return Transformations.distinctUntilChanged(Transformations.map(numberViewState, NumberViewState::getCountryDisplayName)); + } + + @NonNull LiveData getRegionCode() { + return Transformations.distinctUntilChanged(regionCode); + } + + @NonNull LiveData getCountryCode() { + return Transformations.distinctUntilChanged(Transformations.map(numberViewState, NumberViewState::getCountryCode)); + } + + @NonNull SingleLiveEvent getEvents() { + return events; + } + + @Nullable Long getNationalNumber() { + Long number = nationalNumber.getValue(); + if (number == null || number == NumberViewState.INITIAL.getNationalNumber()) { + return null; + } else { + return number; + } + } + + void onQueryChanged(@NonNull String query) { + this.query.setValue(query.toLowerCase()); + } + + void deleteAccount() { + repository.deleteAccount(() -> events.postValue(EventType.PIN_DELETION_FAILED), + () -> events.postValue(EventType.SERVER_DELETION_FAILED), + () -> events.postValue(EventType.LOCAL_DATA_DELETION_FAILED)); + } + + void submit() { + Integer countryCode = this.countryCode.getValue(); + Long nationalNumber = this.nationalNumber.getValue(); + + if (countryCode == null || countryCode == 0) { + events.setValue(EventType.NO_COUNTRY_CODE); + return; + } + + if (nationalNumber == null) { + events.setValue(EventType.NO_NATIONAL_NUMBER); + return; + } + + Phonenumber.PhoneNumber number = new Phonenumber.PhoneNumber(); + number.setCountryCode(countryCode); + number.setNationalNumber(nationalNumber); + + if (PhoneNumberUtil.getInstance().isNumberMatch(number, Recipient.self().requireE164()) == PhoneNumberUtil.MatchType.EXACT_MATCH) { + events.setValue(EventType.CONFIRM_DELETION); + } else { + events.setValue(EventType.NOT_A_MATCH); + } + } + + void onCountrySelected(@Nullable String countryDisplayName, int countryCode) { + if (countryDisplayName != null) { + this.countryDisplayName.setValue(countryDisplayName); + } + + this.countryCode.setValue(countryCode); + } + + void setNationalNumber(long nationalNumber) { + this.nationalNumber.setValue(nationalNumber); + } + + private @NonNull String mapCountryCodeToRegionCode(int countryCode) { + return PhoneNumberUtil.getInstance().getRegionCodeForCountryCode(countryCode); + } + + private static @NonNull NumberViewState getPartialNumberViewState(int countryCode, @Nullable String countryDisplayName) { + return new NumberViewState.Builder().countryCode(countryCode).selectedCountryDisplayName(countryDisplayName).build(); + } + + private static @NonNull NumberViewState getCompleteNumberViewState(@NonNull NumberViewState partial, long nationalNumber) { + return partial.toBuilder().nationalNumber(nationalNumber).build(); + } + + private static boolean isMatch(@NonNull String query, @NonNull Country country) { + if (TextUtils.isEmpty(query)) { + return true; + } else { + return country.getNormalizedDisplayName().contains(query.toLowerCase()); + } + } + + enum EventType { + NO_COUNTRY_CODE, + NO_NATIONAL_NUMBER, + NOT_A_MATCH, + CONFIRM_DELETION, + PIN_DELETION_FAILED, + SERVER_DELETION_FAILED, + LOCAL_DATA_DELETION_FAILED + } + + 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/preferences/AdvancedPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/AdvancedPreferenceFragment.java index 88edbe1868..8759155460 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/AdvancedPreferenceFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/AdvancedPreferenceFragment.java @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.preferences; +import android.app.ActionBar; import android.app.Activity; import android.content.Context; import android.content.DialogInterface; @@ -8,11 +9,14 @@ import android.net.Uri; import android.os.AsyncTask; import android.os.Bundle; import android.provider.ContactsContract; +import android.view.View; +import android.view.ViewGroup; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; +import androidx.core.content.ContextCompat; import androidx.preference.CheckBoxPreference; import androidx.preference.Preference; @@ -24,11 +28,13 @@ import org.thoughtcrime.securesms.BuildConfig; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.contacts.ContactAccessor; import org.thoughtcrime.securesms.contacts.ContactIdentityManager; +import org.thoughtcrime.securesms.delete.DeleteAccountFragment; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogActivity; import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter; import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity; +import org.thoughtcrime.securesms.util.DynamicTheme; import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.task.ProgressDialogAsyncTask; @@ -45,6 +51,7 @@ public class AdvancedPreferenceFragment extends CorrectedPreferenceFragment { private static final String SUBMIT_DEBUG_LOG_PREF = "pref_submit_debug_logs"; private static final String INTERNAL_PREF = "pref_internal"; private static final String ADVANCED_PIN_PREF = "pref_advanced_pin_settings"; + private static final String DELETE_ACCOUNT = "pref_delete_account"; private static final int PICK_IDENTITY_CONTACT = 1; @@ -60,12 +67,7 @@ public class AdvancedPreferenceFragment extends CorrectedPreferenceFragment { Preference pinSettings = this.findPreference(ADVANCED_PIN_PREF); pinSettings.setOnPreferenceClickListener(preference -> { - requireActivity().getSupportFragmentManager() - .beginTransaction() - .setCustomAnimations(R.anim.slide_from_end, R.anim.slide_to_start, R.anim.slide_from_start, R.anim.slide_to_end) - .replace(android.R.id.content, new AdvancedPinPreferenceFragment()) - .addToBackStack(null) - .commit(); + getApplicationPreferencesActivity().pushFragment(new AdvancedPreferenceFragment()); return false; }); @@ -73,17 +75,32 @@ public class AdvancedPreferenceFragment extends CorrectedPreferenceFragment { internalPreference.setVisible(FeatureFlags.internalUser()); internalPreference.setOnPreferenceClickListener(preference -> { if (FeatureFlags.internalUser()) { - requireActivity().getSupportFragmentManager() - .beginTransaction() - .setCustomAnimations(R.anim.slide_from_end, R.anim.slide_to_start, R.anim.slide_from_start, R.anim.slide_to_end) - .replace(android.R.id.content, new InternalOptionsPreferenceFragment()) - .addToBackStack(null) - .commit(); + getApplicationPreferencesActivity().pushFragment(new InternalOptionsPreferenceFragment()); return true; } else { return false; } }); + + Preference deleteAccount = this.findPreference(DELETE_ACCOUNT); + deleteAccount.setOnPreferenceClickListener(preference -> { + getApplicationPreferencesActivity().pushFragment(new DeleteAccountFragment()); + return false; + }); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + view.setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.signal_background_tertiary)); + + View list = view.findViewById(R.id.recycler_view); + ViewGroup.LayoutParams params = list.getLayoutParams(); + + params.height = ActionBar.LayoutParams.WRAP_CONTENT; + list.setLayoutParams(params); + list.setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.signal_background_primary)); } @Override @@ -94,7 +111,7 @@ public class AdvancedPreferenceFragment extends CorrectedPreferenceFragment { @Override public void onResume() { super.onResume(); - ((ApplicationPreferencesActivity) getActivity()).getSupportActionBar().setTitle(R.string.preferences__advanced); + getApplicationPreferencesActivity().getSupportActionBar().setTitle(R.string.preferences__advanced); initializePushMessagingToggle(); } @@ -109,6 +126,10 @@ public class AdvancedPreferenceFragment extends CorrectedPreferenceFragment { } } + private @NonNull ApplicationPreferencesActivity getApplicationPreferencesActivity() { + return (ApplicationPreferencesActivity) requireActivity(); + } + private void initializePushMessagingToggle() { CheckBoxPreference preference = (CheckBoxPreference)this.findPreference(PUSH_MESSAGING_PREF); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/SpanUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/SpanUtil.java index e974f95b76..ec5cd2f085 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/SpanUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/SpanUtil.java @@ -6,6 +6,7 @@ import android.os.Build; import android.text.Spannable; import android.text.SpannableString; import android.text.style.AbsoluteSizeSpan; +import android.text.style.BulletSpan; import android.text.style.DynamicDrawableSpan; import android.text.style.ForegroundColorSpan; import android.text.style.ImageSpan; @@ -50,6 +51,12 @@ public class SpanUtil { return spannable; } + public static @NonNull CharSequence bullet(@NonNull CharSequence sequence) { + SpannableString spannable = new SpannableString(sequence); + spannable.setSpan(new BulletSpan(), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + return spannable; + } + public static CharSequence buildImageSpan(@NonNull Drawable drawable) { SpannableString imageSpan = new SpannableString(" "); diff --git a/app/src/main/res/drawable-night/ic_warning_40.xml b/app/src/main/res/drawable-night/ic_warning_40.xml new file mode 100644 index 0000000000..9bf386997d --- /dev/null +++ b/app/src/main/res/drawable-night/ic_warning_40.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_warning_40.xml b/app/src/main/res/drawable/ic_warning_40.xml new file mode 100644 index 0000000000..ee05474a03 --- /dev/null +++ b/app/src/main/res/drawable/ic_warning_40.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/delete_account_country_adapter_item.xml b/app/src/main/res/layout/delete_account_country_adapter_item.xml new file mode 100644 index 0000000000..f0b6d2fa14 --- /dev/null +++ b/app/src/main/res/layout/delete_account_country_adapter_item.xml @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/delete_account_country_picker.xml b/app/src/main/res/layout/delete_account_country_picker.xml new file mode 100644 index 0000000000..4c12e44500 --- /dev/null +++ b/app/src/main/res/layout/delete_account_country_picker.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/delete_account_fragment.xml b/app/src/main/res/layout/delete_account_fragment.xml new file mode 100644 index 0000000000..701fdf7dd5 --- /dev/null +++ b/app/src/main/res/layout/delete_account_fragment.xml @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/preference_danger.xml b/app/src/main/res/layout/preference_danger.xml new file mode 100644 index 0000000000..87348f85a7 --- /dev/null +++ b/app/src/main/res/layout/preference_danger.xml @@ -0,0 +1,19 @@ + + diff --git a/app/src/main/res/values/signal_styles.xml b/app/src/main/res/values/signal_styles.xml index c8b4198243..12bfb955d7 100644 --- a/app/src/main/res/values/signal_styles.xml +++ b/app/src/main/res/values/signal_styles.xml @@ -17,6 +17,10 @@ 48dp + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 790c205690..7f3dd3866f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2357,6 +2357,7 @@ Advanced PIN settings Free private messages and calls to Signal users Submit debug log + Delete account \'WiFi Calling\' compatibility mode Enable if your device uses SMS/MMS delivery over WiFi (only enable when \'WiFi Calling\' is enabled on your device) Incognito keyboard @@ -2987,6 +2988,25 @@ You (on another device) %1$s (on another device) + + Deleting your account will: + Enter your phone number + Delete account + Delete your account info and profile photo + Delete all your messages + Remove you from all Signal groups + 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 account. Do you have a network connection? + Failed to delete local data. You can manually clear it in the system application settings. + Launch App Settings + + + Search Countries + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index fce36bc5d6..388c01d308 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -39,14 +39,6 @@ @drawable/dialog_background @null - - - -