diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index e4422db033..49e8a65667 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -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() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActivity.java b/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActivity.java index cb718802c6..e78100369e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/PassphraseRequiredActivity.java @@ -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() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt index 6c96b73749..e9f1639bd5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt new file mode 100644 index 0000000000..f6bcaaa6ff --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationNetworkController.kt @@ -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 = withContext(Dispatchers.IO) { + try { + pushServiceSocket.createVerificationSessionV2(e164, fcmToken, mcc, mnc).use { response -> + when (response.code) { + 200 -> { + val session = json.decodeFromString(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 = withContext(Dispatchers.IO) { + try { + pushServiceSocket.getSessionStatusV2(sessionId).use { response -> + when (response.code) { + 200 -> { + val session = json.decodeFromString(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 = withContext(Dispatchers.IO) { + try { + pushServiceSocket.patchVerificationSessionV2( + sessionId, + null, + null, + null, + captchaToken, + pushChallengeToken + ).use { response -> + when (response.code) { + 200 -> { + val session = json.decodeFromString(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(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 = 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(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(response.body.string()) + RegistrationNetworkResult.Failure(RequestVerificationCodeError.MissingRequestInformationOrAlreadyVerified(session)) + } + 418 -> { + val session = json.decodeFromString(response.body.string()) + RegistrationNetworkResult.Failure(RequestVerificationCodeError.CouldNotFulfillWithRequestedTransport(session)) + } + 429 -> { + val session = json.decodeFromString(response.body.string()) + RegistrationNetworkResult.Failure(RequestVerificationCodeError.RateLimited(response.retryAfter(), session)) + } + 440 -> { + val errorBody = json.decodeFromString(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 = withContext(Dispatchers.IO) { + try { + pushServiceSocket.submitVerificationCodeV2(sessionId, verificationCode).use { response -> + when (response.code) { + 200 -> { + val session = json.decodeFromString(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(response.body.string()) + RegistrationNetworkResult.Failure(SubmitVerificationCodeError.SessionAlreadyVerifiedOrNoCodeRequested(session)) + } + 429 -> { + val session = json.decodeFromString(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 = 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(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(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() + + 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 = 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 = 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 = 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 = 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 = 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 + ): RegistrationNetworkResult = 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(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 = 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 = callbackFlow { + val socketHandles = mutableListOf() + val configuration = AppDependencies.signalServiceNetworkAccess.getConfiguration() + + fun startSocket() { + val handle = ProvisioningSocket.start( + 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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationStorageController.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationStorageController.kt new file mode 100644 index 0000000000..1195070486 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/v2/AppRegistrationStorageController.kt @@ -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 = withContext(Dispatchers.IO) { + // TODO [greyson] Real options + val options = mutableSetOf() + + options.add(ArchiveRestoreOption.LocalBackup) + options.add(ArchiveRestoreOption.DeviceTransfer) + + options + } + + override fun restoreLocalBackupV1(uri: Uri, passphrase: String): Flow = 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 = 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 = 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() + + // 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)) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt b/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt index 3f0912f4d2..d971051e58 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Environment.kt @@ -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 { diff --git a/app/src/test/java/org/thoughtcrime/securesms/util/EnvironmentTest.kt b/app/src/test/java/org/thoughtcrime/securesms/util/EnvironmentTest.kt new file mode 100644 index 0000000000..5b70c7141d --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/util/EnvironmentTest.kt @@ -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) + } +} diff --git a/demo/registration/src/main/java/org/signal/registration/sample/debug/DebugNetworkController.kt b/demo/registration/src/main/java/org/signal/registration/sample/debug/DebugNetworkController.kt index d3a8d59d3a..b6d670dce9 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/debug/DebugNetworkController.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/debug/DebugNetworkController.kt @@ -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() } diff --git a/demo/registration/src/main/java/org/signal/registration/sample/debug/NetworkDebugOverlay.kt b/demo/registration/src/main/java/org/signal/registration/sample/debug/NetworkDebugOverlay.kt index c4aa1c5d80..8db694e366 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/debug/NetworkDebugOverlay.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/debug/NetworkDebugOverlay.kt @@ -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 diff --git a/demo/registration/src/main/java/org/signal/registration/sample/debug/NetworkDebugState.kt b/demo/registration/src/main/java/org/signal/registration/sample/debug/NetworkDebugState.kt index 13bf84e72e..8267c01ddc 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/debug/NetworkDebugState.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/debug/NetworkDebugState.kt @@ -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 = _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. diff --git a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoStorageController.kt b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoStorageController.kt index fd64e996d8..ce3f63fc53 100644 --- a/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoStorageController.kt +++ b/demo/registration/src/main/java/org/signal/registration/sample/dependencies/DemoStorageController.kt @@ -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 = flow { + override fun restoreLocalBackupV2(rootUri: Uri, backupUri: Uri, aep: AccountEntropyPool): Flow = 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" } diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationActivity.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationActivity.kt index 0e02fa5b15..0d049e5075 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationActivity.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationActivity.kt @@ -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) } diff --git a/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt b/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt index 66069733e6..9bf52b98e9 100644 --- a/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt +++ b/feature/registration/src/main/java/org/signal/registration/RegistrationRepository.kt @@ -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 { + fun restoreV2Backup(rootUri: Uri, backupUri: Uri, aep: AccountEntropyPool): Flow { return storageController.restoreLocalBackupV2(rootUri, backupUri, aep) } diff --git a/feature/registration/src/main/java/org/signal/registration/StorageController.kt b/feature/registration/src/main/java/org/signal/registration/StorageController.kt index 74471f25ba..450b3d40ca 100644 --- a/feature/registration/src/main/java/org/signal/registration/StorageController.kt +++ b/feature/registration/src/main/java/org/signal/registration/StorageController.kt @@ -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 + fun restoreLocalBackupV2(rootUri: Uri, backupUri: Uri, aep: AccountEntropyPool): Flow /** * Scans the given folder URI for local backup files, checking for both modern diff --git a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModel.kt index e240fdc048..0ed81c619b 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/localbackuprestore/LocalBackupRestoreViewModel.kt @@ -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) { diff --git a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt index 796c43a866..89755aabf6 100644 --- a/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt +++ b/feature/registration/src/main/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModel.kt @@ -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 } diff --git a/feature/registration/src/test/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModelTest.kt b/feature/registration/src/test/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModelTest.kt index 0b9bf20cfb..8ed7297566 100644 --- a/feature/registration/src/test/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModelTest.kt +++ b/feature/registration/src/test/java/org/signal/registration/screens/phonenumber/PhoneNumberEntryViewModelTest.kt @@ -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() + assertThat(emittedEvents[1]).isInstanceOf() + assertThat(emittedEvents[2]) .isInstanceOf() .prop(RegistrationFlowEvent.NavigateToScreen::route) .isInstanceOf() @@ -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() + assertThat(emittedEvents[1]).isInstanceOf() + assertThat(emittedEvents[2]) .isInstanceOf() .prop(RegistrationFlowEvent.NavigateToScreen::route) .isInstanceOf() diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java index 171730b509..3e54aa8038 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/internal/push/PushServiceSocket.java @@ -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 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); } /**