ExportBackupService.java

  1package eu.siacs.conversations.services;
  2
  3import android.app.Notification;
  4import android.app.NotificationManager;
  5import android.app.Service;
  6import android.content.Context;
  7import android.content.Intent;
  8import android.database.Cursor;
  9import android.database.DatabaseUtils;
 10import android.database.sqlite.SQLiteDatabase;
 11import android.os.IBinder;
 12import android.support.v4.app.NotificationCompat;
 13import android.util.Log;
 14
 15import java.io.DataOutputStream;
 16import java.io.File;
 17import java.io.FileOutputStream;
 18import java.io.PrintWriter;
 19import java.security.NoSuchAlgorithmException;
 20import java.security.SecureRandom;
 21import java.security.spec.InvalidKeySpecException;
 22import java.util.Arrays;
 23import java.util.List;
 24import java.util.concurrent.atomic.AtomicBoolean;
 25import java.util.zip.GZIPOutputStream;
 26
 27import javax.crypto.Cipher;
 28import javax.crypto.CipherOutputStream;
 29import javax.crypto.SecretKeyFactory;
 30import javax.crypto.spec.IvParameterSpec;
 31import javax.crypto.spec.PBEKeySpec;
 32import javax.crypto.spec.SecretKeySpec;
 33
 34import eu.siacs.conversations.Config;
 35import eu.siacs.conversations.R;
 36import eu.siacs.conversations.crypto.axolotl.SQLiteAxolotlStore;
 37import eu.siacs.conversations.entities.Account;
 38import eu.siacs.conversations.entities.Conversation;
 39import eu.siacs.conversations.entities.Message;
 40import eu.siacs.conversations.persistance.DatabaseBackend;
 41import eu.siacs.conversations.persistance.FileBackend;
 42import eu.siacs.conversations.utils.BackupFileHeader;
 43import eu.siacs.conversations.utils.Compatibility;
 44
 45public class ExportBackupService extends Service {
 46
 47    public static final String KEYTYPE = "AES";
 48    public static final String CIPHERMODE = "AES/GCM/NoPadding";
 49    public static final String PROVIDER = "BC";
 50
 51    private static final int NOTIFICATION_ID = 19;
 52    private static final int PAGE_SIZE = 20;
 53    private static AtomicBoolean running = new AtomicBoolean(false);
 54    private DatabaseBackend mDatabaseBackend;
 55    private List<Account> mAccounts;
 56    private NotificationManager notificationManager;
 57
 58    private static void accountExport(SQLiteDatabase db, String uuid, PrintWriter writer) {
 59        final StringBuilder builder = new StringBuilder();
 60        final Cursor accountCursor = db.query(Account.TABLENAME, null, Account.UUID + "=?", new String[]{uuid}, null, null, null);
 61        while (accountCursor != null && accountCursor.moveToNext()) {
 62            builder.append("INSERT INTO ").append(Account.TABLENAME).append("(");
 63            for (int i = 0; i < accountCursor.getColumnCount(); ++i) {
 64                if (i != 0) {
 65                    builder.append(',');
 66                }
 67                builder.append(accountCursor.getColumnName(i));
 68            }
 69            builder.append(") VALUES(");
 70            for (int i = 0; i < accountCursor.getColumnCount(); ++i) {
 71                if (i != 0) {
 72                    builder.append(',');
 73                }
 74                final String value = accountCursor.getString(i);
 75                if (value == null || Account.ROSTERVERSION.equals(accountCursor.getColumnName(i))) {
 76                    builder.append("NULL");
 77                } else if (value.matches("\\d+")) {
 78                    int intValue = Integer.parseInt(value);
 79                    if (Account.OPTIONS.equals(accountCursor.getColumnName(i))) {
 80                        intValue |= 1 << Account.OPTION_DISABLED;
 81                    }
 82                    builder.append(intValue);
 83                } else {
 84                    DatabaseUtils.appendEscapedSQLString(builder, value);
 85                }
 86            }
 87            builder.append(")");
 88            builder.append(';');
 89            builder.append('\n');
 90        }
 91        if (accountCursor != null) {
 92            accountCursor.close();
 93        }
 94        writer.append(builder.toString());
 95    }
 96
 97    private static void simpleExport(SQLiteDatabase db, String table, String column, String uuid, PrintWriter writer) {
 98        final Cursor cursor = db.query(table, null, column + "=?", new String[]{uuid}, null, null, null);
 99        while (cursor != null && cursor.moveToNext()) {
100            writer.write(cursorToString(table, cursor, PAGE_SIZE));
101        }
102        if (cursor != null) {
103            cursor.close();
104        }
105    }
106
107    public static byte[] getKey(String password, byte[] salt) {
108        try {
109            SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
110            return factory.generateSecret(new PBEKeySpec(password.toCharArray(), salt, 1024, 128)).getEncoded();
111        } catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
112            throw new AssertionError(e);
113        }
114    }
115
116    private static String cursorToString(String tablename, Cursor cursor, int max) {
117        return cursorToString(tablename, cursor, max, false);
118    }
119
120    private static String cursorToString(String tablename, Cursor cursor, int max, boolean ignore) {
121        StringBuilder builder = new StringBuilder();
122        builder.append("INSERT ");
123        if (ignore) {
124            builder.append("OR IGNORE ");
125        }
126        builder.append("INTO ").append(tablename).append("(");
127        for (int i = 0; i < cursor.getColumnCount(); ++i) {
128            if (i != 0) {
129                builder.append(',');
130            }
131            builder.append(cursor.getColumnName(i));
132        }
133        builder.append(") VALUES");
134        for (int i = 0; i < max; ++i) {
135            if (i != 0) {
136                builder.append(',');
137            }
138            appendValues(cursor, builder);
139            if (i < max - 1 && !cursor.moveToNext()) {
140                break;
141            }
142        }
143        builder.append(';');
144        builder.append('\n');
145        return builder.toString();
146    }
147
148    private static void appendValues(Cursor cursor, StringBuilder builder) {
149        builder.append("(");
150        for (int i = 0; i < cursor.getColumnCount(); ++i) {
151            if (i != 0) {
152                builder.append(',');
153            }
154            final String value = cursor.getString(i);
155            if (value == null) {
156                builder.append("NULL");
157            } else if (value.matches("\\d+")) {
158                builder.append(value);
159            } else {
160                DatabaseUtils.appendEscapedSQLString(builder, value);
161            }
162        }
163        builder.append(")");
164
165    }
166
167    @Override
168    public void onCreate() {
169        mDatabaseBackend = DatabaseBackend.getInstance(getBaseContext());
170        mAccounts = mDatabaseBackend.getAccounts();
171        notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
172    }
173
174    @Override
175    public int onStartCommand(Intent intent, int flags, int startId) {
176        if (running.compareAndSet(false, true)) {
177            new Thread(() -> {
178                export();
179                stopForeground(true);
180                running.set(false);
181                stopSelf();
182            }).start();
183            return START_STICKY;
184        }
185        return START_NOT_STICKY;
186    }
187
188    private void messageExport(SQLiteDatabase db, String uuid, PrintWriter writer, Progress progress) {
189        Cursor cursor = db.rawQuery("select messages.* from messages join conversations on conversations.uuid=messages.conversationUuid where conversations.accountUuid=?", new String[]{uuid});
190        int size = cursor != null ? cursor.getCount() : 0;
191        Log.d(Config.LOGTAG, "exporting " + size + " messages");
192        int i = 0;
193        int p = 0;
194        while (cursor != null && cursor.moveToNext()) {
195            writer.write(cursorToString(Message.TABLENAME, cursor, PAGE_SIZE, false));
196            if (i + PAGE_SIZE > size) {
197                i = size;
198            } else {
199                i += PAGE_SIZE;
200            }
201            final int percentage = i * 100 / size;
202            if (p < percentage) {
203                p = percentage;
204                notificationManager.notify(NOTIFICATION_ID, progress.build(p));
205            }
206        }
207        if (cursor != null) {
208            cursor.close();
209        }
210    }
211
212    private void export() {
213        NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(getBaseContext(), "backup");
214        mBuilder.setContentTitle(getString(R.string.notification_create_backup_title))
215                .setSmallIcon(R.drawable.ic_archive_white_24dp)
216                .setProgress(1, 0, false);
217        startForeground(NOTIFICATION_ID, mBuilder.build());
218        try {
219            int count = 0;
220            final int max = this.mAccounts.size();
221            final SecureRandom secureRandom = new SecureRandom();
222            for (Account account : this.mAccounts) {
223                final byte[] IV = new byte[12];
224                final byte[] salt = new byte[16];
225                secureRandom.nextBytes(IV);
226                secureRandom.nextBytes(salt);
227                final BackupFileHeader backupFileHeader = new BackupFileHeader(getString(R.string.app_name), account.getJid(), System.currentTimeMillis(), IV, salt);
228                final Progress progress = new Progress(mBuilder, max, count);
229                final File file = new File(FileBackend.getBackupDirectory(this) + account.getJid().asBareJid().toEscapedString() + ".ceb");
230                if (file.getParentFile().mkdirs()) {
231                    Log.d(Config.LOGTAG, "created backup directory " + file.getParentFile().getAbsolutePath());
232                }
233                final FileOutputStream fileOutputStream = new FileOutputStream(file);
234                final DataOutputStream dataOutputStream = new DataOutputStream(fileOutputStream);
235                backupFileHeader.write(dataOutputStream);
236                dataOutputStream.flush();
237
238                final Cipher cipher = Compatibility.twentyEight() ? Cipher.getInstance(CIPHERMODE) : Cipher.getInstance(CIPHERMODE, PROVIDER);
239                byte[] key = getKey(account.getPassword(), salt);
240                Log.d(Config.LOGTAG, backupFileHeader.toString());
241                SecretKeySpec keySpec = new SecretKeySpec(key, KEYTYPE);
242                IvParameterSpec ivSpec = new IvParameterSpec(IV);
243                cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
244                CipherOutputStream cipherOutputStream = new CipherOutputStream(fileOutputStream, cipher);
245
246                GZIPOutputStream gzipOutputStream = new GZIPOutputStream(cipherOutputStream);
247                PrintWriter writer = new PrintWriter(gzipOutputStream);
248                SQLiteDatabase db = this.mDatabaseBackend.getReadableDatabase();
249                final String uuid = account.getUuid();
250                accountExport(db, uuid, writer);
251                simpleExport(db, Conversation.TABLENAME, Conversation.ACCOUNT, uuid, writer);
252                messageExport(db, uuid, writer, progress);
253                for (String table : Arrays.asList(SQLiteAxolotlStore.PREKEY_TABLENAME, SQLiteAxolotlStore.SIGNED_PREKEY_TABLENAME, SQLiteAxolotlStore.SESSION_TABLENAME, SQLiteAxolotlStore.IDENTITIES_TABLENAME)) {
254                    simpleExport(db, table, SQLiteAxolotlStore.ACCOUNT, uuid, writer);
255                }
256                writer.flush();
257                writer.close();
258                Log.d(Config.LOGTAG, "written backup to " + file.getAbsoluteFile());
259                count++;
260            }
261        } catch (Exception e) {
262            Log.d(Config.LOGTAG, "unable to create backup ", e);
263        }
264    }
265
266    @Override
267    public IBinder onBind(Intent intent) {
268        return null;
269    }
270
271    private class Progress {
272        private final NotificationCompat.Builder builder;
273        private final int max;
274        private final int count;
275
276        private Progress(NotificationCompat.Builder builder, int max, int count) {
277            this.builder = builder;
278            this.max = max;
279            this.count = count;
280        }
281
282        private Notification build(int percentage) {
283            builder.setProgress(max * 100, count * 100 + percentage, false);
284            return builder.build();
285        }
286    }
287}