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.documentfile.provider.DocumentFile;
 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;
 30import eu.siacs.conversations.AppSettings;
 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.services.QuickConversationsService;
 40import eu.siacs.conversations.utils.BackupFileHeader;
 41import eu.siacs.conversations.utils.Compatibility;
 42import java.io.DataOutputStream;
 43import java.io.File;
 44import java.io.FileOutputStream;
 45import java.io.IOException;
 46import java.io.OutputStream;
 47import java.io.OutputStreamWriter;
 48import java.io.PrintWriter;
 49import java.nio.charset.StandardCharsets;
 50import java.security.InvalidAlgorithmParameterException;
 51import java.security.InvalidKeyException;
 52import java.security.NoSuchAlgorithmException;
 53import java.security.NoSuchProviderException;
 54import java.security.SecureRandom;
 55import java.security.spec.InvalidKeySpecException;
 56import java.text.SimpleDateFormat;
 57import java.util.ArrayList;
 58import java.util.Arrays;
 59import java.util.Date;
 60import java.util.List;
 61import java.util.Locale;
 62import java.util.zip.GZIPOutputStream;
 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    private static final String KEY_TYPE = "AES";
 77    private static final String CIPHER_MODE = "AES/GCM/NoPadding";
 78    private 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<Uri> 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<Uri> export()
