Move v3 classes to base registration package.

This commit is contained in:
Cody Henthorne
2025-09-22 12:21:06 -04:00
committed by Jeffrey Starke
parent 8dc2077ad0
commit 6976ac7d44
88 changed files with 181 additions and 217 deletions

View File

@@ -14,7 +14,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.LocalRegistratio
import org.whispersystems.signalservice.api.account.PreKeyCollection
/**
* Takes the two sources of registration data ([RegistrationData], [org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository.AccountRegistrationResult])
* Takes the two sources of registration data ([RegistrationData], [RegistrationRepository.AccountRegistrationResult])
* and combines them into a proto-backed class [LocalRegistrationMetadata] so they can be serialized & stored.
*/
object LocalRegistrationMetadataUtil {

View File

@@ -0,0 +1,170 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.data
import android.net.Uri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import okio.ByteString.Companion.toByteString
import org.signal.core.util.Base64.decode
import org.signal.core.util.isNotNullOrBlank
import org.signal.core.util.logging.Log
import org.signal.libsignal.protocol.InvalidKeyException
import org.signal.libsignal.protocol.ecc.ECPublicKey
import org.signal.registration.proto.RegistrationProvisionMessage
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.SignalNetwork
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.provisioning.RestoreMethod
import java.io.IOException
import kotlin.coroutines.coroutineContext
import kotlin.time.Duration.Companion.seconds
/**
* Helpers for quickly re-registering on a new device with the old device.
*/
object QuickRegistrationRepository {
private val TAG = Log.tag(QuickRegistrationRepository::class)
private const val REREG_URI_HOST = "rereg"
fun isValidReRegistrationQr(data: String): Boolean {
val uri = Uri.parse(data)
if (!uri.isHierarchical) {
return false
}
val ephemeralId: String? = uri.getQueryParameter("uuid")
val publicKeyEncoded: String? = uri.getQueryParameter("pub_key")
return uri.host == REREG_URI_HOST && ephemeralId.isNotNullOrBlank() && publicKeyEncoded.isNotNullOrBlank()
}
/**
* Send registration provisioning message to new device.
*/
fun transferAccount(reRegisterUri: String, restoreMethodToken: String): TransferAccountResult {
if (!isValidReRegistrationQr(reRegisterUri)) {
Log.w(TAG, "Invalid quick re-register qr data")
return TransferAccountResult.FAILED
}
val uri = Uri.parse(reRegisterUri)
try {
val ephemeralId: String? = uri.getQueryParameter("uuid")
val publicKeyEncoded: String? = uri.getQueryParameter("pub_key")
if (ephemeralId == null || publicKeyEncoded == null) {
Log.w(TAG, "Invalid link data hasId: ${ephemeralId != null} hasKey: ${publicKeyEncoded != null}")
return TransferAccountResult.FAILED
}
val publicKey = ECPublicKey(decode(publicKeyEncoded))
SignalNetwork
.provisioning
.sendReRegisterDeviceProvisioningMessage(
ephemeralId,
publicKey,
RegistrationProvisionMessage(
e164 = SignalStore.account.requireE164(),
aci = SignalStore.account.requireAci().toByteString(),
accountEntropyPool = SignalStore.account.accountEntropyPool.value,
pin = SignalStore.svr.pin,
platform = RegistrationProvisionMessage.Platform.ANDROID,
backupTimestampMs = SignalStore.backup.lastBackupTime.coerceAtLeast(0L).takeIf { it > 0 },
tier = when (SignalStore.backup.backupTier) {
MessageBackupTier.PAID -> RegistrationProvisionMessage.Tier.PAID
MessageBackupTier.FREE -> RegistrationProvisionMessage.Tier.FREE
null -> null
},
backupSizeBytes = if (SignalStore.backup.backupTier == MessageBackupTier.PAID) SignalDatabase.attachments.getPaidEstimatedArchiveMediaSize().takeIf { it > 0 } else null,
restoreMethodToken = restoreMethodToken,
aciIdentityKeyPublic = SignalStore.account.aciIdentityKey.publicKey.serialize().toByteString(),
aciIdentityKeyPrivate = SignalStore.account.aciIdentityKey.privateKey.serialize().toByteString(),
pniIdentityKeyPublic = SignalStore.account.pniIdentityKey.publicKey.serialize().toByteString(),
pniIdentityKeyPrivate = SignalStore.account.pniIdentityKey.privateKey.serialize().toByteString(),
backupVersion = SignalStore.backup.lastBackupProtoVersion
)
)
.successOrThrow()
Log.i(TAG, "Re-registration provisioning message sent")
} catch (e: IOException) {
Log.w(TAG, "Exception re-registering new device", e)
return TransferAccountResult.FAILED
} catch (e: InvalidKeyException) {
Log.w(TAG, "Exception re-registering new device", e)
return TransferAccountResult.FAILED
}
return TransferAccountResult.SUCCESS
}
/**
* Sets the restore method enum for the old device to retrieve and update their UI with.
*/
suspend fun setRestoreMethodForOldDevice(restoreMethod: RestoreMethod) {
val restoreMethodToken = SignalStore.registration.restoreMethodToken
if (restoreMethodToken != null) {
withContext(Dispatchers.IO) {
Log.d(TAG, "Setting restore method ***${restoreMethodToken.takeLast(4)}: $restoreMethod")
var retries = 3
var result: NetworkResult<Unit>? = null
while (retries-- > 0 && result !is NetworkResult.Success) {
Log.d(TAG, "Setting method, retries remaining: $retries")
result = AppDependencies.registrationApi.setRestoreMethod(restoreMethodToken, restoreMethod)
if (result !is NetworkResult.Success) {
delay(1.seconds)
}
}
if (result is NetworkResult.Success) {
Log.i(TAG, "Restore method set successfully")
SignalStore.registration.restoreMethodToken = null
} else {
Log.w(TAG, "Restore method set failed", result?.getCause())
}
}
}
}
/**
* Gets the restore method used by the new device to update UI with. This is a long polling operation.
*/
suspend fun waitForRestoreMethodSelectionOnNewDevice(restoreMethodToken: String): RestoreMethod {
var retries = 5
var result: NetworkResult<RestoreMethod>? = null
Log.d(TAG, "Waiting for restore method with token: ***${restoreMethodToken.takeLast(4)}")
while (retries-- > 0 && result !is NetworkResult.Success && coroutineContext.isActive) {
Log.d(TAG, "Waiting, remaining tries: $retries")
result = SignalNetwork.provisioning.waitForRestoreMethod(restoreMethodToken)
Log.d(TAG, "Result: $result")
}
if (result is NetworkResult.Success) {
Log.i(TAG, "Restore method selected on new device ${result.result}")
return result.result
} else {
Log.w(TAG, "Failed to determine restore method, using DECLINE")
return RestoreMethod.DECLINE
}
}
enum class TransferAccountResult {
SUCCESS,
FAILED
}
}

View File

@@ -0,0 +1,749 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.data
import android.app.backup.BackupManager
import android.content.Context
import androidx.annotation.VisibleForTesting
import androidx.annotation.WorkerThread
import androidx.core.app.NotificationManagerCompat
import com.google.android.gms.auth.api.phone.SmsRetriever
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.signal.core.util.Base64
import org.signal.core.util.logging.Log
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.util.KeyHelper
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.AppCapabilities
import org.thoughtcrime.securesms.crypto.PreKeyUtil
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
import org.thoughtcrime.securesms.crypto.SenderKeyUtil
import org.thoughtcrime.securesms.crypto.storage.PreKeyMetadataStore
import org.thoughtcrime.securesms.crypto.storage.SignalServiceAccountDataStoreImpl
import org.thoughtcrime.securesms.database.IdentityTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.LocalRegistrationMetadata
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.gcm.FcmUtil
import org.thoughtcrime.securesms.jobmanager.runJobBlocking
import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob
import org.thoughtcrime.securesms.jobs.PreKeysSyncJob
import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob
import org.thoughtcrime.securesms.jobs.RotateCertificateJob
import org.thoughtcrime.securesms.keyvalue.PhoneNumberPrivacyValues
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.notifications.NotificationIds
import org.thoughtcrime.securesms.pin.Svr3Migration
import org.thoughtcrime.securesms.pin.SvrRepository
import org.thoughtcrime.securesms.pin.SvrWrongPinException
import org.thoughtcrime.securesms.profiles.AvatarHelper
import org.thoughtcrime.securesms.push.AccountManagerFactory
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.registration.data.LocalRegistrationMetadataUtil.getAciIdentityKeyPair
import org.thoughtcrime.securesms.registration.data.LocalRegistrationMetadataUtil.getAciPreKeyCollection
import org.thoughtcrime.securesms.registration.data.LocalRegistrationMetadataUtil.getPniIdentityKeyPair
import org.thoughtcrime.securesms.registration.data.LocalRegistrationMetadataUtil.getPniPreKeyCollection
import org.thoughtcrime.securesms.registration.data.network.BackupAuthCheckResult
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionCheckResult
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionCreationResult
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionResult
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult
import org.thoughtcrime.securesms.registration.fcm.PushChallengeRequest
import org.thoughtcrime.securesms.registration.secondary.DeviceNameCipher
import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet
import org.thoughtcrime.securesms.service.DirectoryRefreshListener
import org.thoughtcrime.securesms.service.RotateSignedPreKeyListener
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.whispersystems.signalservice.api.AccountEntropyPool
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.SvrNoDataException
import org.whispersystems.signalservice.api.account.AccountAttributes
import org.whispersystems.signalservice.api.account.PreKeyCollection
import org.whispersystems.signalservice.api.backup.MediaRootBackupKey
import org.whispersystems.signalservice.api.crypto.UnidentifiedAccess
import org.whispersystems.signalservice.api.kbs.MasterKey
import org.whispersystems.signalservice.api.kbs.PinHashUtil
import org.whispersystems.signalservice.api.link.TransferArchiveResponse
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.push.ServiceId.ACI
import org.whispersystems.signalservice.api.push.ServiceId.PNI
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import org.whispersystems.signalservice.api.registration.RegistrationApi
import org.whispersystems.signalservice.api.svr.Svr3Credentials
import org.whispersystems.signalservice.internal.push.AuthCredentials
import org.whispersystems.signalservice.internal.push.ProvisionMessage
import org.whispersystems.signalservice.internal.push.PushServiceSocket
import org.whispersystems.signalservice.internal.push.RegistrationSessionMetadataResponse
import org.whispersystems.signalservice.internal.push.VerifyAccountResponse
import java.io.IOException
import java.nio.charset.StandardCharsets
import java.util.Locale
import java.util.Optional
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.coroutines.coroutineContext
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* A repository that deals with disk I/O during account registration.
*/
object RegistrationRepository {
private val TAG = Log.tag(RegistrationRepository::class.java)
private val PUSH_REQUEST_TIMEOUT = 5.seconds.inWholeMilliseconds
/**
* Retrieve the FCM token from the Firebase service.
*/
suspend fun getFcmToken(context: Context): String? =
withContext(Dispatchers.Default) {
FcmUtil.getToken(context).orElse(null)
}
/**
* Queries, and creates if needed, the local registration ID.
*/
@JvmStatic
fun getRegistrationId(): Int {
// TODO [regv2]: make creation more explicit instead of hiding it in this getter
var registrationId = SignalStore.account.registrationId
if (registrationId == 0) {
registrationId = KeyHelper.generateRegistrationId(false)
SignalStore.account.registrationId = registrationId
}
return registrationId
}
/**
* Queries, and creates if needed, the local PNI registration ID.
*/
@JvmStatic
fun getPniRegistrationId(): Int {
// TODO [regv2]: make creation more explicit instead of hiding it in this getter
var pniRegistrationId = SignalStore.account.pniRegistrationId
if (pniRegistrationId == 0) {
pniRegistrationId = KeyHelper.generateRegistrationId(false)
SignalStore.account.pniRegistrationId = pniRegistrationId
}
return pniRegistrationId
}
/**
* Queries, and creates if needed, the local profile key.
*/
@JvmStatic
suspend fun getProfileKey(e164: String): ProfileKey =
withContext(Dispatchers.IO) {
// TODO [regv2]: make creation more explicit instead of hiding it in this getter
val recipientTable = SignalDatabase.recipients
val recipient = recipientTable.getByE164(e164)
var profileKey = if (recipient.isPresent) {
ProfileKeyUtil.profileKeyOrNull(Recipient.resolved(recipient.get()).profileKey)
} else {
null
}
if (profileKey == null) {
profileKey = ProfileKeyUtil.createNew()
Log.i(TAG, "No profile key found, created a new one")
}
profileKey
}
/**
* Takes a server response from a successful registration and persists the relevant data.
*/
@JvmStatic
suspend fun registerAccountLocally(context: Context, data: LocalRegistrationMetadata) =
withContext(Dispatchers.IO) {
Log.v(TAG, "registerAccountLocally()")
val aciIdentityKeyPair = data.getAciIdentityKeyPair()
val pniIdentityKeyPair = data.getPniIdentityKeyPair()
SignalStore.account.restoreAciIdentityKeyFromBackup(aciIdentityKeyPair.publicKey.serialize(), aciIdentityKeyPair.privateKey.serialize())
SignalStore.account.restorePniIdentityKeyFromBackup(pniIdentityKeyPair.publicKey.serialize(), pniIdentityKeyPair.privateKey.serialize())
val aciPreKeyCollection = data.getAciPreKeyCollection()
val pniPreKeyCollection = data.getPniPreKeyCollection()
val aci: ACI = ACI.parseOrThrow(data.aci)
val pni: PNI = PNI.parseOrThrow(data.pni)
val hasPin: Boolean = data.hasPin
SignalStore.account.setAci(aci)
SignalStore.account.setPni(pni)
AppDependencies.resetProtocolStores()
AppDependencies.protocolStore.aci().sessions().archiveAllSessions()
AppDependencies.protocolStore.pni().sessions().archiveAllSessions()
SenderKeyUtil.clearAllState()
val aciProtocolStore = AppDependencies.protocolStore.aci()
val aciMetadataStore = SignalStore.account.aciPreKeys
val pniProtocolStore = AppDependencies.protocolStore.pni()
val pniMetadataStore = SignalStore.account.pniPreKeys
storeSignedAndLastResortPreKeys(aciProtocolStore, aciMetadataStore, aciPreKeyCollection)
storeSignedAndLastResortPreKeys(pniProtocolStore, pniMetadataStore, pniPreKeyCollection)
val recipientTable = SignalDatabase.recipients
val selfId = Recipient.trustedPush(aci, pni, data.e164).id
recipientTable.setProfileSharing(selfId, true)
recipientTable.markRegisteredOrThrow(selfId, aci)
recipientTable.linkIdsForSelf(aci, pni, data.e164)
recipientTable.setProfileKey(selfId, ProfileKey(data.profileKey.toByteArray()))
AppDependencies.recipientCache.clearSelf()
SignalStore.account.setE164(data.e164)
SignalStore.account.fcmToken = data.fcmToken
SignalStore.account.fcmEnabled = data.fcmEnabled
val now = System.currentTimeMillis()
saveOwnIdentityKey(selfId, aci, aciProtocolStore, now)
saveOwnIdentityKey(selfId, pni, pniProtocolStore, now)
if (data.linkedDeviceInfo != null) {
SignalStore.account.deviceId = data.linkedDeviceInfo.deviceId
SignalStore.account.deviceName = data.linkedDeviceInfo.deviceName
if (data.linkedDeviceInfo.accountEntropyPool != null) {
SignalStore.account.setAccountEntropyPoolFromPrimaryDevice(AccountEntropyPool(data.linkedDeviceInfo.accountEntropyPool))
}
if (data.linkedDeviceInfo.mediaRootBackupKey != null) {
SignalStore.backup.mediaRootBackupKey = MediaRootBackupKey(data.linkedDeviceInfo.mediaRootBackupKey.toByteArray())
}
}
SignalStore.account.setServicePassword(data.servicePassword)
SignalStore.account.setRegistered(true)
TextSecurePreferences.setPromptedPushRegistration(context, true)
TextSecurePreferences.setUnauthorizedReceived(context, false)
NotificationManagerCompat.from(context).cancel(NotificationIds.UNREGISTERED_NOTIFICATION_ID)
val masterKey = if (data.masterKey != null) MasterKey(data.masterKey.toByteArray()) else null
SvrRepository.onRegistrationComplete(masterKey, data.pin, hasPin, data.reglockEnabled, SignalStore.account.restoredAccountEntropyPool)
AppDependencies.resetNetwork()
AppDependencies.startNetwork()
PreKeysSyncJob.enqueue()
val jobManager = AppDependencies.jobManager
if (data.linkedDeviceInfo == null) {
jobManager.add(DirectoryRefreshJob(false))
jobManager.add(RotateCertificateJob())
DirectoryRefreshListener.schedule(context)
RotateSignedPreKeyListener.schedule(context)
} else {
SignalStore.account.isMultiDevice = true
SignalStore.registration.hasUploadedProfile = true
jobManager.runJobBlocking(RefreshOwnProfileJob(), 30.seconds)
jobManager.add(RotateCertificateJob())
RotateSignedPreKeyListener.schedule(context)
}
}
@JvmStatic
private fun saveOwnIdentityKey(selfId: RecipientId, serviceId: ServiceId, protocolStore: SignalServiceAccountDataStoreImpl, now: Long) {
protocolStore.identities().saveIdentityWithoutSideEffects(
selfId,
serviceId,
protocolStore.identityKeyPair.publicKey,
IdentityTable.VerifiedStatus.VERIFIED,
true,
now,
true
)
}
@JvmStatic
private fun storeSignedAndLastResortPreKeys(protocolStore: SignalServiceAccountDataStoreImpl, metadataStore: PreKeyMetadataStore, preKeyCollection: PreKeyCollection) {
PreKeyUtil.storeSignedPreKey(protocolStore, metadataStore, preKeyCollection.signedPreKey)
metadataStore.isSignedPreKeyRegistered = true
metadataStore.activeSignedPreKeyId = preKeyCollection.signedPreKey.id
metadataStore.lastSignedPreKeyRotationTime = System.currentTimeMillis()
PreKeyUtil.storeLastResortKyberPreKey(protocolStore, metadataStore, preKeyCollection.lastResortKyberPreKey)
metadataStore.lastResortKyberPreKeyId = preKeyCollection.lastResortKyberPreKey.id
metadataStore.lastResortKyberPreKeyRotationTime = System.currentTimeMillis()
}
fun canUseLocalRecoveryPassword(): Boolean {
val recoveryPassword = SignalStore.svr.recoveryPassword
val pinHash = SignalStore.svr.localPinHash
return recoveryPassword != null && pinHash != null
}
fun doesPinMatchLocalHash(pin: String): Boolean {
val pinHash = SignalStore.svr.localPinHash ?: throw IllegalStateException("Local PIN hash is not present!")
return PinHashUtil.verifyLocalPinHash(pinHash, pin)
}
suspend fun fetchMasterKeyFromSvrRemote(pin: String, svr2Credentials: AuthCredentials?, svr3Credentials: Svr3Credentials?): MasterKey =
withContext(Dispatchers.IO) {
val credentialSet = SvrAuthCredentialSet(svr2Credentials = svr2Credentials, svr3Credentials = svr3Credentials)
val masterKey = SvrRepository.restoreMasterKeyPreRegistration(credentialSet, pin)
return@withContext masterKey
}
/**
* Validates a session ID.
*/
private suspend fun validateSession(context: Context, sessionId: String, e164: String, password: String): RegistrationSessionCheckResult =
withContext(Dispatchers.IO) {
val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password).registrationApi
Log.d(TAG, "Validating registration session with service.")
val registrationSessionResult = api.getRegistrationSessionStatus(sessionId)
return@withContext RegistrationSessionCheckResult.from(registrationSessionResult)
}
/**
* Initiates a new registration session on the service.
*/
suspend fun createSession(context: Context, e164: String, password: String, mcc: String?, mnc: String?): RegistrationSessionCreationResult =
withContext(Dispatchers.IO) {
Log.d(TAG, "About to create a registration session…")
val fcmToken: String? = FcmUtil.getToken(context).orElse(null)
val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password).registrationApi
val registrationSessionResult = if (fcmToken == null) {
Log.d(TAG, "Creating registration session without FCM token.")
api.createRegistrationSession(null, mcc, mnc)
} else {
Log.d(TAG, "Creating registration session with FCM token.")
createSessionAndBlockForPushChallenge(api, fcmToken, mcc, mnc)
}
val result = RegistrationSessionCreationResult.from(registrationSessionResult)
if (result is RegistrationSessionCreationResult.Success) {
Log.d(TAG, "Updating registration session and E164 in value store.")
SignalStore.registration.sessionId = result.sessionId
SignalStore.registration.sessionE164 = e164
}
return@withContext result
}
/**
* Validates an existing session, if its ID is provided. If the session is expired/invalid, or none is provided, it will attempt to initiate a new session.
*/
suspend fun createOrValidateSession(context: Context, sessionId: String?, e164: String, password: String, mcc: String?, mnc: String?): RegistrationSessionResult {
val savedSessionId = if (sessionId == null && e164 == SignalStore.registration.sessionE164) {
SignalStore.registration.sessionId
} else {
sessionId
}
if (savedSessionId != null) {
Log.d(TAG, "Validating existing registration session.")
val sessionValidationResult = validateSession(context, savedSessionId, e164, password)
when (sessionValidationResult) {
is RegistrationSessionCheckResult.Success -> {
Log.d(TAG, "Existing registration session is valid.")
return sessionValidationResult
}
is RegistrationSessionCheckResult.UnknownError -> {
Log.w(TAG, "Encountered error when validating existing session.", sessionValidationResult.getCause())
return sessionValidationResult
}
is RegistrationSessionCheckResult.SessionNotFound -> {
Log.i(TAG, "Current session is invalid or has expired. Must create new one.")
// fall through to creation
}
}
}
return createSession(context, e164, password, mcc, mnc)
}
/**
* Asks the service to send a verification code through one of our supported channels (SMS, phone call).
*/
suspend fun requestSmsCode(context: Context, sessionId: String, e164: String, password: String, mode: E164VerificationMode): VerificationCodeRequestResult =
withContext(Dispatchers.IO) {
val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password).registrationApi
val codeRequestResult = api.requestSmsVerificationCode(sessionId, Locale.getDefault(), mode.isSmsRetrieverSupported, mode.transport)
return@withContext VerificationCodeRequestResult.from(codeRequestResult)
}
/**
* Submits the user-entered verification code to the service.
*/
suspend fun submitVerificationCode(context: Context, sessionId: String, registrationData: RegistrationData): VerificationCodeRequestResult =
withContext(Dispatchers.IO) {
val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, registrationData.e164, SignalServiceAddress.DEFAULT_DEVICE_ID, registrationData.password).registrationApi
val result = api.verifyAccount(sessionId = sessionId, verificationCode = registrationData.code)
return@withContext VerificationCodeRequestResult.from(result)
}
/**
* Submits the solved captcha token to the service.
*/
suspend fun submitCaptchaToken(context: Context, e164: String, password: String, sessionId: String, captchaToken: String): VerificationCodeRequestResult =
withContext(Dispatchers.IO) {
val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password).registrationApi
val captchaSubmissionResult = api.submitCaptchaToken(sessionId = sessionId, captchaToken = captchaToken)
return@withContext VerificationCodeRequestResult.from(captchaSubmissionResult)
}
suspend fun requestAndVerifyPushToken(context: Context, sessionId: String, e164: String, password: String) =
withContext(Dispatchers.IO) {
val fcmToken = getFcmToken(context)
val accountManager = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password)
val pushChallenge = PushChallengeRequest.getPushChallengeBlocking(accountManager, sessionId, Optional.ofNullable(fcmToken), PUSH_REQUEST_TIMEOUT).orElse(null)
val pushSubmissionResult = accountManager.registrationApi.submitPushChallengeToken(sessionId = sessionId, pushChallengeToken = pushChallenge)
return@withContext VerificationCodeRequestResult.from(pushSubmissionResult)
}
/**
* Submit the necessary assets as a verified account so that the user can actually use the service.
*/
suspend fun registerAccount(context: Context, sessionId: String?, registrationData: RegistrationData, pin: String? = null, masterKeyProducer: MasterKeyProducer? = null): RegisterAccountResult =
withContext(Dispatchers.IO) {
Log.v(TAG, "registerAccount()")
val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, registrationData.e164, SignalServiceAddress.DEFAULT_DEVICE_ID, registrationData.password).registrationApi
val universalUnidentifiedAccess: Boolean = TextSecurePreferences.isUniversalUnidentifiedAccess(context)
val unidentifiedAccessKey: ByteArray = UnidentifiedAccess.deriveAccessKeyFrom(registrationData.profileKey)
val masterKey: MasterKey?
try {
masterKey = masterKeyProducer?.produceMasterKey()
} catch (e: SvrNoDataException) {
return@withContext RegisterAccountResult.SvrNoData(e)
} catch (e: SvrWrongPinException) {
return@withContext RegisterAccountResult.SvrWrongPin(e)
} catch (e: IOException) {
return@withContext RegisterAccountResult.UnknownError(e)
}
val registrationLock: String? = masterKey?.deriveRegistrationLock()
val accountAttributes = AccountAttributes(
signalingKey = null,
registrationId = registrationData.registrationId,
fetchesMessages = registrationData.isNotFcm,
registrationLock = registrationLock,
unidentifiedAccessKey = unidentifiedAccessKey,
unrestrictedUnidentifiedAccess = universalUnidentifiedAccess,
capabilities = AppCapabilities.getCapabilities(true),
discoverableByPhoneNumber = SignalStore.phoneNumberPrivacy.phoneNumberDiscoverabilityMode == PhoneNumberPrivacyValues.PhoneNumberDiscoverabilityMode.DISCOVERABLE,
name = null,
pniRegistrationId = registrationData.pniRegistrationId,
recoveryPassword = registrationData.recoveryPassword
)
SignalStore.account.generateAciIdentityKeyIfNecessary()
val aciIdentity: IdentityKeyPair = SignalStore.account.aciIdentityKey
SignalStore.account.generatePniIdentityKeyIfNecessary()
val pniIdentity: IdentityKeyPair = SignalStore.account.pniIdentityKey
val aciPreKeyCollection = generateSignedAndLastResortPreKeys(aciIdentity, SignalStore.account.aciPreKeys)
val pniPreKeyCollection = generateSignedAndLastResortPreKeys(pniIdentity, SignalStore.account.pniPreKeys)
val result: NetworkResult<AccountRegistrationResult> = api.registerAccount(sessionId, registrationData.recoveryPassword, accountAttributes, aciPreKeyCollection, pniPreKeyCollection, registrationData.fcmToken, true)
.map { accountRegistrationResponse: VerifyAccountResponse ->
AccountRegistrationResult(
uuid = accountRegistrationResponse.uuid,
pni = accountRegistrationResponse.pni,
storageCapable = accountRegistrationResponse.storageCapable,
number = accountRegistrationResponse.number,
masterKey = masterKey,
pin = pin,
aciPreKeyCollection = aciPreKeyCollection,
pniPreKeyCollection = pniPreKeyCollection,
reRegistration = accountRegistrationResponse.reregistration
)
}
return@withContext RegisterAccountResult.from(result)
}
@WorkerThread
fun registerAsLinkedDevice(
context: Context,
deviceName: String,
message: ProvisionMessage,
registrationData: RegistrationData,
aciIdentityKeyPair: IdentityKeyPair,
pniIdentityKeyPair: IdentityKeyPair
): NetworkResult<RegisterAsLinkedDeviceResponse> {
val aci = message.aciBinary?.let { ACI.parseOrThrow(it) } ?: ACI.parseOrThrow(message.aci)
val pni = message.pniBinary?.let { PNI.parseOrThrow(it) } ?: PNI.parseOrThrow(message.pni)
val universalUnidentifiedAccess = TextSecurePreferences.isUniversalUnidentifiedAccess(context)
val unidentifiedAccessKey = UnidentifiedAccess.deriveAccessKeyFrom(registrationData.profileKey)
val encryptedDeviceName = DeviceNameCipher.encryptDeviceName(deviceName.toByteArray(StandardCharsets.UTF_8), aciIdentityKeyPair)
val accountAttributes = AccountAttributes(
signalingKey = null,
registrationId = getRegistrationId(),
fetchesMessages = registrationData.fcmToken == null,
registrationLock = null,
unidentifiedAccessKey = unidentifiedAccessKey,
unrestrictedUnidentifiedAccess = universalUnidentifiedAccess,
capabilities = AppCapabilities.getCapabilities(false),
discoverableByPhoneNumber = false,
name = Base64.encodeWithPadding(encryptedDeviceName),
pniRegistrationId = getPniRegistrationId(),
recoveryPassword = null
)
val aciPreKeys = generateSignedAndLastResortPreKeys(aciIdentityKeyPair, SignalStore.account.aciPreKeys)
val pniPreKeys = generateSignedAndLastResortPreKeys(pniIdentityKeyPair, SignalStore.account.pniPreKeys)
return AccountManagerFactory
.getInstance()
.createUnauthenticated(context, message.number!!, -1, registrationData.password)
.registrationApi
.registerAsSecondaryDevice(message.provisioningCode!!, accountAttributes, aciPreKeys, pniPreKeys, registrationData.fcmToken)
.map { respone ->
RegisterAsLinkedDeviceResponse(
deviceId = respone.deviceId.toInt(),
accountRegistrationResult = AccountRegistrationResult(
uuid = aci.toString(),
pni = pni.toString(),
storageCapable = false,
number = message.number!!,
masterKey = MasterKey(message.masterKey!!.toByteArray()),
pin = null,
aciPreKeyCollection = aciPreKeys,
pniPreKeyCollection = pniPreKeys,
reRegistration = true
)
)
}
}
private suspend fun createSessionAndBlockForPushChallenge(accountManager: RegistrationApi, fcmToken: String, mcc: String?, mnc: String?): NetworkResult<RegistrationSessionMetadataResponse> =
withContext(Dispatchers.IO) {
// TODO [regv2]: do not use event bus nor latch
val subscriber = PushTokenChallengeSubscriber()
val eventBus = EventBus.getDefault()
eventBus.register(subscriber)
try {
Log.d(TAG, "Requesting a registration session with FCM token…")
val sessionCreationResponse = accountManager.createRegistrationSession(fcmToken, mcc, mnc)
if (sessionCreationResponse !is NetworkResult.Success) {
return@withContext sessionCreationResponse
}
val receivedPush = subscriber.latch.await(PUSH_REQUEST_TIMEOUT, TimeUnit.MILLISECONDS)
eventBus.unregister(subscriber)
if (receivedPush) {
val challenge = subscriber.challenge
if (challenge != null) {
Log.i(TAG, "Push challenge token received.")
return@withContext accountManager.submitPushChallengeToken(sessionCreationResponse.result.metadata.id, challenge)
} else {
Log.w(TAG, "Push received but challenge token was null.")
}
} else {
Log.i(TAG, "Push challenge timed out.")
}
Log.i(TAG, "Push challenge unsuccessful. Continuing with session created without one.")
return@withContext sessionCreationResponse
} catch (ex: Exception) {
Log.w(TAG, "Exception caught, but the earlier try block should have caught it?", ex)
return@withContext NetworkResult.ApplicationError<RegistrationSessionMetadataResponse>(ex)
}
}
suspend fun hasValidSvrAuthCredentials(context: Context, e164: String, password: String): BackupAuthCheckResult =
withContext(Dispatchers.IO) {
val api: RegistrationApi = AccountManagerFactory.getInstance().createUnauthenticated(context, e164, SignalServiceAddress.DEFAULT_DEVICE_ID, password).registrationApi
val svr3Result = SignalStore.svr.svr3AuthTokens
?.takeIf { Svr3Migration.shouldReadFromSvr3 }
?.takeIf { it.isNotEmpty() }
?.toSvrCredentials()
?.let { authTokens ->
api
.validateSvr3AuthCredential(e164, authTokens)
.runIfSuccessful {
val removedInvalidTokens = SignalStore.svr.removeSvr3AuthTokens(it.invalid)
if (removedInvalidTokens) {
BackupManager(context).dataChanged()
}
}
.let { BackupAuthCheckResult.fromV3(it) }
}
if (svr3Result is BackupAuthCheckResult.SuccessWithCredentials) {
Log.d(TAG, "Found valid SVR3 credentials.")
return@withContext svr3Result
}
Log.d(TAG, "No valid SVR3 credentials, looking for SVR2.")
return@withContext SignalStore.svr.svr2AuthTokens
?.takeIf { it.isNotEmpty() }
?.toSvrCredentials()
?.let { authTokens ->
api
.validateSvr2AuthCredential(e164, authTokens)
.runIfSuccessful {
val removedInvalidTokens = SignalStore.svr.removeSvr2AuthTokens(it.invalid)
if (removedInvalidTokens) {
BackupManager(context).dataChanged()
}
}
.let { BackupAuthCheckResult.fromV2(it) }
} ?: BackupAuthCheckResult.SuccessWithoutCredentials()
}
/** Converts the basic-auth creds we have locally into username:password pairs that are suitable for handing off to the service. */
private fun List<String?>.toSvrCredentials(): List<String> {
return this
.asSequence()
.filterNotNull()
.take(10)
.map { it.replace("Basic ", "").trim() }
.mapNotNull {
try {
Base64.decode(it)
} catch (e: IOException) {
Log.w(TAG, "Encountered error trying to decode a token!", e)
null
}
}
.map { String(it, StandardCharsets.ISO_8859_1) }
.toList()
}
/**
* Starts an SMS listener to auto-enter a verification code.
*
* The listener [lives for 5 minutes](https://developers.google.com/android/reference/com/google/android/gms/auth/api/phone/SmsRetrieverApi).
*
* @return whether or not the Play Services SMS Listener was successfully registered.
*/
suspend fun registerSmsListener(context: Context): Boolean {
Log.d(TAG, "Attempting to start verification code SMS retriever.")
val started = withTimeoutOrNull(5.seconds.inWholeMilliseconds) {
try {
SmsRetriever.getClient(context).startSmsRetriever().await()
Log.d(TAG, "Successfully started verification code SMS retriever.")
return@withTimeoutOrNull true
} catch (ex: Exception) {
Log.w(TAG, "Could not start verification code SMS retriever due to exception.", ex)
return@withTimeoutOrNull false
}
}
if (started == null) {
Log.w(TAG, "Could not start verification code SMS retriever due to timeout.")
}
return started == true
}
@VisibleForTesting
fun generateSignedAndLastResortPreKeys(identity: IdentityKeyPair, metadataStore: PreKeyMetadataStore): PreKeyCollection {
val signedPreKey = PreKeyUtil.generateSignedPreKey(metadataStore.nextSignedPreKeyId, identity.privateKey)
val lastResortKyberPreKey = PreKeyUtil.generateLastResortKyberPreKey(metadataStore.nextKyberPreKeyId, identity.privateKey)
return PreKeyCollection(
identity.publicKey,
signedPreKey,
lastResortKyberPreKey
)
}
fun isMissingProfileData(): Boolean {
return Recipient.self().profileName.isEmpty || !AvatarHelper.hasAvatar(AppDependencies.application, Recipient.self().id)
}
suspend fun waitForLinkAndSyncBackupDetails(maxWaitTime: Duration = 60.seconds): TransferArchiveResponse? {
val startTime = System.currentTimeMillis()
var timeRemaining = maxWaitTime.inWholeMilliseconds
while (timeRemaining > 0 && coroutineContext.isActive) {
Log.d(TAG, "[waitForLinkAndSyncBackupDetails] Willing to wait for $timeRemaining ms...")
when (val result = SignalNetwork.linkDevice.waitForPrimaryDevice(timeout = 60.seconds)) {
is NetworkResult.Success -> {
Log.i(TAG, "[waitForLinkAndSyncBackupDetails] Transfer archive data provided by primary")
return result.result
}
is NetworkResult.ApplicationError -> {
Log.e(TAG, "[waitForLinkAndSyncBackupDetails] Application error!", result.throwable)
throw result.throwable
}
is NetworkResult.NetworkError -> {
Log.w(TAG, "[waitForLinkAndSyncBackupDetails] Hit a network error while waiting for linking. Will try to wait again.", result.exception)
}
is NetworkResult.StatusCodeError -> {
when (result.code) {
400 -> {
Log.w(TAG, "[waitForLinkAndSyncBackupDetails] Invalid timeout!")
return null
}
429 -> {
Log.w(TAG, "[waitForLinkAndSyncBackupDetails] Hit a rate-limit. Will try to wait again after delay: ${result.retryAfter()}.")
result.retryAfter()?.let { retryAfter ->
delay(retryAfter)
}
}
else -> {
Log.w(TAG, "[waitForLinkAndSyncBackupDetails] Hit an unknown status code of ${result.code}. Will try to wait again.")
}
}
}
}
timeRemaining = maxWaitTime.inWholeMilliseconds - (System.currentTimeMillis() - startTime)
}
Log.w(TAG, "[waitForLinkAndSyncBackupDetails] Failed to get transfer archive data from primary")
return null
}
fun interface MasterKeyProducer {
@Throws(IOException::class, SvrWrongPinException::class, SvrNoDataException::class)
fun produceMasterKey(): MasterKey
}
enum class E164VerificationMode(val isSmsRetrieverSupported: Boolean, val transport: PushServiceSocket.VerificationCodeTransport) {
SMS_WITH_LISTENER(true, PushServiceSocket.VerificationCodeTransport.SMS),
SMS_WITHOUT_LISTENER(false, PushServiceSocket.VerificationCodeTransport.SMS),
PHONE_CALL(false, PushServiceSocket.VerificationCodeTransport.VOICE)
}
private class PushTokenChallengeSubscriber {
var challenge: String? = null
val latch = CountDownLatch(1)
@Subscribe
fun onChallengeEvent(pushChallengeEvent: PushChallengeRequest.PushChallengeEvent) {
Log.d(TAG, "Push challenge received!")
challenge = pushChallengeEvent.challenge
latch.countDown()
}
}
}

