Security: Introduce backup file format v2
This switches the SQL based backup format to something JSON based. The SQL based format has always been prone to SQL injections that, for example, could delete other messages or preexisting accounts in the app. This hasn’t been a concern this far because why would anyone purposely try to restore a faulty backup? However the argument has been made that a user can be socially engineered to restore an exploited backup file. Before version 2.12.8 a third party app could even trigger the restore process, leaving the backup password entry dialog the only hurdle. On top of that it has been demonstrated that a backup file can be crafted in a way that puts preexisting credentials into a 'pending' message to an attacker ultimately leading to that information being leaked. While destorying information has always been deemed an acceptable risk, leaking information is one step too far. Starting with Conversations 2.12.9 Conversations will no longer be able to read v1 backup files. This means if you are restoring on a new device and you have a v1 backup file you must first install Conversations <= 2.12.8, restore the backup, and then upgrade to Conversations >= 2.12.9. ceb2txt¹ has support for v2 backup files. Conceivably ceb2txt could be extended to convert between v1 and v2 file formats. (ceb2txt already recreates the database from v1 files; It is relatively straight forward to create v2 files from that database. Pull requests welcome.) ¹: https://github.com/iNPUTmice/ceb2txt/
This commit is contained in:
parent
0677ddc59b
commit
09f6343ced
|
@ -6,6 +6,7 @@ import android.app.Notification;
|
||||||
import android.app.NotificationManager;
|
import android.app.NotificationManager;
|
||||||
import android.app.PendingIntent;
|
import android.app.PendingIntent;
|
||||||
import android.app.Service;
|
import android.app.Service;
|
||||||
|
import android.content.ContentValues;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.database.Cursor;
|
import android.database.Cursor;
|
||||||
|
@ -22,6 +23,8 @@ import androidx.core.app.NotificationManagerCompat;
|
||||||
import com.google.common.base.Charsets;
|
import com.google.common.base.Charsets;
|
||||||
import com.google.common.base.Stopwatch;
|
import com.google.common.base.Stopwatch;
|
||||||
import com.google.common.io.CountingInputStream;
|
import com.google.common.io.CountingInputStream;
|
||||||
|
import com.google.gson.stream.JsonReader;
|
||||||
|
import com.google.gson.stream.JsonToken;
|
||||||
|
|
||||||
import org.bouncycastle.crypto.engines.AESEngine;
|
import org.bouncycastle.crypto.engines.AESEngine;
|
||||||
import org.bouncycastle.crypto.io.CipherInputStream;
|
import org.bouncycastle.crypto.io.CipherInputStream;
|
||||||
|
@ -40,6 +43,7 @@ import java.io.InputStream;
|
||||||
import java.io.InputStreamReader;
|
import java.io.InputStreamReader;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
@ -53,6 +57,10 @@ import javax.crypto.BadPaddingException;
|
||||||
|
|
||||||
import eu.siacs.conversations.Config;
|
import eu.siacs.conversations.Config;
|
||||||
import eu.siacs.conversations.R;
|
import eu.siacs.conversations.R;
|
||||||
|
import eu.siacs.conversations.crypto.axolotl.SQLiteAxolotlStore;
|
||||||
|
import eu.siacs.conversations.entities.Account;
|
||||||
|
import eu.siacs.conversations.entities.Conversation;
|
||||||
|
import eu.siacs.conversations.entities.Message;
|
||||||
import eu.siacs.conversations.persistance.DatabaseBackend;
|
import eu.siacs.conversations.persistance.DatabaseBackend;
|
||||||
import eu.siacs.conversations.persistance.FileBackend;
|
import eu.siacs.conversations.persistance.FileBackend;
|
||||||
import eu.siacs.conversations.ui.ManageAccountActivity;
|
import eu.siacs.conversations.ui.ManageAccountActivity;
|
||||||
|
@ -65,25 +73,28 @@ public class ImportBackupService extends Service {
|
||||||
private static final int NOTIFICATION_ID = 21;
|
private static final int NOTIFICATION_ID = 21;
|
||||||
private static final AtomicBoolean running = new AtomicBoolean(false);
|
private static final AtomicBoolean running = new AtomicBoolean(false);
|
||||||
private final ImportBackupServiceBinder binder = new ImportBackupServiceBinder();
|
private final ImportBackupServiceBinder binder = new ImportBackupServiceBinder();
|
||||||
private final SerialSingleThreadExecutor executor = new SerialSingleThreadExecutor(getClass().getSimpleName());
|
private final SerialSingleThreadExecutor executor =
|
||||||
private final Set<OnBackupProcessed> mOnBackupProcessedListeners = Collections.newSetFromMap(new WeakHashMap<>());
|
new SerialSingleThreadExecutor(getClass().getSimpleName());
|
||||||
|
private final Set<OnBackupProcessed> mOnBackupProcessedListeners =
|
||||||
|
Collections.newSetFromMap(new WeakHashMap<>());
|
||||||
private DatabaseBackend mDatabaseBackend;
|
private DatabaseBackend mDatabaseBackend;
|
||||||
private NotificationManager notificationManager;
|
private NotificationManager notificationManager;
|
||||||
|
|
||||||
private static int count(String input, char c) {
|
private static final Collection<String> TABLE_ALLOW_LIST =
|
||||||
int count = 0;
|
Arrays.asList(
|
||||||
for (char aChar : input.toCharArray()) {
|
Account.TABLENAME,
|
||||||
if (aChar == c) {
|
Conversation.TABLENAME,
|
||||||
++count;
|
Message.TABLENAME,
|
||||||
}
|
SQLiteAxolotlStore.PREKEY_TABLENAME,
|
||||||
}
|
SQLiteAxolotlStore.SIGNED_PREKEY_TABLENAME,
|
||||||
return count;
|
SQLiteAxolotlStore.SESSION_TABLENAME,
|
||||||
}
|
SQLiteAxolotlStore.IDENTITIES_TABLENAME);
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onCreate() {
|
public void onCreate() {
|
||||||
mDatabaseBackend = DatabaseBackend.getInstance(getBaseContext());
|
mDatabaseBackend = DatabaseBackend.getInstance(getBaseContext());
|
||||||
notificationManager = (android.app.NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
|
notificationManager =
|
||||||
|
(android.app.NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -105,7 +116,8 @@ public class ImportBackupService extends Service {
|
||||||
return START_NOT_STICKY;
|
return START_NOT_STICKY;
|
||||||
}
|
}
|
||||||
if (running.compareAndSet(false, true)) {
|
if (running.compareAndSet(false, true)) {
|
||||||
executor.execute(() -> {
|
executor.execute(
|
||||||
|
() -> {
|
||||||
startForegroundService();
|
startForegroundService();
|
||||||
final boolean success = importBackup(uri, password);
|
final boolean success = importBackup(uri, password);
|
||||||
stopForeground(true);
|
stopForeground(true);
|
||||||
|
@ -126,10 +138,16 @@ public class ImportBackupService extends Service {
|
||||||
}
|
}
|
||||||
|
|
||||||
public void loadBackupFiles(final OnBackupFilesLoaded onBackupFilesLoaded) {
|
public void loadBackupFiles(final OnBackupFilesLoaded onBackupFilesLoaded) {
|
||||||
executor.execute(() -> {
|
executor.execute(
|
||||||
|
() -> {
|
||||||
final List<Jid> accounts = mDatabaseBackend.getAccountJids(false);
|
final List<Jid> accounts = mDatabaseBackend.getAccountJids(false);
|
||||||
final ArrayList<BackupFile> backupFiles = new ArrayList<>();
|
final ArrayList<BackupFile> backupFiles = new ArrayList<>();
|
||||||
final Set<String> apps = new HashSet<>(Arrays.asList("Conversations", "Quicksy", getString(R.string.app_name)));
|
final Set<String> apps =
|
||||||
|
new HashSet<>(
|
||||||
|
Arrays.asList(
|
||||||
|
"Conversations",
|
||||||
|
"Quicksy",
|
||||||
|
getString(R.string.app_name)));
|
||||||
final List<File> directories = new ArrayList<>();
|
final List<File> directories = new ArrayList<>();
|
||||||
for (final String app : apps) {
|
for (final String app : apps) {
|
||||||
directories.add(FileBackend.getLegacyBackupDirectory(app));
|
directories.add(FileBackend.getLegacyBackupDirectory(app));
|
||||||
|
@ -137,7 +155,9 @@ public class ImportBackupService extends Service {
|
||||||
directories.add(FileBackend.getBackupDirectory(this));
|
directories.add(FileBackend.getBackupDirectory(this));
|
||||||
for (final File directory : directories) {
|
for (final File directory : directories) {
|
||||||
if (!directory.exists() || !directory.isDirectory()) {
|
if (!directory.exists() || !directory.isDirectory()) {
|
||||||
Log.d(Config.LOGTAG, "directory not found: " + directory.getAbsolutePath());
|
Log.d(
|
||||||
|
Config.LOGTAG,
|
||||||
|
"directory not found: " + directory.getAbsolutePath());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
final File[] files = directory.listFiles();
|
final File[] files = directory.listFiles();
|
||||||
|
@ -149,7 +169,10 @@ public class ImportBackupService extends Service {
|
||||||
try {
|
try {
|
||||||
final BackupFile backupFile = BackupFile.read(file);
|
final BackupFile backupFile = BackupFile.read(file);
|
||||||
if (accounts.contains(backupFile.getHeader().getJid())) {
|
if (accounts.contains(backupFile.getHeader().getJid())) {
|
||||||
Log.d(Config.LOGTAG, "skipping backup for " + backupFile.getHeader().getJid());
|
Log.d(
|
||||||
|
Config.LOGTAG,
|
||||||
|
"skipping backup for "
|
||||||
|
+ backupFile.getHeader().getJid());
|
||||||
} else {
|
} else {
|
||||||
backupFiles.add(backupFile);
|
backupFiles.add(backupFile);
|
||||||
}
|
}
|
||||||
|
@ -159,7 +182,13 @@ public class ImportBackupService extends Service {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Collections.sort(backupFiles, (a, b) -> a.header.getJid().toString().compareTo(b.header.getJid().toString()));
|
Collections.sort(
|
||||||
|
backupFiles,
|
||||||
|
(a, b) ->
|
||||||
|
a.header
|
||||||
|
.getJid()
|
||||||
|
.toString()
|
||||||
|
.compareTo(b.header.getJid().toString()));
|
||||||
onBackupFilesLoaded.onBackupFilesLoaded(backupFiles);
|
onBackupFilesLoaded.onBackupFilesLoaded(backupFiles);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -180,14 +209,16 @@ public class ImportBackupService extends Service {
|
||||||
}
|
}
|
||||||
final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this);
|
final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this);
|
||||||
try {
|
try {
|
||||||
notificationManager.notify(NOTIFICATION_ID, createImportBackupNotification(max, progress));
|
notificationManager.notify(
|
||||||
|
NOTIFICATION_ID, createImportBackupNotification(max, progress));
|
||||||
} catch (final RuntimeException e) {
|
} catch (final RuntimeException e) {
|
||||||
Log.d(Config.LOGTAG, "unable to make notification", e);
|
Log.d(Config.LOGTAG, "unable to make notification", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Notification createImportBackupNotification(final int max, final int progress) {
|
private Notification createImportBackupNotification(final int max, final int progress) {
|
||||||
NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(getBaseContext(), "backup");
|
NotificationCompat.Builder mBuilder =
|
||||||
|
new NotificationCompat.Builder(getBaseContext(), "backup");
|
||||||
mBuilder.setContentTitle(getString(R.string.restoring_backup))
|
mBuilder.setContentTitle(getString(R.string.restoring_backup))
|
||||||
.setSmallIcon(R.drawable.ic_unarchive_white_24dp)
|
.setSmallIcon(R.drawable.ic_unarchive_white_24dp)
|
||||||
.setProgress(max, progress, max == 1 && progress == 0);
|
.setProgress(max, progress, max == 1 && progress == 0);
|
||||||
|
@ -212,7 +243,9 @@ public class ImportBackupService extends Service {
|
||||||
fileSize = 0;
|
fileSize = 0;
|
||||||
} else {
|
} else {
|
||||||
returnCursor.moveToFirst();
|
returnCursor.moveToFirst();
|
||||||
fileSize = returnCursor.getLong(returnCursor.getColumnIndex(OpenableColumns.SIZE));
|
fileSize =
|
||||||
|
returnCursor.getLong(
|
||||||
|
returnCursor.getColumnIndexOrThrow(OpenableColumns.SIZE));
|
||||||
returnCursor.close();
|
returnCursor.close();
|
||||||
}
|
}
|
||||||
inputStream = getContentResolver().openInputStream(uri);
|
inputStream = getContentResolver().openInputStream(uri);
|
||||||
|
@ -242,40 +275,46 @@ public class ImportBackupService extends Service {
|
||||||
final byte[] key = ExportBackupService.getKey(password, backupFileHeader.getSalt());
|
final byte[] key = ExportBackupService.getKey(password, backupFileHeader.getSalt());
|
||||||
|
|
||||||
final AEADBlockCipher cipher = new GCMBlockCipher(new AESEngine());
|
final AEADBlockCipher cipher = new GCMBlockCipher(new AESEngine());
|
||||||
cipher.init(false, new AEADParameters(new KeyParameter(key), 128, backupFileHeader.getIv()));
|
cipher.init(
|
||||||
final CipherInputStream cipherInputStream = new CipherInputStream(countingInputStream, cipher);
|
false,
|
||||||
|
new AEADParameters(new KeyParameter(key), 128, backupFileHeader.getIv()));
|
||||||
|
final CipherInputStream cipherInputStream =
|
||||||
|
new CipherInputStream(countingInputStream, cipher);
|
||||||
|
|
||||||
final GZIPInputStream gzipInputStream = new GZIPInputStream(cipherInputStream);
|
final GZIPInputStream gzipInputStream = new GZIPInputStream(cipherInputStream);
|
||||||
final BufferedReader reader = new BufferedReader(new InputStreamReader(gzipInputStream, Charsets.UTF_8));
|
final BufferedReader reader =
|
||||||
|
new BufferedReader(new InputStreamReader(gzipInputStream, Charsets.UTF_8));
|
||||||
|
final JsonReader jsonReader = new JsonReader(reader);
|
||||||
|
if (jsonReader.peek() == JsonToken.BEGIN_ARRAY) {
|
||||||
|
jsonReader.beginArray();
|
||||||
|
} else {
|
||||||
|
throw new IllegalStateException("Backup file did not begin with array");
|
||||||
|
}
|
||||||
db.beginTransaction();
|
db.beginTransaction();
|
||||||
String line;
|
while (jsonReader.hasNext()) {
|
||||||
StringBuilder multiLineQuery = null;
|
if (jsonReader.peek() == JsonToken.BEGIN_OBJECT) {
|
||||||
while ((line = reader.readLine()) != null) {
|
importRow(db, jsonReader, backupFileHeader.getJid(), password);
|
||||||
int count = count(line, '\'');
|
} else if (jsonReader.peek() == JsonToken.END_ARRAY) {
|
||||||
if (multiLineQuery != null) {
|
jsonReader.endArray();
|
||||||
multiLineQuery.append('\n');
|
continue;
|
||||||
multiLineQuery.append(line);
|
}
|
||||||
if (count % 2 == 1) {
|
|
||||||
db.execSQL(multiLineQuery.toString());
|
|
||||||
multiLineQuery = null;
|
|
||||||
updateImportBackupNotification(fileSize, countingInputStream.getCount());
|
updateImportBackupNotification(fileSize, countingInputStream.getCount());
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
if (count % 2 == 0) {
|
|
||||||
db.execSQL(line);
|
|
||||||
updateImportBackupNotification(fileSize, countingInputStream.getCount());
|
|
||||||
} else {
|
|
||||||
multiLineQuery = new StringBuilder(line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
db.setTransactionSuccessful();
|
db.setTransactionSuccessful();
|
||||||
db.endTransaction();
|
db.endTransaction();
|
||||||
final Jid jid = backupFileHeader.getJid();
|
final Jid jid = backupFileHeader.getJid();
|
||||||
final Cursor countCursor = db.rawQuery("select count(messages.uuid) from messages join conversations on conversations.uuid=messages.conversationUuid join accounts on conversations.accountUuid=accounts.uuid where accounts.username=? and accounts.server=?", new String[]{jid.getEscapedLocal(), jid.getDomain().toEscapedString()});
|
final Cursor countCursor =
|
||||||
|
db.rawQuery(
|
||||||
|
"select count(messages.uuid) from messages join conversations on conversations.uuid=messages.conversationUuid join accounts on conversations.accountUuid=accounts.uuid where accounts.username=? and accounts.server=?",
|
||||||
|
new String[] {
|
||||||
|
jid.getEscapedLocal(), jid.getDomain().toEscapedString()
|
||||||
|
});
|
||||||
countCursor.moveToFirst();
|
countCursor.moveToFirst();
|
||||||
final int count = countCursor.getInt(0);
|
final int count = countCursor.getInt(0);
|
||||||
Log.d(Config.LOGTAG, String.format("restored %d messages in %s", count, stopwatch.stop().toString()));
|
Log.d(
|
||||||
|
Config.LOGTAG,
|
||||||
|
String.format(
|
||||||
|
"restored %d messages in %s", count, stopwatch.stop().toString()));
|
||||||
countCursor.close();
|
countCursor.close();
|
||||||
stopBackgroundService();
|
stopBackgroundService();
|
||||||
synchronized (mOnBackupProcessedListeners) {
|
synchronized (mOnBackupProcessedListeners) {
|
||||||
|
@ -286,7 +325,8 @@ public class ImportBackupService extends Service {
|
||||||
return true;
|
return true;
|
||||||
} catch (final Exception e) {
|
} catch (final Exception e) {
|
||||||
final Throwable throwable = e.getCause();
|
final Throwable throwable = e.getCause();
|
||||||
final boolean reasonWasCrypto = throwable instanceof BadPaddingException || e instanceof ZipException;
|
final boolean reasonWasCrypto =
|
||||||
|
throwable instanceof BadPaddingException || e instanceof ZipException;
|
||||||
synchronized (mOnBackupProcessedListeners) {
|
synchronized (mOnBackupProcessedListeners) {
|
||||||
for (OnBackupProcessed l : mOnBackupProcessedListeners) {
|
for (OnBackupProcessed l : mOnBackupProcessedListeners) {
|
||||||
if (reasonWasCrypto) {
|
if (reasonWasCrypto) {
|
||||||
|
@ -301,13 +341,70 @@ public class ImportBackupService extends Service {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void importRow(
|
||||||
|
final SQLiteDatabase db,
|
||||||
|
final JsonReader jsonReader,
|
||||||
|
final Jid account,
|
||||||
|
final String passphrase)
|
||||||
|
throws IOException {
|
||||||
|
jsonReader.beginObject();
|
||||||
|
final String firstParameter = jsonReader.nextName();
|
||||||
|
if (!firstParameter.equals("table")) {
|
||||||
|
throw new IllegalStateException("Expected key 'table'");
|
||||||
|
}
|
||||||
|
final String table = jsonReader.nextString();
|
||||||
|
if (!TABLE_ALLOW_LIST.contains(table)) {
|
||||||
|
throw new IOException(String.format("%s is not recognized for import", table));
|
||||||
|
}
|
||||||
|
final ContentValues contentValues = new ContentValues();
|
||||||
|
final String secondParameter = jsonReader.nextName();
|
||||||
|
if (!secondParameter.equals("values")) {
|
||||||
|
throw new IllegalStateException("Expected key 'values'");
|
||||||
|
}
|
||||||
|
jsonReader.beginObject();
|
||||||
|
while (jsonReader.peek() != JsonToken.END_OBJECT) {
|
||||||
|
final String name = jsonReader.nextName();
|
||||||
|
if (jsonReader.peek() == JsonToken.NULL) {
|
||||||
|
jsonReader.nextNull();
|
||||||
|
contentValues.putNull(name);
|
||||||
|
} else if (jsonReader.peek() == JsonToken.NUMBER) {
|
||||||
|
contentValues.put(name, jsonReader.nextLong());
|
||||||
|
} else {
|
||||||
|
contentValues.put(name, jsonReader.nextString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jsonReader.endObject();
|
||||||
|
jsonReader.endObject();
|
||||||
|
if (Account.TABLENAME.equals(table)) {
|
||||||
|
final Jid jid =
|
||||||
|
Jid.of(
|
||||||
|
contentValues.getAsString(Account.USERNAME),
|
||||||
|
contentValues.getAsString(Account.SERVER),
|
||||||
|
null);
|
||||||
|
final String password = contentValues.getAsString(Account.PASSWORD);
|
||||||
|
if (jid.equals(account) && passphrase.equals(password)) {
|
||||||
|
Log.d(Config.LOGTAG, "jid and password from backup header had matching row");
|
||||||
|
} else {
|
||||||
|
throw new IOException("jid or password in table did not match backup");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.insert(table, null, contentValues);
|
||||||
|
}
|
||||||
|
|
||||||
private void notifySuccess() {
|
private void notifySuccess() {
|
||||||
NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(getBaseContext(), "backup");
|
NotificationCompat.Builder mBuilder =
|
||||||
|
new NotificationCompat.Builder(getBaseContext(), "backup");
|
||||||
mBuilder.setContentTitle(getString(R.string.notification_restored_backup_title))
|
mBuilder.setContentTitle(getString(R.string.notification_restored_backup_title))
|
||||||
.setContentText(getString(R.string.notification_restored_backup_subtitle))
|
.setContentText(getString(R.string.notification_restored_backup_subtitle))
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.setContentIntent(PendingIntent.getActivity(this, 145, new Intent(this, ManageAccountActivity.class), s()
|
.setContentIntent(
|
||||||
? PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
|
PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
145,
|
||||||
|
new Intent(this, ManageAccountActivity.class),
|
||||||
|
s()
|
||||||
|
? PendingIntent.FLAG_IMMUTABLE
|
||||||
|
| PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
: PendingIntent.FLAG_UPDATE_CURRENT))
|
: PendingIntent.FLAG_UPDATE_CURRENT))
|
||||||
.setSmallIcon(R.drawable.ic_unarchive_white_24dp);
|
.setSmallIcon(R.drawable.ic_unarchive_white_24dp);
|
||||||
notificationManager.notify(NOTIFICATION_ID, mBuilder.build());
|
notificationManager.notify(NOTIFICATION_ID, mBuilder.build());
|
||||||
|
|
|
@ -19,11 +19,15 @@ import androidx.core.app.NotificationCompat;
|
||||||
|
|
||||||
import com.google.common.base.CharMatcher;
|
import com.google.common.base.CharMatcher;
|
||||||
import com.google.common.base.Strings;
|
import com.google.common.base.Strings;
|
||||||
|
import com.google.gson.stream.JsonWriter;
|
||||||
|
|
||||||
import java.io.DataOutputStream;
|
import java.io.DataOutputStream;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.FileOutputStream;
|
import java.io.FileOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.OutputStreamWriter;
|
||||||
import java.io.PrintWriter;
|
import java.io.PrintWriter;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.security.SecureRandom;
|
import java.security.SecureRandom;
|
||||||
import java.security.spec.InvalidKeySpecException;
|
import java.security.spec.InvalidKeySpecException;
|
||||||
|
@ -61,16 +65,16 @@ public class ExportBackupService extends Service {
|
||||||
public static final String MIME_TYPE = "application/vnd.conversations.backup";
|
public static final String MIME_TYPE = "application/vnd.conversations.backup";
|
||||||
|
|
||||||
private static final int NOTIFICATION_ID = 19;
|
private static final int NOTIFICATION_ID = 19;
|
||||||
private static final int PAGE_SIZE = 20;
|
|
||||||
private static final AtomicBoolean RUNNING = new AtomicBoolean(false);
|
private static final AtomicBoolean RUNNING = new AtomicBoolean(false);
|
||||||
private DatabaseBackend mDatabaseBackend;
|
private DatabaseBackend mDatabaseBackend;
|
||||||
private List<Account> mAccounts;
|
private List<Account> mAccounts;
|
||||||
private NotificationManager notificationManager;
|
private NotificationManager notificationManager;
|
||||||
|
|
||||||
private static List<Intent> getPossibleFileOpenIntents(final Context context, final String path) {
|
private static List<Intent> getPossibleFileOpenIntents(
|
||||||
|
final Context context, final String path) {
|
||||||
|
|
||||||
//http://www.openintents.org/action/android-intent-action-view/file-directory
|
// http://www.openintents.org/action/android-intent-action-view/file-directory
|
||||||
//do not use 'vnd.android.document/directory' since this will trigger system file manager
|
// do not use 'vnd.android.document/directory' since this will trigger system file manager
|
||||||
final Intent openIntent = new Intent(Intent.ACTION_VIEW);
|
final Intent openIntent = new Intent(Intent.ACTION_VIEW);
|
||||||
openIntent.addCategory(Intent.CATEGORY_DEFAULT);
|
openIntent.addCategory(Intent.CATEGORY_DEFAULT);
|
||||||
if (Compatibility.runsAndTargetsTwentyFour(context)) {
|
if (Compatibility.runsAndTargetsTwentyFour(context)) {
|
||||||
|
@ -83,134 +87,95 @@ public class ExportBackupService extends Service {
|
||||||
final Intent amazeIntent = new Intent(Intent.ACTION_VIEW);
|
final Intent amazeIntent = new Intent(Intent.ACTION_VIEW);
|
||||||
amazeIntent.setDataAndType(Uri.parse("com.amaze.filemanager:" + path), "resource/folder");
|
amazeIntent.setDataAndType(Uri.parse("com.amaze.filemanager:" + path), "resource/folder");
|
||||||
|
|
||||||
//will open a file manager at root and user can navigate themselves
|
// will open a file manager at root and user can navigate themselves
|
||||||
final Intent systemFallBack = new Intent(Intent.ACTION_VIEW);
|
final Intent systemFallBack = new Intent(Intent.ACTION_VIEW);
|
||||||
systemFallBack.addCategory(Intent.CATEGORY_DEFAULT);
|
systemFallBack.addCategory(Intent.CATEGORY_DEFAULT);
|
||||||
systemFallBack.setData(Uri.parse("content://com.android.externalstorage.documents/root/primary"));
|
systemFallBack.setData(
|
||||||
|
Uri.parse("content://com.android.externalstorage.documents/root/primary"));
|
||||||
|
|
||||||
return Arrays.asList(openIntent, amazeIntent, systemFallBack);
|
return Arrays.asList(openIntent, amazeIntent, systemFallBack);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void accountExport(final SQLiteDatabase db, final String uuid, final PrintWriter writer) {
|
private static void accountExport(
|
||||||
final StringBuilder builder = new StringBuilder();
|
final SQLiteDatabase db, final String uuid, final JsonWriter writer)
|
||||||
final Cursor accountCursor = db.query(Account.TABLENAME, null, Account.UUID + "=?", new String[]{uuid}, null, null, null);
|
throws IOException {
|
||||||
|
final Cursor accountCursor =
|
||||||
|
db.query(
|
||||||
|
Account.TABLENAME,
|
||||||
|
null,
|
||||||
|
Account.UUID + "=?",
|
||||||
|
new String[] {uuid},
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null);
|
||||||
while (accountCursor != null && accountCursor.moveToNext()) {
|
while (accountCursor != null && accountCursor.moveToNext()) {
|
||||||
builder.append("INSERT INTO ").append(Account.TABLENAME).append("(");
|
writer.beginObject();
|
||||||
|
writer.name("table");
|
||||||
|
writer.value(Account.TABLENAME);
|
||||||
|
writer.name("values");
|
||||||
|
writer.beginObject();
|
||||||
for (int i = 0; i < accountCursor.getColumnCount(); ++i) {
|
for (int i = 0; i < accountCursor.getColumnCount(); ++i) {
|
||||||
if (i != 0) {
|
final String name = accountCursor.getColumnName(i);
|
||||||
builder.append(',');
|
writer.name(name);
|
||||||
}
|
|
||||||
builder.append(accountCursor.getColumnName(i));
|
|
||||||
}
|
|
||||||
builder.append(") VALUES(");
|
|
||||||
for (int i = 0; i < accountCursor.getColumnCount(); ++i) {
|
|
||||||
if (i != 0) {
|
|
||||||
builder.append(',');
|
|
||||||
}
|
|
||||||
final String value = accountCursor.getString(i);
|
final String value = accountCursor.getString(i);
|
||||||
if (value == null || Account.ROSTERVERSION.equals(accountCursor.getColumnName(i))) {
|
if (value == null || Account.ROSTERVERSION.equals(accountCursor.getColumnName(i))) {
|
||||||
builder.append("NULL");
|
writer.nullValue();
|
||||||
} else if (Account.OPTIONS.equals(accountCursor.getColumnName(i)) && value.matches("\\d+")) {
|
} else if (Account.OPTIONS.equals(accountCursor.getColumnName(i))
|
||||||
|
&& value.matches("\\d+")) {
|
||||||
int intValue = Integer.parseInt(value);
|
int intValue = Integer.parseInt(value);
|
||||||
intValue |= 1 << Account.OPTION_DISABLED;
|
intValue |= 1 << Account.OPTION_DISABLED;
|
||||||
builder.append(intValue);
|
writer.value(intValue);
|
||||||
} else {
|
} else {
|
||||||
appendEscapedSQLString(builder, value);
|
writer.value(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
builder.append(")");
|
writer.endObject();
|
||||||
builder.append(';');
|
writer.endObject();
|
||||||
builder.append('\n');
|
|
||||||
}
|
}
|
||||||
if (accountCursor != null) {
|
if (accountCursor != null) {
|
||||||
accountCursor.close();
|
accountCursor.close();
|
||||||
}
|
}
|
||||||
writer.append(builder.toString());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void appendEscapedSQLString(final StringBuilder sb, final String sqlString) {
|
private static void simpleExport(
|
||||||
DatabaseUtils.appendEscapedSQLString(sb, CharMatcher.is('\u0000').removeFrom(sqlString));
|
final SQLiteDatabase db,
|
||||||
}
|
final String table,
|
||||||
|
final String column,
|
||||||
private static void simpleExport(SQLiteDatabase db, String table, String column, String uuid, PrintWriter writer) {
|
final String uuid,
|
||||||
final Cursor cursor = db.query(table, null, column + "=?", new String[]{uuid}, null, null, null);
|
final JsonWriter writer)
|
||||||
|
throws IOException {
|
||||||
|
final Cursor cursor =
|
||||||
|
db.query(table, null, column + "=?", new String[] {uuid}, null, null, null);
|
||||||
while (cursor != null && cursor.moveToNext()) {
|
while (cursor != null && cursor.moveToNext()) {
|
||||||
writer.write(cursorToString(table, cursor, PAGE_SIZE));
|
writer.beginObject();
|
||||||
|
writer.name("table");
|
||||||
|
writer.value(table);
|
||||||
|
writer.name("values");
|
||||||
|
writer.beginObject();
|
||||||
|
for (int i = 0; i < cursor.getColumnCount(); ++i) {
|
||||||
|
final String name = cursor.getColumnName(i);
|
||||||
|
writer.name(name);
|
||||||
|
final String value = cursor.getString(i);
|
||||||
|
writer.value(value);
|
||||||
|
}
|
||||||
|
writer.endObject();
|
||||||
|
writer.endObject();
|
||||||
}
|
}
|
||||||
if (cursor != null) {
|
if (cursor != null) {
|
||||||
cursor.close();
|
cursor.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static byte[] getKey(final String password, final byte[] salt) throws InvalidKeySpecException {
|
public static byte[] getKey(final String password, final byte[] salt)
|
||||||
|
throws InvalidKeySpecException {
|
||||||
final SecretKeyFactory factory;
|
final SecretKeyFactory factory;
|
||||||
try {
|
try {
|
||||||
factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
|
factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
|
||||||
} catch (NoSuchAlgorithmException e) {
|
} catch (NoSuchAlgorithmException e) {
|
||||||
throw new IllegalStateException(e);
|
throw new IllegalStateException(e);
|
||||||
}
|
}
|
||||||
return factory.generateSecret(new PBEKeySpec(password.toCharArray(), salt, 1024, 128)).getEncoded();
|
return factory.generateSecret(new PBEKeySpec(password.toCharArray(), salt, 1024, 128))
|
||||||
}
|
.getEncoded();
|
||||||
|
|
||||||
private static String cursorToString(final String table, final Cursor cursor, final int max) {
|
|
||||||
return cursorToString(table, cursor, max, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String cursorToString(final String table, final Cursor cursor, int max, boolean ignore) {
|
|
||||||
final boolean identities = SQLiteAxolotlStore.IDENTITIES_TABLENAME.equals(table);
|
|
||||||
StringBuilder builder = new StringBuilder();
|
|
||||||
builder.append("INSERT ");
|
|
||||||
if (ignore) {
|
|
||||||
builder.append("OR IGNORE ");
|
|
||||||
}
|
|
||||||
builder.append("INTO ").append(table).append("(");
|
|
||||||
int skipColumn = -1;
|
|
||||||
for (int i = 0; i < cursor.getColumnCount(); ++i) {
|
|
||||||
final String name = cursor.getColumnName(i);
|
|
||||||
if (identities && SQLiteAxolotlStore.TRUSTED.equals(name)) {
|
|
||||||
skipColumn = i;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (i != 0) {
|
|
||||||
builder.append(',');
|
|
||||||
}
|
|
||||||
builder.append(name);
|
|
||||||
}
|
|
||||||
builder.append(") VALUES");
|
|
||||||
for (int i = 0; i < max; ++i) {
|
|
||||||
if (i != 0) {
|
|
||||||
builder.append(',');
|
|
||||||
}
|
|
||||||
appendValues(cursor, builder, skipColumn);
|
|
||||||
if (i < max - 1 && !cursor.moveToNext()) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
builder.append(';');
|
|
||||||
builder.append('\n');
|
|
||||||
return builder.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void appendValues(final Cursor cursor, final StringBuilder builder, final int skipColumn) {
|
|
||||||
builder.append("(");
|
|
||||||
for (int i = 0; i < cursor.getColumnCount(); ++i) {
|
|
||||||
if (i == skipColumn) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (i != 0) {
|
|
||||||
builder.append(',');
|
|
||||||
}
|
|
||||||
final String value = cursor.getString(i);
|
|
||||||
if (value == null) {
|
|
||||||
builder.append("NULL");
|
|
||||||
} else if (value.matches("[0-9]+")) {
|
|
||||||
builder.append(value);
|
|
||||||
} else {
|
|
||||||
appendEscapedSQLString(builder, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
builder.append(")");
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -223,7 +188,8 @@ public class ExportBackupService extends Service {
|
||||||
@Override
|
@Override
|
||||||
public int onStartCommand(Intent intent, int flags, int startId) {
|
public int onStartCommand(Intent intent, int flags, int startId) {
|
||||||
if (RUNNING.compareAndSet(false, true)) {
|
if (RUNNING.compareAndSet(false, true)) {
|
||||||
new Thread(() -> {
|
new Thread(
|
||||||
|
() -> {
|
||||||
boolean success;
|
boolean success;
|
||||||
List<File> files;
|
List<File> files;
|
||||||
try {
|
try {
|
||||||
|
@ -240,32 +206,51 @@ public class ExportBackupService extends Service {
|
||||||
notifySuccess(files);
|
notifySuccess(files);
|
||||||
}
|
}
|
||||||
stopSelf();
|
stopSelf();
|
||||||
}).start();
|
})
|
||||||
|
.start();
|
||||||
return START_STICKY;
|
return START_STICKY;
|
||||||
} else {
|
} else {
|
||||||
Log.d(Config.LOGTAG, "ExportBackupService. ignoring start command because already running");
|
Log.d(
|
||||||
|
Config.LOGTAG,
|
||||||
|
"ExportBackupService. ignoring start command because already running");
|
||||||
}
|
}
|
||||||
return START_NOT_STICKY;
|
return START_NOT_STICKY;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void messageExport(SQLiteDatabase db, String uuid, PrintWriter writer, Progress progress) {
|
private void messageExport(
|
||||||
Cursor cursor = db.rawQuery("select messages.* from messages join conversations on conversations.uuid=messages.conversationUuid where conversations.accountUuid=?", new String[]{uuid});
|
final SQLiteDatabase db,
|
||||||
|
final String uuid,
|
||||||
|
final JsonWriter writer,
|
||||||
|
final Progress progress)
|
||||||
|
throws IOException {
|
||||||
|
Cursor cursor =
|
||||||
|
db.rawQuery(
|
||||||
|
"select messages.* from messages join conversations on conversations.uuid=messages.conversationUuid where conversations.accountUuid=?",
|
||||||
|
new String[] {uuid});
|
||||||
int size = cursor != null ? cursor.getCount() : 0;
|
int size = cursor != null ? cursor.getCount() : 0;
|
||||||
Log.d(Config.LOGTAG, "exporting " + size + " messages for account " + uuid);
|
Log.d(Config.LOGTAG, "exporting " + size + " messages for account " + uuid);
|
||||||
int i = 0;
|
int i = 0;
|
||||||
int p = 0;
|
int p = 0;
|
||||||
while (cursor != null && cursor.moveToNext()) {
|
while (cursor != null && cursor.moveToNext()) {
|
||||||
writer.write(cursorToString(Message.TABLENAME, cursor, PAGE_SIZE, false));
|
writer.beginObject();
|
||||||
if (i + PAGE_SIZE > size) {
|
writer.name("table");
|
||||||
i = size;
|
writer.value(Message.TABLENAME);
|
||||||
} else {
|
writer.name("values");
|
||||||
i += PAGE_SIZE;
|
writer.beginObject();
|
||||||
|
for (int j = 0; j < cursor.getColumnCount(); ++j) {
|
||||||
|
final String name = cursor.getColumnName(j);
|
||||||
|
writer.name(name);
|
||||||
|
final String value = cursor.getString(j);
|
||||||
|
writer.value(value);
|
||||||
}
|
}
|
||||||
|
writer.endObject();
|
||||||
|
writer.endObject();
|
||||||
final int percentage = i * 100 / size;
|
final int percentage = i * 100 / size;
|
||||||
if (p < percentage) {
|
if (p < percentage) {
|
||||||
p = percentage;
|
p = percentage;
|
||||||
notificationManager.notify(NOTIFICATION_ID, progress.build(p));
|
notificationManager.notify(NOTIFICATION_ID, progress.build(p));
|
||||||
}
|
}
|
||||||
|
i++;
|
||||||
}
|
}
|
||||||
if (cursor != null) {
|
if (cursor != null) {
|
||||||
cursor.close();
|
cursor.close();
|
||||||
|
@ -273,7 +258,8 @@ public class ExportBackupService extends Service {
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<File> export() throws Exception {
|
private List<File> export() throws Exception {
|
||||||
NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(getBaseContext(), "backup");
|
NotificationCompat.Builder mBuilder =
|
||||||
|
new NotificationCompat.Builder(getBaseContext(), "backup");
|
||||||
mBuilder.setContentTitle(getString(R.string.notification_create_backup_title))
|
mBuilder.setContentTitle(getString(R.string.notification_create_backup_title))
|
||||||
.setSmallIcon(R.drawable.ic_archive_white_24dp)
|
.setSmallIcon(R.drawable.ic_archive_white_24dp)
|
||||||
.setProgress(1, 0, false);
|
.setProgress(1, 0, false);
|
||||||
|
@ -286,17 +272,34 @@ public class ExportBackupService extends Service {
|
||||||
for (final Account account : this.mAccounts) {
|
for (final Account account : this.mAccounts) {
|
||||||
final String password = account.getPassword();
|
final String password = account.getPassword();
|
||||||
if (Strings.nullToEmpty(password).trim().isEmpty()) {
|
if (Strings.nullToEmpty(password).trim().isEmpty()) {
|
||||||
Log.d(Config.LOGTAG, String.format("skipping backup for %s because password is empty. unable to encrypt", account.getJid().asBareJid()));
|
Log.d(
|
||||||
|
Config.LOGTAG,
|
||||||
|
String.format(
|
||||||
|
"skipping backup for %s because password is empty. unable to encrypt",
|
||||||
|
account.getJid().asBareJid()));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Log.d(Config.LOGTAG, String.format("exporting data for account %s (%s)", account.getJid().asBareJid(), account.getUuid()));
|
Log.d(
|
||||||
|
Config.LOGTAG,
|
||||||
|
String.format(
|
||||||
|
"exporting data for account %s (%s)",
|
||||||
|
account.getJid().asBareJid(), account.getUuid()));
|
||||||
final byte[] IV = new byte[12];
|
final byte[] IV = new byte[12];
|
||||||
final byte[] salt = new byte[16];
|
final byte[] salt = new byte[16];
|
||||||
secureRandom.nextBytes(IV);
|
secureRandom.nextBytes(IV);
|
||||||
secureRandom.nextBytes(salt);
|
secureRandom.nextBytes(salt);
|
||||||
final BackupFileHeader backupFileHeader = new BackupFileHeader(getString(R.string.app_name), account.getJid(), System.currentTimeMillis(), IV, salt);
|
final BackupFileHeader backupFileHeader =
|
||||||
|
new BackupFileHeader(
|
||||||
|
getString(R.string.app_name),
|
||||||
|
account.getJid(),
|
||||||
|
System.currentTimeMillis(),
|
||||||
|
IV,
|
||||||
|
salt);
|
||||||
final Progress progress = new Progress(mBuilder, max, count);
|
final Progress progress = new Progress(mBuilder, max, count);
|
||||||
final File file = new File(FileBackend.getBackupDirectory(this), account.getJid().asBareJid().toEscapedString() + ".ceb");
|
final File file =
|
||||||
|
new File(
|
||||||
|
FileBackend.getBackupDirectory(this),
|
||||||
|
account.getJid().asBareJid().toEscapedString() + ".ceb");
|
||||||
files.add(file);
|
files.add(file);
|
||||||
final File directory = file.getParentFile();
|
final File directory = file.getParentFile();
|
||||||
if (directory != null && directory.mkdirs()) {
|
if (directory != null && directory.mkdirs()) {
|
||||||
|
@ -307,25 +310,38 @@ public class ExportBackupService extends Service {
|
||||||
backupFileHeader.write(dataOutputStream);
|
backupFileHeader.write(dataOutputStream);
|
||||||
dataOutputStream.flush();
|
dataOutputStream.flush();
|
||||||
|
|
||||||
final Cipher cipher = Compatibility.twentyEight() ? Cipher.getInstance(CIPHERMODE) : Cipher.getInstance(CIPHERMODE, PROVIDER);
|
final Cipher cipher =
|
||||||
|
Compatibility.twentyEight()
|
||||||
|
? Cipher.getInstance(CIPHERMODE)
|
||||||
|
: Cipher.getInstance(CIPHERMODE, PROVIDER);
|
||||||
final byte[] key = getKey(password, salt);
|
final byte[] key = getKey(password, salt);
|
||||||
SecretKeySpec keySpec = new SecretKeySpec(key, KEYTYPE);
|
SecretKeySpec keySpec = new SecretKeySpec(key, KEYTYPE);
|
||||||
IvParameterSpec ivSpec = new IvParameterSpec(IV);
|
IvParameterSpec ivSpec = new IvParameterSpec(IV);
|
||||||
cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
|
cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
|
||||||
CipherOutputStream cipherOutputStream = new CipherOutputStream(fileOutputStream, cipher);
|
CipherOutputStream cipherOutputStream =
|
||||||
|
new CipherOutputStream(fileOutputStream, cipher);
|
||||||
|
|
||||||
GZIPOutputStream gzipOutputStream = new GZIPOutputStream(cipherOutputStream);
|
final GZIPOutputStream gzipOutputStream = new GZIPOutputStream(cipherOutputStream);
|
||||||
PrintWriter writer = new PrintWriter(gzipOutputStream);
|
final JsonWriter jsonWriter =
|
||||||
SQLiteDatabase db = this.mDatabaseBackend.getReadableDatabase();
|
new JsonWriter(
|
||||||
|
new OutputStreamWriter(gzipOutputStream, StandardCharsets.UTF_8));
|
||||||
|
jsonWriter.beginArray();
|
||||||
|
final SQLiteDatabase db = this.mDatabaseBackend.getReadableDatabase();
|
||||||
final String uuid = account.getUuid();
|
final String uuid = account.getUuid();
|
||||||
accountExport(db, uuid, writer);
|
accountExport(db, uuid, jsonWriter);
|
||||||
simpleExport(db, Conversation.TABLENAME, Conversation.ACCOUNT, uuid, writer);
|
simpleExport(db, Conversation.TABLENAME, Conversation.ACCOUNT, uuid, jsonWriter);
|
||||||
messageExport(db, uuid, writer, progress);
|
messageExport(db, uuid, jsonWriter, progress);
|
||||||
for (String table : Arrays.asList(SQLiteAxolotlStore.PREKEY_TABLENAME, SQLiteAxolotlStore.SIGNED_PREKEY_TABLENAME, SQLiteAxolotlStore.SESSION_TABLENAME, SQLiteAxolotlStore.IDENTITIES_TABLENAME)) {
|
for (final String table :
|
||||||
simpleExport(db, table, SQLiteAxolotlStore.ACCOUNT, uuid, writer);
|
Arrays.asList(
|
||||||
|
SQLiteAxolotlStore.PREKEY_TABLENAME,
|
||||||
|
SQLiteAxolotlStore.SIGNED_PREKEY_TABLENAME,
|
||||||
|
SQLiteAxolotlStore.SESSION_TABLENAME,
|
||||||
|
SQLiteAxolotlStore.IDENTITIES_TABLENAME)) {
|
||||||
|
simpleExport(db, table, SQLiteAxolotlStore.ACCOUNT, uuid, jsonWriter);
|
||||||
}
|
}
|
||||||
writer.flush();
|
jsonWriter.endArray();
|
||||||
writer.close();
|
jsonWriter.flush();
|
||||||
|
jsonWriter.close();
|
||||||
mediaScannerScanFile(file);
|
mediaScannerScanFile(file);
|
||||||
Log.d(Config.LOGTAG, "written backup to " + file.getAbsoluteFile());
|
Log.d(Config.LOGTAG, "written backup to " + file.getAbsoluteFile());
|
||||||
count++;
|
count++;
|
||||||
|
@ -346,8 +362,14 @@ public class ExportBackupService extends Service {
|
||||||
|
|
||||||
for (final Intent intent : getPossibleFileOpenIntents(this, path)) {
|
for (final Intent intent : getPossibleFileOpenIntents(this, path)) {
|
||||||
if (intent.resolveActivityInfo(getPackageManager(), 0) != null) {
|
if (intent.resolveActivityInfo(getPackageManager(), 0) != null) {
|
||||||
openFolderIntent = PendingIntent.getActivity(this, 189, intent, s()
|
openFolderIntent =
|
||||||
? PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
|
PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
189,
|
||||||
|
intent,
|
||||||
|
s()
|
||||||
|
? PendingIntent.FLAG_IMMUTABLE
|
||||||
|
| PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
: PendingIntent.FLAG_UPDATE_CURRENT);
|
: PendingIntent.FLAG_UPDATE_CURRENT);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -363,22 +385,39 @@ public class ExportBackupService extends Service {
|
||||||
intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris);
|
intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris);
|
||||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||||
intent.setType(MIME_TYPE);
|
intent.setType(MIME_TYPE);
|
||||||
final Intent chooser = Intent.createChooser(intent, getString(R.string.share_backup_files));
|
final Intent chooser =
|
||||||
shareFilesIntent = PendingIntent.getActivity(this, 190, chooser, s()
|
Intent.createChooser(intent, getString(R.string.share_backup_files));
|
||||||
? PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
|
shareFilesIntent =
|
||||||
|
PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
190,
|
||||||
|
chooser,
|
||||||
|
s()
|
||||||
|
? PendingIntent.FLAG_IMMUTABLE
|
||||||
|
| PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
: PendingIntent.FLAG_UPDATE_CURRENT);
|
: PendingIntent.FLAG_UPDATE_CURRENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(getBaseContext(), "backup");
|
NotificationCompat.Builder mBuilder =
|
||||||
|
new NotificationCompat.Builder(getBaseContext(), "backup");
|
||||||
mBuilder.setContentTitle(getString(R.string.notification_backup_created_title))
|
mBuilder.setContentTitle(getString(R.string.notification_backup_created_title))
|
||||||
.setContentText(getString(R.string.notification_backup_created_subtitle, path))
|
.setContentText(getString(R.string.notification_backup_created_subtitle, path))
|
||||||
.setStyle(new NotificationCompat.BigTextStyle().bigText(getString(R.string.notification_backup_created_subtitle, FileBackend.getBackupDirectory(this).getAbsolutePath())))
|
.setStyle(
|
||||||
|
new NotificationCompat.BigTextStyle()
|
||||||
|
.bigText(
|
||||||
|
getString(
|
||||||
|
R.string.notification_backup_created_subtitle,
|
||||||
|
FileBackend.getBackupDirectory(this)
|
||||||
|
.getAbsolutePath())))
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.setContentIntent(openFolderIntent)
|
.setContentIntent(openFolderIntent)
|
||||||
.setSmallIcon(R.drawable.ic_archive_white_24dp);
|
.setSmallIcon(R.drawable.ic_archive_white_24dp);
|
||||||
|
|
||||||
if (shareFilesIntent != null) {
|
if (shareFilesIntent != null) {
|
||||||
mBuilder.addAction(R.drawable.ic_share_white_24dp, getString(R.string.share_backup_files), shareFilesIntent);
|
mBuilder.addAction(
|
||||||
|
R.drawable.ic_share_white_24dp,
|
||||||
|
getString(R.string.share_backup_files),
|
||||||
|
shareFilesIntent);
|
||||||
}
|
}
|
||||||
|
|
||||||
notificationManager.notify(NOTIFICATION_ID, mBuilder.build());
|
notificationManager.notify(NOTIFICATION_ID, mBuilder.build());
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
package eu.siacs.conversations.utils;
|
package eu.siacs.conversations.utils;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
import java.io.DataInputStream;
|
import java.io.DataInputStream;
|
||||||
import java.io.DataOutputStream;
|
import java.io.DataOutputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
@ -8,7 +10,7 @@ import eu.siacs.conversations.xmpp.Jid;
|
||||||
|
|
||||||
public class BackupFileHeader {
|
public class BackupFileHeader {
|
||||||
|
|
||||||
private static final int VERSION = 1;
|
private static final int VERSION = 2;
|
||||||
|
|
||||||
private final String app;
|
private final String app;
|
||||||
private final Jid jid;
|
private final Jid jid;
|
||||||
|
@ -17,6 +19,7 @@ public class BackupFileHeader {
|
||||||
private final byte[] salt;
|
private final byte[] salt;
|
||||||
|
|
||||||
|
|
||||||
|
@NonNull
|
||||||
@Override
|
@Override
|
||||||
public String toString() {
|
public String toString() {
|
||||||
return "BackupFileHeader{" +
|
return "BackupFileHeader{" +
|
||||||
|
@ -47,8 +50,8 @@ public class BackupFileHeader {
|
||||||
|
|
||||||
public static BackupFileHeader read(DataInputStream inputStream) throws IOException {
|
public static BackupFileHeader read(DataInputStream inputStream) throws IOException {
|
||||||
final int version = inputStream.readInt();
|
final int version = inputStream.readInt();
|
||||||
if (version > VERSION) {
|
if (version != VERSION) {
|
||||||
throw new IllegalArgumentException("Backup File version was " + version + " but app only supports up to version " + VERSION);
|
throw new IllegalArgumentException("Backup File version was " + version + " but app only supports version " + VERSION);
|
||||||
}
|
}
|
||||||
String app = inputStream.readUTF();
|
String app = inputStream.readUTF();
|
||||||
String jid = inputStream.readUTF();
|
String jid = inputStream.readUTF();
|
||||||
|
|
Loading…
Reference in a new issue