From f2d502518ea3de673c7f0ebf425f53295f620f2f Mon Sep 17 00:00:00 2001 From: Christian Schneppe Date: Sat, 26 Jan 2019 15:07:28 +0100 Subject: rework backup & restore use the implementation from Conversations --- .../messenger/services/ExportBackupService.java | 287 +++++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 src/main/java/de/pixart/messenger/services/ExportBackupService.java (limited to 'src/main/java/de/pixart/messenger/services/ExportBackupService.java') diff --git a/src/main/java/de/pixart/messenger/services/ExportBackupService.java b/src/main/java/de/pixart/messenger/services/ExportBackupService.java new file mode 100644 index 000000000..36b926f6a --- /dev/null +++ b/src/main/java/de/pixart/messenger/services/ExportBackupService.java @@ -0,0 +1,287 @@ +package de.pixart.messenger.services; + +import android.app.Notification; +import android.app.NotificationManager; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.database.DatabaseUtils; +import android.database.sqlite.SQLiteDatabase; +import android.os.IBinder; +import android.support.v4.app.NotificationCompat; +import android.util.Log; + +import java.io.DataOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.PrintWriter; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.security.spec.InvalidKeySpecException; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.zip.GZIPOutputStream; + +import javax.crypto.Cipher; +import javax.crypto.CipherOutputStream; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.SecretKeySpec; + +import de.pixart.messenger.Config; +import de.pixart.messenger.R; +import de.pixart.messenger.crypto.axolotl.SQLiteAxolotlStore; +import de.pixart.messenger.entities.Account; +import de.pixart.messenger.entities.Conversation; +import de.pixart.messenger.entities.Message; +import de.pixart.messenger.persistance.DatabaseBackend; +import de.pixart.messenger.persistance.FileBackend; +import de.pixart.messenger.utils.BackupFileHeader; +import de.pixart.messenger.utils.Compatibility; + +public class ExportBackupService extends Service { + + public static final String KEYTYPE = "AES"; + public static final String CIPHERMODE = "AES/GCM/NoPadding"; + public static final String PROVIDER = "BC"; + + private static final int NOTIFICATION_ID = 19; + private static final int PAGE_SIZE = 20; + private static AtomicBoolean running = new AtomicBoolean(false); + private DatabaseBackend mDatabaseBackend; + private List mAccounts; + private NotificationManager notificationManager; + + private static void accountExport(SQLiteDatabase db, String uuid, PrintWriter writer) { + final StringBuilder builder = new StringBuilder(); + 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("("); + 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 value = accountCursor.getString(i); + if (value == null || Account.ROSTERVERSION.equals(accountCursor.getColumnName(i))) { + builder.append("NULL"); + } else if (value.matches("\\d+")) { + int intValue = Integer.parseInt(value); + if (Account.OPTIONS.equals(accountCursor.getColumnName(i))) { + intValue |= 1 << Account.OPTION_DISABLED; + } + builder.append(intValue); + } else { + DatabaseUtils.appendEscapedSQLString(builder, value); + } + } + builder.append(")"); + builder.append(';'); + builder.append('\n'); + } + if (accountCursor != null) { + accountCursor.close(); + } + writer.append(builder.toString()); + } + + 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); + while (cursor != null && cursor.moveToNext()) { + writer.write(cursorToString(table, cursor, PAGE_SIZE)); + } + if (cursor != null) { + cursor.close(); + } + } + + public static byte[] getKey(String password, byte[] salt) { + try { + SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1"); + return factory.generateSecret(new PBEKeySpec(password.toCharArray(), salt, 1024, 128)).getEncoded(); + } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { + throw new AssertionError(e); + } + } + + private static String cursorToString(String tablename, Cursor cursor, int max) { + return cursorToString(tablename, cursor, max, false); + } + + private static String cursorToString(String tablename, Cursor cursor, int max, boolean ignore) { + StringBuilder builder = new StringBuilder(); + builder.append("INSERT "); + if (ignore) { + builder.append("OR IGNORE "); + } + builder.append("INTO ").append(tablename).append("("); + for (int i = 0; i < cursor.getColumnCount(); ++i) { + if (i != 0) { + builder.append(','); + } + builder.append(cursor.getColumnName(i)); + } + builder.append(") VALUES"); + for (int i = 0; i < max; ++i) { + if (i != 0) { + builder.append(','); + } + appendValues(cursor, builder); + if (i < max - 1 && !cursor.moveToNext()) { + break; + } + } + builder.append(';'); + builder.append('\n'); + return builder.toString(); + } + + private static void appendValues(Cursor cursor, StringBuilder builder) { + builder.append("("); + for (int i = 0; i < cursor.getColumnCount(); ++i) { + if (i != 0) { + builder.append(','); + } + final String value = cursor.getString(i); + if (value == null) { + builder.append("NULL"); + } else if (value.matches("\\d+")) { + builder.append(value); + } else { + DatabaseUtils.appendEscapedSQLString(builder, value); + } + } + builder.append(")"); + + } + + @Override + public void onCreate() { + mDatabaseBackend = DatabaseBackend.getInstance(getBaseContext()); + mAccounts = mDatabaseBackend.getAccounts(); + notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (running.compareAndSet(false, true)) { + new Thread(() -> { + export(); + stopForeground(true); + running.set(false); + stopSelf(); + }).start(); + return START_STICKY; + } + 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}); + int size = cursor != null ? cursor.getCount() : 0; + Log.d(Config.LOGTAG, "exporting " + size + " messages"); + 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; + } + final int percentage = i * 100 / size; + if (p < percentage) { + p = percentage; + notificationManager.notify(NOTIFICATION_ID, progress.build(p)); + } + } + if (cursor != null) { + cursor.close(); + } + } + + private void export() { + 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); + startForeground(NOTIFICATION_ID, mBuilder.build()); + try { + int count = 0; + final int max = this.mAccounts.size(); + final SecureRandom secureRandom = new SecureRandom(); + for (Account account : this.mAccounts) { + 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 Progress progress = new Progress(mBuilder, max, count); + final File file = new File(FileBackend.getBackupDirectory() + account.getJid().asBareJid().toEscapedString() + ".ceb"); + if (file.getParentFile().mkdirs()) { + Log.d(Config.LOGTAG, "created backup directory " + file.getParentFile().getAbsolutePath()); + } + final FileOutputStream fileOutputStream = new FileOutputStream(file); + final DataOutputStream dataOutputStream = new DataOutputStream(fileOutputStream); + backupFileHeader.write(dataOutputStream); + dataOutputStream.flush(); + + final Cipher cipher = Compatibility.twentyEight() ? Cipher.getInstance(CIPHERMODE) : Cipher.getInstance(CIPHERMODE, PROVIDER); + byte[] key = getKey(account.getPassword(), salt); + Log.d(Config.LOGTAG, backupFileHeader.toString()); + SecretKeySpec keySpec = new SecretKeySpec(key, KEYTYPE); + IvParameterSpec ivSpec = new IvParameterSpec(IV); + cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); + CipherOutputStream cipherOutputStream = new CipherOutputStream(fileOutputStream, cipher); + + GZIPOutputStream gzipOutputStream = new GZIPOutputStream(cipherOutputStream); + PrintWriter writer = new PrintWriter(gzipOutputStream); + 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); + } + writer.flush(); + writer.close(); + Log.d(Config.LOGTAG, "written backup to " + file.getAbsoluteFile()); + count++; + } + } catch (Exception e) { + Log.d(Config.LOGTAG, "unable to create backup ", e); + } + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + private class Progress { + private final NotificationCompat.Builder builder; + private final int max; + private final int count; + + private Progress(NotificationCompat.Builder builder, int max, int count) { + this.builder = builder; + this.max = max; + this.count = count; + } + + private Notification build(int percentage) { + builder.setProgress(max * 100, count * 100 + percentage, false); + return builder.build(); + } + } +} \ No newline at end of file -- cgit v1.2.3