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