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