Verify backup can be decrypted as part of creation flow.
This commit is contained in:
parent
5212b33b47
commit
cfebd0eeb9
16 changed files with 430 additions and 253 deletions
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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 {}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Add table
Reference in a new issue