From 20250faf5ac7b23a0e3d55fce12f4e7257b6d48b Mon Sep 17 00:00:00 2001 From: Roghetti Date: Thu, 10 Feb 2022 12:39:17 +0100 Subject: [PATCH] introduce chunked backups as option --- .../securesms/backup/FullBackupBase.java | 14 +- .../securesms/backup/FullBackupExporter.java | 92 ++++-- .../securesms/backup/FullBackupImporter.java | 31 +- .../backup/MultiFileInputStream.java | 71 +++++ .../backup/MultiFileOutputStream.java | 83 +++++ .../backup/MultiFileOutputStream29.java | 89 ++++++ .../thoughtcrime/securesms/backup/Util.java | 34 ++ .../newdevice/NewDeviceServerTask.java | 1 + .../securesms/jobs/JobManagerFactories.java | 2 + .../securesms/jobs/LocalChunkedBackupJob.java | 176 +++++++++++ .../jobs/LocalChunkedBackupJobApi29.java | 175 +++++++++++ .../BackupsPreferenceFragment.java | 95 +++++- .../fragments/ChooseBackupFragment.java | 14 + .../fragments/RestoreBackupFragment.java | 54 +++- .../securesms/util/BackupUtil.java | 291 +++++++++++++++++- app/src/main/res/layout/fragment_backups.xml | 67 ++++ app/src/main/res/navigation/registration.xml | 6 + app/src/main/res/values/strings.xml | 1 + 18 files changed, 1237 insertions(+), 59 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/backup/MultiFileInputStream.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/backup/MultiFileOutputStream.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/backup/MultiFileOutputStream29.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/backup/Util.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/jobs/LocalChunkedBackupJob.java create mode 100644 app/src/main/java/org/thoughtcrime/securesms/jobs/LocalChunkedBackupJobApi29.java diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupBase.java b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupBase.java index d4effce360a..2b14087301b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupBase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupBase.java @@ -15,9 +15,9 @@ public abstract class FullBackupBase { private static final int DIGEST_ROUNDS = 250_000; static class BackupStream { - static @NonNull byte[] getBackupKey(@NonNull String passphrase, @Nullable byte[] salt) { + static @NonNull byte[] getBackupKey(@NonNull String passphrase, @Nullable byte[] salt, boolean isChunked) { try { - EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, 0, 0)); + EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, isChunked, 0, 0)); MessageDigest digest = MessageDigest.getInstance("SHA-512"); byte[] input = passphrase.replace(" ", "").getBytes(); @@ -26,7 +26,7 @@ static class BackupStream { if (salt != null) digest.update(salt); for (int i = 0; i < DIGEST_ROUNDS; i++) { - if (i % 1000 == 0) EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, 0, 0)); + if (i % 1000 == 0) EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, isChunked, 0, 0)); digest.update(hash); hash = digest.digest(input); } @@ -45,11 +45,13 @@ public enum Type { } private final Type type; + private final boolean isChunked; private final long count; private final long estimatedTotalCount; - BackupEvent(Type type, long count, long estimatedTotalCount) { + BackupEvent(Type type, boolean isChunked, long count, long estimatedTotalCount) { this.type = type; + this.isChunked = isChunked; this.count = count; this.estimatedTotalCount = estimatedTotalCount; } @@ -58,6 +60,10 @@ public Type getType() { return type; } + public boolean isChunked() { + return isChunked; + } + public long getCount() { return count; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupExporter.java b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupExporter.java index cff40a8a61e..11a5cece473 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupExporter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupExporter.java @@ -106,7 +106,23 @@ public static void export(@NonNull Context context, throws IOException { try (OutputStream outputStream = new FileOutputStream(output)) { - internalExport(context, attachmentSecret, input, outputStream, passphrase, true, cancellationSignal); + internalExport(context, attachmentSecret, input, outputStream, passphrase, true, false, cancellationSignal); + } + } + + public static List exportChunked(@NonNull Context context, + @NonNull AttachmentSecret attachmentSecret, + @NonNull SQLiteDatabase input, + @NonNull File backupDir, + @NonNull String backupPrefix, + @NonNull String backupSuffix, + @NonNull String passphrase, + @NonNull BackupCancellationSignal cancellationSignal) + throws IOException + { + try (MultiFileOutputStream outputStream = new MultiFileOutputStream(backupDir, backupPrefix, backupSuffix)) { + internalExport(context, attachmentSecret, input, outputStream, passphrase, true, true, cancellationSignal); + return outputStream.getFiles(); } } @@ -120,7 +136,24 @@ public static void export(@NonNull Context context, throws IOException { try (OutputStream outputStream = Objects.requireNonNull(context.getContentResolver().openOutputStream(output.getUri()))) { - internalExport(context, attachmentSecret, input, outputStream, passphrase, true, cancellationSignal); + internalExport(context, attachmentSecret, input, outputStream, passphrase, true, false, cancellationSignal); + } + } + + @RequiresApi(29) + public static List exportChunked(@NonNull Context context, + @NonNull AttachmentSecret attachmentSecret, + @NonNull SQLiteDatabase input, + @NonNull DocumentFile backupDir, + @NonNull String backupPrefix, + @NonNull String backupSuffix, + @NonNull String passphrase, + @NonNull BackupCancellationSignal cancellationSignal) + throws IOException + { + try (MultiFileOutputStream29 outputStream = new MultiFileOutputStream29(context, backupDir, backupPrefix, backupSuffix)) { + internalExport(context, attachmentSecret, input, outputStream, passphrase, true, true, cancellationSignal); + return outputStream.getFiles(); } } @@ -131,7 +164,7 @@ public static void transfer(@NonNull Context context, @NonNull String passphrase) throws IOException { - internalExport(context, attachmentSecret, input, outputStream, passphrase, false, () -> false); + internalExport(context, attachmentSecret, input, outputStream, passphrase, false, false, () -> false); } private static void internalExport(@NonNull Context context, @@ -140,10 +173,11 @@ private static void internalExport(@NonNull Context context, @NonNull OutputStream fileOutputStream, @NonNull String passphrase, boolean closeOutputStream, + boolean isChunked, @NonNull BackupCancellationSignal cancellationSignal) throws IOException { - BackupFrameOutputStream outputStream = new BackupFrameOutputStream(fileOutputStream, passphrase); + BackupFrameOutputStream outputStream = new BackupFrameOutputStream(fileOutputStream, passphrase, isChunked); int count = 0; long estimatedCountOutside = 0L; @@ -162,41 +196,41 @@ private static void internalExport(@NonNull Context context, for (String table : tables) { throwIfCanceled(cancellationSignal); if (table.equals(MmsDatabase.TABLE_NAME)) { - count = exportTable(table, input, outputStream, cursor -> isNonExpiringMmsMessage(cursor) && isNotReleaseChannel(cursor), null, count, estimatedCount, cancellationSignal); + count = exportTable(table, input, outputStream, cursor -> isNonExpiringMmsMessage(cursor) && isNotReleaseChannel(cursor), null, count, estimatedCount, isChunked, cancellationSignal); } else if (table.equals(SmsDatabase.TABLE_NAME)) { - count = exportTable(table, input, outputStream, cursor -> isNonExpiringSmsMessage(cursor) && isNotReleaseChannel(cursor), null, count, estimatedCount, cancellationSignal); + count = exportTable(table, input, outputStream, FullBackupExporter::isNonExpiringSmsMessage, null, count, estimatedCount, isChunked, cancellationSignal); } else if (table.equals(ReactionDatabase.TABLE_NAME)) { - count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, new MessageId(CursorUtil.requireLong(cursor, ReactionDatabase.MESSAGE_ID), CursorUtil.requireBoolean(cursor, ReactionDatabase.IS_MMS))), null, count, estimatedCount, cancellationSignal); + count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMessage(input, new MessageId(CursorUtil.requireLong(cursor, ReactionDatabase.MESSAGE_ID), CursorUtil.requireBoolean(cursor, ReactionDatabase.IS_MMS))), null, count, estimatedCount, isChunked, cancellationSignal); } else if (table.equals(MentionDatabase.TABLE_NAME)) { - count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMmsMessageAndNotReleaseChannel(input, CursorUtil.requireLong(cursor, MentionDatabase.MESSAGE_ID)), null, count, estimatedCount, cancellationSignal); + count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMmsMessage(input, CursorUtil.requireLong(cursor, MentionDatabase.MESSAGE_ID)), null, count, estimatedCount, isChunked, cancellationSignal); } else if (table.equals(GroupReceiptDatabase.TABLE_NAME)) { - count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMmsMessageAndNotReleaseChannel(input, cursor.getLong(cursor.getColumnIndexOrThrow(GroupReceiptDatabase.MMS_ID))), null, count, estimatedCount, cancellationSignal); + count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMmsMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(GroupReceiptDatabase.MMS_ID))), null, count, estimatedCount, isChunked, cancellationSignal); } else if (table.equals(AttachmentDatabase.TABLE_NAME)) { - count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMmsMessageAndNotReleaseChannel(input, cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.MMS_ID))), (cursor, innerCount) -> exportAttachment(attachmentSecret, cursor, outputStream, innerCount, estimatedCount), count, estimatedCount, cancellationSignal); + count = exportTable(table, input, outputStream, cursor -> isForNonExpiringMmsMessage(input, cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.MMS_ID))), (cursor, innerCount) -> exportAttachment(attachmentSecret, cursor, outputStream, innerCount, estimatedCount, isChunked), count, estimatedCount, isChunked, cancellationSignal); } else if (table.equals(StickerDatabase.TABLE_NAME)) { - count = exportTable(table, input, outputStream, cursor -> true, (cursor, innerCount) -> exportSticker(attachmentSecret, cursor, outputStream, innerCount, estimatedCount), count, estimatedCount, cancellationSignal); + count = exportTable(table, input, outputStream, cursor -> true, (cursor, innerCount) -> exportSticker(attachmentSecret, cursor, outputStream, innerCount, estimatedCount, isChunked), count, estimatedCount, isChunked, cancellationSignal); } else if (!BLACKLISTED_TABLES.contains(table) && !table.startsWith("sqlite_")) { - count = exportTable(table, input, outputStream, null, null, count, estimatedCount, cancellationSignal); + count = exportTable(table, input, outputStream, null, null, count, estimatedCount, isChunked, cancellationSignal); } stopwatch.split("table::" + table); } for (BackupProtos.SharedPreference preference : TextSecurePreferences.getPreferencesToSaveToBackup(context)) { throwIfCanceled(cancellationSignal); - EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount)); + EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, isChunked, ++count, estimatedCount)); outputStream.write(preference); } stopwatch.split("prefs"); - count = exportKeyValues(outputStream, SignalStore.getKeysToIncludeInBackup(), count, estimatedCount, cancellationSignal); + count = exportKeyValues(outputStream, SignalStore.getKeysToIncludeInBackup(), count, estimatedCount, isChunked, cancellationSignal); stopwatch.split("key_values"); for (AvatarHelper.Avatar avatar : AvatarHelper.getAvatars(context)) { throwIfCanceled(cancellationSignal); if (avatar != null) { - EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount)); + EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, isChunked, ++count, estimatedCount)); try (InputStream inputStream = avatar.getInputStream()) { outputStream.write(avatar.getFilename(), inputStream, avatar.getLength()); } @@ -211,7 +245,7 @@ private static void internalExport(@NonNull Context context, if (closeOutputStream) { outputStream.close(); } - EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.FINISHED, ++count, estimatedCountOutside)); + EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.FINISHED, isChunked, ++count, estimatedCountOutside)); } } @@ -300,6 +334,7 @@ private static int exportTable(@NonNull String table, @Nullable PostProcessor postProcess, int count, long estimatedCount, + boolean isChunked, @NonNull BackupCancellationSignal cancellationSignal) throws IOException { @@ -339,7 +374,7 @@ private static int exportTable(@NonNull String table, statement.append(')'); - EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount)); + EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, isChunked, ++count, estimatedCount)); outputStream.write(statementBuilder.setStatement(statement.toString()).build()); if (postProcess != null) { @@ -352,7 +387,7 @@ private static int exportTable(@NonNull String table, return count; } - private static int exportAttachment(@NonNull AttachmentSecret attachmentSecret, @NonNull Cursor cursor, @NonNull BackupFrameOutputStream outputStream, int count, long estimatedCount) { + private static int exportAttachment(@NonNull AttachmentSecret attachmentSecret, @NonNull Cursor cursor, @NonNull BackupFrameOutputStream outputStream, int count, long estimatedCount, boolean isChunked) { try { long rowId = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.ROW_ID)); long uniqueId = cursor.getLong(cursor.getColumnIndexOrThrow(AttachmentDatabase.UNIQUE_ID)); @@ -377,7 +412,7 @@ private static int exportAttachment(@NonNull AttachmentSecret attachmentSecret, if (random != null && random.length == 32) inputStream = ModernDecryptingPartInputStream.createFor(attachmentSecret, random, new File(data), 0); else inputStream = ClassicDecryptingPartInputStream.createFor(attachmentSecret, new File(data)); - EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount)); + EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, isChunked, ++count, estimatedCount)); outputStream.write(new AttachmentId(rowId, uniqueId), inputStream, size); inputStream.close(); } @@ -388,7 +423,7 @@ private static int exportAttachment(@NonNull AttachmentSecret attachmentSecret, return count; } - private static int exportSticker(@NonNull AttachmentSecret attachmentSecret, @NonNull Cursor cursor, @NonNull BackupFrameOutputStream outputStream, int count, long estimatedCount) { + private static int exportSticker(@NonNull AttachmentSecret attachmentSecret, @NonNull Cursor cursor, @NonNull BackupFrameOutputStream outputStream, int count, long estimatedCount, boolean isChunked) { try { long rowId = cursor.getLong(cursor.getColumnIndexOrThrow(StickerDatabase._ID)); long size = cursor.getLong(cursor.getColumnIndexOrThrow(StickerDatabase.FILE_LENGTH)); @@ -397,7 +432,7 @@ private static int exportSticker(@NonNull AttachmentSecret attachmentSecret, @No byte[] random = cursor.getBlob(cursor.getColumnIndexOrThrow(StickerDatabase.FILE_RANDOM)); if (!TextUtils.isEmpty(data) && size > 0) { - EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount)); + EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, isChunked, ++count, estimatedCount)); try (InputStream inputStream = ModernDecryptingPartInputStream.createFor(attachmentSecret, random, new File(data), 0)) { outputStream.writeSticker(rowId, inputStream, size); } @@ -430,6 +465,7 @@ private static int exportKeyValues(@NonNull BackupFrameOutputStream outputStream @NonNull List keysToIncludeInBackup, int count, long estimatedCount, + boolean isChunked, BackupCancellationSignal cancellationSignal) throws IOException { KeyValueDataSet dataSet = KeyValueDatabase.getInstance(ApplicationDependencies.getApplication()) @@ -470,7 +506,7 @@ private static int exportKeyValues(@NonNull BackupFrameOutputStream outputStream throw new AssertionError("Unknown type: " + type); } - EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, ++count, estimatedCount)); + EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, isChunked, ++count, estimatedCount)); outputStream.write(builder.build()); } @@ -488,7 +524,7 @@ private static boolean isNonExpiringSmsMessage(@NonNull Cursor cursor) { private static boolean isForNonExpiringMessage(@NonNull SQLiteDatabase db, @NonNull MessageId messageId) { if (messageId.isMms()) { - return isForNonExpiringMmsMessageAndNotReleaseChannel(db, messageId.getId()); + return isForNonExpiringMmsMessage(db, messageId.getId()); } else { return isForNonExpiringSmsMessage(db, messageId.getId()); } @@ -508,14 +544,14 @@ private static boolean isForNonExpiringSmsMessage(@NonNull SQLiteDatabase db, lo return false; } - private static boolean isForNonExpiringMmsMessageAndNotReleaseChannel(@NonNull SQLiteDatabase db, long mmsId) { - String[] columns = new String[] { MmsDatabase.RECIPIENT_ID, MmsDatabase.EXPIRES_IN, MmsDatabase.VIEW_ONCE}; + private static boolean isForNonExpiringMmsMessage(@NonNull SQLiteDatabase db, long mmsId) { + String[] columns = new String[] { MmsDatabase.EXPIRES_IN, MmsDatabase.VIEW_ONCE}; String where = MmsDatabase.ID + " = ?"; String[] args = new String[] { String.valueOf(mmsId) }; try (Cursor mmsCursor = db.query(MmsDatabase.TABLE_NAME, columns, where, args, null, null, null)) { if (mmsCursor != null && mmsCursor.moveToFirst()) { - return isNonExpiringMmsMessage(mmsCursor) && isNotReleaseChannel(mmsCursor); + return isNonExpiringMmsMessage(mmsCursor); } } @@ -539,10 +575,10 @@ private static class BackupFrameOutputStream extends BackupStream { private byte[] iv; private int counter; - private BackupFrameOutputStream(@NonNull OutputStream output, @NonNull String passphrase) throws IOException { + private BackupFrameOutputStream(@NonNull OutputStream output, @NonNull String passphrase, boolean isChunked) throws IOException { try { byte[] salt = Util.getSecretBytes(32); - byte[] key = getBackupKey(passphrase, salt); + byte[] key = getBackupKey(passphrase, salt, isChunked); byte[] derived = new HKDFv3().deriveSecrets(key, "Backup Export".getBytes(), 64); byte[][] split = ByteUtil.split(derived, 32, 32); diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.java b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.java index 09fe1c2f150..5dad61387cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.java @@ -50,6 +50,7 @@ import java.security.InvalidKeyException; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.LinkedList; @@ -86,12 +87,21 @@ public static void importFile(@NonNull Context context, @NonNull AttachmentSecre throws IOException { try (InputStream is = getInputStream(context, uri)) { - importFile(context, attachmentSecret, db, is, passphrase); + importFile(context, attachmentSecret, db, is, false, passphrase); } } public static void importFile(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret, - @NonNull SQLiteDatabase db, @NonNull InputStream is, @NonNull String passphrase) + @NonNull SQLiteDatabase db, @NonNull Uri[] uris, @NonNull String passphrase) + throws IOException + { + try (InputStream is = getInputStream(context, uris)) { + importFile(context, attachmentSecret, db, is, true, passphrase); + } + } + + public static void importFile(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret, + @NonNull SQLiteDatabase db, @NonNull InputStream is, boolean isChunked, @NonNull String passphrase) throws IOException { int count = 0; @@ -101,14 +111,14 @@ public static void importFile(@NonNull Context context, @NonNull AttachmentSecre db.beginTransaction(); keyValueDatabase.beginTransaction(); try { - BackupRecordInputStream inputStream = new BackupRecordInputStream(is, passphrase); + BackupRecordInputStream inputStream = new BackupRecordInputStream(is, passphrase, isChunked); dropAllTables(db); BackupFrame frame; while (!(frame = inputStream.readFrame()).getEnd()) { - if (count % 100 == 0) EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, count, 0)); + if (count % 100 == 0) EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, isChunked, count, 0)); count++; if (frame.hasVersion()) processVersion(db, frame.getVersion()); @@ -128,17 +138,22 @@ public static void importFile(@NonNull Context context, @NonNull AttachmentSecre keyValueDatabase.endTransaction(); } - EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.FINISHED, count, 0)); + EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.FINISHED, isChunked, count, 0)); } private static @NonNull InputStream getInputStream(@NonNull Context context, @NonNull Uri uri) throws IOException{ if (BackupUtil.isUserSelectionRequired(context) || uri.getScheme().equals("content")) { return Objects.requireNonNull(context.getContentResolver().openInputStream(uri)); } else { - return new FileInputStream(new File(Objects.requireNonNull(uri.getPath()))); + return new FileInputStream(Objects.requireNonNull(uri.getPath())); } } + private static @NonNull InputStream getInputStream(@NonNull Context context, @NonNull Uri[] uris) throws IOException{ + Arrays.sort(uris); + return new MultiFileInputStream(context.getContentResolver(), uris); + } + private static void processVersion(@NonNull SQLiteDatabase db, DatabaseVersion version) throws IOException { if (version.getVersion() > db.getVersion()) { throw new DatabaseDowngradeException(db.getVersion(), version.getVersion()); @@ -313,7 +328,7 @@ private static class BackupRecordInputStream extends BackupStream { private byte[] iv; private int counter; - private BackupRecordInputStream(@NonNull InputStream in, @NonNull String passphrase) throws IOException { + private BackupRecordInputStream(@NonNull InputStream in, @NonNull String passphrase, boolean isChunked) throws IOException { try { this.in = in; @@ -338,7 +353,7 @@ private BackupRecordInputStream(@NonNull InputStream in, @NonNull String passphr throw new IOException("Invalid IV length!"); } - byte[] key = getBackupKey(passphrase, header.hasSalt() ? header.getSalt().toByteArray() : null); + byte[] key = getBackupKey(passphrase, header.hasSalt() ? header.getSalt().toByteArray() : null, isChunked); byte[] derived = new HKDFv3().deriveSecrets(key, "Backup Export".getBytes(), 64); byte[][] split = ByteUtil.split(derived, 32, 32); diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/MultiFileInputStream.java b/app/src/main/java/org/thoughtcrime/securesms/backup/MultiFileInputStream.java new file mode 100644 index 00000000000..b0580df37ea --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/MultiFileInputStream.java @@ -0,0 +1,71 @@ +package org.thoughtcrime.securesms.backup; + +import android.content.ContentResolver; +import android.net.Uri; + +import java.io.InputStream; +import java.io.IOException; + +public class MultiFileInputStream extends InputStream { + private int currentChunk = -1; + private InputStream currentInputStream = null; + ContentResolver contentResolver; + Uri[] uris; + boolean noFilesLeft; + + public MultiFileInputStream(ContentResolver contentResolver, + Uri[] uris) { + this.contentResolver = contentResolver; + this.uris = uris; + this.noFilesLeft = (uris.length == 0); + } + + public void close() throws IOException { + if (currentInputStream != null) { + currentInputStream.close(); + } + } + + public void swapFiles() throws IOException, SecurityException { + if (currentInputStream != null) { + currentInputStream.close(); + } + currentChunk++; + Uri uri = uris[currentChunk]; + currentInputStream = contentResolver.openInputStream(uri); + } + + public int read() throws IOException { + byte[] b = new byte[1]; + if (read(b) == -1) { + return -1; + } + return b[0]; + } + + public int read(byte[] b) throws IOException { + return read(b, 0, b.length); + } + + public int read(byte[] b, int off, int len) throws IOException { + if (currentInputStream == null) { + if (noFilesLeft) { + return -1; + } + swapFiles(); + } + while (true) { + int bytesRead = currentInputStream.read(b, off, len); + boolean eofRead = (bytesRead == -1); + if (eofRead) { + if (noFilesLeft) { + return -1; + } else { + swapFiles(); + } + } else { + return bytesRead; + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/MultiFileOutputStream.java b/app/src/main/java/org/thoughtcrime/securesms/backup/MultiFileOutputStream.java new file mode 100644 index 00000000000..592dfbca137 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/MultiFileOutputStream.java @@ -0,0 +1,83 @@ +package org.thoughtcrime.securesms.backup; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +public class MultiFileOutputStream extends OutputStream { + private int currentChunk = -1; + private int bytesRemaining = 0; + private OutputStream currentOutputStream = null; + private final File dir; + private final String prefix; + private final String suffix; + private final List files; + + public MultiFileOutputStream(File dir, + String prefix, + String suffix) { + this.dir = dir; + this.prefix = prefix; + this.suffix = suffix; + this.files = new ArrayList<>(); + } + + public void close() throws IOException { + if (currentOutputStream != null) { + currentOutputStream.close(); + } + } + + public void flush() throws IOException { + if (currentOutputStream != null) { + currentOutputStream.flush(); + } + } + + public void write(int b) throws IOException { + byte[] c = new byte[1]; + c[0] = (byte) b; + write(c); + } + + public void write(byte[] b) throws IOException { + write(b, 0, b.length); + } + + private void swapFiles() throws IOException { + if (currentOutputStream != null) { + currentOutputStream.close(); + } + currentChunk++; + String filename = String.format(Locale.ENGLISH, "%s.%03d%s", prefix, currentChunk, suffix); + File f = new File(dir, filename); + files.add(f); + currentOutputStream = new FileOutputStream(f); + bytesRemaining = Util.MAX_BYTES_PER_FILE; + } + + public void write(byte[] b, int offset, int len) throws IOException { + if (bytesRemaining == 0) { + swapFiles(); + } + int lLen = len; + int lOffset = offset; + while (bytesRemaining < lLen) { + int bytesToWrite = bytesRemaining; + currentOutputStream.write(b, lOffset, bytesToWrite); + swapFiles(); + lOffset += bytesToWrite; + lLen -= bytesToWrite; + } + currentOutputStream.write(b, lOffset, lLen); + bytesRemaining -= lLen; + } + + public List getFiles() { + return files; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/MultiFileOutputStream29.java b/app/src/main/java/org/thoughtcrime/securesms/backup/MultiFileOutputStream29.java new file mode 100644 index 00000000000..b42e795bbbf --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/MultiFileOutputStream29.java @@ -0,0 +1,89 @@ +package org.thoughtcrime.securesms.backup; + +import android.content.Context; + +import androidx.documentfile.provider.DocumentFile; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; + +public class MultiFileOutputStream29 extends OutputStream { + private int currentChunk = -1; + private int bytesRemaining = 0; + private OutputStream currentOutputStream = null; + private final DocumentFile dir; + private final String prefix; + private final String suffix; + private final List files; + private final Context context; + + public MultiFileOutputStream29(Context context, + DocumentFile dir, + String prefix, + String suffix) { + this.context = context; + this.dir = dir; + this.prefix = prefix; + this.files = new ArrayList<>(); + this.suffix = suffix; + } + + public void close() throws IOException { + if (currentOutputStream != null) { + currentOutputStream.close(); + } + } + + public void flush() throws IOException { + if (currentOutputStream != null) { + currentOutputStream.flush(); + } + } + + public void write(int b) throws IOException { + byte[] c = new byte[1]; + c[0] = (byte) b; + write(c); + } + + public void write(byte[] b) throws IOException { + write(b, 0, b.length); + } + + private void swapFiles() throws IOException { + if (currentOutputStream != null) { + currentOutputStream.close(); + } + currentChunk++; + String filename = String.format(Locale.ENGLISH, "%s.%03d%s", prefix, currentChunk, suffix); + DocumentFile f = dir.createFile("application/octet-stream", filename); + files.add(f); + currentOutputStream = Objects.requireNonNull(context.getContentResolver().openOutputStream(f.getUri())); + bytesRemaining = Util.MAX_BYTES_PER_FILE; + } + + public void write(byte[] b, int offset, int len) throws IOException { + if (bytesRemaining == 0) { + swapFiles(); + } + int lLen = len; + int lOffset = offset; + while (bytesRemaining < lLen) { + int bytesToWrite = bytesRemaining; + currentOutputStream.write(b, lOffset, bytesToWrite); + swapFiles(); + lOffset += bytesToWrite; + lLen -= bytesToWrite; + } + currentOutputStream.write(b, lOffset, lLen); + bytesRemaining -= lLen; + } + + public List getFiles() { + return files; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/Util.java b/app/src/main/java/org/thoughtcrime/securesms/backup/Util.java new file mode 100644 index 00000000000..d5db5d99483 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/Util.java @@ -0,0 +1,34 @@ +package org.thoughtcrime.securesms.backup; + +import java.io.File; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +class Util { + public final static int MAX_BYTES_PER_FILE = 1024 * 1024 * 1024; + + public static void renameMulti(List fromFiles, List toFiles) { + if (fromFiles.size() != toFiles.size()) { + throw (new RuntimeException("fromFiles.size() doesn't match toFiles.size()")); + } + for (int i = 0; i < fromFiles.size(); ++i) { + File from = fromFiles.get(i); + File to = toFiles.get(i); + from.renameTo(to); + } + } + + public static List generateBackupFilenames(File backupDirectory, int n) { + String timestamp = new SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.US).format(new Date()); + List files = new ArrayList<>(n); + for (int i = 0; i < n; ++i) { + String fileName = String.format("signal-%s.backup.part%03d", timestamp, n); + File file = new File(backupDirectory, fileName); + files.add(file); + } + return files; + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceServerTask.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceServerTask.java index fbae06b74e7..a92bc73f462 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceServerTask.java +++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/newdevice/NewDeviceServerTask.java @@ -47,6 +47,7 @@ public void run(@NonNull Context context, @NonNull InputStream inputStream) { AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(), database, inputStream, + false, passphrase); SignalDatabase.upgradeRestored(database); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java index 0b99355f534..3b096217071 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/JobManagerFactories.java @@ -105,7 +105,9 @@ public static Map getJobFactories(@NonNull Application appl put(LeaveGroupV2Job.KEY, new LeaveGroupV2Job.Factory()); put(LeaveGroupV2WorkerJob.KEY, new LeaveGroupV2WorkerJob.Factory()); put(LocalBackupJob.KEY, new LocalBackupJob.Factory()); + put(LocalChunkedBackupJob.KEY, new LocalChunkedBackupJob.Factory()); put(LocalBackupJobApi29.KEY, new LocalBackupJobApi29.Factory()); + put(LocalChunkedBackupJobApi29.KEY, new LocalChunkedBackupJobApi29.Factory()); put(MarkerJob.KEY, new MarkerJob.Factory()); put(MmsDownloadJob.KEY, new MmsDownloadJob.Factory()); put(MmsReceiveJob.KEY, new MmsReceiveJob.Factory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalChunkedBackupJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalChunkedBackupJob.java new file mode 100644 index 00000000000..38952f97c79 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalChunkedBackupJob.java @@ -0,0 +1,176 @@ +package org.thoughtcrime.securesms.jobs; + + +import android.Manifest; + +import androidx.annotation.NonNull; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.backup.BackupFileIOError; +import org.thoughtcrime.securesms.backup.BackupPassphrase; +import org.thoughtcrime.securesms.backup.FullBackupExporter; +import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider; +import org.thoughtcrime.securesms.database.NoExternalStorageException; +import org.thoughtcrime.securesms.database.SignalDatabase; +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.jobmanager.JobManager; +import org.thoughtcrime.securesms.jobmanager.impl.ChargingConstraint; +import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.permissions.Permissions; +import org.thoughtcrime.securesms.service.GenericForegroundService; +import org.thoughtcrime.securesms.service.NotificationController; +import org.thoughtcrime.securesms.util.BackupUtil; +import org.thoughtcrime.securesms.util.StorageUtil; + +import java.io.File; +import java.io.IOException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.Objects; + +public final class LocalChunkedBackupJob extends BaseJob { + + public static final String KEY = "LocalChunkedBackupJob"; + + private static final String TAG = Log.tag(LocalChunkedBackupJob.class); + + public static final String QUEUE = "__LOCAL_CHUNKED_BACKUP__"; + + public static final String TEMP_BACKUP_FILE_PREFIX = ".backup"; + public static final String TEMP_BACKUP_FILE_SUFFIX = ".tmp"; + + public static void enqueue(boolean force) { + JobManager jobManager = ApplicationDependencies.getJobManager(); + Parameters.Builder parameters = new Parameters.Builder() + .setQueue(QUEUE) + .setMaxInstancesForFactory(1) + .setMaxAttempts(3); + if (force) { + jobManager.cancelAllInQueue(QUEUE); + } else { + parameters.addConstraint(ChargingConstraint.KEY); + } + + if (BackupUtil.isUserSelectionRequired(ApplicationDependencies.getApplication())) { + jobManager.add(new LocalChunkedBackupJobApi29(parameters.build())); + } else { + jobManager.add(new LocalChunkedBackupJob(parameters.build())); + } + } + + private LocalChunkedBackupJob(@NonNull Job.Parameters parameters) { + super(parameters); + } + + @Override + public @NonNull Data serialize() { + return Data.EMPTY; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() throws NoExternalStorageException, IOException { + Log.i(TAG, "Executing backup job..."); + + BackupFileIOError.clearNotification(context); + + if (!Permissions.hasAll(context, Manifest.permission.WRITE_EXTERNAL_STORAGE)) { + throw new IOException("No external storage permission!"); + } + + try (NotificationController notification = GenericForegroundService.startForegroundTask(context, + context.getString(R.string.LocalBackupJob_creating_signal_backup), + NotificationChannels.BACKUPS, + R.drawable.ic_signal_backup)) + { + notification.setIndeterminateProgress(); + + String backupPassword = BackupPassphrase.get(context); + File backupDirectory = StorageUtil.getOrCreateBackupDirectory(); + + deleteOldTemporaryBackups(backupDirectory); + + if (backupPassword == null) { + throw new IOException("Backup password is null"); + } + + List tmpFiles = null; + + try { + tmpFiles = FullBackupExporter.exportChunked(context, + AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(), + SignalDatabase.getBackupDatabase(), + backupDirectory, + TEMP_BACKUP_FILE_PREFIX, + TEMP_BACKUP_FILE_SUFFIX, + backupPassword, + this::isCanceled); + List outFiles = BackupUtil.generateBackupFilenames2(backupDirectory, tmpFiles.size()); + if (!BackupUtil.renameMulti(tmpFiles, outFiles)) { + Log.w(TAG, "Failed to rename temp files"); + throw new IOException("Renaming temporary backup files failed!"); + } + } catch (FullBackupExporter.BackupCanceledException e) { + Log.w(TAG, "Backup cancelled"); + throw e; + } catch (IOException e) { + BackupFileIOError.postNotificationForException(context, e, getRunAttempt()); + throw e; + } finally { + if (tmpFiles != null) { + for (File tempFile: tmpFiles) { + if (tempFile.exists()) { + if (tempFile.delete()) { + Log.w(TAG, "Backup failed. Deleted temp file"); + } else { + Log.w(TAG, "Backup failed. Failed to delete temp file " + tempFile); + } + } + } + } + } + + BackupUtil.deleteOldChunkedBackups(); + } + } + + private static void deleteOldTemporaryBackups(@NonNull File backupDirectory) { + for (File file : Objects.requireNonNull(backupDirectory.listFiles())) { + if (file.isFile()) { + String name = file.getName(); + if (name.startsWith(TEMP_BACKUP_FILE_PREFIX) && name.endsWith(TEMP_BACKUP_FILE_SUFFIX)) { + if (file.delete()) { + Log.w(TAG, "Deleted old temporary backup file"); + } else { + Log.w(TAG, "Could not delete old temporary backup file"); + } + } + } + } + } + + @Override + public boolean onShouldRetry(@NonNull Exception e) { + return false; + } + + @Override + public void onFailure() { + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull LocalChunkedBackupJob create(@NonNull Parameters parameters, @NonNull Data data) { + return new LocalChunkedBackupJob(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalChunkedBackupJobApi29.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalChunkedBackupJobApi29.java new file mode 100644 index 00000000000..34342fc838a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalChunkedBackupJobApi29.java @@ -0,0 +1,175 @@ +package org.thoughtcrime.securesms.jobs; + + +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.documentfile.provider.DocumentFile; + +import org.signal.core.util.logging.Log; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.backup.BackupFileIOError; +import org.thoughtcrime.securesms.backup.BackupPassphrase; +import org.thoughtcrime.securesms.backup.FullBackupExporter; +import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider; +import org.thoughtcrime.securesms.database.SignalDatabase; +import org.thoughtcrime.securesms.jobmanager.Data; +import org.thoughtcrime.securesms.jobmanager.Job; +import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.notifications.NotificationChannels; +import org.thoughtcrime.securesms.service.GenericForegroundService; +import org.thoughtcrime.securesms.service.NotificationController; +import org.thoughtcrime.securesms.util.BackupUtil; + +import java.io.IOException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.UUID; + +/** + * Backup Job for installs requiring Scoped Storage. + * + * @see LocalBackupJob#enqueue(boolean) + */ +public final class LocalChunkedBackupJobApi29 extends BaseJob { + + public static final String KEY = "LocalChunkedBackupJobApi29"; + + private static final String TAG = Log.tag(LocalChunkedBackupJobApi29.class); + + public static final String TEMP_BACKUP_FILE_PREFIX = ".backup"; + public static final String TEMP_BACKUP_FILE_SUFFIX = ".tmp"; + + LocalChunkedBackupJobApi29(@NonNull Parameters parameters) { + super(parameters); + } + + @Override + public @NonNull Data serialize() { + return Data.EMPTY; + } + + @Override + public @NonNull String getFactoryKey() { + return KEY; + } + + @Override + public void onRun() throws IOException { + Log.i(TAG, "Executing backup job..."); + + BackupFileIOError.clearNotification(context); + + if (!BackupUtil.isUserSelectionRequired(context)) { + throw new IOException("Wrong backup job!"); + } + + Uri backupDirectoryUri = SignalStore.settings().getSignalBackupDirectory(); + if (backupDirectoryUri == null || backupDirectoryUri.getPath() == null) { + throw new IOException("Backup Directory has not been selected!"); + } + + try (NotificationController notification = GenericForegroundService.startForegroundTask(context, + context.getString(R.string.LocalBackupJob_creating_signal_backup), + NotificationChannels.BACKUPS, + R.drawable.ic_signal_backup)) + { + notification.setIndeterminateProgress(); + + String backupPassword = BackupPassphrase.get(context); + DocumentFile backupDirectory = DocumentFile.fromTreeUri(context, backupDirectoryUri); + String timestamp = new SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.US).format(new Date()); + String fileName = String.format("signal-%s.backup", timestamp); + + if (backupDirectory == null || !backupDirectory.canWrite()) { + BackupFileIOError.ACCESS_ERROR.postNotification(context); + throw new IOException("Cannot write to backup directory location."); + } + + deleteOldTemporaryBackups(backupDirectory); + + if (backupDirectory.findFile(fileName) != null) { + throw new IOException("Backup file already exists!"); + } + + String temporaryPrefix = String.format(Locale.US, "%s%s", TEMP_BACKUP_FILE_PREFIX, UUID.randomUUID()); + + if (backupPassword == null) { + throw new IOException("Backup password is null"); + } + + List tmpFiles = null; + + try { + tmpFiles = FullBackupExporter.exportChunked(context, + AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(), + SignalDatabase.getBackupDatabase(), + backupDirectory, + temporaryPrefix, + TEMP_BACKUP_FILE_SUFFIX, + backupPassword, + this::isCanceled); + List outFiles = BackupUtil.generateBackupFilenames(tmpFiles.size()); + if (!BackupUtil.renameMulti2(tmpFiles, outFiles)) { + Log.w(TAG, "Failed to rename temp files"); + throw new IOException("Renaming temporary backup files failed!"); + } + } catch (FullBackupExporter.BackupCanceledException e) { + Log.w(TAG, "Backup cancelled"); + throw e; + } catch (IOException e) { + Log.w(TAG, "Error during backup!", e); + BackupFileIOError.postNotificationForException(context, e, getRunAttempt()); + throw e; + } finally { + if (tmpFiles != null) { + for (DocumentFile fileToCleanUp : tmpFiles) { + if (fileToCleanUp != null) { + if (fileToCleanUp.delete()) { + Log.w(TAG, "Backup failed. Deleted temp file"); + } else { + Log.w(TAG, "Backup failed. Failed to delete temp file " + fileToCleanUp.getName()); + } + } + } + } + } + + BackupUtil.deleteOldChunkedBackups(); + } + } + + private static void deleteOldTemporaryBackups(@NonNull DocumentFile backupDirectory) { + for (DocumentFile file : backupDirectory.listFiles()) { + if (file.isFile()) { + String name = file.getName(); + if (name != null && name.startsWith(TEMP_BACKUP_FILE_PREFIX) && name.endsWith(TEMP_BACKUP_FILE_SUFFIX)) { + if (file.delete()) { + Log.w(TAG, "Deleted old temporary backup file"); + } else { + Log.w(TAG, "Could not delete old temporary backup file"); + } + } + } + } + } + + @Override + public boolean onShouldRetry(@NonNull Exception e) { + return false; + } + + @Override + public void onFailure() { + } + + public static class Factory implements Job.Factory { + @Override + public @NonNull + LocalChunkedBackupJobApi29 create(@NonNull Parameters parameters, @NonNull Data data) { + return new LocalChunkedBackupJobApi29(parameters); + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/BackupsPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/BackupsPreferenceFragment.java index d948f43fa15..e81fb953e1a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/BackupsPreferenceFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/BackupsPreferenceFragment.java @@ -29,6 +29,7 @@ import org.thoughtcrime.securesms.database.NoExternalStorageException; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.jobs.LocalBackupJob; +import org.thoughtcrime.securesms.jobs.LocalChunkedBackupJob; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.util.BackupUtil; @@ -45,14 +46,18 @@ public class BackupsPreferenceFragment extends Fragment { private static final short CHOOSE_BACKUPS_LOCATION_REQUEST_CODE = 26212; private View create; + private View createChunked; private View folder; private View verify; private TextView toggle; private TextView info; private TextView summary; + private TextView chunkedSummary; private TextView folderName; private ProgressBar progress; + private ProgressBar chunkedProgress; private TextView progressSummary; + private TextView chunkedProgressSummary; private final NumberFormat formatter = NumberFormat.getInstance(); @@ -63,18 +68,23 @@ public class BackupsPreferenceFragment extends Fragment { @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - create = view.findViewById(R.id.fragment_backup_create); - folder = view.findViewById(R.id.fragment_backup_folder); - verify = view.findViewById(R.id.fragment_backup_verify); - toggle = view.findViewById(R.id.fragment_backup_toggle); - info = view.findViewById(R.id.fragment_backup_info); - summary = view.findViewById(R.id.fragment_backup_create_summary); - folderName = view.findViewById(R.id.fragment_backup_folder_name); - progress = view.findViewById(R.id.fragment_backup_progress); - progressSummary = view.findViewById(R.id.fragment_backup_progress_summary); + create = view.findViewById(R.id.fragment_backup_create); + createChunked = view.findViewById(R.id.fragment_backup_create_chunked); + folder = view.findViewById(R.id.fragment_backup_folder); + verify = view.findViewById(R.id.fragment_backup_verify); + toggle = view.findViewById(R.id.fragment_backup_toggle); + info = view.findViewById(R.id.fragment_backup_info); + summary = view.findViewById(R.id.fragment_backup_create_summary); + chunkedSummary = view.findViewById(R.id.fragment_backup_create_chunked_summary); + folderName = view.findViewById(R.id.fragment_backup_folder_name); + progress = view.findViewById(R.id.fragment_backup_progress); + chunkedProgress = view.findViewById(R.id.fragment_backup_chunked_progress); + progressSummary = view.findViewById(R.id.fragment_backup_progress_summary); + chunkedProgressSummary = view.findViewById(R.id.fragment_backup_chunked_progress_summary); toggle.setOnClickListener(unused -> onToggleClicked()); create.setOnClickListener(unused -> onCreateClicked()); + createChunked.setOnClickListener(unused -> onCreateChunkedClicked()); verify.setOnClickListener(unused -> BackupDialog.showVerifyBackupPassphraseDialog(requireContext())); formatter.setMinimumFractionDigits(1); @@ -90,6 +100,7 @@ public void onResume() { setBackupStatus(); setBackupSummary(); + setChunkedBackupSummary(); setInfo(); } @@ -121,6 +132,14 @@ public void onActivityResult(int requestCode, int resultCode, @Nullable Intent d @Subscribe(threadMode = ThreadMode.MAIN) public void onEvent(FullBackupBase.BackupEvent event) { + if (event.isChunked()) { + onChunkedBackupEvent(event); + } else { + onSingleFileBackupEvent(event); + } + } + + private void onSingleFileBackupEvent(FullBackupBase.BackupEvent event) { if (event.getType() == FullBackupBase.BackupEvent.Type.PROGRESS) { create.setEnabled(false); summary.setText(getString(R.string.BackupsPreferenceFragment__in_progress)); @@ -146,6 +165,32 @@ public void onEvent(FullBackupBase.BackupEvent event) { } } + private void onChunkedBackupEvent(FullBackupBase.BackupEvent event) { + if (event.getType() == FullBackupBase.BackupEvent.Type.PROGRESS) { + createChunked.setEnabled(false); + chunkedSummary.setText(getString(R.string.BackupsPreferenceFragment__in_progress)); + chunkedProgress.setVisibility(View.VISIBLE); + chunkedProgressSummary.setVisibility(event.getCount() > 0 ? View.VISIBLE : View.GONE); + + if (event.getEstimatedTotalCount() == 0) { + chunkedProgress.setIndeterminate(true); + chunkedProgressSummary.setText(getString(R.string.BackupsPreferenceFragment__d_so_far, event.getCount())); + } else { + double completionPercentage = event.getCompletionPercentage(); + + chunkedProgress.setIndeterminate(false); + chunkedProgress.setMax(100); + chunkedProgress.setProgress((int) completionPercentage); + chunkedProgressSummary.setText(getString(R.string.BackupsPreferenceFragment__s_so_far, formatter.format(completionPercentage))); + } + } else if (event.getType() == FullBackupBase.BackupEvent.Type.FINISHED) { + createChunked.setEnabled(true); + chunkedProgress.setVisibility(View.GONE); + chunkedProgressSummary.setVisibility(View.GONE); + setChunkedBackupSummary(); + } + } + private void setBackupStatus() { if (SignalStore.settings().isBackupEnabled()) { if (BackupUtil.canUserAccessBackupDirectory(requireContext())) { @@ -165,6 +210,10 @@ private void setBackupSummary() { summary.setText(getString(R.string.BackupsPreferenceFragment__last_backup, BackupUtil.getLastBackupTime(requireContext(), Locale.getDefault()))); } + private void setChunkedBackupSummary() { + chunkedSummary.setText(getString(R.string.BackupsPreferenceFragment__last_backup, BackupUtil.getLastChunkedBackupTime(requireContext(), Locale.getDefault()))); + } + private void setBackupFolderName() { folder.setVisibility(View.GONE); @@ -235,12 +284,26 @@ private void onCreateClicked() { } } + private void onCreateChunkedClicked() { + if (BackupUtil.isUserSelectionRequired(requireContext())) { + onCreateChunkedClickedApi29(); + } else { + onCreateChunkedClickedLegacy(); + } + } + @RequiresApi(29) private void onCreateClickedApi29() { Log.i(TAG, "Queing backup..."); LocalBackupJob.enqueue(true); } + @RequiresApi(29) + private void onCreateChunkedClickedApi29() { + Log.i(TAG, "Queing backup..."); + LocalChunkedBackupJob.enqueue(true); + } + private void onCreateClickedLegacy() { Permissions.with(this) .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) @@ -253,9 +316,22 @@ private void onCreateClickedLegacy() { .execute(); } + private void onCreateChunkedClickedLegacy() { + Permissions.with(this) + .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) + .ifNecessary() + .onAllGranted(() -> { + Log.i(TAG, "Queuing backup..."); + LocalChunkedBackupJob.enqueue(true); + }) + .withPermanentDenialDialog(getString(R.string.BackupsPreferenceFragment_signal_requires_external_storage_permission_in_order_to_create_backups)) + .execute(); + } + private void setBackupsEnabled() { toggle.setText(R.string.BackupsPreferenceFragment__turn_off); create.setVisibility(View.VISIBLE); + createChunked.setVisibility(View.VISIBLE); verify.setVisibility(View.VISIBLE); setBackupFolderName(); } @@ -263,6 +339,7 @@ private void setBackupsEnabled() { private void setBackupsDisabled() { toggle.setText(R.string.BackupsPreferenceFragment__turn_on); create.setVisibility(View.GONE); + createChunked.setVisibility(View.GONE); folder.setVisibility(View.GONE); verify.setVisibility(View.GONE); ApplicationDependencies.getJobManager().cancelAllInQueue(LocalBackupJob.QUEUE); diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/ChooseBackupFragment.java b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/ChooseBackupFragment.java index 1115d6e10ae..df1b0bc609b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/ChooseBackupFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/ChooseBackupFragment.java @@ -2,7 +2,9 @@ import android.app.Activity; import android.content.ActivityNotFoundException; +import android.content.ClipData; import android.content.Intent; +import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.provider.DocumentsContract; @@ -25,6 +27,8 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.util.navigation.SafeNavigation; +import java.util.Arrays; + public class ChooseBackupFragment extends LoggingFragment { private static final String TAG = Log.tag(ChooseBackupFragment.class); @@ -54,7 +58,16 @@ public void onActivityResult(int requestCode, int resultCode, @Nullable Intent d if (requestCode == OPEN_FILE_REQUEST_CODE && resultCode == Activity.RESULT_OK && data != null) { ChooseBackupFragmentDirections.ActionRestore restore = ChooseBackupFragmentDirections.actionRestore(); + ClipData clipData = data.getClipData(); + int multiUrisCount = clipData == null ? 0 : clipData.getItemCount(); + Uri[] multiUris = new Uri[multiUrisCount]; + for (int i = 0; i < multiUrisCount; ++i) { + Uri uri = clipData.getItemAt(i).getUri(); + multiUris[i] = uri; + } + restore.setUri(data.getData()); + restore.setMultiUris(multiUris); SafeNavigation.safeNavigate(Navigation.findNavController(requireView()), restore); } @@ -67,6 +80,7 @@ private void onChooseBackupSelected(@NonNull View view) { intent.setType("application/octet-stream"); intent.addCategory(Intent.CATEGORY_OPENABLE); intent.putExtra(Intent.EXTRA_LOCAL_ONLY, true); + intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); if (Build.VERSION.SDK_INT >= 26) { intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, SignalStore.settings().getLatestSignalBackupDirectory()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/RestoreBackupFragment.java b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/RestoreBackupFragment.java index 258c425dd17..b540fdc2e77 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/RestoreBackupFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/fragments/RestoreBackupFragment.java @@ -126,9 +126,19 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat } RestoreBackupFragmentArgs args = RestoreBackupFragmentArgs.fromBundle(requireArguments()); - if ((Build.VERSION.SDK_INT < 29 || BackupUtil.isUserSelectionRequired(requireContext())) && args.getUri() != null) { - Log.i(TAG, "Restoring backup from passed uri"); - initializeBackupForUri(view, args.getUri()); + if ((Build.VERSION.SDK_INT < 29 || BackupUtil.isUserSelectionRequired(requireContext())) && (args.getUri() != null || args.getMultiUris() != null)) { + Uri singleUri = args.getUri(); + Uri[] multiUris = args.getMultiUris(); + boolean isMultifileBackup = (multiUris != null && multiUris.length > 0); + if (singleUri != null && singleUri.toString().endsWith(".backup.part000")) { + isMultifileBackup = true; + multiUris = new Uri[] { singleUri }; + } + if (isMultifileBackup) { + initializeBackupForUris(view, multiUris); + } else { + initializeBackupForUri(view, singleUri); + } return; } @@ -164,6 +174,10 @@ private void initializeBackupForUri(@NonNull View view, @NonNull Uri uri) { getFromUri(requireContext(), uri, backup -> handleBackupInfo(view, backup)); } + private void initializeBackupForUris(@NonNull View view, @NonNull Uri[] uris) { + getFromUris(requireContext(), uris, backup -> handleBackupInfo(view, backup)); + } + @SuppressLint("StaticFieldLeak") private void initializeBackupDetection(@NonNull View view) { searchForBackup(backup -> handleBackupInfo(view, backup)); @@ -248,6 +262,22 @@ private static void postToastForBackupRestorationFailure(@NonNull Context contex ThreadUtil.postToMain(() -> Toast.makeText(context, errorResId, Toast.LENGTH_LONG).show()); } + static void getFromUris(@NonNull Context context, + @NonNull Uri[] backupUris, + @NonNull OnBackupSearchResultListener listener) + { + SimpleTask.run(() -> { + try { + return BackupUtil.getBackupInfoFromMultiUris(context, backupUris); + } catch (BackupUtil.BackupFileException e) { + Log.w(TAG, "Could not restore backup.", e); + postToastForBackupRestorationFailure(context, e); + return null; + } + }, + listener::run); + } + private void handleRestore(@NonNull Context context, @NonNull BackupUtil.BackupInfo backup) { View view = LayoutInflater.from(context).inflate(R.layout.enter_backup_passphrase_dialog, null); EditText prompt = view.findViewById(R.id.restore_passphrase_input); @@ -288,11 +318,19 @@ protected BackupImportResult doInBackground(Void... voids) { SQLiteDatabase database = SignalDatabase.getBackupDatabase(); BackupPassphrase.set(context, passphrase); - FullBackupImporter.importFile(context, - AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(), - database, - backup.getUri(), - passphrase); + if (backup.isSingleFileBackup()) { + FullBackupImporter.importFile(context, + AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(), + database, + backup.getUri(), + passphrase); + } else if (backup.isMultiFileBackup()) { + FullBackupImporter.importFile(context, + AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(), + database, + backup.getUris(), + passphrase); + } SignalDatabase.upgradeRestored(database); NotificationChannels.restoreContactNotificationChannels(context); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.java index 5693470ff3b..e37be3a67e0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/BackupUtil.java @@ -24,9 +24,13 @@ import java.io.File; import java.security.SecureRandom; +import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Calendar; import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Objects; @@ -49,6 +53,18 @@ public class BackupUtil { } } + public static @NonNull String getLastChunkedBackupTime(@NonNull Context context, @NonNull Locale locale) { + try { + BackupInfo backup = getLatestChunkedBackup(); + + if (backup == null) return context.getString(R.string.BackupUtil_never); + else return DateUtils.getExtendedRelativeTimeSpanString(context, locale, backup.getTimestamp()); + } catch (NoExternalStorageException e) { + Log.w(TAG, e); + return context.getString(R.string.BackupUtil_unknown); + } + } + public static boolean isUserSelectionRequired(@NonNull Context context) { return Build.VERSION.SDK_INT >= 29 && !Permissions.hasAll(context, Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE); } @@ -73,6 +89,12 @@ public static boolean canUserAccessBackupDirectory(@NonNull Context context) { return backups.isEmpty() ? null : backups.get(0); } + public static @Nullable BackupInfo getLatestChunkedBackup() throws NoExternalStorageException { + List backups = getAllChunkedBackupsNewestFirst(); + + return backups.isEmpty() ? null : backups.get(0); + } + public static void deleteAllBackups() { Log.i(TAG, "Deleting all backups"); @@ -101,6 +123,20 @@ public static void deleteOldBackups() { } } + public static void deleteOldChunkedBackups() { + Log.i(TAG, "Deleting older backups"); + + try { + List backups = getAllChunkedBackupsNewestFirst(); + + for (int i = 2; i < backups.size(); i++) { + backups.get(i).delete(); + } + } catch (NoExternalStorageException e) { + Log.w(TAG, e); + } + } + public static void disableBackups(@NonNull Context context) { BackupPassphrase.set(context, null); SignalStore.settings().setBackupEnabled(false); @@ -134,6 +170,14 @@ private static List getAllBackupsNewestFirst() throws NoExternalStor } } + private static List getAllChunkedBackupsNewestFirst() throws NoExternalStorageException { + if (isUserSelectionRequired(ApplicationDependencies.getApplication())) { + return getAllChunkedBackupsNewestFirstApi29(); + } else { + return getAllChunkedBackupsNewestFirstLegacy(); + } + } + @RequiresApi(29) private static List getAllBackupsNewestFirstApi29() { Uri backupDirectoryUri = SignalStore.settings().getSignalBackupDirectory(); @@ -166,6 +210,65 @@ private static List getAllBackupsNewestFirstApi29() { return backups; } + @RequiresApi(29) + private static List getAllChunkedBackupsNewestFirstApi29() { + Uri backupDirectoryUri = SignalStore.settings().getSignalBackupDirectory(); + if (backupDirectoryUri == null) { + Log.i(TAG, "Backup directory is not set. Returning an empty list."); + return Collections.emptyList(); + } + + DocumentFile backupDirectory = DocumentFile.fromTreeUri(ApplicationDependencies.getApplication(), backupDirectoryUri); + if (backupDirectory == null || !backupDirectory.exists() || !backupDirectory.canRead()) { + Log.w(TAG, "Backup directory is inaccessible. Returning an empty list."); + return Collections.emptyList(); + } + + DocumentFile[] files = backupDirectory.listFiles(); + List backups = new ArrayList<>(files.length); + + HashMap> m = new HashMap<>(); + + for (DocumentFile file : files) { + if (!file.isFile()) { + continue; + } + String name = file.getName(); + long backupTimestamp = getBackupTimestampFromMultiFileBackup(name); + if (backupTimestamp == -1) { + continue; + } + if (!m.containsKey(backupTimestamp)) { + m.put(backupTimestamp, new ArrayList<>()); + } + m.get(backupTimestamp).add(file); + } + + for (List l: m.values()) { + Collections.sort(l, Comparator.comparing(DocumentFile::getName)); + } + + for (long backupTimestamp: m.keySet()) { + long size = 0; + List documentFiles = m.get(backupTimestamp); + + for (DocumentFile d: documentFiles) { + size += d.length(); + } + + Uri[] uris = new Uri[documentFiles.size()]; + for (int i = 0; i < uris.length; ++i) { + uris[i] = documentFiles.get(i).getUri(); + } + BackupInfo backupInfo = new BackupInfo(backupTimestamp, size, uris); + backups.add(backupInfo); + } + + Collections.sort(backups, (a, b) -> Long.compare(b.timestamp, a.timestamp)); + + return backups; + } + public static @Nullable BackupInfo getBackupInfoFromSingleUri(@NonNull Context context, @NonNull Uri singleUri) throws BackupFileException { DocumentFile documentFile = Objects.requireNonNull(DocumentFile.fromSingleUri(context, singleUri)); @@ -186,6 +289,20 @@ private static List getAllBackupsNewestFirstApi29() { } } + public static @Nullable BackupInfo getBackupInfoFromMultiUris(@NonNull Context context, @NonNull Uri[] multiUris) throws BackupFileException { + DocumentFile[] documentFiles = toDocumentFiles(context, multiUris); + BackupFileState backupFileState = getBackupFileState(documentFiles); + + if (backupFileState.isSuccess()) { + long backupTimestamp = getBackupTimestampFromMultiFileBackup(Objects.requireNonNull(documentFiles[0].getName())); + return new BackupInfo(backupTimestamp, getLength(documentFiles), multiUris); + } + + Log.w(TAG, "Could not load backup info."); + backupFileState.throwIfError(); + return null; + } + private static List getAllBackupsNewestFirstLegacy() throws NoExternalStorageException { File backupDirectory = StorageUtil.getOrCreateBackupDirectory(); File[] files = backupDirectory.listFiles(); @@ -206,6 +323,54 @@ private static List getAllBackupsNewestFirstLegacy() throws NoExtern return backups; } + private static List getAllChunkedBackupsNewestFirstLegacy() throws NoExternalStorageException { + File backupDirectory = StorageUtil.getOrCreateBackupDirectory(); + File[] files = backupDirectory.listFiles(); + List backups = new ArrayList<>(files.length); + + HashMap> m = new HashMap<>(); + + for (File file: files) { + if (!file.isFile()) { + continue; + } + String name = file.getName(); + long backupTimestamp = getBackupTimestampFromMultiFileBackup(name); + if (backupTimestamp == -1) { + continue; + } + if (!m.containsKey(backupTimestamp)) { + m.put(backupTimestamp, new ArrayList<>()); + } + m.get(backupTimestamp).add(file); + } + + for (List l: m.values()) { + Collections.sort(l, Comparator.comparing(File::getName)); + } + + for (long backupTimestamp: m.keySet()) { + long size = 0; + List filesList = m.get(backupTimestamp); + + for (File f: filesList) { + size += f.length(); + } + + Uri[] uris = new Uri[filesList.size()]; + for (int i = 0; i < uris.length; ++i) { + File f = filesList.get(i); + uris[i] = Uri.fromFile(f); + } + BackupInfo backupInfo = new BackupInfo(backupTimestamp, size, uris); + backups.add(backupInfo); + } + + Collections.sort(backups, (a, b) -> Long.compare(b.timestamp, a.timestamp)); + + return backups; + } + public static @NonNull String[] generateBackupPassphrase() { String[] result = new String[6]; byte[] random = new byte[30]; @@ -239,6 +404,53 @@ public static boolean hasBackupFiles(@NonNull Context context) { } } + public static boolean renameMulti(List fromFiles, List toFiles) { + if (fromFiles.size() != toFiles.size()) { + throw (new RuntimeException("fromFiles.size() doesn't match toFiles.size()")); + } + boolean success = true; + for (int i = 0; i < fromFiles.size(); ++i) { + File from = fromFiles.get(i); + File to = toFiles.get(i); + success = success && from.renameTo(to); + } + return success; + } + + public static boolean renameMulti2(List fromFiles, List toFiles) { + if (fromFiles.size() != toFiles.size()) { + throw (new RuntimeException("fromFiles.size() doesn't match toFiles.size()")); + } + boolean success = true; + for (int i = 0; i < fromFiles.size(); ++i) { + DocumentFile from = fromFiles.get(i); + String to = toFiles.get(i); + success = success && from.renameTo(to); + fromFiles.set(i, null); + } + return success; + } + + public static List generateBackupFilenames(int n) { + String timestamp = new SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.US).format(new Date()); + List filenames = new ArrayList<>(n); + for (int i = 0; i < n; ++i) { + String filename = String.format(Locale.ENGLISH, "signal-%s.backup.part%03d", timestamp, i); + filenames.add(filename); + } + return filenames; + } + + public static List generateBackupFilenames2(File backupDirectory, int n) { + List filenames = generateBackupFilenames(n); + List result = new ArrayList<>(filenames.size()); + for (int i = 0; i < n; ++i) { + File f = new File(backupDirectory, filenames.get(i)); + result.add(i, f); + } + return result; + } + private static long getBackupTimestamp(@NonNull String backupName) { String[] prefixSuffix = backupName.split("[.]"); @@ -274,8 +486,51 @@ private static BackupFileState getBackupFileState(@NonNull DocumentFile document } else if (Util.isEmpty(documentFile.getName()) || !documentFile.getName().endsWith(".backup")) { return BackupFileState.UNSUPPORTED_FILE_EXTENSION; } else { - return BackupFileState.READABLE; + return BackupFileState.READABLE; + } + } + + static final String multiFileRegex = "^signal-\\d{4}-\\d{2}-\\d{2}-\\d{2}-\\d{2}-\\d{2}.backup.part\\d{3}$"; + + private static BackupFileState getBackupFileState(@NonNull DocumentFile[] documentFiles) { + if (documentFiles.length == 0) { + return BackupFileState.NOT_FOUND; + } + for (DocumentFile documentFile: documentFiles) { + if (!documentFile.exists()) { + return BackupFileState.NOT_FOUND; + } else if (!documentFile.canRead()) { + return BackupFileState.NOT_READABLE; + } else if (Util.isEmpty(documentFile.getName()) || !documentFile.getName().matches(multiFileRegex)) { + return BackupFileState.UNSUPPORTED_FILE_EXTENSION; + } + } + return BackupFileState.READABLE; + } + + private static DocumentFile[] toDocumentFiles(Context context, @NonNull Uri[] uris) { + DocumentFile[] documentFiles = new DocumentFile[uris.length]; + for (int i = 0; i < uris.length; i++) { + documentFiles[i] = DocumentFile.fromSingleUri(context, uris[i]); + } + return documentFiles; + } + + private static long getLength(@NonNull DocumentFile[] documentFiles) { + long length = 0; + for(DocumentFile documentFile: documentFiles) { + length += documentFile.length(); + } + return length; + } + + private static long getBackupTimestampFromMultiFileBackup(@NonNull String filename) { + if (!filename.matches(multiFileRegex)) { + // this is no (correctly named) multifile backup + return -1; } + String s = filename.substring(0, filename.length() - ".partXXX".length()); + return getBackupTimestamp(s); } /** @@ -326,11 +581,28 @@ public static class BackupInfo { private final long timestamp; private final long size; private final Uri uri; + private final Uri[] uris; BackupInfo(long timestamp, long size, Uri uri) { this.timestamp = timestamp; this.size = size; this.uri = uri; + this.uris = null; + } + + BackupInfo(long timestamp, long size, Uri[] uris) { + this.timestamp = timestamp; + this.size = size; + this.uri = null; + this.uris = uris; + } + + public boolean isSingleFileBackup() { + return uri != null; + } + + public boolean isMultiFileBackup() { + return uris != null; } public long getTimestamp() { @@ -345,7 +617,11 @@ public Uri getUri() { return uri; } - private void delete() { + public Uri[] getUris() { + return uris; + } + + private static void delete(Uri uri) { File file = new File(Objects.requireNonNull(uri.getPath())); if (file.exists()) { @@ -365,5 +641,16 @@ private void delete() { } } } + + private void delete() { + if (uri != null) { + delete(uri); + } + if (uris != null) { + for (Uri uri: uris) { + delete(uri); + } + } + } } } diff --git a/app/src/main/res/layout/fragment_backups.xml b/app/src/main/res/layout/fragment_backups.xml index ee4d1093b40..fa73056534f 100644 --- a/app/src/main/res/layout/fragment_backups.xml +++ b/app/src/main/res/layout/fragment_backups.xml @@ -87,6 +87,73 @@ + + + + + + + + + + + + + + Chat backups Backups are encrypted with a passphrase and stored on your device. Create backup + Create chunked backup Last backup: %1$s Backup folder Verify backup passphrase