Update username recovery flow.

This commit is contained in:
Greyson Parrelli 2024-01-19 16:59:06 -05:00
parent 5e97a6b192
commit 16b78f0843
11 changed files with 119 additions and 25 deletions

View file

@ -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<UsernameEditFragment> {
val fragmentArgs = UsernameEditFragmentArgs.Builder().setIsInRegistration(isInRegistration).build().toBundle()
private fun createScenario(mode: UsernameEditMode = UsernameEditMode.NORMAL): FragmentScenario<UsernameEditFragment> {
val fragmentArgs = UsernameEditFragmentArgs.Builder().setMode(mode).build().toBundle()
return launchFragmentInContainer(
fragmentArgs = fragmentArgs,
themeResId = R.style.Signal_DayNight_NoActionBar

View file

@ -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 {

View file

@ -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)

View file

@ -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) {

View file

@ -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();
}

View file

@ -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
}

View file

@ -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<Event> = 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 <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(UsernameEditViewModel(isInRegistration))!!
return modelClass.cast(UsernameEditViewModel(mode))!!
}
}

View file

@ -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()

View file

@ -583,6 +583,21 @@
app:popUpTo="@id/app_settings"
app:popUpToInclusive="true" />
<action
android:id="@+id/action_direct_to_usernameRecovery"
app:destination="@id/create_username"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit"
app:popUpTo="@id/app_settings"
app:popUpToInclusive="true">
<argument
android:name="mode"
android:defaultValue="RECOVERY"
app:argType="org.thoughtcrime.securesms.profiles.manage.UsernameEditMode" />
</action>
<!-- endregion -->
<!-- Internal Settings -->
@ -923,6 +938,7 @@
<include app:graph="@navigation/username_link_settings" />
<include app:graph="@navigation/create_username" />
<include app:graph="@navigation/story_privacy_settings" />
</navigation>

View file

@ -11,9 +11,9 @@
tools:layout="@layout/username_edit_fragment">
<argument
android:name="is_in_registration"
android:defaultValue="false"
app:argType="boolean" />
android:name="mode"
android:defaultValue="NORMAL"
app:argType="org.thoughtcrime.securesms.profiles.manage.UsernameEditMode" />
</fragment>

View file

@ -562,6 +562,8 @@
<string name="ConversationListFragment__turn_your_notification_profile_on_or_off_here">Turn your notification profile on or off here.</string>
<!-- Message shown in top toast to indicate the named profile is on -->
<string name="ConversationListFragment__s_on">%1$s on</string>
<!-- -->
<string name="ConversationListFragment_username_recovered_toast">Your QR code and link have been reset and your username is %1$s</string>
<!-- ConversationListItem -->
<string name="ConversationListItem_key_exchange_message">Key exchange message</string>
@ -2241,6 +2243,8 @@
<string name="UsernameEditFragment__invalid_username_enter_a_minimum_of_d_digits">Invalid username, enter a minimum of %1$d digits.</string>
<!-- Displayed when the chosen discriminator is too long -->
<string name="UsernameEditFragment__invalid_username_enter_a_maximum_of_d_digits">Invalid username, enter a maximum of %1$d digits.</string>
<!-- The body of an alert dialog asking the user to confirm that they want to recover their username -->
<string name="UsernameEditFragment_recovery_dialog_confirmation">Recovering your username will reset your existing QR code and link. Are you sure?</string>
<plurals name="UserNotificationMigrationJob_d_contacts_are_on_signal">
<item quantity="one">%d contact is on Signal!</item>