Verify backup can be decrypted as part of creation flow.

This commit is contained in:
Cody Henthorne 2022-08-08 12:28:10 -04:00
parent 5212b33b47
commit cfebd0eeb9
16 changed files with 430 additions and 253 deletions

View file

@ -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);
}
}

View file

@ -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;

View file

@ -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 {}
}

View file

@ -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
}
}

View file

@ -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);
}
}
}

View file

@ -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<String> tables) {

View file

@ -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);

View file

@ -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));
}
}

View file

@ -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();

View file

@ -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;
}
}
}
}

View file

@ -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;
}
}
}
}

View file

@ -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);
}
}

View file

@ -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();
}
}

View file

@ -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);

View file

@ -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;

View file

@ -536,6 +536,8 @@
<string name="BackupsPreferenceFragment__to_restore_a_backup">To restore a backup, install a new copy of Signal. Open the app and tap "Restore backup", then locate a backup file. %1$s</string>
<string name="BackupsPreferenceFragment__learn_more">Learn more</string>
<string name="BackupsPreferenceFragment__in_progress">In progress…</string>
<!-- Status text shown in backup preferences when verifying a backup -->
<string name="BackupsPreferenceFragment__verifying_backup">Verifying backup…</string>
<string name="BackupsPreferenceFragment__d_so_far">%1$d so far…</string>
<!-- Show percentage of completion of backup -->
<string name="BackupsPreferenceFragment__s_so_far">%1$s%% so far…</string>
@ -3276,10 +3278,14 @@
<string name="BackupDialog_you_successfully_entered_your_backup_passphrase">You successfully entered your backup passphrase</string>
<string name="BackupDialog_passphrase_was_not_correct">Passphrase was not correct</string>
<string name="LocalBackupJob_creating_signal_backup">Creating Signal backup…</string>
<!-- Title for progress notification shown in a system notification while verifying a recent backup. -->
<string name="LocalBackupJob_verifying_signal_backup">Verifying Signal backup…</string>
<string name="LocalBackupJobApi29_backup_failed">Backup failed</string>
<string name="LocalBackupJobApi29_your_backup_directory_has_been_deleted_or_moved">Your backup directory has been deleted or moved.</string>
<string name="LocalBackupJobApi29_your_backup_file_is_too_large">Your backup file is too large to store on this volume.</string>
<string name="LocalBackupJobApi29_there_is_not_enough_space">There is not enough space to store your backup.</string>
<!-- Error message shown if a newly created backup could not be verified as accurate -->
<string name="LocalBackupJobApi29_your_backup_could_not_be_verified">Your recent backup could not be created and verified. Please create a new one.</string>
<string name="LocalBackupJobApi29_tap_to_manage_backups">Tap to manage backups.</string>
<string name="ProgressPreference_d_messages_so_far">%d messages so far</string>
<string name="RegistrationActivity_wrong_number">Wrong number</string>