diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/AttachmentTableTestUtil.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/AttachmentTableTestUtil.kt index cccf02a917..aa05d60dd7 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/database/AttachmentTableTestUtil.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/AttachmentTableTestUtil.kt @@ -7,9 +7,9 @@ package org.thoughtcrime.securesms.database import org.signal.core.util.Base64 import org.signal.core.util.Util +import org.signal.network.api.AttachmentUploadResult import org.thoughtcrime.securesms.attachments.AttachmentId import org.thoughtcrime.securesms.attachments.Cdn -import org.whispersystems.signalservice.api.attachment.AttachmentUploadResult import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId import kotlin.random.Random diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/dependencies/InstrumentationApplicationDependencyProvider.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/dependencies/InstrumentationApplicationDependencyProvider.kt index fec6022c9c..5333f9f0a1 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/dependencies/InstrumentationApplicationDependencyProvider.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/dependencies/InstrumentationApplicationDependencyProvider.kt @@ -10,11 +10,11 @@ import org.thoughtcrime.securesms.recipients.LiveRecipientCache import org.whispersystems.signalservice.api.SignalServiceDataStore import org.whispersystems.signalservice.api.SignalServiceMessageSender import org.whispersystems.signalservice.api.account.AccountApi -import org.whispersystems.signalservice.api.attachment.AttachmentApi import org.whispersystems.signalservice.api.donations.DonationsApi import org.whispersystems.signalservice.api.keys.KeysApi import org.whispersystems.signalservice.api.message.MessageApi import org.whispersystems.signalservice.api.websocket.SignalWebSocket +import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration import org.whispersystems.signalservice.internal.push.PushServiceSocket /** @@ -41,7 +41,7 @@ class InstrumentationApplicationDependencyProvider(val application: Application, return recipientCache } - override fun provideArchiveApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket, pushServiceSocket: PushServiceSocket): ArchiveApi { + override fun provideArchiveApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket, pushServiceSocket: PushServiceSocket, signalServiceConfiguration: SignalServiceConfiguration): ArchiveApi { return mockk() } @@ -52,12 +52,11 @@ class InstrumentationApplicationDependencyProvider(val application: Application, override fun provideSignalServiceMessageSender( protocolStore: SignalServiceDataStore, pushServiceSocket: PushServiceSocket, - attachmentApi: AttachmentApi, messageApi: MessageApi, keysApi: KeysApi ): SignalServiceMessageSender { if (signalServiceMessageSender == null) { - signalServiceMessageSender = spyk(objToCopy = default.provideSignalServiceMessageSender(protocolStore, pushServiceSocket, attachmentApi, messageApi, keysApi)) + signalServiceMessageSender = spyk(objToCopy = default.provideSignalServiceMessageSender(protocolStore, pushServiceSocket, messageApi, keysApi)) } return signalServiceMessageSender!! } diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/messages/SyncMessageProcessorTest_synchronizeDeleteForMe.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/messages/SyncMessageProcessorTest_synchronizeDeleteForMe.kt index 418365bedb..102de53352 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/messages/SyncMessageProcessorTest_synchronizeDeleteForMe.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/messages/SyncMessageProcessorTest_synchronizeDeleteForMe.kt @@ -25,6 +25,7 @@ import org.signal.core.util.Util import org.signal.core.util.logging.Log import org.signal.core.util.update import org.signal.core.util.withinTransaction +import org.signal.network.api.AttachmentUploadResult import org.thoughtcrime.securesms.attachments.Attachment import org.thoughtcrime.securesms.attachments.DatabaseAttachment import org.thoughtcrime.securesms.database.AttachmentTable @@ -37,7 +38,6 @@ import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.testing.MessageContentFuzzer.DeleteForMeSync import org.thoughtcrime.securesms.testing.SignalActivityRule import org.thoughtcrime.securesms.util.IdentityUtil -import org.whispersystems.signalservice.api.attachment.AttachmentUploadResult import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId import java.util.UUID diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt index 55d11d799e..8e9c77f615 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/v2/BackupRepository.kt @@ -71,6 +71,7 @@ import org.signal.network.NetworkResult import org.signal.network.StatusCodeErrorAction import org.signal.network.api.SvrBApi import org.signal.network.exceptions.NonSuccessfulResponseCodeException +import org.signal.network.rest.toNetworkResult import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.attachments.AttachmentId import org.thoughtcrime.securesms.attachments.Cdn @@ -1628,19 +1629,6 @@ object BackupRepository { } } - fun getResumableMessagesBackupUploadSpec(backupFileSize: Long): NetworkResult { - return initBackupAndFetchAuth() - .then { credential -> - SignalNetwork.archive.getMessageBackupUploadForm(SignalStore.account.requireAci(), credential.messageBackupAccess, backupFileSize) - .also { Log.i(TAG, "UploadFormResult: ${it::class.simpleName}") } - } - .then { form -> - SignalNetwork.archive.getBackupResumableUploadUrl(form) - .also { Log.i(TAG, "ResumableUploadUrlResult: ${it::class.simpleName}") } - .map { ResumableMessagesBackupUploadSpec(attachmentUploadForm = form, resumableUri = it) } - } - } - fun getMessageBackupUploadForm(backupFileSize: Long): NetworkResult { return initBackupAndFetchAuth() .then { credential -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt index 51fe676a4f..c9a571ae05 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt @@ -66,6 +66,7 @@ import org.signal.core.util.toInt import org.signal.core.util.update import org.signal.core.util.withinTransaction import org.signal.glide.decryptableuri.DecryptableUri +import org.signal.network.api.AttachmentUploadResult import org.thoughtcrime.securesms.attachments.ArchivedAttachment import org.thoughtcrime.securesms.attachments.Attachment import org.thoughtcrime.securesms.attachments.AttachmentId @@ -107,7 +108,6 @@ import org.thoughtcrime.securesms.util.ImageCompressionUtil import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.RemoteConfig import org.thoughtcrime.securesms.video.EncryptedMediaDataSource -import org.whispersystems.signalservice.api.attachment.AttachmentUploadResult import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil import org.whispersystems.signalservice.internal.crypto.PaddingInputStream import java.io.ByteArrayInputStream 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 3908b098e4..6a0b0c60e3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/AppDependencies.kt @@ -17,6 +17,7 @@ import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations import org.signal.mediasend.MediaSendDependencies import org.signal.network.api.ArchiveApi +import org.signal.network.api.AttachmentApi import org.signal.network.api.CallingApi import org.signal.network.api.CdsApi import org.signal.network.api.CertificateApi @@ -27,6 +28,7 @@ import org.signal.network.api.RateLimitChallengeApi import org.signal.network.api.RemoteConfigApi import org.signal.network.api.SvrBApi import org.signal.network.api.UsernameApi +import org.signal.network.rest.SignalRestClient import org.thoughtcrime.securesms.BuildConfig import org.thoughtcrime.securesms.components.TypingStatusRepository import org.thoughtcrime.securesms.components.TypingStatusSender @@ -63,7 +65,6 @@ import org.whispersystems.signalservice.api.SignalServiceDataStore import org.whispersystems.signalservice.api.SignalServiceMessageReceiver import org.whispersystems.signalservice.api.SignalServiceMessageSender import org.whispersystems.signalservice.api.account.AccountApi -import org.whispersystems.signalservice.api.attachment.AttachmentApi import org.whispersystems.signalservice.api.donations.DonationsApi import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations import org.whispersystems.signalservice.api.keys.KeysApi @@ -348,6 +349,10 @@ object AppDependencies { val pushServiceSocket: PushServiceSocket get() = networkModule.pushServiceSocket + @JvmStatic + val signalRestClient: SignalRestClient + get() = networkModule.signalRestClient + @JvmStatic val registrationApi: RegistrationApi get() = networkModule.registrationApi @@ -433,9 +438,10 @@ object AppDependencies { interface Provider { fun providePushServiceSocket(signalServiceConfiguration: SignalServiceConfiguration, groupsV2Operations: GroupsV2Operations): PushServiceSocket + fun provideSignalRestClient(signalServiceConfiguration: SignalServiceConfiguration): SignalRestClient fun provideGroupsV2Operations(signalServiceConfiguration: SignalServiceConfiguration): GroupsV2Operations fun provideSignalServiceAccountManager(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, accountApi: AccountApi, pushServiceSocket: PushServiceSocket, groupsV2Operations: GroupsV2Operations): SignalServiceAccountManager - fun provideSignalServiceMessageSender(protocolStore: SignalServiceDataStore, pushServiceSocket: PushServiceSocket, attachmentApi: AttachmentApi, messageApi: MessageApi, keysApi: KeysApi): SignalServiceMessageSender + fun provideSignalServiceMessageSender(protocolStore: SignalServiceDataStore, pushServiceSocket: PushServiceSocket, messageApi: MessageApi, keysApi: KeysApi): SignalServiceMessageSender fun provideSignalServiceMessageReceiver(pushServiceSocket: PushServiceSocket): SignalServiceMessageReceiver fun provideSignalServiceNetworkAccess(): SignalServiceNetworkAccess fun provideRecipientCache(): LiveRecipientCache @@ -471,7 +477,7 @@ object AppDependencies { fun providePinnedMessageManager(): PinnedMessageManager fun provideLibsignalNetwork(config: SignalServiceConfiguration): Network fun provideBillingApi(): BillingApi - fun provideArchiveApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket, pushServiceSocket: PushServiceSocket): ArchiveApi + fun provideArchiveApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket, pushServiceSocket: PushServiceSocket, signalServiceConfiguration: SignalServiceConfiguration): ArchiveApi fun provideKeysApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket): KeysApi fun provideAttachmentApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, pushServiceSocket: PushServiceSocket): AttachmentApi fun provideLinkDeviceApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket): LinkDeviceApi diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java index 4347d85690..51c8cb9829 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java @@ -18,9 +18,12 @@ import org.signal.core.util.concurrent.DeadlockDetector; import org.signal.core.util.concurrent.SignalExecutors; import org.signal.libsignal.net.Network; import org.signal.libsignal.protocol.SignalProtocolAddress; +import org.signal.libsignal.zkgroup.GenericServerPublicParams; +import org.signal.libsignal.zkgroup.InvalidInputException; import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations; import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations; import org.signal.network.api.ArchiveApi; +import org.signal.network.rest.SignalRestClient; import org.signal.network.api.CallingApi; import org.signal.network.api.CdsApi; import org.signal.network.api.CertificateApi; @@ -104,7 +107,7 @@ import org.whispersystems.signalservice.api.SignalServiceDataStore; import org.whispersystems.signalservice.api.SignalServiceMessageReceiver; import org.whispersystems.signalservice.api.SignalServiceMessageSender; import org.whispersystems.signalservice.api.account.AccountApi; -import org.whispersystems.signalservice.api.attachment.AttachmentApi; +import org.signal.network.api.AttachmentApi; import org.whispersystems.signalservice.api.donations.DonationsApi; import org.whispersystems.signalservice.api.groupsv2.ClientZkOperations; import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations; @@ -154,6 +157,14 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider { RemoteConfig.okHttpAutomaticRetry()); } + @Override + public @NonNull SignalRestClient provideSignalRestClient(@NonNull SignalServiceConfiguration signalServiceConfiguration) { + return new SignalRestClient(signalServiceConfiguration, + BuildConfig.SIGNAL_AGENT, + new DynamicCredentialsProvider(), + RemoteConfig.okHttpAutomaticRetry()); + } + @Override public @NonNull GroupsV2Operations provideGroupsV2Operations(@NonNull SignalServiceConfiguration signalServiceConfiguration) { return new GroupsV2Operations(provideClientZkOperations(signalServiceConfiguration), RemoteConfig.groupLimits().getHardLimit()); @@ -167,13 +178,11 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider { @Override public @NonNull SignalServiceMessageSender provideSignalServiceMessageSender(@NonNull SignalServiceDataStore protocolStore, @NonNull PushServiceSocket pushServiceSocket, - @NonNull AttachmentApi attachmentApi, @NonNull MessageApi messageApi, @NonNull KeysApi keysApi) { return new SignalServiceMessageSender(pushServiceSocket, protocolStore, ReentrantSessionLock.INSTANCE, - attachmentApi, messageApi, keysApi, Optional.of(new SecurityEventListener(context)), @@ -502,8 +511,12 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider { } @Override - public @NonNull ArchiveApi provideArchiveApi(@NonNull SignalWebSocket.AuthenticatedWebSocket authWebSocket, @NonNull SignalWebSocket.UnauthenticatedWebSocket unauthWebSocket, @NonNull PushServiceSocket pushServiceSocket) { - return new ArchiveApi(authWebSocket, unauthWebSocket, pushServiceSocket); + public @NonNull ArchiveApi provideArchiveApi(@NonNull SignalWebSocket.AuthenticatedWebSocket authWebSocket, @NonNull SignalWebSocket.UnauthenticatedWebSocket unauthWebSocket, @NonNull PushServiceSocket pushServiceSocket, @NonNull SignalServiceConfiguration signalServiceConfiguration) { + try { + return new ArchiveApi(authWebSocket, unauthWebSocket, pushServiceSocket, new GenericServerPublicParams(signalServiceConfiguration.getBackupServerPublicParams())); + } catch (InvalidInputException e) { + throw new RuntimeException(e); + } } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkDependenciesModule.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkDependenciesModule.kt index f358593bfe..9d029e580d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkDependenciesModule.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/NetworkDependenciesModule.kt @@ -17,6 +17,7 @@ import org.signal.core.util.resettableLazy import org.signal.libsignal.net.Network import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations import org.signal.network.api.ArchiveApi +import org.signal.network.api.AttachmentApi import org.signal.network.api.CallingApi import org.signal.network.api.CdsApi import org.signal.network.api.CertificateApi @@ -27,6 +28,7 @@ import org.signal.network.api.RateLimitChallengeApi import org.signal.network.api.RemoteConfigApi import org.signal.network.api.SvrBApi import org.signal.network.api.UsernameApi +import org.signal.network.rest.SignalRestClient import org.thoughtcrime.securesms.crypto.storage.SignalServiceDataStoreImpl import org.thoughtcrime.securesms.groups.GroupsV2Authorization import org.thoughtcrime.securesms.groups.GroupsV2AuthorizationMemoryValueCache @@ -40,7 +42,6 @@ import org.whispersystems.signalservice.api.SignalServiceAccountManager import org.whispersystems.signalservice.api.SignalServiceMessageReceiver import org.whispersystems.signalservice.api.SignalServiceMessageSender import org.whispersystems.signalservice.api.account.AccountApi -import org.whispersystems.signalservice.api.attachment.AttachmentApi import org.whispersystems.signalservice.api.donations.DonationsApi import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations import org.whispersystems.signalservice.api.keys.KeysApi @@ -90,7 +91,7 @@ class NetworkDependenciesModule( val protocolStore: SignalServiceDataStoreImpl by _protocolStore private val _signalServiceMessageSender = resettableLazy { - provider.provideSignalServiceMessageSender(protocolStore, pushServiceSocket, attachmentApi, messageApi, keysApi) + provider.provideSignalServiceMessageSender(protocolStore, pushServiceSocket, messageApi, keysApi) } val signalServiceMessageSender: SignalServiceMessageSender by _signalServiceMessageSender @@ -102,6 +103,10 @@ class NetworkDependenciesModule( provider.providePushServiceSocket(signalServiceNetworkAccess.getConfiguration(), groupsV2Operations) } + val signalRestClient: SignalRestClient by lazy { + provider.provideSignalRestClient(signalServiceNetworkAccess.getConfiguration()) + } + val signalServiceAccountManager: SignalServiceAccountManager by lazy { provider.provideSignalServiceAccountManager(authWebSocket, accountApi, pushServiceSocket, groupsV2Operations) } @@ -150,7 +155,7 @@ class NetworkDependenciesModule( } val archiveApi: ArchiveApi by lazy { - provider.provideArchiveApi(authWebSocket, unauthWebSocket, pushServiceSocket) + provider.provideArchiveApi(authWebSocket, unauthWebSocket, pushServiceSocket, signalServiceNetworkAccess.getConfiguration()) } val keysApi: KeysApi by lazy { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveThumbnailUploadJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveThumbnailUploadJob.kt index 8dae9442cd..090bd93019 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveThumbnailUploadJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ArchiveThumbnailUploadJob.kt @@ -9,6 +9,7 @@ import org.signal.core.util.Util import org.signal.core.util.logging.Log import org.signal.glide.decryptableuri.DecryptableUri import org.signal.network.NetworkResult +import org.signal.network.api.AttachmentUploadResult import org.thoughtcrime.securesms.attachments.AttachmentId import org.thoughtcrime.securesms.attachments.AttachmentUploadUtil import org.thoughtcrime.securesms.attachments.DatabaseAttachment @@ -30,7 +31,6 @@ import org.thoughtcrime.securesms.net.SignalNetwork import org.thoughtcrime.securesms.util.ImageCompressionUtil import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.RemoteConfig -import org.whispersystems.signalservice.api.attachment.AttachmentUploadResult import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil import org.whispersystems.signalservice.api.messages.SignalServiceAttachment import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.kt index 66ed94f12d..a941ccaa81 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentUploadJob.kt @@ -16,6 +16,7 @@ import org.signal.core.util.readLength import org.signal.libsignal.net.RequestResult import org.signal.libsignal.net.RetryLaterException import org.signal.libsignal.net.UploadTooLargeException +import org.signal.network.api.AttachmentUploadResult import org.signal.protos.resumableuploads.ResumableUpload import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.attachments.Attachment @@ -41,7 +42,6 @@ import org.thoughtcrime.securesms.transport.UndeliverableMessageException import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.MessageUtil import org.thoughtcrime.securesms.util.RemoteConfig -import org.whispersystems.signalservice.api.attachment.AttachmentUploadResult import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil import org.whispersystems.signalservice.api.messages.AttachmentTransferProgress import org.whispersystems.signalservice.api.messages.SignalServiceAttachment diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceContactUpdateJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceContactUpdateJob.java index 30228b87f1..df2a3a3e4e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceContactUpdateJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceContactUpdateJob.java @@ -19,6 +19,7 @@ import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; import org.thoughtcrime.securesms.database.RecipientTable; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.model.IdentityRecord; +import org.signal.network.service.CdnService; import org.thoughtcrime.securesms.dependencies.AppDependencies; import org.thoughtcrime.securesms.jobmanager.Job; import org.thoughtcrime.securesms.jobmanager.JsonJobData; @@ -286,11 +287,12 @@ public class MultiDeviceContactUpdateJob extends BaseJob { { if (length > 0) { try { + CdnService cdnService = new CdnService(AppDependencies.getSignalRestClient(), AppDependencies.getAttachmentApi()); SignalServiceAttachmentStream.Builder attachmentStream = SignalServiceAttachment.newStreamBuilder() .withStream(stream) .withContentType("application/octet-stream") .withLength(length) - .withResumableUploadSpec(messageSender.getResumableUploadSpec(AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(length)))); + .withResumableUploadSpec(cdnService.getResumableUploadSpecBlocking(AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(length)))); messageSender.sendSyncMessage(SignalServiceSyncMessage.forContacts(new ContactsMessage(attachmentStream.build(), complete)) ); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceProfileKeyUpdateJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceProfileKeyUpdateJob.java index 5f0ecee1d5..ab560fdde2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceProfileKeyUpdateJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/MultiDeviceProfileKeyUpdateJob.java @@ -6,6 +6,7 @@ import androidx.annotation.Nullable; import org.signal.core.util.logging.Log; import org.signal.libsignal.zkgroup.profiles.ProfileKey; +import org.signal.network.service.CdnService; import org.thoughtcrime.securesms.BuildConfig; import org.thoughtcrime.securesms.crypto.ProfileKeyUtil; import org.thoughtcrime.securesms.dependencies.AppDependencies; @@ -93,7 +94,8 @@ public class MultiDeviceProfileKeyUpdateJob extends BaseJob { SignalServiceMessageSender messageSender = AppDependencies.getSignalServiceMessageSender(); long dataLength = baos.toByteArray().length; long ciphertextLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(dataLength)); - ResumableUploadSpec uploadSpec = messageSender.getResumableUploadSpec(ciphertextLength); + CdnService cdnService = new CdnService(AppDependencies.getSignalRestClient(), AppDependencies.getAttachmentApi()); + ResumableUploadSpec uploadSpec = cdnService.getResumableUploadSpecBlocking(ciphertextLength); SignalServiceAttachmentStream attachmentStream = SignalServiceAttachment.newStreamBuilder() .withStream(new ByteArrayInputStream(baos.toByteArray())) .withContentType("application/octet-stream") diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.kt index e1558f0af4..82f8764d33 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.kt @@ -16,6 +16,7 @@ import org.signal.core.util.Util import org.signal.core.util.logging.Log import org.signal.libsignal.zkgroup.InvalidInputException import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation +import org.signal.network.service.CdnService import org.thoughtcrime.securesms.BuildConfig import org.thoughtcrime.securesms.TextSecureExpiredException import org.thoughtcrime.securesms.attachments.Attachment @@ -233,7 +234,7 @@ abstract class PushSendJob protected constructor(parameters: Parameters) : BaseJ val inputStream = PartAuthority.getAttachmentStream(context, attachment.uri!!) val ciphertextLength = getCiphertextLength(PaddingInputStream.getPaddedSize(attachment.size)) - val uploadSpec = AppDependencies.signalServiceMessageSender.getResumableUploadSpec(ciphertextLength) + val uploadSpec = CdnService(AppDependencies.signalRestClient, AppDependencies.attachmentApi).getResumableUploadSpecBlocking(ciphertextLength) return SignalServiceAttachment.newStreamBuilder() .withStream(inputStream) diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/UploadAttachmentToArchiveJob.kt b/app/src/main/java/org/thoughtcrime/securesms/jobs/UploadAttachmentToArchiveJob.kt index 79f8d91548..565a65350f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/UploadAttachmentToArchiveJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/UploadAttachmentToArchiveJob.kt @@ -14,6 +14,7 @@ import org.signal.core.util.isNotNullOrBlank import org.signal.core.util.logging.Log import org.signal.core.util.readLength import org.signal.network.NetworkResult +import org.signal.network.api.AttachmentUploadResult import org.signal.protos.resumableuploads.ResumableUpload import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.attachments.AttachmentId @@ -35,7 +36,6 @@ import org.thoughtcrime.securesms.service.AttachmentProgressService import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.RemoteConfig import org.whispersystems.signalservice.api.archive.ArchiveMediaUploadFormStatusCodes -import org.whispersystems.signalservice.api.attachment.AttachmentUploadResult import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil import org.whispersystems.signalservice.api.messages.AttachmentTransferProgress import org.whispersystems.signalservice.api.messages.SignalServiceAttachment diff --git a/app/src/main/java/org/thoughtcrime/securesms/net/SignalNetwork.kt b/app/src/main/java/org/thoughtcrime/securesms/net/SignalNetwork.kt index 7d926a2543..d9669dced4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/net/SignalNetwork.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/net/SignalNetwork.kt @@ -6,6 +6,7 @@ package org.thoughtcrime.securesms.net import org.signal.network.api.ArchiveApi +import org.signal.network.api.AttachmentApi import org.signal.network.api.CallingApi import org.signal.network.api.CdsApi import org.signal.network.api.CertificateApi @@ -19,7 +20,6 @@ import org.signal.network.api.UsernameApi import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.dependencies.KeyTransparencyApi import org.whispersystems.signalservice.api.account.AccountApi -import org.whispersystems.signalservice.api.attachment.AttachmentApi import org.whispersystems.signalservice.api.keys.KeysApi import org.whispersystems.signalservice.api.message.MessageApi import org.whispersystems.signalservice.api.profiles.ProfileApi diff --git a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt index c4848d7ba8..dea15aca1e 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/dependencies/MockApplicationDependencyProvider.kt @@ -7,6 +7,7 @@ import org.signal.libsignal.net.Network import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations import org.signal.network.api.ArchiveApi +import org.signal.network.api.AttachmentApi import org.signal.network.api.CallingApi import org.signal.network.api.CdsApi import org.signal.network.api.CertificateApi @@ -17,6 +18,7 @@ import org.signal.network.api.RateLimitChallengeApi import org.signal.network.api.RemoteConfigApi import org.signal.network.api.SvrBApi import org.signal.network.api.UsernameApi +import org.signal.network.rest.SignalRestClient import org.thoughtcrime.securesms.components.TypingStatusRepository import org.thoughtcrime.securesms.components.TypingStatusSender import org.thoughtcrime.securesms.crypto.storage.SignalServiceDataStoreImpl @@ -50,7 +52,6 @@ import org.whispersystems.signalservice.api.SignalServiceDataStore import org.whispersystems.signalservice.api.SignalServiceMessageReceiver import org.whispersystems.signalservice.api.SignalServiceMessageSender import org.whispersystems.signalservice.api.account.AccountApi -import org.whispersystems.signalservice.api.attachment.AttachmentApi import org.whispersystems.signalservice.api.donations.DonationsApi import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations import org.whispersystems.signalservice.api.keys.KeysApi @@ -70,6 +71,10 @@ class MockApplicationDependencyProvider : AppDependencies.Provider { return mockk(relaxed = true) } + override fun provideSignalRestClient(signalServiceConfiguration: SignalServiceConfiguration): SignalRestClient { + return mockk(relaxed = true) + } + override fun provideGroupsV2Operations(signalServiceConfiguration: SignalServiceConfiguration): GroupsV2Operations { return mockk(relaxed = true) } @@ -81,7 +86,6 @@ class MockApplicationDependencyProvider : AppDependencies.Provider { override fun provideSignalServiceMessageSender( protocolStore: SignalServiceDataStore, pushServiceSocket: PushServiceSocket, - attachmentApi: AttachmentApi, messageApi: MessageApi, keysApi: KeysApi ): SignalServiceMessageSender { @@ -232,7 +236,7 @@ class MockApplicationDependencyProvider : AppDependencies.Provider { return mockk(relaxed = true) } - override fun provideArchiveApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket, pushServiceSocket: PushServiceSocket): ArchiveApi { + override fun provideArchiveApi(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket, pushServiceSocket: PushServiceSocket, signalServiceConfiguration: SignalServiceConfiguration): ArchiveApi { return mockk(relaxed = true) } diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java index 8c6ee6999b..7aff11349b 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java +++ b/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java @@ -19,7 +19,6 @@ import org.signal.libsignal.net.MultiRecipientSendAuthorization; import org.signal.libsignal.net.MultiRecipientSendFailure; import org.signal.libsignal.net.RequestResult; import org.signal.libsignal.net.RequestUnauthorizedException; -import org.signal.libsignal.net.UploadTooLargeException; import org.signal.libsignal.net.RetryLaterException; import org.signal.libsignal.protocol.IdentityKey; import org.signal.libsignal.protocol.IdentityKeyPair; @@ -36,7 +35,6 @@ import org.signal.libsignal.protocol.message.SenderKeyDistributionMessage; import org.signal.libsignal.protocol.state.PreKeyBundle; import org.signal.libsignal.protocol.state.SessionRecord; import org.signal.libsignal.zkgroup.groupsend.GroupSendFullToken; -import org.whispersystems.signalservice.api.attachment.AttachmentApi; import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil; import org.whispersystems.signalservice.api.crypto.ContentHint; import org.whispersystems.signalservice.api.crypto.EnvelopeContent; @@ -103,7 +101,6 @@ import org.whispersystems.signalservice.api.websocket.WebSocketUnavailableExcept import org.whispersystems.signalservice.internal.crypto.AttachmentDigest; import org.whispersystems.signalservice.internal.crypto.PaddingInputStream; import org.whispersystems.signalservice.internal.push.AttachmentPointer; -import org.whispersystems.signalservice.internal.push.AttachmentUploadForm; import org.whispersystems.signalservice.internal.push.BodyRange; import org.whispersystems.signalservice.internal.push.CallMessage; import org.whispersystems.signalservice.internal.push.Content; @@ -186,7 +183,6 @@ public class SignalServiceMessageSender { private final Optional eventListener; private final IdentityKeyPair localPniIdentity; - private final AttachmentApi attachmentApi; private final MessageApi messageApi; private final KeysApi keysApi; private final PreKeyRepository preKeyRepository; @@ -201,7 +197,6 @@ public class SignalServiceMessageSender { public SignalServiceMessageSender(PushServiceSocket pushServiceSocket, SignalServiceDataStore store, SignalSessionLock sessionLock, - AttachmentApi attachmentApi, MessageApi messageApi, KeysApi keysApi, Optional eventListener, @@ -222,7 +217,6 @@ public class SignalServiceMessageSender { this.localDeviceId = credentialsProvider.getDeviceId(); this.localProtocolAddress = new SignalProtocolAddress(localAddress.getIdentifier(), localDeviceId); this.localPni = credentialsProvider.getPni(); - this.attachmentApi = attachmentApi; this.messageApi = messageApi; this.eventListener = eventListener; this.maxEnvelopeSize = maxEnvelopeSize; @@ -841,24 +835,6 @@ public class SignalServiceMessageSender { return uploadAttachmentV4(attachment, attachmentKey, attachmentData); } - public ResumableUploadSpec getResumableUploadSpec(long uploadSizeBytes) throws IOException { - Log.d(TAG, "Using pipe to retrieve attachment upload attributes..."); - RequestResult result = attachmentApi.getAttachmentV4UploadForm(uploadSizeBytes); - - if (result instanceof RequestResult.Success) { - AttachmentUploadForm v4UploadAttributes = ((RequestResult.Success) result).getResult(); - return socket.getResumableUploadSpec(v4UploadAttributes); - } else if (result instanceof RequestResult.NonSuccess) { - throw ((RequestResult.NonSuccess) result).getError(); - } else if (result instanceof RequestResult.RetryableNetworkError) { - throw new PushNetworkException(((RequestResult.RetryableNetworkError) result).getNetworkError()); - } else if (result instanceof RequestResult.ApplicationError) { - throw new RuntimeException(((RequestResult.ApplicationError) result).getCause()); - } else { - throw new IOException("Unexpected RequestResult type: " + result.getClass().getSimpleName()); - } - } - private SignalServiceAttachmentPointer uploadAttachmentV4(SignalServiceAttachmentStream attachment, byte[] attachmentKey, PushAttachmentData attachmentData) throws IOException { AttachmentDigest digest = socket.uploadAttachment(attachmentData); return new SignalServiceAttachmentPointer(attachmentData.getResumableUploadSpec().getCdnNumber(), diff --git a/lib/network/build.gradle.kts b/lib/network/build.gradle.kts index d5addc8fb6..679c3649bd 100644 --- a/lib/network/build.gradle.kts +++ b/lib/network/build.gradle.kts @@ -59,4 +59,5 @@ dependencies { testImplementation(testLibs.junit.junit) testImplementation(testLibs.assertk) testImplementation(testLibs.mockk) + testImplementation(testLibs.kotlinx.coroutines.test) } diff --git a/lib/network/src/main/java/org/signal/network/api/ArchiveApi.kt b/lib/network/src/main/java/org/signal/network/api/ArchiveApi.kt index 9e511294b2..5cc71fc5b0 100644 --- a/lib/network/src/main/java/org/signal/network/api/ArchiveApi.kt +++ b/lib/network/src/main/java/org/signal/network/api/ArchiveApi.kt @@ -57,15 +57,14 @@ import kotlin.time.Duration.Companion.seconds class ArchiveApi( private val authWebSocket: SignalWebSocket.AuthenticatedWebSocket, private val unauthWebSocket: SignalWebSocket.UnauthenticatedWebSocket, - private val pushServiceSocket: PushServiceSocket + private val pushServiceSocket: PushServiceSocket, + private val backupServerPublicParams: GenericServerPublicParams ) { companion object { private val TAG = Log.tag(ArchiveApi::class) } - private val backupServerPublicParams: GenericServerPublicParams = GenericServerPublicParams(pushServiceSocket.configuration.backupServerPublicParams) - /** * Retrieves a set of credentials one can use to authorize other requests. * @@ -246,15 +245,6 @@ class ArchiveApi( } } - /** - * Retrieves a resumable upload URL you can use to upload your main message backup file or an arbitrary media file to cloud storage. - */ - fun getBackupResumableUploadUrl(uploadForm: AttachmentUploadForm): NetworkResult { - return NetworkResult.fromFetch { - pushServiceSocket.getResumableUploadUrl(uploadForm) - } - } - /** * Uploads a pre-encrypted backup file, automatically choosing the best upload strategy based on CDN version. * For CDN3, uses TUS "Creation With Upload" (single POST). For other CDNs, falls back to the legacy @@ -294,7 +284,7 @@ class ArchiveApi( /** * Retrieves an [AttachmentUploadForm] that can be used to upload pre-existing media to the archive. * - * This is basically the same as [org.whispersystems.signalservice.api.attachment.AttachmentApi.getAttachmentV4UploadForm], but with a relaxed rate limit + * This is basically the same as [org.signal.network.api.AttachmentApi.getAttachmentV4UploadForm], but with a relaxed rate limit * so we can request them more often (which is required for backfilling). * * After uploading, the media still needs to be copied via [copyAttachmentToArchive]. diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/attachment/AttachmentApi.kt b/lib/network/src/main/java/org/signal/network/api/AttachmentApi.kt similarity index 99% rename from lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/attachment/AttachmentApi.kt rename to lib/network/src/main/java/org/signal/network/api/AttachmentApi.kt index 7627b1640a..45210ef59d 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/attachment/AttachmentApi.kt +++ b/lib/network/src/main/java/org/signal/network/api/AttachmentApi.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.whispersystems.signalservice.api.attachment +package org.signal.network.api import kotlinx.coroutines.runBlocking import org.signal.core.util.logging.Log diff --git a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/attachment/AttachmentUploadResult.kt b/lib/network/src/main/java/org/signal/network/api/AttachmentUploadResult.kt similarity index 91% rename from lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/attachment/AttachmentUploadResult.kt rename to lib/network/src/main/java/org/signal/network/api/AttachmentUploadResult.kt index 6acc497336..5806d3c927 100644 --- a/lib/libsignal-service/src/main/java/org/whispersystems/signalservice/api/attachment/AttachmentUploadResult.kt +++ b/lib/network/src/main/java/org/signal/network/api/AttachmentUploadResult.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.whispersystems.signalservice.api.attachment +package org.signal.network.api import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId diff --git a/lib/network/src/main/java/org/signal/network/rest/DownloadResult.kt b/lib/network/src/main/java/org/signal/network/rest/DownloadResult.kt new file mode 100644 index 0000000000..720af49c4a --- /dev/null +++ b/lib/network/src/main/java/org/signal/network/rest/DownloadResult.kt @@ -0,0 +1,16 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.network.rest + +/** + * Success outcome of [SignalRestClient.download]. The actual payload was streamed into the + * caller-supplied destination; this carries metadata about the response. + */ +data class DownloadResult( + val statusCode: Int, + val headers: Map, + val totalBytes: Long +) diff --git a/lib/network/src/main/java/org/signal/network/rest/ErrorMapper.kt b/lib/network/src/main/java/org/signal/network/rest/ErrorMapper.kt new file mode 100644 index 0000000000..88b2edca05 --- /dev/null +++ b/lib/network/src/main/java/org/signal/network/rest/ErrorMapper.kt @@ -0,0 +1,16 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.network.rest + +import org.signal.libsignal.net.BadRequestError + +/** + * Maps a non-2xx HTTP response into a typed [BadRequestError]. Pass an instance to + * [SignalRestClient.request] when a request has known business-logic errors. + */ +fun interface ErrorMapper { + fun map(statusCode: Int, headers: Map, body: ByteArray?): E +} diff --git a/lib/network/src/main/java/org/signal/network/rest/RequestResultExtensions.kt b/lib/network/src/main/java/org/signal/network/rest/RequestResultExtensions.kt new file mode 100644 index 0000000000..5581de00e8 --- /dev/null +++ b/lib/network/src/main/java/org/signal/network/rest/RequestResultExtensions.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.network.rest + +import org.signal.libsignal.net.RequestResult +import org.signal.network.NetworkResult +import org.signal.network.exceptions.NonSuccessfulResponseCodeException +import java.io.IOException + +/** + * Bridge a [RequestResult] (returned by [SignalRestClient]) into the older [NetworkResult] surface + * still used by most API classes. Use this during the migration off `PushServiceSocket`. + */ +fun RequestResult.toNetworkResult(): NetworkResult { + return when (this) { + is RequestResult.Success -> NetworkResult.Success(result) + is RequestResult.NonSuccess -> NetworkResult.StatusCodeError(error.toNonSuccessfulResponseCodeException()) + is RequestResult.RetryableNetworkError -> NetworkResult.NetworkError(networkError) + is RequestResult.ApplicationError -> NetworkResult.ApplicationError(cause) + } +} + +/** Build a [NonSuccessfulResponseCodeException] from a [RestStatusCodeError]. */ +fun RestStatusCodeError.toNonSuccessfulResponseCodeException(): NonSuccessfulResponseCodeException { + return NonSuccessfulResponseCodeException( + statusCode, + "Bad response: $statusCode", + body, + headers + ) +} + +/** Convert a [RequestResult] failure into an [IOException]-style throw, matching `NetworkResult.successOrThrow()` shape. */ +@Throws(IOException::class) +fun RequestResult.successOrThrow(): T { + return when (this) { + is RequestResult.Success -> result + is RequestResult.NonSuccess -> throw error.toNonSuccessfulResponseCodeException() + is RequestResult.RetryableNetworkError -> throw networkError + is RequestResult.ApplicationError -> when (val t = cause) { + is IOException -> throw t + is RuntimeException -> throw t + else -> throw RuntimeException(t) + } + } +} diff --git a/lib/network/src/main/java/org/signal/network/rest/RequestSpec.kt b/lib/network/src/main/java/org/signal/network/rest/RequestSpec.kt new file mode 100644 index 0000000000..f6d2b67a8e --- /dev/null +++ b/lib/network/src/main/java/org/signal/network/rest/RequestSpec.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.network.rest + +import okhttp3.RequestBody + +/** + * Description of a single REST request to be executed by [SignalRestClient]. + */ +data class RequestSpec( + val method: Method, + val host: Host, + val path: String, + val body: RequestBody? = null, + val headers: Map = emptyMap(), + val auth: Auth = Auth.None +) { + enum class Method(val value: String) { + GET("GET"), + PUT("PUT"), + POST("POST"), + PATCH("PATCH"), + DELETE("DELETE"), + HEAD("HEAD") + } + + /** Which set of Signal URLs the request should be routed through. */ + sealed interface Host { + /** The standard Signal service URLs. */ + data object Service : Host + + /** A specific Signal CDN. [number] selects the entry in [SignalServiceConfiguration.signalCdnUrlMap]. */ + data class Cdn(val number: Int) : Host + + /** The Signal storage service URLs. */ + data object Storage : Host + } + + /** How (or whether) to attach authentication to the outgoing request. */ + sealed interface Auth { + /** No auth header. */ + data object None : Auth + + /** Basic auth derived from the CredentialsProvider passed to the [SignalRestClient] constructor. */ + data object Standard : Auth + + /** A caller-supplied header. */ + data class Header(val name: String, val value: String) : Auth + } +} diff --git a/lib/network/src/main/java/org/signal/network/rest/RestResponse.kt b/lib/network/src/main/java/org/signal/network/rest/RestResponse.kt new file mode 100644 index 0000000000..05b47860e4 --- /dev/null +++ b/lib/network/src/main/java/org/signal/network/rest/RestResponse.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.network.rest + +/** + * The result of a successful (2xx) [SignalRestClient] request when no response class was provided. + * Holds the raw status, headers, and body bytes for the caller to consume. + */ +data class RestResponse( + val statusCode: Int, + val headers: Map, + val body: ByteArray +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is RestResponse) return false + return statusCode == other.statusCode && headers == other.headers && body.contentEquals(other.body) + } + + override fun hashCode(): Int { + var result = statusCode + result = 31 * result + headers.hashCode() + result = 31 * result + body.contentHashCode() + return result + } +} diff --git a/lib/network/src/main/java/org/signal/network/rest/RestStatusCodeError.kt b/lib/network/src/main/java/org/signal/network/rest/RestStatusCodeError.kt new file mode 100644 index 0000000000..8fba8f7ef2 --- /dev/null +++ b/lib/network/src/main/java/org/signal/network/rest/RestStatusCodeError.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.network.rest + +import org.signal.libsignal.net.BadRequestError + +/** + * Default error returned when a request produces a non-2xx response and the caller didn't supply + * a custom [ErrorMapper]. Carries the raw response info so callers can inspect after the fact. + */ +data class RestStatusCodeError( + val statusCode: Int, + val headers: Map, + val body: ByteArray? +) : BadRequestError { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is RestStatusCodeError) return false + return statusCode == other.statusCode && + headers == other.headers && + (body?.contentEquals(other.body) ?: (other.body == null)) + } + + override fun hashCode(): Int { + var result = statusCode + result = 31 * result + headers.hashCode() + result = 31 * result + (body?.contentHashCode() ?: 0) + return result + } +} diff --git a/lib/network/src/main/java/org/signal/network/rest/SignalRestClient.kt b/lib/network/src/main/java/org/signal/network/rest/SignalRestClient.kt new file mode 100644 index 0000000000..67c6d58e4a --- /dev/null +++ b/lib/network/src/main/java/org/signal/network/rest/SignalRestClient.kt @@ -0,0 +1,549 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.network.rest + +import kotlinx.coroutines.suspendCancellableCoroutine +import okhttp3.Call +import okhttp3.Callback +import okhttp3.ConnectionPool +import okhttp3.ConnectionSpec +import okhttp3.Credentials +import okhttp3.Dns +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okio.Buffer +import okio.BufferedSink +import okio.ByteString +import okio.ForwardingSink +import okio.buffer +import org.signal.core.util.logging.Log +import org.signal.libsignal.net.BadRequestError +import org.signal.libsignal.net.RequestResult +import org.signal.network.rest.RequestSpec.Host +import org.signal.network.util.JsonUtil +import org.whispersystems.signalservice.api.messages.AttachmentTransferProgress +import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener +import org.whispersystems.signalservice.api.push.SignalServiceAddress +import org.whispersystems.signalservice.api.util.CredentialsProvider +import org.whispersystems.signalservice.api.util.Tls12SocketFactory +import org.whispersystems.signalservice.api.util.TlsProxySocketFactory +import org.whispersystems.signalservice.internal.configuration.SignalProxy +import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration +import org.whispersystems.signalservice.internal.configuration.SignalUrl +import org.whispersystems.signalservice.internal.util.BlacklistingTrustManager +import java.io.IOException +import java.io.OutputStream +import java.security.KeyManagementException +import java.security.NoSuchAlgorithmException +import java.security.SecureRandom +import java.util.Optional +import java.util.Random +import java.util.concurrent.TimeUnit +import javax.net.ssl.SSLContext +import javax.net.ssl.X509TrustManager +import kotlin.coroutines.resume +import kotlin.jvm.Throws +import kotlin.reflect.KClass + +/** + * A suspending REST client that handles common infrastructure like CC, cert pinning, DNS, and proxies. + * It also standardizes responses to be [RequestResult]s. + * + * Only use this for requests that cannot be done over the websocket (generally CDN, storage service, etc). + */ +class SignalRestClient @JvmOverloads constructor( + private val configuration: SignalServiceConfiguration, + private val signalAgent: String?, + private val credentialsProvider: CredentialsProvider? = null, + private val automaticNetworkRetry: Boolean = true, + private val socketTimeoutMillis: Long = DEFAULT_TIMEOUT_MILLIS, + private val random: Random = SecureRandom(), + private val clientOverride: OkHttpClient? = null +) { + + companion object { + private val TAG = Log.tag(SignalRestClient::class) + + private const val AUTHORIZATION_HEADER = "Authorization" + private const val RANGE_HEADER = "Range" + private const val DEFAULT_TIMEOUT_MILLIS = 30_000L + private const val DOWNLOAD_BUFFER_SIZE = 32 * 1024 + + private val EMPTY_BODY: RequestBody = ByteArray(0).toRequestBody() + + private val DEFAULT_ERROR_MAPPER = ErrorMapper { statusCode, headers, body -> + RestStatusCodeError(statusCode, headers, body) + } + + private fun createConnectionHolders( + urls: Array, + interceptors: List, + dns: Optional, + proxy: Optional, + clientOverride: OkHttpClient? + ): Array { + return urls.map { url -> + ConnectionHolder( + client = clientOverride ?: buildClient(url, interceptors, dns, proxy), + url = url.url, + hostHeader = url.hostHeader + ) + }.toTypedArray() + } + + private fun buildClient( + url: SignalUrl, + interceptors: List, + dns: Optional, + proxy: Optional + ): OkHttpClient { + try { + val trustManagers = BlacklistingTrustManager.createFor(url.trustStore) + val sslContext = SSLContext.getInstance("TLS").apply { init(null, trustManagers, null) } + + val builder = OkHttpClient.Builder() + .sslSocketFactory(Tls12SocketFactory(sslContext.socketFactory), trustManagers[0] as X509TrustManager) + .connectionSpecs(url.connectionSpecs.orElse(listOf(ConnectionSpec.RESTRICTED_TLS))!!) + .dns(dns.orElse(Dns.SYSTEM)!!) + .connectionPool(ConnectionPool(5, 45, TimeUnit.SECONDS)) + + if (proxy.isPresent) { + builder.socketFactory(TlsProxySocketFactory(proxy.get().host, proxy.get().port, dns)) + } + + for (interceptor in interceptors) { + builder.addInterceptor(interceptor) + } + + return builder.build() + } catch (e: NoSuchAlgorithmException) { + throw AssertionError(e) + } catch (e: KeyManagementException) { + throw AssertionError(e) + } + } + } + + private val serviceClients: Array = createConnectionHolders( + configuration.signalServiceUrls, + configuration.networkInterceptors, + configuration.dns, + configuration.signalProxy, + clientOverride + ) + + private val cdnClientsMap: Map> = configuration.signalCdnUrlMap + .mapValues { (_, urls) -> + createConnectionHolders( + urls = urls, + interceptors = configuration.networkInterceptors, + dns = configuration.dns, + proxy = configuration.signalProxy, + clientOverride = clientOverride + ) + } + + private val storageClients: Array = createConnectionHolders( + configuration.signalStorageUrls, + configuration.networkInterceptors, + configuration.dns, + configuration.signalProxy, + clientOverride + ) + + /** + * Make a request, returning the raw [RestResponse] on success. Non-2xx responses are mapped to + * a [RestStatusCodeError]. If a [progressListener] is supplied and [RequestSpec.body] is non-null, + * upload progress will be reported as bytes are written to the wire. + */ + suspend fun request( + spec: RequestSpec, + progressListener: ProgressListener? = null + ): RequestResult { + return execute(spec, responseClass = null, errorMapper = DEFAULT_ERROR_MAPPER, progressListener = progressListener) + } + + /** + * Make a request and decode the 2xx body via [JsonUtil] (or pass through directly for [Unit], + * [String], or [ByteArray]). Non-2xx responses are mapped to [RestStatusCodeError]. If a + * [progressListener] is supplied and [RequestSpec.body] is non-null, upload progress will be + * reported as bytes are written to the wire. + */ + suspend fun request( + spec: RequestSpec, + responseClass: KClass, + progressListener: ProgressListener? = null + ): RequestResult { + return execute(spec, responseClass = responseClass, errorMapper = DEFAULT_ERROR_MAPPER, progressListener = progressListener) + } + + /** + * Make a request returning the raw [RestResponse] on success, using a custom [ErrorMapper] for + * non-2xx responses. + */ + suspend fun request( + spec: RequestSpec, + errorMapper: ErrorMapper, + progressListener: ProgressListener? = null + ): RequestResult { + return execute(spec, responseClass = null, errorMapper = errorMapper, progressListener = progressListener) + } + + /** + * Make a request, decoding the 2xx body to [T] and mapping non-2xx via the supplied + * [ErrorMapper]. + */ + suspend fun request( + spec: RequestSpec, + responseClass: KClass, + errorMapper: ErrorMapper, + progressListener: ProgressListener? = null + ): RequestResult { + return execute(spec, responseClass = responseClass, errorMapper = errorMapper, progressListener = progressListener) + } + + /** + * Stream the response body for [spec] into [destination], reporting progress through + * [progressListener] as bytes flow. + * + * - [offset] adds a `Range: bytes=-` header (use for resuming partial downloads). It is + * also used as the starting count for progress reporting. + * - [maxSize] caps the total bytes streamed; exceeding it produces a [RequestResult.RetryableNetworkError]. + * - Cancellation propagates from coroutine cancellation and from [ProgressListener.shouldCancel]. + * + * Non-2xx responses produce a [RequestResult.NonSuccess] using [RestStatusCodeError]. + */ + suspend fun download( + spec: RequestSpec, + destination: OutputStream, + offset: Long = 0, + maxSize: Long = Long.MAX_VALUE, + progressListener: ProgressListener? = null + ): RequestResult { + return executeDownload(spec, destination, offset, maxSize, progressListener, DEFAULT_ERROR_MAPPER) + } + + /** + * Same as [download] but with a caller-supplied [ErrorMapper]. + */ + suspend fun download( + spec: RequestSpec, + destination: OutputStream, + errorMapper: ErrorMapper, + offset: Long = 0, + maxSize: Long = Long.MAX_VALUE, + progressListener: ProgressListener? = null + ): RequestResult { + return executeDownload(spec, destination, offset, maxSize, progressListener, errorMapper) + } + + @Suppress("UNCHECKED_CAST") + private suspend fun execute( + spec: RequestSpec, + responseClass: KClass<*>?, + errorMapper: ErrorMapper, + progressListener: ProgressListener? + ): RequestResult { + return try { + val holder = pickHolder(spec.host) + val effectiveBody = spec.body?.let { body -> + if (progressListener != null) ProgressRequestBody(body, progressListener) else body + } + val httpRequest = buildHttpRequest(spec, holder, effectiveBody, extraHeaders = emptyMap()) + val client = newCallClient(holder) + val response = client.newCall(httpRequest).await() + + response.use { resp -> + val body = resp.body.bytes() + val headers = resp.headers.toLowercaseMap() + val code = resp.code + if (code in 200..299) { + val parsed = parseSuccessBody(code, headers, body, responseClass) + RequestResult.Success(parsed as T) + } else { + RequestResult.NonSuccess(errorMapper.map(code, headers, body)) + } + } + } catch (e: IOException) { + RequestResult.RetryableNetworkError(e) + } catch (e: Throwable) { + RequestResult.ApplicationError(e) + } + } + + private suspend fun executeDownload( + spec: RequestSpec, + destination: OutputStream, + offset: Long, + maxSize: Long, + progressListener: ProgressListener?, + errorMapper: ErrorMapper + ): RequestResult { + require(offset >= 0) { "offset must be non-negative (was $offset)" } + require(maxSize >= 0) { "maxSize must be non-negative (was $maxSize)" } + + return try { + val holder = pickHolder(spec.host) + val rangeHeader = if (offset > 0) mapOf(RANGE_HEADER to "bytes=$offset-") else emptyMap() + val httpRequest = buildHttpRequest(spec, holder, body = spec.body, extraHeaders = rangeHeader) + val client = newCallClient(holder) + val call = client.newCall(httpRequest) + val response = call.await() + + response.use { resp -> + val code = resp.code + val headers = resp.headers.toLowercaseMap() + if (code in 200..299) { + val totalBytes = streamToDestination(resp, destination, offset, maxSize, progressListener, call) + RequestResult.Success(DownloadResult(code, headers, totalBytes)) + } else { + val errorBody = runCatching { resp.body.bytes() }.getOrNull() + RequestResult.NonSuccess(errorMapper.map(code, headers, errorBody)) + } + } + } catch (e: IOException) { + RequestResult.RetryableNetworkError(e) + } catch (e: Throwable) { + RequestResult.ApplicationError(e) + } + } + + @Throws(IOException::class) + private fun streamToDestination( + response: Response, + destination: OutputStream, + startingOffset: Long, + maxSize: Long, + progressListener: ProgressListener?, + call: Call + ): Long { + val body = response.body + val contentLength = body.contentLength() + if (contentLength > 0 && contentLength + startingOffset > maxSize) { + throw IOException("Response exceeds max size!") + } + + val totalExpected = if (contentLength > 0) contentLength + startingOffset else -1L + val input = body.byteStream() + val buffer = ByteArray(DOWNLOAD_BUFFER_SIZE) + var totalBytes = startingOffset + + while (true) { + val read = input.read(buffer) + if (read == -1) break + + if (progressListener?.shouldCancel() == true) { + runCatching { call.cancel() } + throw IOException("Canceled by listener check.") + } + + destination.write(buffer, 0, read) + totalBytes += read + + if (totalBytes > maxSize) { + throw IOException("Response exceeded max size!") + } + + if (progressListener != null && totalExpected > 0) { + progressListener.onAttachmentProgress(AttachmentTransferProgress(totalExpected, totalBytes)) + } + } + + return totalBytes + } + + private fun pickHolder(host: Host): ConnectionHolder { + val pool: Array = when (host) { + is Host.Service -> serviceClients + is Host.Storage -> storageClients + is Host.Cdn -> cdnClientsMap[host.number] + ?: throw IllegalArgumentException("No CDN configuration for number ${host.number}") + } + return pool[random.nextInt(pool.size)] + } + + private fun newCallClient(holder: ConnectionHolder): OkHttpClient { + return holder.client.newBuilder() + .connectTimeout(socketTimeoutMillis, TimeUnit.MILLISECONDS) + .readTimeout(socketTimeoutMillis, TimeUnit.MILLISECONDS) + .retryOnConnectionFailure(automaticNetworkRetry) + .build() + } + + private fun buildHttpRequest( + spec: RequestSpec, + holder: ConnectionHolder, + body: RequestBody?, + extraHeaders: Map + ): Request { + val requestBody = body ?: when (spec.method) { + RequestSpec.Method.POST, RequestSpec.Method.PUT, RequestSpec.Method.PATCH -> EMPTY_BODY + else -> null + } + + val builder = Request.Builder() + .url(computeRequestUrl(spec.path, holder)) + .method(spec.method.value, requestBody) + + for ((key, value) in spec.headers) { + builder.addHeader(key, value) + } + + for ((key, value) in extraHeaders) { + if (!spec.headers.containsKey(key)) { + builder.addHeader(key, value) + } + } + + when (val auth = spec.auth) { + is RequestSpec.Auth.None -> Unit + is RequestSpec.Auth.Standard -> { + val provider = checkNotNull(credentialsProvider) { "RequestSpec.Auth.Basic requires a CredentialsProvider on SignalRestClient" } + if (!spec.headers.containsKey(AUTHORIZATION_HEADER)) { + builder.addHeader(AUTHORIZATION_HEADER, basicAuthHeader(provider)) + } else { + Log.w(TAG, "Requested Basic auth, but there was already an auth header. Keeping existing auth header.") + } + } + is RequestSpec.Auth.Header -> { + if (!spec.headers.containsKey(auth.name)) { + builder.addHeader(auth.name, auth.value) + } + } + } + + if (signalAgent != null) { + builder.addHeader("X-Signal-Agent", signalAgent) + } + + holder.hostHeader.ifPresent { builder.addHeader("Host", it) } + + return builder.build() + } + + /** + * Turn a [path] into a concrete [HttpUrl]. For relative paths we just append. For + * absolute URLs (e.g. resumable upload locations the server hands us) we keep the scheme, host, + * port, and base path from the pinned [holder] and graft the supplied path/query/fragment on top + * — the same trick `PushServiceSocket.buildConfiguredUrl` plays so traffic stays on our + * cert-pinned endpoints regardless of what URL the server returned. + */ + private fun computeRequestUrl(path: String, holder: ConnectionHolder): HttpUrl { + if (!path.startsWith("http://") && !path.startsWith("https://")) { + return (holder.url + path).toHttpUrl() + } + + val absolute = path.toHttpUrl() + val base = holder.url.toHttpUrl() + return HttpUrl.Builder() + .scheme(base.scheme) + .host(base.host) + .port(base.port) + .encodedPath(base.encodedPath) + .addEncodedPathSegments(absolute.encodedPath.removePrefix("/")) + .apply { + absolute.encodedQuery?.let { encodedQuery(it) } + absolute.encodedFragment?.let { encodedFragment(it) } + } + .build() + } + + private fun parseSuccessBody( + statusCode: Int, + headers: Map, + body: ByteArray, + responseClass: KClass<*>? + ): Any { + if (responseClass == null) { + return RestResponse(statusCode, headers, body) + } + return when (responseClass) { + Unit::class -> Unit + String::class -> body.toString(Charsets.UTF_8) + ByteArray::class -> body + ByteString::class -> ByteString.of(*body) + else -> JsonUtil.fromJson(body, responseClass.java) + } + } + + private fun basicAuthHeader(provider: CredentialsProvider): String { + val baseIdentifier = provider.aci?.toString() ?: provider.e164 + val identifier = if (provider.deviceId != SignalServiceAddress.DEFAULT_DEVICE_ID) { + "$baseIdentifier.${provider.deviceId}" + } else { + baseIdentifier + } + + return Credentials.basic(identifier, provider.password) + } + + private fun okhttp3.Headers.toLowercaseMap(): Map { + val map = LinkedHashMap(size) + for (i in 0 until size) { + map[name(i).lowercase()] = value(i) + } + return map + } + + private suspend fun Call.await(): Response = suspendCancellableCoroutine { cont -> + cont.invokeOnCancellation { runCatching { cancel() } } + enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + if (!cont.isCancelled) cont.resumeWith(Result.failure(e)) + } + + override fun onResponse(call: Call, response: Response) { + cont.resume(response) + } + }) + } + + private data class ConnectionHolder( + val client: OkHttpClient, + val url: String, + val hostHeader: Optional + ) + + /** + * Wraps an outgoing [RequestBody] to report progress as bytes flow to the underlying sink. Used + * internally when callers pass a [ProgressListener] to one of the upload-capable [request] + * overloads. + */ + private class ProgressRequestBody( + private val delegate: RequestBody, + private val listener: ProgressListener + ) : RequestBody() { + override fun contentType() = delegate.contentType() + + override fun contentLength(): Long = delegate.contentLength() + + override fun isOneShot(): Boolean = delegate.isOneShot() + + override fun writeTo(sink: BufferedSink) { + val total = contentLength() + val countingSink = object : ForwardingSink(sink) { + var written: Long = 0 + + override fun write(source: Buffer, byteCount: Long) { + super.write(source, byteCount) + written += byteCount + if (total > 0) { + listener.onAttachmentProgress(AttachmentTransferProgress(total, written)) + } + } + } + val buffered = countingSink.buffer() + delegate.writeTo(buffered) + buffered.flush() + } + } +} diff --git a/lib/network/src/main/java/org/signal/network/rest/StreamingRequestBody.kt b/lib/network/src/main/java/org/signal/network/rest/StreamingRequestBody.kt new file mode 100644 index 0000000000..5e7efc3f11 --- /dev/null +++ b/lib/network/src/main/java/org/signal/network/rest/StreamingRequestBody.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.network.rest + +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.RequestBody +import okio.BufferedSink +import okio.source +import java.io.InputStream + +/** + * A [RequestBody] that streams its content from an [InputStream] rather than buffering it all in + * memory. Suitable for uploading large files / blobs through [SignalRestClient.request]. + * + * Pair with a [org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener] + * passed to `request()` to receive upload progress as bytes flow. + * + * @param source The data to upload. Will be read once and not closed by this body; callers should + * close it themselves (or wrap it in a stream that closes the underlying resource). + * @param contentLength The total number of bytes to upload, or `-1` if unknown (causes chunked + * transfer encoding). Progress reporting requires a known length. + * @param contentType Optional `Content-Type` for the body, e.g. `"application/octet-stream"`. + */ +class StreamingRequestBody( + private val source: InputStream, + private val contentLength: Long, + private val contentType: String? = null +) : RequestBody() { + + override fun contentType(): MediaType? = contentType?.toMediaTypeOrNull() + + override fun contentLength(): Long = contentLength + + /** [InputStream] cannot be rewound, so retries that require replaying the body are not safe. */ + override fun isOneShot(): Boolean = true + + override fun writeTo(sink: BufferedSink) { + source.source().use { okSource -> + sink.writeAll(okSource) + } + } +} diff --git a/lib/network/src/main/java/org/signal/network/service/CdnService.kt b/lib/network/src/main/java/org/signal/network/service/CdnService.kt new file mode 100644 index 0000000000..ac977e186e --- /dev/null +++ b/lib/network/src/main/java/org/signal/network/service/CdnService.kt @@ -0,0 +1,157 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.network.service + +import kotlinx.coroutines.runBlocking +import org.signal.libsignal.net.RequestResult +import org.signal.libsignal.net.UploadTooLargeException +import org.signal.network.api.AttachmentApi +import org.signal.network.exceptions.PushNetworkException +import org.signal.network.rest.RequestSpec +import org.signal.network.rest.RequestSpec.Method +import org.signal.network.rest.RestStatusCodeError +import org.signal.network.rest.SignalRestClient +import org.signal.network.rest.toNonSuccessfulResponseCodeException +import org.whispersystems.signalservice.internal.push.AttachmentUploadForm +import org.whispersystems.signalservice.internal.push.PushServiceSocket +import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec +import org.whispersystems.signalservice.internal.util.Util +import java.io.IOException + +/** + * Assists in CDN operations. + */ +class CdnService( + private val signalRestClient: SignalRestClient, + private val attachmentApi: AttachmentApi +) { + + /** + * POST to the signed upload location from [uploadForm] to obtain a resumable upload URL. The + * URL is returned in the response's `Location` header. The exact headers we send depend on the + * CDN version the upload form targets: + * + * - CDN 2: legacy resumable upload — `Content-Type: application/octet-stream`. + * - CDN 3: TUS protocol — `Upload-Defer-Length: 1`, `Tus-Resumable: 1.0.0`, plus an optional + * `x-signal-checksum-sha256` if [checksumSha256] is provided. + */ + suspend fun getResumableUploadUrl(uploadForm: AttachmentUploadForm, checksumSha256: String? = null): RequestResult { + val headers = mutableMapOf() + for ((key, value) in uploadForm.headers) { + if (!key.equals("host", ignoreCase = true)) { + headers[key] = value + } + } + headers["Content-Length"] = "0" + + when (uploadForm.cdn) { + 2 -> headers["Content-Type"] = "application/octet-stream" + 3 -> { + headers["Upload-Defer-Length"] = "1" + headers["Tus-Resumable"] = "1.0.0" + if (checksumSha256 != null) { + headers["x-signal-checksum-sha256"] = checksumSha256 + } + } + else -> return RequestResult.ApplicationError(AssertionError("Unknown CDN version: ${uploadForm.cdn}")) + } + + val spec = RequestSpec( + method = Method.POST, + host = RequestSpec.Host.Cdn(uploadForm.cdn), + path = uploadForm.signedUploadLocation, + headers = headers + ) + + return when (val result = signalRestClient.request(spec)) { + is RequestResult.Success -> { + val location = result.result.headers["location"] + if (location != null) { + RequestResult.Success(location) + } else { + RequestResult.ApplicationError(IOException("Missing Location header in resumable-upload response")) + } + } + is RequestResult.NonSuccess -> result + is RequestResult.RetryableNetworkError -> result + is RequestResult.ApplicationError -> result + } + } + + /** + * Fetches a v4 attachment upload form (sized for [uploadSizeBytes]) and turns it into a + * ready-to-use [ResumableUploadSpec]. + * + * This is a composite of two requests (fetch form + fetch resumable URL), so it has more possible + * outcomes than a single request — see [ResumableUploadSpecResult]. + */ + suspend fun getResumableUploadSpec(uploadSizeBytes: Long): ResumableUploadSpecResult { + val form: AttachmentUploadForm = when (val formResult = attachmentApi.getAttachmentV4UploadForm(uploadSizeBytes)) { + is RequestResult.Success -> formResult.result + is RequestResult.NonSuccess -> return ResumableUploadSpecResult.UploadTooLarge(formResult.error) + is RequestResult.RetryableNetworkError -> return ResumableUploadSpecResult.NetworkError(formResult.networkError) + is RequestResult.ApplicationError -> return ResumableUploadSpecResult.ApplicationError(formResult.cause) + } + + return when (val urlResult = getResumableUploadUrl(form)) { + is RequestResult.Success -> ResumableUploadSpecResult.Success( + ResumableUploadSpec( + attachmentKey = Util.getSecretBytes(64), + attachmentIv = Util.getSecretBytes(16), + cdnKey = form.key, + cdnNumber = form.cdn, + resumeLocation = urlResult.result, + expirationTimestamp = System.currentTimeMillis() + PushServiceSocket.CDN2_RESUMABLE_LINK_LIFETIME_MILLIS, + headers = form.headers + ) + ) + is RequestResult.NonSuccess -> ResumableUploadSpecResult.UploadUrlStatusError(urlResult.error) + is RequestResult.RetryableNetworkError -> ResumableUploadSpecResult.NetworkError(urlResult.networkError) + is RequestResult.ApplicationError -> ResumableUploadSpecResult.ApplicationError(urlResult.cause) + } + } + + /** + * Legacy adapter over [getResumableUploadSpec] that throws on failure. Should only be used + * by java code. + */ + @Throws(IOException::class) + fun getResumableUploadSpecBlocking(uploadSizeBytes: Long): ResumableUploadSpec { + return when (val result = runBlocking { getResumableUploadSpec(uploadSizeBytes) }) { + is ResumableUploadSpecResult.Success -> result.spec + is ResumableUploadSpecResult.UploadTooLarge -> throw result.exception + is ResumableUploadSpecResult.UploadUrlStatusError -> throw result.error.toNonSuccessfulResponseCodeException() + is ResumableUploadSpecResult.NetworkError -> throw PushNetworkException(result.exception) + is ResumableUploadSpecResult.ApplicationError -> when (val cause = result.throwable) { + is IOException -> throw cause + is RuntimeException -> throw cause + else -> throw RuntimeException(cause) + } + } + } + + /** + * The possible outcomes of [getResumableUploadSpec]. Because that call composes multiple requests, + * it can't honestly be represented as a single [RequestResult] — each failure mode here means + * something different to a caller. + */ + sealed interface ResumableUploadSpecResult { + /** Got a usable spec. */ + data class Success(val spec: ResumableUploadSpec) : ResumableUploadSpecResult + + /** The server rejected the upload because it's larger than the maximum supported size. */ + data class UploadTooLarge(val exception: UploadTooLargeException) : ResumableUploadSpecResult + + /** The request for a resumable upload URL produced a non-2xx HTTP response. */ + data class UploadUrlStatusError(val error: RestStatusCodeError) : ResumableUploadSpecResult + + /** A retryable network failure occurred during one of the underlying requests. */ + data class NetworkError(val exception: IOException) : ResumableUploadSpecResult + + /** An unexpected client-side failure (likely a bug). */ + data class ApplicationError(val throwable: Throwable) : ResumableUploadSpecResult + } +} diff --git a/lib/network/src/test/java/org/signal/network/rest/SignalRestClientTest.kt b/lib/network/src/test/java/org/signal/network/rest/SignalRestClientTest.kt new file mode 100644 index 0000000000..008e16416f --- /dev/null +++ b/lib/network/src/test/java/org/signal/network/rest/SignalRestClientTest.kt @@ -0,0 +1,217 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.network.rest + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.isSameInstanceAs +import kotlinx.coroutines.runBlocking +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Test +import org.signal.libsignal.net.BadRequestError +import org.signal.libsignal.net.RequestResult +import org.signal.network.rest.RequestSpec.Host +import org.signal.network.rest.RequestSpec.Method +import org.whispersystems.signalservice.api.push.TrustStore +import org.whispersystems.signalservice.internal.configuration.SignalCdnUrl +import org.whispersystems.signalservice.internal.configuration.SignalCdsiUrl +import org.whispersystems.signalservice.internal.configuration.SignalServiceConfiguration +import org.whispersystems.signalservice.internal.configuration.SignalServiceUrl +import org.whispersystems.signalservice.internal.configuration.SignalStorageUrl +import org.whispersystems.signalservice.internal.configuration.SignalSvr2Url +import java.io.ByteArrayInputStream +import java.io.IOException +import java.util.Optional +import java.util.Random + +class SignalRestClientTest { + + private val recordedRequests = mutableListOf() + + /** Default: 200 with an empty JSON body. Override per-test before issuing a request. */ + private var responder: (Request) -> Response = { req -> response(req, 200, "{}") } + + private val recordingClient: OkHttpClient = OkHttpClient.Builder() + .addInterceptor( + Interceptor { chain -> + val request = chain.request() + recordedRequests += request + responder(request) + } + ) + .build() + + @Test + fun `cycles through service urls based on injected random`() = runBlockingTest { + val client = client(random = ScriptedRandom(0, 2, 1, 0)) + + repeat(4) { + client.request(RequestSpec(Method.GET, Host.Service, "/v1/ping")) + } + + assertThat(recordedRequests.map { it.url.host }).isEqualTo( + listOf("service-a.test", "service-c.test", "service-b.test", "service-a.test") + ) + } + + @Test + fun `routes to the cdn pool for the requested cdn number`() = runBlockingTest { + val client = client(random = ScriptedRandom(0, 0)) + + client.request(RequestSpec(Method.GET, Host.Cdn(2), "/file")) + client.request(RequestSpec(Method.GET, Host.Cdn(3), "/file")) + + assertThat(recordedRequests.map { it.url.host }).isEqualTo(listOf("cdn2.test", "cdn3.test")) + } + + @Test + fun `routes to the storage pool`() = runBlockingTest { + val client = client(random = ScriptedRandom(0)) + + client.request(RequestSpec(Method.GET, Host.Storage, "/v1/storage")) + + assertThat(recordedRequests.single().url.host).isEqualTo("storage.test") + } + + @Test + fun `2xx maps to Success`() = runBlockingTest { + responder = { req -> response(req, 200, "hello", extraHeader = "X-Foo" to "Bar") } + val client = client() + + val result = client.request(RequestSpec(Method.GET, Host.Service, "/v1/ping")) + + assertThat(result).isInstanceOf(RequestResult.Success::class) + val success = result as RequestResult.Success + assertThat(success.result.statusCode).isEqualTo(200) + assertThat(String(success.result.body)).isEqualTo("hello") + assertThat(success.result.headers["x-foo"]).isEqualTo("Bar") + } + + @Test + fun `non-2xx maps to NonSuccess with default RestStatusCodeError`() = runBlockingTest { + responder = { req -> response(req, 404, "nope") } + val client = client() + + val result = client.request(RequestSpec(Method.GET, Host.Service, "/v1/ping")) + + assertThat(result).isInstanceOf(RequestResult.NonSuccess::class) + val error = (result as RequestResult.NonSuccess).error + assertThat(error.statusCode).isEqualTo(404) + } + + @Test + fun `non-2xx uses the supplied error mapper`() = runBlockingTest { + responder = { req -> response(req, 500, "boom") } + val client = client() + val mapped = TestError(599) + + val result = client.request( + RequestSpec(Method.GET, Host.Service, "/v1/ping"), + ErrorMapper { _, _, _ -> mapped } + ) + + assertThat(result).isInstanceOf(RequestResult.NonSuccess::class) + assertThat((result as RequestResult.NonSuccess).error).isSameInstanceAs(mapped) + } + + @Test + fun `transport IOException maps to RetryableNetworkError`() = runBlockingTest { + responder = { throw IOException("connection reset") } + val client = client() + + val result = client.request(RequestSpec(Method.GET, Host.Service, "/v1/ping")) + + assertThat(result).isInstanceOf(RequestResult.RetryableNetworkError::class) + } + + @Test + fun `unknown cdn number maps to ApplicationError`() = runBlockingTest { + val client = client() + + val result = client.request(RequestSpec(Method.GET, Host.Cdn(99), "/file")) + + assertThat(result).isInstanceOf(RequestResult.ApplicationError::class) + } + + private fun runBlockingTest(block: suspend () -> Unit) { + runBlocking { block() } + } + + private fun client(random: Random = ScriptedRandom(0)): SignalRestClient { + return SignalRestClient( + configuration = testConfiguration(), + signalAgent = "test-agent", + credentialsProvider = null, + automaticNetworkRetry = false, + socketTimeoutMillis = 1_000, + random = random, + clientOverride = recordingClient + ) + } + + private fun testConfiguration(): SignalServiceConfiguration { + return SignalServiceConfiguration( + signalServiceUrls = arrayOf( + SignalServiceUrl("https://service-a.test", DUMMY_TRUST_STORE), + SignalServiceUrl("https://service-b.test", DUMMY_TRUST_STORE), + SignalServiceUrl("https://service-c.test", DUMMY_TRUST_STORE) + ), + signalCdnUrlMap = mapOf( + 2 to arrayOf(SignalCdnUrl("https://cdn2.test", DUMMY_TRUST_STORE)), + 3 to arrayOf(SignalCdnUrl("https://cdn3.test", DUMMY_TRUST_STORE)) + ), + signalStorageUrls = arrayOf(SignalStorageUrl("https://storage.test", DUMMY_TRUST_STORE)), + signalCdsiUrls = emptyArray(), + signalSvr2Urls = emptyArray(), + networkInterceptors = emptyList(), + dns = Optional.empty(), + signalProxy = Optional.empty(), + systemHttpProxy = Optional.empty(), + zkGroupServerPublicParams = ByteArray(0), + genericServerPublicParams = ByteArray(0), + backupServerPublicParams = ByteArray(0), + censored = false + ) + } + + private fun response(request: Request, code: Int, body: String, extraHeader: Pair? = null): Response { + val builder = Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(code) + .message(if (code in 200..299) "OK" else "Error") + .body(body.toResponseBody("application/octet-stream".toMediaType())) + + if (extraHeader != null) { + builder.header(extraHeader.first, extraHeader.second) + } + + return builder.build() + } + + private class TestError(val code: Int) : BadRequestError + + /** A [Random] whose `nextInt(bound)` returns a scripted sequence of values. */ + private class ScriptedRandom(vararg values: Int) : Random() { + private val queue = ArrayDeque(values.toList()) + + override fun nextInt(bound: Int): Int = queue.removeFirst() + } + + companion object { + private val DUMMY_TRUST_STORE = object : TrustStore { + override fun getKeyStoreInputStream() = ByteArrayInputStream(ByteArray(0)) + override fun getKeyStorePassword() = "" + } + } +}