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