diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberEnterCodeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberEnterCodeFragment.kt index 42f195a960..f05044acc2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberEnterCodeFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberEnterCodeFragment.kt @@ -33,6 +33,7 @@ import org.thoughtcrime.securesms.registration.sms.ReceivedSmsEvent import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener import org.thoughtcrime.securesms.util.navigation.safeNavigate import org.thoughtcrime.securesms.util.visible +import kotlin.math.ceil import kotlin.time.Duration.Companion.milliseconds /** @@ -168,7 +169,7 @@ class ChangeNumberEnterCodeFragment : LoggingFragment(R.layout.fragment_change_n is ChangeNumberResult.RegistrationLocked -> presentRegistrationLocked(result.timeRemaining) is ChangeNumberResult.AuthorizationFailed -> presentIncorrectCodeDialog() is ChangeNumberResult.AttemptsExhausted -> presentAccountLocked() - is ChangeNumberResult.RateLimited -> presentRateLimitedDialog() + is ChangeNumberResult.RateLimited -> presentRateLimitedDialog(result.timeRemaining) else -> presentGenericError(result) } @@ -195,13 +196,25 @@ class ChangeNumberEnterCodeFragment : LoggingFragment(R.layout.fragment_change_n ) } - private fun presentRateLimitedDialog() { + private fun presentRateLimitedDialog(retryAfterSeconds: Long = 0) { binding.codeEntryLayout.keyboard.displayFailure().addListener( object : AssertedSuccessListener() { override fun onSuccess(result: Boolean?) { MaterialAlertDialogBuilder(requireContext()).apply { setTitle(R.string.RegistrationActivity_too_many_attempts) - setMessage(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later) + if (retryAfterSeconds > 0) { + val minutes = ceil(retryAfterSeconds / 60.0).toInt().coerceAtLeast(1) + setMessage( + if (minutes >= 60) { + val hours = ceil(minutes / 60.0).toInt() + resources.getQuantityString(R.plurals.ChangeNumberEnterCodeFragment__too_many_attempts_try_again_in_hours, hours, hours) + } else { + resources.getQuantityString(R.plurals.ChangeNumberEnterCodeFragment__too_many_attempts_try_again_in_minutes, minutes, minutes) + } + ) + } else { + setMessage(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later) + } setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> binding.codeEntryLayout.callMeCountDown.visibility = View.VISIBLE binding.codeEntryLayout.resendSmsCountDown.visibility = View.VISIBLE diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberFragment.kt index 6eeadb88cc..6900ce76c4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberFragment.kt @@ -23,7 +23,6 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.navigation.NavController @@ -34,7 +33,10 @@ import org.signal.core.ui.compose.DayNightPreviews import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.Scaffolds import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.util.RemoteConfig import org.thoughtcrime.securesms.util.navigation.safeNavigate +import kotlin.time.Duration.Companion.milliseconds class ChangeNumberFragment : ComposeFragment() { @@ -46,10 +48,25 @@ class ChangeNumberFragment : ComposeFragment() { navController.popBackStack() }, onContinueClick = { - navController.safeNavigate(ChangeNumberFragmentDirections.actionChangePhoneNumberFragmentToEnterPhoneNumberChangeFragment()) + val remainingWaitSeconds = remainingPostRegistrationWaitSeconds() + if (remainingWaitSeconds > 0) { + ChangeNumberPostRegistrationWaitSheet.show(parentFragmentManager, remainingWaitSeconds) + } else { + navController.safeNavigate(ChangeNumberFragmentDirections.actionChangePhoneNumberFragmentToEnterPhoneNumberChangeFragment()) + } } ) } + + private fun remainingPostRegistrationWaitSeconds(): Long { + val registeredAt = SignalStore.account.registeredAtTimestamp + if (registeredAt <= 0) { + return 0 + } + val waitingPeriodSeconds = RemoteConfig.changeNumberPostRegistrationWaitingPeriodSeconds + val elapsedSeconds = (System.currentTimeMillis() - registeredAt).milliseconds.inWholeSeconds + return (waitingPeriodSeconds - elapsedSeconds).coerceAtLeast(0) + } } @Composable @@ -73,7 +90,7 @@ fun ChangeNumberScreen( .padding(horizontal = 32.dp) ) { Image( - painter = painterResource(id = R.drawable.change_number_hero_image), + painter = painterResource(id = R.drawable.change_number), contentDescription = null, modifier = Modifier .padding(top = 20.dp) @@ -83,7 +100,6 @@ fun ChangeNumberScreen( text = stringResource(id = R.string.AccountSettingsFragment__change_phone_number), style = MaterialTheme.typography.headlineMedium, textAlign = TextAlign.Center, - fontWeight = FontWeight.Bold, modifier = Modifier.padding(top = 24.dp) ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberPostRegistrationWaitSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberPostRegistrationWaitSheet.kt new file mode 100644 index 0000000000..c2d41c9c26 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/changenumber/ChangeNumberPostRegistrationWaitSheet.kt @@ -0,0 +1,151 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.changenumber + +import android.os.Bundle +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentManager +import org.signal.core.ui.BottomSheetUtil +import org.signal.core.ui.compose.BottomSheets +import org.signal.core.ui.compose.Buttons +import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.horizontalGutters +import org.thoughtcrime.securesms.R +import kotlin.math.ceil + +/** + * Sheet shown when the user attempts to change their phone number before the + * post-registration waiting period has elapsed. + */ +class ChangeNumberPostRegistrationWaitSheet : ComposeBottomSheetDialogFragment() { + + companion object { + private const val ARG_REMAINING_SECONDS = "arg.remaining_seconds" + + @JvmStatic + fun show(fragmentManager: FragmentManager, remainingSeconds: Long) { + ChangeNumberPostRegistrationWaitSheet().apply { + arguments = Bundle().apply { + putLong(ARG_REMAINING_SECONDS, remainingSeconds) + } + }.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) + } + } + + private val remainingSeconds: Long + get() = requireArguments().getLong(ARG_REMAINING_SECONDS) + + @Composable + override fun SheetContent() { + SheetContent( + remainingSeconds = remainingSeconds, + onDismiss = { dismissAllowingStateLoss() } + ) + } +} + +@Composable +private fun SheetContent( + remainingSeconds: Long, + onDismiss: () -> Unit +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .horizontalGutters(gutterSize = 36.dp) + ) { + BottomSheets.Handle() + + Image( + painter = painterResource(R.drawable.change_number_error), + contentDescription = null, + modifier = Modifier + .padding(top = 26.dp) + ) + + Text( + text = stringResource(R.string.ChangeNumberPostRegistrationWaitSheet__title), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(top = 16.dp) + ) + + Text( + text = stringResource(R.string.ChangeNumberPostRegistrationWaitSheet__body), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 12.dp) + ) + + Text( + text = formatTryAgainIn(remainingSeconds), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 16.dp) + ) + + Buttons.LargeTonal( + onClick = onDismiss, + modifier = Modifier + .fillMaxWidth() + .padding(top = 32.dp, bottom = 24.dp, start = 12.dp, end = 12.dp) + ) { + Text(stringResource(R.string.ChangeNumberPostRegistrationWaitSheet__ok)) + } + } +} + +@DayNightPreviews +@Composable +private fun SheetContentMinutesPreview() { + Previews.BottomSheetContentPreview { + SheetContent( + remainingSeconds = 25 * 60, + onDismiss = {} + ) + } +} + +@DayNightPreviews +@Composable +private fun SheetContentHoursPreview() { + Previews.BottomSheetContentPreview { + SheetContent( + remainingSeconds = 2 * 60 * 60, + onDismiss = {} + ) + } +} + +@Composable +private fun formatTryAgainIn(remainingSeconds: Long): String { + val minutes = ceil(remainingSeconds / 60.0).toInt().coerceAtLeast(1) + return if (minutes >= 60) { + val hours = ceil(minutes / 60.0).toInt() + pluralStringResource(R.plurals.ChangeNumberPostRegistrationWaitSheet__try_again_in_hours, hours, hours) + } else { + pluralStringResource(R.plurals.ChangeNumberPostRegistrationWaitSheet__try_again_in_minutes, minutes, minutes) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt index 98be79057a..5b829b475b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/AccountValues.kt @@ -434,7 +434,7 @@ class AccountValues internal constructor(store: KeyValueStore, context: Context) val isRegistered: Boolean get() = getBoolean(KEY_IS_REGISTERED, false) - fun setRegistered(registered: Boolean) { + fun setRegistered(registered: Boolean, isAciChanged: Boolean = false) { Log.i(TAG, "Setting push registered: $registered", Throwable()) val previous = isRegistered @@ -451,7 +451,7 @@ class AccountValues internal constructor(store: KeyValueStore, context: Context) clearLocalCredentials() } - if (!previous && registered) { + if (registered && (!previous || isAciChanged)) { registeredAtTimestamp = System.currentTimeMillis() } else if (!registered) { registeredAtTimestamp = -1 diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/data/RegistrationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/data/RegistrationRepository.kt index 628abe3875..6f2a5d24cd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/data/RegistrationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/data/RegistrationRepository.kt @@ -184,6 +184,7 @@ object RegistrationRepository { val aci: ACI = ACI.parseOrThrow(data.aci) val pni: PNI = PNI.parseOrThrow(data.pni) val hasPin: Boolean = data.hasPin + val isAciChanged: Boolean = SignalStore.account.aci != aci SignalStore.account.setAci(aci) SignalStore.account.setPni(pni) @@ -232,7 +233,7 @@ object RegistrationRepository { } SignalStore.account.setServicePassword(data.servicePassword) - SignalStore.account.setRegistered(true) + SignalStore.account.setRegistered(registered = true, isAciChanged = isAciChanged) TextSecurePreferences.setPromptedPushRegistration(context, true) TextSecurePreferences.setUnauthorizedReceived(context, false) NotificationManagerCompat.from(context).cancel(NotificationIds.UNREGISTERED_NOTIFICATION_ID) diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt index 592fed7f68..5887dd9408 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt @@ -1411,6 +1411,15 @@ object RemoteConfig { hotSwappable = true ) + /** Seconds after registration during which change-number is blocked. */ + @JvmStatic + @get:JvmName("changeNumberPostRegistrationWaitingPeriodSeconds") + val changeNumberPostRegistrationWaitingPeriodSeconds: Long by remoteLong( + key = "global.changeNumber.postRegistrationWaitingPeriodSeconds", + defaultValue = 3600, + hotSwappable = true + ) + /** * A ratio between 0 and 1, where 0 means that a session is never archived due * to a lack of PQ, and 1 means that a session is always archived due to a diff --git a/app/src/main/res/drawable/change_number.xml b/app/src/main/res/drawable/change_number.xml new file mode 100644 index 0000000000..635ca9ab0a --- /dev/null +++ b/app/src/main/res/drawable/change_number.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/change_number_error.xml b/app/src/main/res/drawable/change_number_error.xml new file mode 100644 index 0000000000..e8272e2af4 --- /dev/null +++ b/app/src/main/res/drawable/change_number_error.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/change_number_hero_image.xml b/app/src/main/res/drawable/change_number_hero_image.xml deleted file mode 100644 index 7d480e4de2..0000000000 --- a/app/src/main/res/drawable/change_number_hero_image.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 84eb03b412..f4b80f296a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5728,6 +5728,33 @@ Okay + + Can\'t change number + + Because you recently registered this account, you can\'t change phone numbers right now. A short waiting period helps protect your account. + + + Try again in %1$d hour. + Try again in %1$d hours. + + + + Try again in %1$d minute. + Try again in %1$d minutes. + + + OK + + + You have made too many attempts. Please try again in %1$d hour. + You have made too many attempts. Please try again in %1$d hours. + + + + You have made too many attempts. Please try again in %1$d minute. + You have made too many attempts. Please try again in %1$d minutes. + + Change Number Your old number