View File

@@ -0,0 +1,378 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.olddevice
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.ui.compose.Texts
import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.BiometricDeviceAuthentication
import org.thoughtcrime.securesms.BiometricDeviceLockContract
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.PassphraseRequiredActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceTransferActivity
import org.thoughtcrime.securesms.fonts.SignalSymbols
import org.thoughtcrime.securesms.fonts.SignalSymbols.SignalSymbol
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.data.QuickRegistrationRepository
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
import org.thoughtcrime.securesms.util.DynamicTheme
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.viewModel
import org.whispersystems.signalservice.api.provisioning.RestoreMethod
/**
* Launched after scanning QR code from new device to start the transfer/reregistration process from
* old phone to new phone.
*/
class TransferAccountActivity : PassphraseRequiredActivity() {
companion object {
private val TAG = Log.tag(TransferAccountActivity::class)
private const val KEY_URI = "URI"
const val LEARN_MORE_URL = "https://support.signal.org/hc/articles/360007059752-Backup-and-Restore-Messages"
fun intent(context: Context, uri: String): Intent {
return Intent(context, TransferAccountActivity::class.java).apply {
putExtra(KEY_URI, uri)
}
}
}
private val theme: DynamicTheme = DynamicNoActionBarTheme()
private val viewModel: TransferAccountViewModel by viewModel {
TransferAccountViewModel(intent.getStringExtra(KEY_URI)!!)
}
private lateinit var biometricAuth: BiometricDeviceAuthentication
private lateinit var biometricDeviceLockLauncher: ActivityResultLauncher<String>
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
super.onCreate(savedInstanceState, ready)
theme.onCreate(this)
if (!SignalStore.account.isRegistered) {
finish()
}
biometricDeviceLockLauncher = registerForActivityResult(BiometricDeviceLockContract()) { result: Int ->
if (result == BiometricDeviceAuthentication.AUTHENTICATED) {
Log.i(TAG, "Device authentication succeeded via contract")
transferAccount()
}
}
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setAllowedAuthenticators(BiometricDeviceAuthentication.ALLOWED_AUTHENTICATORS)
.setTitle(getString(R.string.TransferAccount_unlock_to_transfer))
.setConfirmationRequired(true)
.build()
biometricAuth = BiometricDeviceAuthentication(
BiometricManager.from(this),
BiometricPrompt(this, BiometricAuthenticationListener()),
promptInfo
)
lifecycleScope.launch {
val restoreMethodSelected = viewModel
.state
.mapNotNull { it.restoreMethodSelected }
.firstOrNull()
when (restoreMethodSelected) {
RestoreMethod.DEVICE_TRANSFER -> {
startActivities(
arrayOf(
MainActivity.clearTop(this@TransferAccountActivity),
Intent(this@TransferAccountActivity, OldDeviceTransferActivity::class.java)
)
)
}
RestoreMethod.REMOTE_BACKUP,
RestoreMethod.LOCAL_BACKUP,
RestoreMethod.DECLINE,
null -> startActivity(MainActivity.clearTop(this@TransferAccountActivity))
}
}
setContent {
val state by viewModel.state.collectAsState()
SignalTheme {
TransferToNewDevice(
state = state,
onTransferAccount = this::authenticate,
onContinueOnOtherDeviceDismiss = {
finish()
viewModel.clearReRegisterResult()
},
onErrorDismiss = viewModel::clearReRegisterResult,
onBackClicked = { finish() }
)
}
}
}
override fun onPause() {
super.onPause()
biometricAuth.cancelAuthentication()
}
override fun onResume() {
super.onResume()
theme.onResume(this)
}
private fun authenticate() {
val canAuthenticate = biometricAuth.authenticate(this, true) {
biometricDeviceLockLauncher.launch(getString(R.string.TransferAccount_unlock_to_transfer))
}
if (!canAuthenticate) {
Log.w(TAG, "Device authentication not available")
transferAccount()
}
}
private fun transferAccount() {
Log.d(TAG, "transferAccount()")
viewModel.transferAccount()
}
private inner class BiometricAuthenticationListener : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errorString: CharSequence) {
Log.w(TAG, "Device authentication error: $errorCode")
onAuthenticationFailed()
}
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
Log.i(TAG, "Device authentication succeeded")
transferAccount()
}
override fun onAuthenticationFailed() {
Log.w(TAG, "Device authentication failed")
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TransferToNewDevice(
state: TransferAccountViewModel.TransferAccountState,
onTransferAccount: () -> Unit = {},
onContinueOnOtherDeviceDismiss: () -> Unit = {},
onErrorDismiss: () -> Unit = {},
onBackClicked: () -> Unit = {}
) {
Scaffold(
topBar = { TopAppBarContent(onBackClicked = onBackClicked) }
) { contentPadding ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(contentPadding)
.horizontalGutters()
) {
Image(
painter = painterResource(R.drawable.image_transfer_phones),
contentDescription = null,
modifier = Modifier.padding(top = 20.dp, bottom = 28.dp)
)
val context = LocalContext.current
val learnMore = stringResource(id = R.string.TransferAccount_learn_more)
val fullString = stringResource(id = R.string.TransferAccount_body, learnMore)
val spanned = SpanUtil.urlSubsequence(fullString, learnMore, TransferAccountActivity.LEARN_MORE_URL)
Texts.LinkifiedText(
textWithUrlSpans = spanned,
onUrlClick = { CommunicationActions.openBrowserLink(context, it) },
style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center)
)
Spacer(modifier = Modifier.height(28.dp))
AnimatedContent(
targetState = state.inProgress,
contentAlignment = Alignment.Center
) { inProgress ->
if (inProgress) {
CircularProgressIndicator()
} else {
Buttons.LargeTonal(
onClick = onTransferAccount,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = R.string.TransferAccount_button))
}
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = buildAnnotatedString {
SignalSymbol(SignalSymbols.Weight.REGULAR, SignalSymbols.Glyph.LOCK)
append(" ")
append(stringResource(id = R.string.TransferAccount_messages_e2e))
},
style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center)
)
}
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
when (state.reRegisterResult) {
QuickRegistrationRepository.TransferAccountResult.SUCCESS -> {
ModalBottomSheet(
dragHandle = null,
onDismissRequest = onContinueOnOtherDeviceDismiss,
sheetState = sheetState
) {
ContinueOnOtherDevice()
}
}
QuickRegistrationRepository.TransferAccountResult.FAILED -> {
Dialogs.SimpleMessageDialog(
message = stringResource(R.string.RegistrationActivity_unable_to_connect_to_service),
dismiss = stringResource(android.R.string.ok),
onDismiss = onErrorDismiss
)
}
null -> Unit
}
}
}
@SignalPreview
@Composable
private fun TransferToNewDevicePreview() {
Previews.Preview {
TransferToNewDevice(state = TransferAccountViewModel.TransferAccountState("sgnl://rereg"))
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TopAppBarContent(onBackClicked: () -> Unit) {
TopAppBar(
title = {
Text(text = stringResource(R.string.TransferAccount_title))
},
navigationIcon = {
IconButton(onClick = onBackClicked) {
Icon(
painter = painterResource(R.drawable.symbol_x_24),
tint = MaterialTheme.colorScheme.onSurface,
contentDescription = null
)
}
}
)
}
/**
* Shown after successfully sending provisioning message to new device.
*/
@Composable
fun ContinueOnOtherDevice() {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.horizontalGutters()
.padding(bottom = 54.dp)
) {
BottomSheets.Handle()
Spacer(modifier = Modifier.height(26.dp))
Image(
painter = painterResource(R.drawable.image_other_device),
contentDescription = null,
modifier = Modifier.padding(bottom = 20.dp)
)
Text(
text = stringResource(id = R.string.TransferAccount_continue_on_your_other_device),
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(id = R.string.TransferAccount_continue_on_your_other_device_details),
style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center)
)
Spacer(modifier = Modifier.height(36.dp))
CircularProgressIndicator(modifier = Modifier.size(44.dp))
}
}
@SignalPreview
@Composable
private fun ContinueOnOtherDevicePreview() {
Previews.Preview {
ContinueOnOtherDevice()
}
}

View File

@@ -0,0 +1,55 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.olddevice
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.data.QuickRegistrationRepository
import org.whispersystems.signalservice.api.provisioning.RestoreMethod
import java.util.UUID
class TransferAccountViewModel(reRegisterUri: String) : ViewModel() {
private val store: MutableStateFlow<TransferAccountState> = MutableStateFlow(TransferAccountState(reRegisterUri))
val state: StateFlow<TransferAccountState> = store
fun transferAccount() {
viewModelScope.launch(Dispatchers.IO) {
val restoreMethodToken = UUID.randomUUID().toString()
store.update { it.copy(inProgress = true) }
val result = QuickRegistrationRepository.transferAccount(store.value.reRegisterUri, restoreMethodToken)
store.update { it.copy(reRegisterResult = result, inProgress = false) }
if (result == QuickRegistrationRepository.TransferAccountResult.SUCCESS) {
val restoreMethod = QuickRegistrationRepository.waitForRestoreMethodSelectionOnNewDevice(restoreMethodToken)
if (restoreMethod != RestoreMethod.DECLINE) {
SignalStore.registration.restoringOnNewDevice = true
}
store.update { it.copy(restoreMethodSelected = restoreMethod) }
}
}
}
fun clearReRegisterResult() {
store.update { it.copy(reRegisterResult = null) }
}
data class TransferAccountState(
val reRegisterUri: String,
val inProgress: Boolean = false,
val reRegisterResult: QuickRegistrationRepository.TransferAccountResult? = null,
val restoreMethodSelected: RestoreMethod? = null
)
}

View File

@@ -0,0 +1,101 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.viewModels
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.navigation.ActivityNavigator
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.BaseActivity
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.sms.SmsRetrieverReceiver
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme
/**
* Activity to hold the entire registration process.
*/
class RegistrationActivity : BaseActivity() {
private val TAG = Log.tag(RegistrationActivity::class.java)
private val dynamicTheme = DynamicNoActionBarTheme()
val sharedViewModel: RegistrationViewModel by viewModels()
private var smsRetrieverReceiver: SmsRetrieverReceiver? = null
init {
lifecycle.addObserver(SmsRetrieverObserver())
}
override fun onCreate(savedInstanceState: Bundle?) {
dynamicTheme.onCreate(this)
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_registration_navigation_v3)
sharedViewModel.isReregister = intent.getBooleanExtra(RE_REGISTRATION_EXTRA, false)
sharedViewModel.checkpoint.observe(this) {
if (it >= RegistrationCheckpoint.LOCAL_REGISTRATION_COMPLETE) {
RegistrationUtil.maybeMarkRegistrationComplete()
handleSuccessfulVerify()
}
}
}
override fun onResume() {
super.onResume()
dynamicTheme.onResume(this)
}
private fun handleSuccessfulVerify() {
if (SignalStore.account.isPrimaryDevice && SignalStore.account.isMultiDevice) {
SignalStore.misc.shouldShowLinkedDevicesReminder = sharedViewModel.isReregister
}
startActivity(MainActivity.clearTop(this))
finish()
ActivityNavigator.applyPopAnimationsToPendingTransition(this)
}
private inner class SmsRetrieverObserver : DefaultLifecycleObserver {
override fun onCreate(owner: LifecycleOwner) {
smsRetrieverReceiver = SmsRetrieverReceiver(application)
smsRetrieverReceiver?.registerReceiver()
}
override fun onDestroy(owner: LifecycleOwner) {
smsRetrieverReceiver?.unregisterReceiver()
smsRetrieverReceiver = null
}
}
companion object {
const val RE_REGISTRATION_EXTRA: String = "re_registration"
@JvmStatic
fun newIntentForNewRegistration(context: Context, originalIntent: Intent): Intent {
return Intent(context, RegistrationActivity::class.java).apply {
putExtra(RE_REGISTRATION_EXTRA, false)
setData(originalIntent.data)
}
}
@JvmStatic
fun newIntentForReRegistration(context: Context): Intent {
return Intent(context, RegistrationActivity::class.java).apply {
putExtra(RE_REGISTRATION_EXTRA, true)
}
}
}
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui
/**
* An ordered list of checkpoints of the registration process.
* This is used for screens to know when to advance, as well as restoring state after process death.
*/
enum class RegistrationCheckpoint {
INITIALIZATION,
PERMISSIONS_GRANTED,
BACKUP_RESTORED_OR_SKIPPED,
PUSH_NETWORK_AUDITED,
PHONE_NUMBER_CONFIRMED,
PIN_CONFIRMED,
VERIFICATION_CODE_REQUESTED,
VERIFICATION_CODE_ENTERED,
PIN_ENTERED,
VERIFICATION_CODE_VALIDATED,
SERVICE_REGISTRATION_COMPLETED,
BACKUP_TIMESTAMP_NOT_RESTORED,
LOCAL_REGISTRATION_COMPLETE
}

View File

@@ -0,0 +1,94 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui
import com.google.i18n.phonenumbers.NumberParseException
import com.google.i18n.phonenumbers.PhoneNumberUtil
import com.google.i18n.phonenumbers.Phonenumber
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
import org.thoughtcrime.securesms.registration.data.network.Challenge
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionResult
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult
import org.whispersystems.signalservice.api.svr.Svr3Credentials
import org.whispersystems.signalservice.internal.push.AuthCredentials
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* State holder shared across all of registration.
*/
data class RegistrationState(
val sessionId: String? = null,
val enteredCode: String = "",
val phoneNumber: Phonenumber.PhoneNumber? = fetchExistingE164FromValues(),
val nationalNumber: String = "",
val inProgress: Boolean = false,
val isReRegister: Boolean = false,
val recoveryPassword: String? = null,
val canSkipSms: Boolean = false,
val svr2AuthCredentials: AuthCredentials? = null,
val svr3AuthCredentials: Svr3Credentials? = null,
val svrTriesRemaining: Int = 10,
val incorrectCodeAttempts: Int = 0,
val isRegistrationLockEnabled: Boolean = false,
val lockedTimeRemaining: Long = 0L,
val userSkippedReregistration: Boolean = false,
val isFcmSupported: Boolean = false,
val isAllowedToRequestCode: Boolean = false,
val fcmToken: String? = null,
val challengesRequested: List<Challenge> = emptyList(),
val captchaToken: String? = null,
val allowedToRequestCode: Boolean = false,
val nextSmsTimestamp: Duration = 0.seconds,
val nextCallTimestamp: Duration = 0.seconds,
val nextVerificationAttempt: Duration = 0.seconds,
val verified: Boolean = false,
val smsListenerTimeout: Long = 0L,
val pinKeyboardType: PinKeyboardType = SignalStore.pin.keyboardType,
val registrationCheckpoint: RegistrationCheckpoint = RegistrationCheckpoint.INITIALIZATION,
val networkError: Throwable? = null,
val sessionCreationError: RegistrationSessionResult? = null,
val sessionStateError: VerificationCodeRequestResult? = null,
val registerAccountError: RegisterAccountResult? = null,
val challengeInProgress: Boolean = false
) {
companion object {
private val TAG = Log.tag(RegistrationState::class)
private fun fetchExistingE164FromValues(): Phonenumber.PhoneNumber? {
val existingE164 = SignalStore.registration.sessionE164
if (existingE164 != null) {
try {
return PhoneNumberUtil.getInstance().parse(existingE164, null)
} catch (ex: NumberParseException) {
Log.w(TAG, "Could not parse stored E164.", ex)
return null
}
} else {
return null
}
}
}
fun toNavigationStateOnly(): NavigationState {
return NavigationState(challengesRequested, captchaToken, registrationCheckpoint, canSkipSms, challengeInProgress)
}
/**
* Subset of [RegistrationState] useful for deciding on navigation. Prevents other properties updating from re-triggering
* navigation decisions.
*/
data class NavigationState(
val challengesRequested: List<Challenge>,
val captchaToken: String? = null,
val registrationCheckpoint: RegistrationCheckpoint,
val canSkipSms: Boolean,
val challengeInProgress: Boolean
)
}

View File

@@ -0,0 +1,67 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.accountlocked
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.widget.TextView
import androidx.activity.OnBackPressedCallback
import androidx.fragment.app.activityViewModels
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView
import org.thoughtcrime.securesms.registration.ui.RegistrationViewModel
import kotlin.time.Duration.Companion.milliseconds
/**
* Screen educating the user that they need to wait some number of days to register.
*/
class AccountLockedFragment : LoggingFragment(R.layout.account_locked_fragment) {
private val viewModel by activityViewModels<RegistrationViewModel>()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setDebugLogSubmitMultiTapView(view.findViewById(R.id.account_locked_title))
val description = view.findViewById<TextView>(R.id.account_locked_description)
viewModel.lockedTimeRemaining.observe(
viewLifecycleOwner
) { t: Long? -> description.text = getString(R.string.AccountLockedFragment__your_account_has_been_locked_to_protect_your_privacy, durationToDays(t!!)) }
view.findViewById<View>(R.id.account_locked_next).setOnClickListener { v: View? -> onNext() }
view.findViewById<View>(R.id.account_locked_learn_more).setOnClickListener { v: View? -> learnMore() }
requireActivity().onBackPressedDispatcher.addCallback(
viewLifecycleOwner,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
onNext()
}
}
)
}
private fun learnMore() {
val intent = Intent(Intent.ACTION_VIEW)
intent.setData(Uri.parse(getString(R.string.AccountLockedFragment__learn_more_url)))
startActivity(intent)
}
fun onNext() {
requireActivity().finish()
}
private fun durationToDays(duration: Long): Long {
return if (duration != 0L) getLockoutDays(duration).toLong() else 7
}
private fun getLockoutDays(timeRemainingMs: Long): Int {
return timeRemainingMs.milliseconds.inWholeDays.toInt() + 1
}
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.captcha
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.View
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.navigation.fragment.findNavController
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.databinding.FragmentRegistrationCaptchaBinding
import org.thoughtcrime.securesms.registration.fragments.RegistrationConstants
abstract class CaptchaFragment : LoggingFragment(R.layout.fragment_registration_captcha) {
private val binding: FragmentRegistrationCaptchaBinding by ViewBinderDelegate(FragmentRegistrationCaptchaBinding::bind)
@SuppressLint("SetJavaScriptEnabled")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.registrationCaptchaWebView.settings.javaScriptEnabled = true
binding.registrationCaptchaWebView.clearCache(true)
binding.registrationCaptchaWebView.webViewClient = object : WebViewClient() {
@Deprecated("Deprecated in Java")
override fun shouldOverrideUrlLoading(view: WebView, url: String): Boolean {
if (url.startsWith(RegistrationConstants.SIGNAL_CAPTCHA_SCHEME)) {
val token = url.substring(RegistrationConstants.SIGNAL_CAPTCHA_SCHEME.length)
handleCaptchaToken(token)
findNavController().navigateUp()
return true
}
return false
}
}
binding.registrationCaptchaWebView.loadUrl(BuildConfig.SIGNAL_CAPTCHA_URL)
}
abstract fun handleCaptchaToken(token: String)
}

View File

@@ -0,0 +1,23 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.captcha
import androidx.fragment.app.activityViewModels
import org.thoughtcrime.securesms.registration.ui.RegistrationViewModel
/**
* Screen that displays a captcha as part of the registration flow.
* This subclass plugs in [RegistrationViewModel] to the shared super class.
*
* @see CaptchaFragment
*/
class RegistrationCaptchaFragment : CaptchaFragment() {
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
override fun handleCaptchaToken(token: String) {
sharedViewModel.setCaptchaResponse(token)
}
}

View File

@@ -0,0 +1,67 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.countrycode
import android.os.Bundle
import android.view.View
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.res.stringResource
import androidx.core.os.bundleOf
import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.fragment.findNavController
import org.signal.core.util.getParcelableCompat
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeFragment
/**
* Country picker fragment used in registration V3
*/
class CountryCodeFragment : ComposeFragment() {
companion object {
private val TAG = Log.tag(CountryCodeFragment::class.java)
const val RESULT_KEY = "result_key"
const val REQUEST_KEY_COUNTRY = "request_key_country"
const val REQUEST_COUNTRY = "country"
const val RESULT_COUNTRY = "country"
}
private val viewModel: CountryCodeViewModel by viewModels()
@Composable
override fun FragmentContent() {
val state by viewModel.state.collectAsStateWithLifecycle()
val resultKey = arguments?.getString(RESULT_KEY) ?: REQUEST_KEY_COUNTRY
CountryCodeSelectScreen(
state = state,
title = stringResource(R.string.CountryCodeFragment__your_country),
onSearch = { search -> viewModel.filterCountries(search) },
onDismissed = { findNavController().popBackStack() },
onClick = { country ->
setFragmentResult(
resultKey,
bundleOf(
RESULT_COUNTRY to country
)
)
findNavController().popBackStack()
}
)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val initialCountry = arguments?.getParcelableCompat(REQUEST_COUNTRY, Country::class.java)
viewModel.loadCountries(initialCountry)
}
}

