App ability to regV5 in the main app, behind compile flag.

This commit is contained in:
Greyson Parrelli
2026-04-03 14:20:09 -04:00
parent 0e11a1fe3e
commit 0e8dedf4d0
18 changed files with 1133 additions and 72 deletions
@@ -110,6 +110,7 @@ import org.thoughtcrime.securesms.util.AppForegroundObserver;
import org.thoughtcrime.securesms.util.AppStartup;
import org.thoughtcrime.securesms.util.DeviceProperties;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.Environment;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
import org.thoughtcrime.securesms.util.SignalUncaughtExceptionHandler;
@@ -403,6 +404,20 @@ public class ApplicationContext extends Application implements AppForegroundObse
AppDependencies.init(this, new ApplicationDependencyProvider(this));
}
AppForegroundObserver.begin();
if (Environment.USE_NEW_REGISTRATION) {
initializeRegistrationDependencies();
}
}
private void initializeRegistrationDependencies() {
org.signal.registration.RegistrationDependencies.Companion.provide(
new org.signal.registration.RegistrationDependencies(
new org.thoughtcrime.securesms.registration.v2.AppRegistrationNetworkController(this, AppDependencies.getPushServiceSocket()),
new org.thoughtcrime.securesms.registration.v2.AppRegistrationStorageController(this),
null
)
);
}
private void initializeFirstEverAppLaunch() {
@@ -30,6 +30,7 @@ import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity;
import org.thoughtcrime.securesms.push.SignalServiceNetworkAccess;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.registration.ui.RegistrationActivity;
import org.thoughtcrime.securesms.util.Environment;
import org.thoughtcrime.securesms.restore.RestoreActivity;
import org.thoughtcrime.securesms.service.KeyCachingService;
import org.thoughtcrime.securesms.util.AppForegroundObserver;
@@ -134,8 +135,12 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
Intent intent = getIntentForState(applicationState);
if (intent != null) {
Log.d(TAG, "routeApplicationState(), intent: " + intent.getComponent());
startActivity(intent);
finish();
if (applicationState == STATE_WELCOME_PUSH_SCREEN && Environment.USE_NEW_REGISTRATION) {
startActivity(intent);
} else {
startActivity(intent);
finish();
}
}
}
@@ -221,7 +226,11 @@ public abstract class PassphraseRequiredActivity extends BaseActivity implements
}
private Intent getPushRegistrationIntent() {
return RegistrationActivity.newIntentForNewRegistration(this, getIntent());
if (Environment.USE_NEW_REGISTRATION) {
return org.signal.registration.RegistrationActivity.createIntent(this);
} else {
return RegistrationActivity.newIntentForNewRegistration(this, getIntent());
}
}
private Intent getEnterSignalPinIntent() {
@@ -344,6 +344,10 @@ object AppDependencies {
val linkDeviceApi: LinkDeviceApi
get() = networkModule.linkDeviceApi
@JvmStatic
val pushServiceSocket: PushServiceSocket
get() = networkModule.pushServiceSocket
@JvmStatic
val registrationApi: RegistrationApi
get() = networkModule.registrationApi
@@ -0,0 +1,658 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.v2
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import org.signal.core.models.MasterKey
import org.signal.core.util.logging.Log
import org.signal.libsignal.protocol.IdentityKey
import org.signal.libsignal.protocol.IdentityKeyPair
import org.signal.libsignal.protocol.ecc.ECPrivateKey
import org.signal.registration.NetworkController
import org.signal.registration.NetworkController.AccountAttributes
import org.signal.registration.NetworkController.BackupMasterKeyError
import org.signal.registration.NetworkController.CheckSvrCredentialsError
import org.signal.registration.NetworkController.CheckSvrCredentialsResponse
import org.signal.registration.NetworkController.CreateSessionError
import org.signal.registration.NetworkController.GetSessionStatusError
import org.signal.registration.NetworkController.GetSvrCredentialsError
import org.signal.registration.NetworkController.PreKeyCollection
import org.signal.registration.NetworkController.ProvisioningEvent
import org.signal.registration.NetworkController.ProvisioningMessage
import org.signal.registration.NetworkController.RegisterAccountError
import org.signal.registration.NetworkController.RegisterAccountResponse
import org.signal.registration.NetworkController.RegistrationLockResponse
import org.signal.registration.NetworkController.RegistrationNetworkResult
import org.signal.registration.NetworkController.RequestVerificationCodeError
import org.signal.registration.NetworkController.RestoreMasterKeyError
import org.signal.registration.NetworkController.SessionMetadata
import org.signal.registration.NetworkController.SetAccountAttributesError
import org.signal.registration.NetworkController.SetRegistrationLockError
import org.signal.registration.NetworkController.SubmitVerificationCodeError
import org.signal.registration.NetworkController.SvrCredentials
import org.signal.registration.NetworkController.ThirdPartyServiceErrorResponse
import org.signal.registration.NetworkController.UpdateSessionError
import org.signal.registration.NetworkController.VerificationCodeTransport
import org.signal.registration.proto.RegistrationProvisionMessage
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.gcm.FcmUtil
import org.thoughtcrime.securesms.jobs.ResetSvrGuessCountJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.net.SignalNetwork
import org.thoughtcrime.securesms.pin.SvrRepository
import org.thoughtcrime.securesms.pin.SvrWrongPinException
import org.thoughtcrime.securesms.registration.fcm.PushChallengeRequest
import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.SvrNoDataException
import org.whispersystems.signalservice.api.provisioning.ProvisioningSocket
import org.whispersystems.signalservice.api.svr.SecureValueRecovery.BackupResponse
import org.whispersystems.signalservice.internal.crypto.SecondaryProvisioningCipher
import org.whispersystems.signalservice.internal.push.AuthCredentials
import org.whispersystems.signalservice.internal.push.PushServiceSocket
import java.io.IOException
import java.util.Locale
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import org.whispersystems.signalservice.api.account.AccountAttributes as ServiceAccountAttributes
import org.whispersystems.signalservice.api.account.PreKeyCollection as ServicePreKeyCollection
/**
* Implementation of [NetworkController] that bridges to the app's existing network infrastructure.
*/
class AppRegistrationNetworkController(
private val context: Context,
private val pushServiceSocket: PushServiceSocket
) : NetworkController {
companion object {
private val TAG = Log.tag(AppRegistrationNetworkController::class)
private val PUSH_REQUEST_TIMEOUT = 5.seconds.inWholeMilliseconds
}
private val json = Json { ignoreUnknownKeys = true }
override suspend fun createSession(
e164: String,
fcmToken: String?,
mcc: String?,
mnc: String?
): RegistrationNetworkResult<SessionMetadata, CreateSessionError> = withContext(Dispatchers.IO) {
try {
pushServiceSocket.createVerificationSessionV2(e164, fcmToken, mcc, mnc).use { response ->
when (response.code) {
200 -> {
val session = json.decodeFromString<SessionMetadata>(response.body.string())
RegistrationNetworkResult.Success(session)
}
422 -> {
RegistrationNetworkResult.Failure(CreateSessionError.InvalidRequest(response.body.string()))
}
429 -> {
RegistrationNetworkResult.Failure(CreateSessionError.RateLimited(response.retryAfter()))
}
else -> {
RegistrationNetworkResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}, body: ${response.body.string()}"))
}
}
}
} catch (e: IOException) {
RegistrationNetworkResult.NetworkError(e)
} catch (e: Exception) {
RegistrationNetworkResult.ApplicationError(e)
}
}
override suspend fun getSession(sessionId: String): RegistrationNetworkResult<SessionMetadata, GetSessionStatusError> = withContext(Dispatchers.IO) {
try {
pushServiceSocket.getSessionStatusV2(sessionId).use { response ->
when (response.code) {
200 -> {
val session = json.decodeFromString<SessionMetadata>(response.body.string())
RegistrationNetworkResult.Success(session)
}
400 -> {
RegistrationNetworkResult.Failure(GetSessionStatusError.InvalidRequest(response.body.string()))
}
404 -> {
RegistrationNetworkResult.Failure(GetSessionStatusError.SessionNotFound(response.body.string()))
}
422 -> {
RegistrationNetworkResult.Failure(GetSessionStatusError.InvalidSessionId(response.body.string()))
}
else -> {
RegistrationNetworkResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}, body: ${response.body.string()}"))
}
}
}
} catch (e: IOException) {
RegistrationNetworkResult.NetworkError(e)
} catch (e: Exception) {
RegistrationNetworkResult.ApplicationError(e)
}
}
override suspend fun updateSession(
sessionId: String?,
pushChallengeToken: String?,
captchaToken: String?
): RegistrationNetworkResult<SessionMetadata, UpdateSessionError> = withContext(Dispatchers.IO) {
try {
pushServiceSocket.patchVerificationSessionV2(
sessionId,
null,
null,
null,
captchaToken,
pushChallengeToken
).use { response ->
when (response.code) {
200 -> {
val session = json.decodeFromString<SessionMetadata>(response.body.string())
RegistrationNetworkResult.Success(session)
}
400 -> {
RegistrationNetworkResult.Failure(UpdateSessionError.InvalidRequest(response.body.string()))
}
409 -> {
RegistrationNetworkResult.Failure(UpdateSessionError.RejectedUpdate(response.body.string()))
}
429 -> {
val session = json.decodeFromString<SessionMetadata>(response.body.string())
RegistrationNetworkResult.Failure(UpdateSessionError.RateLimited(response.retryAfter(), session))
}
else -> {
RegistrationNetworkResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}, body: ${response.body.string()}"))
}
}
}
} catch (e: IOException) {
RegistrationNetworkResult.NetworkError(e)
} catch (e: Exception) {
RegistrationNetworkResult.ApplicationError(e)
}
}
override suspend fun requestVerificationCode(
sessionId: String,
locale: Locale?,
androidSmsRetrieverSupported: Boolean,
transport: VerificationCodeTransport
): RegistrationNetworkResult<SessionMetadata, RequestVerificationCodeError> = withContext(Dispatchers.IO) {
try {
val socketTransport = when (transport) {
VerificationCodeTransport.SMS -> PushServiceSocket.VerificationCodeTransport.SMS
VerificationCodeTransport.VOICE -> PushServiceSocket.VerificationCodeTransport.VOICE
}
pushServiceSocket.requestVerificationCodeV2(
sessionId,
locale,
androidSmsRetrieverSupported,
socketTransport
).use { response ->
when (response.code) {
200 -> {
val session = json.decodeFromString<SessionMetadata>(response.body.string())
RegistrationNetworkResult.Success(session)
}
400 -> {
RegistrationNetworkResult.Failure(RequestVerificationCodeError.InvalidSessionId(response.body.string()))
}
404 -> {
RegistrationNetworkResult.Failure(RequestVerificationCodeError.SessionNotFound(response.body.string()))
}
409 -> {
val session = json.decodeFromString<SessionMetadata>(response.body.string())
RegistrationNetworkResult.Failure(RequestVerificationCodeError.MissingRequestInformationOrAlreadyVerified(session))
}
418 -> {
val session = json.decodeFromString<SessionMetadata>(response.body.string())
RegistrationNetworkResult.Failure(RequestVerificationCodeError.CouldNotFulfillWithRequestedTransport(session))
}
429 -> {
val session = json.decodeFromString<SessionMetadata>(response.body.string())
RegistrationNetworkResult.Failure(RequestVerificationCodeError.RateLimited(response.retryAfter(), session))
}
440 -> {
val errorBody = json.decodeFromString<ThirdPartyServiceErrorResponse>(response.body.string())
RegistrationNetworkResult.Failure(RequestVerificationCodeError.ThirdPartyServiceError(errorBody))
}
else -> {
RegistrationNetworkResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}, body: ${response.body.string()}"))
}
}
}
} catch (e: IOException) {
RegistrationNetworkResult.NetworkError(e)
} catch (e: Exception) {
RegistrationNetworkResult.ApplicationError(e)
}
}
override suspend fun submitVerificationCode(
sessionId: String,
verificationCode: String
): RegistrationNetworkResult<SessionMetadata, SubmitVerificationCodeError> = withContext(Dispatchers.IO) {
try {
pushServiceSocket.submitVerificationCodeV2(sessionId, verificationCode).use { response ->
when (response.code) {
200 -> {
val session = json.decodeFromString<SessionMetadata>(response.body.string())
RegistrationNetworkResult.Success(session)
}
400 -> {
RegistrationNetworkResult.Failure(SubmitVerificationCodeError.InvalidSessionIdOrVerificationCode(response.body.string()))
}
404 -> {
RegistrationNetworkResult.Failure(SubmitVerificationCodeError.SessionNotFound(response.body.string()))
}
409 -> {
val session = json.decodeFromString<SessionMetadata>(response.body.string())
RegistrationNetworkResult.Failure(SubmitVerificationCodeError.SessionAlreadyVerifiedOrNoCodeRequested(session))
}
429 -> {
val session = json.decodeFromString<SessionMetadata>(response.body.string())
RegistrationNetworkResult.Failure(SubmitVerificationCodeError.RateLimited(response.retryAfter(), session))
}
else -> {
RegistrationNetworkResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}, body: ${response.body.string()}"))
}
}
}
} catch (e: IOException) {
RegistrationNetworkResult.NetworkError(e)
} catch (e: Exception) {
RegistrationNetworkResult.ApplicationError(e)
}
}
override suspend fun registerAccount(
e164: String,
password: String,
sessionId: String?,
recoveryPassword: String?,
attributes: AccountAttributes,
aciPreKeys: PreKeyCollection,
pniPreKeys: PreKeyCollection,
fcmToken: String?,
skipDeviceTransfer: Boolean
): RegistrationNetworkResult<RegisterAccountResponse, RegisterAccountError> = withContext(Dispatchers.IO) {
check(sessionId != null || recoveryPassword != null) { "Either sessionId or recoveryPassword must be provided" }
check(sessionId == null || recoveryPassword == null) { "Either sessionId or recoveryPassword must be provided, but not both" }
try {
pushServiceSocket.submitRegistrationRequestV2(
e164,
password,
sessionId,
recoveryPassword,
attributes.toServiceAccountAttributes(),
aciPreKeys.toServicePreKeyCollection(),
pniPreKeys.toServicePreKeyCollection(),
fcmToken,
skipDeviceTransfer
).use { response ->
when (response.code) {
200 -> {
val result = json.decodeFromString<RegisterAccountResponse>(response.body.string())
RegistrationNetworkResult.Success(result)
}
401 -> {
RegistrationNetworkResult.Failure(RegisterAccountError.SessionNotFoundOrNotVerified(response.body.string()))
}
403 -> {
RegistrationNetworkResult.Failure(RegisterAccountError.RegistrationRecoveryPasswordIncorrect(response.body.string()))
}
409 -> {
RegistrationNetworkResult.Failure(RegisterAccountError.DeviceTransferPossible)
}
422 -> {
RegistrationNetworkResult.Failure(RegisterAccountError.InvalidRequest(response.body.string()))
}
423 -> {
val lockResponse = json.decodeFromString<RegistrationLockResponse>(response.body.string())
RegistrationNetworkResult.Failure(RegisterAccountError.RegistrationLock(lockResponse))
}
429 -> {
RegistrationNetworkResult.Failure(RegisterAccountError.RateLimited(response.retryAfter()))
}
else -> {
RegistrationNetworkResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}, body: ${response.body.string()}"))
}
}
}
} catch (e: IOException) {
RegistrationNetworkResult.NetworkError(e)
} catch (e: Exception) {
RegistrationNetworkResult.ApplicationError(e)
}
}
override suspend fun getFcmToken(): String? {
return try {
FcmUtil.getToken(context).orElse(null)
} catch (e: Exception) {
Log.w(TAG, "Failed to get FCM token", e)
null
}
}
override suspend fun awaitPushChallengeToken(): String? = withContext(Dispatchers.IO) {
try {
val latch = java.util.concurrent.CountDownLatch(1)
val challenge = java.util.concurrent.atomic.AtomicReference<String>()
val subscriber = object {
@org.greenrobot.eventbus.Subscribe(threadMode = org.greenrobot.eventbus.ThreadMode.POSTING)
fun onChallengeEvent(event: PushChallengeRequest.PushChallengeEvent) {
challenge.set(event.challenge)
latch.countDown()
}
}
val eventBus = org.greenrobot.eventbus.EventBus.getDefault()
eventBus.register(subscriber)
try {
latch.await(PUSH_REQUEST_TIMEOUT, java.util.concurrent.TimeUnit.MILLISECONDS)
challenge.get()
} finally {
eventBus.unregister(subscriber)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to await push challenge token", e)
null
}
}
override fun getCaptchaUrl(): String {
return BuildConfig.SIGNAL_CAPTCHA_URL
}
override suspend fun restoreMasterKeyFromSvr(
svrCredentials: SvrCredentials,
pin: String
): RegistrationNetworkResult<NetworkController.MasterKeyResponse, RestoreMasterKeyError> = withContext(Dispatchers.IO) {
try {
val authCredentials = AuthCredentials.create(svrCredentials.username, svrCredentials.password)
val credentialSet = SvrAuthCredentialSet(svr2Credentials = authCredentials, svr3Credentials = null)
val masterKey = SvrRepository.restoreMasterKeyPreRegistration(credentialSet, pin)
RegistrationNetworkResult.Success(NetworkController.MasterKeyResponse(masterKey))
} catch (e: SvrWrongPinException) {
RegistrationNetworkResult.Failure(RestoreMasterKeyError.WrongPin(e.triesRemaining))
} catch (e: SvrNoDataException) {
RegistrationNetworkResult.Failure(RestoreMasterKeyError.NoDataFound)
} catch (e: IOException) {
RegistrationNetworkResult.NetworkError(e)
} catch (e: Exception) {
RegistrationNetworkResult.ApplicationError(e)
}
}
override suspend fun setPinAndMasterKeyOnSvr(
pin: String,
masterKey: MasterKey
): RegistrationNetworkResult<SvrCredentials?, BackupMasterKeyError> = withContext(Dispatchers.IO) {
try {
val svr2 = AppDependencies.signalServiceAccountManager.getSecureValueRecoveryV2(BuildConfig.SVR2_MRENCLAVE)
val session = svr2.setPin(pin, masterKey)
when (val response = session.execute()) {
is BackupResponse.Success -> {
RegistrationNetworkResult.Success(SvrCredentials(response.authorization.username(), response.authorization.password()))
}
is BackupResponse.EnclaveNotFound -> {
RegistrationNetworkResult.Failure(BackupMasterKeyError.EnclaveNotFound)
}
is BackupResponse.ExposeFailure -> {
RegistrationNetworkResult.Success(null)
}
is BackupResponse.NetworkError -> {
RegistrationNetworkResult.NetworkError(response.exception)
}
is BackupResponse.ApplicationError -> {
RegistrationNetworkResult.ApplicationError(response.exception)
}
is BackupResponse.ServerRejected -> {
RegistrationNetworkResult.NetworkError(IOException("Server rejected backup request"))
}
is BackupResponse.RateLimited -> {
RegistrationNetworkResult.NetworkError(IOException("Rate limited"))
}
}
} catch (e: IOException) {
RegistrationNetworkResult.NetworkError(e)
} catch (e: Exception) {
RegistrationNetworkResult.ApplicationError(e)
}
}
override suspend fun enqueueSvrGuessResetJob() {
AppDependencies.jobManager.add(ResetSvrGuessCountJob())
}
override suspend fun enableRegistrationLock(): RegistrationNetworkResult<Unit, SetRegistrationLockError> = withContext(Dispatchers.IO) {
val masterKey = SignalStore.svr.masterKey
if (masterKey == null) {
return@withContext RegistrationNetworkResult.Failure(SetRegistrationLockError.NoPinSet)
}
when (val result = SignalNetwork.account.enableRegistrationLock(masterKey.deriveRegistrationLock())) {
is NetworkResult.Success -> RegistrationNetworkResult.Success(Unit)
is NetworkResult.StatusCodeError -> {
when (result.code) {
401 -> RegistrationNetworkResult.Failure(SetRegistrationLockError.Unauthorized)
422 -> RegistrationNetworkResult.Failure(SetRegistrationLockError.InvalidRequest(result.toString()))
else -> RegistrationNetworkResult.ApplicationError(IllegalStateException("Unexpected response code: ${result.code}"))
}
}
is NetworkResult.NetworkError -> RegistrationNetworkResult.NetworkError(result.exception)
is NetworkResult.ApplicationError -> RegistrationNetworkResult.ApplicationError(result.throwable)
}
}
override suspend fun disableRegistrationLock(): RegistrationNetworkResult<Unit, SetRegistrationLockError> = withContext(Dispatchers.IO) {
when (val result = SignalNetwork.account.disableRegistrationLock()) {
is NetworkResult.Success -> RegistrationNetworkResult.Success(Unit)
is NetworkResult.StatusCodeError -> {
when (result.code) {
401 -> RegistrationNetworkResult.Failure(SetRegistrationLockError.Unauthorized)
else -> RegistrationNetworkResult.ApplicationError(IllegalStateException("Unexpected response code: ${result.code}"))
}
}
is NetworkResult.NetworkError -> RegistrationNetworkResult.NetworkError(result.exception)
is NetworkResult.ApplicationError -> RegistrationNetworkResult.ApplicationError(result.throwable)
}
}
override suspend fun getSvrCredentials(): RegistrationNetworkResult<SvrCredentials, GetSvrCredentialsError> = withContext(Dispatchers.IO) {
try {
val svr2 = AppDependencies.signalServiceAccountManager.getSecureValueRecoveryV2(BuildConfig.SVR2_MRENCLAVE)
val auth = svr2.authorization()
RegistrationNetworkResult.Success(SvrCredentials(auth.username(), auth.password()))
} catch (e: IOException) {
RegistrationNetworkResult.NetworkError(e)
} catch (e: Exception) {
RegistrationNetworkResult.ApplicationError(e)
}
}
override suspend fun checkSvrCredentials(
e164: String,
credentials: List<SvrCredentials>
): RegistrationNetworkResult<CheckSvrCredentialsResponse, CheckSvrCredentialsError> = withContext(Dispatchers.IO) {
try {
val tokens = credentials.map { "${it.username}:${it.password}" }
pushServiceSocket.checkSvr2AuthCredentialsV2(e164, tokens).use { response ->
when (response.code) {
200 -> {
val result = json.decodeFromString<CheckSvrCredentialsResponse>(response.body.string())
RegistrationNetworkResult.Success(result)
}
400, 422 -> {
RegistrationNetworkResult.Failure(CheckSvrCredentialsError.InvalidRequest(response.body.string()))
}
401 -> {
RegistrationNetworkResult.Failure(CheckSvrCredentialsError.Unauthorized)
}
else -> {
RegistrationNetworkResult.ApplicationError(IllegalStateException("Unexpected response code: ${response.code}, body: ${response.body.string()}"))
}
}
}
} catch (e: IOException) {
RegistrationNetworkResult.NetworkError(e)
} catch (e: Exception) {
RegistrationNetworkResult.ApplicationError(e)
}
}
override suspend fun setAccountAttributes(
attributes: AccountAttributes
): RegistrationNetworkResult<Unit, SetAccountAttributesError> = withContext(Dispatchers.IO) {
when (val result = SignalNetwork.account.setAccountAttributes(attributes.toServiceAccountAttributes())) {
is NetworkResult.Success -> RegistrationNetworkResult.Success(Unit)
is NetworkResult.StatusCodeError -> {
when (result.code) {
401 -> RegistrationNetworkResult.Failure(SetAccountAttributesError.Unauthorized)
422 -> RegistrationNetworkResult.Failure(SetAccountAttributesError.InvalidRequest(result.toString()))
else -> RegistrationNetworkResult.ApplicationError(IllegalStateException("Unexpected response code: ${result.code}"))
}
}
is NetworkResult.NetworkError -> RegistrationNetworkResult.NetworkError(result.exception)
is NetworkResult.ApplicationError -> RegistrationNetworkResult.ApplicationError(result.throwable)
}
}
override fun startProvisioning(): Flow<ProvisioningEvent> = callbackFlow {
val socketHandles = mutableListOf<java.io.Closeable>()
val configuration = AppDependencies.signalServiceNetworkAccess.getConfiguration()
fun startSocket() {
val handle = ProvisioningSocket.start<RegistrationProvisionMessage>(
mode = ProvisioningSocket.Mode.REREG,
identityKeyPair = IdentityKeyPair.generate(),
configuration = configuration,
handler = { id, t ->
Log.w(TAG, "[startProvisioning] Socket [$id] failed", t)
trySend(ProvisioningEvent.Error(t))
}
) { socket ->
val url = socket.getProvisioningUrl()
trySend(ProvisioningEvent.QrCodeReady(url))
val result = socket.getProvisioningMessageDecryptResult()
if (result is SecondaryProvisioningCipher.ProvisioningDecryptResult.Success) {
val msg = result.message
trySend(
ProvisioningEvent.MessageReceived(
ProvisioningMessage(
accountEntropyPool = msg.accountEntropyPool,
e164 = msg.e164,
pin = msg.pin,
aciIdentityKeyPair = IdentityKeyPair(IdentityKey(msg.aciIdentityKeyPublic.toByteArray()), ECPrivateKey(msg.aciIdentityKeyPrivate.toByteArray())),
pniIdentityKeyPair = IdentityKeyPair(IdentityKey(msg.pniIdentityKeyPublic.toByteArray()), ECPrivateKey(msg.pniIdentityKeyPrivate.toByteArray())),
platform = when (msg.platform) {
RegistrationProvisionMessage.Platform.ANDROID -> ProvisioningMessage.Platform.ANDROID
RegistrationProvisionMessage.Platform.IOS -> ProvisioningMessage.Platform.IOS
},
tier = when (msg.tier) {
RegistrationProvisionMessage.Tier.FREE -> ProvisioningMessage.Tier.FREE
RegistrationProvisionMessage.Tier.PAID -> ProvisioningMessage.Tier.PAID
null -> null
},
backupTimestampMs = msg.backupTimestampMs,
backupSizeBytes = msg.backupSizeBytes,
restoreMethodToken = msg.restoreMethodToken,
backupVersion = msg.backupVersion
)
)
)
channel.close()
} else {
Log.w(TAG, "[startProvisioning] Failed to decrypt provisioning message")
trySend(ProvisioningEvent.Error(IOException("Failed to decrypt provisioning message")))
}
}
synchronized(socketHandles) {
socketHandles += handle
if (socketHandles.size > 2) {
socketHandles.removeAt(0).close()
}
}
}
startSocket()
val rotationJob = launch {
var count = 0
while (count < 5 && isActive) {
kotlinx.coroutines.delay(ProvisioningSocket.LIFESPAN / 2)
if (isActive) {
startSocket()
count++
Log.d(TAG, "[startProvisioning] Rotated socket, count: $count")
}
}
}
awaitClose {
rotationJob.cancel()
synchronized(socketHandles) {
socketHandles.forEach { it.close() }
socketHandles.clear()
}
}
}
private fun AccountAttributes.toServiceAccountAttributes(): ServiceAccountAttributes {
return ServiceAccountAttributes(
signalingKey,
registrationId,
fetchesMessages,
registrationLock,
unidentifiedAccessKey,
unrestrictedUnidentifiedAccess,
capabilities?.toServiceCapabilities(),
discoverableByPhoneNumber,
name,
pniRegistrationId,
recoveryPassword
)
}
private fun AccountAttributes.Capabilities.toServiceCapabilities(): ServiceAccountAttributes.Capabilities {
return ServiceAccountAttributes.Capabilities(
storage,
versionedExpirationTimer,
attachmentBackfill,
spqr
)
}
private fun PreKeyCollection.toServicePreKeyCollection(): ServicePreKeyCollection {
return ServicePreKeyCollection(
identityKey = identityKey,
signedPreKey = signedPreKey,
lastResortKyberPreKey = lastResortKyberPreKey
)
}
private fun okhttp3.Response.retryAfter(): Duration {
return this.header("Retry-After")?.toLongOrNull()?.seconds ?: 0.seconds
}
}
@@ -0,0 +1,336 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.registration.v2
import android.content.Context
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withContext
import okio.ByteString.Companion.toByteString
import org.signal.archive.LocalBackupRestoreProgress
import org.signal.core.models.AccountEntropyPool
import org.signal.core.models.MasterKey
import org.signal.core.util.logging.Log
import org.signal.registration.PreExistingRegistrationData
import org.signal.registration.StorageController
import org.signal.registration.proto.RegistrationData
import org.signal.registration.screens.localbackuprestore.LocalBackupInfo
import org.signal.registration.screens.restoreselection.ArchiveRestoreOption
import org.thoughtcrime.securesms.backup.FullBackupImporter
import org.thoughtcrime.securesms.backup.v2.BackupRepository
import org.thoughtcrime.securesms.backup.v2.local.LocalArchiver
import org.thoughtcrime.securesms.backup.v2.local.SnapshotFileSystem
import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.LocalRegistrationMetadata
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.pin.SvrRepository
import org.thoughtcrime.securesms.registration.data.RegistrationRepository
import java.io.File
import java.io.IOException
import java.time.LocalDateTime
import kotlin.time.Duration.Companion.minutes
/**
* Implementation of [StorageController] that bridges to the app's existing storage infrastructure.
*/
class AppRegistrationStorageController(private val context: Context) : StorageController {
companion object {
private val TAG = Log.tag(AppRegistrationStorageController::class)
private const val TEMP_PROTO_FILENAME = "registration-in-progress.proto"
private val TEMP_PROTO_TIMEOUT = 15.minutes
private val MODERN_BACKUP_PATTERN = Regex("^signal-backup-(\\d{4})-(\\d{2})-(\\d{2})-(\\d{2})-(\\d{2})-(\\d{2})$")
private val LEGACY_BACKUP_PATTERN = Regex("^signal-(\\d{4})-(\\d{2})-(\\d{2})-(\\d{2})-(\\d{2})-(\\d{2})\\.backup$")
}
override suspend fun getPreExistingRegistrationData(): PreExistingRegistrationData? = withContext(Dispatchers.IO) {
if (!SignalStore.account.isRegistered) {
return@withContext null
}
val aci = SignalStore.account.aci ?: return@withContext null
val pni = SignalStore.account.pni ?: return@withContext null
val e164 = SignalStore.account.e164 ?: return@withContext null
val servicePassword = SignalStore.account.servicePassword ?: return@withContext null
val aep = SignalStore.account.accountEntropyPool ?: return@withContext null
val aciIdentityKeyPair = SignalStore.account.aciIdentityKey
val pniIdentityKeyPair = SignalStore.account.pniIdentityKey
PreExistingRegistrationData(
e164 = e164,
aci = aci,
pni = pni,
servicePassword = servicePassword,
aep = aep,
registrationLockEnabled = SignalStore.svr.isRegistrationLockEnabled,
aciIdentityKeyPair = aciIdentityKeyPair,
pniIdentityKeyPair = pniIdentityKeyPair
)
}
override suspend fun clearAllData() = withContext(Dispatchers.IO) {
File(context.cacheDir, TEMP_PROTO_FILENAME).takeIf { it.exists() }?.delete()
Unit
}
override suspend fun readInProgressRegistrationData(): RegistrationData = withContext(Dispatchers.IO) {
val file = File(context.cacheDir, TEMP_PROTO_FILENAME)
if (file.exists()) {
val age = System.currentTimeMillis() - file.lastModified()
if (age > TEMP_PROTO_TIMEOUT.inWholeMilliseconds) {
Log.w(TAG, "In-progress registration data is stale (${age}ms old), discarding.")
file.delete()
return@withContext RegistrationData()
}
try {
RegistrationData.ADAPTER.decode(file.readBytes())
} catch (e: Exception) {
Log.w(TAG, "Failed to decode registration data, returning empty.", e)
RegistrationData()
}
} else {
RegistrationData()
}
}
override suspend fun updateInProgressRegistrationData(updater: RegistrationData.Builder.() -> Unit) = withContext(Dispatchers.IO) {
val current = readInProgressRegistrationData()
val updated = current.newBuilder().apply(updater).build()
writeRegistrationData(updated)
}
override suspend fun commitRegistrationData() = withContext(Dispatchers.IO) {
val data = readInProgressRegistrationData()
// Build LocalRegistrationMetadata if we have enough data for account setup
if (data.e164.isNotEmpty() && data.aci.isNotEmpty() && data.pni.isNotEmpty() && data.servicePassword.isNotEmpty()) {
val profileKey = RegistrationRepository.getProfileKey(data.e164)
val metadata = LocalRegistrationMetadata.Builder().apply {
if (data.aciIdentityKeyPair.size > 0) {
aciIdentityKeyPair = data.aciIdentityKeyPair
}
if (data.pniIdentityKeyPair.size > 0) {
pniIdentityKeyPair = data.pniIdentityKeyPair
}
if (data.aciSignedPreKey.size > 0) {
aciSignedPreKey = data.aciSignedPreKey
}
if (data.pniSignedPreKey.size > 0) {
pniSignedPreKey = data.pniSignedPreKey
}
if (data.aciLastResortKyberPreKey.size > 0) {
aciLastRestoreKyberPreKey = data.aciLastResortKyberPreKey
}
if (data.pniLastResortKyberPreKey.size > 0) {
pniLastRestoreKyberPreKey = data.pniLastResortKyberPreKey
}
aci = data.aci
pni = data.pni
e164 = data.e164
this.servicePassword = data.servicePassword
this.profileKey = profileKey.serialize().toByteString()
hasPin = data.pin.isNotEmpty()
if (data.pin.isNotEmpty()) {
pin = data.pin
}
if (data.temporaryMasterKey.size > 0) {
masterKey = data.temporaryMasterKey
}
fcmEnabled = SignalStore.account.fcmEnabled
fcmToken = SignalStore.account.fcmToken ?: ""
reglockEnabled = data.registrationLockEnabled
}.build()
// TODO [greyson] Should probably move this stuff into this file as we get closer to being done
RegistrationRepository.registerAccountLocally(context, metadata)
SignalStore.registration.localRegistrationMetadata = metadata
if (data.accountEntropyPool.isNotEmpty()) {
SignalStore.account.restoreAccountEntropyPool(AccountEntropyPool(data.accountEntropyPool))
}
}
// Handle PIN/master key
if (data.pin.isNotEmpty() && data.temporaryMasterKey.size > 0) {
val masterKey = MasterKey(data.temporaryMasterKey.toByteArray())
SvrRepository.onRegistrationComplete(
masterKey,
data.pin,
true,
data.registrationLockEnabled,
data.accountEntropyPool.isNotEmpty()
)
}
Unit
}
override suspend fun getAvailableRestoreOptions(): Set<ArchiveRestoreOption> = withContext(Dispatchers.IO) {
// TODO [greyson] Real options
val options = mutableSetOf<ArchiveRestoreOption>()
options.add(ArchiveRestoreOption.LocalBackup)
options.add(ArchiveRestoreOption.DeviceTransfer)
options
}
override fun restoreLocalBackupV1(uri: Uri, passphrase: String): Flow<LocalBackupRestoreProgress> = flow {
// TODO [greyson] better progress
Log.d(TAG, "Starting V1 local backup restore from: $uri")
emit(LocalBackupRestoreProgress.Preparing)
try {
if (!FullBackupImporter.validatePassphrase(context, uri, passphrase)) {
emit(LocalBackupRestoreProgress.Error(IllegalArgumentException("Invalid passphrase")))
return@flow
}
val database = SignalDatabase.backupDatabase
FullBackupImporter.importFile(
context,
AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(),
database,
uri,
passphrase,
SignalStore.registration.localRegistrationMetadata != null
)
SignalDatabase.runPostBackupRestoreTasks(database)
emit(LocalBackupRestoreProgress.Complete)
Log.d(TAG, "V1 restore complete.")
} catch (e: FullBackupImporter.DatabaseDowngradeException) {
Log.w(TAG, "V1 restore failed: database downgrade", e)
emit(LocalBackupRestoreProgress.Error(e))
} catch (e: Exception) {
Log.w(TAG, "V1 restore failed", e)
emit(LocalBackupRestoreProgress.Error(e))
}
}.flowOn(Dispatchers.IO)
override fun restoreLocalBackupV2(rootUri: Uri, backupUri: Uri, aep: AccountEntropyPool): Flow<LocalBackupRestoreProgress> = flow {
// TODO [greyson] better progress
Log.d(TAG, "Starting V2 local backup restore from backup=$backupUri, root=$rootUri")
emit(LocalBackupRestoreProgress.Preparing)
try {
val backupDir = DocumentFile.fromTreeUri(context, backupUri)
if (backupDir == null || !backupDir.canRead()) {
emit(LocalBackupRestoreProgress.Error(IllegalStateException("Could not open backup directory")))
return@flow
}
val selfAci = SignalStore.account.aci
val selfPni = SignalStore.account.pni
val selfE164 = SignalStore.account.e164
if (selfAci == null || selfPni == null || selfE164 == null) {
emit(LocalBackupRestoreProgress.Error(IllegalStateException("Account not registered, cannot restore V2 backup")))
return@flow
}
val selfData = BackupRepository.SelfData(selfAci, selfPni, selfE164, ProfileKeyUtil.getSelfProfileKey())
val messageBackupKey = aep.deriveMessageBackupKey()
val snapshotFileSystem = SnapshotFileSystem(context, backupDir)
when (val result = LocalArchiver.import(snapshotFileSystem, selfData, messageBackupKey)) {
is org.signal.core.util.Result.Success -> {
emit(LocalBackupRestoreProgress.Complete)
Log.d(TAG, "V2 restore complete.")
}
is org.signal.core.util.Result.Failure -> {
Log.w(TAG, "V2 restore failed: ${result.failure}")
emit(LocalBackupRestoreProgress.Error(IOException("V2 restore failed: ${result.failure}")))
}
}
} catch (e: Exception) {
Log.w(TAG, "V2 restore failed", e)
emit(LocalBackupRestoreProgress.Error(e))
}
}.flowOn(Dispatchers.IO)
override suspend fun scanLocalBackupFolder(folderUri: Uri): List<LocalBackupInfo> = withContext(Dispatchers.IO) {
val folder = DocumentFile.fromTreeUri(context, folderUri) ?: return@withContext emptyList()
val children = folder.listFiles()
// If the selected folder contains a SignalBackups directory, use that instead
val signalBackupsDir = children.firstOrNull { it.isDirectory && it.name == "SignalBackups" }
val effectiveChildren = if (signalBackupsDir != null) {
Log.d(TAG, "Found SignalBackups directory, using it as the effective folder")
signalBackupsDir.listFiles()
} else {
children
}
val backups = mutableListOf<LocalBackupInfo>()
// Check for modern backups: requires a 'files' directory and signal-backup-* directories
val hasFilesDir = effectiveChildren.any { it.isDirectory && it.name == "files" }
if (hasFilesDir) {
for (child in effectiveChildren) {
if (!child.isDirectory) continue
val name = child.name ?: continue
val match = MODERN_BACKUP_PATTERN.matchEntire(name) ?: continue
val (year, month, day, hour, minute, second) = match.destructured
try {
val date = LocalDateTime.of(year.toInt(), month.toInt(), day.toInt(), hour.toInt(), minute.toInt(), second.toInt())
backups.add(
LocalBackupInfo(
type = LocalBackupInfo.BackupType.V2,
date = date,
name = name,
uri = child.uri
)
)
} catch (e: Exception) {
Log.w(TAG, "Failed to parse date from modern backup name: $name", e)
}
}
}
// Check for legacy backups: signal-yyyy-MM-dd-HH-mm-ss.backup files
for (child in effectiveChildren) {
if (!child.isFile) continue
val name = child.name ?: continue
val match = LEGACY_BACKUP_PATTERN.matchEntire(name) ?: continue
val (year, month, day, hour, minute, second) = match.destructured
try {
val date = LocalDateTime.of(year.toInt(), month.toInt(), day.toInt(), hour.toInt(), minute.toInt(), second.toInt())
backups.add(
LocalBackupInfo(
type = LocalBackupInfo.BackupType.V1,
date = date,
name = name,
uri = child.uri,
sizeBytes = child.length()
)
)
} catch (e: Exception) {
Log.w(TAG, "Failed to parse date from legacy backup name: $name", e)
}
}
backups.sortedByDescending { it.date }
}
private suspend fun writeRegistrationData(data: RegistrationData) = withContext(Dispatchers.IO) {
val file = File(context.cacheDir, TEMP_PROTO_FILENAME)
file.writeBytes(RegistrationData.ADAPTER.encode(data))
}
}
@@ -19,6 +19,8 @@ object Environment {
return !IS_INSTRUMENTATION && (BuildConfig.DEBUG || IS_NIGHTLY || IS_PERF || IS_STAGING)
}
const val USE_NEW_REGISTRATION: Boolean = false
object Backups {
@JvmStatic
fun supportsGooglePlayBilling(): Boolean {
@@ -0,0 +1,11 @@
package org.thoughtcrime.securesms.util
import org.junit.Assert.assertFalse
import org.junit.Test
class EnvironmentTest {
@Test
fun `USE_NEW_REGISTRATION must be false for release`() {
assertFalse("USE_NEW_REGISTRATION must not be committed as true!", Environment.USE_NEW_REGISTRATION)
}
}
@@ -131,7 +131,10 @@ class DebugNetworkController(
}
override suspend fun awaitPushChallengeToken(): String? {
// No override support for simple value methods
if (NetworkDebugState.skipPushChallenge.value) {
Log.d(TAG, "[awaitPushChallengeToken] Skipping push challenge (debug override)")
return null
}
return delegate.awaitPushChallengeToken()
}
@@ -27,8 +27,10 @@ import androidx.compose.material3.Card
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -160,6 +162,10 @@ private fun NetworkDebugDialog(
Spacer(modifier = Modifier.height(16.dp))
SkipPushChallengeToggle()
HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
LazyColumn(
modifier = Modifier
.fillMaxWidth()
@@ -194,6 +200,37 @@ private fun NetworkDebugDialog(
}
}
@Composable
private fun SkipPushChallengeToggle() {
val skipPushChallenge by NetworkDebugState.skipPushChallenge.collectAsState()
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Skip push challenge",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
Text(
text = "Always return null, forcing captcha",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = skipPushChallenge,
onCheckedChange = { NetworkDebugState.setSkipPushChallenge(it) }
)
}
}
@Composable
private fun MethodOverrideRow(
methodInfo: DebugNetworkMockData.MethodOverrideInfo
@@ -16,6 +16,14 @@ import kotlinx.coroutines.flow.update
*/
object NetworkDebugState {
/** When true, [NetworkController.awaitPushChallengeToken] will always return null, forcing the captcha path. */
private val _skipPushChallenge = MutableStateFlow(false)
val skipPushChallenge: StateFlow<Boolean> = _skipPushChallenge.asStateFlow()
fun setSkipPushChallenge(skip: Boolean) {
_skipPushChallenge.value = skip
}
/**
* Map of method name to the selected option name (e.g., "createSession" -> "success")
* A value of "unset" or absence means no override is active.
@@ -271,7 +271,7 @@ class DemoStorageController(private val context: Context) : StorageController {
Log.d(TAG, "Simulated V1 restore complete.")
}.flowOn(Dispatchers.IO)
override fun restoreLocalBackupV2(rootUri: Uri, backupUri: Uri, aep: String): Flow<LocalBackupRestoreProgress> = flow {
override fun restoreLocalBackupV2(rootUri: Uri, backupUri: Uri, aep: AccountEntropyPool): Flow<LocalBackupRestoreProgress> = flow {
Log.d(TAG, "Starting simulated V2 local backup restore from backup=$backupUri, root=$rootUri")
require(DocumentFile.fromTreeUri(context, backupUri)?.exists() == true) { "Backup directory does not exist: $backupUri" }
@@ -5,8 +5,13 @@ import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContract
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.google.accompanist.permissions.ExperimentalPermissionsApi
@@ -31,18 +36,25 @@ class RegistrationActivity : ComponentActivity() {
@OptIn(ExperimentalPermissionsApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
SignalTheme(incognitoKeyboardEnabled = false) {
Surface {
RegistrationNavHost(
registrationRepository = repository,
modifier = Modifier.fillMaxSize(),
onRegistrationComplete = {
setResult(RESULT_OK)
finish()
}
)
Box(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing)
) {
RegistrationNavHost(
registrationRepository = repository,
modifier = Modifier.fillMaxSize(),
onRegistrationComplete = {
setResult(RESULT_OK)
finish()
}
)
}
}
}
}
@@ -55,6 +67,7 @@ class RegistrationActivity : ComponentActivity() {
* @param context The context used to create the intent.
* @return An intent that can be used to start the RegistrationActivity.
*/
@JvmStatic
fun createIntent(context: Context): Intent {
return Intent(context, RegistrationActivity::class.java)
}
@@ -505,7 +505,7 @@ class RegistrationRepository(val context: Context, val networkController: Networ
return storageController.restoreLocalBackupV1(uri, passphrase)
}
fun restoreV2Backup(rootUri: Uri, backupUri: Uri, aep: String): Flow<LocalBackupRestoreProgress> {
fun restoreV2Backup(rootUri: Uri, backupUri: Uri, aep: AccountEntropyPool): Flow<LocalBackupRestoreProgress> {
return storageController.restoreLocalBackupV2(rootUri, backupUri, aep)
}
@@ -106,7 +106,7 @@ interface StorageController {
* @return A [Flow] of [LocalBackupRestoreProgress] that reports the state of the restore operation
* from preparation through completion or error.
*/
fun restoreLocalBackupV2(rootUri: Uri, backupUri: Uri, aep: String): Flow<LocalBackupRestoreProgress>
fun restoreLocalBackupV2(rootUri: Uri, backupUri: Uri, aep: AccountEntropyPool): Flow<LocalBackupRestoreProgress>
/**
* Scans the given folder URI for local backup files, checking for both modern
@@ -109,12 +109,13 @@ class LocalBackupRestoreViewModel(
private fun applyPassphraseSubmitted(state: LocalBackupRestoreState, credential: String, stateEmitter: (LocalBackupRestoreState) -> Unit) {
val backup = state.backupInfo ?: return
val aep = if (backup.type == LocalBackupInfo.BackupType.V2) AccountEntropyPool(credential) else null
val updatedState = when (backup.type) {
LocalBackupInfo.BackupType.V1 -> state.copy(v1Passphrase = credential)
LocalBackupInfo.BackupType.V2 -> state.copy(aep = AccountEntropyPool(credential))
LocalBackupInfo.BackupType.V2 -> state.copy(aep = aep)
}
stateEmitter(updatedState)
startRestore(backup, state.selectedFolderUri, credential)
startRestore(backup, state.selectedFolderUri, credential, aep)
}
private fun onRestoreComplete(state: LocalBackupRestoreState) {
@@ -163,13 +164,13 @@ class LocalBackupRestoreViewModel(
}
}
private fun startRestore(backup: LocalBackupInfo, rootUri: Uri?, credential: String) {
private fun startRestore(backup: LocalBackupInfo, rootUri: Uri?, credential: String, aep: AccountEntropyPool?) {
restoreJob?.cancel()
restoreJob = viewModelScope.launch {
val currentState = _localState.value
val restoreFlow = when (backup.type) {
LocalBackupInfo.BackupType.V1 -> repository.restoreV1Backup(backup.uri, passphrase = credential)
LocalBackupInfo.BackupType.V2 -> repository.restoreV2Backup(rootUri = rootUri!!, backupUri = backup.uri, aep = credential)
LocalBackupInfo.BackupType.V2 -> repository.restoreV2Backup(rootUri = rootUri!!, backupUri = backup.uri, aep = aep!!)
}
restoreFlow.collect { progress ->
_localState.value = when (progress) {
@@ -456,6 +456,8 @@ class PhoneNumberEntryViewModel(
}
if (sessionMetadata.requestedInformation.contains("captcha")) {
parentEventEmitter(RegistrationFlowEvent.SessionUpdated(sessionMetadata))
parentEventEmitter(RegistrationFlowEvent.E164Chosen(e164))
parentEventEmitter.navigateTo(RegistrationRoute.Captcha(sessionMetadata))
return state
}
@@ -319,8 +319,10 @@ class PhoneNumberEntryViewModelTest {
assertThat(emittedStates.first().showSpinner).isTrue()
assertThat(emittedStates.last().showSpinner).isFalse()
assertThat(emittedEvents).hasSize(1)
assertThat(emittedEvents.first())
assertThat(emittedEvents).hasSize(3)
assertThat(emittedEvents[0]).isInstanceOf<RegistrationFlowEvent.SessionUpdated>()
assertThat(emittedEvents[1]).isInstanceOf<RegistrationFlowEvent.E164Chosen>()
assertThat(emittedEvents[2])
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isInstanceOf<RegistrationRoute.Captcha>()
@@ -737,8 +739,10 @@ class PhoneNumberEntryViewModelTest {
assertThat(emittedStates.last().showSpinner).isFalse()
// Verify navigation to captcha
assertThat(emittedEvents).hasSize(1)
assertThat(emittedEvents.first())
assertThat(emittedEvents).hasSize(3)
assertThat(emittedEvents[0]).isInstanceOf<RegistrationFlowEvent.SessionUpdated>()
assertThat(emittedEvents[1]).isInstanceOf<RegistrationFlowEvent.E164Chosen>()
assertThat(emittedEvents[2])
.isInstanceOf<RegistrationFlowEvent.NavigateToScreen>()
.prop(RegistrationFlowEvent.NavigateToScreen::route)
.isInstanceOf<RegistrationRoute.Captcha>()
@@ -403,55 +403,13 @@ public class PushServiceSocket {
* V2 API: Submits registration request and returns the raw Response for manual handling.
* Caller is responsible for closing the response.
*/
public Response submitRegistrationRequestV2(@Nullable String sessionId, @Nullable String recoveryPassword, AccountAttributes attributes, PreKeyCollection aciPreKeys, PreKeyCollection pniPreKeys, @Nullable String fcmToken, boolean skipDeviceTransfer) throws IOException {
String path = REGISTRATION_PATH;
if (sessionId == null && recoveryPassword == null) {
throw new IllegalArgumentException("Neither Session ID nor Recovery Password provided.");
}
if (sessionId != null && recoveryPassword != null) {
throw new IllegalArgumentException("You must supply one and only one of either: Session ID, or Recovery Password.");
}
GcmRegistrationId gcmRegistrationId;
if (attributes.getFetchesMessages()) {
gcmRegistrationId = null;
} else {
gcmRegistrationId = new GcmRegistrationId(fcmToken, true);
}
RegistrationSessionRequestBody body;
try {
final SignedPreKeyEntity aciSignedPreKey = new SignedPreKeyEntity(Objects.requireNonNull(aciPreKeys.getSignedPreKey()).getId(),
aciPreKeys.getSignedPreKey().getKeyPair().getPublicKey(),
aciPreKeys.getSignedPreKey().getSignature());
final SignedPreKeyEntity pniSignedPreKey = new SignedPreKeyEntity(Objects.requireNonNull(pniPreKeys.getSignedPreKey()).getId(),
pniPreKeys.getSignedPreKey().getKeyPair().getPublicKey(),
pniPreKeys.getSignedPreKey().getSignature());
final KyberPreKeyEntity aciLastResortKyberPreKey = new KyberPreKeyEntity(Objects.requireNonNull(aciPreKeys.getLastResortKyberPreKey()).getId(),
aciPreKeys.getLastResortKyberPreKey().getKeyPair().getPublicKey(),
aciPreKeys.getLastResortKyberPreKey().getSignature());
final KyberPreKeyEntity pniLastResortKyberPreKey = new KyberPreKeyEntity(Objects.requireNonNull(pniPreKeys.getLastResortKyberPreKey()).getId(),
pniPreKeys.getLastResortKyberPreKey().getKeyPair().getPublicKey(),
pniPreKeys.getLastResortKyberPreKey().getSignature());
body = new RegistrationSessionRequestBody(sessionId,
recoveryPassword,
attributes,
Base64.encodeWithoutPadding(aciPreKeys.getIdentityKey().serialize()),
Base64.encodeWithoutPadding(pniPreKeys.getIdentityKey().serialize()),
aciSignedPreKey,
pniSignedPreKey,
aciLastResortKyberPreKey,
pniLastResortKyberPreKey,
gcmRegistrationId,
skipDeviceTransfer,
true);
} catch (InvalidKeyException e) {
throw new AssertionError("unexpected invalid key", e);
}
return makeServiceRequestWithoutValidation(path, "POST", jsonRequestBody(JsonUtil.toJson(body)), NO_HEADERS, SealedSenderAccess.NONE, false);
/**
* V2 API: Checks SVR2 auth credentials and returns the raw Response for manual handling.
* Caller is responsible for closing the response.
*/
public Response checkSvr2AuthCredentialsV2(@Nullable String number, @Nonnull List<String> passwords) throws IOException {
String jsonBody = JsonUtil.toJson(new BackupAuthCheckRequest(number, passwords));
return makeServiceRequestWithoutValidation(BACKUP_AUTH_CHECK_V2, "POST", jsonRequestBody(jsonBody), NO_HEADERS, SealedSenderAccess.NONE, false);
}
/**