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

View File

@@ -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() {

View File

@@ -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() {

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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))
}
}

View File

@@ -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 {