View File

@@ -14,7 +14,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
/**
* View model to support [org.thoughtcrime.securesms.registrationv3.ui.countrycode.CountryCodeFragment] and track the countries
* View model to support [CountryCodeFragment] and track the countries
*/
class CountryCodeViewModel : ViewModel() {

View File

@@ -0,0 +1,431 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.entercode
import android.content.DialogInterface
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.fragment.findNavController
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.i18n.phonenumbers.PhoneNumberUtil
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.signal.core.util.ThreadUtil
import org.signal.core.util.isNotNullOrBlank
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.conversation.v2.registerForLifecycle
import org.thoughtcrime.securesms.databinding.FragmentRegistrationEnterCodeBinding
import org.thoughtcrime.securesms.registration.data.network.Challenge
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
import org.thoughtcrime.securesms.registration.data.network.RegistrationResult
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionCheckResult
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionCreationResult
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionResult
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult
import org.thoughtcrime.securesms.registration.fragments.ContactSupportBottomSheetFragment
import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView
import org.thoughtcrime.securesms.registration.fragments.SignalStrengthPhoneStateListener
import org.thoughtcrime.securesms.registration.sms.ReceivedSmsEvent
import org.thoughtcrime.securesms.registration.ui.RegistrationCheckpoint
import org.thoughtcrime.securesms.registration.ui.RegistrationViewModel
import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.visible
import kotlin.time.Duration.Companion.milliseconds
/**
* The final screen of account registration, where the user enters their verification code.
*/
class EnterCodeFragment : LoggingFragment(R.layout.fragment_registration_enter_code) {
companion object {
private val TAG = Log.tag(EnterCodeFragment::class.java)
private const val BOTTOM_SHEET_TAG = "support_bottom_sheet"
}
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
private val fragmentViewModel by viewModels<EnterCodeViewModel>()
private val bottomSheet = ContactSupportBottomSheetFragment()
private val binding: FragmentRegistrationEnterCodeBinding by ViewBinderDelegate(FragmentRegistrationEnterCodeBinding::bind)
private lateinit var phoneStateListener: SignalStrengthPhoneStateListener
private var autopilotCodeEntryActive = false
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setDebugLogSubmitMultiTapView(binding.verifyHeader)
phoneStateListener = SignalStrengthPhoneStateListener(this, PhoneStateCallback())
requireActivity().onBackPressedDispatcher.addCallback(
viewLifecycleOwner,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
popBackStack()
}
}
)
binding.wrongNumber.setOnClickListener {
popBackStack()
}
binding.code.setOnCompleteListener {
sharedViewModel.verifyCodeWithoutRegistrationLock(requireContext(), it)
}
binding.havingTroubleButton.setOnClickListener {
bottomSheet.showSafely(childFragmentManager, BOTTOM_SHEET_TAG)
}
binding.callMeCountDown.apply {
setTextResources(R.string.RegistrationActivity_call, R.string.RegistrationActivity_call_me_instead_available_in)
setOnClickListener {
sharedViewModel.requestVerificationCall(requireContext())
}
}
binding.resendSmsCountDown.apply {
setTextResources(R.string.RegistrationActivity_resend_code, R.string.RegistrationActivity_resend_sms_available_in)
setOnClickListener {
sharedViewModel.requestSmsCode(requireContext())
}
}
binding.keyboard.setOnKeyPressListener { key ->
if (!autopilotCodeEntryActive) {
if (key >= 0) {
binding.code.append(key)
} else {
binding.code.delete()
}
}
}
sharedViewModel.incorrectCodeAttempts.observe(viewLifecycleOwner) { attempts: Int ->
if (attempts >= 3) {
binding.havingTroubleButton.visible = true
}
}
sharedViewModel.uiState.observe(viewLifecycleOwner) { sharedState ->
sharedState.sessionCreationError?.let { error ->
handleSessionCreationError(error)
sharedViewModel.sessionCreationErrorShown()
}
sharedState.sessionStateError?.let { error ->
handleSessionErrorResponse(error)
sharedViewModel.sessionStateErrorShown()
}
sharedState.registerAccountError?.let { error ->
handleRegistrationErrorResponse(error)
sharedViewModel.registerAccountErrorShown()
}
if (sharedState.challengesRequested.contains(Challenge.CAPTCHA) && sharedState.captchaToken.isNotNullOrBlank()) {
sharedViewModel.submitCaptchaToken(requireContext())
} else if (sharedState.challengesRequested.isNotEmpty() && !sharedState.challengeInProgress) {
handleChallenges(sharedState.challengesRequested)
}
binding.resendSmsCountDown.startCountDownTo(sharedState.nextSmsTimestamp)
binding.callMeCountDown.startCountDownTo(sharedState.nextCallTimestamp)
if (sharedState.inProgress) {
binding.keyboard.displayProgress()
} else {
binding.keyboard.displayKeyboard()
}
}
fragmentViewModel.uiState.observe(viewLifecycleOwner) {
if (it.resetRequiredAfterFailure) {
binding.callMeCountDown.visibility = View.VISIBLE
binding.resendSmsCountDown.visibility = View.VISIBLE
binding.wrongNumber.visibility = View.VISIBLE
binding.code.clear()
binding.keyboard.displayKeyboard()
fragmentViewModel.allViewsResetCompleted()
} else if (it.showKeyboard) {
binding.keyboard.displayKeyboard()
fragmentViewModel.keyboardShown()
}
}
EventBus.getDefault().registerForLifecycle(subscriber = this, lifecycleOwner = viewLifecycleOwner)
}
override fun onResume() {
super.onResume()
sharedViewModel.phoneNumber?.let {
val formatted = PhoneNumberUtil.getInstance().format(it, PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL)
binding.verificationSubheader.text = requireContext().getString(R.string.RegistrationActivity_enter_the_code_we_sent_to_s, formatted)
}
}
private fun handleSessionCreationError(result: RegistrationSessionResult) {
if (!result.isSuccess()) {
Log.i(TAG, "[sessionCreateError] Handling error response of ${result.javaClass.name}", result.getCause())
}
when (result) {
is RegistrationSessionCheckResult.Success,
is RegistrationSessionCreationResult.Success -> throw IllegalStateException("Session error handler called on successful response!")
is RegistrationSessionCreationResult.AttemptsExhausted -> presentRemoteErrorDialog(getString(R.string.RegistrationActivity_rate_limited_to_service))
is RegistrationSessionCreationResult.MalformedRequest -> presentRemoteErrorDialog(getString(R.string.RegistrationActivity_unable_to_connect_to_service))
is RegistrationSessionCreationResult.RateLimited -> {
val timeRemaining = result.timeRemaining?.milliseconds
Log.i(TAG, "Session creation rate limited! Next attempt: $timeRemaining")
if (timeRemaining != null) {
presentRemoteErrorDialog(getString(R.string.RegistrationActivity_rate_limited_to_try_again, timeRemaining.toString()))
} else {
presentRemoteErrorDialog(getString(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later))
}
}
is RegistrationSessionCreationResult.ServerUnableToParse -> presentGenericError(result)
is RegistrationSessionCheckResult.SessionNotFound -> presentGenericError(result)
is RegistrationSessionCheckResult.UnknownError,
is RegistrationSessionCreationResult.UnknownError -> presentGenericError(result)
}
}
private fun handleSessionErrorResponse(result: VerificationCodeRequestResult) {
if (!result.isSuccess()) {
Log.i(TAG, "[sessionError] Handling error response of ${result.javaClass.name}", result.getCause())
}
when (result) {
is VerificationCodeRequestResult.Success -> throw IllegalStateException("Session error handler called on successful response!")
is VerificationCodeRequestResult.RateLimited -> {
val timeRemaining = result.timeRemaining?.milliseconds
Log.i(TAG, "Session patch rate limited! Next attempt: $timeRemaining")
if (timeRemaining != null) {
presentRemoteErrorDialog(getString(R.string.RegistrationActivity_rate_limited_to_try_again, timeRemaining.toString()))
} else {
presentRemoteErrorDialog(getString(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later))
}
}
is VerificationCodeRequestResult.RegistrationLocked -> presentRegistrationLocked(result.timeRemaining)
is VerificationCodeRequestResult.ExternalServiceFailure -> presentSmsGenericError(result)
is VerificationCodeRequestResult.RequestVerificationCodeRateLimited -> {
Log.i(TAG, result.log())
handleRequestVerificationCodeRateLimited(result)
}
is VerificationCodeRequestResult.SubmitVerificationCodeRateLimited -> presentSubmitVerificationCodeRateLimited()
is VerificationCodeRequestResult.TokenNotAccepted -> presentRemoteErrorDialog(getString(R.string.RegistrationActivity_we_need_to_verify_that_youre_human)) { _, _ -> moveToCaptcha() }
else -> presentGenericError(result)
}
}
private fun handleRegistrationErrorResponse(result: RegisterAccountResult) {
if (!result.isSuccess()) {
Log.i(TAG, "[registrationError] Handling error response of ${result.javaClass.name}", result.getCause())
}
when (result) {
is RegisterAccountResult.Success -> throw IllegalStateException("Register account error handler called on successful response!")
is RegisterAccountResult.RegistrationLocked -> presentRegistrationLocked(result.timeRemaining)
is RegisterAccountResult.AuthorizationFailed -> presentIncorrectCodeDialog()
is RegisterAccountResult.AttemptsExhausted -> presentAccountLocked()
is RegisterAccountResult.RateLimited -> presentRateLimitedDialog()
else -> presentGenericError(result)
}
}
private fun handleChallenges(remainingChallenges: List<Challenge>) {
when (remainingChallenges.first()) {
Challenge.CAPTCHA -> moveToCaptcha()
Challenge.PUSH -> sharedViewModel.requestAndSubmitPushToken(requireContext())
}
}
private fun presentAccountLocked() {
binding.keyboard.displayLocked().addListener(
object : AssertedSuccessListener<Boolean>() {
override fun onSuccess(result: Boolean?) {
findNavController().safeNavigate(EnterCodeFragmentDirections.actionAccountLocked())
}
}
)
}
private fun presentRegistrationLocked(timeRemaining: Long) {
binding.keyboard.displayLocked().addListener(
object : AssertedSuccessListener<Boolean>() {
override fun onSuccess(result: Boolean?) {
findNavController().safeNavigate(EnterCodeFragmentDirections.actionRequireKbsLockPin(timeRemaining))
sharedViewModel.setInProgress(false)
}
}
)
}
private fun presentRateLimitedDialog() {
binding.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)
setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int ->
fragmentViewModel.resetAllViews()
}
show()
}
}
}
)
}
private fun presentIncorrectCodeDialog() {
sharedViewModel.incrementIncorrectCodeAttempts()
Toast.makeText(requireContext(), R.string.RegistrationActivity_incorrect_code, Toast.LENGTH_LONG).show()
binding.keyboard.displayFailure().addListener(object : AssertedSuccessListener<Boolean?>() {
override fun onSuccess(result: Boolean?) {
fragmentViewModel.resetAllViews()
}
})
}
private fun presentSmsGenericError(requestResult: RegistrationResult) {
binding.keyboard.displayFailure().addListener(
object : AssertedSuccessListener<Boolean>() {
override fun onSuccess(result: Boolean?) {
Log.w(TAG, "Encountered sms provider error!", requestResult.getCause())
MaterialAlertDialogBuilder(requireContext()).apply {
setMessage(R.string.RegistrationActivity_sms_provider_error)
setPositiveButton(android.R.string.ok) { _, _ -> fragmentViewModel.showKeyboard() }
show()
}
}
}
)
}
private fun presentRemoteErrorDialog(message: String, positiveButtonListener: DialogInterface.OnClickListener? = null) {
MaterialAlertDialogBuilder(requireContext()).apply {
setMessage(message)
setPositiveButton(android.R.string.ok, positiveButtonListener)
show()
}
}
private fun presentGenericError(requestResult: RegistrationResult) {
binding.keyboard.displayFailure().addListener(
object : AssertedSuccessListener<Boolean>() {
override fun onSuccess(result: Boolean?) {
Log.w(TAG, "Encountered unexpected error!", requestResult.getCause())
MaterialAlertDialogBuilder(requireContext()).apply {
setMessage(R.string.RegistrationActivity_error_connecting_to_service)
setPositiveButton(android.R.string.ok) { _, _ -> fragmentViewModel.showKeyboard() }
show()
}
}
}
)
}
private fun handleRequestVerificationCodeRateLimited(result: VerificationCodeRequestResult.RequestVerificationCodeRateLimited) {
if (result.willBeAbleToRequestAgain) {
Log.i(TAG, "Attempted to request new code too soon, timers should be updated")
} else {
Log.w(TAG, "Request for new verification code impossible, need to restart registration")
MaterialAlertDialogBuilder(requireContext()).apply {
setMessage(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later)
setPositiveButton(android.R.string.ok) { _, _ -> popBackStack() }
setCancelable(false)
show()
}
}
}
private fun presentSubmitVerificationCodeRateLimited() {
binding.keyboard.displayFailure().addListener(
object : AssertedSuccessListener<Boolean>() {
override fun onSuccess(result: Boolean?) {
Log.w(TAG, "Submit verification code impossible, need to request a new code and restart registration")
MaterialAlertDialogBuilder(requireContext()).apply {
setMessage(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later)
setPositiveButton(android.R.string.ok) { _, _ -> popBackStack() }
setCancelable(false)
show()
}
}
}
)
}
private fun popBackStack() {
sharedViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.PUSH_NETWORK_AUDITED)
NavHostFragment.findNavController(this).popBackStack()
sharedViewModel.setInProgress(false)
}
private fun moveToCaptcha() {
findNavController().safeNavigate(EnterCodeFragmentDirections.actionRequestCaptcha())
ThreadUtil.postToMain { sharedViewModel.setInProgress(false) }
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun onVerificationCodeReceived(event: ReceivedSmsEvent) {
Log.i(TAG, "Received verification code via EventBus.")
binding.code.clear()
if (event.code.isBlank() || event.code.length != ReceivedSmsEvent.CODE_LENGTH) {
Log.i(TAG, "Received invalid code of length ${event.code.length}. Ignoring.")
return
}
val finalIndex = ReceivedSmsEvent.CODE_LENGTH - 1
autopilotCodeEntryActive = true
try {
event.code
.map { it.digitToInt() }
.forEachIndexed { i, digit ->
binding.code.postDelayed({
binding.code.append(digit)
if (i == finalIndex) {
autopilotCodeEntryActive = false
}
}, i * 200L)
}
Log.i(TAG, "Finished auto-filling code.")
} catch (notADigit: IllegalArgumentException) {
Log.w(TAG, "Failed to convert code into digits.", notADigit)
autopilotCodeEntryActive = false
}
}
private inner class PhoneStateCallback : SignalStrengthPhoneStateListener.Callback {
override fun onNoCellSignalPresent() {
if (isAdded) {
bottomSheet.showSafely(childFragmentManager, BOTTOM_SHEET_TAG)
}
}
override fun onCellSignalPresent() {
if (bottomSheet.isResumed) {
bottomSheet.dismiss()
}
}
}
}

View File

@@ -0,0 +1,8 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.entercode
data class EnterCodeState(val resetRequiredAfterFailure: Boolean = false, val showKeyboard: Boolean = false)

View File

@@ -0,0 +1,37 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.entercode
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
class EnterCodeViewModel : ViewModel() {
private val store = MutableStateFlow(EnterCodeState())
val uiState = store.asLiveData()
fun resetAllViews() {
store.update { it.copy(resetRequiredAfterFailure = true) }
}
fun allViewsResetCompleted() {
store.update {
it.copy(
resetRequiredAfterFailure = false,
showKeyboard = false
)
}
}
fun showKeyboard() {
store.update { it.copy(showKeyboard = true) }
}
fun keyboardShown() {
store.update { it.copy(showKeyboard = false) }
}
}

View File

@@ -0,0 +1,318 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.link
import android.os.Bundle
import android.view.View
import android.view.WindowManager
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCode
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.registration.ui.RegistrationViewModel
import org.thoughtcrime.securesms.registration.ui.shared.RegistrationScreen
import java.lang.IllegalStateException
/**
* Crude show QR code on link device to allow linking from primary device.
*/
class RegisterLinkDeviceQrFragment : ComposeFragment() {
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
private val viewModel: RegisterLinkDeviceQrViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onResume(owner: LifecycleOwner) {
requireActivity().window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
override fun onPause(owner: LifecycleOwner) {
requireActivity().window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
})
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) {
viewModel
.state
.mapNotNull { it.provisionMessage }
.distinctUntilChanged()
.collect { message ->
withContext(Dispatchers.IO) {
val result = sharedViewModel.registerAsLinkedDevice(requireContext().applicationContext, message)
when (result) {
RegisterLinkDeviceResult.Success -> Unit
else -> viewModel.setRegisterAsLinkedDeviceError(result)
}
}
}
}
}
}
@Composable
override fun FragmentContent() {
val state by viewModel.state.collectAsState()
RegisterLinkDeviceQrScreen(
state = state,
onRetryQrCode = viewModel::restartProvisioningSocket,
onErrorDismiss = viewModel::clearErrors,
onCancel = { findNavController().popBackStack() }
)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun RegisterLinkDeviceQrScreen(
state: RegisterLinkDeviceQrViewModel.RegisterLinkDeviceState,
onRetryQrCode: () -> Unit = {},
onErrorDismiss: () -> Unit = {},
onCancel: () -> Unit = {}
) {
// TODO [link-device] use actual design
RegistrationScreen(
title = "Scan this code with your phone",
subtitle = null,
bottomContent = {
TextButton(
onClick = onCancel,
modifier = Modifier.align(Alignment.Center)
) {
Text(text = stringResource(android.R.string.cancel))
}
}
) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(space = 48.dp, alignment = Alignment.CenterHorizontally),
verticalArrangement = Arrangement.spacedBy(space = 48.dp),
modifier = Modifier
.fillMaxWidth()
.horizontalGutters()
) {
Box(
modifier = Modifier
.widthIn(160.dp, 320.dp)
.aspectRatio(1f)
.clip(RoundedCornerShape(24.dp))
.background(SignalTheme.colors.colorSurface5)
.padding(40.dp)
) {
SignalTheme(isDarkMode = false) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surface)
.fillMaxWidth()
.fillMaxHeight()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
AnimatedContent(
targetState = state.qrState,
contentKey = { it::class },
contentAlignment = Alignment.Center,
label = "qr-code-progress",
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
) { qrState ->
when (qrState) {
is RegisterLinkDeviceQrViewModel.QrState.Loaded -> {
QrCode(
data = qrState.qrData,
foregroundColor = Color(0xFF2449C0),
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
)
}
RegisterLinkDeviceQrViewModel.QrState.Loading -> {
Box(contentAlignment = Alignment.Center) {
CircularProgressIndicator(modifier = Modifier.size(48.dp))
}
}
is RegisterLinkDeviceQrViewModel.QrState.Scanned,
RegisterLinkDeviceQrViewModel.QrState.Failed -> {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
val text = if (state.qrState is RegisterLinkDeviceQrViewModel.QrState.Scanned) {
"Scanned on device"
} else {
stringResource(R.string.RestoreViaQr_qr_code_error)
}
Text(
text = text,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Buttons.Small(
onClick = onRetryQrCode
) {
Text(text = stringResource(R.string.RestoreViaQr_retry))
}
}
}
}
}
}
}
}
// TODO [link-device] use actual copy
Column(
modifier = Modifier
.align(alignment = Alignment.CenterVertically)
.widthIn(160.dp, 320.dp)
) {
InstructionRow(
icon = painterResource(R.drawable.symbol_settings_android_24),
instruction = "Open Signal Settings on your device"
)
InstructionRow(
icon = painterResource(R.drawable.symbol_link_24),
instruction = "Tap \"Linked devices\""
)
InstructionRow(
icon = painterResource(R.drawable.symbol_qrcode_24),
instruction = "Tap \"Link a new device\" and scan this code"
)
}
}
if (state.isRegistering) {
Dialogs.IndeterminateProgressDialog()
} else if (state.showProvisioningError) {
Dialogs.SimpleMessageDialog(
message = "failed provision",
onDismiss = onErrorDismiss,
dismiss = stringResource(android.R.string.ok)
)
} else if (state.registrationErrorResult != null) {
val message = when (state.registrationErrorResult) {
RegisterLinkDeviceResult.IncorrectVerification -> "incorrect verification"
RegisterLinkDeviceResult.InvalidRequest -> "invalid request"
RegisterLinkDeviceResult.MaxLinkedDevices -> "max linked devices reached"
RegisterLinkDeviceResult.MissingCapability -> "missing capability, must update"
is RegisterLinkDeviceResult.NetworkException -> "network exception ${state.registrationErrorResult.t.message}"
is RegisterLinkDeviceResult.RateLimited -> "rate limited ${state.registrationErrorResult.retryAfter}"
is RegisterLinkDeviceResult.UnexpectedException -> "unexpected exception ${state.registrationErrorResult.t.message}"
RegisterLinkDeviceResult.Success -> throw IllegalStateException()
}
Dialogs.SimpleMessageDialog(
message = message,
onDismiss = onErrorDismiss,
dismiss = stringResource(android.R.string.ok)
)
}
}
}
@Composable
private fun InstructionRow(
icon: Painter,
instruction: String
) {
Row(
modifier = Modifier
.padding(vertical = 12.dp)
) {
Icon(
painter = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(16.dp))
Text(
text = instruction,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@SignalPreview
@Composable
private fun InstructionRowPreview() {
Previews.Preview {
InstructionRow(
icon = painterResource(R.drawable.symbol_phone_24),
instruction = "Instruction!"
)
}
}

View File

@@ -0,0 +1,178 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.link
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.whispersystems.signalservice.api.provisioning.ProvisioningSocket
import org.whispersystems.signalservice.internal.crypto.SecondaryProvisioningCipher
import org.whispersystems.signalservice.internal.push.ProvisionMessage
import java.io.Closeable
/**
* Handles creating and maintaining a provisioning websocket in the pursuit
* of adding this device as a linked device.
*/
class RegisterLinkDeviceQrViewModel : ViewModel() {
companion object {
private val TAG = Log.tag(RegisterLinkDeviceQrViewModel::class)
}
private val store: MutableStateFlow<RegisterLinkDeviceState> = MutableStateFlow(RegisterLinkDeviceState())
val state: StateFlow<RegisterLinkDeviceState> = store
private var socketHandles: MutableList<Closeable> = mutableListOf()
private var startNewSocketJob: Job? = null
init {
restartProvisioningSocket()
}
override fun onCleared() {
shutdown()
}
fun restartProvisioningSocket() {
shutdown()
startNewSocket()
startNewSocketJob = viewModelScope.launch(Dispatchers.IO) {
var count = 0
while (count < 5 && isActive) {
delay(ProvisioningSocket.LIFESPAN / 2)
if (isActive) {
startNewSocket()
count++
Log.d(TAG, "Started next websocket count: $count")
}
}
}
}
private fun startNewSocket() {
synchronized(socketHandles) {
socketHandles += start()
if (socketHandles.size > 2) {
socketHandles.removeAt(0).close()
}
}
}
private fun start(): Closeable {
store.update {
if (it.qrState !is QrState.Loaded) {
it.copy(qrState = QrState.Loading)
} else {
it
}
}
return ProvisioningSocket.start<ProvisionMessage>(
mode = ProvisioningSocket.Mode.LINK,
identityKeyPair = IdentityKeyUtil.generateIdentityKeyPair(),
configuration = AppDependencies.signalServiceNetworkAccess.getConfiguration(),
handler = { id, t ->
store.update {
if (it.currentSocketId == null || it.currentSocketId == id) {
Log.w(TAG, "Current socket [$id] has failed, stopping automatic connects", t)
shutdown()
it.copy(currentSocketId = null, qrState = QrState.Failed)
} else {
Log.i(TAG, "Old socket [$id] failed, ignoring")
it
}
}
}
) { socket ->
val url = socket.getProvisioningUrl()
store.update {
Log.d(TAG, "Updating QR code with data from [${socket.id}]")
it.copy(
currentSocketId = socket.id,
qrState = QrState.Loaded(
qrData = QrCodeData.forData(
data = url,
supportIconOverlay = false
)
)
)
}
val result = socket.getProvisioningMessageDecryptResult()
if (result is SecondaryProvisioningCipher.ProvisioningDecryptResult.Success) {
store.update { it.copy(isRegistering = true, provisionMessage = result.message, qrState = QrState.Scanned) }
shutdown()
} else {
store.update {
if (it.currentSocketId == socket.id) {
it.copy(qrState = QrState.Scanned, showProvisioningError = true)
} else {
it
}
}
}
}
}
private fun shutdown() {
startNewSocketJob?.cancel()
synchronized(socketHandles) {
socketHandles.forEach { it.close() }
socketHandles.clear()
}
}
fun clearErrors() {
store.update {
it.copy(
showProvisioningError = false,
registrationErrorResult = null
)
}
restartProvisioningSocket()
}
fun setRegisterAsLinkedDeviceError(result: RegisterLinkDeviceResult) {
store.update {
it.copy(registrationErrorResult = result)
}
}
data class RegisterLinkDeviceState(
val isRegistering: Boolean = false,
val qrState: QrState = QrState.Loading,
val provisionMessage: ProvisionMessage? = null,
val showProvisioningError: Boolean = false,
val registrationErrorResult: RegisterLinkDeviceResult? = null,
val currentSocketId: Int? = null
)
sealed interface QrState {
data object Loading : QrState
data class Loaded(val qrData: QrCodeData) : QrState
data object Failed : QrState
data object Scanned : QrState
}
}

View File

@@ -0,0 +1,19 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.link
import kotlin.time.Duration
sealed interface RegisterLinkDeviceResult {
data object Success : RegisterLinkDeviceResult
data object IncorrectVerification : RegisterLinkDeviceResult
data object MissingCapability : RegisterLinkDeviceResult
data object MaxLinkedDevices : RegisterLinkDeviceResult
data object InvalidRequest : RegisterLinkDeviceResult
data class RateLimited(val retryAfter: Duration?) : RegisterLinkDeviceResult
data class NetworkException(val t: Throwable) : RegisterLinkDeviceResult
data class UnexpectedException(val t: Throwable) : RegisterLinkDeviceResult
}

View File

@@ -0,0 +1,87 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.permissions
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.setFragmentResult
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.registration.fragments.WelcomePermissions
import org.thoughtcrime.securesms.registration.ui.RegistrationCheckpoint
import org.thoughtcrime.securesms.registration.ui.RegistrationViewModel
import org.thoughtcrime.securesms.registration.ui.welcome.WelcomeUserSelection
import org.thoughtcrime.securesms.util.BackupUtil
/**
* Screen in account registration that provides rationales for the suggested runtime permissions.
*/
@RequiresApi(23)
class GrantPermissionsFragment : ComposeFragment() {
companion object {
private val TAG = Log.tag(GrantPermissionsFragment::class.java)
const val REQUEST_KEY = "GrantPermissionsFragment"
}
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
private val args by navArgs<GrantPermissionsFragmentArgs>()
private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions(),
::onPermissionsGranted
)
private val welcomeUserSelection: WelcomeUserSelection by lazy { args.welcomeUserSelection }
@Composable
override fun FragmentContent() {
GrantPermissionsScreen(
deviceBuildVersion = Build.VERSION.SDK_INT,
isBackupSelectionRequired = BackupUtil.isUserSelectionRequired(LocalContext.current),
onNextClicked = this::launchPermissionRequests,
onNotNowClicked = this::proceedToNextScreen
)
}
private fun launchPermissionRequests() {
val isUserSelectionRequired = BackupUtil.isUserSelectionRequired(requireContext())
val neededPermissions = WelcomePermissions.getWelcomePermissions(isUserSelectionRequired).filterNot {
ContextCompat.checkSelfPermission(requireContext(), it) == PackageManager.PERMISSION_GRANTED
}
if (neededPermissions.isEmpty()) {
proceedToNextScreen()
} else {
requestPermissionLauncher.launch(neededPermissions.toTypedArray())
}
}
private fun onPermissionsGranted(permissions: Map<String, Boolean>) {
permissions.forEach {
Log.d(TAG, "${it.key} = ${it.value}")
}
sharedViewModel.maybePrefillE164(requireContext())
sharedViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.PERMISSIONS_GRANTED)
proceedToNextScreen()
}
private fun proceedToNextScreen() {
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to welcomeUserSelection))
findNavController().popBackStack()
}
}

View File

