Update username recovery flow.
This commit is contained in:
parent
5e97a6b192
commit
16b78f0843
11 changed files with 119 additions and 25 deletions
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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))!!
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Add table
Reference in a new issue