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