Enforce change number post registration delay.

This commit is contained in:
Cody Henthorne
2026-05-15 15:26:05 -04:00
committed by jeffrey-signal
parent 4decae274b
commit a9649fd017
10 changed files with 325 additions and 29 deletions
@@ -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<Boolean?>() {
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
@@ -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)
)
@@ -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)
}
}
@@ -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
@@ -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)
@@ -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