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