Implement send support for resumable uploads behind a flag.
This commit is contained in:
parent
7c442865c5
commit
2afb939ee6
24 changed files with 913 additions and 97 deletions
|
@ -7,6 +7,7 @@ import android.os.Build;
|
|||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import org.greenrobot.eventbus.EventBus;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
|
@ -26,12 +27,15 @@ import org.thoughtcrime.securesms.logging.Log;
|
|||
import org.thoughtcrime.securesms.mms.PartAuthority;
|
||||
import org.thoughtcrime.securesms.service.GenericForegroundService;
|
||||
import org.thoughtcrime.securesms.service.NotificationController;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.MediaMetadataRetrieverUtil;
|
||||
import org.thoughtcrime.securesms.util.MediaUtil;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.api.SignalServiceMessageSender;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPointer;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.ResumeLocationInvalidException;
|
||||
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
@ -51,8 +55,8 @@ public final class AttachmentUploadJob extends BaseJob {
|
|||
|
||||
private static final long UPLOAD_REUSE_THRESHOLD = TimeUnit.DAYS.toMillis(3);
|
||||
|
||||
private static final String KEY_ROW_ID = "row_id";
|
||||
private static final String KEY_UNIQUE_ID = "unique_id";
|
||||
private static final String KEY_ROW_ID = "row_id";
|
||||
private static final String KEY_UNIQUE_ID = "unique_id";
|
||||
|
||||
/**
|
||||
* Foreground notification shows while uploading attachments above this.
|
||||
|
@ -89,6 +93,18 @@ public final class AttachmentUploadJob extends BaseJob {
|
|||
|
||||
@Override
|
||||
public void onRun() throws Exception {
|
||||
final ResumableUploadSpec resumableUploadSpec;
|
||||
if (FeatureFlags.attachmentsV3()) {
|
||||
Data inputData = requireInputData();
|
||||
if (!inputData.hasString(ResumableUploadSpecJob.KEY_RESUME_SPEC)) {
|
||||
throw new ResumeLocationInvalidException("V3 Attachment upload requires a ResumableUploadSpec");
|
||||
}
|
||||
|
||||
resumableUploadSpec = ResumableUploadSpec.deserialize(inputData.getString(ResumableUploadSpecJob.KEY_RESUME_SPEC));
|
||||
} else {
|
||||
resumableUploadSpec = null;
|
||||
}
|
||||
|
||||
SignalServiceMessageSender messageSender = ApplicationDependencies.getSignalServiceMessageSender();
|
||||
AttachmentDatabase database = DatabaseFactory.getAttachmentDatabase(context);
|
||||
DatabaseAttachment databaseAttachment = database.getAttachment(attachmentId);
|
||||
|
@ -108,7 +124,7 @@ public final class AttachmentUploadJob extends BaseJob {
|
|||
Log.i(TAG, "Uploading attachment for message " + databaseAttachment.getMmsId() + " with ID " + databaseAttachment.getAttachmentId());
|
||||
|
||||
try (NotificationController notification = getNotificationForAttachment(databaseAttachment)) {
|
||||
SignalServiceAttachment localAttachment = getAttachmentFor(databaseAttachment, notification);
|
||||
SignalServiceAttachment localAttachment = getAttachmentFor(databaseAttachment, notification, resumableUploadSpec);
|
||||
SignalServiceAttachmentPointer remoteAttachment = messageSender.uploadAttachment(localAttachment.asStream());
|
||||
Attachment attachment = PointerAttachment.forPointer(Optional.of(remoteAttachment), null, databaseAttachment.getFastPreflightId()).get();
|
||||
|
||||
|
@ -133,10 +149,12 @@ public final class AttachmentUploadJob extends BaseJob {
|
|||
|
||||
@Override
|
||||
protected boolean onShouldRetry(@NonNull Exception exception) {
|
||||
if (exception instanceof ResumeLocationInvalidException) return false;
|
||||
|
||||
return exception instanceof IOException;
|
||||
}
|
||||
|
||||
private @NonNull SignalServiceAttachment getAttachmentFor(Attachment attachment, @Nullable NotificationController notification) throws InvalidAttachmentException {
|
||||
private @NonNull SignalServiceAttachment getAttachmentFor(Attachment attachment, @Nullable NotificationController notification, @Nullable ResumableUploadSpec resumableUploadSpec) throws InvalidAttachmentException {
|
||||
try {
|
||||
if (attachment.getDataUri() == null || attachment.getSize() == 0) throw new IOException("Assertion failed, outgoing attachment has no data!");
|
||||
InputStream is = PartAuthority.getAttachmentStream(context, attachment.getDataUri());
|
||||
|
@ -151,6 +169,7 @@ public final class AttachmentUploadJob extends BaseJob {
|
|||
.withUploadTimestamp(System.currentTimeMillis())
|
||||
.withCaption(attachment.getCaption())
|
||||
.withCancelationSignal(this::isCanceled)
|
||||
.withResumableUploadSpec(resumableUploadSpec)
|
||||
.withListener((total, progress) -> {
|
||||
EventBus.getDefault().postSticky(new PartProgressEvent(attachment, PartProgressEvent.Type.NETWORK, total, progress));
|
||||
if (notification != null) {
|
||||
|
|
|
@ -89,6 +89,7 @@ public final class JobManagerFactories {
|
|||
put(RemoteConfigRefreshJob.KEY, new RemoteConfigRefreshJob.Factory());
|
||||
put(RemoteDeleteSendJob.KEY, new RemoteDeleteSendJob.Factory());
|
||||
put(RequestGroupInfoJob.KEY, new RequestGroupInfoJob.Factory());
|
||||
put(ResumableUploadSpecJob.KEY, new ResumableUploadSpecJob.Factory());
|
||||
put(StorageAccountRestoreJob.KEY, new StorageAccountRestoreJob.Factory());
|
||||
put(RetrieveProfileAvatarJob.KEY, new RetrieveProfileAvatarJob.Factory());
|
||||
put(RetrieveProfileJob.KEY, new RetrieveProfileJob.Factory());
|
||||
|
|
|
@ -107,12 +107,11 @@ public class PushGroupSendJob extends PushSendJob {
|
|||
throw new MmsException("Inactive group!");
|
||||
}
|
||||
|
||||
MmsDatabase database = DatabaseFactory.getMmsDatabase(context);
|
||||
OutgoingMediaMessage message = database.getOutgoingMessage(messageId);
|
||||
JobManager.Chain compressAndUploadAttachment = createCompressingAndUploadAttachmentsChain(jobManager, message);
|
||||
MmsDatabase database = DatabaseFactory.getMmsDatabase(context);
|
||||
OutgoingMediaMessage message = database.getOutgoingMessage(messageId);
|
||||
Set<String> attachmentUploadIds = enqueueCompressingAndUploadAttachmentsChains(jobManager, message);
|
||||
|
||||
compressAndUploadAttachment.then(new PushGroupSendJob(messageId, destination, filterAddress))
|
||||
.enqueue();
|
||||
jobManager.add(new PushGroupSendJob(messageId, destination, filterAddress), attachmentUploadIds);
|
||||
|
||||
} catch (NoSuchMessageException | MmsException e) {
|
||||
Log.w(TAG, "Failed to enqueue message.", e);
|
||||
|
|
|
@ -45,6 +45,7 @@ import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserExce
|
|||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
public class PushMediaSendJob extends PushSendJob {
|
||||
|
||||
|
@ -72,12 +73,11 @@ public class PushMediaSendJob extends PushSendJob {
|
|||
throw new AssertionError();
|
||||
}
|
||||
|
||||
MmsDatabase database = DatabaseFactory.getMmsDatabase(context);
|
||||
OutgoingMediaMessage message = database.getOutgoingMessage(messageId);
|
||||
JobManager.Chain compressAndUploadAttachment = createCompressingAndUploadAttachmentsChain(jobManager, message);
|
||||
MmsDatabase database = DatabaseFactory.getMmsDatabase(context);
|
||||
OutgoingMediaMessage message = database.getOutgoingMessage(messageId);
|
||||
Set<String> attachmentUploadIds = enqueueCompressingAndUploadAttachmentsChains(jobManager, message);
|
||||
|
||||
compressAndUploadAttachment.then(new PushMediaSendJob(messageId, recipient))
|
||||
.enqueue();
|
||||
jobManager.add(new PushMediaSendJob(messageId, recipient), attachmentUploadIds);
|
||||
|
||||
} catch (NoSuchMessageException | MmsException e) {
|
||||
Log.w(TAG, "Failed to enqueue message.", e);
|
||||
|
|
|
@ -57,8 +57,10 @@ import java.io.ByteArrayInputStream;
|
|||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public abstract class PushSendJob extends SendJob {
|
||||
|
@ -146,7 +148,7 @@ public abstract class PushSendJob extends SendJob {
|
|||
return null;
|
||||
}
|
||||
|
||||
protected static JobManager.Chain createCompressingAndUploadAttachmentsChain(@NonNull JobManager jobManager, OutgoingMediaMessage message) {
|
||||
protected static Set<String> enqueueCompressingAndUploadAttachmentsChains(@NonNull JobManager jobManager, OutgoingMediaMessage message) {
|
||||
List<Attachment> attachments = new LinkedList<>();
|
||||
|
||||
attachments.addAll(message.getAttachments());
|
||||
|
@ -162,12 +164,17 @@ public abstract class PushSendJob extends SendJob {
|
|||
.map(Contact.Avatar::getAttachment).withoutNulls()
|
||||
.toList());
|
||||
|
||||
List<AttachmentCompressionJob> compressionJobs = Stream.of(attachments).map(a -> AttachmentCompressionJob.fromAttachment((DatabaseAttachment) a, false, -1)).toList();
|
||||
return new HashSet<>(Stream.of(attachments).map(a -> {
|
||||
AttachmentUploadJob attachmentUploadJob = new AttachmentUploadJob(((DatabaseAttachment) a).getAttachmentId());
|
||||
|
||||
List<AttachmentUploadJob> attachmentJobs = Stream.of(attachments).map(a -> new AttachmentUploadJob(((DatabaseAttachment) a).getAttachmentId())).toList();
|
||||
jobManager.startChain(AttachmentCompressionJob.fromAttachment((DatabaseAttachment) a, false, -1))
|
||||
.then(new ResumableUploadSpecJob())
|
||||
.then(attachmentUploadJob)
|
||||
.enqueue();
|
||||
|
||||
return jobManager.startChain(compressionJobs)
|
||||
.then(attachmentJobs);
|
||||
return attachmentUploadJob.getId();
|
||||
})
|
||||
.toList());
|
||||
}
|
||||
|
||||
protected @NonNull List<SignalServiceAttachment> getAttachmentPointersFor(List<Attachment> attachments) {
|
||||
|
|
|
@ -0,0 +1,77 @@
|
|||
package org.thoughtcrime.securesms.jobs;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
|
||||
import org.thoughtcrime.securesms.jobmanager.Data;
|
||||
import org.thoughtcrime.securesms.jobmanager.Job;
|
||||
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
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";
|
||||
|
||||
public ResumableUploadSpecJob() {
|
||||
this(new Job.Parameters.Builder()
|
||||
.addConstraint(NetworkConstraint.KEY)
|
||||
.setLifespan(TimeUnit.DAYS.toMillis(1))
|
||||
.setMaxAttempts(Parameters.UNLIMITED)
|
||||
.build());
|
||||
}
|
||||
|
||||
private ResumableUploadSpecJob(@NonNull Parameters parameters) {
|
||||
super(parameters);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onRun() throws Exception {
|
||||
if (!FeatureFlags.attachmentsV3()) {
|
||||
Log.i(TAG, "Attachments V3 is not enabled so there is nothing to do!");
|
||||
}
|
||||
|
||||
ResumableUploadSpec resumableUploadSpec = ApplicationDependencies.getSignalServiceMessageSender()
|
||||
.getResumableUploadSpec();
|
||||
|
||||
setOutputData(new Data.Builder()
|
||||
.putString(KEY_RESUME_SPEC, resumableUploadSpec.serialize())
|
||||
.build());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean onShouldRetry(@NonNull Exception e) {
|
||||
return e instanceof IOException;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull Data serialize() {
|
||||
return Data.EMPTY;
|
||||
}
|
||||
|
||||
@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, @NonNull Data data) {
|
||||
return new ResumableUploadSpecJob(parameters);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -59,6 +59,7 @@ import org.thoughtcrime.securesms.jobs.PushMediaSendJob;
|
|||
import org.thoughtcrime.securesms.jobs.PushTextSendJob;
|
||||
import org.thoughtcrime.securesms.jobs.ReactionSendJob;
|
||||
import org.thoughtcrime.securesms.jobs.RemoteDeleteSendJob;
|
||||
import org.thoughtcrime.securesms.jobs.ResumableUploadSpecJob;
|
||||
import org.thoughtcrime.securesms.jobs.SmsSendJob;
|
||||
import org.thoughtcrime.securesms.logging.Log;
|
||||
import org.thoughtcrime.securesms.mms.MmsException;
|
||||
|
@ -275,15 +276,17 @@ public class MessageSender {
|
|||
AttachmentDatabase attachmentDatabase = DatabaseFactory.getAttachmentDatabase(context);
|
||||
DatabaseAttachment databaseAttachment = attachmentDatabase.insertAttachmentForPreUpload(attachment);
|
||||
|
||||
Job compressionJob = AttachmentCompressionJob.fromAttachment(databaseAttachment, false, -1);
|
||||
Job uploadJob = new AttachmentUploadJob(databaseAttachment.getAttachmentId());
|
||||
Job compressionJob = AttachmentCompressionJob.fromAttachment(databaseAttachment, false, -1);
|
||||
Job resumableUploadSpecJob = new ResumableUploadSpecJob();
|
||||
Job uploadJob = new AttachmentUploadJob(databaseAttachment.getAttachmentId());
|
||||
|
||||
ApplicationDependencies.getJobManager()
|
||||
.startChain(compressionJob)
|
||||
.then(resumableUploadSpecJob)
|
||||
.then(uploadJob)
|
||||
.enqueue();
|
||||
|
||||
return new PreUploadResult(databaseAttachment.getAttachmentId(), Arrays.asList(compressionJob.getId(), uploadJob.getId()));
|
||||
return new PreUploadResult(databaseAttachment.getAttachmentId(), Arrays.asList(compressionJob.getId(), resumableUploadSpecJob.getId(), uploadJob.getId()));
|
||||
} catch (MmsException e) {
|
||||
Log.w(TAG, "preUploadPushAttachment() - Failed to upload!", e);
|
||||
return null;
|
||||
|
|
|
@ -80,6 +80,7 @@ import org.whispersystems.signalservice.internal.push.StaleDevices;
|
|||
import org.whispersystems.signalservice.internal.push.exceptions.MismatchedDevicesException;
|
||||
import org.whispersystems.signalservice.internal.push.exceptions.StaleDevicesException;
|
||||
import org.whispersystems.signalservice.internal.push.http.AttachmentCipherOutputStreamFactory;
|
||||
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec;
|
||||
import org.whispersystems.signalservice.internal.util.StaticCredentialsProvider;
|
||||
import org.whispersystems.signalservice.internal.util.Util;
|
||||
import org.whispersystems.util.Base64;
|
||||
|
@ -350,16 +351,18 @@ public class SignalServiceMessageSender {
|
|||
}
|
||||
|
||||
public SignalServiceAttachmentPointer uploadAttachment(SignalServiceAttachmentStream attachment) throws IOException {
|
||||
byte[] attachmentKey = Util.getSecretBytes(64);
|
||||
byte[] attachmentKey = attachment.getResumableUploadSpec().transform(ResumableUploadSpec::getSecretKey).or(() -> Util.getSecretBytes(64));
|
||||
byte[] attachmentIV = attachment.getResumableUploadSpec().transform(ResumableUploadSpec::getIV).or(() -> Util.getSecretBytes(16));
|
||||
long paddedLength = PaddingInputStream.getPaddedSize(attachment.getLength());
|
||||
InputStream dataStream = new PaddingInputStream(attachment.getInputStream(), attachment.getLength());
|
||||
long ciphertextLength = AttachmentCipherOutputStream.getCiphertextLength(paddedLength);
|
||||
PushAttachmentData attachmentData = new PushAttachmentData(attachment.getContentType(),
|
||||
dataStream,
|
||||
ciphertextLength,
|
||||
new AttachmentCipherOutputStreamFactory(attachmentKey),
|
||||
new AttachmentCipherOutputStreamFactory(attachmentKey, attachmentIV),
|
||||
attachment.getListener(),
|
||||
attachment.getCancelationSignal());
|
||||
attachment.getCancelationSignal(),
|
||||
attachment.getResumableUploadSpec().orNull());
|
||||
|
||||
if (attachmentsV3.get()) {
|
||||
return uploadAttachmentV3(attachment, attachmentKey, attachmentData);
|
||||
|
@ -403,7 +406,7 @@ public class SignalServiceMessageSender {
|
|||
attachment.getUploadTimestamp());
|
||||
}
|
||||
|
||||
private SignalServiceAttachmentPointer uploadAttachmentV3(SignalServiceAttachmentStream attachment, byte[] attachmentKey, PushAttachmentData attachmentData) throws IOException {
|
||||
public ResumableUploadSpec getResumableUploadSpec() throws IOException {
|
||||
AttachmentV3UploadAttributes v3UploadAttributes = null;
|
||||
Optional<SignalServiceMessagePipe> localPipe = pipe.get();
|
||||
|
||||
|
@ -421,9 +424,13 @@ public class SignalServiceMessageSender {
|
|||
v3UploadAttributes = socket.getAttachmentV3UploadAttributes();
|
||||
}
|
||||
|
||||
byte[] digest = socket.uploadAttachment(attachmentData, v3UploadAttributes);
|
||||
return new SignalServiceAttachmentPointer(v3UploadAttributes.getCdn(),
|
||||
new SignalServiceAttachmentRemoteId(v3UploadAttributes.getKey()),
|
||||
return socket.getResumableUploadSpec(v3UploadAttributes);
|
||||
}
|
||||
|
||||
private SignalServiceAttachmentPointer uploadAttachmentV3(SignalServiceAttachmentStream attachment, byte[] attachmentKey, PushAttachmentData attachmentData) throws IOException {
|
||||
byte[] digest = socket.uploadAttachment(attachmentData);
|
||||
return new SignalServiceAttachmentPointer(attachmentData.getResumableUploadSpec().getCdnNumber(),
|
||||
new SignalServiceAttachmentRemoteId(attachmentData.getResumableUploadSpec().getCdnKey()),
|
||||
attachment.getContentType(),
|
||||
attachmentKey,
|
||||
Optional.of(Util.toIntExact(attachment.getLength())),
|
||||
|
|
|
@ -10,6 +10,7 @@ import org.whispersystems.signalservice.internal.util.Util;
|
|||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.security.InvalidAlgorithmParameterException;
|
||||
import java.security.InvalidKeyException;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
|
||||
|
@ -18,6 +19,7 @@ import javax.crypto.Cipher;
|
|||
import javax.crypto.IllegalBlockSizeException;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.NoSuchPaddingException;
|
||||
import javax.crypto.spec.IvParameterSpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
public class AttachmentCipherOutputStream extends DigestingOutputStream {
|
||||
|
@ -26,6 +28,7 @@ public class AttachmentCipherOutputStream extends DigestingOutputStream {
|
|||
private final Mac mac;
|
||||
|
||||
public AttachmentCipherOutputStream(byte[] combinedKeyMaterial,
|
||||
byte[] iv,
|
||||
OutputStream outputStream)
|
||||
throws IOException
|
||||
{
|
||||
|
@ -35,12 +38,17 @@ public class AttachmentCipherOutputStream extends DigestingOutputStream {
|
|||
this.mac = initializeMac();
|
||||
byte[][] keyParts = Util.split(combinedKeyMaterial, 32, 32);
|
||||
|
||||
this.cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(keyParts[0], "AES"));
|
||||
if (iv == null) {
|
||||
this.cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(keyParts[0], "AES"));
|
||||
} else {
|
||||
this.cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(keyParts[0], "AES"), new IvParameterSpec(iv));
|
||||
}
|
||||
|
||||
this.mac.init(new SecretKeySpec(keyParts[1], "HmacSHA256"));
|
||||
|
||||
mac.update(cipher.getIV());
|
||||
super.write(cipher.getIV());
|
||||
} catch (InvalidKeyException e) {
|
||||
} catch (InvalidKeyException | InvalidAlgorithmParameterException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,7 +17,7 @@ public abstract class DigestingOutputStream extends FilterOutputStream {
|
|||
super(outputStream);
|
||||
|
||||
try {
|
||||
this.runningDigest = MessageDigest.getInstance("SHA256");
|
||||
this.runningDigest = MessageDigest.getInstance("SHA-256");
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
package org.whispersystems.signalservice.api.crypto;
|
||||
|
||||
import java.io.FilterOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* SkippingOutputStream will skip a number of bytes being written as specified by toSkip and then
|
||||
* continue writing all remaining bytes to the wrapped output stream.
|
||||
*/
|
||||
public class SkippingOutputStream extends FilterOutputStream {
|
||||
|
||||
private long toSkip;
|
||||
|
||||
public SkippingOutputStream(long toSkip, OutputStream wrapped) {
|
||||
super(wrapped);
|
||||
this.toSkip = toSkip;
|
||||
}
|
||||
|
||||
public void write(int b) throws IOException {
|
||||
if (toSkip > 0) {
|
||||
toSkip--;
|
||||
} else {
|
||||
out.write(b);
|
||||
}
|
||||
}
|
||||
|
||||
public void write(byte[] b) throws IOException {
|
||||
write(b, 0, b.length);
|
||||
}
|
||||
|
||||
public void write(byte[] b, int off, int len) throws IOException {
|
||||
if (b == null) {
|
||||
throw new NullPointerException();
|
||||
}
|
||||
|
||||
if (off < 0 || off > b.length || len < 0 || len + off > b.length || len + off < 0) {
|
||||
throw new IndexOutOfBoundsException();
|
||||
}
|
||||
|
||||
if (toSkip > 0) {
|
||||
if (len <= toSkip) {
|
||||
toSkip -= len;
|
||||
} else {
|
||||
out.write(b, off + (int) toSkip, len - (int) toSkip);
|
||||
toSkip = 0;
|
||||
}
|
||||
} else {
|
||||
out.write(b, off, len);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -8,6 +8,7 @@ package org.whispersystems.signalservice.api.messages;
|
|||
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.internal.push.http.CancelationSignal;
|
||||
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec;
|
||||
|
||||
import java.io.InputStream;
|
||||
|
||||
|
@ -40,18 +41,19 @@ public abstract class SignalServiceAttachment {
|
|||
|
||||
public static class Builder {
|
||||
|
||||
private InputStream inputStream;
|
||||
private String contentType;
|
||||
private String fileName;
|
||||
private long length;
|
||||
private ProgressListener listener;
|
||||
private CancelationSignal cancelationSignal;
|
||||
private boolean voiceNote;
|
||||
private int width;
|
||||
private int height;
|
||||
private String caption;
|
||||
private String blurHash;
|
||||
private long uploadTimestamp;
|
||||
private InputStream inputStream;
|
||||
private String contentType;
|
||||
private String fileName;
|
||||
private long length;
|
||||
private ProgressListener listener;
|
||||
private CancelationSignal cancelationSignal;
|
||||
private boolean voiceNote;
|
||||
private int width;
|
||||
private int height;
|
||||
private String caption;
|
||||
private String blurHash;
|
||||
private long uploadTimestamp;
|
||||
private ResumableUploadSpec resumableUploadSpec;
|
||||
|
||||
private Builder() {}
|
||||
|
||||
|
@ -115,6 +117,11 @@ public abstract class SignalServiceAttachment {
|
|||
return this;
|
||||
}
|
||||
|
||||
public Builder withResumableUploadSpec(ResumableUploadSpec resumableUploadSpec) {
|
||||
this.resumableUploadSpec = resumableUploadSpec;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SignalServiceAttachmentStream build() {
|
||||
if (inputStream == null) throw new IllegalArgumentException("Must specify stream!");
|
||||
if (contentType == null) throw new IllegalArgumentException("No content type specified!");
|
||||
|
@ -132,7 +139,8 @@ public abstract class SignalServiceAttachment {
|
|||
Optional.fromNullable(caption),
|
||||
Optional.fromNullable(blurHash),
|
||||
listener,
|
||||
cancelationSignal);
|
||||
cancelationSignal,
|
||||
Optional.fromNullable(resumableUploadSpec));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -8,6 +8,7 @@ package org.whispersystems.signalservice.api.messages;
|
|||
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.signalservice.internal.push.http.CancelationSignal;
|
||||
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec;
|
||||
|
||||
import java.io.InputStream;
|
||||
|
||||
|
@ -16,21 +17,22 @@ import java.io.InputStream;
|
|||
*/
|
||||
public class SignalServiceAttachmentStream extends SignalServiceAttachment {
|
||||
|
||||
private final InputStream inputStream;
|
||||
private final long length;
|
||||
private final Optional<String> fileName;
|
||||
private final ProgressListener listener;
|
||||
private final CancelationSignal cancelationSignal;
|
||||
private final Optional<byte[]> preview;
|
||||
private final boolean voiceNote;
|
||||
private final int width;
|
||||
private final int height;
|
||||
private final long uploadTimestamp;
|
||||
private final Optional<String> caption;
|
||||
private final Optional<String> blurHash;
|
||||
private final InputStream inputStream;
|
||||
private final long length;
|
||||
private final Optional<String> fileName;
|
||||
private final ProgressListener listener;
|
||||
private final CancelationSignal cancelationSignal;
|
||||
private final Optional<byte[]> preview;
|
||||
private final boolean voiceNote;
|
||||
private final int width;
|
||||
private final int height;
|
||||
private final long uploadTimestamp;
|
||||
private final Optional<String> caption;
|
||||
private final Optional<String> blurHash;
|
||||
private final Optional<ResumableUploadSpec> resumableUploadSpec;
|
||||
|
||||
public SignalServiceAttachmentStream(InputStream inputStream, String contentType, long length, Optional<String> fileName, boolean voiceNote, ProgressListener listener, CancelationSignal cancelationSignal) {
|
||||
this(inputStream, contentType, length, fileName, voiceNote, Optional.<byte[]>absent(), 0, 0, System.currentTimeMillis(), Optional.<String>absent(), Optional.<String>absent(), listener, cancelationSignal);
|
||||
this(inputStream, contentType, length, fileName, voiceNote, Optional.<byte[]>absent(), 0, 0, System.currentTimeMillis(), Optional.<String>absent(), Optional.<String>absent(), listener, cancelationSignal, Optional.absent());
|
||||
}
|
||||
|
||||
public SignalServiceAttachmentStream(InputStream inputStream,
|
||||
|
@ -45,21 +47,23 @@ public class SignalServiceAttachmentStream extends SignalServiceAttachment {
|
|||
Optional<String> caption,
|
||||
Optional<String> blurHash,
|
||||
ProgressListener listener,
|
||||
CancelationSignal cancelationSignal)
|
||||
CancelationSignal cancelationSignal,
|
||||
Optional<ResumableUploadSpec> resumableUploadSpec)
|
||||
{
|
||||
super(contentType);
|
||||
this.inputStream = inputStream;
|
||||
this.length = length;
|
||||
this.fileName = fileName;
|
||||
this.listener = listener;
|
||||
this.voiceNote = voiceNote;
|
||||
this.preview = preview;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.uploadTimestamp = uploadTimestamp;
|
||||
this.caption = caption;
|
||||
this.blurHash = blurHash;
|
||||
this.cancelationSignal = cancelationSignal;
|
||||
this.inputStream = inputStream;
|
||||
this.length = length;
|
||||
this.fileName = fileName;
|
||||
this.listener = listener;
|
||||
this.voiceNote = voiceNote;
|
||||
this.preview = preview;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.uploadTimestamp = uploadTimestamp;
|
||||
this.caption = caption;
|
||||
this.blurHash = blurHash;
|
||||
this.cancelationSignal = cancelationSignal;
|
||||
this.resumableUploadSpec = resumableUploadSpec;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -119,4 +123,8 @@ public class SignalServiceAttachmentStream extends SignalServiceAttachment {
|
|||
public long getUploadTimestamp() {
|
||||
return uploadTimestamp;
|
||||
}
|
||||
|
||||
public Optional<ResumableUploadSpec> getResumableUploadSpec() {
|
||||
return resumableUploadSpec;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
package org.whispersystems.signalservice.api.push.exceptions;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public class ResumeLocationInvalidException extends IOException {
|
||||
|
||||
public ResumeLocationInvalidException() {
|
||||
super();
|
||||
}
|
||||
|
||||
public ResumeLocationInvalidException(String s) {
|
||||
super(s);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,176 @@
|
|||
package org.whispersystems.signalservice.internal.push;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.Charset;
|
||||
|
||||
import okio.Buffer;
|
||||
import okio.BufferedSink;
|
||||
import okio.ByteString;
|
||||
import okio.Source;
|
||||
import okio.Timeout;
|
||||
|
||||
/**
|
||||
* NowhereBufferedSync allows a programmer to write out data into the void. This has no memory
|
||||
* implications, as we don't actually store bytes. Supports getting an OutputStream, which also
|
||||
* just writes into the void.
|
||||
*/
|
||||
public class NowhereBufferedSink implements BufferedSink {
|
||||
@Override
|
||||
public Buffer buffer() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BufferedSink write(ByteString byteString) throws IOException {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BufferedSink write(byte[] source) throws IOException {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BufferedSink write(byte[] source, int offset, int byteCount) throws IOException {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long writeAll(Source source) throws IOException {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BufferedSink write(Source source, long byteCount) throws IOException {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BufferedSink writeUtf8(String string) throws IOException {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BufferedSink writeUtf8(String string, int beginIndex, int endIndex) throws IOException {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BufferedSink writeUtf8CodePoint(int codePoint) throws IOException {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BufferedSink writeString(String string, Charset charset) throws IOException {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BufferedSink writeString(String string, int beginIndex, int endIndex, Charset charset) throws IOException {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BufferedSink writeByte(int b) throws IOException {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BufferedSink writeShort(int s) throws IOException {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BufferedSink writeShortLe(int s) throws IOException {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BufferedSink writeInt(int i) throws IOException {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BufferedSink writeIntLe(int i) throws IOException {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BufferedSink writeLong(long v) throws IOException {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BufferedSink writeLongLe(long v) throws IOException {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BufferedSink writeDecimalLong(long v) throws IOException {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BufferedSink writeHexadecimalUnsignedLong(long v) throws IOException {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(Buffer source, long byteCount) throws IOException {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void flush() throws IOException {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public Timeout timeout() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public BufferedSink emit() throws IOException {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public BufferedSink emitCompleteSegments() throws IOException {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public OutputStream outputStream() {
|
||||
return new OutputStream() {
|
||||
@Override
|
||||
public void write(int i) throws IOException {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(byte[] bytes) throws IOException {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(byte[] bytes, int i, int i1) throws IOException {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public int write(ByteBuffer byteBuffer) throws IOException {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isOpen() {
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -9,28 +9,32 @@ package org.whispersystems.signalservice.internal.push;
|
|||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener;
|
||||
import org.whispersystems.signalservice.internal.push.http.CancelationSignal;
|
||||
import org.whispersystems.signalservice.internal.push.http.OutputStreamFactory;
|
||||
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec;
|
||||
|
||||
import java.io.InputStream;
|
||||
|
||||
public class PushAttachmentData {
|
||||
|
||||
private final String contentType;
|
||||
private final InputStream data;
|
||||
private final long dataSize;
|
||||
private final OutputStreamFactory outputStreamFactory;
|
||||
private final ProgressListener listener;
|
||||
private final CancelationSignal cancelationSignal;
|
||||
private final String contentType;
|
||||
private final InputStream data;
|
||||
private final long dataSize;
|
||||
private final OutputStreamFactory outputStreamFactory;
|
||||
private final ProgressListener listener;
|
||||
private final CancelationSignal cancelationSignal;
|
||||
private final ResumableUploadSpec resumableUploadSpec;
|
||||
|
||||
public PushAttachmentData(String contentType, InputStream data, long dataSize,
|
||||
OutputStreamFactory outputStreamFactory, ProgressListener listener,
|
||||
CancelationSignal cancelationSignal)
|
||||
OutputStreamFactory outputStreamFactory,
|
||||
ProgressListener listener, CancelationSignal cancelationSignal,
|
||||
ResumableUploadSpec resumableUploadSpec)
|
||||
{
|
||||
this.contentType = contentType;
|
||||
this.data = data;
|
||||
this.dataSize = dataSize;
|
||||
this.outputStreamFactory = outputStreamFactory;
|
||||
this.listener = listener;
|
||||
this.cancelationSignal = cancelationSignal;
|
||||
this.contentType = contentType;
|
||||
this.data = data;
|
||||
this.dataSize = dataSize;
|
||||
this.outputStreamFactory = outputStreamFactory;
|
||||
this.resumableUploadSpec = resumableUploadSpec;
|
||||
this.listener = listener;
|
||||
this.cancelationSignal = cancelationSignal;
|
||||
}
|
||||
|
||||
public String getContentType() {
|
||||
|
@ -56,4 +60,9 @@ public class PushAttachmentData {
|
|||
public CancelationSignal getCancelationSignal() {
|
||||
return cancelationSignal;
|
||||
}
|
||||
|
||||
public ResumableUploadSpec getResumableUploadSpec() {
|
||||
return resumableUploadSpec;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -56,6 +56,7 @@ import org.whispersystems.signalservice.api.push.exceptions.NotFoundException;
|
|||
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.RateLimitException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.RemoteAttestationResponseExpiredException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.ResumeLocationInvalidException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.UnregisteredUserException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.UsernameMalformedException;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.UsernameTakenException;
|
||||
|
@ -77,6 +78,7 @@ import org.whispersystems.signalservice.internal.push.http.CancelationSignal;
|
|||
import org.whispersystems.signalservice.internal.push.http.DigestingRequestBody;
|
||||
import org.whispersystems.signalservice.internal.push.http.NoCipherOutputStreamFactory;
|
||||
import org.whispersystems.signalservice.internal.push.http.OutputStreamFactory;
|
||||
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec;
|
||||
import org.whispersystems.signalservice.internal.storage.protos.ReadOperation;
|
||||
import org.whispersystems.signalservice.internal.storage.protos.StorageItems;
|
||||
import org.whispersystems.signalservice.internal.storage.protos.StorageManifest;
|
||||
|
@ -191,6 +193,8 @@ public class PushServiceSocket {
|
|||
private static final Map<String, String> NO_HEADERS = Collections.emptyMap();
|
||||
private static final ResponseCodeHandler NO_HANDLER = new EmptyResponseCodeHandler();
|
||||
|
||||
private static final long CDN2_RESUMABLE_LINK_LIFETIME_MILLIS = TimeUnit.DAYS.toMillis(7);
|
||||
|
||||
private long soTimeoutMillis = TimeUnit.SECONDS.toMillis(30);
|
||||
private final Set<Call> connections = new HashSet<>();
|
||||
|
||||
|
@ -929,9 +933,22 @@ public class PushServiceSocket {
|
|||
return new Pair<>(id, digest);
|
||||
}
|
||||
|
||||
public byte[] uploadAttachment(PushAttachmentData attachment, AttachmentV3UploadAttributes uploadAttributes) throws IOException {
|
||||
String resumableUploadUrl = getResumableUploadUrl(uploadAttributes.getSignedUploadLocation(), uploadAttributes.getHeaders());
|
||||
return uploadToCdn2(resumableUploadUrl,
|
||||
public ResumableUploadSpec getResumableUploadSpec(AttachmentV3UploadAttributes uploadAttributes) throws IOException {
|
||||
return new ResumableUploadSpec(Util.getSecretBytes(64),
|
||||
Util.getSecretBytes(16),
|
||||
uploadAttributes.getKey(),
|
||||
uploadAttributes.getCdn(),
|
||||
getResumableUploadUrl(uploadAttributes.getSignedUploadLocation(), uploadAttributes.getHeaders()),
|
||||
System.currentTimeMillis() + CDN2_RESUMABLE_LINK_LIFETIME_MILLIS);
|
||||
}
|
||||
|
||||
public byte[] uploadAttachment(PushAttachmentData attachment) throws IOException {
|
||||
|
||||
if (attachment.getResumableUploadSpec() == null || attachment.getResumableUploadSpec().getExpirationTimestamp() < System.currentTimeMillis()) {
|
||||
throw new ResumeLocationInvalidException();
|
||||
}
|
||||
|
||||
return uploadToCdn2(attachment.getResumableUploadSpec().getResumeLocation(),
|
||||
attachment.getData(),
|
||||
"application/octet-stream",
|
||||
attachment.getDataSize(),
|
||||
|
@ -1036,7 +1053,7 @@ public class PushServiceSocket {
|
|||
.readTimeout(soTimeoutMillis, TimeUnit.MILLISECONDS)
|
||||
.build();
|
||||
|
||||
DigestingRequestBody file = new DigestingRequestBody(data, outputStreamFactory, contentType, length, progressListener, cancelationSignal);
|
||||
DigestingRequestBody file = new DigestingRequestBody(data, outputStreamFactory, contentType, length, progressListener, cancelationSignal, 0);
|
||||
|
||||
RequestBody requestBody = new MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
|
@ -1152,9 +1169,20 @@ public class PushServiceSocket {
|
|||
.readTimeout(soTimeoutMillis, TimeUnit.MILLISECONDS)
|
||||
.build();
|
||||
|
||||
DigestingRequestBody file = new DigestingRequestBody(data, outputStreamFactory, contentType, length, progressListener, cancelationSignal);
|
||||
ResumeInfo resumeInfo = getResumeInfo(resumableUrl, length);
|
||||
DigestingRequestBody file = new DigestingRequestBody(data, outputStreamFactory, contentType, length, progressListener, cancelationSignal, resumeInfo.contentStart);
|
||||
|
||||
if (resumeInfo.contentStart == length) {
|
||||
Log.w(TAG, "Resume start point == content length");
|
||||
try (NowhereBufferedSink buffer = new NowhereBufferedSink()) {
|
||||
file.writeTo(buffer);
|
||||
}
|
||||
return file.getTransmittedDigest();
|
||||
}
|
||||
|
||||
Request.Builder request = new Request.Builder().url(resumableUrl)
|
||||
.put(file);
|
||||
.put(file)
|
||||
.addHeader("Content-Range", resumeInfo.contentRange);
|
||||
|
||||
if (connectionHolder.getHostHeader().isPresent()) {
|
||||
request.header("host", connectionHolder.getHostHeader().get());
|
||||
|
@ -1184,6 +1212,67 @@ public class PushServiceSocket {
|
|||
}
|
||||
}
|
||||
|
||||
private ResumeInfo getResumeInfo(String resumableUrl, long contentLength) throws IOException {
|
||||
ConnectionHolder connectionHolder = getRandom(cdnClientsMap.get(2), random);
|
||||
OkHttpClient okHttpClient = connectionHolder.getClient()
|
||||
.newBuilder()
|
||||
.connectTimeout(soTimeoutMillis, TimeUnit.MILLISECONDS)
|
||||
.readTimeout(soTimeoutMillis, TimeUnit.MILLISECONDS)
|
||||
.build();
|
||||
|
||||
final long offset;
|
||||
final String contentRange;
|
||||
|
||||
Request.Builder request = new Request.Builder().url(resumableUrl)
|
||||
.put(RequestBody.create(null, ""))
|
||||
.addHeader("Content-Range", String.format(Locale.US, "bytes */%d", contentLength));
|
||||
|
||||
if (connectionHolder.getHostHeader().isPresent()) {
|
||||
request.header("host", connectionHolder.getHostHeader().get());
|
||||
}
|
||||
|
||||
Call call = okHttpClient.newCall(request.build());
|
||||
|
||||
synchronized (connections) {
|
||||
connections.add(call);
|
||||
}
|
||||
|
||||
try {
|
||||
Response response;
|
||||
|
||||
try {
|
||||
response = call.execute();
|
||||
} catch (IOException e) {
|
||||
throw new PushNetworkException(e);
|
||||
}
|
||||
|
||||
if (response.isSuccessful()) {
|
||||
offset = contentLength;
|
||||
contentRange = null;
|
||||
} else if (response.code() == 308) {
|
||||
String rangeCompleted = response.header("Range");
|
||||
|
||||
if (rangeCompleted == null) {
|
||||
offset = 0;
|
||||
} else {
|
||||
offset = Long.parseLong(rangeCompleted.split("-")[1]) + 1;
|
||||
}
|
||||
|
||||
contentRange = String.format(Locale.US, "bytes %d-%d/%d", offset, contentLength - 1, contentLength);
|
||||
} else if (response.code() == 404) {
|
||||
throw new ResumeLocationInvalidException();
|
||||
} else {
|
||||
throw new NonSuccessfulResponseCodeException("Response: " + response);
|
||||
}
|
||||
} finally {
|
||||
synchronized (connections) {
|
||||
connections.remove(call);
|
||||
}
|
||||
}
|
||||
|
||||
return new ResumeInfo(contentRange, offset);
|
||||
}
|
||||
|
||||
private String makeServiceRequest(String urlFragment, String method, String jsonBody)
|
||||
throws NonSuccessfulResponseCodeException, PushNetworkException
|
||||
{
|
||||
|
@ -1806,4 +1895,13 @@ public class PushServiceSocket {
|
|||
}
|
||||
}
|
||||
|
||||
private final class ResumeInfo {
|
||||
private final String contentRange;
|
||||
private final long contentStart;
|
||||
|
||||
private ResumeInfo(String contentRange, long offset) {
|
||||
this.contentRange = contentRange;
|
||||
this.contentStart = offset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,14 +10,16 @@ import java.io.OutputStream;
|
|||
public class AttachmentCipherOutputStreamFactory implements OutputStreamFactory {
|
||||
|
||||
private final byte[] key;
|
||||
private final byte[] iv;
|
||||
|
||||
public AttachmentCipherOutputStreamFactory(byte[] key) {
|
||||
public AttachmentCipherOutputStreamFactory(byte[] key, byte[] iv) {
|
||||
this.key = key;
|
||||
this.iv = iv;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DigestingOutputStream createFor(OutputStream wrap) throws IOException {
|
||||
return new AttachmentCipherOutputStream(key, wrap);
|
||||
return new AttachmentCipherOutputStream(key, iv, wrap);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
package org.whispersystems.signalservice.internal.push.http;
|
||||
|
||||
|
||||
import org.whispersystems.libsignal.util.guava.Preconditions;
|
||||
import org.whispersystems.signalservice.api.crypto.DigestingOutputStream;
|
||||
import org.whispersystems.signalservice.api.crypto.SkippingOutputStream;
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment.ProgressListener;
|
||||
|
||||
import java.io.IOException;
|
||||
|
@ -19,6 +21,7 @@ public class DigestingRequestBody extends RequestBody {
|
|||
private final long contentLength;
|
||||
private final ProgressListener progressListener;
|
||||
private final CancelationSignal cancelationSignal;
|
||||
private final long contentStart;
|
||||
|
||||
private byte[] digest;
|
||||
|
||||
|
@ -26,14 +29,19 @@ public class DigestingRequestBody extends RequestBody {
|
|||
OutputStreamFactory outputStreamFactory,
|
||||
String contentType, long contentLength,
|
||||
ProgressListener progressListener,
|
||||
CancelationSignal cancelationSignal)
|
||||
CancelationSignal cancelationSignal,
|
||||
long contentStart)
|
||||
{
|
||||
Preconditions.checkArgument(contentLength >= contentStart);
|
||||
Preconditions.checkArgument(contentStart >= 0);
|
||||
|
||||
this.inputStream = inputStream;
|
||||
this.outputStreamFactory = outputStreamFactory;
|
||||
this.contentType = contentType;
|
||||
this.contentLength = contentLength;
|
||||
this.progressListener = progressListener;
|
||||
this.cancelationSignal = cancelationSignal;
|
||||
this.contentStart = contentStart;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -43,7 +51,7 @@ public class DigestingRequestBody extends RequestBody {
|
|||
|
||||
@Override
|
||||
public void writeTo(BufferedSink sink) throws IOException {
|
||||
DigestingOutputStream outputStream = outputStreamFactory.createFor(sink.outputStream());
|
||||
DigestingOutputStream outputStream = outputStreamFactory.createFor(new SkippingOutputStream(contentStart, sink.outputStream()));
|
||||
byte[] buffer = new byte[8192];
|
||||
|
||||
int read;
|
||||
|
@ -68,7 +76,7 @@ public class DigestingRequestBody extends RequestBody {
|
|||
|
||||
@Override
|
||||
public long contentLength() {
|
||||
if (contentLength > 0) return contentLength;
|
||||
if (contentLength > 0) return contentLength - contentStart;
|
||||
else return -1;
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,93 @@
|
|||
package org.whispersystems.signalservice.internal.push.http;
|
||||
|
||||
import com.google.protobuf.ByteString;
|
||||
|
||||
import org.signal.protos.resumableuploads.ResumableUploads;
|
||||
import org.whispersystems.libsignal.util.guava.Optional;
|
||||
import org.whispersystems.libsignal.util.guava.Preconditions;
|
||||
import org.whispersystems.signalservice.api.push.exceptions.ResumeLocationInvalidException;
|
||||
import org.whispersystems.util.Base64;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
public final class ResumableUploadSpec {
|
||||
|
||||
private final byte[] secretKey;
|
||||
private final byte[] iv;
|
||||
|
||||
private final String cdnKey;
|
||||
private final Integer cdnNumber;
|
||||
private final String resumeLocation;
|
||||
private final Long expirationTimestamp;
|
||||
|
||||
public ResumableUploadSpec(byte[] secretKey,
|
||||
byte[] iv,
|
||||
String cdnKey,
|
||||
int cdnNumber,
|
||||
String resumeLocation,
|
||||
long expirationTimestamp)
|
||||
{
|
||||
this.secretKey = secretKey;
|
||||
this.iv = iv;
|
||||
this.cdnKey = cdnKey;
|
||||
this.cdnNumber = cdnNumber;
|
||||
this.resumeLocation = resumeLocation;
|
||||
this.expirationTimestamp = expirationTimestamp;
|
||||
}
|
||||
|
||||
public byte[] getSecretKey() {
|
||||
return secretKey;
|
||||
}
|
||||
|
||||
public byte[] getIV() {
|
||||
return iv;
|
||||
}
|
||||
|
||||
public String getCdnKey() {
|
||||
return cdnKey;
|
||||
}
|
||||
|
||||
public Integer getCdnNumber() {
|
||||
return cdnNumber;
|
||||
}
|
||||
|
||||
public String getResumeLocation() {
|
||||
return resumeLocation;
|
||||
}
|
||||
|
||||
public Long getExpirationTimestamp() {
|
||||
return expirationTimestamp;
|
||||
}
|
||||
|
||||
public String serialize() {
|
||||
ResumableUploads.ResumableUpload.Builder builder = ResumableUploads.ResumableUpload.newBuilder()
|
||||
.setSecretKey(ByteString.copyFrom(getSecretKey()))
|
||||
.setIv(ByteString.copyFrom(getIV()))
|
||||
.setTimeout(getExpirationTimestamp())
|
||||
.setCdnNumber(getCdnNumber())
|
||||
.setCdnKey(getCdnKey())
|
||||
.setLocation(getResumeLocation())
|
||||
.setTimeout(getExpirationTimestamp());
|
||||
|
||||
return Base64.encodeBytes(builder.build().toByteArray());
|
||||
}
|
||||
|
||||
public static ResumableUploadSpec deserialize(String serializedSpec) throws ResumeLocationInvalidException {
|
||||
if (serializedSpec == null) return null;
|
||||
|
||||
try {
|
||||
ResumableUploads.ResumableUpload resumableUpload = ResumableUploads.ResumableUpload.parseFrom(ByteString.copyFrom(Base64.decode(serializedSpec)));
|
||||
|
||||
return new ResumableUploadSpec(
|
||||
resumableUpload.getSecretKey().toByteArray(),
|
||||
resumableUpload.getIv().toByteArray(),
|
||||
resumableUpload.getCdnKey(),
|
||||
resumableUpload.getCdnNumber(),
|
||||
resumableUpload.getLocation(),
|
||||
resumableUpload.getTimeout()
|
||||
);
|
||||
} catch (IOException e) {
|
||||
throw new ResumeLocationInvalidException();
|
||||
}
|
||||
}
|
||||
}
|
17
libsignal/service/src/main/proto/ResumableUploads.proto
Normal file
17
libsignal/service/src/main/proto/ResumableUploads.proto
Normal file
|
@ -0,0 +1,17 @@
|
|||
/**
|
||||
* Copyright (C) 2020 Open Whisper Systems
|
||||
*
|
||||
* Licensed according to the LICENSE file in this repository.
|
||||
*/
|
||||
syntax = "proto3";
|
||||
|
||||
option java_package = "org.signal.protos.resumableuploads";
|
||||
|
||||
message ResumableUpload {
|
||||
bytes secretKey = 1;
|
||||
bytes iv = 2;
|
||||
string cdnKey = 3;
|
||||
uint32 cdnNumber = 4;
|
||||
string location = 5;
|
||||
uint64 timeout = 6;
|
||||
}
|
|
@ -203,7 +203,7 @@ public class AttachmentCipherTest extends TestCase {
|
|||
|
||||
private static EncryptResult encryptData(byte[] data, byte[] keyMaterial) throws IOException {
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
AttachmentCipherOutputStream encryptStream = new AttachmentCipherOutputStream(keyMaterial, outputStream);
|
||||
AttachmentCipherOutputStream encryptStream = new AttachmentCipherOutputStream(keyMaterial, null, outputStream);
|
||||
|
||||
encryptStream.write(data);
|
||||
encryptStream.flush();
|
||||
|
|
|
@ -0,0 +1,136 @@
|
|||
package org.whispersystems.signalservice.api.crypto;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
public class SkippingOutputStreamTest {
|
||||
|
||||
private final ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
|
||||
@Test
|
||||
public void givenZeroToSkip_whenIWriteInt_thenIGetIntInOutput() throws Exception {
|
||||
// GIVEN
|
||||
SkippingOutputStream testSubject = new SkippingOutputStream(0, outputStream);
|
||||
|
||||
// WHEN
|
||||
testSubject.write(0);
|
||||
|
||||
// THEN
|
||||
assertEquals(1, outputStream.toByteArray().length);
|
||||
assertEquals(0, outputStream.toByteArray()[0]);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenOneToSkip_whenIWriteIntTwice_thenIGetSecondIntInOutput() throws Exception {
|
||||
// GIVEN
|
||||
SkippingOutputStream testSubject = new SkippingOutputStream(1, outputStream);
|
||||
|
||||
// WHEN
|
||||
testSubject.write(0);
|
||||
testSubject.write(1);
|
||||
|
||||
// THEN
|
||||
assertEquals(1, outputStream.toByteArray().length);
|
||||
assertEquals(1, outputStream.toByteArray()[0]);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenZeroToSkip_whenIWriteArray_thenIGetArrayInOutput() throws Exception {
|
||||
// GIVEN
|
||||
byte[] expected = new byte[]{1, 2, 3, 4, 5};
|
||||
SkippingOutputStream testSubject = new SkippingOutputStream(0, outputStream);
|
||||
|
||||
// WHEN
|
||||
testSubject.write(expected);
|
||||
|
||||
// THEN
|
||||
assertEquals(expected.length, outputStream.toByteArray().length);
|
||||
assertArrayEquals(expected, outputStream.toByteArray());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenNonZeroToSkip_whenIWriteArray_thenIGetEndOfArrayInOutput() throws Exception {
|
||||
// GIVEN
|
||||
byte[] expected = new byte[]{1, 2, 3, 4, 5};
|
||||
SkippingOutputStream testSubject = new SkippingOutputStream(3, outputStream);
|
||||
|
||||
// WHEN
|
||||
testSubject.write(expected);
|
||||
|
||||
// THEN
|
||||
assertEquals(2, outputStream.toByteArray().length);
|
||||
assertArrayEquals(new byte[]{4, 5}, outputStream.toByteArray());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenSkipGreaterThanByteArray_whenIWriteArray_thenIGetNoOutput() throws Exception {
|
||||
// GIVEN
|
||||
byte[] array = new byte[]{1, 2, 3, 4, 5};
|
||||
SkippingOutputStream testSubject = new SkippingOutputStream(10, outputStream);
|
||||
|
||||
// WHEN
|
||||
testSubject.write(array);
|
||||
|
||||
// THEN
|
||||
assertEquals(0, outputStream.toByteArray().length);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenZeroToSkip_whenIWriteArrayRange_thenIGetArrayRangeInOutput() throws Exception {
|
||||
// GIVEN
|
||||
byte[] expected = new byte[]{1, 2, 3, 4, 5};
|
||||
SkippingOutputStream testSubject = new SkippingOutputStream(0, outputStream);
|
||||
|
||||
// WHEN
|
||||
testSubject.write(expected, 1, 3);
|
||||
|
||||
// THEN
|
||||
assertEquals(3, outputStream.toByteArray().length);
|
||||
assertArrayEquals(new byte[]{2, 3, 4}, outputStream.toByteArray());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenNonZeroToSkip_whenIWriteArrayRange_thenIGetEndOfArrayRangeInOutput() throws Exception {
|
||||
// GIVEN
|
||||
byte[] expected = new byte[]{1, 2, 3, 4, 5};
|
||||
SkippingOutputStream testSubject = new SkippingOutputStream(1, outputStream);
|
||||
|
||||
// WHEN
|
||||
testSubject.write(expected, 3, 2);
|
||||
|
||||
// THEN
|
||||
assertEquals(1, outputStream.toByteArray().length);
|
||||
assertArrayEquals(new byte[]{5}, outputStream.toByteArray());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenSkipGreaterThanByteArrayRange_whenIWriteArrayRange_thenIGetNoOutput() throws Exception {
|
||||
// GIVEN
|
||||
byte[] array = new byte[]{1, 2, 3, 4, 5};
|
||||
SkippingOutputStream testSubject = new SkippingOutputStream(10, outputStream);
|
||||
|
||||
// WHEN
|
||||
testSubject.write(array, 3, 2);
|
||||
|
||||
// THEN
|
||||
assertEquals(0, outputStream.toByteArray().length);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenSkipGreaterThanByteArrayRange_whenIWriteArrayRangeTwice_thenIGetExpectedOutput() throws Exception {
|
||||
// GIVEN
|
||||
byte[] array = new byte[]{1, 2, 3, 4, 5};
|
||||
SkippingOutputStream testSubject = new SkippingOutputStream(3, outputStream);
|
||||
|
||||
// WHEN
|
||||
testSubject.write(array, 3, 2);
|
||||
testSubject.write(array, 3, 2);
|
||||
|
||||
// THEN
|
||||
assertEquals(1, outputStream.toByteArray().length);
|
||||
assertArrayEquals(new byte[]{5}, outputStream.toByteArray());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
package org.whispersystems.signalservice.internal.push.http;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.whispersystems.signalservice.api.crypto.AttachmentCipherOutputStream;
|
||||
import org.whispersystems.signalservice.internal.util.Util;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
|
||||
import okio.Buffer;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
public class DigestingRequestBodyTest {
|
||||
|
||||
private static int CONTENT_LENGTH = 70000;
|
||||
private static int TOTAL_LENGTH = (int) AttachmentCipherOutputStream.getCiphertextLength(CONTENT_LENGTH);
|
||||
|
||||
private final byte[] attachmentKey = Util.getSecretBytes(64);
|
||||
private final byte[] attachmentIV = Util.getSecretBytes(16);
|
||||
private final byte[] input = Util.getSecretBytes(CONTENT_LENGTH);
|
||||
|
||||
private final OutputStreamFactory outputStreamFactory = new AttachmentCipherOutputStreamFactory(attachmentKey, attachmentIV);
|
||||
|
||||
@Test
|
||||
public void givenSameKeyAndIV_whenIWriteToBuffer_thenIExpectSameTransmittedDigest() throws Exception {
|
||||
DigestingRequestBody fromStart = getBody(0);
|
||||
DigestingRequestBody fromMiddle = getBody(CONTENT_LENGTH / 2);
|
||||
|
||||
try (Buffer buffer = new Buffer()) {
|
||||
fromStart.writeTo(buffer);
|
||||
}
|
||||
|
||||
try (Buffer buffer = new Buffer()) {
|
||||
fromMiddle.writeTo(buffer);
|
||||
}
|
||||
|
||||
assertArrayEquals(fromStart.getTransmittedDigest(), fromMiddle.getTransmittedDigest());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenSameKeyAndIV_whenIWriteToBuffer_thenIExpectSameContents() throws Exception {
|
||||
DigestingRequestBody fromStart = getBody(0);
|
||||
DigestingRequestBody fromMiddle = getBody(CONTENT_LENGTH / 2);
|
||||
|
||||
byte[] cipher1;
|
||||
|
||||
try (Buffer buffer = new Buffer()) {
|
||||
fromStart.writeTo(buffer);
|
||||
|
||||
cipher1 = buffer.readByteArray();
|
||||
}
|
||||
|
||||
byte[] cipher2;
|
||||
|
||||
try (Buffer buffer = new Buffer()) {
|
||||
fromMiddle.writeTo(buffer);
|
||||
|
||||
cipher2 = buffer.readByteArray();
|
||||
}
|
||||
|
||||
assertEquals(cipher1.length, TOTAL_LENGTH);
|
||||
assertEquals(cipher2.length, TOTAL_LENGTH - (CONTENT_LENGTH / 2));
|
||||
|
||||
for (int i = 0; i < cipher2.length; i++) {
|
||||
assertEquals(cipher2[i], cipher1[i + (CONTENT_LENGTH / 2)]);
|
||||
}
|
||||
}
|
||||
|
||||
private DigestingRequestBody getBody(long contentStart) {
|
||||
return new DigestingRequestBody(new ByteArrayInputStream(input), outputStreamFactory, "application/octet", CONTENT_LENGTH, (a, b) -> {}, () -> false, contentStart);
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue