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