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