From 16b78f0843770fc038d2404cf0fd78e3c60fc9c7 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Fri, 19 Jan 2024 16:59:06 -0500 Subject: [PATCH] Update username recovery flow. --- .../manage/UsernameEditFragmentTest.kt | 10 ++-- .../settings/app/AppSettingsActivity.kt | 7 ++- .../app/internal/InternalSettingsFragment.kt | 2 +- .../ConversationListFragment.java | 11 ++++- .../profiles/manage/UsernameEditFragment.java | 46 ++++++++++++++++--- .../profiles/manage/UsernameEditMode.kt | 19 ++++++++ .../profiles/manage/UsernameEditViewModel.kt | 20 +++++--- .../profiles/username/AddAUsernameActivity.kt | 3 +- app/src/main/res/navigation/app_settings.xml | 16 +++++++ .../main/res/navigation/create_username.xml | 6 +-- app/src/main/res/values/strings.xml | 4 ++ 11 files changed, 119 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditMode.kt diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditFragmentTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditFragmentTest.kt index c545416a14..1bf3ab3c8f 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditFragmentTest.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditFragmentTest.kt @@ -59,7 +59,7 @@ class UsernameEditFragmentTest { @Test fun testUsernameCreationInRegistration() { - val scenario = createScenario(true) + val scenario = createScenario(UsernameEditMode.REGISTRATION) scenario.moveToState(Lifecycle.State.RESUMED) @@ -77,7 +77,7 @@ class UsernameEditFragmentTest { @Ignore("Flakey espresso test.") @Test fun testUsernameCreationOutsideOfRegistration() { - val scenario = createScenario() + val scenario = createScenario(UsernameEditMode.NORMAL) scenario.moveToState(Lifecycle.State.RESUMED) @@ -108,7 +108,7 @@ class UsernameEditFragmentTest { } ) - val scenario = createScenario(isInRegistration = true) + val scenario = createScenario(UsernameEditMode.REGISTRATION) scenario.moveToState(Lifecycle.State.RESUMED) onView(withId(R.id.username_text)).perform(typeText(nickname)) @@ -132,8 +132,8 @@ class UsernameEditFragmentTest { onView(withId(R.id.username_done_button)).check(matches(isNotEnabled())) } - private fun createScenario(isInRegistration: Boolean = false): FragmentScenario { - val fragmentArgs = UsernameEditFragmentArgs.Builder().setIsInRegistration(isInRegistration).build().toBundle() + private fun createScenario(mode: UsernameEditMode = UsernameEditMode.NORMAL): FragmentScenario { + val fragmentArgs = UsernameEditFragmentArgs.Builder().setMode(mode).build().toBundle() return launchFragmentInContainer( fragmentArgs = fragmentArgs, themeResId = R.style.Signal_DayNight_NoActionBar diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt index d42f820669..99fbb98f42 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/AppSettingsActivity.kt @@ -66,6 +66,7 @@ class AppSettingsActivity : DSLSettingsActivity(), DonationPaymentComponent { StartLocation.PRIVACY -> AppSettingsFragmentDirections.actionDirectToPrivacy() StartLocation.LINKED_DEVICES -> AppSettingsFragmentDirections.actionDirectToDevices() StartLocation.USERNAME_LINK -> AppSettingsFragmentDirections.actionDirectToUsernameLinkSettings() + StartLocation.RECOVER_USERNAME -> AppSettingsFragmentDirections.actionDirectToUsernameRecovery() } } @@ -192,6 +193,9 @@ class AppSettingsActivity : DSLSettingsActivity(), DonationPaymentComponent { @JvmStatic fun usernameLinkSettings(context: Context): Intent = getIntentForStartLocation(context, StartLocation.USERNAME_LINK) + @JvmStatic + fun usernameRecovery(context: Context): Intent = getIntentForStartLocation(context, StartLocation.RECOVER_USERNAME) + private fun getIntentForStartLocation(context: Context, startLocation: StartLocation): Intent { return Intent(context, AppSettingsActivity::class.java) .putExtra(ARG_NAV_GRAPH, R.navigation.app_settings) @@ -214,7 +218,8 @@ class AppSettingsActivity : DSLSettingsActivity(), DonationPaymentComponent { NOTIFICATION_PROFILE_DETAILS(11), PRIVACY(12), LINKED_DEVICES(13), - USERNAME_LINK(14); + USERNAME_LINK(14), + RECOVER_USERNAME(15); companion object { fun fromCode(code: Int?): StartLocation { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt index f91c4c019d..e57e429c83 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt @@ -731,7 +731,7 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter .setTitle("Corrupt your username?") .setMessage("Are you sure? You might not be able to get your original username back.") .setPositiveButton(android.R.string.ok) { _, _ -> - val random = "${(1..5).map { ('a'..'z').random() }.joinToString(separator = "") }.${Random.nextInt(1, 100)}" + val random = "${(1..5).map { ('a'..'z').random() }.joinToString(separator = "") }.${Random.nextInt(10, 100)}" SignalStore.account().username = random SignalDatabase.recipients.setUsername(Recipient.self().id, random) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index 1866ca6d2f..0e44e0abb7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -44,6 +44,9 @@ import android.widget.FrameLayout; import android.widget.Toast; import androidx.activity.OnBackPressedCallback; +import androidx.activity.result.ActivityResultCallback; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.ColorInt; import androidx.annotation.DrawableRes; import androidx.annotation.IdRes; @@ -155,6 +158,7 @@ import org.thoughtcrime.securesms.notifications.MarkReadReceiver; import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.profiles.manage.EditProfileActivity; +import org.thoughtcrime.securesms.profiles.manage.UsernameEditFragment; import org.thoughtcrime.securesms.ratelimit.RecaptchaProofBottomSheetFragment; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; @@ -700,6 +704,11 @@ public class ConversationListFragment extends MainFragment implements ActionMode Snackbar.make(fab, R.string.ConfirmKbsPinFragment__pin_created, Snackbar.LENGTH_LONG).show(); viewModel.onMegaphoneCompleted(Megaphones.Event.PINS_FOR_ALL); } + + if (resultCode == RESULT_OK && requestCode == UsernameEditFragment.REQUEST_CODE) { + String snackbarString = getString(R.string.ConversationListFragment_username_recovered_toast, SignalStore.account().getUsername()); + Snackbar.make(fab, snackbarString, Snackbar.LENGTH_LONG).show(); + } } private void onConversationClicked(@NonNull ThreadRecord threadRecord) { @@ -791,7 +800,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode } else if (reminderActionId == R.id.reminder_action_cds_permanent_error_learn_more) { CdsPermanentErrorBottomSheet.show(getChildFragmentManager()); } else if (reminderActionId == R.id.reminder_action_fix_username_and_link) { - startActivity(EditProfileActivity.getIntent(requireContext())); + startActivityForResult(AppSettingsActivity.usernameRecovery(requireContext()), UsernameEditFragment.REQUEST_CODE); } else if (reminderActionId == R.id.reminder_action_fix_username_link) { startActivity(AppSettingsActivity.usernameLinkSettings(requireContext())); } else if (reminderActionId == R.id.reminder_action_re_register) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditFragment.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditFragment.java index 39899d9ed0..8e6e8b0654 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditFragment.java @@ -1,6 +1,7 @@ package org.thoughtcrime.securesms.profiles.manage; import android.animation.LayoutTransition; +import android.app.Activity; import android.content.Intent; import android.content.res.ColorStateList; import android.os.Bundle; @@ -30,6 +31,7 @@ import org.thoughtcrime.securesms.PassphraseRequiredActivity; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher; import org.thoughtcrime.securesms.databinding.UsernameEditFragmentBinding; +import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.util.FragmentResultContract; import org.thoughtcrime.securesms.util.UsernameUtil; import org.thoughtcrime.securesms.util.ViewUtil; @@ -40,6 +42,9 @@ public class UsernameEditFragment extends LoggingFragment { private static final float DISABLED_ALPHA = 0.5f; public static final String IGNORE_TEXT_CHANGE_EVENT = "ignore.text.change.event"; + public static final int REQUEST_CODE = 4242; + public static final String EXTRA_USERNAME = "username"; + private UsernameEditViewModel viewModel; private UsernameEditFragmentBinding binding; private LifecycleDisposable lifecycleDisposable; @@ -75,13 +80,19 @@ public class UsernameEditFragment extends LoggingFragment { args = new UsernameEditFragmentArgs.Builder().build(); } - if (args.getIsInRegistration()) { + if (args.getMode() == UsernameEditMode.REGISTRATION) { binding.toolbar.setNavigationIcon(null); binding.toolbar.setTitle(R.string.UsernameEditFragment__add_a_username); binding.usernameSkipButton.setVisibility(View.VISIBLE); binding.usernameDoneButton.setVisibility(View.VISIBLE); } else { - binding.toolbar.setNavigationOnClickListener(v -> Navigation.findNavController(view).popBackStack()); + binding.toolbar.setNavigationOnClickListener(v -> { + if (args.getMode() == UsernameEditMode.RECOVERY) { + getActivity().finish(); + } else { + Navigation.findNavController(view).popBackStack(); + } + }); binding.usernameSubmitButton.setVisibility(View.VISIBLE); } @@ -90,13 +101,13 @@ public class UsernameEditFragment extends LoggingFragment { lifecycleDisposable = new LifecycleDisposable(); lifecycleDisposable.bindTo(getViewLifecycleOwner()); - viewModel = new ViewModelProvider(this, new UsernameEditViewModel.Factory(args.getIsInRegistration())).get(UsernameEditViewModel.class); + viewModel = new ViewModelProvider(this, new UsernameEditViewModel.Factory(args.getMode())).get(UsernameEditViewModel.class); lifecycleDisposable.add(viewModel.getUiState().subscribe(this::onUiStateChanged)); lifecycleDisposable.add(viewModel.getEvents().subscribe(this::onEvent)); lifecycleDisposable.add(viewModel.getUsernameInputState().subscribe(this::presentUsernameInputState)); - binding.usernameSubmitButton.setOnClickListener(v -> viewModel.onUsernameSubmitted()); + binding.usernameSubmitButton.setOnClickListener(v -> promptOrSubmitUsername()); binding.usernameDeleteButton.setOnClickListener(v -> viewModel.onUsernameDeleted()); binding.usernameDoneButton.setOnClickListener(v -> viewModel.onUsernameSubmitted()); binding.usernameSkipButton.setOnClickListener(v -> viewModel.onUsernameSkipped()); @@ -121,7 +132,7 @@ public class UsernameEditFragment extends LoggingFragment { binding.discriminatorText.setOnEditorActionListener((v, actionId, event) -> { if (actionId == EditorInfo.IME_ACTION_DONE) { - viewModel.onUsernameSubmitted(); + promptOrSubmitUsername(); return true; } return false; @@ -140,6 +151,22 @@ public class UsernameEditFragment extends LoggingFragment { binding = null; } + private void promptOrSubmitUsername() { + if (args.getMode() == UsernameEditMode.RECOVERY) { + new MaterialAlertDialogBuilder(requireContext()) + .setMessage(R.string.UsernameEditFragment_recovery_dialog_confirmation) + .setPositiveButton(android.R.string.ok, ((dialog, which) -> { + viewModel.onUsernameSubmitted(); + dialog.dismiss(); + })) + .setNegativeButton(android.R.string.cancel, (dialog, which) -> dialog.dismiss()) + .show(); + } else { + viewModel.onUsernameSubmitted(); + } + } + + private void onLearnMore(@Nullable View unused) { new MaterialAlertDialogBuilder(requireContext()) .setTitle(new StringBuilder("#\n").append(getString(R.string.UsernameEditFragment__what_is_this_number))) @@ -182,7 +209,7 @@ public class UsernameEditFragment extends LoggingFragment { } private void presentButtonState(@NonNull UsernameEditViewModel.ButtonState buttonState) { - if (args.getIsInRegistration()) { + if (args.getMode() == UsernameEditMode.REGISTRATION) { presentRegistrationButtonState(buttonState); } else { presentProfileUpdateButtonState(buttonState); @@ -306,6 +333,9 @@ public class UsernameEditFragment extends LoggingFragment { switch (event) { case SUBMIT_SUCCESS: ResultContract.setUsernameCreated(getParentFragmentManager()); + if (getActivity() != null) { + getActivity().setResult(Activity.RESULT_OK); + } closeScreen(); break; case SUBMIT_FAIL_TAKEN: @@ -328,8 +358,10 @@ public class UsernameEditFragment extends LoggingFragment { } private void closeScreen() { - if (args.getIsInRegistration()) { + if (args.getMode() == UsernameEditMode.REGISTRATION) { finishAndStartNextIntent(); + } else if (args.getMode() == UsernameEditMode.RECOVERY) { + getActivity().finish(); } else { NavHostFragment.findNavController(this).popBackStack(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditMode.kt b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditMode.kt new file mode 100644 index 0000000000..a99936ff58 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditMode.kt @@ -0,0 +1,19 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.profiles.manage + +enum class UsernameEditMode { + /** A typical launch, no special conditions. */ + NORMAL, + + /** Screen is launched during registration, includes special first-time flows. */ + REGISTRATION, + + /** Screen was launched because the username was in a bad state and needs to be recovered. Shows a special dialog. */ + RECOVERY; + + val allowsDelete get() = this == NORMAL || this == RECOVERY +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditViewModel.kt index 5319c378b3..88b0cabc2a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/manage/UsernameEditViewModel.kt @@ -37,7 +37,7 @@ import java.util.concurrent.TimeUnit * * The nickname is user-controlled, whereas the discriminator is controlled by the server. */ -internal class UsernameEditViewModel private constructor(private val isInRegistration: Boolean) : ViewModel() { +internal class UsernameEditViewModel private constructor(private val mode: UsernameEditMode) : ViewModel() { private val events: PublishSubject = PublishSubject.create() private val disposables: CompositeDisposable = CompositeDisposable() @@ -67,6 +67,10 @@ internal class UsernameEditViewModel private constructor(private val isInRegistr .filter { it.stateModifier == UsernameEditStateMachine.StateModifier.USER } .debounce(NICKNAME_PUBLISHER_DEBOUNCE_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS) .subscribeBy(onNext = this::onUsernameStateUpdateDebounced) + + if (mode == UsernameEditMode.RECOVERY) { + onNicknameUpdated(SignalStore.account().username?.split(Usernames.DELIMITER)?.first() ?: "") + } } override fun onCleared() { @@ -79,7 +83,7 @@ internal class UsernameEditViewModel private constructor(private val isInRegistr uiState.update { state: State -> if (nickname.isBlank() && SignalStore.account().username != null) { return@update State( - buttonState = if (isInRegistration) ButtonState.SUBMIT_DISABLED else ButtonState.DELETE, + buttonState = if (mode.allowsDelete) ButtonState.DELETE else ButtonState.SUBMIT_DISABLED, usernameStatus = UsernameStatus.NONE, usernameState = UsernameState.NoUsername ) @@ -101,7 +105,7 @@ internal class UsernameEditViewModel private constructor(private val isInRegistr uiState.update { state: State -> if (discriminator.isBlank() && SignalStore.account().username != null) { return@update State( - buttonState = if (isInRegistration) ButtonState.SUBMIT_DISABLED else ButtonState.DELETE, + buttonState = if (mode.allowsDelete) ButtonState.DELETE else ButtonState.SUBMIT_DISABLED, usernameStatus = UsernameStatus.NONE, usernameState = UsernameState.NoUsername ) @@ -140,7 +144,7 @@ internal class UsernameEditViewModel private constructor(private val isInRegistr return } - if (usernameState.requireUsername().username == SignalStore.account().username) { + if (usernameState.requireUsername().username == SignalStore.account().username && mode != UsernameEditMode.RECOVERY) { Log.d(TAG, "Username was submitted, but was identical to the current username. Ignoring.") uiState.update { it.copy(buttonState = ButtonState.SUBMIT_DISABLED, usernameStatus = UsernameStatus.NONE) } return @@ -219,6 +223,10 @@ internal class UsernameEditViewModel private constructor(private val isInRegistr } private fun isCaseChange(state: UsernameEditStateMachine.State): Boolean { + if (mode == UsernameEditMode.RECOVERY) { + return false + } + if (state is UsernameEditStateMachine.UserEnteredDiscriminator || state is UsernameEditStateMachine.UserEnteredNicknameAndDiscriminator) { return false } @@ -358,9 +366,9 @@ internal class UsernameEditViewModel private constructor(private val isInRegistr NETWORK_FAILURE, SUBMIT_SUCCESS, DELETE_SUCCESS, SUBMIT_FAIL_INVALID, SUBMIT_FAIL_TAKEN, SKIPPED } - class Factory(private val isInRegistration: Boolean) : ViewModelProvider.Factory { + class Factory(private val mode: UsernameEditMode) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { - return modelClass.cast(UsernameEditViewModel(isInRegistration))!! + return modelClass.cast(UsernameEditViewModel(mode))!! } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/username/AddAUsernameActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/profiles/username/AddAUsernameActivity.kt index 968cf1adca..214eebe948 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/username/AddAUsernameActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/username/AddAUsernameActivity.kt @@ -5,6 +5,7 @@ import androidx.navigation.fragment.NavHostFragment import org.thoughtcrime.securesms.BaseActivity import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.profiles.manage.UsernameEditFragmentArgs +import org.thoughtcrime.securesms.profiles.manage.UsernameEditMode import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme import org.thoughtcrime.securesms.util.DynamicTheme @@ -23,7 +24,7 @@ class AddAUsernameActivity : BaseActivity() { R.id.fragment_container, NavHostFragment.create( R.navigation.create_username, - UsernameEditFragmentArgs.Builder().setIsInRegistration(true).build().toBundle() + UsernameEditFragmentArgs.Builder().setMode(UsernameEditMode.REGISTRATION).build().toBundle() ) ) .commit() diff --git a/app/src/main/res/navigation/app_settings.xml b/app/src/main/res/navigation/app_settings.xml index 0af5687f87..3f3c101ab4 100644 --- a/app/src/main/res/navigation/app_settings.xml +++ b/app/src/main/res/navigation/app_settings.xml @@ -583,6 +583,21 @@ app:popUpTo="@id/app_settings" app:popUpToInclusive="true" /> + + + + @@ -923,6 +938,7 @@ + \ No newline at end of file diff --git a/app/src/main/res/navigation/create_username.xml b/app/src/main/res/navigation/create_username.xml index 75a36d7ebb..73ad8c0949 100644 --- a/app/src/main/res/navigation/create_username.xml +++ b/app/src/main/res/navigation/create_username.xml @@ -11,9 +11,9 @@ tools:layout="@layout/username_edit_fragment"> + android:name="mode" + android:defaultValue="NORMAL" + app:argType="org.thoughtcrime.securesms.profiles.manage.UsernameEditMode" /> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 003621bb21..794ce446eb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -562,6 +562,8 @@ Turn your notification profile on or off here. %1$s on + + Your QR code and link have been reset and your username is %1$s Key exchange message @@ -2241,6 +2243,8 @@ Invalid username, enter a minimum of %1$d digits. Invalid username, enter a maximum of %1$d digits. + + Recovering your username will reset your existing QR code and link. Are you sure? %d contact is on Signal!