@@ -0,0 +1,152 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.permissions
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.registration.ui.shared.RegistrationScreen
/**
* Layout that explains permissions rationale to the user.
*/
@Composable
fun GrantPermissionsScreen(
deviceBuildVersion: Int,
isBackupSelectionRequired: Boolean,
onNextClicked: () -> Unit = {},
onNotNowClicked: () -> Unit = {}
) {
RegistrationScreen(
title = stringResource(id = R.string.GrantPermissionsFragment__allow_permissions),
subtitle = stringResource(id = R.string.GrantPermissionsFragment__to_help_you_message_people_you_know),
bottomContent = {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
TextButton(
modifier = Modifier.weight(weight = 1f, fill = false),
onClick = onNotNowClicked
) {
Text(
text = stringResource(id = R.string.GrantPermissionsFragment__not_now)
)
}
Spacer(modifier = Modifier.size(24.dp))
Buttons.LargeTonal(
onClick = onNextClicked
) {
Text(
text = stringResource(id = R.string.GrantPermissionsFragment__next)
)
}
}
}
) {
if (deviceBuildVersion >= 33) {
PermissionRow(
imageVector = ImageVector.vectorResource(id = R.drawable.permission_notification),
title = stringResource(id = R.string.GrantPermissionsFragment__notifications),
subtitle = stringResource(id = R.string.GrantPermissionsFragment__get_notified_when)
)
}
PermissionRow(
imageVector = ImageVector.vectorResource(id = R.drawable.permission_contact),
title = stringResource(id = R.string.GrantPermissionsFragment__contacts),
subtitle = stringResource(id = R.string.GrantPermissionsFragment__find_people_you_know)
)
if (deviceBuildVersion < 29 || !isBackupSelectionRequired) {
PermissionRow(
imageVector = ImageVector.vectorResource(id = R.drawable.permission_file),
title = stringResource(id = R.string.GrantPermissionsFragment__storage),
subtitle = stringResource(id = R.string.GrantPermissionsFragment__send_photos_videos_and_files)
)
}
PermissionRow(
imageVector = ImageVector.vectorResource(id = R.drawable.permission_phone),
title = stringResource(id = R.string.GrantPermissionsFragment__phone_calls),
subtitle = stringResource(id = R.string.GrantPermissionsFragment__make_registering_easier)
)
}
}
@SignalPreview
@Composable
fun GrantPermissionsScreenPreview() {
Previews.Preview {
GrantPermissionsScreen(
deviceBuildVersion = 33,
isBackupSelectionRequired = true
)
}
}
@Composable
fun PermissionRow(
imageVector: ImageVector,
title: String,
subtitle: String
) {
Row(modifier = Modifier.padding(bottom = 32.dp)) {
Image(
imageVector = imageVector,
contentDescription = null,
modifier = Modifier.size(48.dp)
)
Spacer(modifier = Modifier.size(16.dp))
Column {
Text(
text = title,
style = MaterialTheme.typography.titleSmall
)
Text(
text = subtitle,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.size(32.dp))
}
}
@SignalPreview
@Composable
fun PermissionRowPreview() {
Previews.Preview {
PermissionRow(
imageVector = ImageVector.vectorResource(id = R.drawable.permission_notification),
title = stringResource(id = R.string.GrantPermissionsFragment__notifications),
subtitle = stringResource(id = R.string.GrantPermissionsFragment__get_notified_when)
)
}
}

View File

@@ -0,0 +1,734 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.phonenumber
import android.content.Context
import android.content.DialogInterface
import android.os.Bundle
import android.text.Editable
import android.text.SpannableStringBuilder
import android.view.KeyEvent
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.inputmethod.EditorInfo
import android.widget.ArrayAdapter
import android.widget.TextView
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.MenuProvider
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.TextInputEditText
import com.google.i18n.phonenumbers.AsYouTypeFormatter
import com.google.i18n.phonenumbers.NumberParseException
import com.google.i18n.phonenumbers.PhoneNumberUtil
import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber
import org.signal.core.util.ThreadUtil
import org.signal.core.util.getParcelableCompat
import org.signal.core.util.isNotNullOrBlank
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.databinding.FragmentRegistrationEnterPhoneNumberBinding
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.registration.data.RegistrationRepository
import org.thoughtcrime.securesms.registration.data.network.Challenge
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
import org.thoughtcrime.securesms.registration.data.network.RegistrationResult
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionCheckResult
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionCreationResult
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionResult
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult
import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView
import org.thoughtcrime.securesms.registration.ui.RegistrationCheckpoint
import org.thoughtcrime.securesms.registration.ui.RegistrationState
import org.thoughtcrime.securesms.registration.ui.RegistrationViewModel
import org.thoughtcrime.securesms.registration.ui.countrycode.Country
import org.thoughtcrime.securesms.registration.ui.countrycode.CountryCodeFragment
import org.thoughtcrime.securesms.registration.ui.toE164
import org.thoughtcrime.securesms.registration.util.CountryPrefix
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.Dialogs
import org.thoughtcrime.securesms.util.PlayServicesUtil
import org.thoughtcrime.securesms.util.SignalE164Util
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.SupportEmailUtil
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.livedata.LiveDataObserverCallback
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.visible
import kotlin.time.Duration.Companion.milliseconds
/**
* Screen in registration where the user enters their phone number.
*/
class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_enter_phone_number) {
private val TAG = Log.tag(EnterPhoneNumberFragment::class.java)
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
private val fragmentViewModel by viewModels<EnterPhoneNumberViewModel>()
private val args by navArgs<EnterPhoneNumberFragmentArgs>()
private val binding: FragmentRegistrationEnterPhoneNumberBinding by ViewBinderDelegate(FragmentRegistrationEnterPhoneNumberBinding::bind)
private val enterPhoneNumberMode: EnterPhoneNumberMode by lazy { args.enterPhoneNumberMode }
private var processedResumeMode: Boolean = false
private val skipToNextScreen: DialogInterface.OnClickListener = DialogInterface.OnClickListener { _: DialogInterface?, _: Int -> moveToVerificationEntryScreen() }
private lateinit var spinnerAdapter: ArrayAdapter<CountryPrefix>
private lateinit var phoneNumberInputLayout: TextInputEditText
private lateinit var spinnerView: TextInputEditText
private lateinit var countryPickerView: View
private var currentPhoneNumberFormatter: AsYouTypeFormatter? = null
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setDebugLogSubmitMultiTapView(binding.verifyHeader)
requireActivity().onBackPressedDispatcher.addCallback(
viewLifecycleOwner,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
popBackStack()
}
}
)
phoneNumberInputLayout = binding.number.editText as TextInputEditText
spinnerView = binding.countryCode.editText as TextInputEditText
countryPickerView = binding.countryPicker
countryPickerView.setOnClickListener {
moveToCountryPickerScreen()
}
parentFragmentManager.setFragmentResultListener(
CountryCodeFragment.REQUEST_KEY_COUNTRY,
this
) { _, bundle ->
val country: Country = bundle.getParcelableCompat(CountryCodeFragment.RESULT_COUNTRY, Country::class.java)!!
fragmentViewModel.setCountry(country.countryCode, country)
}
spinnerAdapter = ArrayAdapter<CountryPrefix>(
requireContext(),
R.layout.registration_country_code_dropdown_item,
fragmentViewModel.supportedCountryPrefixes
)
binding.registerButton.setOnClickListener { onRegistrationButtonClicked() }
binding.cancelButton.setOnClickListener { popBackStack() }
binding.toolbar.title = ""
val activity = requireActivity() as AppCompatActivity
activity.setSupportActionBar(binding.toolbar)
requireActivity().addMenuProvider(UseProxyMenuProvider(), viewLifecycleOwner)
sharedViewModel.uiState.observe(viewLifecycleOwner) { sharedState ->
presentRegisterButton(sharedState)
updateEnabledControls(sharedState.inProgress, sharedState.isReRegister)
sharedState.networkError?.let {
presentNetworkError(it)
sharedViewModel.networkErrorShown()
}
sharedState.sessionCreationError?.let {
handleSessionCreationError(it)
sharedViewModel.sessionCreationErrorShown()
}
sharedState.sessionStateError?.let {
handleSessionStateError(it)
sharedViewModel.sessionStateErrorShown()
}
sharedState.registerAccountError?.let {
handleRegistrationErrorResponse(it)
sharedViewModel.registerAccountErrorShown()
}
}
sharedViewModel
.uiState
.map { it.toNavigationStateOnly() }
.distinctUntilChanged()
.observe(viewLifecycleOwner) { sharedState ->
if (sharedState.challengesRequested.contains(Challenge.CAPTCHA) && sharedState.captchaToken.isNotNullOrBlank()) {
sharedViewModel.submitCaptchaToken(requireContext())
} else if (sharedState.challengesRequested.isNotEmpty()) {
if (!sharedState.challengeInProgress) {
handleChallenges(sharedState.challengesRequested)
}
} else if (sharedState.registrationCheckpoint >= RegistrationCheckpoint.PHONE_NUMBER_CONFIRMED && sharedState.canSkipSms) {
moveToEnterPinScreen()
} else if (sharedState.registrationCheckpoint >= RegistrationCheckpoint.VERIFICATION_CODE_REQUESTED) {
moveToVerificationEntryScreen()
}
}
fragmentViewModel
.uiState
.map { it.phoneNumberRegionCode }
.distinctUntilChanged()
.observe(viewLifecycleOwner) { regionCode ->
if (regionCode.isNotNullOrBlank()) {
currentPhoneNumberFormatter = PhoneNumberUtil.getInstance().getAsYouTypeFormatter(regionCode)
reformatText(phoneNumberInputLayout.text)
phoneNumberInputLayout.requestFocus()
}
}
fragmentViewModel.uiState.observe(viewLifecycleOwner) { fragmentState ->
if (fragmentViewModel.isEnteredNumberPossible(fragmentState)) {
sharedViewModel.setPhoneNumber(fragmentViewModel.parsePhoneNumber(fragmentState))
sharedViewModel.nationalNumber = ""
} else {
sharedViewModel.setPhoneNumber(null)
}
updateCountrySelection(fragmentState.country)
if (fragmentState.error != EnterPhoneNumberState.Error.NONE) {
presentLocalError(fragmentState)
}
}
initializeInputFields()
val existingPhoneNumber = sharedViewModel.phoneNumber
val existingNationalNumber = sharedViewModel.nationalNumber
if (existingPhoneNumber != null) {
fragmentViewModel.restoreState(existingPhoneNumber)
spinnerView.setText(existingPhoneNumber.countryCode.toString())
phoneNumberInputLayout.setText(existingPhoneNumber.nationalNumber.toString())
} else if (spinnerView.text?.isEmpty() == true) {
spinnerView.setText(fragmentViewModel.getDefaultCountryCode(requireContext()).toString())
phoneNumberInputLayout.setText(existingNationalNumber)
} else {
phoneNumberInputLayout.setText(existingNationalNumber)
}
if (enterPhoneNumberMode == EnterPhoneNumberMode.RESTART_AFTER_COLLECTION && (savedInstanceState == null && !processedResumeMode)) {
processedResumeMode = true
startNormalRegistration()
} else {
ViewUtil.focusAndShowKeyboard(phoneNumberInputLayout)
}
}
private fun updateCountrySelection(country: Country?) {
if (country != null) {
binding.countryEmoji.visible = true
binding.countryEmoji.text = country.emoji
binding.country.text = country.name
if (spinnerView.text.toString() != country.countryCode.toString()) {
spinnerView.setText(country.countryCode.toString())
}
} else {
binding.countryEmoji.visible = false
binding.country.text = getString(R.string.RegistrationActivity_select_a_country)
}
}
private fun reformatText(text: Editable?) {
if (text.isNullOrEmpty()) {
return
}
currentPhoneNumberFormatter?.let { formatter ->
formatter.clear()
var formattedNumber: String? = null
text.forEach {
if (it.isDigit()) {
formattedNumber = formatter.inputDigit(it)
}
}
if (formattedNumber != null && text.toString() != formattedNumber) {
text.replace(0, text.length, formattedNumber)
}
}
}
private fun handleChallenges(remainingChallenges: List<Challenge>) {
when (remainingChallenges.first()) {
Challenge.CAPTCHA -> moveToCaptcha()
Challenge.PUSH -> performPushChallenge()
}
}
private fun performPushChallenge() {
sharedViewModel.requestAndSubmitPushToken(requireContext())
}
private fun initializeInputFields() {
binding.countryCode.editText?.addTextChangedListener { s ->
val sanitized = s.toString().filter { c -> c.isDigit() }
if (sanitized.isNotNullOrBlank()) {
val countryCode: Int = sanitized.toInt()
fragmentViewModel.setCountry(countryCode)
} else {
binding.countryCode.editText?.setHint(R.string.RegistrationActivity_default_country_code)
fragmentViewModel.clearCountry()
}
}
phoneNumberInputLayout.addTextChangedListener(
afterTextChanged = {
reformatText(it)
fragmentViewModel.setPhoneNumber(it?.toString())
sharedViewModel.nationalNumber = it?.toString() ?: ""
}
)
val scrollView = binding.scrollView
val registerButton = binding.registerButton
phoneNumberInputLayout.onFocusChangeListener = View.OnFocusChangeListener { _: View?, hasFocus: Boolean ->
if (hasFocus) {
scrollView.postDelayed({
scrollView.smoothScrollTo(0, registerButton.bottom)
}, 250)
}
}
phoneNumberInputLayout.imeOptions = EditorInfo.IME_ACTION_DONE
phoneNumberInputLayout.setOnEditorActionListener { v: TextView?, actionId: Int, _: KeyEvent? ->
if (actionId == EditorInfo.IME_ACTION_DONE && v != null) {
onRegistrationButtonClicked()
return@setOnEditorActionListener true
}
false
}
}
private fun presentRegisterButton(sharedState: RegistrationState) {
binding.registerButton.isEnabled = sharedState.phoneNumber != null && PhoneNumberUtil.getInstance().isPossibleNumber(sharedState.phoneNumber)
if (sharedState.inProgress) {
binding.registerButton.setSpinning()
} else {
binding.registerButton.cancelSpinning()
}
}
private fun presentLocalError(state: EnterPhoneNumberState) {
when (state.error) {
EnterPhoneNumberState.Error.NONE -> Unit
EnterPhoneNumberState.Error.INVALID_PHONE_NUMBER -> {
MaterialAlertDialogBuilder(requireContext()).apply {
setTitle(R.string.RegistrationActivity_invalid_number)
setMessage(
String.format(
getString(R.string.RegistrationActivity_the_number_you_specified_s_is_invalid),
state.phoneNumber
)
)
setPositiveButton(android.R.string.ok) { _, _ -> fragmentViewModel.clearError() }
setOnCancelListener { fragmentViewModel.clearError() }
setOnDismissListener { fragmentViewModel.clearError() }
show()
}
}
EnterPhoneNumberState.Error.PLAY_SERVICES_MISSING -> {
handlePromptForNoPlayServices()
}
EnterPhoneNumberState.Error.PLAY_SERVICES_NEEDS_UPDATE -> {
GoogleApiAvailability.getInstance().getErrorDialog(requireActivity(), ConnectionResult.SERVICE_VERSION_UPDATE_REQUIRED, 0)?.show()
}
EnterPhoneNumberState.Error.PLAY_SERVICES_TRANSIENT -> {
MaterialAlertDialogBuilder(requireContext()).apply {
setTitle(R.string.RegistrationActivity_play_services_error)
setMessage(R.string.RegistrationActivity_google_play_services_is_updating_or_unavailable)
setPositiveButton(android.R.string.ok) { _, _ -> fragmentViewModel.clearError() }
setOnCancelListener { fragmentViewModel.clearError() }
setOnDismissListener { fragmentViewModel.clearError() }
show()
}
}
}
}
private fun presentNetworkError(networkError: Throwable) {
Log.i(TAG, "Unknown error during verification code request", networkError)
MaterialAlertDialogBuilder(requireContext()).apply {
setMessage(R.string.RegistrationActivity_unable_to_connect_to_service)
setPositiveButton(android.R.string.ok, null)
show()
}
}
private fun handleSessionCreationError(result: RegistrationSessionResult) {
if (!result.isSuccess()) {
Log.i(TAG, "Handling error response of ${result.javaClass.name}", result.getCause())
}
when (result) {
is RegistrationSessionCheckResult.Success,
is RegistrationSessionCreationResult.Success -> throw IllegalStateException("Session error handler called on successful response!")
is RegistrationSessionCreationResult.AttemptsExhausted -> presentRemoteErrorDialog(getString(R.string.RegistrationActivity_rate_limited_to_service))
is RegistrationSessionCreationResult.MalformedRequest -> presentRemoteErrorDialog(getString(R.string.RegistrationActivity_unable_to_connect_to_service), skipToNextScreen)
is RegistrationSessionCreationResult.RateLimited -> {
val timeRemaining = result.timeRemaining?.milliseconds
Log.i(TAG, "Session creation rate limited! Next attempt: $timeRemaining")
if (timeRemaining != null) {
presentRemoteErrorDialog(getString(R.string.RegistrationActivity_rate_limited_to_try_again, timeRemaining.toString()))
} else {
presentRemoteErrorDialog(getString(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later))
}
}
is RegistrationSessionCreationResult.ServerUnableToParse -> presentGenericError(result)
is RegistrationSessionCheckResult.SessionNotFound -> presentGenericError(result)
is RegistrationSessionCheckResult.UnknownError,
is RegistrationSessionCreationResult.UnknownError -> presentGenericError(result)
}
}
private fun handleSessionStateError(result: VerificationCodeRequestResult) {
if (!result.isSuccess()) {
Log.i(TAG, "Handling error response.", result.getCause())
}
when (result) {
is VerificationCodeRequestResult.Success -> throw IllegalStateException("Session error handler called on successful response!")
is VerificationCodeRequestResult.ChallengeRequired -> handleChallenges(result.challenges)
is VerificationCodeRequestResult.ExternalServiceFailure -> presentRemoteErrorDialog(getString(R.string.RegistrationActivity_sms_provider_error))
is VerificationCodeRequestResult.ImpossibleNumber -> {
MaterialAlertDialogBuilder(requireContext()).apply {
setMessage(getString(R.string.RegistrationActivity_the_number_you_specified_s_is_invalid, fragmentViewModel.phoneNumber?.toE164()))
setPositiveButton(android.R.string.ok, null)
show()
}
}
is VerificationCodeRequestResult.InvalidTransportModeFailure -> {
MaterialAlertDialogBuilder(requireContext()).apply {
setMessage(R.string.RegistrationActivity_we_couldnt_send_you_a_verification_code)
setPositiveButton(R.string.RegistrationActivity_voice_call) { _, _ ->
sharedViewModel.requestVerificationCall(requireContext())
}
setNegativeButton(R.string.RegistrationActivity_cancel, null)
show()
}
}
is VerificationCodeRequestResult.MalformedRequest -> presentRemoteErrorDialog(getString(R.string.RegistrationActivity_unable_to_connect_to_service), skipToNextScreen)
is VerificationCodeRequestResult.RequestVerificationCodeRateLimited -> {
Log.i(TAG, result.log())
handleRequestVerificationCodeRateLimited(result)
}
is VerificationCodeRequestResult.SubmitVerificationCodeRateLimited -> presentGenericError(result)
is VerificationCodeRequestResult.NonNormalizedNumber -> handleNonNormalizedNumberError(result.originalNumber, result.normalizedNumber, fragmentViewModel.e164VerificationMode)
is VerificationCodeRequestResult.RateLimited -> {
val timeRemaining = result.timeRemaining?.milliseconds
Log.i(TAG, "Session patch rate limited! Next attempt: $timeRemaining")
if (timeRemaining != null) {
presentRemoteErrorDialog(getString(R.string.RegistrationActivity_rate_limited_to_try_again, timeRemaining.toString()))
} else {
presentRemoteErrorDialog(getString(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later))
}
}
is VerificationCodeRequestResult.TokenNotAccepted -> presentRemoteErrorDialog(getString(R.string.RegistrationActivity_we_need_to_verify_that_youre_human)) { _, _ -> moveToCaptcha() }
is VerificationCodeRequestResult.RegistrationLocked -> presentRegistrationLocked(result.timeRemaining)
is VerificationCodeRequestResult.AlreadyVerified -> presentGenericError(result)
is VerificationCodeRequestResult.NoSuchSession -> presentGenericError(result)
is VerificationCodeRequestResult.UnknownError -> presentGenericError(result)
}
}
private fun presentGenericError(result: RegistrationResult) {
Log.i(TAG, "Received unhandled response: ${result.javaClass.name}", result.getCause())
presentRemoteErrorDialog(getString(R.string.RegistrationActivity_unable_to_connect_to_service))
}
private fun handleRegistrationErrorResponse(result: RegisterAccountResult) {
when (result) {
is RegisterAccountResult.Success -> throw IllegalStateException("Register account error handler called on successful response!")
is RegisterAccountResult.RegistrationLocked -> presentRegistrationLocked(result.timeRemaining)
is RegisterAccountResult.AttemptsExhausted -> presentAccountLocked()
is RegisterAccountResult.RateLimited -> presentRateLimitedDialog()
is RegisterAccountResult.SvrNoData -> presentAccountLocked()
else -> presentGenericError(result)
}
}
private fun presentRegistrationLocked(timeRemaining: Long) {
findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.actionPhoneNumberRegistrationLock(timeRemaining))
sharedViewModel.setInProgress(false)
}
private fun presentRateLimitedDialog() {
presentRemoteErrorDialog(getString(R.string.RegistrationActivity_rate_limited_to_service))
}
private fun presentAccountLocked() {
findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.actionPhoneNumberAccountLocked())
ThreadUtil.postToMain { sharedViewModel.setInProgress(false) }
}
private fun moveToCaptcha() {
findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.actionRequestCaptcha())
ThreadUtil.postToMain { sharedViewModel.setInProgress(false) }
}
private fun presentRemoteErrorDialog(message: String, positiveButtonListener: DialogInterface.OnClickListener? = null) {
MaterialAlertDialogBuilder(requireContext()).apply {
setMessage(message)
setPositiveButton(android.R.string.ok, positiveButtonListener)
show()
}
}
private fun handleRequestVerificationCodeRateLimited(result: VerificationCodeRequestResult.RequestVerificationCodeRateLimited) {
if (result.willBeAbleToRequestAgain) {
Log.i(TAG, "New verification code cannot be requested yet but can soon, moving to enter code to show timers")
moveToVerificationEntryScreen()
} else {
Log.w(TAG, "Unable to request new verification code, prompting to start new session")
MaterialAlertDialogBuilder(requireContext()).apply {
setMessage(R.string.RegistrationActivity_unable_to_connect_to_service)
setPositiveButton(R.string.NetworkFailure__retry) { _, _ ->
onRegistrationButtonClicked()
}
setNegativeButton(android.R.string.cancel, null)
show()
}
}
}
private fun handleNonNormalizedNumberError(originalNumber: String, normalizedNumber: String, mode: RegistrationRepository.E164VerificationMode) {
try {
val phoneNumber = PhoneNumberUtil.getInstance().parse(normalizedNumber, null)
MaterialAlertDialogBuilder(requireContext()).apply {
setTitle(R.string.RegistrationActivity_non_standard_number_format)
setMessage(getString(R.string.RegistrationActivity_the_number_you_entered_appears_to_be_a_non_standard, originalNumber, normalizedNumber))
setNegativeButton(android.R.string.no) { d: DialogInterface, i: Int -> d.dismiss() }
setNeutralButton(R.string.RegistrationActivity_contact_signal_support) { dialogInterface, _ ->
val subject = getString(R.string.RegistrationActivity_signal_android_phone_number_format)
val body = SupportEmailUtil.generateSupportEmailBody(requireContext(), R.string.RegistrationActivity_signal_android_phone_number_format, null, null)
CommunicationActions.openEmail(requireContext(), SupportEmailUtil.getSupportEmailAddress(requireContext()), subject, body)
dialogInterface.dismiss()
}
setPositiveButton(R.string.yes) { dialogInterface, _ ->
spinnerView.setText(phoneNumber.countryCode.toString())
phoneNumberInputLayout.setText(phoneNumber.nationalNumber.toString())
when (mode) {
RegistrationRepository.E164VerificationMode.SMS_WITH_LISTENER,
RegistrationRepository.E164VerificationMode.SMS_WITHOUT_LISTENER -> sharedViewModel.requestSmsCode(requireContext())
RegistrationRepository.E164VerificationMode.PHONE_CALL -> sharedViewModel.requestVerificationCall(requireContext())
}
dialogInterface.dismiss()
}
show()
}
} catch (e: NumberParseException) {
Log.w(TAG, "Failed to parse number!", e)
Dialogs.showAlertDialog(
requireContext(),
getString(R.string.RegistrationActivity_invalid_number),
getString(R.string.RegistrationActivity_the_number_you_specified_s_is_invalid, fragmentViewModel.phoneNumber?.toE164())
)
}
}
private fun onRegistrationButtonClicked() {
when (enterPhoneNumberMode) {
EnterPhoneNumberMode.NORMAL,
EnterPhoneNumberMode.RESTART_AFTER_COLLECTION -> startNormalRegistration()
EnterPhoneNumberMode.COLLECT_FOR_MANUAL_SIGNAL_BACKUPS_RESTORE -> findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.goToEnterBackupKey())
}
}
private fun startNormalRegistration() {
ViewUtil.hideKeyboard(requireContext(), phoneNumberInputLayout)
sharedViewModel.setInProgress(true)
val hasFcm = validateFcmStatus(requireContext())
if (hasFcm) {
sharedViewModel.uiState.observe(viewLifecycleOwner, FcmTokenRetrievedObserver())
sharedViewModel.fetchFcmToken(requireContext())
} else {
sharedViewModel.uiState.value?.let { value ->
val now = System.currentTimeMillis().milliseconds
if (value.phoneNumber == null) {
fragmentViewModel.setError(EnterPhoneNumberState.Error.INVALID_PHONE_NUMBER)
sharedViewModel.setInProgress(false)
} else if (now < value.nextSmsTimestamp) {
moveToVerificationEntryScreen()
} else {
presentConfirmNumberDialog(value.phoneNumber, value.isReRegister, value.canSkipSms, missingFcmConsentRequired = true)
}
}
}
}
private fun onFcmTokenRetrieved(value: RegistrationState) {
if (value.phoneNumber == null) {
fragmentViewModel.setError(EnterPhoneNumberState.Error.INVALID_PHONE_NUMBER)
sharedViewModel.setInProgress(false)
} else {
presentConfirmNumberDialog(value.phoneNumber, value.isReRegister, value.canSkipSms, missingFcmConsentRequired = false)
}
}
private fun updateEnabledControls(showProgress: Boolean, isReRegister: Boolean) {
binding.countryCode.isEnabled = !showProgress
binding.number.isEnabled = !showProgress
binding.cancelButton.visible = !showProgress && isReRegister
}
private fun validateFcmStatus(context: Context): Boolean {
val fcmStatus = PlayServicesUtil.getPlayServicesStatus(context)
Log.d(TAG, "Got $fcmStatus for Play Services status.")
when (fcmStatus) {
PlayServicesUtil.PlayServicesStatus.SUCCESS -> {
return true
}
PlayServicesUtil.PlayServicesStatus.MISSING -> {
fragmentViewModel.setError(EnterPhoneNumberState.Error.PLAY_SERVICES_MISSING)
return false
}
PlayServicesUtil.PlayServicesStatus.NEEDS_UPDATE -> {
fragmentViewModel.setError(EnterPhoneNumberState.Error.PLAY_SERVICES_NEEDS_UPDATE)
return false
}
PlayServicesUtil.PlayServicesStatus.TRANSIENT_ERROR -> {
fragmentViewModel.setError(EnterPhoneNumberState.Error.PLAY_SERVICES_TRANSIENT)
return false
}
null -> {
Log.w(TAG, "Null result received from PlayServicesUtil, marking Play Services as missing.")
fragmentViewModel.setError(EnterPhoneNumberState.Error.PLAY_SERVICES_MISSING)
return false
}
}
}
private fun handleConfirmNumberDialogCanceled() {
Log.d(TAG, "User canceled confirm number, returning to edit number.")
sharedViewModel.setInProgress(false)
ViewUtil.focusAndMoveCursorToEndAndOpenKeyboard(phoneNumberInputLayout)
}
private fun presentConfirmNumberDialog(phoneNumber: PhoneNumber, isReRegister: Boolean, canSkipSms: Boolean, missingFcmConsentRequired: Boolean) {
val title = if (isReRegister) {
R.string.RegistrationActivity_additional_verification_required
} else {
R.string.RegistrationActivity_phone_number_verification_dialog_title
}
val message: CharSequence = SpannableStringBuilder().apply {
append(SpanUtil.bold(SignalE164Util.prettyPrint(phoneNumber.toE164())))
if (!canSkipSms) {
append("\n\n")
append(getString(R.string.RegistrationActivity_a_verification_code_will_be_sent_to_this_number))
}
}
MaterialAlertDialogBuilder(requireContext()).apply {
setTitle(title)
setMessage(message)
setPositiveButton(android.R.string.ok) { _, _ ->
Log.d(TAG, "User confirmed number.")
if (missingFcmConsentRequired) {
handlePromptForNoPlayServices()
} else {
sharedViewModel.onUserConfirmedPhoneNumber(requireContext())
}
}
setNegativeButton(R.string.RegistrationActivity_edit_number) { _, _ -> handleConfirmNumberDialogCanceled() }
setOnCancelListener { _ -> handleConfirmNumberDialogCanceled() }
}.show()
}
private fun handlePromptForNoPlayServices() {
val context = activity
if (context != null) {
Log.d(TAG, "Device does not have Play Services, showing consent dialog.")
MaterialAlertDialogBuilder(context).apply {
setTitle(R.string.RegistrationActivity_missing_google_play_services)
setMessage(R.string.RegistrationActivity_this_device_is_missing_google_play_services)
setPositiveButton(R.string.RegistrationActivity_i_understand) { _, _ ->
Log.d(TAG, "User confirmed number.")
sharedViewModel.onUserConfirmedPhoneNumber(AppDependencies.application)
}
setNegativeButton(android.R.string.cancel, null)
setOnCancelListener { fragmentViewModel.clearError() }
setOnDismissListener { fragmentViewModel.clearError() }
show()
}
}
}
private fun moveToEnterPinScreen() {
findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.actionReRegisterWithPinFragment())
sharedViewModel.setInProgress(false)
}
private fun moveToVerificationEntryScreen() {
findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.actionEnterVerificationCode())
sharedViewModel.setInProgress(false)
}
private fun moveToCountryPickerScreen() {
findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.actionCountryPicker(fragmentViewModel.country))
}
private fun popBackStack() {
sharedViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.INITIALIZATION)
findNavController().popBackStack()
}
private inner class FcmTokenRetrievedObserver : LiveDataObserverCallback<RegistrationState>(sharedViewModel.uiState) {
override fun onValue(value: RegistrationState): Boolean {
val fcmRetrieved = value.isFcmSupported
if (fcmRetrieved) {
onFcmTokenRetrieved(value)
}
return fcmRetrieved
}
}
private inner class UseProxyMenuProvider : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menuInflater.inflate(R.menu.enter_phone_number, menu)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = if (menuItem.itemId == R.id.phone_menu_use_proxy) {
NavHostFragment.findNavController(this@EnterPhoneNumberFragment).safeNavigate(EnterPhoneNumberFragmentDirections.actionEditProxy())
true
} else {
false
}
}
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.phonenumber
/**
* Enter phone number mode to determine if verification is needed or just e164 input is necessary.
*/
enum class EnterPhoneNumberMode {
/** Normal registration start, collect number to verify */
NORMAL,
/** User pre-selected restore/transfer flow, collect number to re-register and restore with */
COLLECT_FOR_MANUAL_SIGNAL_BACKUPS_RESTORE,
/** User reversed decision on restore and needs to resume normal re-register but automatically start verify */
RESTART_AFTER_COLLECTION
}

