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