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;
 16
 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.Optional;
 25import com.google.common.base.Strings;
 26import com.google.common.collect.ImmutableList;
 27import com.google.gson.stream.JsonWriter;
 28
 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;
 39
 40import java.io.DataOutputStream;
 41import java.io.File;
 42import java.io.FileOutputStream;
 43import java.io.IOException;
 44import java.io.OutputStreamWriter;
 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;
 59
 60import javax.crypto.Cipher;
 61import javax.crypto.CipherOutputStream;
 62import javax.crypto.NoSuchPaddingException;
 63import javax.crypto.SecretKeyFactory;
 64import javax.crypto.spec.IvParameterSpec;
 65import javax.crypto.spec.PBEKeySpec;
 66import javax.crypto.spec.SecretKeySpec;
 67
 68public class ExportBackupWorker extends Worker {
 69
 70    private static final SimpleDateFormat DATE_FORMAT =
 71            new SimpleDateFormat("yyyy-MM-dd-HH-mm", Locale.US);
 72
 73    public static final String KEYTYPE = "AES";
 74    public static final String CIPHERMODE = "AES/GCM/NoPadding";
 75    public static final String PROVIDER = "BC";
 76
 77    public static final String MIME_TYPE = "application/vnd.conversations.backup";
 78
 79    private static final int NOTIFICATION_ID = 19;
 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 encrypt",
166                                account.getJid().asBareJid()));
167                count++;
168                continue;
169            }
170            final String filename =
171                    String.format(
172                            "%s.%s.ceb",
173                            account.getJid().asBareJid().toEscapedString(),
174                            DATE_FORMAT.format(new Date()));
175            final File file = new File(FileBackend.getBackupDirectory(context), filename);
176            try {
177                export(database, account, password, file, max, count);
178            } catch (final WorkStoppedException e) {
179                if (file.delete()) {
180                    Log.d(
181                            Config.LOGTAG,
182                            "deleted in progress backup file " + file.getAbsolutePath());
183                }
184                Log.d(Config.LOGTAG, "ExportBackupWorker has stopped. Returning what we have");
185                return files.build();
186            }
187            files.add(file);
188            count++;
189        }
190        return files.build();
191    }
192
193    private void export(
194            final DatabaseBackend database,
195            final Account account,
196            final String password,
197            final File file,
198            final int max,
199            final int count)
200            throws IOException,
201                    InvalidKeySpecException,
202                    InvalidAlgorithmParameterException,
203                    InvalidKeyException,
204                    NoSuchPaddingException,
205                    NoSuchAlgorithmException,
206                    NoSuchProviderException,
207                    WorkStoppedException {
208        final var context = getApplicationContext();
209        final SecureRandom secureRandom = new SecureRandom();
210        Log.d(
211                Config.LOGTAG,
212                String.format(
213                        "exporting data for account %s (%s)",
214                        account.getJid().asBareJid(), account.getUuid()));
215        final byte[] IV = new byte[12];
216        final byte[] salt = new byte[16];
217        secureRandom.nextBytes(IV);
218        secureRandom.nextBytes(salt);
219        final BackupFileHeader backupFileHeader =
220                new BackupFileHeader(
221                        context.getString(R.string.app_name),
222                        account.getJid(),
223                        System.currentTimeMillis(),
224                        IV,
225                        salt);
226        final var notification = getNotification();
227        final var cancelPendingIntent =
228                WorkManager.getInstance(context).createCancelPendingIntent(getId());
229        notification.addAction(
230                new NotificationCompat.Action.Builder(
231                                R.drawable.ic_cancel_24dp,
232                                context.getString(R.string.cancel),
233                                cancelPendingIntent)
234                        .build());
235        final Progress progress = new Progress(notification, max, count);
236        final File directory = file.getParentFile();
237        if (directory != null && directory.mkdirs()) {
238            Log.d(Config.LOGTAG, "created backup directory " + directory.getAbsolutePath());
239        }
240        final FileOutputStream fileOutputStream = new FileOutputStream(file);
241        final DataOutputStream dataOutputStream = new DataOutputStream(fileOutputStream);
242        backupFileHeader.write(dataOutputStream);
243        dataOutputStream.flush();
244
245        final Cipher cipher =
246                Compatibility.twentyEight()
247                        ? Cipher.getInstance(CIPHERMODE)
248                        : Cipher.getInstance(CIPHERMODE, PROVIDER);
249        final byte[] key = getKey(password, salt);
250        SecretKeySpec keySpec = new SecretKeySpec(key, KEYTYPE);
251        IvParameterSpec ivSpec = new IvParameterSpec(IV);
252        cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
253        CipherOutputStream cipherOutputStream = new CipherOutputStream(fileOutputStream, cipher);
254
255        final GZIPOutputStream gzipOutputStream = new GZIPOutputStream(cipherOutputStream);
256        final JsonWriter jsonWriter =
257                new JsonWriter(new OutputStreamWriter(gzipOutputStream, StandardCharsets.UTF_8));
258        jsonWriter.beginArray();
259        final SQLiteDatabase db = database.getReadableDatabase();
260        final String uuid = account.getUuid();
261        accountExport(db, uuid, jsonWriter);
262        simpleExport(db, Conversation.TABLENAME, Conversation.ACCOUNT, uuid, jsonWriter);
263        messageExport(db, uuid, jsonWriter, 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, jsonWriter);
272        }
273        jsonWriter.endArray();
274        jsonWriter.flush();
275        jsonWriter.close();
276        mediaScannerScanFile(file);
277        Log.d(Config.LOGTAG, "written backup to " + file.getAbsoluteFile());
278    }
279
280    private NotificationCompat.Builder getNotification() {
281        final var context = getApplicationContext();
282        final NotificationCompat.Builder notification =
283                new NotificationCompat.Builder(context, "backup");
284        notification
285                .setContentTitle(context.getString(R.string.notification_create_backup_title))
286                .setSmallIcon(R.drawable.ic_archive_24dp)
287                .setProgress(1, 0, false);
288        notification.setOngoing(true);
289        notification.setLocalOnly(true);
290        return notification;
291    }
292
293    private void throwIfWorkStopped() throws WorkStoppedException {
294        if (isStopped()) {
295            throw new WorkStoppedException();
296        }
297    }
298
299    private void mediaScannerScanFile(final File file) {
300        final Intent intent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
301        intent.setData(Uri.fromFile(file));
302        getApplicationContext().sendBroadcast(intent);
303    }
304
305    private static void accountExport(
306            final SQLiteDatabase db, final String uuid, final JsonWriter writer)
307            throws IOException {
308        try (final Cursor accountCursor =
309                db.query(
310                        Account.TABLENAME,
311                        null,
312                        Account.UUID + "=?",
313                        new String[] {uuid},
314                        null,
315                        null,
316                        null)) {
317            while (accountCursor != null && accountCursor.moveToNext()) {
318                writer.beginObject();
319                writer.name("table");
320                writer.value(Account.TABLENAME);
321                writer.name("values");
322                writer.beginObject();
323                for (int i = 0; i < accountCursor.getColumnCount(); ++i) {
324                    final String name = accountCursor.getColumnName(i);
325                    writer.name(name);
326                    final String value = accountCursor.getString(i);
327                    if (value == null
328                            || Account.ROSTERVERSION.equals(accountCursor.getColumnName(i))) {
329                        writer.nullValue();
330                    } else if (Account.OPTIONS.equals(accountCursor.getColumnName(i))
331                            && value.matches("\\d+")) {
332                        int intValue = Integer.parseInt(value);
333                        intValue |= 1 << Account.OPTION_DISABLED;
334                        writer.value(intValue);
335                    } else {
336                        writer.value(value);
337                    }
338                }
339                writer.endObject();
340                writer.endObject();
341            }
342        }
343    }
344
345    private static void simpleExport(
346            final SQLiteDatabase db,
347            final String table,
348            final String column,
349            final String uuid,
350            final JsonWriter writer)
351            throws IOException {
352        try (final Cursor cursor =
353                db.query(table, null, column + "=?", new String[] {uuid}, null, null, null)) {
354            while (cursor != null && cursor.moveToNext()) {
355                writer.beginObject();
356                writer.name("table");
357                writer.value(table);
358                writer.name("values");
359                writer.beginObject();
360                for (int i = 0; i < cursor.getColumnCount(); ++i) {
361                    final String name = cursor.getColumnName(i);
362                    writer.name(name);
363                    final String value = cursor.getString(i);
364                    writer.value(value);
365                }
366                writer.endObject();
367                writer.endObject();
368            }
369        }
370    }
371
372    private void messageExport(
373            final SQLiteDatabase db,
374            final String uuid,
375            final JsonWriter writer,
376            final Progress progress)
377            throws IOException, WorkStoppedException {
378        final var notificationManager =
379                getApplicationContext().getSystemService(NotificationManager.class);
380        try (final Cursor cursor =
381                db.rawQuery(
382                        "select messages.* from messages join conversations on conversations.uuid=messages.conversationUuid where conversations.accountUuid=?",
383                        new String[] {uuid})) {
384            final int size = cursor != null ? cursor.getCount() : 0;
385            Log.d(Config.LOGTAG, "exporting " + size + " messages for account " + uuid);
386            long lastUpdate = 0;
387            int i = 0;
388            int p = Integer.MIN_VALUE;
389            while (cursor != null && cursor.moveToNext()) {
390                throwIfWorkStopped();
391                writer.beginObject();
392                writer.name("table");
393                writer.value(Message.TABLENAME);
394                writer.name("values");
395                writer.beginObject();
396                for (int j = 0; j < cursor.getColumnCount(); ++j) {
397                    final String name = cursor.getColumnName(j);
398                    writer.name(name);
399                    final String value = cursor.getString(j);
400                    writer.value(value);
401                }
402                writer.endObject();
403                writer.endObject();
404                final int percentage = i * 100 / size;
405                if (p < percentage && (SystemClock.elapsedRealtime() - lastUpdate) > 2_000) {
406                    p = percentage;
407                    lastUpdate = SystemClock.elapsedRealtime();
408                    notificationManager.notify(NOTIFICATION_ID, progress.build(p));
409                }
410                i++;
411            }
412        }
413    }
414
415    public static byte[] getKey(final String password, final byte[] salt)
416            throws InvalidKeySpecException {
417        final SecretKeyFactory factory;
418        try {
419            factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
420        } catch (NoSuchAlgorithmException e) {
421            throw new IllegalStateException(e);
422        }
423        return factory.generateSecret(new PBEKeySpec(password.toCharArray(), salt, 1024, 128))
424                .getEncoded();
425    }
426
427    private void notifySuccess(final List<File> files) {
428        final var context = getApplicationContext();
429        final String path = FileBackend.getBackupDirectory(context).getAbsolutePath();
430
431        final var openFolderIntent = getOpenFolderIntent(path);
432
433        final Intent intent = new Intent(Intent.ACTION_SEND_MULTIPLE);
434        final ArrayList<Uri> uris = new ArrayList<>();
435        for (final File file : files) {
436            uris.add(FileBackend.getUriForFile(context, file));
437        }
438        intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris);
439        intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
440        intent.setType(MIME_TYPE);
441        final Intent chooser =
442                Intent.createChooser(intent, context.getString(R.string.share_backup_files));
443        final var shareFilesIntent =
444                PendingIntent.getActivity(context, 190, chooser, PENDING_INTENT_FLAGS);
445
446        NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(context, "backup");
447        mBuilder.setContentTitle(context.getString(R.string.notification_backup_created_title))
448                .setContentText(
449                        context.getString(R.string.notification_backup_created_subtitle, path))
450                .setStyle(
451                        new NotificationCompat.BigTextStyle()
452                                .bigText(
453                                        context.getString(
454                                                R.string.notification_backup_created_subtitle,
455                                                FileBackend.getBackupDirectory(context)
456                                                        .getAbsolutePath())))
457                .setAutoCancel(true)
458                .setSmallIcon(R.drawable.ic_archive_24dp);
459
460        if (openFolderIntent.isPresent()) {
461            mBuilder.setContentIntent(openFolderIntent.get());
462        } else {
463            Log.w(Config.LOGTAG, "no app can display folders");
464        }
465
466        mBuilder.addAction(
467                R.drawable.ic_share_24dp,
468                context.getString(R.string.share_backup_files),
469                shareFilesIntent);
470        final var notificationManager = context.getSystemService(NotificationManager.class);
471        notificationManager.notify(BACKUP_CREATED_NOTIFICATION_ID, mBuilder.build());
472    }
473
474    private Optional<PendingIntent> getOpenFolderIntent(final String path) {
475        final var context = getApplicationContext();
476        for (final Intent intent : getPossibleFileOpenIntents(context, path)) {
477            if (intent.resolveActivityInfo(context.getPackageManager(), 0) != null) {
478                return Optional.of(
479                        PendingIntent.getActivity(context, 189, intent, PENDING_INTENT_FLAGS));
480            }
481        }
482        return Optional.absent();
483    }
484
485    private static List<Intent> getPossibleFileOpenIntents(
486            final Context context, final String path) {
487
488        // http://www.openintents.org/action/android-intent-action-view/file-directory
489        // do not use 'vnd.android.document/directory' since this will trigger system file manager
490        final Intent openIntent = new Intent(Intent.ACTION_VIEW);
491        openIntent.addCategory(Intent.CATEGORY_DEFAULT);
492        if (Compatibility.runsAndTargetsTwentyFour(context)) {
493            openIntent.setType("resource/folder");
494        } else {
495            openIntent.setDataAndType(Uri.parse("file://" + path), "resource/folder");
496        }
497        openIntent.putExtra("org.openintents.extra.ABSOLUTE_PATH", path);
498
499        final Intent amazeIntent = new Intent(Intent.ACTION_VIEW);
500        amazeIntent.setDataAndType(Uri.parse("com.amaze.filemanager:" + path), "resource/folder");
501
502        // will open a file manager at root and user can navigate themselves
503        final Intent systemFallBack = new Intent(Intent.ACTION_VIEW);
504        systemFallBack.addCategory(Intent.CATEGORY_DEFAULT);
505        systemFallBack.setData(
506                Uri.parse("content://com.android.externalstorage.documents/root/primary"));
507
508        return Arrays.asList(openIntent, amazeIntent, systemFallBack);
509    }
510
511    private static class Progress {
512        private final NotificationCompat.Builder notification;
513        private final int max;
514        private final int count;
515
516        private Progress(
517                final NotificationCompat.Builder notification, final int max, final int count) {
518            this.notification = notification;
519            this.max = max;
520            this.count = count;
521        }
522
523        private Notification build(int percentage) {
524            notification.setProgress(max * 100, count * 100 + percentage, false);
525            return notification.build();
526        }
527    }
528
529    private static class WorkStoppedException extends Exception {}
530}