ExportBackupService.java

  1package eu.siacs.conversations.services;
  2
  3import android.app.Notification;
  4import android.app.NotificationManager;
  5import android.app.PendingIntent;
  6import android.app.Service;
  7import android.content.Context;
  8import android.content.Intent;
  9import android.database.Cursor;
 10import android.database.DatabaseUtils;
 11import android.database.sqlite.SQLiteDatabase;
 12import android.net.Uri;
 13import android.os.IBinder;
 14import android.support.v4.app.NotificationCompat;
 15import android.util.Log;
 16
 17import java.io.DataOutputStream;
 18import java.io.File;
 19import java.io.FileOutputStream;
 20import java.io.PrintWriter;
 21import java.security.NoSuchAlgorithmException;
 22import java.security.SecureRandom;
 23import java.security.spec.InvalidKeySpecException;
 24import java.util.ArrayList;
 25import java.util.Arrays;
 26import java.util.Collections;
 27import java.util.List;
 28import java.util.concurrent.atomic.AtomicBoolean;
 29import java.util.zip.GZIPOutputStream;
 30
 31import javax.crypto.Cipher;
 32import javax.crypto.CipherOutputStream;
 33import javax.crypto.SecretKeyFactory;
 34import javax.crypto.spec.IvParameterSpec;
 35import javax.crypto.spec.PBEKeySpec;
 36import javax.crypto.spec.SecretKeySpec;
 37
 38import eu.siacs.conversations.Config;
 39import eu.siacs.conversations.R;
 40import eu.siacs.conversations.crypto.axolotl.SQLiteAxolotlStore;
 41import eu.siacs.conversations.entities.Account;
 42import eu.siacs.conversations.entities.Conversation;
 43import eu.siacs.conversations.entities.Message;
 44import eu.siacs.conversations.persistance.DatabaseBackend;
 45import eu.siacs.conversations.persistance.FileBackend;
 46import eu.siacs.conversations.utils.BackupFileHeader;
 47import eu.siacs.conversations.utils.Compatibility;
 48
 49public class ExportBackupService extends Service {
 50
 51    public static final String KEYTYPE = "AES";
 52    public static final String CIPHERMODE = "AES/GCM/NoPadding";
 53    public static final String PROVIDER = "BC";
 54
 55    public static final String MIME_TYPE = "application/vnd.conversations.backup";
 56
 57    private static final int NOTIFICATION_ID = 19;
 58    private static final int PAGE_SIZE = 20;
 59    private static AtomicBoolean running = new AtomicBoolean(false);
 60    private DatabaseBackend mDatabaseBackend;
 61    private List<Account> mAccounts;
 62    private NotificationManager notificationManager;
 63
 64    private static List<Intent> getPossibleFileOpenIntents(final Context context, final String path) {
 65
 66        //http://www.openintents.org/action/android-intent-action-view/file-directory
 67        //do not use 'vnd.android.document/directory' since this will trigger system file manager
 68        Intent openIntent = new Intent(Intent.ACTION_VIEW);
 69        openIntent.addCategory(Intent.CATEGORY_DEFAULT);
 70        if (Compatibility.runsAndTargetsTwentyFour(context)) {
 71            openIntent.setType("resource/folder");
 72        } else {
 73            openIntent.setDataAndType(Uri.parse("file://"+path),"resource/folder");
 74        }
 75        openIntent.putExtra("org.openintents.extra.ABSOLUTE_PATH", path);
 76
 77        Intent amazeIntent = new Intent(Intent.ACTION_VIEW);
 78        amazeIntent.setDataAndType(Uri.parse("com.amaze.filemanager:" + path), "resource/folder");
 79
 80        //will open a file manager at root and user can navigate themselves
 81        Intent systemFallBack = new Intent(Intent.ACTION_VIEW);
 82        systemFallBack.addCategory(Intent.CATEGORY_DEFAULT);
 83        systemFallBack.setData(Uri.parse("content://com.android.externalstorage.documents/root/primary"));
 84
 85        return Arrays.asList(openIntent, amazeIntent, systemFallBack);
 86
 87
 88    }
 89
 90    private static void accountExport(SQLiteDatabase db, String uuid, PrintWriter writer) {
 91        final StringBuilder builder = new StringBuilder();
 92        final Cursor accountCursor = db.query(Account.TABLENAME, null, Account.UUID + "=?", new String[]{uuid}, null, null, null);
 93        while (accountCursor != null && accountCursor.moveToNext()) {
 94            builder.append("INSERT INTO ").append(Account.TABLENAME).append("(");
 95            for (int i = 0; i < accountCursor.getColumnCount(); ++i) {
 96                if (i != 0) {
 97                    builder.append(',');
 98                }
 99                builder.append(accountCursor.getColumnName(i));
100            }
101            builder.append(") VALUES(");
102            for (int i = 0; i < accountCursor.getColumnCount(); ++i) {
103                if (i != 0) {
104                    builder.append(',');
105                }
106                final String value = accountCursor.getString(i);
107                if (value == null || Account.ROSTERVERSION.equals(accountCursor.getColumnName(i))) {
108                    builder.append("NULL");
109                } else if (value.matches("\\d+")) {
110                    int intValue = Integer.parseInt(value);
111                    if (Account.OPTIONS.equals(accountCursor.getColumnName(i))) {
112                        intValue |= 1 << Account.OPTION_DISABLED;
113                    }
114                    builder.append(intValue);
115                } else {
116                    DatabaseUtils.appendEscapedSQLString(builder, value);
117                }
118            }
119            builder.append(")");
120            builder.append(';');
121            builder.append('\n');
122        }
123        if (accountCursor != null) {
124            accountCursor.close();
125        }
126        writer.append(builder.toString());
127    }
128
129    private static void simpleExport(SQLiteDatabase db, String table, String column, String uuid, PrintWriter writer) {
130        final Cursor cursor = db.query(table, null, column + "=?", new String[]{uuid}, null, null, null);
131        while (cursor != null && cursor.moveToNext()) {
132            writer.write(cursorToString(table, cursor, PAGE_SIZE));
133        }
134        if (cursor != null) {
135            cursor.close();
136        }
137    }
138
139    public static byte[] getKey(String password, byte[] salt) {
140        try {
141            SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
142            return factory.generateSecret(new PBEKeySpec(password.toCharArray(), salt, 1024, 128)).getEncoded();
143        } catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
144            throw new AssertionError(e);
145        }
146    }
147
148    private static String cursorToString(String tablename, Cursor cursor, int max) {
149        return cursorToString(tablename, cursor, max, false);
150    }
151
152    private static String cursorToString(final String tablename, final Cursor cursor, int max, boolean ignore) {
153        final boolean identities = SQLiteAxolotlStore.IDENTITIES_TABLENAME.equals(tablename);
154        StringBuilder builder = new StringBuilder();
155        builder.append("INSERT ");
156        if (ignore) {
157            builder.append("OR IGNORE ");
158        }
159        builder.append("INTO ").append(tablename).append("(");
160        int skipColumn = -1;
161        for (int i = 0; i < cursor.getColumnCount(); ++i) {
162            final String name = cursor.getColumnName(i);
163            if (identities && SQLiteAxolotlStore.TRUSTED.equals(name)) {
164                skipColumn = i;
165                continue;
166            }
167            if (i != 0) {
168                builder.append(',');
169            }
170            builder.append(name);
171        }
172        builder.append(") VALUES");
173        for (int i = 0; i < max; ++i) {
174            if (i != 0) {
175                builder.append(',');
176            }
177            appendValues(cursor, builder, skipColumn);
178            if (i < max - 1 && !cursor.moveToNext()) {
179                break;
180            }
181        }
182        builder.append(';');
183        builder.append('\n');
184        return builder.toString();
185    }
186
187    private static void appendValues(final Cursor cursor, final StringBuilder builder, final int skipColumn) {
188        builder.append("(");
189        for (int i = 0; i < cursor.getColumnCount(); ++i) {
190            if (i == skipColumn) {
191                continue;
192            }
193            if (i != 0) {
194                builder.append(',');
195            }
196            final String value = cursor.getString(i);
197            if (value == null) {
198                builder.append("NULL");
199            } else if (value.matches("[0-9]+")) {
200                builder.append(value);
201            } else {
202                DatabaseUtils.appendEscapedSQLString(builder, value);
203            }
204        }
205        builder.append(")");
206
207    }
208
209    @Override
210    public void onCreate() {
211        mDatabaseBackend = DatabaseBackend.getInstance(getBaseContext());
212        mAccounts = mDatabaseBackend.getAccounts();
213        notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
214    }
215
216    @Override
217    public int onStartCommand(Intent intent, int flags, int startId) {
218        if (running.compareAndSet(false, true)) {
219            new Thread(() -> {
220                boolean success;
221                List<File> files;
222                try {
223                    files = export();
224                    success = true;
225                } catch (Exception e) {
226                    success = false;
227                    files = Collections.emptyList();
228                }
229                stopForeground(true);
230                running.set(false);
231                if (success) {
232                    notifySuccess(files);
233                }
234                stopSelf();
235            }).start();
236            return START_STICKY;
237        }
238        return START_NOT_STICKY;
239    }
240
241    private void messageExport(SQLiteDatabase db, String uuid, PrintWriter writer, Progress progress) {
242        Cursor cursor = db.rawQuery("select messages.* from messages join conversations on conversations.uuid=messages.conversationUuid where conversations.accountUuid=?", new String[]{uuid});
243        int size = cursor != null ? cursor.getCount() : 0;
244        Log.d(Config.LOGTAG, "exporting " + size + " messages");
245        int i = 0;
246        int p = 0;
247        while (cursor != null && cursor.moveToNext()) {
248            writer.write(cursorToString(Message.TABLENAME, cursor, PAGE_SIZE, false));
249            if (i + PAGE_SIZE > size) {
250                i = size;
251            } else {
252                i += PAGE_SIZE;
253            }
254            final int percentage = i * 100 / size;
255            if (p < percentage) {
256                p = percentage;
257                notificationManager.notify(NOTIFICATION_ID, progress.build(p));
258            }
259        }
260        if (cursor != null) {
261            cursor.close();
262        }
263    }
264
265    private List<File> export() throws Exception {
266        NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(getBaseContext(), "backup");
267        mBuilder.setContentTitle(getString(R.string.notification_create_backup_title))
268                .setSmallIcon(R.drawable.ic_archive_white_24dp)
269                .setProgress(1, 0, false);
270        startForeground(NOTIFICATION_ID, mBuilder.build());
271        int count = 0;
272        final int max = this.mAccounts.size();
273        final SecureRandom secureRandom = new SecureRandom();
274        final List<File> files = new ArrayList<>();
275        for (Account account : this.mAccounts) {
276            final byte[] IV = new byte[12];
277            final byte[] salt = new byte[16];
278            secureRandom.nextBytes(IV);
279            secureRandom.nextBytes(salt);
280            final BackupFileHeader backupFileHeader = new BackupFileHeader(getString(R.string.app_name), account.getJid(), System.currentTimeMillis(), IV, salt);
281            final Progress progress = new Progress(mBuilder, max, count);
282            final File file = new File(FileBackend.getBackupDirectory(this) + account.getJid().asBareJid().toEscapedString() + ".ceb");
283            files.add(file);
284            if (file.getParentFile().mkdirs()) {
285                Log.d(Config.LOGTAG, "created backup directory " + file.getParentFile().getAbsolutePath());
286            }
287            final FileOutputStream fileOutputStream = new FileOutputStream(file);
288            final DataOutputStream dataOutputStream = new DataOutputStream(fileOutputStream);
289            backupFileHeader.write(dataOutputStream);
290            dataOutputStream.flush();
291
292            final Cipher cipher = Compatibility.twentyEight() ? Cipher.getInstance(CIPHERMODE) : Cipher.getInstance(CIPHERMODE, PROVIDER);
293            byte[] key = getKey(account.getPassword(), salt);
294            Log.d(Config.LOGTAG, backupFileHeader.toString());
295            SecretKeySpec keySpec = new SecretKeySpec(key, KEYTYPE);
296            IvParameterSpec ivSpec = new IvParameterSpec(IV);
297            cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
298            CipherOutputStream cipherOutputStream = new CipherOutputStream(fileOutputStream, cipher);
299
300            GZIPOutputStream gzipOutputStream = new GZIPOutputStream(cipherOutputStream);
301            PrintWriter writer = new PrintWriter(gzipOutputStream);
302            SQLiteDatabase db = this.mDatabaseBackend.getReadableDatabase();
303            final String uuid = account.getUuid();
304            accountExport(db, uuid, writer);
305            simpleExport(db, Conversation.TABLENAME, Conversation.ACCOUNT, uuid, writer);
306            messageExport(db, uuid, writer, progress);
307            for (String table : Arrays.asList(SQLiteAxolotlStore.PREKEY_TABLENAME, SQLiteAxolotlStore.SIGNED_PREKEY_TABLENAME, SQLiteAxolotlStore.SESSION_TABLENAME, SQLiteAxolotlStore.IDENTITIES_TABLENAME)) {
308                simpleExport(db, table, SQLiteAxolotlStore.ACCOUNT, uuid, writer);
309            }
310            writer.flush();
311            writer.close();
312            Log.d(Config.LOGTAG, "written backup to " + file.getAbsoluteFile());
313            count++;
314        }
315        return files;
316    }
317
318    private void notifySuccess(List<File> files) {
319        final String path = FileBackend.getBackupDirectory(this);
320
321        PendingIntent openFolderIntent = null;
322
323        for (Intent intent : getPossibleFileOpenIntents(this, path)) {
324            if (intent.resolveActivityInfo(getPackageManager(), 0) != null) {
325                openFolderIntent = PendingIntent.getActivity(this, 189, intent, PendingIntent.FLAG_UPDATE_CURRENT);
326                break;
327            }
328        }
329
330        PendingIntent shareFilesIntent = null;
331        if (files.size() > 0) {
332            final Intent intent = new Intent(Intent.ACTION_SEND_MULTIPLE);
333            ArrayList<Uri> uris = new ArrayList<>();
334            for(File file : files) {
335                uris.add(FileBackend.getUriForFile(this, file));
336            }
337            intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris);
338            intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
339            intent.setType(MIME_TYPE);
340            final Intent chooser = Intent.createChooser(intent, getString(R.string.share_backup_files));
341            shareFilesIntent = PendingIntent.getActivity(this,190, chooser, PendingIntent.FLAG_UPDATE_CURRENT);
342        }
343
344        NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(getBaseContext(), "backup");
345        mBuilder.setContentTitle(getString(R.string.notification_backup_created_title))
346                .setContentText(getString(R.string.notification_backup_created_subtitle, path))
347                .setStyle(new NotificationCompat.BigTextStyle().bigText(getString(R.string.notification_backup_created_subtitle, FileBackend.getBackupDirectory(this))))
348                .setAutoCancel(true)
349                .setContentIntent(openFolderIntent)
350                .setSmallIcon(R.drawable.ic_archive_white_24dp);
351
352        if (shareFilesIntent != null) {
353            mBuilder.addAction(R.drawable.ic_share_white_24dp, getString(R.string.share_backup_files), shareFilesIntent);
354        }
355
356        notificationManager.notify(NOTIFICATION_ID, mBuilder.build());
357    }
358
359    @Override
360    public IBinder onBind(Intent intent) {
361        return null;
362    }
363
364    private class Progress {
365        private final NotificationCompat.Builder builder;
366        private final int max;
367        private final int count;
368
369        private Progress(NotificationCompat.Builder builder, int max, int count) {
370            this.builder = builder;
371            this.max = max;
372            this.count = count;
373        }
374
375        private Notification build(int percentage) {
376            builder.setProgress(max * 100, count * 100 + percentage, false);
377            return builder.build();
378        }
379    }
380}