diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/BackupEvent.java b/app/src/main/java/org/thoughtcrime/securesms/backup/BackupEvent.java new file mode 100644 index 0000000000..66a49c63d2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/BackupEvent.java @@ -0,0 +1,39 @@ +package org.thoughtcrime.securesms.backup; + +public class BackupEvent { + public enum Type { + PROGRESS, + PROGRESS_VERIFYING, + FINISHED + } + + private final Type type; + private final long count; + private final long estimatedTotalCount; + + BackupEvent(Type type, long count, long estimatedTotalCount) { + this.type = type; + this.count = count; + this.estimatedTotalCount = estimatedTotalCount; + } + + public Type getType() { + return type; + } + + public long getCount() { + return count; + } + + public long getEstimatedTotalCount() { + return estimatedTotalCount; + } + + public double getCompletionPercentage() { + if (estimatedTotalCount == 0) { + return 0; + } + + return Math.min(99.9f, (double) count * 100L / (double) estimatedTotalCount); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/BackupFileIOError.java b/app/src/main/java/org/thoughtcrime/securesms/backup/BackupFileIOError.java index 5c92c77529..0c870e85a3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/BackupFileIOError.java +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/BackupFileIOError.java @@ -21,6 +21,7 @@ public enum BackupFileIOError { ACCESS_ERROR(R.string.LocalBackupJobApi29_backup_failed, R.string.LocalBackupJobApi29_your_backup_directory_has_been_deleted_or_moved), FILE_TOO_LARGE(R.string.LocalBackupJobApi29_backup_failed, R.string.LocalBackupJobApi29_your_backup_file_is_too_large), NOT_ENOUGH_SPACE(R.string.LocalBackupJobApi29_backup_failed, R.string.LocalBackupJobApi29_there_is_not_enough_space), + VERIFICATION_FAILED(R.string.LocalBackupJobApi29_backup_failed, R.string.LocalBackupJobApi29_your_backup_could_not_be_verified), UNKNOWN(R.string.LocalBackupJobApi29_backup_failed, R.string.LocalBackupJobApi29_tap_to_manage_backups); private static final short BACKUP_FAILED_ID = 31321; diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/BackupRecordInputStream.java b/app/src/main/java/org/thoughtcrime/securesms/backup/BackupRecordInputStream.java new file mode 100644 index 0000000000..9c11e14e1e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/BackupRecordInputStream.java @@ -0,0 +1,161 @@ +package org.thoughtcrime.securesms.backup; + +import androidx.annotation.NonNull; + +import org.signal.core.util.Conversions; +import org.signal.core.util.StreamUtil; +import org.signal.libsignal.protocol.kdf.HKDF; +import org.signal.libsignal.protocol.util.ByteUtil; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.Mac; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +class BackupRecordInputStream extends FullBackupBase.BackupStream { + + private final InputStream in; + private final Cipher cipher; + private final Mac mac; + + private final byte[] cipherKey; + + private final byte[] iv; + private int counter; + + BackupRecordInputStream(@NonNull InputStream in, @NonNull String passphrase) throws IOException { + try { + this.in = in; + + byte[] headerLengthBytes = new byte[4]; + StreamUtil.readFully(in, headerLengthBytes); + + int headerLength = Conversions.byteArrayToInt(headerLengthBytes); + byte[] headerFrame = new byte[headerLength]; + StreamUtil.readFully(in, headerFrame); + + BackupProtos.BackupFrame frame = BackupProtos.BackupFrame.parseFrom(headerFrame); + + if (!frame.hasHeader()) { + throw new IOException("Backup stream does not start with header!"); + } + + BackupProtos.Header header = frame.getHeader(); + + this.iv = header.getIv().toByteArray(); + + if (iv.length != 16) { + throw new IOException("Invalid IV length!"); + } + + byte[] key = getBackupKey(passphrase, header.hasSalt() ? header.getSalt().toByteArray() : null); + byte[] derived = HKDF.deriveSecrets(key, "Backup Export".getBytes(), 64); + byte[][] split = ByteUtil.split(derived, 32, 32); + + this.cipherKey = split[0]; + byte[] macKey = split[1]; + + this.cipher = Cipher.getInstance("AES/CTR/NoPadding"); + this.mac = Mac.getInstance("HmacSHA256"); + this.mac.init(new SecretKeySpec(macKey, "HmacSHA256")); + + this.counter = Conversions.byteArrayToInt(iv); + } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException e) { + throw new AssertionError(e); + } + } + + BackupProtos.BackupFrame readFrame() throws IOException { + return readFrame(in); + } + + void readAttachmentTo(OutputStream out, int length) throws IOException { + try { + Conversions.intToByteArray(iv, 0, counter++); + cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(cipherKey, "AES"), new IvParameterSpec(iv)); + mac.update(iv); + + byte[] buffer = new byte[8192]; + + while (length > 0) { + int read = in.read(buffer, 0, Math.min(buffer.length, length)); + if (read == -1) throw new IOException("File ended early!"); + + mac.update(buffer, 0, read); + + byte[] plaintext = cipher.update(buffer, 0, read); + + if (plaintext != null) { + out.write(plaintext, 0, plaintext.length); + } + + length -= read; + } + + byte[] plaintext = cipher.doFinal(); + + if (plaintext != null) { + out.write(plaintext, 0, plaintext.length); + } + + out.close(); + + byte[] ourMac = ByteUtil.trim(mac.doFinal(), 10); + byte[] theirMac = new byte[10]; + + try { + StreamUtil.readFully(in, theirMac); + } catch (IOException e) { + throw new IOException(e); + } + + if (!MessageDigest.isEqual(ourMac, theirMac)) { + throw new BadMacException(); + } + } catch (InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) { + throw new AssertionError(e); + } + } + + private BackupProtos.BackupFrame readFrame(InputStream in) throws IOException { + try { + byte[] length = new byte[4]; + StreamUtil.readFully(in, length); + + byte[] frame = new byte[Conversions.byteArrayToInt(length)]; + StreamUtil.readFully(in, frame); + + byte[] theirMac = new byte[10]; + System.arraycopy(frame, frame.length - 10, theirMac, 0, theirMac.length); + + mac.update(frame, 0, frame.length - 10); + byte[] ourMac = ByteUtil.trim(mac.doFinal(), 10); + + if (!MessageDigest.isEqual(ourMac, theirMac)) { + throw new IOException("Bad MAC"); + } + + Conversions.intToByteArray(iv, 0, counter++); + cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(cipherKey, "AES"), new IvParameterSpec(iv)); + + byte[] plaintext = cipher.doFinal(frame, 0, frame.length - 10); + + return BackupProtos.BackupFrame.parseFrom(plaintext); + } catch (InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) { + throw new AssertionError(e); + } + } + + static class BadMacException extends IOException {} +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/backup/BackupVerifier.kt b/app/src/main/java/org/thoughtcrime/securesms/backup/BackupVerifier.kt new file mode 100644 index 0000000000..e1e917db8a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/BackupVerifier.kt @@ -0,0 +1,82 @@ +package org.thoughtcrime.securesms.backup + +import org.greenrobot.eventbus.EventBus +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream + +/** + * Given a backup file, run over it and verify it will decrypt properly when attempting to import it. + */ +object BackupVerifier { + + private val TAG = Log.tag(BackupVerifier::class.java) + + @JvmStatic + fun verifyFile(cipherStream: InputStream, passphrase: String, expectedCount: Long): Boolean { + val inputStream = BackupRecordInputStream(cipherStream, passphrase) + + var count = 0L + var frame: BackupFrame = inputStream.readFrame() + + while (!frame.end) { + val verified = when { + frame.hasAttachment() -> verifyAttachment(frame.attachment, inputStream) + frame.hasSticker() -> verifySticker(frame.sticker, inputStream) + frame.hasAvatar() -> verifyAvatar(frame.avatar, inputStream) + else -> true + } + + if (!verified) { + return false + } + + EventBus.getDefault().post(BackupEvent(BackupEvent.Type.PROGRESS_VERIFYING, ++count, expectedCount)) + + frame = inputStream.readFrame() + } + + cipherStream.close() + + return true + } + + private fun verifyAttachment(attachment: BackupProtos.Attachment, inputStream: BackupRecordInputStream): Boolean { + try { + inputStream.readAttachmentTo(NullOutputStream, attachment.length) + } catch (e: IOException) { + Log.w(TAG, "Bad attachment: ${attachment.attachmentId}", e) + return false + } + + return true + } + + private fun verifySticker(sticker: BackupProtos.Sticker, inputStream: BackupRecordInputStream): Boolean { + try { + inputStream.readAttachmentTo(NullOutputStream, sticker.length) + } catch (e: IOException) { + Log.w(TAG, "Bad sticker: ${sticker.rowId}", e) + return false + } + return true + } + + private fun verifyAvatar(avatar: BackupProtos.Avatar, inputStream: BackupRecordInputStream): Boolean { + try { + inputStream.readAttachmentTo(NullOutputStream, avatar.length) + } catch (e: IOException) { + Log.w(TAG, "Bad sticker: ${avatar.recipientId}", e) + return false + } + return true + } + + private object NullOutputStream : OutputStream() { + override fun write(b: Int) = Unit + override fun write(b: ByteArray?) = Unit + override fun write(b: ByteArray?, off: Int, len: Int) = Unit + } +} 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 d4effce360..bfbceeca89 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupBase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupBase.java @@ -17,8 +17,6 @@ public abstract class FullBackupBase { static class BackupStream { static @NonNull byte[] getBackupKey(@NonNull String passphrase, @Nullable byte[] salt) { try { - EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.PROGRESS, 0, 0)); - MessageDigest digest = MessageDigest.getInstance("SHA-512"); byte[] input = passphrase.replace(" ", "").getBytes(); byte[] hash = input; @@ -26,7 +24,6 @@ public abstract class FullBackupBase { 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)); digest.update(hash); hash = digest.digest(input); } @@ -37,42 +34,4 @@ public abstract class FullBackupBase { } } } - - public static class BackupEvent { - public enum Type { - PROGRESS, - FINISHED - } - - private final Type type; - private final long count; - private final long estimatedTotalCount; - - BackupEvent(Type type, long count, long estimatedTotalCount) { - this.type = type; - this.count = count; - this.estimatedTotalCount = estimatedTotalCount; - } - - public Type getType() { - return type; - } - - public long getCount() { - return count; - } - - public long getEstimatedTotalCount() { - return estimatedTotalCount; - } - - public double getCompletionPercentage() { - if (estimatedTotalCount == 0) { - return 0; - } - - return Math.min(99.9f, (double) count * 100L / (double) estimatedTotalCount); - } - } - } 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 98f09db573..73221d1af7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupExporter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupExporter.java @@ -96,7 +96,7 @@ public class FullBackupExporter extends FullBackupBase { AvatarPickerDatabase.TABLE_NAME ); - public static void export(@NonNull Context context, + public static BackupEvent export(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret, @NonNull SQLiteDatabase input, @NonNull File output, @@ -105,12 +105,12 @@ public class FullBackupExporter extends FullBackupBase { throws IOException { try (OutputStream outputStream = new FileOutputStream(output)) { - internalExport(context, attachmentSecret, input, outputStream, passphrase, true, cancellationSignal); + return internalExport(context, attachmentSecret, input, outputStream, passphrase, true, cancellationSignal); } } @RequiresApi(29) - public static void export(@NonNull Context context, + public static BackupEvent export(@NonNull Context context, @NonNull AttachmentSecret attachmentSecret, @NonNull SQLiteDatabase input, @NonNull DocumentFile output, @@ -119,7 +119,7 @@ public class FullBackupExporter extends FullBackupBase { throws IOException { try (OutputStream outputStream = Objects.requireNonNull(context.getContentResolver().openOutputStream(output.getUri()))) { - internalExport(context, attachmentSecret, input, outputStream, passphrase, true, cancellationSignal); + return internalExport(context, attachmentSecret, input, outputStream, passphrase, true, cancellationSignal); } } @@ -130,16 +130,16 @@ public class FullBackupExporter extends FullBackupBase { @NonNull String passphrase) throws IOException { - internalExport(context, attachmentSecret, input, outputStream, passphrase, false, () -> false); + EventBus.getDefault().post(internalExport(context, attachmentSecret, input, outputStream, passphrase, false, () -> false)); } - private static void internalExport(@NonNull Context context, - @NonNull AttachmentSecret attachmentSecret, - @NonNull SQLiteDatabase input, - @NonNull OutputStream fileOutputStream, - @NonNull String passphrase, - boolean closeOutputStream, - @NonNull BackupCancellationSignal cancellationSignal) + private static BackupEvent internalExport(@NonNull Context context, + @NonNull AttachmentSecret attachmentSecret, + @NonNull SQLiteDatabase input, + @NonNull OutputStream fileOutputStream, + @NonNull String passphrase, + boolean closeOutputStream, + @NonNull BackupCancellationSignal cancellationSignal) throws IOException { BackupFrameOutputStream outputStream = new BackupFrameOutputStream(fileOutputStream, passphrase); @@ -210,8 +210,8 @@ public class FullBackupExporter extends FullBackupBase { if (closeOutputStream) { outputStream.close(); } - EventBus.getDefault().post(new BackupEvent(BackupEvent.Type.FINISHED, ++count, estimatedCountOutside)); } + return new BackupEvent(BackupEvent.Type.FINISHED, ++count, estimatedCountOutside); } private static long calculateCount(@NonNull Context context, @NonNull SQLiteDatabase input, List tables) { 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 09fe1c2f15..ba3f110190 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/backup/FullBackupImporter.java @@ -14,11 +14,8 @@ import androidx.annotation.NonNull; import net.zetetic.database.sqlcipher.SQLiteDatabase; import org.greenrobot.eventbus.EventBus; -import org.signal.core.util.Conversions; -import org.signal.core.util.StreamUtil; +import org.signal.core.util.SqlUtil; import org.signal.core.util.logging.Log; -import org.signal.libsignal.protocol.kdf.HKDFv3; -import org.signal.libsignal.protocol.util.ByteUtil; import org.thoughtcrime.securesms.backup.BackupProtos.Attachment; import org.thoughtcrime.securesms.backup.BackupProtos.BackupFrame; import org.thoughtcrime.securesms.backup.BackupProtos.DatabaseVersion; @@ -38,7 +35,6 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.profiles.AvatarHelper; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.util.BackupUtil; -import org.signal.core.util.SqlUtil; import java.io.ByteArrayOutputStream; import java.io.File; @@ -46,24 +42,12 @@ import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; -import java.security.InvalidAlgorithmParameterException; -import java.security.InvalidKeyException; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; import java.util.Collections; import java.util.HashSet; import java.util.LinkedList; import java.util.List; import java.util.Objects; -import javax.crypto.BadPaddingException; -import javax.crypto.Cipher; -import javax.crypto.IllegalBlockSizeException; -import javax.crypto.Mac; -import javax.crypto.NoSuchPaddingException; -import javax.crypto.spec.IvParameterSpec; -import javax.crypto.spec.SecretKeySpec; - public class FullBackupImporter extends FullBackupBase { @SuppressWarnings("unused") @@ -185,7 +169,7 @@ public class FullBackupImporter extends FullBackupBase { contentValues.put(AttachmentDatabase.DATA, dataFile.getAbsolutePath()); contentValues.put(AttachmentDatabase.DATA_RANDOM, output.first); - } catch (BadMacException e) { + } catch (BackupRecordInputStream.BadMacException e) { Log.w(TAG, "Bad MAC for attachment " + attachment.getAttachmentId() + "! Can't restore it.", e); dataFile.delete(); contentValues.put(AttachmentDatabase.DATA, (String) null); @@ -301,144 +285,6 @@ public class FullBackupImporter extends FullBackupBase { } } - private static class BackupRecordInputStream extends BackupStream { - - private final InputStream in; - private final Cipher cipher; - private final Mac mac; - - private final byte[] cipherKey; - private final byte[] macKey; - - private byte[] iv; - private int counter; - - private BackupRecordInputStream(@NonNull InputStream in, @NonNull String passphrase) throws IOException { - try { - this.in = in; - - byte[] headerLengthBytes = new byte[4]; - StreamUtil.readFully(in, headerLengthBytes); - - int headerLength = Conversions.byteArrayToInt(headerLengthBytes); - byte[] headerFrame = new byte[headerLength]; - StreamUtil.readFully(in, headerFrame); - - BackupFrame frame = BackupFrame.parseFrom(headerFrame); - - if (!frame.hasHeader()) { - throw new IOException("Backup stream does not start with header!"); - } - - BackupProtos.Header header = frame.getHeader(); - - this.iv = header.getIv().toByteArray(); - - if (iv.length != 16) { - throw new IOException("Invalid IV length!"); - } - - byte[] key = getBackupKey(passphrase, header.hasSalt() ? header.getSalt().toByteArray() : null); - byte[] derived = new HKDFv3().deriveSecrets(key, "Backup Export".getBytes(), 64); - byte[][] split = ByteUtil.split(derived, 32, 32); - - this.cipherKey = split[0]; - this.macKey = split[1]; - - this.cipher = Cipher.getInstance("AES/CTR/NoPadding"); - this.mac = Mac.getInstance("HmacSHA256"); - this.mac.init(new SecretKeySpec(macKey, "HmacSHA256")); - - this.counter = Conversions.byteArrayToInt(iv); - } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException e) { - throw new AssertionError(e); - } - } - - BackupFrame readFrame() throws IOException { - return readFrame(in); - } - - void readAttachmentTo(OutputStream out, int length) throws IOException { - try { - Conversions.intToByteArray(iv, 0, counter++); - cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(cipherKey, "AES"), new IvParameterSpec(iv)); - mac.update(iv); - - byte[] buffer = new byte[8192]; - - while (length > 0) { - int read = in.read(buffer, 0, Math.min(buffer.length, length)); - if (read == -1) throw new IOException("File ended early!"); - - mac.update(buffer, 0, read); - - byte[] plaintext = cipher.update(buffer, 0, read); - - if (plaintext != null) { - out.write(plaintext, 0, plaintext.length); - } - - length -= read; - } - - byte[] plaintext = cipher.doFinal(); - - if (plaintext != null) { - out.write(plaintext, 0, plaintext.length); - } - - out.close(); - - byte[] ourMac = ByteUtil.trim(mac.doFinal(), 10); - byte[] theirMac = new byte[10]; - - try { - StreamUtil.readFully(in, theirMac); - } catch (IOException e) { - throw new IOException(e); - } - - if (!MessageDigest.isEqual(ourMac, theirMac)) { - throw new BadMacException(); - } - } catch (InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) { - throw new AssertionError(e); - } - } - - private BackupFrame readFrame(InputStream in) throws IOException { - try { - byte[] length = new byte[4]; - StreamUtil.readFully(in, length); - - byte[] frame = new byte[Conversions.byteArrayToInt(length)]; - StreamUtil.readFully(in, frame); - - byte[] theirMac = new byte[10]; - System.arraycopy(frame, frame.length - 10, theirMac, 0, theirMac.length); - - mac.update(frame, 0, frame.length - 10); - byte[] ourMac = ByteUtil.trim(mac.doFinal(), 10); - - if (!MessageDigest.isEqual(ourMac, theirMac)) { - throw new IOException("Bad MAC"); - } - - Conversions.intToByteArray(iv, 0, counter++); - cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(cipherKey, "AES"), new IvParameterSpec(iv)); - - byte[] plaintext = cipher.doFinal(frame, 0, frame.length - 10); - - return BackupFrame.parseFrom(plaintext); - } catch (InvalidKeyException | InvalidAlgorithmParameterException | IllegalBlockSizeException | BadPaddingException e) { - throw new AssertionError(e); - } - } - } - - private static class BadMacException extends IOException {} - public static class DatabaseDowngradeException extends IOException { DatabaseDowngradeException(int currentVersion, int backupVersion) { super("Tried to import a backup with version " + backupVersion + " into a database with version " + currentVersion); 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 fbae06b74e..173edeeeed 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 @@ -12,8 +12,8 @@ import org.greenrobot.eventbus.ThreadMode; import org.signal.core.util.logging.Log; import org.signal.devicetransfer.ServerTask; import org.thoughtcrime.securesms.AppInitialization; +import org.thoughtcrime.securesms.backup.BackupEvent; import org.thoughtcrime.securesms.backup.BackupPassphrase; -import org.thoughtcrime.securesms.backup.FullBackupBase; import org.thoughtcrime.securesms.backup.FullBackupImporter; import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider; import org.thoughtcrime.securesms.database.SignalDatabase; @@ -70,10 +70,10 @@ final class NewDeviceServerTask implements ServerTask { } @Subscribe(threadMode = ThreadMode.POSTING) - public void onEvent(FullBackupBase.BackupEvent event) { - if (event.getType() == FullBackupBase.BackupEvent.Type.PROGRESS) { + public void onEvent(BackupEvent event) { + if (event.getType() == BackupEvent.Type.PROGRESS) { EventBus.getDefault().post(new Status(event.getCount(), Status.State.IN_PROGRESS)); - } else if (event.getType() == FullBackupBase.BackupEvent.Type.FINISHED) { + } else if (event.getType() == BackupEvent.Type.FINISHED) { EventBus.getDefault().post(new Status(event.getCount(), Status.State.SUCCESS)); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/olddevice/OldDeviceClientTask.java b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/olddevice/OldDeviceClientTask.java index 305bfd8ace..948dddf52d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/olddevice/OldDeviceClientTask.java +++ b/app/src/main/java/org/thoughtcrime/securesms/devicetransfer/olddevice/OldDeviceClientTask.java @@ -9,7 +9,7 @@ import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; import org.signal.core.util.logging.Log; import org.signal.devicetransfer.ClientTask; -import org.thoughtcrime.securesms.backup.FullBackupBase; +import org.thoughtcrime.securesms.backup.BackupEvent; import org.thoughtcrime.securesms.backup.FullBackupExporter; import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider; import org.thoughtcrime.securesms.database.SignalDatabase; @@ -56,8 +56,8 @@ final class OldDeviceClientTask implements ClientTask { } @Subscribe(threadMode = ThreadMode.POSTING) - public void onEvent(FullBackupBase.BackupEvent event) { - if (event.getType() == FullBackupBase.BackupEvent.Type.PROGRESS) { + public void onEvent(BackupEvent event) { + if (event.getType() == BackupEvent.Type.PROGRESS) { if (System.currentTimeMillis() > lastProgressUpdate + PROGRESS_UPDATE_THROTTLE) { EventBus.getDefault().post(new Status(event.getCount(), event.getEstimatedTotalCount(), event.getCompletionPercentage(), false)); lastProgressUpdate = System.currentTimeMillis(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupJob.java index 8eaf7390d4..41759c400b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupJob.java @@ -8,11 +8,13 @@ import androidx.annotation.NonNull; import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; +import org.signal.core.util.Stopwatch; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.backup.BackupEvent; import org.thoughtcrime.securesms.backup.BackupFileIOError; import org.thoughtcrime.securesms.backup.BackupPassphrase; -import org.thoughtcrime.securesms.backup.FullBackupBase; +import org.thoughtcrime.securesms.backup.BackupVerifier; import org.thoughtcrime.securesms.backup.FullBackupExporter; import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider; import org.thoughtcrime.securesms.database.NoExternalStorageException; @@ -30,6 +32,7 @@ import org.thoughtcrime.securesms.util.BackupUtil; import org.thoughtcrime.securesms.util.StorageUtil; import java.io.File; +import java.io.FileInputStream; import java.io.IOException; import java.text.SimpleDateFormat; import java.util.Date; @@ -89,7 +92,7 @@ public final class LocalBackupJob extends BaseJob { throw new IOException("No external storage permission!"); } - ProgressUpdater updater = new ProgressUpdater(); + ProgressUpdater updater = new ProgressUpdater(context.getString(R.string.LocalBackupJob_verifying_signal_backup)); try (NotificationController notification = GenericForegroundService.startForegroundTask(context, context.getString(R.string.LocalBackupJob_creating_signal_backup), NotificationChannels.BACKUPS, @@ -118,16 +121,28 @@ public final class LocalBackupJob extends BaseJob { File tempFile = File.createTempFile(TEMP_BACKUP_FILE_PREFIX, TEMP_BACKUP_FILE_SUFFIX, backupDirectory); try { - FullBackupExporter.export(context, - AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(), - SignalDatabase.getBackupDatabase(), - tempFile, - backupPassword, - this::isCanceled); + Stopwatch stopwatch = new Stopwatch("backup-export"); + BackupEvent finishedEvent = FullBackupExporter.export(context, + AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(), + SignalDatabase.getBackupDatabase(), + tempFile, + backupPassword, + this::isCanceled); + stopwatch.split("backup-create"); - if (!tempFile.renameTo(backupFile)) { - Log.w(TAG, "Failed to rename temp file"); - throw new IOException("Renaming temporary backup file failed!"); + boolean valid = BackupVerifier.verifyFile(new FileInputStream(tempFile), backupPassword, finishedEvent.getCount()); + stopwatch.split("backup-verify"); + stopwatch.stop(TAG); + + EventBus.getDefault().post(finishedEvent); + + if (valid) { + if (!tempFile.renameTo(backupFile)) { + Log.w(TAG, "Failed to rename temp file"); + throw new IOException("Renaming temporary backup file failed!"); + } + } else { + BackupFileIOError.VERIFICATION_FAILED.postNotification(context); } } catch (FullBackupExporter.BackupCanceledException e) { Log.w(TAG, "Backup cancelled"); @@ -177,19 +192,29 @@ public final class LocalBackupJob extends BaseJob { } private static class ProgressUpdater { - private NotificationController notification; + private final String verifyProgressTitle; + private NotificationController notification; + private boolean verifying = false; + + public ProgressUpdater(String verifyProgressTitle) { + this.verifyProgressTitle = verifyProgressTitle; + } @Subscribe(threadMode = ThreadMode.POSTING) - public void onEvent(FullBackupBase.BackupEvent event) { + public void onEvent(BackupEvent event) { if (notification == null) { return; } - if (event.getType() == FullBackupBase.BackupEvent.Type.PROGRESS) { + if (event.getType() == BackupEvent.Type.PROGRESS || event.getType() == BackupEvent.Type.PROGRESS_VERIFYING) { if (event.getEstimatedTotalCount() == 0) { notification.setIndeterminateProgress(); } else { notification.setProgress(100, (int) event.getCompletionPercentage()); + if (event.getType() == BackupEvent.Type.PROGRESS_VERIFYING && !verifying) { + notification.replaceTitle(verifyProgressTitle); + verifying = true; + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupJobApi29.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupJobApi29.java index 5b041e6bd7..4d027cbab7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupJobApi29.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupJobApi29.java @@ -9,11 +9,13 @@ import androidx.documentfile.provider.DocumentFile; import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; +import org.signal.core.util.Stopwatch; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.backup.BackupEvent; import org.thoughtcrime.securesms.backup.BackupFileIOError; import org.thoughtcrime.securesms.backup.BackupPassphrase; -import org.thoughtcrime.securesms.backup.FullBackupBase; +import org.thoughtcrime.securesms.backup.BackupVerifier; import org.thoughtcrime.securesms.backup.FullBackupExporter; import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider; import org.thoughtcrime.securesms.database.SignalDatabase; @@ -74,7 +76,7 @@ public final class LocalBackupJobApi29 extends BaseJob { throw new IOException("Backup Directory has not been selected!"); } - ProgressUpdater updater = new ProgressUpdater(); + ProgressUpdater updater = new ProgressUpdater(context.getString(R.string.LocalBackupJob_verifying_signal_backup)); try (NotificationController notification = GenericForegroundService.startForegroundTask(context, context.getString(R.string.LocalBackupJob_creating_signal_backup), NotificationChannels.BACKUPS, @@ -112,16 +114,28 @@ public final class LocalBackupJobApi29 extends BaseJob { } try { - FullBackupExporter.export(context, - AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(), - SignalDatabase.getBackupDatabase(), - temporaryFile, - backupPassword, - this::isCanceled); + Stopwatch stopwatch = new Stopwatch("backup-export"); + BackupEvent finishedEvent = FullBackupExporter.export(context, + AttachmentSecretProvider.getInstance(context).getOrCreateAttachmentSecret(), + SignalDatabase.getBackupDatabase(), + temporaryFile, + backupPassword, + this::isCanceled); + stopwatch.split("backup-create"); - if (!temporaryFile.renameTo(fileName)) { - Log.w(TAG, "Failed to rename temp file"); - throw new IOException("Renaming temporary backup file failed!"); + boolean valid = BackupVerifier.verifyFile(context.getContentResolver().openInputStream(temporaryFile.getUri()), backupPassword, finishedEvent.getCount()); + stopwatch.split("backup-verify"); + stopwatch.stop(TAG); + + EventBus.getDefault().post(finishedEvent); + + if (valid) { + if (!temporaryFile.renameTo(fileName)) { + Log.w(TAG, "Failed to rename temp file"); + throw new IOException("Renaming temporary backup file failed!"); + } + } else { + BackupFileIOError.VERIFICATION_FAILED.postNotification(context); } } catch (FullBackupExporter.BackupCanceledException e) { Log.w(TAG, "Backup cancelled"); @@ -173,19 +187,29 @@ public final class LocalBackupJobApi29 extends BaseJob { } private static class ProgressUpdater { - private NotificationController notification; + private final String verifyProgressTitle; + private NotificationController notification; + private boolean verifying = false; + + public ProgressUpdater(String verifyProgressTitle) { + this.verifyProgressTitle = verifyProgressTitle; + } @Subscribe(threadMode = ThreadMode.POSTING) - public void onEvent(FullBackupBase.BackupEvent event) { + public void onEvent(BackupEvent event) { if (notification == null) { return; } - if (event.getType() == FullBackupBase.BackupEvent.Type.PROGRESS) { + if (event.getType() == BackupEvent.Type.PROGRESS || event.getType() == BackupEvent.Type.PROGRESS_VERIFYING) { if (event.getEstimatedTotalCount() == 0) { notification.setIndeterminateProgress(); } else { notification.setProgress(100, (int) event.getCompletionPercentage()); + if (event.getType() == BackupEvent.Type.PROGRESS_VERIFYING && !verifying) { + notification.replaceTitle(verifyProgressTitle); + verifying = true; + } } } } 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 d948f43fa1..7c73656637 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/BackupsPreferenceFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/BackupsPreferenceFragment.java @@ -22,10 +22,11 @@ import androidx.fragment.app.Fragment; import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; +import org.signal.core.util.ThreadUtil; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.backup.BackupDialog; -import org.thoughtcrime.securesms.backup.FullBackupBase; +import org.thoughtcrime.securesms.backup.BackupEvent; import org.thoughtcrime.securesms.database.NoExternalStorageException; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.jobs.LocalBackupJob; @@ -37,6 +38,7 @@ import org.thoughtcrime.securesms.util.StorageUtil; import java.text.NumberFormat; import java.util.Locale; import java.util.Objects; +import java.util.concurrent.TimeUnit; public class BackupsPreferenceFragment extends Fragment { @@ -120,10 +122,11 @@ public class BackupsPreferenceFragment extends Fragment { } @Subscribe(threadMode = ThreadMode.MAIN) - public void onEvent(FullBackupBase.BackupEvent event) { - if (event.getType() == FullBackupBase.BackupEvent.Type.PROGRESS) { + public void onEvent(BackupEvent event) { + if (event.getType() == BackupEvent.Type.PROGRESS || event.getType() == BackupEvent.Type.PROGRESS_VERIFYING) { create.setEnabled(false); - summary.setText(getString(R.string.BackupsPreferenceFragment__in_progress)); + summary.setText(getString(event.getType() == BackupEvent.Type.PROGRESS ? R.string.BackupsPreferenceFragment__in_progress + : R.string.BackupsPreferenceFragment__verifying_backup)); progress.setVisibility(View.VISIBLE); progressSummary.setVisibility(event.getCount() > 0 ? View.VISIBLE : View.GONE); @@ -138,11 +141,12 @@ public class BackupsPreferenceFragment extends Fragment { progress.setProgress((int) completionPercentage); progressSummary.setText(getString(R.string.BackupsPreferenceFragment__s_so_far, formatter.format(completionPercentage))); } - } else if (event.getType() == FullBackupBase.BackupEvent.Type.FINISHED) { + } else if (event.getType() == BackupEvent.Type.FINISHED) { create.setEnabled(true); progress.setVisibility(View.GONE); progressSummary.setVisibility(View.GONE); setBackupSummary(); + ThreadUtil.runOnMainDelayed(this::setBackupSummary, 100); } } 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 76c2dfa8d3..2187c499a6 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 @@ -41,8 +41,8 @@ import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.AppInitialization; import org.thoughtcrime.securesms.LoggingFragment; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.backup.BackupEvent; import org.thoughtcrime.securesms.backup.BackupPassphrase; -import org.thoughtcrime.securesms.backup.FullBackupBase; import org.thoughtcrime.securesms.backup.FullBackupImporter; import org.thoughtcrime.securesms.crypto.AttachmentSecretProvider; import org.thoughtcrime.securesms.database.NoExternalStorageException; @@ -353,7 +353,7 @@ public final class RestoreBackupFragment extends LoggingFragment { } @Subscribe(threadMode = ThreadMode.MAIN) - public void onEvent(@NonNull FullBackupBase.BackupEvent event) { + public void onEvent(@NonNull BackupEvent event) { long count = event.getCount(); if (count == 0) { @@ -365,7 +365,7 @@ public final class RestoreBackupFragment extends LoggingFragment { restoreButton.setSpinning(); skipRestoreButton.setVisibility(View.INVISIBLE); - if (event.getType() == FullBackupBase.BackupEvent.Type.FINISHED) { + if (event.getType() == BackupEvent.Type.FINISHED) { onBackupComplete(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/GenericForegroundService.java b/app/src/main/java/org/thoughtcrime/securesms/service/GenericForegroundService.java index 1d9b1252c5..0d119e1cbd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/GenericForegroundService.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/GenericForegroundService.java @@ -163,6 +163,28 @@ public final class GenericForegroundService extends Service { ContextCompat.startForegroundService(context, intent); } + synchronized void replaceTitle(int id, @NonNull String title) { + Entry oldEntry = allActiveMessages.get(id); + + if (oldEntry == null) { + Log.w(TAG, "Failed to replace notification, it was not found"); + return; + } + + Entry newEntry = new Entry(title, oldEntry.channelId, oldEntry.iconRes, oldEntry.id, oldEntry.progressMax, oldEntry.progress, oldEntry.indeterminate); + + if (oldEntry.equals(newEntry)) { + Log.d(TAG, String.format("handleReplace() skip, no change %s", newEntry)); + return; + } + + Log.i(TAG, String.format("handleReplace() %s", newEntry)); + + allActiveMessages.put(newEntry.id, newEntry); + + updateNotification(); + } + synchronized void replaceProgress(int id, int progressMax, int progress, boolean indeterminate) { Entry oldEntry = allActiveMessages.get(id); diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/NotificationController.java b/app/src/main/java/org/thoughtcrime/securesms/service/NotificationController.java index 4e989dcc21..15d1eb1d21 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/NotificationController.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/NotificationController.java @@ -67,6 +67,14 @@ public final class NotificationController implements AutoCloseable, setProgress((int) newProgressMax, (int) newProgress, false); } + public void replaceTitle(@NonNull String title) { + GenericForegroundService genericForegroundService = service.get(); + + if (genericForegroundService == null) return; + + genericForegroundService.replaceTitle(id, title); + } + private synchronized void setProgress(int newProgressMax, int newProgress, boolean indeterminant) { int newPercent = newProgressMax != 0 ? 100 * newProgress / newProgressMax : -1; diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4a7e57a452..cb75c54f45 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -536,6 +536,8 @@ To restore a backup, install a new copy of Signal. Open the app and tap "Restore backup", then locate a backup file. %1$s Learn more In progress… + + Verifying backup… %1$d so far… %1$s%% so far… @@ -3276,10 +3278,14 @@ You successfully entered your backup passphrase Passphrase was not correct Creating Signal backup… + + Verifying Signal backup… Backup failed Your backup directory has been deleted or moved. Your backup file is too large to store on this volume. There is not enough space to store your backup. + + Your recent backup could not be created and verified. Please create a new one. Tap to manage backups. %d messages so far Wrong number