Harden and improve change number flow.

This commit is contained in:
Cody Henthorne
2026-06-30 15:40:18 -04:00
committed by Michelle Tang
parent c1c6db2b3b
commit ee11d1fd3b
11 changed files with 217 additions and 59 deletions
@@ -236,8 +236,9 @@ class SyncMessageProcessorTest_synchronizePniChangeNumber {
}
@Test
fun skipsRedeliveryWhenPniAlreadyMatches() {
sendPniChangeNumber()
fun skipsRedeliveryWithSameServerTimestamp() {
val timestamp = messageHelper.nextStartTime()
sendPniChangeNumber(timestamp = timestamp)
val afterFirstApply = captureOriginalState()
val otherIdentity = IdentityKeyPair.generate()
@@ -247,12 +248,41 @@ class SyncMessageProcessorTest_synchronizePniChangeNumber {
identityKeyPair = otherIdentity.serialize().toByteString(),
signedPreKey = otherSignedPreKey.serialize().toByteString(),
e164 = "+15555550100",
timestamp = messageHelper.nextStartTime() + 1000
timestamp = timestamp
)
assertOriginalStatePreserved(afterFirstApply)
}
@Test
fun reappliesWhenServerTimestampIsNewer() {
sendPniChangeNumber()
val secondPniUuid = UUID.randomUUID()
val secondPni = ServiceId.PNI.from(secondPniUuid)
val secondPniBytes = UuidUtil.toByteArray(secondPniUuid).toByteString()
val secondIdentity = IdentityKeyPair.generate()
val secondSignedPreKey = PreKeyUtil.generateSignedPreKey(9999, secondIdentity.privateKey)
val secondE164 = "+15555550100"
val secondRegistrationId = 7777
sendPniChangeNumber(
identityKeyPair = secondIdentity.serialize().toByteString(),
signedPreKey = secondSignedPreKey.serialize().toByteString(),
lastResortKyberPreKey = null,
registrationId = secondRegistrationId,
e164 = secondE164,
envelopePniBinary = secondPniBytes,
timestamp = messageHelper.nextStartTime() + 1000
)
assertThat(SignalStore.account.e164).isEqualTo(secondE164)
assertThat(SignalStore.account.pni).isEqualTo(secondPni)
assertThat(SignalStore.account.pniRegistrationId).isEqualTo(secondRegistrationId)
assertThat(SignalStore.account.pniIdentityKey.publicKey.serialize().toByteString())
.isEqualTo(secondIdentity.publicKey.serialize().toByteString())
}
@Test
fun bailsWhenServerTimestampStale() {
sendPniChangeNumber()
@@ -128,11 +128,14 @@ class ChangeNumberEnterCodeFragment : LoggingFragment(R.layout.fragment_change_n
binding.codeEntryLayout.resendSmsCountDown.startCountDownTo(state.nextSmsTimestamp.milliseconds)
binding.codeEntryLayout.callMeCountDown.startCountDownTo(state.nextCallTimestamp.milliseconds)
when (val outcome = state.changeNumberOutcome) {
is ChangeNumberOutcome.RecoveryPasswordWorked,
is ChangeNumberOutcome.VerificationCodeWorked -> changeNumberSuccess()
is ChangeNumberOutcome.Succeeded -> changeNumberSuccess()
is ChangeNumberOutcome.ChangeNumberRequestOutcome -> if (!state.inProgress && !outcome.result.isSuccess()) {
presentGenericError(outcome.result)
if (outcome.result is VerificationCodeRequestResult.RequestVerificationCodeRateLimited) {
Log.i(TAG, "Verification code request rate limited; staying on code entry screen.")
} else {
presentGenericError(outcome.result)
}
}
null -> Unit
@@ -158,6 +161,8 @@ class ChangeNumberEnterCodeFragment : LoggingFragment(R.layout.fragment_change_n
when (result) {
is VerificationCodeRequestResult.Success -> binding.codeEntryLayout.keyboard.displaySuccess()
is VerificationCodeRequestResult.RateLimited -> presentRateLimitedDialog()
is VerificationCodeRequestResult.RequestVerificationCodeRateLimited -> presentRateLimitedDialog(retryAfterSeconds = (result.nextSmsTimestamp - System.currentTimeMillis().milliseconds).inWholeSeconds.coerceAtLeast(0))
is VerificationCodeRequestResult.SubmitVerificationCodeRateLimited -> presentRateLimitedDialog()
is VerificationCodeRequestResult.RegistrationLocked -> presentRegistrationLocked(result.timeRemaining)
else -> presentGenericError(result)
}
@@ -113,7 +113,7 @@ class ChangeNumberRegistrationLockFragment : LoggingFragment(R.layout.fragment_c
}
private fun onStateUpdate(state: ChangeNumberState) {
if (state.changeNumberOutcome == ChangeNumberOutcome.VerificationCodeWorked) {
if (state.changeNumberOutcome == ChangeNumberOutcome.Succeeded) {
handleSuccessfulPinEntry(state.enteredPin)
}
@@ -188,8 +188,7 @@ class ChangeNumberRepository(
StorageSyncHelper.scheduleSyncForDataChange()
}
SignalStore.account.setE164(e164)
SignalStore.account.setPni(pni)
SignalStore.account.setNumberAndPniIdentity(e164, pni, pniRegistrationId, pniIdentityKeyPair)
AppDependencies.resetProtocolStores()
AppDependencies.groupsV2Authorization.clear()
@@ -197,9 +196,6 @@ class ChangeNumberRepository(
val pniProtocolStore = AppDependencies.protocolStore.pni()
val pniMetadataStore = SignalStore.account.pniPreKeys
SignalStore.account.pniRegistrationId = pniRegistrationId
SignalStore.account.setPniIdentityKeyAfterChangeNumber(pniIdentityKeyPair)
PreKeyUtil.storeSignedPreKey(pniProtocolStore, pniMetadataStore, pniSignedPreKey)
pniMetadataStore.activeSignedPreKeyId = pniSignedPreKey.id
@@ -326,6 +322,10 @@ class ChangeNumberRepository(
}
}
if (result !is NetworkResult.Success) {
result = verifyChangeAppliedDespiteError(newE164 = newE164, originalResult = result)
}
if (result is NetworkResult.StatusCodeError) {
SignalStore.misc.unlockChangeNumber()
}
@@ -336,13 +336,39 @@ class ChangeNumberRepository(
NumberChangeResult(
uuid = accountRegistrationResponse.uuid,
pni = accountRegistrationResponse.pni,
storageCapable = accountRegistrationResponse.storageCapable,
number = accountRegistrationResponse.number
)
}
)
}
/**
* The server can commit a number change but still return an error. Before trusting a non-success result, check
* with the server and see if it already reports [newE164] as our number. If it does, then the change actually
* went through, so treat it as a success instead of surfacing the error.
*/
private suspend fun verifyChangeAppliedDespiteError(newE164: String, originalResult: NetworkResult<VerifyAccountResponse>): NetworkResult<VerifyAccountResponse> {
return try {
val whoAmI = withContext(Dispatchers.IO) { whoAmI() }
if (whoAmI.number == newE164 && whoAmI.pni != null) {
Log.w(TAG, "Change number request did not succeed, but whoami reports the new number is already active. Treating the change as successful.")
NetworkResult.Success(
VerifyAccountResponse().apply {
uuid = whoAmI.aci
pni = whoAmI.pni
number = whoAmI.number
}
)
} else {
Log.i(TAG, "Change number request did not succeed and whoami does not report the new number; treating as a genuine failure.")
originalResult
}
} catch (e: IOException) {
Log.w(TAG, "Failed to query whoami while verifying change number outcome; treating as a genuine failure.", e)
originalResult
}
}
@WorkerThread
private fun createChangeNumberRequest(
sessionId: String? = null,
@@ -433,7 +459,6 @@ class ChangeNumberRepository(
data class NumberChangeResult(
val uuid: String,
val pni: String,
val storageCapable: Boolean,
val number: String
)
}
@@ -43,8 +43,7 @@ data class ChangeNumberState(
)
sealed interface ChangeNumberOutcome {
data object RecoveryPasswordWorked : ChangeNumberOutcome
data object VerificationCodeWorked : ChangeNumberOutcome
data object Succeeded : ChangeNumberOutcome
class ChangeNumberRequestOutcome(val result: VerificationCodeRequestResult) : ChangeNumberOutcome
}
@@ -89,8 +89,8 @@ class ChangeNumberVerifyFragment : LoggingFragment(R.layout.fragment_change_phon
private fun handleRequestCodeResult(changeNumberOutcome: ChangeNumberOutcome) {
Log.d(TAG, "Handling request code result: ${changeNumberOutcome.javaClass.name}")
when (changeNumberOutcome) {
is ChangeNumberOutcome.RecoveryPasswordWorked -> {
Log.i(TAG, "Successfully changed number with recovery password.")
is ChangeNumberOutcome.Succeeded -> {
Log.i(TAG, "Successfully changed number.")
changeNumberSuccess()
}
@@ -109,11 +109,27 @@ class ChangeNumberVerifyFragment : LoggingFragment(R.layout.fragment_change_phon
}
}
is VerificationCodeRequestResult.RateLimited -> {
is VerificationCodeRequestResult.RequestVerificationCodeRateLimited -> {
if (castResult.willBeAbleToRequestAgain) {
Log.i(TAG, "Verification code request rate limited; proceeding to code entry screen so the user can wait/resend rather than bailing.")
findNavController().safeNavigate(ChangeNumberVerifyFragmentDirections.actionChangePhoneNumberVerifyFragmentToChangeNumberEnterCodeFragment())
} else {
Log.i(TAG, "Verification code request rate limited with no pending resend; showing rate limit error.")
showErrorDialog(R.string.RegistrationActivity_rate_limited_to_service)
}
}
is VerificationCodeRequestResult.RateLimited,
is VerificationCodeRequestResult.SubmitVerificationCodeRateLimited -> {
Log.i(TAG, "Unable to request sms code due to rate limit")
showErrorDialog(R.string.RegistrationActivity_rate_limited_to_service)
}
is VerificationCodeRequestResult.RegistrationLocked -> {
Log.i(TAG, "Destination number is registration locked; navigating to PIN entry.")
findNavController().safeNavigate(ChangeNumberVerifyFragmentDirections.actionChangePhoneNumberVerifyFragmentToChangeNumberRegistrationLock(castResult.timeRemaining))
}
is VerificationCodeRequestResult.TokenNotAccepted -> {
Log.i(TAG, "Token was not accepted.")
showErrorDialog(R.string.RegistrationActivity_additional_verification_required)
@@ -125,11 +141,6 @@ class ChangeNumberVerifyFragment : LoggingFragment(R.layout.fragment_change_phon
}
}
}
is ChangeNumberOutcome.VerificationCodeWorked -> {
Log.i(TAG, "Successfully changed number with verification code.")
changeNumberSuccess()
}
}
}
@@ -12,14 +12,12 @@ import androidx.lifecycle.viewModelScope
import com.google.i18n.phonenumbers.NumberParseException
import com.google.i18n.phonenumbers.PhoneNumberUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.models.ServiceId
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -36,6 +34,7 @@ import org.thoughtcrime.securesms.registration.viewmodel.NumberViewState
import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet
import org.thoughtcrime.securesms.util.dualsim.MccMncProducer
import java.io.IOException
import java.util.concurrent.atomic.AtomicBoolean
/**
* [ViewModel] for the change number flow.
@@ -50,12 +49,13 @@ class ChangeNumberViewModel : ViewModel() {
private val repository = ChangeNumberRepository()
private val store = MutableStateFlow(ChangeNumberState())
private val serialContext = SignalExecutors.SERIAL.asCoroutineDispatcher()
private val smsRetrieverReceiver: SmsRetrieverReceiver = SmsRetrieverReceiver(AppDependencies.application)
private val initialLocalNumber = SignalStore.account.e164
private val password = SignalStore.account.servicePassword!!
private val changeNumberSessionInFlight = AtomicBoolean(false)
val uiState = store.asLiveData()
val liveOldNumberState = store.map { it.oldPhoneNumber }.asLiveData()
val liveNewNumberState = store.map { it.number }.asLiveData()
@@ -261,14 +261,24 @@ class ChangeNumberViewModel : ViewModel() {
}
private suspend fun verifyCodeInternal(context: Context, pin: String?, verificationErrorHandler: (VerificationCodeRequestResult) -> Unit, numberChangeErrorHandler: (ChangeNumberResult) -> Unit) {
val sessionId = getOrCreateValidSession(context)?.sessionId ?: return bail { Log.i(TAG, "Bailing from code verification due to invalid session.") }
val registrationData = getRegistrationData(context)
val session = getOrCreateValidSession(context) ?: return bail { Log.i(TAG, "Bailing from code verification due to invalid session.") }
val sessionId = session.sessionId
val verificationResponse = RegistrationRepository.submitVerificationCode(context, sessionId, registrationData)
if (!session.verified) {
if (store.value.enteredCode == null) {
Log.w(TAG, "Session is not verified and no code is available to submit; cannot complete change number.")
handleVerificationError(VerificationCodeRequestResult.UnknownError(IllegalStateException("No verification code available for an unverified session")), verificationErrorHandler)
return bail { Log.i(TAG, "Bailing from code verification due to missing code for unverified session.") }
}
if (verificationResponse !is VerificationCodeRequestResult.Success && verificationResponse !is VerificationCodeRequestResult.AlreadyVerified) {
handleVerificationError(verificationResponse, verificationErrorHandler)
return bail { Log.i(TAG, "Bailing from code verification due to non-successful response.") }
val registrationData = getRegistrationData(context)
val verificationResponse = RegistrationRepository.submitVerificationCode(context, sessionId, registrationData)
if (verificationResponse !is VerificationCodeRequestResult.Success && verificationResponse !is VerificationCodeRequestResult.AlreadyVerified) {
handleVerificationError(verificationResponse, verificationErrorHandler)
return bail { Log.i(TAG, "Bailing from code verification due to non-successful response.") }
}
}
val result: ChangeNumberResult = if (pin == null) {
@@ -286,7 +296,7 @@ class ChangeNumberViewModel : ViewModel() {
}
if (result is ChangeNumberResult.Success) {
handleSuccessfulChangedRemoteNumber(e164 = result.numberChangeResult.number, pni = ServiceId.PNI.parseOrThrow(result.numberChangeResult.pni), changeNumberOutcome = ChangeNumberOutcome.RecoveryPasswordWorked)
handleSuccessfulChangedRemoteNumber(e164 = result.numberChangeResult.number, pni = ServiceId.PNI.parseOrThrow(result.numberChangeResult.pni), changeNumberOutcome = ChangeNumberOutcome.Succeeded)
} else {
handleChangeNumberError(result, numberChangeErrorHandler)
}
@@ -359,18 +369,26 @@ class ChangeNumberViewModel : ViewModel() {
fun initiateChangeNumberSession(context: Context, mode: RegistrationRepository.E164VerificationMode) {
Log.v(TAG, "changeNumber()")
if (!changeNumberSessionInFlight.compareAndSet(false, true)) {
Log.i(TAG, "A change number session is already in progress; ignoring duplicate request.")
return
}
store.update { it.copy(inProgress = true) }
viewModelScope.launch(Dispatchers.IO) {
val encryptionDrained = repository.ensureDecryptionsDrained() ?: false
try {
val encryptionDrained = repository.ensureDecryptionsDrained() ?: false
if (!encryptionDrained) {
return@launch bail { Log.i(TAG, "Failed to drain encryption.") }
}
if (!encryptionDrained) {
return@launch bail { Log.i(TAG, "Failed to drain encryption.") }
}
when (changeNumberWithRecoveryPassword()) {
ChangeLocalNumberOutcome.NotPerformed -> requestVerificationCode(context, mode)
ChangeLocalNumberOutcome.Success -> Log.d(TAG, "Successfully changed number using recovery password")
ChangeLocalNumberOutcome.Failure -> Log.w(TAG, "Change number failed, bailing")
when (changeNumberWithRecoveryPassword()) {
ChangeLocalNumberOutcome.NotPerformed -> requestVerificationCode(context, mode)
ChangeLocalNumberOutcome.Success -> Log.d(TAG, "Successfully changed number using recovery password")
ChangeLocalNumberOutcome.Failure -> Log.w(TAG, "Change number failed, bailing")
}
} finally {
changeNumberSessionInFlight.set(false)
}
}
}
@@ -417,7 +435,7 @@ class ChangeNumberViewModel : ViewModel() {
val result = repository.changeNumberWithRecoveryPassword(recoveryPassword = recoveryPassword, newE164 = number.e164Number)
if (result is ChangeNumberResult.Success) {
return handleSuccessfulChangedRemoteNumber(e164 = result.numberChangeResult.number, pni = ServiceId.PNI.parseOrThrow(result.numberChangeResult.pni), changeNumberOutcome = ChangeNumberOutcome.RecoveryPasswordWorked)
return handleSuccessfulChangedRemoteNumber(e164 = result.numberChangeResult.number, pni = ServiceId.PNI.parseOrThrow(result.numberChangeResult.pni), changeNumberOutcome = ChangeNumberOutcome.Succeeded)
} else if (result is ChangeNumberResult.UnknownError) {
store.update {
it.copy(inProgress = false, changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(VerificationCodeRequestResult.UnknownError(result.getCause())))
@@ -512,6 +530,12 @@ class ChangeNumberViewModel : ViewModel() {
return
}
if (validSession.verified) {
Log.i(TAG, "Existing session is already verified; completing the change with the verified session instead of requesting a new code.")
changeNumberWithVerifiedSession(validSession.sessionId)
return
}
val result = if (!validSession.allowedToRequestCode) {
val challenges = validSession.challengesRequested.joinToString()
Log.i(TAG, "Not allowed to request code! Remaining challenges: $challenges")
@@ -532,14 +556,72 @@ class ChangeNumberViewModel : ViewModel() {
}
if (result is VerificationCodeRequestResult.AlreadyVerified) {
Log.w(TAG, "Already verified, not handled properly in change number flow, attempt to recover")
resetLocalSessionState()
Log.i(TAG, "Session became verified while requesting a code; completing the change with the verified session.")
changeNumberWithVerifiedSession(validSession.sessionId)
return
}
Log.d(TAG, "Received result: ${result.javaClass.canonicalName}\nwith challenges: ${challengesRequested.joinToString { it.key }}")
val (nextSmsTimestamp, nextCallTimestamp) = when (result) {
is VerificationCodeRequestResult.Success -> result.nextSmsTimestamp.inWholeMilliseconds to result.nextCallTimestamp.inWholeMilliseconds
is VerificationCodeRequestResult.RequestVerificationCodeRateLimited -> result.nextSmsTimestamp.inWholeMilliseconds to result.nextCallTimestamp.inWholeMilliseconds
else -> store.value.nextSmsTimestamp to store.value.nextCallTimestamp
}
store.update {
it.copy(changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(result), challengesRequested = challengesRequested, inProgress = false)
it.copy(
changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(result),
challengesRequested = challengesRequested,
inProgress = false,
nextSmsTimestamp = nextSmsTimestamp,
nextCallTimestamp = nextCallTimestamp
)
}
}
/**
* Completes a number change using a registration session that the service already considers verified.
*/
private suspend fun changeNumberWithVerifiedSession(sessionId: String) {
Log.v(TAG, "changeNumberWithVerifiedSession()")
when (val result = repository.changeNumberWithoutRegistrationLock(sessionId = sessionId, newE164 = number.e164Number)) {
is ChangeNumberResult.Success -> {
handleSuccessfulChangedRemoteNumber(
e164 = result.numberChangeResult.number,
pni = ServiceId.PNI.parseOrThrow(result.numberChangeResult.pni),
changeNumberOutcome = ChangeNumberOutcome.Succeeded
)
}
is ChangeNumberResult.RegistrationLocked -> {
if (result.svr2Credentials != null) {
Log.i(TAG, "Destination number is registration locked; prompting for PIN.")
store.update {
it.copy(
inProgress = false,
svr2Credentials = result.svr2Credentials,
svr3Credentials = result.svr3Credentials,
lockedTimeRemaining = result.timeRemaining,
changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(
VerificationCodeRequestResult.RegistrationLocked(result.getCause(), result.timeRemaining, result.svr2Credentials, result.svr3Credentials)
)
)
}
} else {
Log.w(TAG, "Destination number is registration locked but SVR credentials were missing, cannot prompt for PIN.")
store.update {
it.copy(inProgress = false, changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(VerificationCodeRequestResult.UnknownError(result.getCause())))
}
}
}
else -> {
Log.w(TAG, "Unable to complete change number with verified session.", result.getCause())
store.update {
it.copy(inProgress = false, changeNumberOutcome = ChangeNumberOutcome.ChangeNumberRequestOutcome(VerificationCodeRequestResult.UnknownError(result.getCause())))
}
}
}
}
@@ -327,15 +327,17 @@ class AccountValues internal constructor(store: KeyValueStore, context: Context)
}
}
/** Set an identity key pair for the PNI identity via change number. */
fun setPniIdentityKeyAfterChangeNumber(key: IdentityKeyPair) {
fun setNumberAndPniIdentity(e164: String, pni: PNI, pniRegistrationId: Int, pniIdentityKeyPair: IdentityKeyPair) {
synchronized(this) {
Log.i(TAG, "Setting a new PNI identity key pair.")
Log.i(TAG, "Setting the E164, PNI, PNI registration ID, and PNI identity key pair.")
store
.beginWrite()
.putBlob(KEY_PNI_IDENTITY_PUBLIC_KEY, key.publicKey.serialize())
.putBlob(KEY_PNI_IDENTITY_PRIVATE_KEY, key.privateKey.serialize())
.putString(KEY_E164, e164)
.putString(KEY_PNI, pni.toString())
.putInteger(KEY_PNI_REGISTRATION_ID, pniRegistrationId)
.putBlob(KEY_PNI_IDENTITY_PUBLIC_KEY, pniIdentityKeyPair.publicKey.serialize())
.putBlob(KEY_PNI_IDENTITY_PRIVATE_KEY, pniIdentityKeyPair.privateKey.serialize())
.commit()
}
}
@@ -1896,11 +1896,6 @@ object SyncMessageProcessor {
return
}
if (SignalStore.account.pni == PNI(pni)) {
log(timestamp, "PniChangeNumber sync already applied locally. Skipping.")
return
}
val identityKeyPairBytes = pniChangeNumber.identityKeyPair
val signedPreKeyBytes = pniChangeNumber.signedPreKey
val registrationId = pniChangeNumber.registrationId
@@ -1934,8 +1929,6 @@ object SyncMessageProcessor {
pniRegistrationId = registrationId
)
SignalStore.misc.lastAppliedPniChangeServerTimestamp = envelopeServerTimestamp
// The primary already submitted these per-device prekeys to the server as part of the
// change-number request, so they are registered server-side from this device's perspective.
val pniMetadataStore = SignalStore.account.pniPreKeys
@@ -1947,6 +1940,8 @@ object SyncMessageProcessor {
// Rotate the primary-generated keys as soon as possible so we don't rely on them long-term.
SignalStore.misc.forcePniSignedPreKeyRotation = true
AppDependencies.jobManager.add(PreKeysSyncJob.create(forceRotationRequested = true))
SignalStore.misc.lastAppliedPniChangeServerTimestamp = envelopeServerTimestamp
}
private fun applyAttachmentData(
@@ -134,7 +134,7 @@ sealed class VerificationCodeRequestResult(cause: Throwable?) : RegistrationResu
class SubmitVerificationCodeRateLimited(cause: Throwable) : VerificationCodeRequestResult(cause)
class RegistrationLocked(cause: Throwable, val timeRemaining: Long, val svr2Credentials: AuthCredentials, val svr3Credentials: Svr3Credentials) : VerificationCodeRequestResult(cause)
class RegistrationLocked(cause: Throwable, val timeRemaining: Long, val svr2Credentials: AuthCredentials, val svr3Credentials: Svr3Credentials?) : VerificationCodeRequestResult(cause)
class NoSuchSession(cause: Throwable) : VerificationCodeRequestResult(cause)
@@ -95,6 +95,15 @@
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit"
app:popUpTo="@+id/enterPhoneNumberChangeFragment" />
<action
android:id="@+id/action_changePhoneNumberVerifyFragment_to_changeNumberRegistrationLock"
app:destination="@id/changeNumberRegistrationLock"
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/enterPhoneNumberChangeFragment" />
</fragment>
<fragment