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

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