diff --git a/src/conversations/java/eu/siacs/conversations/services/ImportBackupService.java b/src/conversations/java/eu/siacs/conversations/services/ImportBackupService.java index c118d7375..34a2cf6d1 100644 --- a/src/conversations/java/eu/siacs/conversations/services/ImportBackupService.java +++ b/src/conversations/java/eu/siacs/conversations/services/ImportBackupService.java @@ -6,6 +6,7 @@ import android.app.Notification; import android.app.NotificationManager; import android.app.PendingIntent; import android.app.Service; +import android.content.ContentValues; import android.content.Context; import android.content.Intent; import android.database.Cursor; @@ -22,6 +23,8 @@ import androidx.core.app.NotificationManagerCompat; import com.google.common.base.Charsets; import com.google.common.base.Stopwatch; 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.io.CipherInputStream; @@ -40,6 +43,7 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.List; @@ -53,6 +57,10 @@ import javax.crypto.BadPaddingException; import eu.siacs.conversations.Config; 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.FileBackend; 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 AtomicBoolean running = new AtomicBoolean(false); private final ImportBackupServiceBinder binder = new ImportBackupServiceBinder(); - private final SerialSingleThreadExecutor executor = new SerialSingleThreadExecutor(getClass().getSimpleName()); - private final Set mOnBackupProcessedListeners = Collections.newSetFromMap(new WeakHashMap<>()); + private final SerialSingleThreadExecutor executor = + new SerialSingleThreadExecutor(getClass().getSimpleName()); + private final Set mOnBackupProcessedListeners = + Collections.newSetFromMap(new WeakHashMap<>()); private DatabaseBackend mDatabaseBackend; private NotificationManager notificationManager; - private static int count(String input, char c) { - int count = 0; - for (char aChar : input.toCharArray()) { - if (aChar == c) { - ++count; - } - } - return count; - } + private static final Collection TABLE_ALLOW_LIST = + Arrays.asList( + Account.TABLENAME, + Conversation.TABLENAME, + Message.TABLENAME, + SQLiteAxolotlStore.PREKEY_TABLENAME, + SQLiteAxolotlStore.SIGNED_PREKEY_TABLENAME, + SQLiteAxolotlStore.SESSION_TABLENAME, + SQLiteAxolotlStore.IDENTITIES_TABLENAME); @Override public void onCreate() { mDatabaseBackend = DatabaseBackend.getInstance(getBaseContext()); - notificationManager = (android.app.NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + notificationManager = + (android.app.NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); } @Override @@ -105,16 +116,17 @@ public class ImportBackupService extends Service { return START_NOT_STICKY; } if (running.compareAndSet(false, true)) { - executor.execute(() -> { - startForegroundService(); - final boolean success = importBackup(uri, password); - stopForeground(true); - running.set(false); - if (success) { - notifySuccess(); - } - stopSelf(); - }); + executor.execute( + () -> { + startForegroundService(); + final boolean success = importBackup(uri, password); + stopForeground(true); + running.set(false); + if (success) { + notifySuccess(); + } + stopSelf(); + }); } else { Log.d(Config.LOGTAG, "backup already running"); } @@ -126,42 +138,59 @@ public class ImportBackupService extends Service { } public void loadBackupFiles(final OnBackupFilesLoaded onBackupFilesLoaded) { - executor.execute(() -> { - final List accounts = mDatabaseBackend.getAccountJids(false); - final ArrayList backupFiles = new ArrayList<>(); - final Set apps = new HashSet<>(Arrays.asList("Conversations", "Quicksy", getString(R.string.app_name))); - final List directories = new ArrayList<>(); - for (final String app : apps) { - directories.add(FileBackend.getLegacyBackupDirectory(app)); - } - directories.add(FileBackend.getBackupDirectory(this)); - for (final File directory : directories) { - if (!directory.exists() || !directory.isDirectory()) { - Log.d(Config.LOGTAG, "directory not found: " + directory.getAbsolutePath()); - continue; - } - final File[] files = directory.listFiles(); - if (files == null) { - continue; - } - for (final File file : files) { - if (file.isFile() && file.getName().endsWith(".ceb")) { - try { - final BackupFile backupFile = BackupFile.read(file); - if (accounts.contains(backupFile.getHeader().getJid())) { - Log.d(Config.LOGTAG, "skipping backup for " + backupFile.getHeader().getJid()); - } else { - backupFiles.add(backupFile); + executor.execute( + () -> { + final List accounts = mDatabaseBackend.getAccountJids(false); + final ArrayList backupFiles = new ArrayList<>(); + final Set apps = + new HashSet<>( + Arrays.asList( + "Conversations", + "Quicksy", + getString(R.string.app_name))); + final List directories = new ArrayList<>(); + for (final String app : apps) { + directories.add(FileBackend.getLegacyBackupDirectory(app)); + } + directories.add(FileBackend.getBackupDirectory(this)); + for (final File directory : directories) { + if (!directory.exists() || !directory.isDirectory()) { + Log.d( + Config.LOGTAG, + "directory not found: " + directory.getAbsolutePath()); + continue; + } + final File[] files = directory.listFiles(); + if (files == null) { + continue; + } + for (final File file : files) { + if (file.isFile() && file.getName().endsWith(".ceb")) { + try { + final BackupFile backupFile = BackupFile.read(file); + if (accounts.contains(backupFile.getHeader().getJid())) { + Log.d( + Config.LOGTAG, + "skipping backup for " + + backupFile.getHeader().getJid()); + } else { + backupFiles.add(backupFile); + } + } catch (IOException | IllegalArgumentException e) { + Log.d(Config.LOGTAG, "unable to read backup file ", e); + } } - } catch (IOException | IllegalArgumentException e) { - Log.d(Config.LOGTAG, "unable to read backup file ", e); } } - } - } - Collections.sort(backupFiles, (a, b) -> a.header.getJid().toString().compareTo(b.header.getJid().toString())); - onBackupFilesLoaded.onBackupFilesLoaded(backupFiles); - }); + Collections.sort( + backupFiles, + (a, b) -> + a.header + .getJid() + .toString() + .compareTo(b.header.getJid().toString())); + onBackupFilesLoaded.onBackupFilesLoaded(backupFiles); + }); } private void startForegroundService() { @@ -180,14 +209,16 @@ public class ImportBackupService extends Service { } final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this); try { - notificationManager.notify(NOTIFICATION_ID, createImportBackupNotification(max, progress)); + notificationManager.notify( + NOTIFICATION_ID, createImportBackupNotification(max, progress)); } catch (final RuntimeException e) { Log.d(Config.LOGTAG, "unable to make notification", e); } } 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)) .setSmallIcon(R.drawable.ic_unarchive_white_24dp) .setProgress(max, progress, max == 1 && progress == 0); @@ -212,7 +243,9 @@ public class ImportBackupService extends Service { fileSize = 0; } else { returnCursor.moveToFirst(); - fileSize = returnCursor.getLong(returnCursor.getColumnIndex(OpenableColumns.SIZE)); + fileSize = + returnCursor.getLong( + returnCursor.getColumnIndexOrThrow(OpenableColumns.SIZE)); returnCursor.close(); } inputStream = getContentResolver().openInputStream(uri); @@ -242,40 +275,46 @@ public class ImportBackupService extends Service { final byte[] key = ExportBackupService.getKey(password, backupFileHeader.getSalt()); final AEADBlockCipher cipher = new GCMBlockCipher(new AESEngine()); - cipher.init(false, new AEADParameters(new KeyParameter(key), 128, backupFileHeader.getIv())); - final CipherInputStream cipherInputStream = new CipherInputStream(countingInputStream, cipher); + cipher.init( + false, + new AEADParameters(new KeyParameter(key), 128, backupFileHeader.getIv())); + final CipherInputStream cipherInputStream = + new CipherInputStream(countingInputStream, cipher); 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(); - String line; - StringBuilder multiLineQuery = null; - while ((line = reader.readLine()) != null) { - int count = count(line, '\''); - if (multiLineQuery != null) { - multiLineQuery.append('\n'); - multiLineQuery.append(line); - if (count % 2 == 1) { - db.execSQL(multiLineQuery.toString()); - multiLineQuery = null; - updateImportBackupNotification(fileSize, countingInputStream.getCount()); - } - } else { - if (count % 2 == 0) { - db.execSQL(line); - updateImportBackupNotification(fileSize, countingInputStream.getCount()); - } else { - multiLineQuery = new StringBuilder(line); - } + while (jsonReader.hasNext()) { + if (jsonReader.peek() == JsonToken.BEGIN_OBJECT) { + importRow(db, jsonReader, backupFileHeader.getJid(), password); + } else if (jsonReader.peek() == JsonToken.END_ARRAY) { + jsonReader.endArray(); + continue; } + updateImportBackupNotification(fileSize, countingInputStream.getCount()); } db.setTransactionSuccessful(); db.endTransaction(); 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(); 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(); stopBackgroundService(); synchronized (mOnBackupProcessedListeners) { @@ -286,7 +325,8 @@ public class ImportBackupService extends Service { return true; } catch (final Exception e) { 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) { for (OnBackupProcessed l : mOnBackupProcessedListeners) { if (reasonWasCrypto) { @@ -301,14 +341,71 @@ 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() { - 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)) .setContentText(getString(R.string.notification_restored_backup_subtitle)) .setAutoCancel(true) - .setContentIntent(PendingIntent.getActivity(this, 145, new Intent(this, ManageAccountActivity.class), s() - ? PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT - : PendingIntent.FLAG_UPDATE_CURRENT)) + .setContentIntent( + PendingIntent.getActivity( + this, + 145, + new Intent(this, ManageAccountActivity.class), + s() + ? PendingIntent.FLAG_IMMUTABLE + | PendingIntent.FLAG_UPDATE_CURRENT + : PendingIntent.FLAG_UPDATE_CURRENT)) .setSmallIcon(R.drawable.ic_unarchive_white_24dp); notificationManager.notify(NOTIFICATION_ID, mBuilder.build()); } @@ -391,4 +488,4 @@ public class ImportBackupService extends Service { return ImportBackupService.this; } } -} \ No newline at end of file +} diff --git a/src/main/java/eu/siacs/conversations/services/ExportBackupService.java b/src/main/java/eu/siacs/conversations/services/ExportBackupService.java index 9826ecbc2..442fc5f4f 100644 --- a/src/main/java/eu/siacs/conversations/services/ExportBackupService.java +++ b/src/main/java/eu/siacs/conversations/services/ExportBackupService.java @@ -19,11 +19,15 @@ import androidx.core.app.NotificationCompat; import com.google.common.base.CharMatcher; import com.google.common.base.Strings; +import com.google.gson.stream.JsonWriter; import java.io.DataOutputStream; import java.io.File; import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; import java.security.spec.InvalidKeySpecException; @@ -61,16 +65,16 @@ public class ExportBackupService extends Service { public static final String MIME_TYPE = "application/vnd.conversations.backup"; private static final int NOTIFICATION_ID = 19; - private static final int PAGE_SIZE = 20; private static final AtomicBoolean RUNNING = new AtomicBoolean(false); private DatabaseBackend mDatabaseBackend; private List mAccounts; private NotificationManager notificationManager; - private static List getPossibleFileOpenIntents(final Context context, final String path) { + private static List getPossibleFileOpenIntents( + final Context context, final String path) { - //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 + // 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 final Intent openIntent = new Intent(Intent.ACTION_VIEW); openIntent.addCategory(Intent.CATEGORY_DEFAULT); if (Compatibility.runsAndTargetsTwentyFour(context)) { @@ -83,134 +87,95 @@ public class ExportBackupService extends Service { final Intent amazeIntent = new Intent(Intent.ACTION_VIEW); 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); 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); } - private static void accountExport(final SQLiteDatabase db, final String uuid, final PrintWriter writer) { - final StringBuilder builder = new StringBuilder(); - final Cursor accountCursor = db.query(Account.TABLENAME, null, Account.UUID + "=?", new String[]{uuid}, null, null, null); + private static void accountExport( + final SQLiteDatabase db, final String uuid, final JsonWriter writer) + throws IOException { + final Cursor accountCursor = + db.query( + Account.TABLENAME, + null, + Account.UUID + "=?", + new String[] {uuid}, + null, + null, + null); 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) { - if (i != 0) { - builder.append(','); - } - builder.append(accountCursor.getColumnName(i)); - } - builder.append(") VALUES("); - for (int i = 0; i < accountCursor.getColumnCount(); ++i) { - if (i != 0) { - builder.append(','); - } + final String name = accountCursor.getColumnName(i); + writer.name(name); final String value = accountCursor.getString(i); if (value == null || Account.ROSTERVERSION.equals(accountCursor.getColumnName(i))) { - builder.append("NULL"); - } else if (Account.OPTIONS.equals(accountCursor.getColumnName(i)) && value.matches("\\d+")) { + writer.nullValue(); + } else if (Account.OPTIONS.equals(accountCursor.getColumnName(i)) + && value.matches("\\d+")) { int intValue = Integer.parseInt(value); intValue |= 1 << Account.OPTION_DISABLED; - builder.append(intValue); + writer.value(intValue); } else { - appendEscapedSQLString(builder, value); + writer.value(value); } } - builder.append(")"); - builder.append(';'); - builder.append('\n'); + writer.endObject(); + writer.endObject(); } if (accountCursor != null) { accountCursor.close(); } - writer.append(builder.toString()); } - private static void appendEscapedSQLString(final StringBuilder sb, final String sqlString) { - DatabaseUtils.appendEscapedSQLString(sb, CharMatcher.is('\u0000').removeFrom(sqlString)); - } - - private static void simpleExport(SQLiteDatabase db, String table, String column, String uuid, PrintWriter writer) { - final Cursor cursor = db.query(table, null, column + "=?", new String[]{uuid}, null, null, null); + private static void simpleExport( + final SQLiteDatabase db, + final String table, + final String column, + final String uuid, + final JsonWriter writer) + throws IOException { + final Cursor cursor = + db.query(table, null, column + "=?", new String[] {uuid}, null, null, null); 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) { 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; try { factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1"); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } - 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(")"); - + return factory.generateSecret(new PBEKeySpec(password.toCharArray(), salt, 1024, 128)) + .getEncoded(); } @Override @@ -223,49 +188,69 @@ public class ExportBackupService extends Service { @Override public int onStartCommand(Intent intent, int flags, int startId) { if (RUNNING.compareAndSet(false, true)) { - new Thread(() -> { - boolean success; - List files; - try { - files = export(); - success = true; - } catch (final Exception e) { - Log.d(Config.LOGTAG, "unable to create backup", e); - success = false; - files = Collections.emptyList(); - } - stopForeground(true); - RUNNING.set(false); - if (success) { - notifySuccess(files); - } - stopSelf(); - }).start(); + new Thread( + () -> { + boolean success; + List files; + try { + files = export(); + success = true; + } catch (final Exception e) { + Log.d(Config.LOGTAG, "unable to create backup", e); + success = false; + files = Collections.emptyList(); + } + stopForeground(true); + RUNNING.set(false); + if (success) { + notifySuccess(files); + } + stopSelf(); + }) + .start(); return START_STICKY; } 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; } - private void messageExport(SQLiteDatabase db, String uuid, PrintWriter writer, Progress progress) { - Cursor cursor = db.rawQuery("select messages.* from messages join conversations on conversations.uuid=messages.conversationUuid where conversations.accountUuid=?", new String[]{uuid}); + private void messageExport( + 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; Log.d(Config.LOGTAG, "exporting " + size + " messages for account " + uuid); int i = 0; int p = 0; while (cursor != null && cursor.moveToNext()) { - writer.write(cursorToString(Message.TABLENAME, cursor, PAGE_SIZE, false)); - if (i + PAGE_SIZE > size) { - i = size; - } else { - i += PAGE_SIZE; + writer.beginObject(); + writer.name("table"); + writer.value(Message.TABLENAME); + writer.name("values"); + 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; if (p < percentage) { p = percentage; notificationManager.notify(NOTIFICATION_ID, progress.build(p)); } + i++; } if (cursor != null) { cursor.close(); @@ -273,7 +258,8 @@ public class ExportBackupService extends Service { } private List 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)) .setSmallIcon(R.drawable.ic_archive_white_24dp) .setProgress(1, 0, false); @@ -286,17 +272,34 @@ public class ExportBackupService extends Service { for (final Account account : this.mAccounts) { final String password = account.getPassword(); 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; } - 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[] salt = new byte[16]; secureRandom.nextBytes(IV); 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 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); final File directory = file.getParentFile(); if (directory != null && directory.mkdirs()) { @@ -307,25 +310,38 @@ public class ExportBackupService extends Service { backupFileHeader.write(dataOutputStream); 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); SecretKeySpec keySpec = new SecretKeySpec(key, KEYTYPE); IvParameterSpec ivSpec = new IvParameterSpec(IV); cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); - CipherOutputStream cipherOutputStream = new CipherOutputStream(fileOutputStream, cipher); + CipherOutputStream cipherOutputStream = + new CipherOutputStream(fileOutputStream, cipher); - GZIPOutputStream gzipOutputStream = new GZIPOutputStream(cipherOutputStream); - PrintWriter writer = new PrintWriter(gzipOutputStream); - SQLiteDatabase db = this.mDatabaseBackend.getReadableDatabase(); + final GZIPOutputStream gzipOutputStream = new GZIPOutputStream(cipherOutputStream); + final JsonWriter jsonWriter = + new JsonWriter( + new OutputStreamWriter(gzipOutputStream, StandardCharsets.UTF_8)); + jsonWriter.beginArray(); + final SQLiteDatabase db = this.mDatabaseBackend.getReadableDatabase(); final String uuid = account.getUuid(); - accountExport(db, uuid, writer); - simpleExport(db, Conversation.TABLENAME, Conversation.ACCOUNT, uuid, writer); - messageExport(db, uuid, writer, progress); - for (String table : Arrays.asList(SQLiteAxolotlStore.PREKEY_TABLENAME, SQLiteAxolotlStore.SIGNED_PREKEY_TABLENAME, SQLiteAxolotlStore.SESSION_TABLENAME, SQLiteAxolotlStore.IDENTITIES_TABLENAME)) { - simpleExport(db, table, SQLiteAxolotlStore.ACCOUNT, uuid, writer); + accountExport(db, uuid, jsonWriter); + simpleExport(db, Conversation.TABLENAME, Conversation.ACCOUNT, uuid, jsonWriter); + messageExport(db, uuid, jsonWriter, progress); + for (final String table : + Arrays.asList( + SQLiteAxolotlStore.PREKEY_TABLENAME, + SQLiteAxolotlStore.SIGNED_PREKEY_TABLENAME, + SQLiteAxolotlStore.SESSION_TABLENAME, + SQLiteAxolotlStore.IDENTITIES_TABLENAME)) { + simpleExport(db, table, SQLiteAxolotlStore.ACCOUNT, uuid, jsonWriter); } - writer.flush(); - writer.close(); + jsonWriter.endArray(); + jsonWriter.flush(); + jsonWriter.close(); mediaScannerScanFile(file); Log.d(Config.LOGTAG, "written backup to " + file.getAbsoluteFile()); count++; @@ -346,9 +362,15 @@ public class ExportBackupService extends Service { for (final Intent intent : getPossibleFileOpenIntents(this, path)) { if (intent.resolveActivityInfo(getPackageManager(), 0) != null) { - openFolderIntent = PendingIntent.getActivity(this, 189, intent, s() - ? PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT - : PendingIntent.FLAG_UPDATE_CURRENT); + openFolderIntent = + PendingIntent.getActivity( + this, + 189, + intent, + s() + ? PendingIntent.FLAG_IMMUTABLE + | PendingIntent.FLAG_UPDATE_CURRENT + : PendingIntent.FLAG_UPDATE_CURRENT); break; } } @@ -363,22 +385,39 @@ public class ExportBackupService extends Service { intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); intent.setType(MIME_TYPE); - final Intent chooser = Intent.createChooser(intent, getString(R.string.share_backup_files)); - shareFilesIntent = PendingIntent.getActivity(this, 190, chooser, s() - ? PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT - : PendingIntent.FLAG_UPDATE_CURRENT); + final Intent chooser = + Intent.createChooser(intent, getString(R.string.share_backup_files)); + shareFilesIntent = + PendingIntent.getActivity( + this, + 190, + chooser, + s() + ? PendingIntent.FLAG_IMMUTABLE + | 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)) .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) .setContentIntent(openFolderIntent) .setSmallIcon(R.drawable.ic_archive_white_24dp); 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()); diff --git a/src/main/java/eu/siacs/conversations/utils/BackupFileHeader.java b/src/main/java/eu/siacs/conversations/utils/BackupFileHeader.java index 54387a8ee..957ccfca1 100644 --- a/src/main/java/eu/siacs/conversations/utils/BackupFileHeader.java +++ b/src/main/java/eu/siacs/conversations/utils/BackupFileHeader.java @@ -1,5 +1,7 @@ package eu.siacs.conversations.utils; +import androidx.annotation.NonNull; + import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.IOException; @@ -8,7 +10,7 @@ import eu.siacs.conversations.xmpp.Jid; public class BackupFileHeader { - private static final int VERSION = 1; + private static final int VERSION = 2; private final String app; private final Jid jid; @@ -17,6 +19,7 @@ public class BackupFileHeader { private final byte[] salt; + @NonNull @Override public String toString() { return "BackupFileHeader{" + @@ -47,8 +50,8 @@ public class BackupFileHeader { public static BackupFileHeader read(DataInputStream inputStream) throws IOException { final int version = inputStream.readInt(); - if (version > VERSION) { - throw new IllegalArgumentException("Backup File version was " + version + " but app only supports up to version " + VERSION); + if (version != VERSION) { + throw new IllegalArgumentException("Backup File version was " + version + " but app only supports version " + VERSION); } String app = inputStream.readUTF(); String jid = inputStream.readUTF();