ExportBackupWorker.java

  1package eu.siacs.conversations.worker;
  2
  3import static eu.siacs.conversations.utils.Compatibility.s;
  4
  5import android.app.Notification;
  6import android.app.NotificationManager;
  7import android.app.PendingIntent;
  8import android.content.Context;
  9import android.content.Intent;
 10import android.content.pm.ServiceInfo;
 11import android.database.Cursor;
 12import android.database.DatabaseUtils;
 13import android.database.sqlite.SQLiteDatabase;
 14import android.net.Uri;
 15import android.os.SystemClock;
 16import android.util.Log;
 17
 18import androidx.annotation.NonNull;
 19import androidx.core.app.NotificationCompat;
 20import androidx.work.ForegroundInfo;
 21import androidx.work.WorkManager;
 22import androidx.work.Worker;
 23import androidx.work.WorkerParameters;
 24
 25import com.google.common.base.CharMatcher;
 26import com.google.common.base.Optional;
 27import com.google.common.base.Strings;
 28import com.google.common.collect.ImmutableList;
 29import com.google.gson.stream.JsonWriter;
 30
 31import eu.siacs.conversations.Config;
 32import eu.siacs.conversations.R;
 33import eu.siacs.conversations.crypto.axolotl.SQLiteAxolotlStore;
 34import eu.siacs.conversations.entities.Account;
 35import eu.siacs.conversations.entities.Conversation;
 36import eu.siacs.conversations.entities.Message;
 37import eu.siacs.conversations.persistance.DatabaseBackend;
 38import eu.siacs.conversations.persistance.FileBackend;
 39import eu.siacs.conversations.utils.BackupFileHeader;
 40import eu.siacs.conversations.utils.Compatibility;
 41
 42import java.io.DataOutputStream;
 43import java.io.File;
 44import java.io.FileOutputStream;
 45import java.io.IOException;
 46import java.io.OutputStreamWriter;
 47import java.io.PrintWriter;
 48import java.nio.charset.StandardCharsets;
 49import java.security.InvalidAlgorithmParameterException;
 50import java.security.InvalidKeyException;
 51import java.security.NoSuchAlgorithmException;
 52import java.security.NoSuchProviderException;
 53import java.security.SecureRandom;
 54import java.security.spec.InvalidKeySpecException;
 55import java.text.SimpleDateFormat;
 56import java.util.ArrayList;
 57import java.util.Arrays;
 58import java.util.Date;
 59import java.util.List;
 60import java.util.Locale;
 61import java.util.zip.GZIPOutputStream;
 62
 63import javax.crypto.Cipher;
 64import javax.crypto.CipherOutputStream;
 65import javax.crypto.NoSuchPaddingException;
 66import javax.crypto.SecretKeyFactory;
 67import javax.crypto.spec.IvParameterSpec;
 68import javax.crypto.spec.PBEKeySpec;
 69import javax.crypto.spec.SecretKeySpec;
 70
 71public class ExportBackupWorker extends Worker {
 72
 73    private static final SimpleDateFormat DATE_FORMAT =
 74            new SimpleDateFormat("yyyy-MM-dd-HH-mm", Locale.US);
 75
 76    public static final String KEYTYPE = "AES";
 77    public static final String CIPHERMODE = "AES/GCM/NoPadding";
 78    public static final String PROVIDER = "BC";
 79
 80    public static final String MIME_TYPE = "application/vnd.conversations.backup";
 81
 82    private static final int NOTIFICATION_ID = 19;
 83    private static final int PAGE_SIZE = 50;
 84    private static final int BACKUP_CREATED_NOTIFICATION_ID = 23;
 85
 86    private static final int PENDING_INTENT_FLAGS =
 87            s()
 88                    ? PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
 89                    : PendingIntent.FLAG_UPDATE_CURRENT;
 90
 91    private final boolean recurringBackup;
 92
 93    public ExportBackupWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
 94        super(context, workerParams);
 95        final var inputData = workerParams.getInputData();
 96        this.recurringBackup = inputData.getBoolean("recurring_backup", false);
 97    }
 98
 99    @NonNull