View File

@@ -0,0 +1,25 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.phonenumber
import org.thoughtcrime.securesms.registration.data.RegistrationRepository
import org.thoughtcrime.securesms.registration.ui.countrycode.Country
/**
* State holder for the phone number entry screen, including phone number and Play Services errors.
*/
data class EnterPhoneNumberState(
val countryPrefixIndex: Int,
val phoneNumber: String = "",
val phoneNumberRegionCode: String,
val mode: RegistrationRepository.E164VerificationMode = RegistrationRepository.E164VerificationMode.SMS_WITHOUT_LISTENER,
val error: Error = Error.NONE,
val country: Country? = null
) {
enum class Error {
NONE, INVALID_PHONE_NUMBER, PLAY_SERVICES_MISSING, PLAY_SERVICES_NEEDS_UPDATE, PLAY_SERVICES_TRANSIENT
}
}

View File

@@ -0,0 +1,179 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.phonenumber
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import com.google.i18n.phonenumbers.NumberParseException
import com.google.i18n.phonenumbers.PhoneNumberUtil
import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.signal.core.util.E164Util
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.registration.data.RegistrationRepository
import org.thoughtcrime.securesms.registration.ui.countrycode.Country
import org.thoughtcrime.securesms.registration.ui.countrycode.CountryUtils
import org.thoughtcrime.securesms.registration.util.CountryPrefix
import org.thoughtcrime.securesms.util.Util
/**
* ViewModel for the phone number entry screen.
*/
class EnterPhoneNumberViewModel : ViewModel() {
companion object {
private val TAG = Log.tag(EnterPhoneNumberViewModel::class.java)
}
val supportedCountryPrefixes: List<CountryPrefix> = PhoneNumberUtil.getInstance().supportedCallingCodes
.map { CountryPrefix(it, PhoneNumberUtil.getInstance().getRegionCodeForCountryCode(it)) }
.sortedBy { it.digits }
private val store = MutableStateFlow(
EnterPhoneNumberState(
countryPrefixIndex = 0,
phoneNumberRegionCode = supportedCountryPrefixes[0].regionCode
)
)
val uiState = store.asLiveData()
val phoneNumber: PhoneNumber?
get() = try {
parsePhoneNumber(store.value)
} catch (ex: NumberParseException) {
Log.w(TAG, "Could not parse phone number in current state.", ex)
null
}
var e164VerificationMode: RegistrationRepository.E164VerificationMode
get() = store.value.mode
set(value) = store.update {
it.copy(mode = value)
}
fun getDefaultCountryCode(context: Context): Int {
val existingCountry = store.value.country
val maybeRegionCode = Util.getNetworkCountryIso(context)
val regionCode = if (maybeRegionCode != null && supportedCountryPrefixes.any { it.regionCode == maybeRegionCode }) {
maybeRegionCode
} else {
Log.w(TAG, "Could not find region code")
"US"
}
val countryCode = PhoneNumberUtil.getInstance().getCountryCodeForRegion(regionCode)
val prefixIndex = countryCodeToAdapterIndex(countryCode)
store.update {
it.copy(
countryPrefixIndex = prefixIndex,
phoneNumberRegionCode = regionCode,
country = existingCountry ?: Country(
name = E164Util.getRegionDisplayName(regionCode).orElse(""),
emoji = CountryUtils.countryToEmoji(regionCode),
countryCode = countryCode,
regionCode = regionCode
)
)
}
return existingCountry?.countryCode ?: countryCode
}
val country: Country?
get() = store.value.country
fun setPhoneNumber(phoneNumber: String?) {
store.update { it.copy(phoneNumber = phoneNumber ?: "") }
}
fun clearCountry() {
store.update {
it.copy(
country = null,
phoneNumberRegionCode = "",
countryPrefixIndex = 0
)
}
}
fun setCountry(digits: Int, country: Country? = null) {
if (country == null && digits == store.value.country?.countryCode) {
return
}
val matchingIndex = countryCodeToAdapterIndex(digits)
if (matchingIndex == -1) {
Log.d(TAG, "Invalid country code specified $digits")
store.update {
it.copy(
country = null,
phoneNumberRegionCode = "",
countryPrefixIndex = 0
)
}
return
}
val regionCode = supportedCountryPrefixes[matchingIndex].regionCode
val matchedCountry = Country(
name = E164Util.getRegionDisplayName(regionCode).orElse(""),
emoji = CountryUtils.countryToEmoji(regionCode),
countryCode = digits,
regionCode = regionCode
)
store.update {
it.copy(
countryPrefixIndex = matchingIndex,
phoneNumberRegionCode = supportedCountryPrefixes[matchingIndex].regionCode,
country = country ?: matchedCountry
)
}
}
fun parsePhoneNumber(state: EnterPhoneNumberState): PhoneNumber {
return PhoneNumberUtil.getInstance().parse(state.phoneNumber, supportedCountryPrefixes[state.countryPrefixIndex].regionCode)
}
fun isEnteredNumberPossible(state: EnterPhoneNumberState): Boolean {
return try {
state.country != null &&
PhoneNumberUtil.getInstance().isPossibleNumber(parsePhoneNumber(state))
} catch (ex: NumberParseException) {
false
}
}
fun restoreState(value: PhoneNumber) {
val prefixIndex = countryCodeToAdapterIndex(value.countryCode)
if (prefixIndex != -1) {
store.update {
it.copy(
countryPrefixIndex = prefixIndex,
phoneNumberRegionCode = PhoneNumberUtil.getInstance().getRegionCodeForNumber(value) ?: it.phoneNumberRegionCode,
phoneNumber = value.nationalNumber.toString()
)
}
}
}
private fun countryCodeToAdapterIndex(countryCode: Int): Int {
return supportedCountryPrefixes.indexOfFirst { prefix -> prefix.digits == countryCode }
}
fun clearError() {
setError(EnterPhoneNumberState.Error.NONE)
}
fun setError(error: EnterPhoneNumberState.Error) {
store.update {
it.copy(error = error)
}
}
}

View File

@@ -0,0 +1,277 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.registrationlock
import android.os.Bundle
import android.view.KeyEvent
import android.view.View
import android.view.inputmethod.EditorInfo
import android.widget.TextView
import android.widget.Toast
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.databinding.FragmentRegistrationLockBinding
import org.thoughtcrime.securesms.lock.v2.SvrConstants
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult
import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView
import org.thoughtcrime.securesms.registration.ui.RegistrationViewModel
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.SupportEmailUtil
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import java.util.concurrent.TimeUnit
class RegistrationLockFragment : LoggingFragment(R.layout.fragment_registration_lock) {
companion object {
private val TAG = Log.tag(RegistrationLockFragment::class.java)
}
private val binding: FragmentRegistrationLockBinding by ViewBinderDelegate(FragmentRegistrationLockBinding::bind)
private val viewModel by activityViewModels<RegistrationViewModel>()
private var timeRemaining: Long = 0
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setDebugLogSubmitMultiTapView(view.findViewById(R.id.kbs_lock_pin_title))
val args: RegistrationLockFragmentArgs = RegistrationLockFragmentArgs.fromBundle(requireArguments())
timeRemaining = args.getTimeRemaining()
binding.kbsLockForgotPin.visibility = View.GONE
binding.kbsLockForgotPin.setOnClickListener { handleForgottenPin(timeRemaining) }
binding.kbsLockPinInput.setImeOptions(EditorInfo.IME_ACTION_DONE)
binding.kbsLockPinInput.setOnEditorActionListener { v: TextView?, actionId: Int, _: KeyEvent? ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
ViewUtil.hideKeyboard(requireContext(), v!!)
handlePinEntry()
return@setOnEditorActionListener true
}
false
}
enableAndFocusPinEntry()
binding.kbsLockPinConfirm.setOnClickListener {
ViewUtil.hideKeyboard(requireContext(), binding.kbsLockPinInput)
handlePinEntry()
}
binding.kbsLockKeyboardToggle.setOnClickListener { viewModel.togglePinKeyboardType() }
viewModel.lockedTimeRemaining.observe(viewLifecycleOwner) { t: Long -> timeRemaining = t }
val triesRemaining: Int = viewModel.svrTriesRemaining
if (triesRemaining <= 3) {
val daysRemaining = getLockoutDays(timeRemaining)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.RegistrationLockFragment__not_many_tries_left)
.setMessage(getTriesRemainingDialogMessage(triesRemaining, daysRemaining))
.setPositiveButton(android.R.string.ok, null)
.setNeutralButton(R.string.PinRestoreEntryFragment_contact_support) { _, _ -> sendEmailToSupport() }
.show()
}
if (triesRemaining < 5) {
binding.kbsLockPinInputLabel.text = requireContext().resources.getQuantityString(R.plurals.RegistrationLockFragment__d_attempts_remaining, triesRemaining, triesRemaining)
}
viewModel.uiState.observe(viewLifecycleOwner) {
if (it.inProgress) {
binding.kbsLockPinConfirm.setSpinning()
} else {
binding.kbsLockPinConfirm.cancelSpinning()
}
it.sessionStateError?.let { error ->
handleSessionErrorResponse(error)
viewModel.sessionStateErrorShown()
}
it.registerAccountError?.let { error ->
handleRegistrationErrorResponse(error)
viewModel.registerAccountErrorShown()
}
it.pinKeyboardType.applyTo(
pinEditText = binding.kbsLockPinInput,
toggleTypeButton = binding.kbsLockKeyboardToggle
)
}
}
private fun handlePinEntry() {
binding.kbsLockPinInput.setEnabled(false)
val pin: String = binding.kbsLockPinInput.getText().toString()
val trimmedLength = pin.replace(" ", "").length
if (trimmedLength == 0) {
Toast.makeText(requireContext(), R.string.RegistrationActivity_you_must_enter_your_registration_lock_PIN, Toast.LENGTH_LONG).show()
enableAndFocusPinEntry()
return
}
if (trimmedLength < SvrConstants.MINIMUM_PIN_LENGTH) {
Toast.makeText(requireContext(), getString(R.string.RegistrationActivity_your_pin_has_at_least_d_digits_or_characters, SvrConstants.MINIMUM_PIN_LENGTH), Toast.LENGTH_LONG).show()
enableAndFocusPinEntry()
return
}
binding.kbsLockPinConfirm.setSpinning()
viewModel.verifyCodeAndRegisterAccountWithRegistrationLock(requireContext(), pin)
}
private fun handleSessionErrorResponse(requestResult: VerificationCodeRequestResult) {
when (requestResult) {
is VerificationCodeRequestResult.Success -> throw IllegalStateException("Session error handler called on successful response!")
is VerificationCodeRequestResult.RateLimited -> onRateLimited()
is VerificationCodeRequestResult.RegistrationLocked -> {
Log.i(TAG, "Registration locked response to verify account!")
binding.kbsLockPinConfirm.cancelSpinning()
enableAndFocusPinEntry()
Toast.makeText(requireContext(), "Reg lock!", Toast.LENGTH_LONG).show()
}
else -> {
Log.w(TAG, "Unable to verify code with registration lock", requestResult.getCause())
onError()
}
}
}
private fun handleRegistrationErrorResponse(result: RegisterAccountResult) {
when (result) {
is RegisterAccountResult.Success -> throw IllegalStateException("Register account error handler called on successful response!")
is RegisterAccountResult.RateLimited -> onRateLimited()
is RegisterAccountResult.AttemptsExhausted -> {
findNavController().safeNavigate(RegistrationLockFragmentDirections.actionAccountLocked())
}
is RegisterAccountResult.RegistrationLocked -> {
Log.i(TAG, "Registration locked response to register account!")
binding.kbsLockPinConfirm.cancelSpinning()
enableAndFocusPinEntry()
Toast.makeText(requireContext(), "Reg lock!", Toast.LENGTH_LONG).show()
}
is RegisterAccountResult.SvrWrongPin -> onIncorrectKbsRegistrationLockPin(result.triesRemaining)
is RegisterAccountResult.SvrNoData -> {
findNavController().safeNavigate(RegistrationLockFragmentDirections.actionAccountLocked())
}
else -> {
Log.w(TAG, "Unable to register account with registration lock", result.getCause())
onError()
}
}
}
private fun onIncorrectKbsRegistrationLockPin(svrTriesRemaining: Int) {
binding.kbsLockPinConfirm.cancelSpinning()
binding.kbsLockPinInput.getText()?.clear()
enableAndFocusPinEntry()
if (svrTriesRemaining == 0) {
Log.w(TAG, "Account locked. User out of attempts on KBS.")
findNavController().safeNavigate(RegistrationLockFragmentDirections.actionAccountLocked())
return
}
if (svrTriesRemaining == 3) {
val daysRemaining = getLockoutDays(timeRemaining)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.RegistrationLockFragment__incorrect_pin)
.setMessage(getTriesRemainingDialogMessage(svrTriesRemaining, daysRemaining))
.setPositiveButton(android.R.string.ok, null)
.show()
}
if (svrTriesRemaining > 5) {
binding.kbsLockPinInputLabel.setText(R.string.RegistrationLockFragment__incorrect_pin_try_again)
} else {
binding.kbsLockPinInputLabel.text = requireContext().resources.getQuantityString(R.plurals.RegistrationLockFragment__incorrect_pin_d_attempts_remaining, svrTriesRemaining, svrTriesRemaining)
binding.kbsLockForgotPin.visibility = View.VISIBLE
}
}
private fun onRateLimited() {
binding.kbsLockPinConfirm.cancelSpinning()
enableAndFocusPinEntry()
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.RegistrationActivity_too_many_attempts)
.setMessage(R.string.RegistrationActivity_you_have_made_too_many_incorrect_registration_lock_pin_attempts_please_try_again_in_a_day)
.setPositiveButton(android.R.string.ok, null)
.show()
}
fun onError() {
binding.kbsLockPinConfirm.cancelSpinning()
enableAndFocusPinEntry()
Toast.makeText(requireContext(), R.string.RegistrationActivity_error_connecting_to_service, Toast.LENGTH_LONG).show()
}
private fun handleForgottenPin(timeRemainingMs: Long) {
val lockoutDays = getLockoutDays(timeRemainingMs)
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.RegistrationLockFragment__forgot_your_pin)
.setMessage(requireContext().resources.getQuantityString(R.plurals.RegistrationLockFragment__for_your_privacy_and_security_there_is_no_way_to_recover, lockoutDays, lockoutDays))
.setPositiveButton(android.R.string.ok, null)
.setNeutralButton(R.string.PinRestoreEntryFragment_contact_support) { _, _ -> sendEmailToSupport() }
.show()
}
private fun getLockoutDays(timeRemainingMs: Long): Int {
return TimeUnit.MILLISECONDS.toDays(timeRemainingMs).toInt() + 1
}
private fun getTriesRemainingDialogMessage(triesRemaining: Int, daysRemaining: Int): String {
val resources = requireContext().resources
val tries = resources.getQuantityString(R.plurals.RegistrationLockFragment__you_have_d_attempts_remaining, triesRemaining, triesRemaining)
val days = resources.getQuantityString(R.plurals.RegistrationLockFragment__if_you_run_out_of_attempts_your_account_will_be_locked_for_d_days, daysRemaining, daysRemaining)
return "$tries $days"
}
private fun enableAndFocusPinEntry() {
binding.kbsLockPinInput.setEnabled(true)
binding.kbsLockPinInput.setFocusable(true)
ViewUtil.focusAndShowKeyboard(binding.kbsLockPinInput)
}
private fun sendEmailToSupport() {
val subject = R.string.RegistrationLockFragment__signal_registration_need_help_with_pin_for_android_v2_pin
val body = SupportEmailUtil.generateSupportEmailBody(
requireContext(),
subject,
null,
null
)
CommunicationActions.openEmail(
requireContext(),
SupportEmailUtil.getSupportEmailAddress(requireContext()),
getString(subject),
body
)
}
}

View File

@@ -0,0 +1,278 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.reregisterwithpin
import android.os.Bundle
import android.view.View
import android.view.inputmethod.EditorInfo
import android.widget.Toast
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.databinding.FragmentRegistrationPinRestoreEntryV2Binding
import org.thoughtcrime.securesms.lock.v2.SvrConstants
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate
import org.thoughtcrime.securesms.registration.ui.RegistrationCheckpoint
import org.thoughtcrime.securesms.registration.ui.RegistrationState
import org.thoughtcrime.securesms.registration.ui.RegistrationViewModel
import org.thoughtcrime.securesms.registration.ui.phonenumber.EnterPhoneNumberMode
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.SupportEmailUtil
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil
import org.thoughtcrime.securesms.util.navigation.safeNavigate
class ReRegisterWithPinFragment : LoggingFragment(R.layout.fragment_registration_pin_restore_entry_v2) {
companion object {
private val TAG = Log.tag(ReRegisterWithPinFragment::class.java)
}
private val registrationViewModel by activityViewModels<RegistrationViewModel>()
private val reRegisterViewModel by viewModels<ReRegisterWithPinViewModel>()
private val binding: FragmentRegistrationPinRestoreEntryV2Binding by ViewBinderDelegate(FragmentRegistrationPinRestoreEntryV2Binding::bind)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
RegistrationViewDelegate.setDebugLogSubmitMultiTapView(binding.pinRestorePinTitle)
binding.pinRestorePinDescription.setText(R.string.RegistrationLockFragment__enter_the_pin_you_created_for_your_account)
binding.pinRestoreForgotPin.visibility = View.GONE
binding.pinRestoreForgotPin.setOnClickListener { onNeedHelpClicked() }
binding.pinRestoreSkipButton.setOnClickListener { onSkipClicked() }
binding.pinRestorePinInput.imeOptions = EditorInfo.IME_ACTION_DONE
binding.pinRestorePinInput.setOnEditorActionListener { v, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
ViewUtil.hideKeyboard(requireContext(), v!!)
handlePinEntry()
return@setOnEditorActionListener true
}
false
}
enableAndFocusPinEntry()
binding.pinRestorePinContinue.setOnClickListener {
handlePinEntry()
}
binding.pinRestoreKeyboardToggle.setOnClickListener { reRegisterViewModel.toggleKeyboardType() }
LiveDataUtil
.combineLatest(registrationViewModel.uiState, reRegisterViewModel.uiState) { reg, rereg -> reg to rereg }
.observe(viewLifecycleOwner) { (registrationState, reRegisterState) -> updateViewState(registrationState, reRegisterState) }
}
private fun updateViewState(state: RegistrationState, reRegisterState: ReRegisterWithPinState) {
if (state.networkError != null) {
genericErrorDialog()
registrationViewModel.networkErrorShown()
} else if (!state.canSkipSms) {
findNavController().safeNavigate(ReRegisterWithPinFragmentDirections.actionReRegisterWithPinFragmentToEnterPhoneNumberFragment(EnterPhoneNumberMode.NORMAL))
registrationViewModel.setInProgress(false)
} else if (state.isRegistrationLockEnabled && state.svrTriesRemaining == 0) {
Log.w(TAG, "Unable to continue skip flow, KBS is locked")
onAccountLocked()
} else {
presentProgress(state.inProgress)
presentTriesRemaining(reRegisterState, state.svrTriesRemaining)
}
reRegisterState.pinKeyboardType.applyTo(
pinEditText = binding.pinRestorePinInput,
toggleTypeButton = binding.pinRestoreKeyboardToggle
)
state.registerAccountError?.let { error ->
registrationErrorHandler(error)
registrationViewModel.registerAccountErrorShown()
}
}
private fun presentProgress(inProgress: Boolean) {
if (inProgress) {
ViewUtil.hideKeyboard(requireContext(), binding.pinRestorePinInput)
binding.pinRestorePinInput.isEnabled = false
binding.pinRestorePinContinue.setSpinning()
} else {
binding.pinRestorePinInput.isEnabled = true
binding.pinRestorePinContinue.cancelSpinning()
}
}
private fun handlePinEntry() {
val pin: String? = binding.pinRestorePinInput.text?.toString()
if (pin.isNullOrBlank()) {
Toast.makeText(requireContext(), R.string.RegistrationActivity_you_must_enter_your_registration_lock_PIN, Toast.LENGTH_LONG).show()
enableAndFocusPinEntry()
return
}
if (pin.trim().length < SvrConstants.MINIMUM_PIN_LENGTH) {
Toast.makeText(requireContext(), getString(R.string.RegistrationActivity_your_pin_has_at_least_d_digits_or_characters, SvrConstants.MINIMUM_PIN_LENGTH), Toast.LENGTH_LONG).show()
enableAndFocusPinEntry()
return
}
registrationViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.PIN_CONFIRMED)
registrationViewModel.verifyReRegisterWithPin(
context = requireContext(),
pin = pin,
wrongPinHandler = {
registrationViewModel.setInProgress(false)
reRegisterViewModel.markIncorrectGuess()
}
)
}
private fun presentTriesRemaining(reRegisterState: ReRegisterWithPinState, triesRemaining: Int) {
if (reRegisterState.hasIncorrectGuess) {
if (triesRemaining == 1 && !reRegisterState.isLocalVerification) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.PinRestoreEntryFragment_incorrect_pin)
.setMessage(resources.getQuantityString(R.plurals.PinRestoreEntryFragment_you_have_d_attempt_remaining, triesRemaining, triesRemaining))
.setPositiveButton(android.R.string.ok, null)
.show()
}
if (triesRemaining > 5) {
binding.pinRestorePinInputLabel.setText(R.string.PinRestoreEntryFragment_incorrect_pin)
} else {
binding.pinRestorePinInputLabel.text = resources.getQuantityString(R.plurals.RegistrationLockFragment__incorrect_pin_d_attempts_remaining, triesRemaining, triesRemaining)
}
binding.pinRestoreForgotPin.visibility = View.VISIBLE
} else {
if (triesRemaining == 1) {
binding.pinRestoreForgotPin.visibility = View.VISIBLE
if (!reRegisterState.isLocalVerification) {
MaterialAlertDialogBuilder(requireContext())
.setMessage(resources.getQuantityString(R.plurals.PinRestoreEntryFragment_you_have_d_attempt_remaining, triesRemaining, triesRemaining))
.setPositiveButton(android.R.string.ok, null)
.show()
}
}
}
if (triesRemaining == 0) {
Log.w(TAG, "Account locked. User out of attempts on KBS.")
onAccountLocked()
}
}
private fun onAccountLocked() {
Log.d(TAG, "Showing Incorrect PIN dialog. Is local verification: ${reRegisterViewModel.isLocalVerification}")
val message = if (reRegisterViewModel.isLocalVerification) R.string.ReRegisterWithPinFragment_out_of_guesses_local else R.string.PinRestoreLockedFragment_youve_run_out_of_pin_guesses
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.PinRestoreEntryFragment_incorrect_pin)
.setMessage(message)
.setCancelable(false)
.setPositiveButton(R.string.ReRegisterWithPinFragment_send_sms_code) { _, _ -> onSkipPinEntry() }
.setNegativeButton(R.string.AccountLockedFragment__learn_more) { _, _ -> CommunicationActions.openBrowserLink(requireContext(), getString(R.string.PinRestoreLockedFragment_learn_more_url)) }
.show()
}
private fun enableAndFocusPinEntry() {
binding.pinRestorePinInput.isEnabled = true
binding.pinRestorePinInput.isFocusable = true
ViewUtil.focusAndShowKeyboard(binding.pinRestorePinInput)
}
private fun onNeedHelpClicked() {
Log.i(TAG, "User clicked need help dialog.")
val message = if (reRegisterViewModel.isLocalVerification) R.string.ReRegisterWithPinFragment_need_help_local else R.string.PinRestoreEntryFragment_your_pin_is_a_d_digit_code
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.PinRestoreEntryFragment_need_help)
.setMessage(getString(message, SvrConstants.MINIMUM_PIN_LENGTH))
.setPositiveButton(R.string.PinRestoreEntryFragment_skip) { _, _ -> onSkipPinEntry() }
.setNeutralButton(R.string.PinRestoreEntryFragment_contact_support) { _, _ ->
val body = SupportEmailUtil.generateSupportEmailBody(requireContext(), R.string.ReRegisterWithPinFragment_support_email_subject, null, null)
CommunicationActions.openEmail(
requireContext(),
SupportEmailUtil.getSupportEmailAddress(requireContext()),
getString(R.string.ReRegisterWithPinFragment_support_email_subject),
body
)
}
.setNegativeButton(R.string.PinRestoreEntryFragment_cancel, null)
.show()
}
private fun onSkipClicked() {
Log.i(TAG, "User clicked the skip PIN button.")
val message = if (reRegisterViewModel.isLocalVerification) R.string.ReRegisterWithPinFragment_skip_local else R.string.PinRestoreEntryFragment_if_you_cant_remember_your_pin
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.PinRestoreEntryFragment_skip_pin_entry)
.setMessage(message)
.setPositiveButton(R.string.PinRestoreEntryFragment_skip) { _, _ -> onSkipPinEntry() }
.setNegativeButton(R.string.PinRestoreEntryFragment_cancel, null)
.show()
}
private fun onSkipPinEntry() {
Log.d(TAG, "User skipping PIN entry.")
registrationViewModel.setUserSkippedReRegisterFlow(true)
}
private fun presentRateLimitedDialog() {
MaterialAlertDialogBuilder(requireContext()).apply {
setTitle(R.string.RegistrationActivity_too_many_attempts)
setMessage(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later)
setPositiveButton(android.R.string.ok, null)
show()
}
}
private fun genericErrorDialog() {
MaterialAlertDialogBuilder(requireContext())
.setMessage(R.string.RegistrationActivity_error_connecting_to_service)
.setPositiveButton(android.R.string.ok, null)
.create()
.show()
}
private fun registrationErrorHandler(result: RegisterAccountResult) {
when (result) {
is RegisterAccountResult.Success -> throw IllegalStateException("Register account error handler called on successful response!")
is RegisterAccountResult.AuthorizationFailed,
is RegisterAccountResult.MalformedRequest,
is RegisterAccountResult.UnknownError,
is RegisterAccountResult.ValidationError,
is RegisterAccountResult.RegistrationLocked -> {
Log.i(TAG, "Registration failed.", result.getCause())
genericErrorDialog()
}
is RegisterAccountResult.IncorrectRecoveryPassword -> {
registrationViewModel.setUserSkippedReRegisterFlow(true)
findNavController().safeNavigate(ReRegisterWithPinFragmentDirections.actionReRegisterWithPinFragmentToEnterPhoneNumberFragment(EnterPhoneNumberMode.NORMAL))
}
is RegisterAccountResult.AttemptsExhausted,
is RegisterAccountResult.RateLimited -> presentRateLimitedDialog()
is RegisterAccountResult.SvrNoData -> onAccountLocked()
is RegisterAccountResult.SvrWrongPin -> {
reRegisterViewModel.markIncorrectGuess()
reRegisterViewModel.markAsRemoteVerification()
}
}
}
}

View File

@@ -0,0 +1,16 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.reregisterwithpin
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.lock.v2.PinKeyboardType
data class ReRegisterWithPinState(
val isLocalVerification: Boolean = false,
val hasIncorrectGuess: Boolean = false,
val localPinMatches: Boolean = false,
val pinKeyboardType: PinKeyboardType = SignalStore.pin.keyboardType
)

View File

