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}