144            throws IOException,
145                    InvalidKeySpecException,
146                    InvalidAlgorithmParameterException,
147                    InvalidKeyException,
148                    NoSuchPaddingException,
149                    NoSuchAlgorithmException,
150                    NoSuchProviderException {
151        final Context context = getApplicationContext();
152        final var appSettings = new AppSettings(context);
153        final var backupLocation = appSettings.getBackupLocation();
154        final var database = DatabaseBackend.getInstance(context);
155        final var accounts = database.getAccounts();
156
157        int count = 0;
158        final int max = accounts.size();
159        final ImmutableList.Builder<Uri> locations = new ImmutableList.Builder<>();
160        Log.d(Config.LOGTAG, "starting backup for " + max + " accounts");
161        for (final Account account : accounts) {
162            if (isStopped()) {
163                Log.d(Config.LOGTAG, "ExportBackupWorker has stopped. Returning what we have");
164                return locations.build();
165            }
166            final String password = account.getPassword();
167            if (Strings.nullToEmpty(password).trim().isEmpty()) {
168                Log.d(
169                        Config.LOGTAG,
170                        String.format(
171                                "skipping backup for %s because password is empty. unable to"
172                                        + " encrypt",
173                                account.getJid().asBareJid()));
174                count++;
175                continue;
176            }
177            final Uri uri;
178            try {
179                uri = export(database, account, password, backupLocation, max, count);
180            } catch (final WorkStoppedException e) {
181                Log.d(Config.LOGTAG, "ExportBackupWorker has stopped. Returning what we have");
182                return locations.build();
183            }
184            locations.add(uri);
185            count++;
186        }
187        return locations.build();
188    }
189
190    private Uri export(
191            final DatabaseBackend database,
192            final Account account,
193            final String password,
194            final Uri backupLocation,
195            final int max,
196            final int count)
197            throws IOException,
198                    InvalidKeySpecException,
199                    InvalidAlgorithmParameterException,
200                    InvalidKeyException,
201                    NoSuchPaddingException,
202                    NoSuchAlgorithmException,
203                    NoSuchProviderException,
204                    WorkStoppedException {
205        final var context = getApplicationContext();
206        final SecureRandom secureRandom = new SecureRandom();
207        Log.d(
208                Config.LOGTAG,
209                String.format(
210                        "exporting data for account %s (%s)",
211                        account.getJid().asBareJid(), account.getUuid()));
212        final byte[] IV = new byte[12];
213        final byte[] salt = new byte[16];
214        secureRandom.nextBytes(IV);
215        secureRandom.nextBytes(salt);
216        final BackupFileHeader backupFileHeader =
217                new BackupFileHeader(
218                        context.getString(R.string.app_name),
219                        account.getJid(),
220                        System.currentTimeMillis(),
221                        IV,
222                        salt);
223        final var notification = getNotification();
224        final var cancelPendingIntent =
225                WorkManager.getInstance(context).createCancelPendingIntent(getId());
226        notification.addAction(
227                new NotificationCompat.Action.Builder(
228                                R.drawable.ic_cancel_24dp,
229                                context.getString(R.string.cancel),
230                                cancelPendingIntent)
231                        .build());
232        final Progress progress = new Progress(notification, max, count);
233        final String filename =
234                String.format(
235                        "%s.%s.ceb",
236                        account.getJid().asBareJid().toString(), DATE_FORMAT.format(new Date()));
237        final OutputStream outputStream;
238        final Uri location;
239        if ("file".equalsIgnoreCase(backupLocation.getScheme())) {
240            final File file = new File(backupLocation.getPath(), filename);
241            final File directory = file.getParentFile();
242            if (directory != null && directory.mkdirs()) {
243                Log.d(Config.LOGTAG, "created backup directory " + directory.getAbsolutePath());
244            }
245            outputStream = new FileOutputStream(file);
246            location = Uri.fromFile(file);
247        } else {
248            final var tree = DocumentFile.fromTreeUri(context, backupLocation);
249            if (tree == null) {
250                throw new IOException(
251                        String.format(
252                                "DocumentFile.fromTreeUri returned null for %s", backupLocation));
253            }
254            final var file = tree.createFile(MIME_TYPE, filename);
255            if (file == null) {
256                throw new IOException(
257                        String.format("Could not create %s in %s", filename, backupLocation));
258            }
259            location = file.getUri();
260            outputStream = context.getContentResolver().openOutputStream(location);
261        }
262        final DataOutputStream dataOutputStream = new DataOutputStream(outputStream);
263        backupFileHeader.write(dataOutputStream);
264        dataOutputStream.flush();
265
266        final Cipher cipher =
267                Compatibility.twentyEight()
268                        ? Cipher.getInstance(CIPHER_MODE)
269                        : Cipher.getInstance(CIPHER_MODE, PROVIDER);
270        final byte[] key = getKey(password, salt);
271        SecretKeySpec keySpec = new SecretKeySpec(key, KEY_TYPE);
272        IvParameterSpec ivSpec = new IvParameterSpec(IV);
273        cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
274        CipherOutputStream cipherOutputStream = new CipherOutputStream(outputStream, cipher);
275
276        final GZIPOutputStream gzipOutputStream = new GZIPOutputStream(cipherOutputStream);
277        final SQLiteDatabase db = database.getReadableDatabase();
278        final var writer = new PrintWriter(gzipOutputStream);
279        final String uuid = account.getUuid();
280        accountExport(db, uuid, writer);
281        simpleExport(db, Conversation.TABLENAME, Conversation.ACCOUNT, uuid, writer);
282        messageExport(db, uuid, writer, progress);
283        messageExportCheogram(db, uuid, writer, progress);
284        for (final String table :
285                Arrays.asList(
286                        SQLiteAxolotlStore.PREKEY_TABLENAME,
287                        SQLiteAxolotlStore.SIGNED_PREKEY_TABLENAME,
288                        SQLiteAxolotlStore.SESSION_TABLENAME,
289                        SQLiteAxolotlStore.IDENTITIES_TABLENAME)) {
290            throwIfWorkStopped(location);
291            simpleExport(db, table, SQLiteAxolotlStore.ACCOUNT, uuid, writer);
292        }
293        writer.flush();
294        writer.close();
295        return location;
296    }
297
298    private NotificationCompat.Builder getNotification() {
299        final var context = getApplicationContext();
300        final NotificationCompat.Builder notification =
301                new NotificationCompat.Builder(context, "backup");
302        notification
303                .setContentTitle(context.getString(R.string.notification_create_backup_title))
304                .setSmallIcon(R.drawable.ic_archive_24dp)
305                .setProgress(1, 0, false);
306        notification.setOngoing(true);
307        notification.setLocalOnly(true);
308        return notification;
309    }
310
311    private void throwIfWorkStopped(final Uri location) throws WorkStoppedException {
312        if (isStopped()) {
313            if ("file".equalsIgnoreCase(location.getScheme())) {
314                final var file = new File(location.getPath());
315                if (file.delete()) {
316                    Log.d(Config.LOGTAG, "deleted " + file.getAbsolutePath());
317                }
318            } else {
319                final var documentFile =
320                        DocumentFile.fromSingleUri(getApplicationContext(), location);
321                if (documentFile != null && documentFile.delete()) {
322                    Log.d(Config.LOGTAG, "deleted " + location);
323                }
324            }
325            throw new WorkStoppedException();
326        }
327    }
328
329    private void mediaScannerScanFile(final File file) {
330        final Intent intent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
331        intent.setData(Uri.fromFile(file));
332        getApplicationContext().sendBroadcast(intent);
333    }
334
335    private void messageExport(SQLiteDatabase db, String uuid, PrintWriter writer, Progress progress) {
336        final var notificationManager = getApplicationContext().getSystemService(NotificationManager.class);
337        Cursor cursor = db.rawQuery("select messages.* from messages 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 + " messages for account " + uuid);
340        int i = 0;
341        int p = 0;
342        while (cursor != null && cursor.moveToNext()) {
343            writer.write(cursorToString(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
360    private void messageExportCheogram(SQLiteDatabase db, String uuid, PrintWriter writer, Progress progress) {
361        final var notificationManager = getApplicationContext().getSystemService(NotificationManager.class);
362        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});
363        int size = cursor != null ? cursor.getCount() : 0;
364        Log.d(Config.LOGTAG, "exporting " + size + " cheogram messages for account " + uuid);
365        int i = 0;
366        int p = 0;
367        while (cursor != null && cursor.moveToNext()) {
368            writer.write(cursorToString("cheogram." + Message.TABLENAME, cursor, PAGE_SIZE, false));
369            if (i + PAGE_SIZE > size) {
370                i = size;
371            } else {
372                i += PAGE_SIZE;
373            }
374            final int percentage = i * 100 / size;
375            if (p < percentage) {
376                p = percentage;
377                notificationManager.notify(NOTIFICATION_ID, progress.build(p));
378            }
379        }
380        if (cursor != null) {
381            cursor.close();
382        }
383
384        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});
385        size = cursor != null ? cursor.getCount() : 0;
386        Log.d(Config.LOGTAG, "exporting " + size + " WebXDC updates for account " + uuid);
387        while (cursor != null && cursor.moveToNext()) {
388            writer.write(cursorToString("cheogram.webxdc_updates", cursor, PAGE_SIZE, false));
389            if (i + PAGE_SIZE > size) {
390                i = size;
391            } else {
392                i += PAGE_SIZE;
393            }
394            final int percentage = i * 100 / size;
395            if (p < percentage) {
396                p = percentage;
397                notificationManager.notify(NOTIFICATION_ID, progress.build(p));
398            }
399        }
400        if (cursor != null) {
401            cursor.close();
402        }
403    }
404
405    private static void accountExport(final SQLiteDatabase db, final String uuid, final PrintWriter writer) {
406        final StringBuilder builder = new StringBuilder();
407        final Cursor accountCursor = db.query(Account.TABLENAME, null, Account.UUID + "=?", new String[]{uuid}, null, null, null);
408        while (accountCursor != null && accountCursor.moveToNext()) {
409            builder.append("INSERT INTO ").append(Account.TABLENAME).append("(");
410            for (int i = 0; i < accountCursor.getColumnCount(); ++i) {
411                if (i != 0) {
412                    builder.append(',');
413                }
414                builder.append(accountCursor.getColumnName(i));
415            }
416            builder.append(") VALUES(");
417            for (int i = 0; i < accountCursor.getColumnCount(); ++i) {
418                if (i != 0) {
419                    builder.append(',');
420                }
421                final String value = accountCursor.getString(i);
422                if (value == null || Account.ROSTERVERSION.equals(accountCursor.getColumnName(i))) {
423                    builder.append("NULL");
424                } else if (Account.OPTIONS.equals(accountCursor.getColumnName(i)) && value.matches("\\d+")) {
425                    int intValue = Integer.parseInt(value);
426                    intValue |= 1 << Account.OPTION_DISABLED;
427                    builder.append(intValue);
428                } else {
429                    appendEscapedSQLString(builder, value);
430                }
431            }
432            builder.append(")");
433            builder.append(';');
434            builder.append('\n');
435        }
436        if (accountCursor != null) {
437            accountCursor.close();
438        }
439        writer.append(builder.toString());
440    }
441
442    private static void simpleExport(SQLiteDatabase db, String table, String column, String uuid, PrintWriter writer) {
443        final Cursor cursor = db.query(table, null, column + "=?", new String[]{uuid}, null, null, null);
444        while (cursor != null && cursor.moveToNext()) {
445            writer.write(cursorToString(table, cursor, PAGE_SIZE));
446        }
447        if (cursor != null) {
448            cursor.close();
449        }
450    }
451
452    private static String cursorToString(final String table, final Cursor cursor, final int max) {
453        return cursorToString(table, cursor, max, false);
454    }
455
456    private static String cursorToString(final String table, final Cursor cursor, int max, boolean ignore) {
457        final boolean identities = SQLiteAxolotlStore.IDENTITIES_TABLENAME.equals(table);
458        StringBuilder builder = new StringBuilder();
459        builder.append("INSERT ");
460        if (ignore) {
461            builder.append("OR IGNORE ");
462        }
463        builder.append("INTO ").append(table).append("(");
464        int skipColumn = -1;
465        for (int i = 0; i < cursor.getColumnCount(); ++i) {
466            final String name = cursor.getColumnName(i);
467            if (identities && SQLiteAxolotlStore.TRUSTED.equals(name)) {
468                skipColumn = i;
469                continue;
470            }
471            if (i != 0) {
472                builder.append(',');
473            }
474            builder.append(name);
475        }
476        builder.append(") VALUES");
477        for (int i = 0; i < max; ++i) {
478            if (i != 0) {
479                builder.append(',');
480            }
481            appendValues(cursor, builder, skipColumn);
482            if (i < max - 1 && !cursor.moveToNext()) {
483                break;
484            }
485        }
486        builder.append(';');
487        builder.append('\n');
488        return builder.toString();
489    }
490
491    private static void appendValues(final Cursor cursor, final StringBuilder builder, final int skipColumn) {
492        builder.append("(");
493        for (int i = 0; i < cursor.getColumnCount(); ++i) {
494            if (i == skipColumn) {
495                continue;
496            }
497            if (i != 0) {
498                builder.append(',');
499            }
500            final String value = cursor.getString(i);
501            if (value == null) {
502                builder.append("NULL");
503            } else if (value.matches("[0-9]+")) {
504                builder.append(value);
505            } else {
506                appendEscapedSQLString(builder, value);
507            }
508        }
509        builder.append(")");
510
511    }
512
513    private static void appendEscapedSQLString(final StringBuilder sb, final String sqlString) {
514        DatabaseUtils.appendEscapedSQLString(sb, CharMatcher.is('\u0000').removeFrom(sqlString));
515    }
516
517    public static byte[] getKey(final String password, final byte[] salt)
518            throws InvalidKeySpecException {
519        final SecretKeyFactory factory;
520        try {
521            factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
522        } catch (NoSuchAlgorithmException e) {
523            throw new IllegalStateException(e);
524        }
525        return factory.generateSecret(new PBEKeySpec(password.toCharArray(), salt, 1024, 128))
526                .getEncoded();
527    }
528
529    private void notifySuccess(final List<Uri> locations) {
530        final var context = getApplicationContext();
531        final var appSettings = new AppSettings(context);
532        final String path = appSettings.getBackupLocationAsPath();
533        final Intent intent = new Intent(Intent.ACTION_SEND_MULTIPLE);
534        final ArrayList<Uri> uris = new ArrayList<>();
535        for (final Uri uri : locations) {
536            if ("file".equalsIgnoreCase(uri.getScheme())) {
537                final var file = new File(uri.getPath());
538                uris.add(FileBackend.getUriForFile(context, file, file.getName()));
539            } else {
540                uris.add(uri);
541            }
542        }
543        intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris);
544        intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
545        intent.setType(MIME_TYPE);
546        final Intent chooser =
547                Intent.createChooser(intent, context.getString(R.string.share_backup_files));
548        final var shareFilesIntent =
549                PendingIntent.getActivity(context, 190, chooser, PENDING_INTENT_FLAGS);
550
551        NotificationCompat.Builder builder = new NotificationCompat.Builder(context, "backup");
552        builder.setContentTitle(context.getString(R.string.notification_backup_created_title))
553                .setContentText(
554                        context.getString(R.string.notification_backup_created_subtitle, path))
555                .setStyle(
556                        new NotificationCompat.BigTextStyle()
557                                .bigText(
558                                        context.getString(
559                                                R.string.notification_backup_created_subtitle,
560                                                path)))
561                .setAutoCancel(true)
562                .setSmallIcon(R.drawable.ic_archive_24dp);
563
564        builder.addAction(
565                R.drawable.ic_share_24dp,
566                context.getString(R.string.share_backup_files),
567                shareFilesIntent);
568        builder.setLocalOnly(true);
569        final var notificationManager = context.getSystemService(NotificationManager.class);
570        notificationManager.notify(BACKUP_CREATED_NOTIFICATION_ID, builder.build());
571    }
572
573    private static class Progress {
574        private final NotificationCompat.Builder notification;
575        private final int max;
576        private final int count;
577
578        private Progress(
579                final NotificationCompat.Builder notification, final int max, final int count) {
580            this.notification = notification;
581            this.max = max;
582            this.count = count;
583        }
584
585        private Notification build(int percentage) {
586            notification.setProgress(max * 100, count * 100 + percentage, false);
587            return notification.build();
588        }
589    }
590
591    private static class WorkStoppedException extends Exception {}
592}