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