100    @Override
101    public Result doWork() {
102        setForegroundAsync(getForegroundInfo());
103        final List<File> files;
104        try {
105            files = export();
106        } catch (final IOException
107                | InvalidKeySpecException
108                | InvalidAlgorithmParameterException
109                | InvalidKeyException
110                | NoSuchPaddingException
111                | NoSuchAlgorithmException
112                | NoSuchProviderException e) {
113            Log.d(Config.LOGTAG, "could not create backup", e);
114            return Result.failure();
115        } finally {
116            getApplicationContext()
117                    .getSystemService(NotificationManager.class)
118                    .cancel(NOTIFICATION_ID);
119        }
120        Log.d(Config.LOGTAG, "done creating " + files.size() + " backup files");
121        if (files.isEmpty() || recurringBackup) {
122            return Result.success();
123        }
124        notifySuccess(files);
125        return Result.success();
126    }
127
128    @NonNull
129    @Override
130    public ForegroundInfo getForegroundInfo() {
131        Log.d(Config.LOGTAG, "getForegroundInfo()");
132        final NotificationCompat.Builder notification = getNotification();
133        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
134            return new ForegroundInfo(
135                    NOTIFICATION_ID,
136                    notification.build(),
137                    ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC);
138        } else {
139            return new ForegroundInfo(NOTIFICATION_ID, notification.build());
140        }
141    }
142
143    private List<File> export()
144            throws IOException,
145                    InvalidKeySpecException,
146                    InvalidAlgorithmParameterException,
147                    InvalidKeyException,
148                    NoSuchPaddingException,
149                    NoSuchAlgorithmException,
150                    NoSuchProviderException {
151        final Context context = getApplicationContext();
152        final var database = DatabaseBackend.getInstance(context);
153        final var accounts = database.getAccounts();
154
155        int count = 0;
156        final int max = accounts.size();
157        final ImmutableList.Builder<File> files = new ImmutableList.Builder<>();
158        Log.d(Config.LOGTAG, "starting backup for " + max + " accounts");
159        for (final Account account : accounts) {
160            if (isStopped()) {
161                Log.d(Config.LOGTAG, "ExportBackupWorker has stopped. Returning what we have");
162                return files.build();
163            }
164            final String password = account.getPassword();
165            if (Strings.nullToEmpty(password).trim().isEmpty()) {
166                Log.d(
167                        Config.LOGTAG,
168                        String.format(
169                                "skipping backup for %s because password is empty. unable to encrypt",
170                                account.getJid().asBareJid()));
171                count++;
172                continue;
173            }
174            final String filename =
175                    String.format(
176                            "%s.%s.ceb",
177                            account.getJid().asBareJid().toEscapedString(),
178                            DATE_FORMAT.format(new Date()));
179            final File file = new File(FileBackend.getBackupDirectory(context), filename);
180            try {
181                export(database, account, password, file, max, count);
182            } catch (final WorkStoppedException e) {
183                if (file.delete()) {
184                    Log.d(
185                            Config.LOGTAG,
186                            "deleted in progress backup file " + file.getAbsolutePath());
187                }
188                Log.d(Config.LOGTAG, "ExportBackupWorker has stopped. Returning what we have");
189                return files.build();
190            }
191            files.add(file);
192            count++;
193        }
194        return files.build();
195    }
196
197    private void export(
198            final DatabaseBackend database,
199            final Account account,
200            final String password,
201            final File file,
202            final int max,
203            final int count)
204            throws IOException,
205                    InvalidKeySpecException,
206                    InvalidAlgorithmParameterException,
207                    InvalidKeyException,
208                    NoSuchPaddingException,
209                    NoSuchAlgorithmException,
210                    NoSuchProviderException,
211                    WorkStoppedException {
212        final var context = getApplicationContext();
213        final SecureRandom secureRandom = new SecureRandom();
214        Log.d(
215                Config.LOGTAG,
216                String.format(
217                        "exporting data for account %s (%s)",
218                        account.getJid().asBareJid(), account.getUuid()));
219        final byte[] IV = new byte[12];
220        final byte[] salt = new byte[16];
221        secureRandom.nextBytes(IV);
222        secureRandom.nextBytes(salt);
223        final BackupFileHeader backupFileHeader =
224                new BackupFileHeader(
225                        context.getString(R.string.app_name),
226                        account.getJid(),
227                        System.currentTimeMillis(),
228                        IV,
229                        salt);
230        final var notification = getNotification();
231        final var cancelPendingIntent =
232                WorkManager.getInstance(context).createCancelPendingIntent(getId());
233        notification.addAction(
234                new NotificationCompat.Action.Builder(
235                                R.drawable.ic_cancel_24dp,
236                                context.getString(R.string.cancel),
237                                cancelPendingIntent)
238                        .build());
239        final Progress progress = new Progress(notification, max, count);
240        final File directory = file.getParentFile();
241        if (directory != null && directory.mkdirs()) {
242            Log.d(Config.LOGTAG, "created backup directory " + directory.getAbsolutePath());
243        }
244        final FileOutputStream fileOutputStream = new FileOutputStream(file);
245        final DataOutputStream dataOutputStream = new DataOutputStream(fileOutputStream);
246        backupFileHeader.write(dataOutputStream);
247        dataOutputStream.flush();
248
249        final Cipher cipher =
250                Compatibility.twentyEight()
251                        ? Cipher.getInstance(CIPHERMODE)
252                        : Cipher.getInstance(CIPHERMODE, PROVIDER);
253        final byte[] key = getKey(password, salt);
254        SecretKeySpec keySpec = new SecretKeySpec(key, KEYTYPE);
255        IvParameterSpec ivSpec = new IvParameterSpec(IV);
256        cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
257        CipherOutputStream cipherOutputStream = new CipherOutputStream(fileOutputStream, cipher);
258
259        final GZIPOutputStream gzipOutputStream = new GZIPOutputStream(cipherOutputStream);
260        final SQLiteDatabase db = database.getReadableDatabase();
261        final var writer = new PrintWriter(gzipOutputStream);
262        final String uuid = account.getUuid();
263        accountExport(db, uuid, writer);
264        simpleExport(db, Conversation.TABLENAME, Conversation.ACCOUNT, uuid, writer);
265        messageExport(db, uuid, writer, progress);
266        messageExportCheogram(db, uuid, writer, progress);
267        for (final String table :
268                Arrays.asList(
269                        SQLiteAxolotlStore.PREKEY_TABLENAME,
270                        SQLiteAxolotlStore.SIGNED_PREKEY_TABLENAME,
271                        SQLiteAxolotlStore.SESSION_TABLENAME,
272                        SQLiteAxolotlStore.IDENTITIES_TABLENAME)) {
273            throwIfWorkStopped();
274            simpleExport(db, table, SQLiteAxolotlStore.ACCOUNT, uuid, writer);
275        }
276        writer.flush();
277        writer.close();
278        mediaScannerScanFile(file);
279        Log.d(Config.LOGTAG, "written backup to " + file.getAbsoluteFile());
280    }
281
282    private NotificationCompat.Builder getNotification() {
283        final var context = getApplicationContext();
284        final NotificationCompat.Builder notification =
285                new NotificationCompat.Builder(context, "backup");
286        notification
287                .setContentTitle(context.getString(R.string.notification_create_backup_title))
288                .setSmallIcon(R.drawable.ic_archive_24dp)
289                .setProgress(1, 0, false);
290        notification.setOngoing(true);
291        notification.setLocalOnly(true);
292        return notification;
293    }
294
295    private void throwIfWorkStopped() throws WorkStoppedException {
296        if (isStopped()) {
297            throw new WorkStoppedException();
298        }
299    }
300
301    private void mediaScannerScanFile(final File file) {
302        final Intent intent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
303        intent.setData(Uri.fromFile(file));
304        getApplicationContext().sendBroadcast(intent);
305    }
306
307    private void messageExport(SQLiteDatabase db, String uuid, PrintWriter writer, Progress progress) {
308        final var notificationManager = getApplicationContext().getSystemService(NotificationManager.class);
309        Cursor cursor = db.rawQuery("select messages.* from messages join conversations on conversations.uuid=messages.conversationUuid where conversations.accountUuid=?", new String[]{uuid});
310        int size = cursor != null ? cursor.getCount() : 0;
311        Log.d(Config.LOGTAG, "exporting " + size + " messages for account " + uuid);
312        int i = 0;
313        int p = 0;
314        while (cursor != null && cursor.moveToNext()) {
315            writer.write(cursorToString(Message.TABLENAME, cursor, PAGE_SIZE, false));
316            if (i + PAGE_SIZE > size) {
317                i = size;
318            } else {
319                i += PAGE_SIZE;
320            }
321            final int percentage = i * 100 / size;
322            if (p < percentage) {
323                p = percentage;
324                notificationManager.notify(NOTIFICATION_ID, progress.build(p));
325            }
326        }
327        if (cursor != null) {
328            cursor.close();
329        }
330    }
331
332    private void messageExportCheogram(SQLiteDatabase db, String uuid, PrintWriter writer, Progress progress) {
333        final var notificationManager = getApplicationContext().getSystemService(NotificationManager.class);
334        Cursor cursor = db.rawQuery("select cmessages.* from messages join cheogram.messages cmessages using (uuid) join conversations on conversations.uuid=messages.conversationUuid where conversations.accountUuid=?", new String[]{uuid});
335        int size = cursor != null ? cursor.getCount() : 0;
336        Log.d(Config.LOGTAG, "exporting " + size + " cheogram messages for account " + uuid);
337        int i = 0;
338        int p = 0;
339        while (cursor != null && cursor.moveToNext()) {
340            writer.write(cursorToString("cheogram." + Message.TABLENAME, cursor, PAGE_SIZE, false));
341            if (i + PAGE_SIZE > size) {
342                i = size;
343            } else {
344                i += PAGE_SIZE;
345            }
346            final int percentage = i * 100 / size;
347            if (p < percentage) {
348                p = percentage;
349                notificationManager.notify(NOTIFICATION_ID, progress.build(p));
350            }
351        }
352        if (cursor != null) {
353            cursor.close();
354        }
355
356        cursor = db.rawQuery("select webxdc_updates.* from " + Conversation.TABLENAME + " join cheogram.webxdc_updates webxdc_updates on " + Conversation.TABLENAME + ".uuid=webxdc_updates." + Message.CONVERSATION + " where conversations.accountUuid=?", new String[]{uuid});
357        size = cursor != null ? cursor.getCount() : 0;
358        Log.d(Config.LOGTAG, "exporting " + size + " WebXDC updates for account " + uuid);
359        while (cursor != null && cursor.moveToNext()) {
360            writer.write(cursorToString("cheogram.webxdc_updates", cursor, PAGE_SIZE, false));
361            if (i + PAGE_SIZE > size) {
362                i = size;
363            } else {
364                i += PAGE_SIZE;
365            }
366            final int percentage = i * 100 / size;
367            if (p < percentage) {
368                p = percentage;
369                notificationManager.notify(NOTIFICATION_ID, progress.build(p));
370            }
371        }
372        if (cursor != null) {
373            cursor.close();
374        }
375    }
376
377    private static void accountExport(final SQLiteDatabase db, final String uuid, final PrintWriter writer) {
378        final StringBuilder builder = new StringBuilder();
379        final Cursor accountCursor = db.query(Account.TABLENAME, null, Account.UUID + "=?", new String[]{uuid}, null, null, null);
380        while (accountCursor != null && accountCursor.moveToNext()) {
381            builder.append("INSERT INTO ").append(Account.TABLENAME).append("(");
382            for (int i = 0; i < accountCursor.getColumnCount(); ++i) {
383                if (i != 0) {
384                    builder.append(',');
385                }
386                builder.append(accountCursor.getColumnName(i));
387            }
388            builder.append(") VALUES(");
389            for (int i = 0; i < accountCursor.getColumnCount(); ++i) {
390                if (i != 0) {
391                    builder.append(',');
392                }
393                final String value = accountCursor.getString(i);
394                if (value == null || Account.ROSTERVERSION.equals(accountCursor.getColumnName(i))) {
395                    builder.append("NULL");
396                } else if (Account.OPTIONS.equals(accountCursor.getColumnName(i)) && value.matches("\\d+")) {
397                    int intValue = Integer.parseInt(value);
398                    intValue |= 1 << Account.OPTION_DISABLED;
399                    builder.append(intValue);
400                } else {
401                    appendEscapedSQLString(builder, value);
402                }
403            }
404            builder.append(")");
405            builder.append(';');
406            builder.append('\n');
407        }
408        if (accountCursor != null) {
409            accountCursor.close();
410        }
411        writer.append(builder.toString());
412    }
413
414    private static void simpleExport(SQLiteDatabase db, String table, String column, String uuid, PrintWriter writer) {
415        final Cursor cursor = db.query(table, null, column + "=?", new String[]{uuid}, null, null, null);
416        while (cursor != null && cursor.moveToNext()) {
417            writer.write(cursorToString(table, cursor, PAGE_SIZE));
418        }
419        if (cursor != null) {
420            cursor.close();
421        }
422    }
423
424    private static String cursorToString(final String table, final Cursor cursor, final int max) {
425        return cursorToString(table, cursor, max, false);
426    }
427
428    private static String cursorToString(final String table, final Cursor cursor, int max, boolean ignore) {
429        final boolean identities = SQLiteAxolotlStore.IDENTITIES_TABLENAME.equals(table);
430        StringBuilder builder = new StringBuilder();
431        builder.append("INSERT ");
432        if (ignore) {
433            builder.append("OR IGNORE ");
434        }
435        builder.append("INTO ").append(table).append("(");
436        int skipColumn = -1;
437        for (int i = 0; i < cursor.getColumnCount(); ++i) {
438            final String name = cursor.getColumnName(i);
439            if (identities && SQLiteAxolotlStore.TRUSTED.equals(name)) {
440                skipColumn = i;
441                continue;
442            }
443            if (i != 0) {
444                builder.append(',');
445            }
446            builder.append(name);
447        }
448        builder.append(") VALUES");
449        for (int i = 0; i < max; ++i) {
450            if (i != 0) {
451                builder.append(',');
452            }
453            appendValues(cursor, builder, skipColumn);
454            if (i < max - 1 && !cursor.moveToNext()) {
455                break;
456            }
457        }
458        builder.append(';');
459        builder.append('\n');
460        return builder.toString();
461    }
462
463    private static void appendValues(final Cursor cursor, final StringBuilder builder, final int skipColumn) {
464        builder.append("(");
465        for (int i = 0; i < cursor.getColumnCount(); ++i) {
466            if (i == skipColumn) {
467                continue;
468            }
469            if (i != 0) {
470                builder.append(',');
471            }
472            final String value = cursor.getString(i);
473            if (value == null) {
474                builder.append("NULL");
475            } else if (value.matches("[0-9]+")) {
476                builder.append(value);
477            } else {
478                appendEscapedSQLString(builder, value);
479            }
480        }
481        builder.append(")");
482
483    }
484
485    private static void appendEscapedSQLString(final StringBuilder sb, final String sqlString) {
486        DatabaseUtils.appendEscapedSQLString(sb, CharMatcher.is('\u0000').removeFrom(sqlString));
487    }
488
489    public static byte[] getKey(final String password, final byte[] salt)
490            throws InvalidKeySpecException {
491        final SecretKeyFactory factory;
492        try {
493            factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
494        } catch (NoSuchAlgorithmException e) {
495            throw new IllegalStateException(e);
496        }
497        return factory.generateSecret(new PBEKeySpec(password.toCharArray(), salt, 1024, 128))
498                .getEncoded();
499    }
500
501    private void notifySuccess(final List<File> files) {
502        final var context = getApplicationContext();
503        final String path = FileBackend.getBackupDirectory(context).getAbsolutePath();
504
505        final var openFolderIntent = getOpenFolderIntent(path);
506
507        final Intent intent = new Intent(Intent.ACTION_SEND_MULTIPLE);
508        final ArrayList<Uri> uris = new ArrayList<>();
509        for (final File file : files) {
510            uris.add(FileBackend.getUriForFile(context, file, file.getName()));
511        }
512        intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris);
513        intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
514        intent.setType(MIME_TYPE);
515        final Intent chooser =
516                Intent.createChooser(intent, context.getString(R.string.share_backup_files));
517        final var shareFilesIntent =
518                PendingIntent.getActivity(context, 190, chooser, PENDING_INTENT_FLAGS);
519
520        NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(context, "backup");
521        mBuilder.setContentTitle(context.getString(R.string.notification_backup_created_title))
522                .setContentText(
523                        context.getString(R.string.notification_backup_created_subtitle, path))
524                .setStyle(
525                        new NotificationCompat.BigTextStyle()
526                                .bigText(
527                                        context.getString(
528                                                R.string.notification_backup_created_subtitle,
529                                                FileBackend.getBackupDirectory(context)
530                                                        .getAbsolutePath())))
531                .setAutoCancel(true)
532                .setSmallIcon(R.drawable.ic_archive_24dp);
533
534        if (openFolderIntent.isPresent()) {
535            mBuilder.setContentIntent(openFolderIntent.get());
536        } else {
537            Log.w(Config.LOGTAG, "no app can display folders");
538        }
539
540        mBuilder.addAction(
541                R.drawable.ic_share_24dp,
542                context.getString(R.string.share_backup_files),
543                shareFilesIntent);
544        final var notificationManager = context.getSystemService(NotificationManager.class);
545        notificationManager.notify(BACKUP_CREATED_NOTIFICATION_ID, mBuilder.build());
546    }
547
548    private Optional<PendingIntent> getOpenFolderIntent(final String path) {
549        final var context = getApplicationContext();
550        for (final Intent intent : getPossibleFileOpenIntents(context, path)) {
551            if (intent.resolveActivityInfo(context.getPackageManager(), 0) != null) {
552                return Optional.of(
553                        PendingIntent.getActivity(context, 189, intent, PENDING_INTENT_FLAGS));
554            }
555        }
556        return Optional.absent();
557    }
558
559    private static List<Intent> getPossibleFileOpenIntents(
560            final Context context, final String path) {
561
562        // http://www.openintents.org/action/android-intent-action-view/file-directory
563        // do not use 'vnd.android.document/directory' since this will trigger system file manager
564        final Intent openIntent = new Intent(Intent.ACTION_VIEW);
565        openIntent.addCategory(Intent.CATEGORY_DEFAULT);
566        if (Compatibility.runsAndTargetsTwentyFour(context)) {
567            openIntent.setType("resource/folder");
568        } else {
569            openIntent.setDataAndType(Uri.parse("file://" + path), "resource/folder");
570        }
571        openIntent.putExtra("org.openintents.extra.ABSOLUTE_PATH", path);
572
573        final Intent amazeIntent = new Intent(Intent.ACTION_VIEW);
574        amazeIntent.setDataAndType(Uri.parse("com.amaze.filemanager:" + path), "resource/folder");
575
576        // will open a file manager at root and user can navigate themselves
577        final Intent systemFallBack = new Intent(Intent.ACTION_VIEW);
578        systemFallBack.addCategory(Intent.CATEGORY_DEFAULT);
579        systemFallBack.setData(
580                Uri.parse("content://com.android.externalstorage.documents/root/primary"));
581
582        return Arrays.asList(openIntent, amazeIntent, systemFallBack);
583    }
584
585    private static class Progress {
586        private final NotificationCompat.Builder notification;
587        private final int max;
588        private final int count;
589
590        private Progress(
591                final NotificationCompat.Builder notification, final int max, final int count) {
592            this.notification = notification;
593            this.max = max;
594            this.count = count;
595        }
596
597        private Notification build(int percentage) {
598            notification.setProgress(max * 100, count * 100 + percentage, false);
599            return notification.build();
600        }
601    }
602
603    private static class WorkStoppedException extends Exception {}
604}