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