Implement resumable downloads.

This commit is contained in:
Greyson Parrelli 2019-12-28 10:08:11 -05:00 committed by Alan Evans
parent 7e72c9c33b
commit 4e7b4da941
9 changed files with 92 additions and 57 deletions

View File

@ -134,7 +134,8 @@ public class SignalServiceMessageReceiver {
* *
* @param pointer The {@link SignalServiceAttachmentPointer} * @param pointer The {@link SignalServiceAttachmentPointer}
* received in a {@link SignalServiceDataMessage}. * received in a {@link SignalServiceDataMessage}.
* @param destination The download destination for this attachment. * @param destination The download destination for this attachment. If this file exists, it is
* assumed that this is previously-downloaded content that can be resumed.
* @param listener An optional listener (may be null) to receive callbacks on download progress. * @param listener An optional listener (may be null) to receive callbacks on download progress.
* *
* @return An InputStream that streams the plaintext attachment contents. * @return An InputStream that streams the plaintext attachment contents.

View File

@ -506,7 +506,7 @@ public class PushServiceSocket {
String hexPackId = Hex.toStringCondensed(packId); String hexPackId = Hex.toStringCondensed(packId);
ByteArrayOutputStream output = new ByteArrayOutputStream(); ByteArrayOutputStream output = new ByteArrayOutputStream();
downloadFromCdn(output, String.format(Locale.US, STICKER_PATH, hexPackId, stickerId), 1024 * 1024, null); downloadFromCdn(output, 0, String.format(Locale.US, STICKER_PATH, hexPackId, stickerId), 1024 * 1024, null);
return output.toByteArray(); return output.toByteArray();
} }
@ -517,7 +517,7 @@ public class PushServiceSocket {
String hexPackId = Hex.toStringCondensed(packId); String hexPackId = Hex.toStringCondensed(packId);
ByteArrayOutputStream output = new ByteArrayOutputStream(); ByteArrayOutputStream output = new ByteArrayOutputStream();
downloadFromCdn(output, String.format(STICKER_MANIFEST_PATH, hexPackId), 1024 * 1024, null); downloadFromCdn(output, 0, String.format(STICKER_MANIFEST_PATH, hexPackId), 1024 * 1024, null);
return output.toByteArray(); return output.toByteArray();
} }
@ -771,14 +771,14 @@ public class PushServiceSocket {
private void downloadFromCdn(File destination, String path, int maxSizeBytes, ProgressListener listener) private void downloadFromCdn(File destination, String path, int maxSizeBytes, ProgressListener listener)
throws PushNetworkException, NonSuccessfulResponseCodeException throws PushNetworkException, NonSuccessfulResponseCodeException
{ {
try (FileOutputStream outputStream = new FileOutputStream(destination)) { try (FileOutputStream outputStream = new FileOutputStream(destination, true)) {
downloadFromCdn(outputStream, path, maxSizeBytes, listener); downloadFromCdn(outputStream, destination.length(), path, maxSizeBytes, listener);
} catch (IOException e) { } catch (IOException e) {
throw new PushNetworkException(e); throw new PushNetworkException(e);
} }
} }
private void downloadFromCdn(OutputStream outputStream, String path, int maxSizeBytes, ProgressListener listener) private void downloadFromCdn(OutputStream outputStream, long offset, String path, int maxSizeBytes, ProgressListener listener)
throws PushNetworkException, NonSuccessfulResponseCodeException throws PushNetworkException, NonSuccessfulResponseCodeException
{ {
ConnectionHolder connectionHolder = getRandom(cdnClients, random); ConnectionHolder connectionHolder = getRandom(cdnClients, random);
@ -794,19 +794,25 @@ public class PushServiceSocket {
request.addHeader("Host", connectionHolder.getHostHeader().get()); request.addHeader("Host", connectionHolder.getHostHeader().get());
} }
if (offset > 0) {
Log.i(TAG, "Starting download from CDN with offset " + offset);
request.addHeader("Range", "bytes=" + offset + "-");
}
Call call = okHttpClient.newCall(request.build()); Call call = okHttpClient.newCall(request.build());
synchronized (connections) { synchronized (connections) {
connections.add(call); connections.add(call);
} }
Response response; Response response = null;
ResponseBody body = null;
try { try {
response = call.execute(); response = call.execute();
if (response.isSuccessful()) { if (response.isSuccessful()) {
ResponseBody body = response.body(); body = response.body();
if (body == null) throw new PushNetworkException("No response body!"); if (body == null) throw new PushNetworkException("No response body!");
if (body.contentLength() > maxSizeBytes) throw new PushNetworkException("Response exceeds max size!"); if (body.contentLength() > maxSizeBytes) throw new PushNetworkException("Response exceeds max size!");
@ -814,20 +820,24 @@ public class PushServiceSocket {
InputStream in = body.byteStream(); InputStream in = body.byteStream();
byte[] buffer = new byte[32768]; byte[] buffer = new byte[32768];
int read, totalRead = 0; int read = 0;
long totalRead = offset;
while ((read = in.read(buffer, 0, buffer.length)) != -1) { while ((read = in.read(buffer, 0, buffer.length)) != -1) {
outputStream.write(buffer, 0, read); outputStream.write(buffer, 0, read);
if ((totalRead += read) > maxSizeBytes) throw new PushNetworkException("Response exceeded max size!"); if ((totalRead += read) > maxSizeBytes) throw new PushNetworkException("Response exceeded max size!");
if (listener != null) { if (listener != null) {
listener.onAttachmentProgress(body.contentLength(), totalRead); listener.onAttachmentProgress(body.contentLength() + offset, totalRead);
} }
} }
return; return;
} }
} catch (IOException e) { } catch (IOException e) {
if (body != null) {
body.close();
}
throw new PushNetworkException(e); throw new PushNetworkException(e);
} finally { } finally {
synchronized (connections) { synchronized (connections) {

View File

@ -57,6 +57,7 @@ import org.thoughtcrime.securesms.stickers.StickerLocator;
import org.thoughtcrime.securesms.util.Base64; import org.thoughtcrime.securesms.util.Base64;
import org.thoughtcrime.securesms.util.BitmapDecodingException; import org.thoughtcrime.securesms.util.BitmapDecodingException;
import org.thoughtcrime.securesms.util.BitmapUtil; import org.thoughtcrime.securesms.util.BitmapUtil;
import org.thoughtcrime.securesms.util.FileUtils;
import org.thoughtcrime.securesms.util.JsonUtils; import org.thoughtcrime.securesms.util.JsonUtils;
import org.thoughtcrime.securesms.util.MediaMetadataRetrieverUtil; import org.thoughtcrime.securesms.util.MediaMetadataRetrieverUtil;
import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.MediaUtil;
@ -98,6 +99,7 @@ public class AttachmentDatabase extends Database {
static final String CONTENT_LOCATION = "cl"; static final String CONTENT_LOCATION = "cl";
public static final String DATA = "_data"; public static final String DATA = "_data";
static final String TRANSFER_STATE = "pending_push"; static final String TRANSFER_STATE = "pending_push";
private static final String TRANSFER_FILE = "transfer_file";
public static final String SIZE = "data_size"; public static final String SIZE = "data_size";
static final String FILE_NAME = "file_name"; static final String FILE_NAME = "file_name";
public static final String THUMBNAIL = "thumbnail"; public static final String THUMBNAIL = "thumbnail";
@ -136,7 +138,7 @@ public class AttachmentDatabase extends Database {
UNIQUE_ID, DIGEST, FAST_PREFLIGHT_ID, VOICE_NOTE, UNIQUE_ID, DIGEST, FAST_PREFLIGHT_ID, VOICE_NOTE,
QUOTE, DATA_RANDOM, THUMBNAIL_RANDOM, WIDTH, HEIGHT, QUOTE, DATA_RANDOM, THUMBNAIL_RANDOM, WIDTH, HEIGHT,
CAPTION, STICKER_PACK_ID, STICKER_PACK_KEY, STICKER_ID, CAPTION, STICKER_PACK_ID, STICKER_PACK_KEY, STICKER_ID,
DATA_HASH, BLUR_HASH, TRANSFORM_PROPERTIES}; DATA_HASH, BLUR_HASH, TRANSFORM_PROPERTIES, TRANSFER_FILE };
public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ROW_ID + " INTEGER PRIMARY KEY, " + public static final String CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + ROW_ID + " INTEGER PRIMARY KEY, " +
MMS_ID + " INTEGER, " + "seq" + " INTEGER DEFAULT 0, " + MMS_ID + " INTEGER, " + "seq" + " INTEGER DEFAULT 0, " +
@ -152,7 +154,7 @@ public class AttachmentDatabase extends Database {
CAPTION + " TEXT DEFAULT NULL, " + STICKER_PACK_ID + " TEXT DEFAULT NULL, " + CAPTION + " TEXT DEFAULT NULL, " + STICKER_PACK_ID + " TEXT DEFAULT NULL, " +
STICKER_PACK_KEY + " DEFAULT NULL, " + STICKER_ID + " INTEGER DEFAULT -1, " + STICKER_PACK_KEY + " DEFAULT NULL, " + STICKER_ID + " INTEGER DEFAULT -1, " +
DATA_HASH + " TEXT DEFAULT NULL, " + BLUR_HASH + " TEXT DEFAULT NULL, " + DATA_HASH + " TEXT DEFAULT NULL, " + BLUR_HASH + " TEXT DEFAULT NULL, " +
TRANSFORM_PROPERTIES + " TEXT DEFAULT NULL);"; TRANSFORM_PROPERTIES + " TEXT DEFAULT NULL, " + TRANSFER_FILE + " TEXT DEFAULT NULL);";
public static final String[] CREATE_INDEXS = { public static final String[] CREATE_INDEXS = {
"CREATE INDEX IF NOT EXISTS part_mms_id_index ON " + TABLE_NAME + " (" + MMS_ID + ");", "CREATE INDEX IF NOT EXISTS part_mms_id_index ON " + TABLE_NAME + " (" + MMS_ID + ");",
@ -396,12 +398,7 @@ public class AttachmentDatabase extends Database {
SQLiteDatabase database = databaseHelper.getWritableDatabase(); SQLiteDatabase database = databaseHelper.getWritableDatabase();
database.delete(TABLE_NAME, null, null); database.delete(TABLE_NAME, null, null);
File attachmentsDirectory = context.getDir(DIRECTORY, Context.MODE_PRIVATE); FileUtils.deleteDirectoryContents(context.getDir(DIRECTORY, Context.MODE_PRIVATE));
File[] attachments = attachmentsDirectory.listFiles();
for (File attachment : attachments) {
attachment.delete();
}
notifyAttachmentListeners(); notifyAttachmentListeners();
} }
@ -449,11 +446,12 @@ public class AttachmentDatabase extends Database {
public void insertAttachmentsForPlaceholder(long mmsId, @NonNull AttachmentId attachmentId, @NonNull InputStream inputStream) public void insertAttachmentsForPlaceholder(long mmsId, @NonNull AttachmentId attachmentId, @NonNull InputStream inputStream)
throws MmsException throws MmsException
{ {
DatabaseAttachment placeholder = getAttachment(attachmentId); DatabaseAttachment placeholder = getAttachment(attachmentId);
SQLiteDatabase database = databaseHelper.getWritableDatabase(); SQLiteDatabase database = databaseHelper.getWritableDatabase();
ContentValues values = new ContentValues(); ContentValues values = new ContentValues();
DataInfo oldInfo = getAttachmentDataFileInfo(attachmentId, DATA); DataInfo oldInfo = getAttachmentDataFileInfo(attachmentId, DATA);
DataInfo dataInfo = setAttachmentData(inputStream, false, attachmentId); DataInfo dataInfo = setAttachmentData(inputStream, false, attachmentId);
File transferFile = getTransferFile(databaseHelper.getReadableDatabase(), attachmentId);
if (oldInfo != null) { if (oldInfo != null) {
updateAttachmentDataHash(database, oldInfo.hash, dataInfo); updateAttachmentDataHash(database, oldInfo.hash, dataInfo);
@ -479,10 +477,16 @@ public class AttachmentDatabase extends Database {
values.put(DIGEST, (byte[])null); values.put(DIGEST, (byte[])null);
values.put(NAME, (String) null); values.put(NAME, (String) null);
values.put(FAST_PREFLIGHT_ID, (String)null); values.put(FAST_PREFLIGHT_ID, (String)null);
values.put(TRANSFER_FILE, (String)null);
if (database.update(TABLE_NAME, values, PART_ID_WHERE, attachmentId.toStrings()) == 0) { if (database.update(TABLE_NAME, values, PART_ID_WHERE, attachmentId.toStrings()) == 0) {
//noinspection ResultOfMethodCallIgnored //noinspection ResultOfMethodCallIgnored
dataInfo.file.delete(); dataInfo.file.delete();
if (transferFile != null) {
//noinspection ResultOfMethodCallIgnored
transferFile.delete();
}
} else { } else {
notifyConversationListeners(DatabaseFactory.getMmsDatabase(context).getThreadIdForMessage(mmsId)); notifyConversationListeners(DatabaseFactory.getMmsDatabase(context).getThreadIdForMessage(mmsId));
notifyConversationListListeners(); notifyConversationListListeners();
@ -616,6 +620,38 @@ public class AttachmentDatabase extends Database {
Log.i(TAG, "[markAttachmentAsTransformed] Updated " + updateCount + " rows."); Log.i(TAG, "[markAttachmentAsTransformed] Updated " + updateCount + " rows.");
} }
public @NonNull File getOrCreateTransferFile(@NonNull AttachmentId attachmentId) throws IOException {
SQLiteDatabase db = databaseHelper.getWritableDatabase();
File existing = getTransferFile(db, attachmentId);
if (existing != null) {
return existing;
}
File partsDirectory = context.getDir(DIRECTORY, Context.MODE_PRIVATE);
File transferFile = File.createTempFile("transfer", ".mms", partsDirectory);
ContentValues values = new ContentValues();
values.put(TRANSFER_FILE, transferFile.getAbsolutePath());
db.update(TABLE_NAME, values, PART_ID_WHERE, attachmentId.toStrings());
return transferFile;
}
private @Nullable static File getTransferFile(@NonNull SQLiteDatabase db, @NonNull AttachmentId attachmentId) {
try (Cursor cursor = db.query(TABLE_NAME, new String[] { TRANSFER_FILE }, PART_ID_WHERE, attachmentId.toStrings(), null, null, "1")) {
if (cursor != null && cursor.moveToFirst()) {
String path = cursor.getString(cursor.getColumnIndexOrThrow(TRANSFER_FILE));
if (path != null) {
return new File(path);
}
}
}
return null;
}
private static int updateAttachmentAndMatchingHashes(@NonNull SQLiteDatabase database, private static int updateAttachmentAndMatchingHashes(@NonNull SQLiteDatabase database,
@NonNull AttachmentId attachmentId, @NonNull AttachmentId attachmentId,
@Nullable String dataHash, @Nullable String dataHash,
@ -1188,6 +1224,7 @@ public class AttachmentDatabase extends Database {
this.hash = hash; this.hash = hash;
} }
} }
public static final class TransformProperties { public static final class TransformProperties {
@JsonProperty private final boolean skipTransform; @JsonProperty private final boolean skipTransform;

View File

@ -98,8 +98,9 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
private static final int REACTIONS = 37; private static final int REACTIONS = 37;
private static final int STORAGE_SERVICE = 38; private static final int STORAGE_SERVICE = 38;
private static final int REACTIONS_UNREAD_INDEX = 39; private static final int REACTIONS_UNREAD_INDEX = 39;
private static final int RESUMABLE_DOWNLOADS = 40;
private static final int DATABASE_VERSION = 39; private static final int DATABASE_VERSION = 40;
private static final String DATABASE_NAME = "signal.db"; private static final String DATABASE_NAME = "signal.db";
private final Context context; private final Context context;
@ -679,6 +680,10 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper {
db.execSQL("CREATE INDEX IF NOT EXISTS mms_reactions_unread_index ON mms (reactions_unread);"); db.execSQL("CREATE INDEX IF NOT EXISTS mms_reactions_unread_index ON mms (reactions_unread);");
} }
if (oldVersion < RESUMABLE_DOWNLOADS) {
db.execSQL("ALTER TABLE part ADD COLUMN transfer_file TEXT DEFAULT NULL");
}
db.setTransactionSuccessful(); db.setTransactionSuccessful();
} finally { } finally {
db.endTransaction(); db.endTransaction();

View File

@ -355,6 +355,7 @@ class JobController {
.setMaxAttempts(jobSpec.getMaxAttempts()) .setMaxAttempts(jobSpec.getMaxAttempts())
.setQueue(jobSpec.getQueueKey()) .setQueue(jobSpec.getQueueKey())
.setConstraints(Stream.of(constraintSpecs).map(ConstraintSpec::getFactoryKey).toList()) .setConstraints(Stream.of(constraintSpecs).map(ConstraintSpec::getFactoryKey).toList())
.setMaxBackoff(jobSpec.getMaxBackoff())
.build(); .build();
} }

View File

@ -33,6 +33,7 @@ import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.util.concurrent.TimeUnit;
public class AttachmentDownloadJob extends BaseJob { public class AttachmentDownloadJob extends BaseJob {
@ -55,12 +56,12 @@ public class AttachmentDownloadJob extends BaseJob {
this(new Job.Parameters.Builder() this(new Job.Parameters.Builder()
.setQueue("AttachmentDownloadJob" + attachmentId.getRowId() + "-" + attachmentId.getUniqueId()) .setQueue("AttachmentDownloadJob" + attachmentId.getRowId() + "-" + attachmentId.getUniqueId())
.addConstraint(NetworkConstraint.KEY) .addConstraint(NetworkConstraint.KEY)
.setMaxAttempts(25) .setLifespan(TimeUnit.DAYS.toMillis(1))
.setMaxAttempts(Parameters.UNLIMITED)
.build(), .build(),
messageId, messageId,
attachmentId, attachmentId,
manual); manual);
} }
private AttachmentDownloadJob(@NonNull Job.Parameters parameters, long messageId, AttachmentId attachmentId, boolean manual) { private AttachmentDownloadJob(@NonNull Job.Parameters parameters, long messageId, AttachmentId attachmentId, boolean manual) {
@ -156,11 +157,9 @@ public class AttachmentDownloadJob extends BaseJob {
{ {
AttachmentDatabase database = DatabaseFactory.getAttachmentDatabase(context); AttachmentDatabase database = DatabaseFactory.getAttachmentDatabase(context);
File attachmentFile = null; File attachmentFile = database.getOrCreateTransferFile(attachmentId);
try { try {
attachmentFile = createTempFile();
SignalServiceMessageReceiver messageReceiver = ApplicationDependencies.getSignalServiceMessageReceiver(); SignalServiceMessageReceiver messageReceiver = ApplicationDependencies.getSignalServiceMessageReceiver();
SignalServiceAttachmentPointer pointer = createAttachmentPointer(attachment); SignalServiceAttachmentPointer pointer = createAttachmentPointer(attachment);
InputStream stream = messageReceiver.retrieveAttachment(pointer, attachmentFile, MAX_ATTACHMENT_SIZE, (total, progress) -> EventBus.getDefault().postSticky(new PartProgressEvent(attachment, PartProgressEvent.Type.NETWORK, total, progress))); InputStream stream = messageReceiver.retrieveAttachment(pointer, attachmentFile, MAX_ATTACHMENT_SIZE, (total, progress) -> EventBus.getDefault().postSticky(new PartProgressEvent(attachment, PartProgressEvent.Type.NETWORK, total, progress)));
@ -169,18 +168,10 @@ public class AttachmentDownloadJob extends BaseJob {
} catch (InvalidPartException | NonSuccessfulResponseCodeException | InvalidMessageException | MmsException e) { } catch (InvalidPartException | NonSuccessfulResponseCodeException | InvalidMessageException | MmsException e) {
Log.w(TAG, "Experienced exception while trying to download an attachment.", e); Log.w(TAG, "Experienced exception while trying to download an attachment.", e);
markFailed(messageId, attachmentId); markFailed(messageId, attachmentId);
} finally {
if (attachmentFile != null) {
//noinspection ResultOfMethodCallIgnored
attachmentFile.delete();
}
} }
} }
@VisibleForTesting private SignalServiceAttachmentPointer createAttachmentPointer(Attachment attachment) throws InvalidPartException {
SignalServiceAttachmentPointer createAttachmentPointer(Attachment attachment)
throws InvalidPartException
{
if (TextUtils.isEmpty(attachment.getLocation())) { if (TextUtils.isEmpty(attachment.getLocation())) {
throw new InvalidPartException("empty content id"); throw new InvalidPartException("empty content id");
} }

View File

@ -39,12 +39,8 @@ public class CachedAttachmentsMigrationJob extends MigrationJob {
return; return;
} }
try { FileUtils.deleteDirectoryContents(context.getExternalCacheDir());
FileUtils.deleteDirectoryContents(context.getExternalCacheDir()); GlideApp.get(context).clearDiskCache();
GlideApp.get(context).clearDiskCache();
} catch (IOException e) {
Log.w(TAG, e);
}
} }
@Override @Override

View File

@ -207,20 +207,12 @@ public class LegacyMigrationJob extends MigrationJob {
} }
if (lastSeenVersion < REMOVE_CACHE) { if (lastSeenVersion < REMOVE_CACHE) {
try { FileUtils.deleteDirectoryContents(context.getCacheDir());
FileUtils.deleteDirectoryContents(context.getCacheDir());
} catch (IOException e) {
Log.w(TAG, e);
}
} }
if (lastSeenVersion < IMAGE_CACHE_CLEANUP) { if (lastSeenVersion < IMAGE_CACHE_CLEANUP) {
try { FileUtils.deleteDirectoryContents(context.getExternalCacheDir());
FileUtils.deleteDirectoryContents(context.getExternalCacheDir()); GlideApp.get(context).clearDiskCache();
GlideApp.get(context).clearDiskCache();
} catch (IOException e) {
Log.w(TAG, e);
}
} }
// This migration became unnecessary after switching away from WorkManager // This migration became unnecessary after switching away from WorkManager

View File

@ -1,5 +1,7 @@
package org.thoughtcrime.securesms.util; package org.thoughtcrime.securesms.util;
import androidx.annotation.Nullable;
import java.io.File; import java.io.File;
import java.io.FileDescriptor; import java.io.FileDescriptor;
import java.io.FileInputStream; import java.io.FileInputStream;
@ -34,7 +36,7 @@ public final class FileUtils {
} }
} }
public static void deleteDirectoryContents(File directory) throws IOException { public static void deleteDirectoryContents(@Nullable File directory) {
if (directory == null || !directory.exists() || !directory.isDirectory()) return; if (directory == null || !directory.exists() || !directory.isDirectory()) return;
File[] files = directory.listFiles(); File[] files = directory.listFiles();
@ -47,7 +49,7 @@ public final class FileUtils {
} }
} }
public static void deleteDirectory(File directory) throws IOException { public static void deleteDirectory(@Nullable File directory) {
if (directory == null || !directory.exists() || !directory.isDirectory()) { if (directory == null || !directory.exists() || !directory.isDirectory()) {
return; return;
} }