@@ -0,0 +1,38 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.reregisterwithpin
import androidx.lifecycle.ViewModel
import androidx.lifecycle.asLiveData
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
class ReRegisterWithPinViewModel : ViewModel() {
private val store = MutableStateFlow(ReRegisterWithPinState())
val uiState = store.asLiveData()
val isLocalVerification: Boolean
get() = store.value.isLocalVerification
fun markAsRemoteVerification() {
store.update {
it.copy(isLocalVerification = false)
}
}
fun markIncorrectGuess() {
store.update {
it.copy(hasIncorrectGuess = true)
}
}
fun toggleKeyboardType() {
store.update { previousState ->
previousState.copy(pinKeyboardType = previousState.pinKeyboardType.other)
}
}
}

View File

@@ -0,0 +1,66 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.restore
import org.thoughtcrime.securesms.restore.enterbackupkey.PostRegistrationEnterBackupKeyViewModel
import org.whispersystems.signalservice.api.AccountEntropyPool
/**
* Help verify a potential string could be an [AccountEntropyPool] string. Intended only
* for use in [EnterBackupKeyViewModel] and [PostRegistrationEnterBackupKeyViewModel].
*/
object AccountEntropyPoolVerification {
/**
* Given a backup key and metadata around it's previous verification state, provide an updated or new state.
*
* @param backupKey key to verify
* @param changed if the key has changed from the previous verification attempt
* @param previousAEPValidationError the error if any of the previous verification attempt
* @return [Pair] of is contents generally valid and any still present or new validation error
*/
fun verifyAEP(backupKey: String, changed: Boolean, previousAEPValidationError: AEPValidationError?): Pair<Boolean, AEPValidationError?> {
val isValid = validateContents(backupKey)
val isShort = backupKey.length < AccountEntropyPool.LENGTH
val isExact = backupKey.length == AccountEntropyPool.LENGTH
var updatedError: AEPValidationError? = checkErrorStillApplies(backupKey, previousAEPValidationError, isShort || isExact, isValid, changed)
if (updatedError == null) {
updatedError = checkForNewError(backupKey, isShort, isExact, isValid)
}
return isValid to updatedError
}
private fun validateContents(backupKey: String): Boolean {
return AccountEntropyPool.isFullyValid(backupKey)
}
private fun checkErrorStillApplies(backupKey: String, error: AEPValidationError?, isShortOrExact: Boolean, isValid: Boolean, isChanged: Boolean): AEPValidationError? {
return when (error) {
is AEPValidationError.TooLong -> if (isShortOrExact) null else error.copy(count = backupKey.length)
AEPValidationError.Invalid -> if (isValid) null else error
AEPValidationError.Incorrect -> if (isChanged) null else error
null -> null
}
}
private fun checkForNewError(backupKey: String, isShort: Boolean, isExact: Boolean, isValid: Boolean): AEPValidationError? {
return if (!isShort && !isExact) {
AEPValidationError.TooLong(backupKey.length, AccountEntropyPool.LENGTH)
} else if (!isValid && isExact) {
AEPValidationError.Invalid
} else {
null
}
}
sealed interface AEPValidationError {
data class TooLong(val count: Int, val max: Int) : AEPValidationError
data object Invalid : AEPValidationError
data object Incorrect : AEPValidationError
}
}

View File

@@ -0,0 +1,118 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.restore
import android.annotation.SuppressLint
import android.content.Context
import android.os.Build
import android.view.autofill.AutofillManager
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.Autofill
import androidx.compose.ui.autofill.AutofillNode
import androidx.compose.ui.autofill.AutofillType
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalAutofill
import androidx.compose.ui.platform.LocalAutofillTree
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.content.ContextCompat
import kotlin.math.roundToInt
import android.graphics.Rect as ViewRect
import androidx.compose.ui.geometry.Rect as ComposeRect
/**
* Provide a compose friendly way to autofill the backup key from a password manager.
*/
@OptIn(ExperimentalComposeUiApi::class)
@SuppressLint("NewApi")
@Composable
fun backupKeyAutoFillHelper(
onFill: (String) -> Unit
): BackupKeyAutoFillHelper {
val view = LocalView.current
val context = LocalContext.current
val autofill: Autofill? = LocalAutofill.current
val node = remember { AutofillNode(autofillTypes = listOf(AutofillType.Password), onFill = onFill) }
LocalAutofillTree.current += node
return remember {
object : BackupKeyAutoFillHelper(context) {
override fun request() {
if (node.boundingBox != null) {
autofill?.requestAutofillForNode(node)
}
}
override fun cancel() {
autofill?.cancelAutofillForNode(node)
}
/**
* Call when need to manually prompt auto-fill options when text field is empty. For some reason calling
* [request] like we do for on focus changes is not enough.
*/
override fun requestDirectly() {
val bounds = node.boundingBox?.let { ViewRect(it.left.roundToInt(), it.top.roundToInt(), it.right.roundToInt(), it.bottom.roundToInt()) }
if (bounds != null) {
autoFillManager?.requestAutofill(view, node.id, bounds)
}
}
override fun updateNodeBounds(boundsInWindow: ComposeRect) {
node.boundingBox = boundsInWindow
}
}
}
}
/**
* Attach a [BackupKeyAutoFillHelper] return from [backupKeyAutoFillHelper] to setup the default
* callbacks needed to make requests on the view's behalf.
*/
fun Modifier.attachBackupKeyAutoFillHelper(helper: BackupKeyAutoFillHelper): Modifier {
return this.then(
Modifier
.onFocusChanged {
if (it.isFocused) {
helper.request()
} else {
helper.cancel()
}
}
.onGloballyPositioned {
helper.updateNodeBounds(it.boundsInWindow())
}
)
}
/**
* Weird compose-interop abstract class to let us return something to the caller of [backupKeyAutoFillHelper]
* and capture inner compose data to implement the methods that need various compose provided things.
*/
abstract class BackupKeyAutoFillHelper(context: Context) {
protected val autoFillManager: AutofillManager? = if (Build.VERSION.SDK_INT >= 26) {
ContextCompat.getSystemService(context, AutofillManager::class.java)
} else {
null
}
fun onValueChanged(value: String) {
if (value.isEmpty()) {
requestDirectly()
}
}
abstract fun request()
abstract fun cancel()
abstract fun requestDirectly()
abstract fun updateNodeBounds(boundsInWindow: ComposeRect)
}

View File

@@ -0,0 +1,51 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.restore
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.OffsetMapping
import androidx.compose.ui.text.input.TransformedText
import androidx.compose.ui.text.input.VisualTransformation
/**
* Visual formatter for backup keys.
*
* @param chunkSize character count per group
*/
class BackupKeyVisualTransformation(private val chunkSize: Int) : VisualTransformation {
override fun filter(text: AnnotatedString): TransformedText {
var output = ""
for ((i, c) in text.withIndex()) {
output += c
if (i % chunkSize == chunkSize - 1) {
output += " "
}
}
val transformed = output.trimEnd()
return TransformedText(
text = AnnotatedString(transformed),
offsetMapping = BackupKeyVisualTransformation(chunkSize, text.length)
)
}
private class BackupKeyVisualTransformation(private val chunkSize: Int, private val inputSize: Int) : OffsetMapping {
override fun originalToTransformed(offset: Int): Int {
val transformed = offset + (offset / chunkSize)
return when {
inputSize == 0 -> 0
offset == inputSize && offset >= chunkSize && offset % chunkSize == 0 -> transformed - 1
else -> transformed
}
}
override fun transformedToOriginal(offset: Int): Int {
return offset - (offset / (chunkSize + 1))
}
}
}

View File

@@ -0,0 +1,222 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.restore
import android.os.Bundle
import android.view.View
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.res.stringResource
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.Dialogs
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.contactsupport.ContactSupportDialog
import org.thoughtcrime.securesms.components.contactsupport.ContactSupportViewModel
import org.thoughtcrime.securesms.components.contactsupport.SendSupportEmailEffect
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
import org.thoughtcrime.securesms.registration.ui.RegistrationCheckpoint
import org.thoughtcrime.securesms.registration.ui.RegistrationViewModel
import org.thoughtcrime.securesms.registration.ui.phonenumber.EnterPhoneNumberMode
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* Enter backup key screen for manual Signal Backups restore flow.
*/
class EnterBackupKeyFragment : ComposeFragment() {
companion object {
private const val LEARN_MORE_URL = "https://support.signal.org/hc/articles/360007059752"
}
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
private val viewModel by viewModels<EnterBackupKeyViewModel>()
private val contactSupportViewModel: ContactSupportViewModel<Unit> by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
sharedViewModel
.state
.map { it.registerAccountError }
.filterNotNull()
.collect {
sharedViewModel.registerAccountErrorShown()
viewModel.handleRegistrationFailure(it)
}
}
}
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
sharedViewModel
.state
.filter { it.registrationCheckpoint == RegistrationCheckpoint.BACKUP_TIMESTAMP_NOT_RESTORED }
.collect {
viewModel.handleBackupTimestampNotRestored()
}
}
}
}
@Composable
override fun FragmentContent() {
val state by viewModel.state.collectAsStateWithLifecycle()
val sharedState by sharedViewModel.state.collectAsStateWithLifecycle()
val contactSupportState: ContactSupportViewModel.ContactSupportState<Unit> by contactSupportViewModel.state.collectAsStateWithLifecycle()
SendSupportEmailEffect(
contactSupportState = contactSupportState,
subjectRes = { R.string.EnterBackupKey_network_failure_support_email },
filterRes = { R.string.EnterBackupKey_network_failure_support_email_filter }
) {
contactSupportViewModel.hideContactSupport()
}
EnterBackupKeyScreen(
isDisplayedDuringManualRestore = true,
backupKey = viewModel.backupKey,
inProgress = sharedState.inProgress,
isBackupKeyValid = state.backupKeyValid,
chunkLength = state.chunkLength,
aepValidationError = state.aepValidationError,
onBackupKeyChanged = viewModel::updateBackupKey,
onNextClicked = {
viewModel.registering()
sharedViewModel.registerWithBackupKey(
context = requireContext(),
backupKey = viewModel.backupKey,
e164 = null,
pin = null,
aciIdentityKeyPair = null,
pniIdentityKeyPair = null
)
},
onLearnMore = { CommunicationActions.openBrowserLink(requireContext(), LEARN_MORE_URL) },
onSkip = {
sharedViewModel.skipRestore()
findNavController().safeNavigate(EnterBackupKeyFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.RESTART_AFTER_COLLECTION))
},
dialogContent = {
if (contactSupportState.show) {
ContactSupportDialog(
showInProgress = contactSupportState.showAsProgress,
callbacks = contactSupportViewModel
)
} else {
ErrorContent(
state = state,
onBackupTierRetry = {
viewModel.incrementBackupTierRetry()
sharedViewModel.checkForBackupFile()
},
onAbandonRemoteRestoreAfterRegistration = {
viewLifecycleOwner.lifecycleScope.launch {
sharedViewModel.resumeNormalRegistration()
}
},
onBackupTierNotRestoredDismiss = viewModel::hideRestoreBackupKeyFailed,
onContactSupport = { contactSupportViewModel.showContactSupport() },
onRegistrationErrorDismiss = viewModel::clearRegistrationError,
onBackupKeyHelp = { CommunicationActions.openBrowserLink(requireContext(), LEARN_MORE_URL) }
)
}
}
)
}
}
@Composable
private fun ErrorContent(
state: EnterBackupKeyViewModel.EnterBackupKeyState,
onBackupTierRetry: () -> Unit = {},
onAbandonRemoteRestoreAfterRegistration: () -> Unit = {},
onBackupTierNotRestoredDismiss: () -> Unit = {},
onContactSupport: () -> Unit = {},
onRegistrationErrorDismiss: () -> Unit = {},
onBackupKeyHelp: () -> Unit = {}
) {
if (state.showBackupTierNotRestoreError == EnterBackupKeyViewModel.TierRestoreError.NETWORK_ERROR) {
if (state.tierRetryAttempts > 1) {
Dialogs.AdvancedAlertDialog(
title = stringResource(R.string.EnterBackupKey_cant_restore_backup),
body = stringResource(R.string.EnterBackupKey_your_backup_cant_be_restored_right_now),
positive = stringResource(R.string.EnterBackupKey_try_again),
neutral = stringResource(R.string.EnterBackupKey_contact_support),
negative = stringResource(android.R.string.cancel),
onPositive = {
onBackupTierNotRestoredDismiss()
onBackupTierRetry()
},
onNeutral = {
onBackupTierNotRestoredDismiss()
onContactSupport()
},
onNegative = {
onBackupTierNotRestoredDismiss()
onAbandonRemoteRestoreAfterRegistration()
}
)
} else {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.EnterBackupKey_cant_restore_backup),
body = stringResource(R.string.EnterBackupKey_your_backup_cant_be_restored_right_now),
confirm = stringResource(R.string.EnterBackupKey_try_again),
dismiss = stringResource(android.R.string.cancel),
onConfirm = onBackupTierRetry,
onDeny = onAbandonRemoteRestoreAfterRegistration,
onDismiss = onBackupTierNotRestoredDismiss,
onDismissRequest = {}
)
}
} else if (state.showBackupTierNotRestoreError == EnterBackupKeyViewModel.TierRestoreError.NOT_FOUND) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.EnterBackupKey_backup_not_found),
body = stringResource(R.string.EnterBackupKey_backup_key_you_entered_is_correct_but_no_backup),
confirm = stringResource(R.string.EnterBackupKey_try_again),
dismiss = stringResource(R.string.EnterBackupKey_skip_restore),
onConfirm = onBackupTierRetry,
onDeny = onAbandonRemoteRestoreAfterRegistration,
onDismiss = onBackupTierNotRestoredDismiss
)
} else if (state.showRegistrationError) {
if (state.registerAccountResult is RegisterAccountResult.IncorrectRecoveryPassword) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.EnterBackupKey_incorrect_backup_key_title),
body = stringResource(R.string.EnterBackupKey_incorrect_backup_key_message),
confirm = stringResource(R.string.EnterBackupKey_try_again),
dismiss = stringResource(R.string.EnterBackupKey_backup_key_help),
onConfirm = {},
onDeny = onBackupKeyHelp,
onDismiss = onRegistrationErrorDismiss
)
} else {
val message = when (state.registerAccountResult) {
is RegisterAccountResult.RateLimited -> stringResource(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later)
else -> stringResource(R.string.RegistrationActivity_error_connecting_to_service)
}
Dialogs.SimpleMessageDialog(
message = message,
onDismiss = onRegistrationErrorDismiss,
dismiss = stringResource(android.R.string.ok)
)
}
}
}

View File

@@ -0,0 +1,343 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.restore
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.ui.compose.horizontalGutters
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.ui.BackupsIconColors
import org.thoughtcrime.securesms.fonts.MonoTypeface
import org.thoughtcrime.securesms.registration.ui.shared.RegistrationScreen
import org.whispersystems.signalservice.api.AccountEntropyPool
/**
* Shared screen infrastructure for entering an [AccountEntropyPool].
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EnterBackupKeyScreen(
isDisplayedDuringManualRestore: Boolean,
backupKey: String,
inProgress: Boolean,
isBackupKeyValid: Boolean,
chunkLength: Int,
aepValidationError: AccountEntropyPoolVerification.AEPValidationError?,
onBackupKeyChanged: (String) -> Unit = {},
onNextClicked: () -> Unit = {},
onLearnMore: () -> Unit = {},
onSkip: () -> Unit = {},
dialogContent: @Composable () -> Unit
) {
val coroutineScope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
RegistrationScreen(
title = stringResource(R.string.EnterBackupKey_title),
subtitle = stringResource(R.string.EnterBackupKey_subtitle),
bottomContent = {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
TextButton(
enabled = !inProgress,
modifier = Modifier.weight(weight = 1f, fill = false),
onClick = {
coroutineScope.launch {
sheetState.show()
}
}
) {
Text(
text = stringResource(id = R.string.EnterBackupKey_no_backup_key)
)
}
Spacer(modifier = Modifier.size(24.dp))
AnimatedContent(
targetState = inProgress,
label = "next-progress"
) { inProgress ->
if (inProgress) {
CircularProgressIndicator(
modifier = Modifier.size(40.dp)
)
} else {
Buttons.LargeTonal(
enabled = isBackupKeyValid && aepValidationError == null,
onClick = onNextClicked
) {
Text(
text = stringResource(id = R.string.RegistrationActivity_next)
)
}
}
}
}
}
) {
val focusRequester = remember { FocusRequester() }
var requestFocus: Boolean by remember { mutableStateOf(true) }
val visualTransform = remember(chunkLength) { BackupKeyVisualTransformation(chunkSize = chunkLength) }
val keyboardController = LocalSoftwareKeyboardController.current
val autoFillHelper = backupKeyAutoFillHelper { onBackupKeyChanged(it) }
TextField(
value = backupKey,
onValueChange = {
onBackupKeyChanged(it)
autoFillHelper.onValueChanged(it)
},
label = {
Text(text = stringResource(id = R.string.EnterBackupKey_backup_key))
},
textStyle = LocalTextStyle.current.copy(
fontFamily = MonoTypeface.fontFamily(),
lineHeight = 36.sp
),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Next,
autoCorrectEnabled = false
),
keyboardActions = KeyboardActions(
onNext = {
if (isBackupKeyValid) {
keyboardController?.hide()
onNextClicked()
}
}
),
supportingText = { aepValidationError?.ValidationErrorMessage() },
isError = aepValidationError != null,
minLines = 4,
visualTransformation = visualTransform,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.attachBackupKeyAutoFillHelper(autoFillHelper)
.onGloballyPositioned {
if (requestFocus) {
focusRequester.requestFocus()
requestFocus = false
}
}
)
if (sheetState.isVisible) {
ModalBottomSheet(
dragHandle = null,
onDismissRequest = {
coroutineScope.launch {
sheetState.hide()
}
}
) {
NoBackupKeyBottomSheet(
onLearnMore = {
coroutineScope.launch {
sheetState.hide()
}
onLearnMore()
},
onSkip = onSkip,
showSecondParagraph = isDisplayedDuringManualRestore
)
}
}
dialogContent()
}
}
@Composable
private fun AccountEntropyPoolVerification.AEPValidationError.ValidationErrorMessage() {
when (this) {
is AccountEntropyPoolVerification.AEPValidationError.TooLong -> Text(text = stringResource(R.string.EnterBackupKey_too_long_error, this.count, this.max))
AccountEntropyPoolVerification.AEPValidationError.Invalid -> Text(text = stringResource(R.string.EnterBackupKey_invalid_backup_key_error))
AccountEntropyPoolVerification.AEPValidationError.Incorrect -> Text(text = stringResource(R.string.EnterBackupKey_incorrect_backup_key_error))
}
}
@SignalPreview
@Composable
private fun EnterBackupKeyScreenPreview() {
Previews.Preview {
EnterBackupKeyScreen(
backupKey = "UY38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t".uppercase(),
isBackupKeyValid = true,
inProgress = false,
chunkLength = 4,
aepValidationError = null,
isDisplayedDuringManualRestore = true
) {}
}
}
@SignalPreview
@Composable
private fun EnterBackupKeyScreenErrorPreview() {
Previews.Preview {
EnterBackupKeyScreen(
backupKey = "UY38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t".uppercase(),
isBackupKeyValid = true,
inProgress = false,
chunkLength = 4,
aepValidationError = AccountEntropyPoolVerification.AEPValidationError.Invalid,
isDisplayedDuringManualRestore = true
) {}
}
}
@Composable
private fun NoBackupKeyBottomSheet(
showSecondParagraph: Boolean,
onLearnMore: () -> Unit = {},
onSkip: () -> Unit = {}
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.horizontalGutters()
) {
BottomSheets.Handle()
Icon(
painter = painterResource(id = R.drawable.symbol_key_24),
tint = BackupsIconColors.Success.foreground,
contentDescription = null,
modifier = Modifier
.padding(top = 18.dp, bottom = 16.dp)
.size(88.dp)
.background(
color = BackupsIconColors.Success.background,
shape = CircleShape
)
.padding(20.dp)
)
Text(
text = stringResource(R.string.EnterBackupKey_no_backup_key),
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.EnterBackupKey_no_key_paragraph_1),
style = MaterialTheme.typography.bodyMedium.copy(textAlign = TextAlign.Center),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (showSecondParagraph) {
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(R.string.EnterBackupKey_no_key_paragraph_2),
style = MaterialTheme.typography.bodyMedium.copy(textAlign = TextAlign.Center),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(36.dp))
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 24.dp)
) {
TextButton(
onClick = onLearnMore
) {
Text(
text = stringResource(id = R.string.EnterBackupKey_learn_more)
)
}
TextButton(
onClick = onSkip
) {
Text(
text = stringResource(id = R.string.EnterBackupKey_skip_and_dont_restore)
)
}
}
}
}
@SignalPreview
@Composable
private fun NoBackupKeyBottomSheetPreview() {
Previews.BottomSheetPreview {
NoBackupKeyBottomSheet(
showSecondParagraph = true
)
}
}
@SignalPreview
@Composable
private fun NoBackupKeyBottomSheetNoSecondParagraphPreview() {
Previews.BottomSheetPreview {
NoBackupKeyBottomSheet(
showSecondParagraph = false
)
}
}

View File

@@ -0,0 +1,124 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.restore
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
import org.whispersystems.signalservice.api.AccountEntropyPool
class EnterBackupKeyViewModel : ViewModel() {
companion object {
private val TAG = Log.tag(EnterBackupKeyViewModel::class)
}
private val store = MutableStateFlow(
EnterBackupKeyState(
requiredLength = 64,
chunkLength = 4
)
)
var backupKey by mutableStateOf("")
private set
val state: StateFlow<EnterBackupKeyState> = store
fun updateBackupKey(key: String) {
val newKey = AccountEntropyPool.removeIllegalCharacters(key).take(AccountEntropyPool.LENGTH + 16).lowercase()
val changed = newKey != backupKey
backupKey = newKey
store.update {
val (isValid, updatedError) = AccountEntropyPoolVerification.verifyAEP(
backupKey = backupKey,
changed = changed,
previousAEPValidationError = it.aepValidationError
)
it.copy(backupKeyValid = isValid, aepValidationError = updatedError)
}
}
fun registering() {
store.update { it.copy(isRegistering = true) }
}
fun handleRegistrationFailure(registerAccountResult: RegisterAccountResult) {
store.update {
if (it.isRegistering) {
Log.w(TAG, "Unable to register [${registerAccountResult::class.simpleName}]", registerAccountResult.getCause(), true)
val incorrectKeyError = registerAccountResult is RegisterAccountResult.IncorrectRecoveryPassword
if (incorrectKeyError && SignalStore.account.restoredAccountEntropyPool) {
SignalStore.account.resetAccountEntropyPool()
SignalStore.account.resetAciAndPniIdentityKeysAfterFailedRestore()
}
it.copy(
isRegistering = false,
showRegistrationError = true,
registerAccountResult = registerAccountResult,
aepValidationError = if (incorrectKeyError) AccountEntropyPoolVerification.AEPValidationError.Incorrect else it.aepValidationError
)
} else {
it
}
}
}
fun clearRegistrationError() {
store.update {
it.copy(
showRegistrationError = false,
registerAccountResult = null
)
}
}
fun handleBackupTimestampNotRestored() {
store.update {
it.copy(
showBackupTierNotRestoreError = if (SignalStore.backup.isBackupTimestampRestored) TierRestoreError.NOT_FOUND else TierRestoreError.NETWORK_ERROR
)
}
}
fun hideRestoreBackupKeyFailed() {
store.update {
it.copy(
showBackupTierNotRestoreError = null
)
}
}
fun incrementBackupTierRetry() {
store.update { it.copy(tierRetryAttempts = it.tierRetryAttempts + 1) }
}
data class EnterBackupKeyState(
val backupKeyValid: Boolean = false,
val requiredLength: Int,
val chunkLength: Int,
val isRegistering: Boolean = false,
val showRegistrationError: Boolean = false,
val showBackupTierNotRestoreError: TierRestoreError? = null,
val registerAccountResult: RegisterAccountResult? = null,
val aepValidationError: AccountEntropyPoolVerification.AEPValidationError? = null,
val tierRetryAttempts: Int = 0
)
enum class TierRestoreError {
NOT_FOUND,
NETWORK_ERROR
}
}

View File

@@ -0,0 +1,118 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.restore
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.fragment.findNavController
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.registration.ui.shared.RegistrationScreen
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* Shown when the old device is iOS and they are trying to transfer/restore on Android without a Signal Backup.
*/
class NoBackupToRestoreFragment : ComposeFragment() {
@Composable
override fun FragmentContent() {
NoBackupToRestoreContent(
onSkipRestore = {},
onCancel = {
findNavController().safeNavigate(NoBackupToRestoreFragmentDirections.restartRegistrationFlow())
}
)
}
}
@Composable
private fun NoBackupToRestoreContent(
onSkipRestore: () -> Unit = {},
onCancel: () -> Unit = {}
) {
RegistrationScreen(
title = stringResource(id = R.string.NoBackupToRestore_title),
subtitle = stringResource(id = R.string.NoBackupToRestore_subtitle),
bottomContent = {
Column {
Buttons.LargeTonal(
onClick = onSkipRestore,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = R.string.NoBackupToRestore_skip_restore))
}
TextButton(
onClick = onCancel,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = android.R.string.cancel))
}
}
}
) {
Column(
verticalArrangement = Arrangement.spacedBy(24.dp),
modifier = Modifier.padding(horizontal = 32.dp)
) {
StepRow(icon = painterResource(R.drawable.symbol_device_phone_24), text = stringResource(id = R.string.NoBackupToRestore_step1))
StepRow(icon = painterResource(R.drawable.symbol_backup_24), text = stringResource(id = R.string.NoBackupToRestore_step2))
StepRow(icon = painterResource(R.drawable.symbol_check_circle_24), text = stringResource(id = R.string.NoBackupToRestore_step3))
}
}
}
@Composable
private fun StepRow(
icon: Painter,
text: String
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = icon,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
contentDescription = null
)
Spacer(modifier = Modifier.width(16.dp))
Text(
text = text,
style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurfaceVariant)
)
}
}
@SignalPreview
@Composable
private fun NoBackupToRestoreContentPreview() {
Previews.Preview {
NoBackupToRestoreContent()
}
}

View File

@@ -0,0 +1,42 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.restore
import org.signal.libsignal.protocol.IdentityKey
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.ecc.ECPrivateKey
import org.signal.registration.proto.RegistrationProvisionMessage
import java.security.InvalidKeyException
/**
* Attempt to parse the ACI identity key pair from the proto message parts.
*/
val RegistrationProvisionMessage.aciIdentityKeyPair: IdentityKeyPair?
get() {
return try {
IdentityKeyPair(
IdentityKey(aciIdentityKeyPublic.toByteArray()),
ECPrivateKey(aciIdentityKeyPrivate.toByteArray())
)
} catch (_: InvalidKeyException) {
null
}
}
/**
* Attempt to parse the PNI identity key pair from the proto message parts.
*/
val RegistrationProvisionMessage.pniIdentityKeyPair: IdentityKeyPair?
get() {
return try {
IdentityKeyPair(
IdentityKey(pniIdentityKeyPublic.toByteArray()),
ECPrivateKey(pniIdentityKeyPrivate.toByteArray())
)
} catch (_: InvalidKeyException) {
null
}
}

View File

@@ -0,0 +1,688 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.restore
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.bytes
import org.thoughtcrime.securesms.BaseActivity
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.backup.v2.RestoreV2Event
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeFeature
import org.thoughtcrime.securesms.backup.v2.ui.subscription.MessageBackupsTypeFeatureRow
import org.thoughtcrime.securesms.components.contactsupport.ContactSupportCallbacks
import org.thoughtcrime.securesms.components.contactsupport.ContactSupportDialog
import org.thoughtcrime.securesms.components.contactsupport.ContactSupportViewModel
import org.thoughtcrime.securesms.components.contactsupport.SendSupportEmailEffect
import org.thoughtcrime.securesms.conversation.v2.registerForLifecycle
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.ui.shared.RegistrationScreen
import org.thoughtcrime.securesms.registration.ui.shared.RegistrationScreenTitleSubtitle
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.PlayStoreUtil
import org.thoughtcrime.securesms.util.viewModel
import java.util.Locale
import kotlin.time.Duration
/**
* Restore backup from remote source.
*/
class RemoteRestoreActivity : BaseActivity() {
companion object {
private const val KEY_ONLY_OPTION = "ONLY_OPTION"
fun getIntent(context: Context, isOnlyOption: Boolean = false): Intent {
return Intent(context, RemoteRestoreActivity::class.java).apply {
putExtra(KEY_ONLY_OPTION, isOnlyOption)
}
}
}
private val viewModel: RemoteRestoreViewModel by viewModel {
RemoteRestoreViewModel(intent.getBooleanExtra(KEY_ONLY_OPTION, false))
}
private val contactSupportViewModel: ContactSupportViewModel<ContactSupportReason> by viewModels()
private lateinit var wakeLock: RemoteRestoreWakeLock
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
wakeLock = RemoteRestoreWakeLock(this)
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
val restored = viewModel
.state
.map { it.importState }
.filterIsInstance<RemoteRestoreViewModel.ImportState.Restored>()
.firstOrNull()
if (restored != null) {
RegistrationUtil.maybeMarkRegistrationComplete()
startActivity(MainActivity.clearTop(this@RemoteRestoreActivity))
finishAffinity()
}
}
}
lifecycleScope.launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
viewModel
.state
.map { it.importState }
.collect {
when (it) {
RemoteRestoreViewModel.ImportState.InProgress -> {
wakeLock.acquire()
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
else -> {
wakeLock.release()
window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
}
}
}
}
setContent {
val state: RemoteRestoreViewModel.ScreenState by viewModel.state.collectAsStateWithLifecycle()
val contactSupportState: ContactSupportViewModel.ContactSupportState<ContactSupportReason> by contactSupportViewModel.state.collectAsStateWithLifecycle()
SendSupportEmailEffect(
contactSupportState = contactSupportState,
subjectRes = { reason ->
when (reason) {
ContactSupportReason.SvrBFailure -> R.string.EnterBackupKey_permanent_failure_support_email
else -> R.string.EnterBackupKey_network_failure_support_email
}
},
filterRes = { reason ->
when (reason) {
ContactSupportReason.SvrBFailure -> R.string.EnterBackupKey_permanent_failure_support_email_filter
else -> R.string.EnterBackupKey_network_failure_support_email_filter
}
}
) {
contactSupportViewModel.hideContactSupport()
}
SignalTheme {
Surface {
RestoreFromBackupContent(
state = state,
contactSupportState = contactSupportState,
onRestoreBackupClick = { viewModel.restore() },
onRetryRestoreTier = { viewModel.reload() },
onContactSupport = { contactSupportViewModel.showContactSupport() },
onCancelClick = {
lifecycleScope.launch {
if (state.isRemoteRestoreOnlyOption) {
viewModel.skipRestore()
viewModel.performStorageServiceAccountRestoreIfNeeded()
startActivity(MainActivity.clearTop(this@RemoteRestoreActivity))
}
finish()
}
},
onImportErrorDialogDismiss = { viewModel.clearError() },
onUpdateSignal = {
PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(this)
},
contactSupportCallbacks = contactSupportViewModel
)
}
}
}
EventBus.getDefault().registerForLifecycle(subscriber = this, lifecycleOwner = this)
}
@Subscribe(threadMode = ThreadMode.MAIN)
fun onEvent(restoreEvent: RestoreV2Event) {
viewModel.updateRestoreProgress(restoreEvent)
}
enum class ContactSupportReason {
NetworkError, SvrBFailure
}
}
@Composable
private fun RestoreFromBackupContent(
state: RemoteRestoreViewModel.ScreenState,
contactSupportState: ContactSupportViewModel.ContactSupportState<RemoteRestoreActivity.ContactSupportReason> = ContactSupportViewModel.ContactSupportState(),
onRestoreBackupClick: () -> Unit = {},
onRetryRestoreTier: () -> Unit = {},
onContactSupport: () -> Unit = {},
onCancelClick: () -> Unit = {},
onImportErrorDialogDismiss: () -> Unit = {},
onUpdateSignal: () -> Unit = {},
contactSupportCallbacks: ContactSupportCallbacks = ContactSupportCallbacks.Empty
) {
when (state.loadState) {
RemoteRestoreViewModel.ScreenState.LoadState.LOADING -> {
Dialogs.IndeterminateProgressDialog(
message = stringResource(R.string.RemoteRestoreActivity__fetching_backup_details)
)
}
RemoteRestoreViewModel.ScreenState.LoadState.LOADED -> {
BackupAvailableContent(
state = state,
onRestoreBackupClick = onRestoreBackupClick,
onCancelClick = onCancelClick,
onImportErrorDialogDismiss = onImportErrorDialogDismiss,
onUpdateSignal = onUpdateSignal,
onContactSupport = onContactSupport
)
}
RemoteRestoreViewModel.ScreenState.LoadState.NOT_FOUND -> {
BackupNotFoundDialog(onDismiss = onCancelClick)
}
RemoteRestoreViewModel.ScreenState.LoadState.FAILURE -> {
if (contactSupportState.show) {
ContactSupportDialog(
showInProgress = contactSupportState.showAsProgress,
callbacks = contactSupportCallbacks
)
} else {
TierRestoreFailedDialog(
loadAttempts = state.loadAttempts,
onRetryRestore = onRetryRestoreTier,
onContactSupport = onContactSupport,
onCancel = onCancelClick
)
}
}
RemoteRestoreViewModel.ScreenState.LoadState.STORAGE_SERVICE_RESTORE -> {
Dialogs.IndeterminateProgressDialog()
}
}
}
@Composable
private fun BackupAvailableContent(
state: RemoteRestoreViewModel.ScreenState,
onRestoreBackupClick: () -> Unit,
onCancelClick: () -> Unit,
onImportErrorDialogDismiss: () -> Unit,
onUpdateSignal: () -> Unit,
onContactSupport: () -> Unit
) {
val subtitle = if (state.backupSize.bytes > 0) {
stringResource(
id = R.string.RemoteRestoreActivity__backup_created_at_with_size,
DateUtils.formatDateWithoutDayOfWeek(Locale.getDefault(), state.backupTime),
DateUtils.getOnlyTimeString(LocalContext.current, state.backupTime),
state.backupSize.toUnitString()
)
} else {
stringResource(
id = R.string.RemoteRestoreActivity__backup_created_at,
DateUtils.formatDateWithoutDayOfWeek(Locale.getDefault(), state.backupTime),
DateUtils.getOnlyTimeString(LocalContext.current, state.backupTime)
)
}
RegistrationScreen(
menu = null,
topContent = {
if (state.backupTier != null) {
RegistrationScreenTitleSubtitle(
title = stringResource(id = R.string.RemoteRestoreActivity__restore_from_backup),
subtitle = AnnotatedString(subtitle)
)
} else {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_backup_24),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.size(64.dp)
.background(color = SignalTheme.colors.colorSurface2, shape = CircleShape)
.padding(12.dp)
.align(Alignment.CenterHorizontally)
)
Spacer(modifier = Modifier.size(16.dp))
Text(
text = stringResource(id = R.string.RemoteRestoreActivity__restore_from_backup),
style = MaterialTheme.typography.headlineMedium.copy(textAlign = TextAlign.Center),
modifier = Modifier.fillMaxWidth()
)
}
},
bottomContent = {
Column {
if (state.isLoaded()) {
Buttons.LargeTonal(
onClick = onRestoreBackupClick,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = R.string.RemoteRestoreActivity__restore_backup))
}
}
TextButton(
onClick = onCancelClick,
modifier = Modifier.fillMaxWidth()
) {
Text(text = stringResource(id = if (state.isRemoteRestoreOnlyOption) R.string.RemoteRestoreActivity__skip_restore else android.R.string.cancel))
}
}
}
) {
if (state.backupTier != null) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(color = SignalTheme.colors.colorSurface2, shape = RoundedCornerShape(18.dp))
.padding(horizontal = 20.dp)
.padding(top = 20.dp, bottom = 18.dp)
) {
Text(
text = stringResource(id = R.string.RemoteRestoreActivity__your_backup_includes),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 6.dp)
)
getFeatures(state.backupTier, state.backupMediaTTL).forEach {
MessageBackupsTypeFeatureRow(
messageBackupsTypeFeature = it,
iconTint = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 16.dp, top = 6.dp)
)
}
}
Text(
text = stringResource(R.string.RemoteRestoreActivity__your_media_will_restore_in_the_background),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary,
modifier = Modifier.padding(top = 16.dp)
)
} else {
Text(
text = subtitle,
style = MaterialTheme.typography.bodyLarge.copy(textAlign = TextAlign.Center),
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 20.dp)
)
Text(
text = stringResource(R.string.RemoteRestoreActivity__your_media_will_restore_in_the_background),
style = MaterialTheme.typography.bodyLarge.copy(textAlign = TextAlign.Center),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
when (state.importState) {
RemoteRestoreViewModel.ImportState.None -> Unit
RemoteRestoreViewModel.ImportState.InProgress -> RestoreProgressDialog(state.restoreProgress)
RemoteRestoreViewModel.ImportState.Restored -> Unit
RemoteRestoreViewModel.ImportState.NetworkFailure -> RestoreNetworkFailedDialog(onDismiss = onImportErrorDialogDismiss)
RemoteRestoreViewModel.ImportState.Failed -> {
if (SignalStore.backup.hasInvalidBackupVersion) {
InvalidBackupVersionDialog(onUpdateSignal = onUpdateSignal, onDismiss = onImportErrorDialogDismiss)
} else {
RestoreFailedDialog(onDismiss = onImportErrorDialogDismiss)
}
}
RemoteRestoreViewModel.ImportState.FailureWithLogPrompt -> {
RestoreFailedWithLogPromptDialog(onDismiss = onImportErrorDialogDismiss, onContactSupport = onContactSupport)
}
}
}
}
@SignalPreview
@Composable
private fun RestoreFromBackupContentPreview() {
Previews.Preview {
RestoreFromBackupContent(
state = RemoteRestoreViewModel.ScreenState(
backupTier = MessageBackupTier.PAID,
backupTime = System.currentTimeMillis(),
backupSize = 1234567.bytes,
importState = RemoteRestoreViewModel.ImportState.None,
restoreProgress = null
)
)
}
}
@SignalPreview
@Composable
private fun RestoreFromBackupUnknownTierPreview() {
Previews.Preview {
RestoreFromBackupContent(
state = RemoteRestoreViewModel.ScreenState(
loadState = RemoteRestoreViewModel.ScreenState.LoadState.LOADED,
backupTier = null,
backupTime = System.currentTimeMillis(),
backupSize = 0.bytes,
importState = RemoteRestoreViewModel.ImportState.Restored,
restoreProgress = null
)
)
}
}
@SignalPreview
@Composable
private fun RestoreFromBackupContentLoadingPreview() {
Previews.Preview {
RestoreFromBackupContent(
state = RemoteRestoreViewModel.ScreenState(
importState = RemoteRestoreViewModel.ImportState.None,
restoreProgress = null
)
)
}
}
@Composable
private fun getFeatures(tier: MessageBackupTier?, mediaTTL: Duration): ImmutableList<MessageBackupsTypeFeature> {
return when (tier) {
null -> persistentListOf()
MessageBackupTier.PAID -> {
persistentListOf(
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
label = stringResource(id = R.string.RemoteRestoreActivity__all_of_your_media)
),
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_recent_compact_bold_16,
label = stringResource(id = R.string.RemoteRestoreActivity__all_of_your_messages)
)
)
}
MessageBackupTier.FREE -> {
persistentListOf(
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_thread_compact_bold_16,
label = stringResource(id = R.string.RemoteRestoreActivity__your_last_d_days_of_media, mediaTTL.inWholeDays)
),
MessageBackupsTypeFeature(
iconResourceId = R.drawable.symbol_recent_compact_bold_16,
label = stringResource(id = R.string.RemoteRestoreActivity__all_of_your_messages)
)
)
}
}
}
/**
* A dialog that *just* shows a spinner. Useful for short actions where you need to
* let the user know that some action is completing.
*/
@Composable
private fun RestoreProgressDialog(restoreProgress: RestoreV2Event?) {
AlertDialog(
onDismissRequest = {},
confirmButton = {},
dismissButton = {},
text = {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.wrapContentSize()
) {
if (restoreProgress == null || restoreProgress.type == RestoreV2Event.Type.PROGRESS_FINALIZING) {
CircularProgressIndicator(
modifier = Modifier
.padding(top = 55.dp, bottom = 16.dp)
.width(48.dp)
.height(48.dp)
)
} else {
CircularProgressIndicator(
progress = { restoreProgress.getProgress() },
modifier = Modifier
.padding(top = 55.dp, bottom = 16.dp)
.width(48.dp)
.height(48.dp)
)
}
val progressText = when (restoreProgress?.type) {
RestoreV2Event.Type.PROGRESS_DOWNLOAD -> stringResource(id = R.string.RemoteRestoreActivity__downloading_backup)
RestoreV2Event.Type.PROGRESS_RESTORE -> stringResource(id = R.string.RemoteRestoreActivity__restoring_messages)
RestoreV2Event.Type.PROGRESS_FINALIZING -> stringResource(id = R.string.RemoteRestoreActivity__finishing_restore)
else -> stringResource(id = R.string.RemoteRestoreActivity__restoring)
}
Text(
text = progressText,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 12.dp)
)
if (restoreProgress != null && restoreProgress.type != RestoreV2Event.Type.PROGRESS_FINALIZING) {
val progressBytes = restoreProgress.count.toUnitString()
val totalBytes = restoreProgress.estimatedTotalCount.toUnitString()
Text(
text = stringResource(id = R.string.RemoteRestoreActivity__s_of_s_s, progressBytes, totalBytes, "%.2f%%".format(restoreProgress.getProgress() * 100)),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(bottom = 12.dp)
)
}
}
}
},
modifier = Modifier.width(212.dp)
)
}
@SignalPreview
@Composable
private fun ProgressDialogPreview() {
Previews.Preview {
RestoreProgressDialog(
RestoreV2Event(
type = RestoreV2Event.Type.PROGRESS_RESTORE,
count = 1234.bytes,
estimatedTotalCount = 10240.bytes
)
)
}
}
@Composable
fun BackupNotFoundDialog(
onDismiss: () -> Unit = {}
) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.EnterBackupKey_backup_not_found),
body = stringResource(R.string.EnterBackupKey_backup_key_you_entered_is_correct_but_no_backup),
confirm = stringResource(android.R.string.ok),
onConfirm = onDismiss,
onDismiss = onDismiss
)
}
@Composable
fun RestoreFailedDialog(
onDismiss: () -> Unit = {}
) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.RemoteRestoreActivity__couldnt_transfer),
body = stringResource(R.string.RemoteRestoreActivity__error_occurred),
confirm = stringResource(android.R.string.ok),
onConfirm = onDismiss,
onDismiss = onDismiss
)
}
@Composable
fun RestoreFailedWithLogPromptDialog(
onDismiss: () -> Unit = {},
onContactSupport: () -> Unit = {}
) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.RemoteRestoreActivity__failure_with_log_prompt_title),
body = stringResource(R.string.RemoteRestoreActivity__failure_with_log_prompt_body),
confirm = stringResource(R.string.RemoteRestoreActivity__failure_with_log_prompt_contact_button),
dismiss = stringResource(android.R.string.ok),
onConfirm = onContactSupport,
onDismiss = onDismiss
)
}
@Composable
fun RestoreNetworkFailedDialog(
onDismiss: () -> Unit = {}
) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.RemoteRestoreActivity__couldnt_transfer),
body = stringResource(R.string.RegistrationActivity_error_connecting_to_service),
confirm = stringResource(android.R.string.ok),
onConfirm = onDismiss,
onDismiss = onDismiss
)
}
@Composable
fun TierRestoreFailedDialog(
loadAttempts: Int = 0,
onRetryRestore: () -> Unit = {},
onContactSupport: () -> Unit = {},
onCancel: () -> Unit = {}
) {
if (loadAttempts > 2) {
Dialogs.AdvancedAlertDialog(
title = stringResource(R.string.EnterBackupKey_cant_restore_backup),
body = stringResource(R.string.EnterBackupKey_your_backup_cant_be_restored_right_now),
positive = stringResource(R.string.EnterBackupKey_try_again),
neutral = stringResource(R.string.EnterBackupKey_contact_support),
negative = stringResource(android.R.string.cancel),
onPositive = onRetryRestore,
onNeutral = onContactSupport,
onNegative = onCancel
)
} else {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.EnterBackupKey_cant_restore_backup),
body = stringResource(R.string.EnterBackupKey_your_backup_cant_be_restored_right_now),
confirm = stringResource(R.string.EnterBackupKey_try_again),
dismiss = stringResource(android.R.string.cancel),
onConfirm = onRetryRestore,
onDeny = onCancel,
onDismissRequest = {}
)
}
}
@SignalPreview
@Composable
private fun RestoreFailedDialogPreview() {
Previews.Preview {
RestoreFailedDialog()
}
}
@SignalPreview
@Composable
private fun RestoreFailedWithLogPromptDialogPreview() {
Previews.Preview {
RestoreFailedWithLogPromptDialog()
}
}
@Composable
fun InvalidBackupVersionDialog(
onUpdateSignal: () -> Unit = {},
onDismiss: () -> Unit = {}
) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.RemoteRestoreActivity__couldnt_restore),
body = stringResource(R.string.RemoteRestoreActivity__update_latest),
confirm = stringResource(R.string.RemoteRestoreActivity__update_signal),
onConfirm = onUpdateSignal,
dismiss = stringResource(R.string.RemoteRestoreActivity__not_now),
onDismiss = onDismiss
)
}
@SignalPreview
@Composable
private fun InvalidBackupVersionDialogPreview() {
Previews.Preview {
InvalidBackupVersionDialog()
}
}

View File

@@ -0,0 +1,191 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.restore
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.util.ByteSize
import org.signal.core.util.bytes
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.backup.v2.RemoteRestoreResult
import org.thoughtcrime.securesms.backup.v2.RestoreTimestampResult
import org.thoughtcrime.securesms.backup.v2.RestoreV2Event
import org.thoughtcrime.securesms.database.model.databaseprotos.RestoreDecisionState
import org.thoughtcrime.securesms.keyvalue.Completed
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.keyvalue.Skipped
import org.thoughtcrime.securesms.registration.data.QuickRegistrationRepository
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.provisioning.RestoreMethod
import kotlin.time.Duration
import kotlin.time.Duration.Companion.days
class RemoteRestoreViewModel(isOnlyRestoreOption: Boolean) : ViewModel() {
companion object {
private val TAG = Log.tag(RemoteRestoreViewModel::class)
}
private val store: MutableStateFlow<ScreenState> = MutableStateFlow(
ScreenState(
isRemoteRestoreOnlyOption = isOnlyRestoreOption,
backupTier = SignalStore.backup.backupTier,
backupTime = SignalStore.backup.lastBackupTime,
backupSize = SignalStore.registration.restoreBackupMediaSize.bytes
)
)
val state: StateFlow<ScreenState> = store.asStateFlow()
init {
reload()
}
fun reload() {
viewModelScope.launch(Dispatchers.IO) {
store.update { it.copy(loadState = ScreenState.LoadState.LOADING, loadAttempts = it.loadAttempts + 1) }
val result = BackupRepository.restoreBackupFileTimestamp()
store.update {
when (result) {
is RestoreTimestampResult.Success -> {
it.copy(
loadState = ScreenState.LoadState.LOADED,
backupTier = SignalStore.backup.backupTier,
backupTime = SignalStore.backup.lastBackupTime,
backupSize = SignalStore.registration.restoreBackupMediaSize.bytes
)
}
is RestoreTimestampResult.NotFound -> {
it.copy(loadState = ScreenState.LoadState.NOT_FOUND)
}
else -> {
if (it.loadState == ScreenState.LoadState.LOADING) {
it.copy(loadState = ScreenState.LoadState.FAILURE)
} else {
it
}
}
}
}
}
viewModelScope.launch(Dispatchers.IO) {
val config = BackupRepository.getBackupLevelConfiguration()
if (config is NetworkResult.Success) {
store.update {
it.copy(backupMediaTTL = config.result.mediaTtlDays.days)
}
}
}
}
fun restore() {
viewModelScope.launch {
store.update { it.copy(importState = ImportState.InProgress) }
withContext(Dispatchers.IO) {
QuickRegistrationRepository.setRestoreMethodForOldDevice(RestoreMethod.REMOTE_BACKUP)
when (val result = BackupRepository.restoreRemoteBackup()) {
RemoteRestoreResult.Success -> {
Log.i(TAG, "Restore successful", true)
SignalStore.registration.restoreDecisionState = RestoreDecisionState.Completed
StorageServiceRestore.restore()
store.update { it.copy(importState = ImportState.Restored) }
}
RemoteRestoreResult.NetworkError -> {
Log.w(TAG, "Restore failed to download", true)
store.update { it.copy(importState = ImportState.NetworkFailure) }
}
RemoteRestoreResult.Canceled,
RemoteRestoreResult.Failure -> {
Log.w(TAG, "Restore failed with $result", true)
store.update { it.copy(importState = ImportState.Failed) }
}
RemoteRestoreResult.PermanentSvrBFailure -> {
Log.w(TAG, "Hit a permanent SVRB error.", true)
store.update { it.copy(importState = ImportState.FailureWithLogPrompt) }
}
}
}
}
}
fun updateRestoreProgress(restoreEvent: RestoreV2Event) {
store.update { it.copy(restoreProgress = restoreEvent) }
}
fun cancel() {
SignalStore.registration.restoreDecisionState = RestoreDecisionState.Skipped
}
fun clearError() {
store.update { it.copy(importState = ImportState.None, restoreProgress = null) }
}
fun skipRestore() {
SignalStore.registration.restoreDecisionState = RestoreDecisionState.Skipped
viewModelScope.launch {
withContext(Dispatchers.IO) {
QuickRegistrationRepository.setRestoreMethodForOldDevice(RestoreMethod.DECLINE)
}
}
}
suspend fun performStorageServiceAccountRestoreIfNeeded() {
if (SignalStore.account.restoredAccountEntropyPool || SignalStore.svr.masterKeyForInitialDataRestore != null) {
store.update { it.copy(loadState = ScreenState.LoadState.STORAGE_SERVICE_RESTORE) }
StorageServiceRestore.restore()
}
}
data class ScreenState(
val isRemoteRestoreOnlyOption: Boolean = false,
val backupMediaTTL: Duration = 30.days,
val backupTier: MessageBackupTier? = null,
val backupTime: Long = -1,
val backupSize: ByteSize = 0.bytes,
val importState: ImportState = ImportState.None,
val restoreProgress: RestoreV2Event? = null,
val loadState: LoadState = if (backupTier != null) LoadState.LOADED else LoadState.LOADING,
val loadAttempts: Int = 0
) {
fun isLoaded(): Boolean {
return loadState == LoadState.LOADED
}
enum class LoadState {
LOADING, LOADED, NOT_FOUND, FAILURE, STORAGE_SERVICE_RESTORE
}
}
sealed interface ImportState {
data object None : ImportState
data object InProgress : ImportState
data object Restored : ImportState
data object NetworkFailure : ImportState
data object Failed : ImportState
data object FailureWithLogPrompt : ImportState
}
}

View File

@@ -0,0 +1,49 @@
package org.thoughtcrime.securesms.registration.ui.restore
import android.os.PowerManager
import androidx.activity.ComponentActivity
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import org.thoughtcrime.securesms.util.WakeLockUtil
import kotlin.time.Duration.Companion.minutes
/**
* Holds on to and manages a wake-lock when restoring a remote backup.
*/
class RemoteRestoreWakeLock(
private val activity: ComponentActivity
) : DefaultLifecycleObserver {
companion object {
private val TIMEOUT = 10.minutes.inWholeMilliseconds
}
private var wakeLock: PowerManager.WakeLock? = null
init {
activity.lifecycle.addObserver(this)
}
fun acquire() {
synchronized(this) {
if (wakeLock?.isHeld == true) {
return
}
wakeLock = WakeLockUtil.acquire(activity, PowerManager.PARTIAL_WAKE_LOCK, TIMEOUT, "remoteRestore")
}
}
fun release() {
synchronized(this) {
if (wakeLock?.isHeld == true) {
wakeLock?.release()
wakeLock = null
}
}
}
override fun onPause(owner: LifecycleOwner) {
release()
}
}

View File

@@ -0,0 +1,34 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.restore
import org.thoughtcrime.securesms.R
/**
* Restore methods for various spots in restore flow.
*/
enum class RestoreMethod(val iconRes: Int, val titleRes: Int, val subtitleRes: Int) {
FROM_SIGNAL_BACKUPS(
iconRes = R.drawable.symbol_signal_backups_24,
titleRes = R.string.SelectRestoreMethodFragment__from_signal_backups,
subtitleRes = R.string.SelectRestoreMethodFragment__your_free_or_paid_signal_backup_plan
),
FROM_LOCAL_BACKUP_V1(
iconRes = R.drawable.symbol_file_24,
titleRes = R.string.SelectRestoreMethodFragment__from_a_backup_file,
subtitleRes = R.string.SelectRestoreMethodFragment__choose_a_backup_youve_saved
),
FROM_LOCAL_BACKUP_V2(
iconRes = R.drawable.symbol_folder_24,
titleRes = R.string.SelectRestoreMethodFragment__from_a_backup_folder,
subtitleRes = R.string.SelectRestoreMethodFragment__choose_a_backup_youve_saved
),
FROM_OLD_DEVICE(
iconRes = R.drawable.symbol_transfer_24,
titleRes = R.string.SelectRestoreMethodFragment__from_your_old_phone,
subtitleRes = R.string.SelectRestoreMethodFragment__transfer_directly_from_old
)
}

View File

