Update to the new attachment upload form libsignal method.

This commit is contained in:
Greyson Parrelli
2026-04-02 11:36:37 -04:00
parent 58e8ea08c2
commit ce6f39ae68
9 changed files with 87 additions and 135 deletions
@@ -12,6 +12,9 @@ import org.signal.core.util.Util
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.inRoundedDays
import org.signal.core.util.logging.Log
import org.signal.libsignal.net.RequestResult
import org.signal.libsignal.net.RetryLaterException
import org.signal.libsignal.net.UploadTooLargeException
import org.signal.protos.resumableuploads.ResumableUpload
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.attachments.Attachment
@@ -171,8 +174,15 @@ class AttachmentUploadJob private constructor(
try {
val existingSpec = uploadSpec?.let { ResumableUploadSpec.from(it) }
val ciphertextLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(databaseAttachment.size))
val uploadForm = if (existingSpec == null) {
SignalNetwork.attachments.getAttachmentV4UploadForm().successOrThrow()
when (val result = SignalNetwork.attachments.getAttachmentV4UploadForm(ciphertextLength)) {
is RequestResult.Success -> result.result
is RequestResult.NonSuccess -> throw result.error
is RequestResult.RetryableNetworkError -> throw RetryLaterException(result.retryAfter)
is RequestResult.ApplicationError -> throw result.cause
}
} else {
null
}
@@ -288,8 +298,16 @@ class AttachmentUploadJob private constructor(
database.setTransferProgressFailed(attachmentId, databaseAttachment.mmsId)
}
override fun getNextRunAttemptBackoff(pastAttemptCount: Int, exception: java.lang.Exception): Long {
if (exception is RetryLaterException && exception.duration != null) {
return exception.duration.toMillis()
}
return super.getNextRunAttemptBackoff(pastAttemptCount, exception)
}
override fun onShouldRetry(exception: Exception): Boolean {
return exception is IOException && exception !is NotPushRegisteredException
return exception is IOException && exception !is NotPushRegisteredException && exception !is UploadTooLargeException
}
@Throws(InvalidAttachmentException::class)
@@ -261,7 +261,6 @@ public final class JobManagerFactories {
put(RemoteDeleteSendJob.KEY, new RemoteDeleteSendJob.Factory());
put(ReportSpamJob.KEY, new ReportSpamJob.Factory());
put(ResendMessageJob.KEY, new ResendMessageJob.Factory());
put(ResumableUploadSpecJob.KEY, new ResumableUploadSpecJob.Factory());
put(RequestGroupV2InfoWorkerJob.KEY, new RequestGroupV2InfoWorkerJob.Factory());
put(RequestGroupV2InfoJob.KEY, new RequestGroupV2InfoJob.Factory());
put(LocalBackupRestoreMediaJob.KEY, new LocalBackupRestoreMediaJob.Factory());
@@ -431,6 +430,7 @@ public final class JobManagerFactories {
put("BackupRestoreJob", new FailingJob.Factory());
put("BackfillDigestsMigrationJob", new PassingMigrationJob.Factory());
put("BackfillDigestJob", new FailingJob.Factory());
put("ResumableUploadSpecJob", new FailingJob.Factory());
}};
}
@@ -35,7 +35,9 @@ import org.thoughtcrime.securesms.util.AppForegroundObserver;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.whispersystems.signalservice.api.SignalServiceMessageSender;
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil;
import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream;
import org.whispersystems.signalservice.api.messages.multidevice.ContactsMessage;
@@ -288,7 +290,7 @@ public class MultiDeviceContactUpdateJob extends BaseJob {
.withStream(stream)
.withContentType("application/octet-stream")
.withLength(length)
.withResumableUploadSpec(messageSender.getResumableUploadSpec());
.withResumableUploadSpec(messageSender.getResumableUploadSpec(AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(length))));
messageSender.sendSyncMessage(SignalServiceSyncMessage.forContacts(new ContactsMessage(attachmentStream.build(), complete))
);
@@ -17,7 +17,9 @@ import org.thoughtcrime.securesms.net.NotPushRegisteredException;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.whispersystems.signalservice.api.SignalServiceMessageSender;
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil;
import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream;
import org.whispersystems.signalservice.api.messages.multidevice.ContactsMessage;
@@ -26,6 +28,7 @@ import org.whispersystems.signalservice.api.messages.multidevice.DeviceContactsO
import org.whispersystems.signalservice.api.messages.multidevice.SignalServiceSyncMessage;
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException;
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
@@ -88,11 +91,14 @@ public class MultiDeviceProfileKeyUpdateJob extends BaseJob {
out.close();
SignalServiceMessageSender messageSender = AppDependencies.getSignalServiceMessageSender();
long dataLength = baos.toByteArray().length;
long ciphertextLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(dataLength));
ResumableUploadSpec uploadSpec = messageSender.getResumableUploadSpec(ciphertextLength);
SignalServiceAttachmentStream attachmentStream = SignalServiceAttachment.newStreamBuilder()
.withStream(new ByteArrayInputStream(baos.toByteArray()))
.withContentType("application/octet-stream")
.withLength(baos.toByteArray().length)
.withResumableUploadSpec(messageSender.getResumableUploadSpec())
.withLength(dataLength)
.withResumableUploadSpec(uploadSpec)
.build();
SignalServiceSyncMessage syncMessage = SignalServiceSyncMessage.forContacts(new ContactsMessage(attachmentStream, false));
@@ -60,7 +60,9 @@ import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.transport.RetryLaterException;
import org.thoughtcrime.securesms.transport.UndeliverableMessageException;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil;
import org.whispersystems.signalservice.api.messages.AttachmentTransferProgress;
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId;
@@ -70,6 +72,7 @@ import org.whispersystems.signalservice.api.messages.shared.SharedContact;
import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException;
import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException;
import org.whispersystems.signalservice.internal.push.BodyRange;
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec;
import java.io.IOException;
import java.io.InputStream;
@@ -171,9 +174,12 @@ public abstract class PushSendJob extends SendJob {
try {
if (attachment.getUri() == null || attachment.size == 0) throw new IOException("Assertion failed, outgoing attachment has no data!");
InputStream is = PartAuthority.getAttachmentStream(context, attachment.getUri());
InputStream inputStream = PartAuthority.getAttachmentStream(context, attachment.getUri());
long ciphertextLength = AttachmentCipherStreamUtil.getCiphertextLength(PaddingInputStream.getPaddedSize(attachment.size));
ResumableUploadSpec uploadSpec = AppDependencies.getSignalServiceMessageSender().getResumableUploadSpec(ciphertextLength);
return SignalServiceAttachment.newStreamBuilder()
.withStream(is)
.withStream(inputStream)
.withContentType(attachment.contentType)
.withLength(attachment.size)
.withFileName(attachment.fileName)
@@ -185,7 +191,7 @@ public abstract class PushSendJob extends SendJob {
.withHeight(attachment.height)
.withCaption(attachment.caption)
.withUuid(attachment.uuid)
.withResumableUploadSpec(AppDependencies.getSignalServiceMessageSender().getResumableUploadSpec())
.withResumableUploadSpec(uploadSpec)
.withListener(new SignalServiceAttachment.ProgressListener() {
@Override
public void onAttachmentProgress(@NonNull AttachmentTransferProgress progress) {
@@ -1,67 +0,0 @@
package org.thoughtcrime.securesms.jobs;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.jobmanager.JsonJobData;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec;
import java.io.IOException;
/**
* No longer used. Functionality has been merged into {@link AttachmentUploadJob}.
*/
@Deprecated
public class ResumableUploadSpecJob extends BaseJob {
private static final String TAG = Log.tag(ResumableUploadSpecJob.class);
static final String KEY_RESUME_SPEC = "resume_spec";
public static final String KEY = "ResumableUploadSpecJob";
private ResumableUploadSpecJob(@NonNull Parameters parameters) {
super(parameters);
}
@Override
protected void onRun() throws Exception {
ResumableUploadSpec resumableUploadSpec = AppDependencies.getSignalServiceMessageSender()
.getResumableUploadSpec();
setOutputData(new JsonJobData.Builder()
.putString(KEY_RESUME_SPEC, resumableUploadSpec.serialize())
.serialize());
}
@Override
protected boolean onShouldRetry(@NonNull Exception e) {
return e instanceof IOException;
}
@Override
public @Nullable byte[] serialize() {
return null;
}
@Override
public @NonNull String getFactoryKey() {
return KEY;
}
@Override
public void onFailure() {
}
public static class Factory implements Job.Factory<ResumableUploadSpecJob> {
@Override
public @NonNull ResumableUploadSpecJob create(@NonNull Parameters parameters, @Nullable byte[] serializedData) {
return new ResumableUploadSpecJob(parameters);
}
}
}
@@ -10,6 +10,7 @@ import org.signal.core.util.logging.logD
import org.signal.core.util.logging.logI
import org.signal.core.util.logging.logW
import org.signal.core.util.toByteArray
import org.signal.libsignal.net.RequestResult
import org.signal.libsignal.protocol.InvalidKeyException
import org.signal.libsignal.protocol.ecc.ECPublicKey
import org.thoughtcrime.securesms.attachments.AttachmentUploadUtil
@@ -337,11 +338,11 @@ object LinkDeviceRepository {
}
Log.d(TAG, "[createAndUploadArchive] Fetching an upload form...")
val uploadForm = when (val result = NetworkResult.withRetry { SignalNetwork.attachments.getAttachmentV4UploadForm() }) {
is NetworkResult.Success -> result.result.logD(TAG, "[createAndUploadArchive] Successfully retrieved upload form.")
is NetworkResult.ApplicationError -> throw result.throwable
is NetworkResult.NetworkError -> return LinkUploadArchiveResult.NetworkError(result.exception).logW(TAG, "[createAndUploadArchive] Network error when fetching form.", result.exception)
is NetworkResult.StatusCodeError -> return LinkUploadArchiveResult.NetworkError(result.exception).logW(TAG, "[createAndUploadArchive] Status code error when fetching form.", result.exception)
val uploadForm = when (val result = SignalNetwork.attachments.getAttachmentV4UploadForm(tempBackupFile.length())) {
is RequestResult.Success -> result.result.logD(TAG, "[createAndUploadArchive] Successfully retrieved upload form.")
is RequestResult.ApplicationError -> throw result.cause
is RequestResult.RetryableNetworkError -> return LinkUploadArchiveResult.NetworkError(result.networkError).logW(TAG, "[createAndUploadArchive] Network error when fetching form.", result.networkError)
is RequestResult.NonSuccess -> return LinkUploadArchiveResult.BadRequest(result.error).logW(TAG, "[createAndUploadArchive] Upload too large when fetching form.", result.error)
}
if (cancellationSignal()) {
@@ -17,6 +17,7 @@ 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;
@@ -832,11 +833,22 @@ public class SignalServiceMessageSender {
return uploadAttachmentV4(attachment, attachmentKey, attachmentData);
}
public ResumableUploadSpec getResumableUploadSpec() throws IOException {
public ResumableUploadSpec getResumableUploadSpec(long uploadSizeBytes) throws IOException {
Log.d(TAG, "Using pipe to retrieve attachment upload attributes...");
AttachmentUploadForm v4UploadAttributes = NetworkResultUtil.toBasicLegacy(attachmentApi.getAttachmentV4UploadForm());
RequestResult<AttachmentUploadForm, UploadTooLargeException> result = attachmentApi.getAttachmentV4UploadForm(uploadSizeBytes);
return socket.getResumableUploadSpec(v4UploadAttributes);
if (result instanceof RequestResult.Success) {
AttachmentUploadForm v4UploadAttributes = ((RequestResult.Success<AttachmentUploadForm>) result).getResult();
return socket.getResumableUploadSpec(v4UploadAttributes);
} else if (result instanceof RequestResult.NonSuccess) {
throw ((RequestResult.NonSuccess<UploadTooLargeException>) 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 {
@@ -5,20 +5,25 @@
package org.whispersystems.signalservice.api.attachment
import kotlinx.coroutines.runBlocking
import org.signal.core.util.logging.Log
import org.signal.libsignal.net.AuthMessagesService
import org.signal.libsignal.net.AuthenticatedChatConnection
import org.signal.libsignal.net.RequestResult
import org.signal.libsignal.net.UploadTooLargeException
import org.signal.libsignal.net.getOrError
import org.whispersystems.signalservice.api.NetworkResult
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream
import org.whispersystems.signalservice.api.websocket.SignalWebSocket
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
import org.whispersystems.signalservice.internal.get
import org.whispersystems.signalservice.internal.push.AttachmentUploadForm
import org.whispersystems.signalservice.internal.push.PushAttachmentData
import org.whispersystems.signalservice.internal.push.PushServiceSocket
import org.whispersystems.signalservice.internal.push.http.AttachmentCipherOutputStreamFactory
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec
import org.whispersystems.signalservice.internal.websocket.WebSocketRequestMessage
import java.io.IOException
import java.io.InputStream
import kotlin.jvm.optionals.getOrNull
@@ -36,56 +41,25 @@ class AttachmentApi(
/**
* Gets a v4 attachment upload form, which provides the necessary information to upload an attachment.
*
* GET /v4/attachments/form/upload
* - 200: Success
* - 413: Too many attempts
* - 429: Too many attempts
*/
fun getAttachmentV4UploadForm(): NetworkResult<AttachmentUploadForm> {
val request = WebSocketRequestMessage.get("/v4/attachments/form/upload")
return NetworkResult.fromWebSocketRequest(authWebSocket, request, AttachmentUploadForm::class)
}
/**
* Uploads an attachment using the v4 upload scheme.
*/
fun uploadAttachmentV4(attachmentStream: SignalServiceAttachmentStream): NetworkResult<AttachmentUploadResult> {
if (attachmentStream.resumableUploadSpec.isEmpty) {
throw IllegalStateException("Attachment must have a resumable upload spec!")
}
return NetworkResult.fromFetch {
val resumableUploadSpec = attachmentStream.resumableUploadSpec.get()
val paddedLength = PaddingInputStream.getPaddedSize(attachmentStream.length)
val dataStream: InputStream = PaddingInputStream(attachmentStream.inputStream, attachmentStream.length)
val ciphertextLength = AttachmentCipherStreamUtil.getCiphertextLength(paddedLength)
val attachmentData = PushAttachmentData(
contentType = attachmentStream.contentType,
data = dataStream,
dataSize = ciphertextLength,
incremental = attachmentStream.isFaststart,
outputStreamFactory = AttachmentCipherOutputStreamFactory(resumableUploadSpec.attachmentKey, resumableUploadSpec.attachmentIv),
listener = attachmentStream.listener,
cancelationSignal = attachmentStream.cancelationSignal,
resumableUploadSpec = attachmentStream.resumableUploadSpec.get()
)
val digestInfo = pushServiceSocket.uploadAttachment(attachmentData)
AttachmentUploadResult(
remoteId = SignalServiceAttachmentRemoteId.V4(resumableUploadSpec.cdnKey),
cdnNumber = resumableUploadSpec.cdnNumber,
key = resumableUploadSpec.attachmentKey,
digest = digestInfo.digest,
incrementalDigest = digestInfo.incrementalDigest,
incrementalDigestChunkSize = digestInfo.incrementalMacChunkSize,
uploadTimestamp = attachmentStream.uploadTimestamp,
dataSize = attachmentStream.length,
blurHash = attachmentStream.blurHash.getOrNull()
)
fun getAttachmentV4UploadForm(uploadSizeBytes: Long): RequestResult<AttachmentUploadForm, UploadTooLargeException> {
return try {
runBlocking {
authWebSocket.runWithChatConnection { chatConnection ->
AuthMessagesService(chatConnection as AuthenticatedChatConnection).getUploadForm(uploadSizeBytes)
}
}.getOrError().map { form ->
AttachmentUploadForm(
cdn = form.cdn,
key = form.key,
headers = form.headers,
signedUploadLocation = form.signedUploadUrl.toString()
)
}
} catch (e: IOException) {
RequestResult.RetryableNetworkError(e)
} catch (e: Throwable) {
RequestResult.ApplicationError(e)
}
}