@@ -0,0 +1,86 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.restore
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
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.draw.clip
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.R
/**
* Renders row-ux used commonly through the restore flows.
*/
@Composable
fun RestoreRow(
icon: Painter,
title: String,
subtitle: String,
onRowClick: () -> Unit = {}
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(bottom = 16.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(18.dp))
.background(SignalTheme.colors.colorSurface2)
.clickable(enabled = true, onClick = onRowClick)
.padding(horizontal = 20.dp, vertical = 22.dp)
) {
Icon(
painter = icon,
tint = MaterialTheme.colorScheme.primary,
contentDescription = null,
modifier = Modifier.size(48.dp)
)
Column(
modifier = Modifier.padding(start = 16.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@SignalPreview
@Composable
private fun RestoreMethodRowPreview() {
Previews.Preview {
RestoreRow(
icon = painterResource(R.drawable.symbol_backup_24),
title = stringResource(R.string.SelectRestoreMethodFragment__from_signal_backups),
subtitle = stringResource(R.string.SelectRestoreMethodFragment__your_free_or_paid_signal_backup_plan)
)
}
}

View File

@@ -0,0 +1,436 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.restore
import android.os.Bundle
import android.view.View
import android.view.WindowManager
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
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.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.fragment.findNavController
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.DropdownMenus
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.registration.proto.RegistrationProvisionMessage
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCode
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
import org.thoughtcrime.securesms.registration.ui.RegistrationViewModel
import org.thoughtcrime.securesms.registration.ui.shared.RegistrationScreen
import org.thoughtcrime.securesms.registration.ui.shared.RegistrationScreenTitleSubtitle
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* Show QR code on new device to allow registration and restore via old device.
*/
class RestoreViaQrFragment : ComposeFragment() {
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
private val viewModel: RestoreViaQrViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onResume(owner: LifecycleOwner) {
requireActivity().window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
override fun onPause(owner: LifecycleOwner) {
requireActivity().window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
}
})
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.CREATED) {
viewModel
.state
.mapNotNull { it.provisioningMessage }
.distinctUntilChanged()
.collect { message ->
if (message.platform == RegistrationProvisionMessage.Platform.ANDROID || message.tier != null) {
sharedViewModel.registerWithBackupKey(requireContext(), message.accountEntropyPool, message.e164, message.pin, message.aciIdentityKeyPair, message.pniIdentityKeyPair)
} else {
findNavController().safeNavigate(RestoreViaQrFragmentDirections.goToNoBackupToRestore())
}
}
}
}
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.CREATED) {
viewModel
.state
.mapNotNull { it.registerAccountResult }
.filter { it !is RegisterAccountResult.Success }
.distinctUntilChanged()
.collect { result ->
when (result) {
is RegisterAccountResult.AttemptsExhausted -> {
findNavController().safeNavigate(RestoreViaQrFragmentDirections.goToAccountLocked())
}
else -> Unit
}
}
}
}
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.CREATED) {
sharedViewModel
.state
.map { it.registerAccountError }
.filterNotNull()
.collect {
sharedViewModel.registerAccountErrorShown()
viewModel.handleRegistrationFailure(it)
}
}
}
}
@Composable
override fun FragmentContent() {
val state by viewModel.state.collectAsState()
RestoreViaQrScreen(
state = state,
onRetryQrCode = viewModel::restart,
onRegistrationErrorDismiss = viewModel::clearRegistrationError,
onCancel = { findNavController().popBackStack() },
onUseProxy = { findNavController().safeNavigate(RestoreViaQrFragmentDirections.goToEditProxy()) }
)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun RestoreViaQrScreen(
state: RestoreViaQrViewModel.RestoreViaQrState,
onRetryQrCode: () -> Unit = {},
onRegistrationErrorDismiss: () -> Unit = {},
onCancel: () -> Unit = {},
onUseProxy: () -> Unit = {}
) {
RegistrationScreen(
menu = {
Box(modifier = Modifier.align(Alignment.End)) {
val controller = remember { DropdownMenus.MenuController() }
IconButton(onClick = { controller.toggle() }) {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.symbol_more_vertical_24),
contentDescription = null
)
}
DropdownMenus.Menu(controller = controller) {
DropdownMenus.Item(
text = { Text(stringResource(R.string.preferences_use_proxy)) },
onClick = {
controller.hide()
onUseProxy()
}
)
}
}
},
topContent = {
RegistrationScreenTitleSubtitle(
title = stringResource(R.string.RestoreViaQr_title),
subtitle = null
)
},
bottomContent = {
TextButton(
onClick = onCancel,
modifier = Modifier.align(Alignment.Center)
) {
Text(text = stringResource(android.R.string.cancel))
}
}
) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(space = 48.dp, alignment = Alignment.CenterHorizontally),
verticalArrangement = Arrangement.spacedBy(space = 48.dp),
modifier = Modifier
.fillMaxWidth()
.horizontalGutters()
) {
Box(
modifier = Modifier
.widthIn(160.dp, 320.dp)
.aspectRatio(1f)
.clip(RoundedCornerShape(24.dp))
.background(SignalTheme.colors.colorSurface5)
.padding(40.dp)
) {
SignalTheme(isDarkMode = false) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surface)
.fillMaxWidth()
.fillMaxHeight()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
AnimatedContent(
targetState = state.qrState,
contentKey = { it::class },
contentAlignment = Alignment.Center,
label = "qr-code-progress",
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
) { qrState ->
when (qrState) {
is RestoreViaQrViewModel.QrState.Loaded -> {
QrCode(
data = qrState.qrData,
foregroundColor = Color(0xFF2449C0),
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
)
}
RestoreViaQrViewModel.QrState.Loading -> {
Box(contentAlignment = Alignment.Center) {
CircularProgressIndicator(modifier = Modifier.size(48.dp))
}
}
is RestoreViaQrViewModel.QrState.Scanned,
RestoreViaQrViewModel.QrState.Failed -> {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
val text = if (state.qrState is RestoreViaQrViewModel.QrState.Scanned) {
stringResource(R.string.RestoreViaQr_qr_code_scanned)
} else {
stringResource(R.string.RestoreViaQr_qr_code_error)
}
Text(
text = text,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Buttons.Small(
onClick = onRetryQrCode
) {
Text(text = stringResource(R.string.RestoreViaQr_retry))
}
}
}
}
}
}
}
}
Column(
modifier = Modifier
.align(alignment = Alignment.CenterVertically)
.widthIn(160.dp, 320.dp)
) {
InstructionRow(
icon = painterResource(R.drawable.symbol_phone_24),
instruction = stringResource(R.string.RestoreViaQr_instruction_1)
)
InstructionRow(
icon = painterResource(R.drawable.symbol_camera_24),
instruction = stringResource(R.string.RestoreViaQr_instruction_2)
)
InstructionRow(
icon = painterResource(R.drawable.symbol_qrcode_24),
instruction = stringResource(R.string.RestoreViaQr_instruction_3)
)
}
}
if (state.isRegistering) {
Dialogs.IndeterminateProgressDialog()
} else if (state.showRegistrationError) {
val message = when (state.registerAccountResult) {
is RegisterAccountResult.IncorrectRecoveryPassword -> stringResource(R.string.RestoreViaQr_registration_error)
is RegisterAccountResult.RateLimited -> stringResource(R.string.RegistrationActivity_you_have_made_too_many_attempts_please_try_again_later)
else -> stringResource(R.string.RegistrationActivity_error_connecting_to_service)
}
Dialogs.SimpleMessageDialog(
message = message,
onDismiss = onRegistrationErrorDismiss,
dismiss = stringResource(android.R.string.ok)
)
}
}
}
@SignalPreview
@Composable
private fun RestoreViaQrScreenPreview() {
Previews.Preview {
RestoreViaQrScreen(
state = RestoreViaQrViewModel.RestoreViaQrState(
qrState = RestoreViaQrViewModel.QrState.Loaded(
QrCodeData.forData("sgnl://rereg?uuid=asdfasdfasdfasdfasdfasdf&pub_key=asdfasdfasdfSDFSsdfsdfSDFSDffd", false)
)
)
)
}
}
@SignalPreview
@Composable
private fun RestoreViaQrScreenLoadingPreview() {
Previews.Preview {
RestoreViaQrScreen(
state = RestoreViaQrViewModel.RestoreViaQrState(qrState = RestoreViaQrViewModel.QrState.Loading)
)
}
}
@SignalPreview
@Composable
private fun RestoreViaQrScreenFailurePreview() {
Previews.Preview {
RestoreViaQrScreen(
state = RestoreViaQrViewModel.RestoreViaQrState(qrState = RestoreViaQrViewModel.QrState.Failed)
)
}
}
@SignalPreview
@Composable
private fun RestoreViaQrScreenScannedPreview() {
Previews.Preview {
RestoreViaQrScreen(
state = RestoreViaQrViewModel.RestoreViaQrState(qrState = RestoreViaQrViewModel.QrState.Scanned)
)
}
}
@SignalPreview
@Composable
private fun RestoreViaQrScreenRegisteringPreview() {
Previews.Preview {
RestoreViaQrScreen(
state = RestoreViaQrViewModel.RestoreViaQrState(isRegistering = true, qrState = RestoreViaQrViewModel.QrState.Scanned)
)
}
}
@SignalPreview
@Composable
private fun RestoreViaQrScreenRegistrationFailedPreview() {
Previews.Preview {
RestoreViaQrScreen(
state = RestoreViaQrViewModel.RestoreViaQrState(isRegistering = false, showRegistrationError = true, qrState = RestoreViaQrViewModel.QrState.Scanned)
)
}
}
@Composable
private fun InstructionRow(
icon: Painter,
instruction: String
) {
Row(
modifier = Modifier
.padding(vertical = 12.dp)
) {
Icon(
painter = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(16.dp))
Text(
text = instruction,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@SignalPreview
@Composable
private fun InstructionRowPreview() {
Previews.Preview {
InstructionRow(
icon = painterResource(R.drawable.symbol_phone_24),
instruction = "Instruction!"
)
}
}

View File

@@ -0,0 +1,204 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.restore
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.signal.core.util.logging.Log
import org.signal.registration.proto.RegistrationProvisionMessage
import org.thoughtcrime.securesms.backup.v2.MessageBackupTier
import org.thoughtcrime.securesms.components.settings.app.usernamelinks.QrCodeData
import org.thoughtcrime.securesms.crypto.IdentityKeyUtil
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
import org.whispersystems.signalservice.api.provisioning.ProvisioningSocket
import org.whispersystems.signalservice.internal.crypto.SecondaryProvisioningCipher
import java.io.Closeable
class RestoreViaQrViewModel : ViewModel() {
companion object {
private val TAG = Log.tag(RestoreViaQrViewModel::class)
}
private val store: MutableStateFlow<RestoreViaQrState> = MutableStateFlow(RestoreViaQrState())
val state: StateFlow<RestoreViaQrState> = store
private var socketHandles: MutableList<Closeable> = mutableListOf()
private var startNewSocketJob: Job? = null
init {
restart()
}
fun restart() {
SignalStore.registration.restoreMethodToken = null
shutdown()
startNewSocket()
startNewSocketJob = viewModelScope.launch(Dispatchers.IO) {
var count = 0
while (count < 5 && isActive) {
delay(ProvisioningSocket.LIFESPAN / 2)
if (isActive) {
startNewSocket()
count++
Log.d(TAG, "Started next websocket count: $count", true)
}
}
}
}
fun handleRegistrationFailure(registerAccountResult: RegisterAccountResult) {
store.update {
if (it.isRegistering) {
Log.w(TAG, "Unable to register [${registerAccountResult::class.simpleName}]", registerAccountResult.getCause(), true)
it.copy(
isRegistering = false,
provisioningMessage = null,
showRegistrationError = true,
registerAccountResult = registerAccountResult
)
} else {
it
}
}
}
fun clearRegistrationError() {
store.update {
it.copy(
showRegistrationError = false,
registerAccountResult = null
)
}
restart()
}
override fun onCleared() {
shutdown()
}
private fun startNewSocket() {
synchronized(socketHandles) {
socketHandles += start()
if (socketHandles.size > 2) {
socketHandles.removeAt(0).close()
}
}
}
private fun shutdown() {
startNewSocketJob?.cancel()
synchronized(socketHandles) {
socketHandles.forEach { it.close() }
socketHandles.clear()
}
}
private fun start(): Closeable {
store.update {
if (it.qrState !is QrState.Loaded) {
it.copy(qrState = QrState.Loading)
} else {
it
}
}
return ProvisioningSocket.start<RegistrationProvisionMessage>(
mode = ProvisioningSocket.Mode.REREG,
identityKeyPair = IdentityKeyUtil.generateIdentityKeyPair(),
configuration = AppDependencies.signalServiceNetworkAccess.getConfiguration(),
handler = { id, t ->
store.update {
if (it.currentSocketId == null || it.currentSocketId == id) {
Log.w(TAG, "Current socket [$id] has failed, stopping automatic connects", t)
shutdown()
it.copy(currentSocketId = null, qrState = QrState.Failed)
} else {
Log.i(TAG, "Old socket [$id] failed, ignoring")
it
}
}
}
) { socket ->
val url = socket.getProvisioningUrl()
store.update {
Log.d(TAG, "Updating QR code with data from [${socket.id}]", true)
it.copy(
currentSocketId = socket.id,
qrState = QrState.Loaded(
qrData = QrCodeData.forData(
data = url,
supportIconOverlay = false
)
)
)
}
val result = socket.getProvisioningMessageDecryptResult()
Log.d(TAG, "Received provisioning message result", true)
if (result is SecondaryProvisioningCipher.ProvisioningDecryptResult.Success) {
Log.i(TAG, "Success! Saving restore method token: ***${result.message.restoreMethodToken.takeLast(4)}", true)
SignalStore.registration.restoreMethodToken = result.message.restoreMethodToken
SignalStore.registration.restoreBackupMediaSize = result.message.backupSizeBytes ?: 0
SignalStore.registration.isOtherDeviceAndroid = result.message.platform == RegistrationProvisionMessage.Platform.ANDROID
SignalStore.backup.lastBackupTime = result.message.backupTimestampMs ?: 0
SignalStore.backup.isBackupTimestampRestored = true
SignalStore.backup.backupTier = when (result.message.tier) {
RegistrationProvisionMessage.Tier.FREE -> MessageBackupTier.FREE
RegistrationProvisionMessage.Tier.PAID -> MessageBackupTier.PAID
null -> null
}
store.update { it.copy(isRegistering = true, provisioningMessage = result.message, qrState = QrState.Scanned) }
shutdown()
} else {
store.update {
if (it.currentSocketId == socket.id) {
it.copy(showProvisioningError = true, qrState = QrState.Scanned)
} else {
it
}
}
}
}
}
data class RestoreViaQrState(
val isRegistering: Boolean = false,
val qrState: QrState = QrState.Loading,
val provisioningMessage: RegistrationProvisionMessage? = null,
val showProvisioningError: Boolean = false,
val showRegistrationError: Boolean = false,
val registerAccountResult: RegisterAccountResult? = null,
val currentSocketId: Int? = null
)
sealed interface QrState {
data object Loading : QrState
data class Loaded(val qrData: QrCodeData) : QrState
data object Failed : QrState
data object Scanned : QrState
}
}

View File

@@ -0,0 +1,71 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.restore
import android.app.Activity
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.registration.ui.RegistrationViewModel
import org.thoughtcrime.securesms.registration.ui.phonenumber.EnterPhoneNumberMode
import org.thoughtcrime.securesms.restore.RestoreActivity
import org.thoughtcrime.securesms.util.navigation.safeNavigate
/**
* Provide options to select restore/transfer operation and flow during manual registration.
*/
class SelectManualRestoreMethodFragment : ComposeFragment() {
companion object {
private val TAG = Log.tag(SelectManualRestoreMethodFragment::class)
}
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
private val localBackupRestore = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
when (val resultCode = result.resultCode) {
Activity.RESULT_OK -> {
sharedViewModel.onBackupSuccessfullyRestored()
findNavController().safeNavigate(SelectManualRestoreMethodFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.NORMAL))
}
Activity.RESULT_CANCELED -> {
Log.w(TAG, "Backup restoration canceled.")
}
else -> Log.w(TAG, "Backup restoration activity ended with unknown result code: $resultCode")
}
}
@Composable
override fun FragmentContent() {
SelectRestoreMethodScreen(
restoreMethods = listOf(RestoreMethod.FROM_SIGNAL_BACKUPS, RestoreMethod.FROM_LOCAL_BACKUP_V1),
onRestoreMethodClicked = this::startRestoreMethod,
onSkip = {
sharedViewModel.skipRestore()
findNavController().safeNavigate(SelectManualRestoreMethodFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.NORMAL))
}
)
}
private fun startRestoreMethod(method: RestoreMethod) {
when (method) {
RestoreMethod.FROM_SIGNAL_BACKUPS -> {
sharedViewModel.intendToRestore(hasOldDevice = false, fromRemote = true)
findNavController().safeNavigate(SelectManualRestoreMethodFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.COLLECT_FOR_MANUAL_SIGNAL_BACKUPS_RESTORE))
}
RestoreMethod.FROM_LOCAL_BACKUP_V1 -> {
sharedViewModel.intendToRestore(hasOldDevice = false, fromRemote = false)
localBackupRestore.launch(RestoreActivity.getLocalRestoreIntent(requireContext()))
}
RestoreMethod.FROM_OLD_DEVICE -> error("Device transfer not supported in manual restore flow")
RestoreMethod.FROM_LOCAL_BACKUP_V2 -> error("Not currently supported")
}
}
}

View File

@@ -0,0 +1,62 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.restore
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.stringResource
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.registration.ui.shared.RegistrationScreen
/**
* Screen showing various restore methods available during quick and manual re-registration.
*/
@Composable
fun SelectRestoreMethodScreen(
restoreMethods: List<RestoreMethod>,
onRestoreMethodClicked: (RestoreMethod) -> Unit = {},
onSkip: () -> Unit = {},
extraContent: @Composable ColumnScope.() -> Unit = {}
) {
RegistrationScreen(
title = stringResource(id = R.string.SelectRestoreMethodFragment__restore_or_transfer_account),
subtitle = stringResource(id = R.string.SelectRestoreMethodFragment__get_your_signal_account),
bottomContent = {
TextButton(
onClick = onSkip,
modifier = Modifier.align(Alignment.Center)
) {
Text(text = stringResource(R.string.registration_activity__skip_restore))
}
}
) {
for (method in restoreMethods) {
RestoreRow(
icon = painterResource(method.iconRes),
title = stringResource(method.titleRes),
subtitle = stringResource(method.subtitleRes),
onRowClick = { onRestoreMethodClicked(method) }
)
}
extraContent()
}
}
@SignalPreview
@Composable
private fun SelectRestoreMethodScreenPreview() {
SignalTheme {
SelectRestoreMethodScreen(listOf(RestoreMethod.FROM_SIGNAL_BACKUPS, RestoreMethod.FROM_OLD_DEVICE, RestoreMethod.FROM_LOCAL_BACKUP_V1))
}
}

View File

@@ -0,0 +1,59 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.restore
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.signal.core.util.Stopwatch
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.enqueueBlocking
import org.thoughtcrime.securesms.jobmanager.runJobBlocking
import org.thoughtcrime.securesms.jobs.ProfileUploadJob
import org.thoughtcrime.securesms.jobs.ReclaimUsernameAndLinkJob
import org.thoughtcrime.securesms.jobs.StorageAccountRestoreJob
import org.thoughtcrime.securesms.jobs.StorageSyncJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.registration.data.RegistrationRepository
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
object StorageServiceRestore {
private val TAG = Log.tag(StorageServiceRestore::class)
/**
* Restore account data from Storage Service in a quasi-blocking manner. Uses existing jobs
* to perform the restore but will not wait indefinitely for them to finish so may return prior
* to completing the restore.
*/
suspend fun restore() {
withContext(Dispatchers.IO) {
val stopwatch = Stopwatch("storage-service-restore")
SignalStore.storageService.needsAccountRestore = false
AppDependencies.jobManager.runJobBlocking(StorageAccountRestoreJob(), StorageAccountRestoreJob.LIFESPAN.milliseconds)
stopwatch.split("account-restore")
AppDependencies
.jobManager
.startChain(StorageSyncJob.forAccountRestore())
.then(ReclaimUsernameAndLinkJob())
.enqueueBlocking(10.seconds)
stopwatch.split("storage-sync-restore")
stopwatch.stop(TAG)
val isMissingProfileData = RegistrationRepository.isMissingProfileData()
RegistrationUtil.maybeMarkRegistrationComplete()
if (!isMissingProfileData) {
AppDependencies.jobManager.add(ProfileUploadJob())
}
}
}
}

View File

@@ -0,0 +1,207 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.shared
import android.content.Intent
import android.widget.Toast
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.ui.compose.horizontalGutters
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.logsubmit.SubmitDebugLogActivity
private const val TAP_TARGET = 8
/**
* A base framework for rendering the various v3 registration screens.
*/
@Composable
fun RegistrationScreen(
title: String,
subtitle: String,
bottomContent: @Composable (BoxScope.() -> Unit),
mainContent: @Composable ColumnScope.() -> Unit
) {
RegistrationScreen(title, AnnotatedString(subtitle), bottomContent, mainContent)
}
/**
* A base framework for rendering the various v3 registration screens.
*/
@Composable
fun RegistrationScreen(
title: String,
subtitle: AnnotatedString?,
bottomContent: @Composable BoxScope.() -> Unit,
mainContent: @Composable ColumnScope.() -> Unit
) {
RegistrationScreen(
menu = null,
topContent = { RegistrationScreenTitleSubtitle(title, subtitle) },
bottomContent = bottomContent,
mainContent = mainContent
)
}
@Composable
fun RegistrationScreenTitleSubtitle(
title: String,
subtitle: AnnotatedString?
) {
Text(
text = title,
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.fillMaxWidth()
)
if (subtitle != null) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 16.dp)
)
}
Spacer(modifier = Modifier.height(40.dp))
}
/**
* A base framework for rendering the various v3 registration screens.
*/
@Composable
fun RegistrationScreen(
menu: @Composable (ColumnScope.() -> Unit)?,
topContent: @Composable ColumnScope.() -> Unit,
bottomContent: @Composable BoxScope.() -> Unit,
mainContent: @Composable ColumnScope.() -> Unit
) {
Surface {
Column(
verticalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
) {
val scrollState = rememberScrollState()
val context = LocalContext.current
var titleTapCount by remember { mutableIntStateOf(0) }
var previousToast by remember { mutableStateOf<Toast?>(null) }
Column(
modifier = Modifier
.verticalScroll(scrollState)
.weight(weight = 1f, fill = false)
.padding(bottom = 16.dp)
.horizontalGutters()
) {
if (menu != null) {
menu()
} else {
Spacer(Modifier.height(40.dp))
}
Column(
modifier = Modifier
.fillMaxWidth()
.clickable {
titleTapCount++
if (titleTapCount >= TAP_TARGET) {
context.startActivity(Intent(context, SubmitDebugLogActivity::class.java))
previousToast?.cancel()
previousToast = null
} else {
val remaining = TAP_TARGET - titleTapCount
previousToast?.cancel()
previousToast = Toast.makeText(context, context.resources.getQuantityString(R.plurals.RegistrationActivity_debug_log_hint, remaining, remaining), Toast.LENGTH_SHORT).apply { show() }
}
}
) {
topContent()
}
mainContent()
}
Surface(
shadowElevation = if (scrollState.canScrollForward) 8.dp else 0.dp,
modifier = Modifier.fillMaxWidth()
) {
Box(
modifier = Modifier
.padding(top = 8.dp, bottom = 24.dp)
.horizontalGutters()
) {
bottomContent()
}
}
}
}
}
@SignalPreview
@Composable
private fun RegistrationScreenPreview() {
Previews.Preview {
RegistrationScreen(
title = "Title",
subtitle = "Subtitle",
bottomContent = {
TextButton(onClick = {}) {
Text("Bottom Button")
}
}
) {
Text("Main content")
}
}
}
@SignalPreview
@Composable
private fun RegistrationScreenNoTitlePreview() {
Previews.Preview {
RegistrationScreen(
menu = null,
topContent = { Text("Top content") },
bottomContent = {
TextButton(onClick = {}) {
Text("Bottom Button")
}
}
) {
Text("Main content")
}
}
}

View File

@@ -0,0 +1,164 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.welcome
import android.content.DialogInterface
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
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.draw.clip
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.fragment.app.setFragmentResult
import org.signal.core.ui.compose.BottomSheets
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.ui.compose.horizontalGutters
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
/**
* Restore flow starting bottom sheet that allows user to progress through quick restore or manual restore flows
* from the Welcome screen.
*/
class RestoreWelcomeBottomSheet : ComposeBottomSheetDialogFragment() {
private var result: WelcomeUserSelection = WelcomeUserSelection.CONTINUE
companion object {
const val REQUEST_KEY = "RestoreWelcomeBottomSheet"
}
@Composable
override fun SheetContent() {
Sheet(
onHasOldPhone = {
result = WelcomeUserSelection.RESTORE_WITH_OLD_PHONE
dismissAllowingStateLoss()
},
onNoPhone = {
result = WelcomeUserSelection.RESTORE_WITH_NO_PHONE
dismissAllowingStateLoss()
}
)
}
override fun onDismiss(dialog: DialogInterface) {
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to result))
super.onDismiss(dialog)
}
}
@Composable
private fun Sheet(
onHasOldPhone: () -> Unit = {},
onNoPhone: () -> Unit = {}
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
.padding(bottom = 54.dp)
) {
BottomSheets.Handle()
val context = LocalContext.current
Spacer(modifier = Modifier.size(26.dp))
RestoreActionRow(
icon = painterResource(R.drawable.symbol_qrcode_24),
title = stringResource(R.string.WelcomeFragment_restore_action_i_have_my_old_phone),
subtitle = stringResource(R.string.WelcomeFragment_restore_action_scan_qr),
onRowClick = onHasOldPhone
)
RestoreActionRow(
icon = painterResource(R.drawable.symbol_no_phone_44),
title = stringResource(R.string.WelcomeFragment_restore_action_i_dont_have_my_old_phone),
subtitle = stringResource(R.string.WelcomeFragment_restore_action_reinstalling),
onRowClick = onNoPhone
)
}
}
@Composable
@SignalPreview
private fun SheetPreview() {
Previews.BottomSheetPreview {
Sheet()
}
}
@Composable
fun RestoreActionRow(
icon: Painter,
title: String,
subtitle: String,
onRowClick: () -> Unit = {}
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.horizontalGutters()
.padding(vertical = 8.dp)
.fillMaxWidth()
.clip(RoundedCornerShape(18.dp))
.background(MaterialTheme.colorScheme.background)
.clickable(enabled = true, onClick = onRowClick)
.padding(horizontal = 24.dp, vertical = 16.dp)
) {
Icon(
painter = icon,
tint = MaterialTheme.colorScheme.primary,
contentDescription = null,
modifier = Modifier.size(44.dp)
)
Column(
modifier = Modifier.padding(start = 16.dp)
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@SignalPreview
@Composable
private fun RestoreActionRowPreview() {
Previews.Preview {
RestoreActionRow(
icon = painterResource(R.drawable.symbol_qrcode_24),
title = stringResource(R.string.WelcomeFragment_restore_action_i_have_my_old_phone),
subtitle = stringResource(R.string.WelcomeFragment_restore_action_scan_qr)
)
}
}

View File

@@ -0,0 +1,161 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.welcome
import android.content.pm.PackageManager
import android.os.Bundle
import android.view.View
import androidx.core.content.ContextCompat
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.util.getSerializableCompat
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.LoggingFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.databinding.FragmentRegistrationWelcomeV3Binding
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView
import org.thoughtcrime.securesms.registration.fragments.WelcomePermissions
import org.thoughtcrime.securesms.registration.ui.RegistrationCheckpoint
import org.thoughtcrime.securesms.registration.ui.RegistrationViewModel
import org.thoughtcrime.securesms.registration.ui.permissions.GrantPermissionsFragment
import org.thoughtcrime.securesms.registration.ui.phonenumber.EnterPhoneNumberMode
import org.thoughtcrime.securesms.util.BackupUtil
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.thoughtcrime.securesms.util.visible
/**
* First screen that is displayed on the very first app launch.
*/
class WelcomeFragment : LoggingFragment(R.layout.fragment_registration_welcome_v3) {
companion object {
private val TAG = Log.tag(WelcomeFragment::class.java)
private const val TERMS_AND_CONDITIONS_URL = "https://signal.org/legal"
}
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
private val binding: FragmentRegistrationWelcomeV3Binding by ViewBinderDelegate(FragmentRegistrationWelcomeV3Binding::bind)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setDebugLogSubmitMultiTapView(binding.image)
setDebugLogSubmitMultiTapView(binding.title)
binding.welcomeContinueButton.setOnClickListener { onContinueClicked() }
binding.welcomeTermsButton.setOnClickListener { onTermsClicked() }
binding.welcomeTransferOrRestore.setOnClickListener { onRestoreOrTransferClicked() }
binding.welcomeTransferOrRestore.visible = !sharedViewModel.isReregister
if (BuildConfig.LINK_DEVICE_UX_ENABLED) {
binding.image.setOnLongClickListener {
MaterialAlertDialogBuilder(requireContext())
.setMessage("Link device?")
.setPositiveButton("Link", { _, _ -> onLinkDeviceClicked() })
.setNegativeButton(android.R.string.cancel, null)
.show()
true
}
}
childFragmentManager.setFragmentResultListener(RestoreWelcomeBottomSheet.REQUEST_KEY, viewLifecycleOwner) { requestKey, bundle ->
if (requestKey == RestoreWelcomeBottomSheet.REQUEST_KEY) {
when (val userSelection = bundle.getSerializableCompat(RestoreWelcomeBottomSheet.REQUEST_KEY, WelcomeUserSelection::class.java)) {
WelcomeUserSelection.RESTORE_WITH_OLD_PHONE,
WelcomeUserSelection.RESTORE_WITH_NO_PHONE -> afterRestoreOrTransferClicked(userSelection)
else -> Unit
}
}
}
if (Permissions.isRuntimePermissionsRequired()) {
parentFragmentManager.setFragmentResultListener(GrantPermissionsFragment.REQUEST_KEY, viewLifecycleOwner) { requestKey, bundle ->
if (requestKey == GrantPermissionsFragment.REQUEST_KEY) {
when (val userSelection = bundle.getSerializableCompat(GrantPermissionsFragment.REQUEST_KEY, WelcomeUserSelection::class.java)) {
WelcomeUserSelection.RESTORE_WITH_OLD_PHONE,
WelcomeUserSelection.RESTORE_WITH_NO_PHONE -> navigateToNextScreenViaRestore(userSelection)
WelcomeUserSelection.CONTINUE -> navigateToNextScreenViaContinue()
WelcomeUserSelection.LINK -> navigateToLinkDevice()
null -> Unit
}
}
}
}
}
private fun onLinkDeviceClicked() {
if (Permissions.isRuntimePermissionsRequired() && !hasAllPermissions()) {
findNavController().safeNavigate(WelcomeFragmentDirections.actionWelcomeFragmentToGrantPermissionsFragment(WelcomeUserSelection.LINK))
} else {
navigateToLinkDevice()
}
}
private fun navigateToLinkDevice() {
findNavController().safeNavigate(WelcomeFragmentDirections.goToLinkViaQr())
}
override fun onResume() {
super.onResume()
sharedViewModel.resetRestoreDecision()
}
private fun onContinueClicked() {
if (Permissions.isRuntimePermissionsRequired() && !hasAllPermissions()) {
findNavController().safeNavigate(WelcomeFragmentDirections.actionWelcomeFragmentToGrantPermissionsFragment(WelcomeUserSelection.CONTINUE))
} else {
navigateToNextScreenViaContinue()
}
}
private fun navigateToNextScreenViaContinue() {
sharedViewModel.maybePrefillE164(requireContext())
findNavController().safeNavigate(WelcomeFragmentDirections.goToEnterPhoneNumber(EnterPhoneNumberMode.NORMAL))
}
private fun onTermsClicked() {
CommunicationActions.openBrowserLink(requireContext(), TERMS_AND_CONDITIONS_URL)
}
private fun onRestoreOrTransferClicked() {
RestoreWelcomeBottomSheet().show(childFragmentManager, null)
}
private fun afterRestoreOrTransferClicked(userSelection: WelcomeUserSelection) {
if (Permissions.isRuntimePermissionsRequired() && !hasAllPermissions()) {
findNavController().safeNavigate(WelcomeFragmentDirections.actionWelcomeFragmentToGrantPermissionsFragment(userSelection))
} else {
navigateToNextScreenViaRestore(userSelection)
}
}
private fun navigateToNextScreenViaRestore(userSelection: WelcomeUserSelection) {
sharedViewModel.maybePrefillE164(requireContext())
sharedViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.PERMISSIONS_GRANTED)
when (userSelection) {
WelcomeUserSelection.LINK,
WelcomeUserSelection.CONTINUE -> throw IllegalArgumentException()
WelcomeUserSelection.RESTORE_WITH_OLD_PHONE -> {
sharedViewModel.intendToRestore(hasOldDevice = true, fromRemote = true)
findNavController().safeNavigate(WelcomeFragmentDirections.goToRestoreViaQr())
}
WelcomeUserSelection.RESTORE_WITH_NO_PHONE -> {
sharedViewModel.intendToRestore(hasOldDevice = false, fromRemote = true)
findNavController().safeNavigate(WelcomeFragmentDirections.goToSelectRestoreMethod(userSelection))
}
}
}
private fun hasAllPermissions(): Boolean {
val isUserSelectionRequired = BackupUtil.isUserSelectionRequired(requireContext())
return WelcomePermissions.getWelcomePermissions(isUserSelectionRequired).all { ContextCompat.checkSelfPermission(requireContext(), it) == PackageManager.PERMISSION_GRANTED }
}
}

View File

@@ -0,0 +1,13 @@
/*
* Copyright 2024 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.ui.welcome
/**
* User options available to start registration flow.
*/
enum class WelcomeUserSelection {
CONTINUE, RESTORE_WITH_OLD_PHONE, RESTORE_WITH_NO_PHONE, LINK
}