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.documentfile.provider.DocumentFile;
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;
30import eu.siacs.conversations.AppSettings;
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.services.QuickConversationsService;
40import eu.siacs.conversations.utils.BackupFileHeader;
41import eu.siacs.conversations.utils.Compatibility;
42import java.io.DataOutputStream;
43import java.io.File;
44import java.io.FileOutputStream;
45import java.io.IOException;
46import java.io.OutputStream;
47import java.io.OutputStreamWriter;
48import java.io.PrintWriter;
49import java.nio.charset.StandardCharsets;
50import java.security.InvalidAlgorithmParameterException;
51import java.security.InvalidKeyException;
52import java.security.NoSuchAlgorithmException;
53import java.security.NoSuchProviderException;
54import java.security.SecureRandom;
55import java.security.spec.InvalidKeySpecException;
56import java.text.SimpleDateFormat;
57import java.util.ArrayList;
58import java.util.Arrays;
59import java.util.Date;
60import java.util.List;
61import java.util.Locale;
62import java.util.zip.GZIPOutputStream;
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 private static final String KEY_TYPE = "AES";
77 private static final String CIPHER_MODE = "AES/GCM/NoPadding";
78 private 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<Uri> 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<Uri> export()
144 throws IOException,
145 InvalidKeySpecException,
146 InvalidAlgorithmParameterException,
147 InvalidKeyException,
148 NoSuchPaddingException,
149 NoSuchAlgorithmException,
150 NoSuchProviderException {
151 final Context context = getApplicationContext();
152 final var appSettings = new AppSettings(context);
153 final var backupLocation = appSettings.getBackupLocation();
154 final var database = DatabaseBackend.getInstance(context);
155 final var accounts = database.getAccounts();
156
157 int count = 0;
158 final int max = accounts.size();
159 final ImmutableList.Builder<Uri> locations = new ImmutableList.Builder<>();
160 Log.d(Config.LOGTAG, "starting backup for " + max + " accounts");
161 for (final Account account : accounts) {
162 if (isStopped()) {
163 Log.d(Config.LOGTAG, "ExportBackupWorker has stopped. Returning what we have");
164 return locations.build();
165 }
166 final String password = account.getPassword();
167 if (Strings.nullToEmpty(password).trim().isEmpty()) {
168 Log.d(
169 Config.LOGTAG,
170 String.format(
171 "skipping backup for %s because password is empty. unable to"
172 + " encrypt",
173 account.getJid().asBareJid()));
174 count++;
175 continue;
176 }
177 final Uri uri;
178 try {
179 uri = export(database, account, password, backupLocation, max, count);
180 } catch (final WorkStoppedException e) {
181 Log.d(Config.LOGTAG, "ExportBackupWorker has stopped. Returning what we have");
182 return locations.build();
183 }
184 locations.add(uri);
185 count++;
186 }
187 return locations.build();
188 }
189
190 private Uri export(
191 final DatabaseBackend database,
192 final Account account,
193 final String password,
194 final Uri backupLocation,
195 final int max,
196 final int count)
197 throws IOException,
198 InvalidKeySpecException,
199 InvalidAlgorithmParameterException,
200 InvalidKeyException,
201 NoSuchPaddingException,
202 NoSuchAlgorithmException,
203 NoSuchProviderException,
204 WorkStoppedException {
205 final var context = getApplicationContext();
206 final SecureRandom secureRandom = new SecureRandom();
207 Log.d(
208 Config.LOGTAG,
209 String.format(
210 "exporting data for account %s (%s)",
211 account.getJid().asBareJid(), account.getUuid()));
212 final byte[] IV = new byte[12];
213 final byte[] salt = new byte[16];
214 secureRandom.nextBytes(IV);
215 secureRandom.nextBytes(salt);
216 final BackupFileHeader backupFileHeader =
217 new BackupFileHeader(
218 context.getString(R.string.app_name),
219 account.getJid(),
220 System.currentTimeMillis(),
221 IV,
222 salt);
223 final var notification = getNotification();
224 final var cancelPendingIntent =
225 WorkManager.getInstance(context).createCancelPendingIntent(getId());
226 notification.addAction(
227 new NotificationCompat.Action.Builder(
228 R.drawable.ic_cancel_24dp,
229 context.getString(R.string.cancel),
230 cancelPendingIntent)
231 .build());
232 final Progress progress = new Progress(notification, max, count);
233 final String filename =
234 String.format(
235 "%s.%s.ceb",
236 account.getJid().asBareJid().toString(), DATE_FORMAT.format(new Date()));
237 final OutputStream outputStream;
238 final Uri location;
239 if ("file".equalsIgnoreCase(backupLocation.getScheme())) {
240 final File file = new File(backupLocation.getPath(), filename);
241 final File directory = file.getParentFile();
242 if (directory != null && directory.mkdirs()) {
243 Log.d(Config.LOGTAG, "created backup directory " + directory.getAbsolutePath());
244 }
245 outputStream = new FileOutputStream(file);
246 location = Uri.fromFile(file);
247 } else {
248 final var tree = DocumentFile.fromTreeUri(context, backupLocation);
249 if (tree == null) {
250 throw new IOException(
251 String.format(
252 "DocumentFile.fromTreeUri returned null for %s", backupLocation));
253 }
254 final var file = tree.createFile(MIME_TYPE, filename);
255 if (file == null) {
256 throw new IOException(
257 String.format("Could not create %s in %s", filename, backupLocation));
258 }
259 location = file.getUri();
260 outputStream = context.getContentResolver().openOutputStream(location);
261 }
262 final DataOutputStream dataOutputStream = new DataOutputStream(outputStream);
263 backupFileHeader.write(dataOutputStream);
264 dataOutputStream.flush();
265
266 final Cipher cipher =
267 Compatibility.twentyEight()
268 ? Cipher.getInstance(CIPHER_MODE)
269 : Cipher.getInstance(CIPHER_MODE, PROVIDER);
270 final byte[] key = getKey(password, salt);
271 SecretKeySpec keySpec = new SecretKeySpec(key, KEY_TYPE);
272 IvParameterSpec ivSpec = new IvParameterSpec(IV);
273 cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
274 CipherOutputStream cipherOutputStream = new CipherOutputStream(outputStream, cipher);
275
276 final GZIPOutputStream gzipOutputStream = new GZIPOutputStream(cipherOutputStream);
277 final SQLiteDatabase db = database.getReadableDatabase();
278 final var writer = new PrintWriter(gzipOutputStream);
279 final String uuid = account.getUuid();
280 accountExport(db, uuid, writer);
281 simpleExport(db, Conversation.TABLENAME, Conversation.ACCOUNT, uuid, writer);
282 messageExport(db, uuid, writer, progress);
283 messageExportCheogram(db, uuid, writer, progress);
284 for (final String table :
285 Arrays.asList(
286 SQLiteAxolotlStore.PREKEY_TABLENAME,
287 SQLiteAxolotlStore.SIGNED_PREKEY_TABLENAME,
288 SQLiteAxolotlStore.SESSION_TABLENAME,
289 SQLiteAxolotlStore.IDENTITIES_TABLENAME)) {
290 throwIfWorkStopped(location);
291 simpleExport(db, table, SQLiteAxolotlStore.ACCOUNT, uuid, writer);
292 }
293 writer.flush();
294 writer.close();
295 return location;
296 }
297
298 private NotificationCompat.Builder getNotification() {
299 final var context = getApplicationContext();
300 final NotificationCompat.Builder notification =
301 new NotificationCompat.Builder(context, "backup");
302 notification
303 .setContentTitle(context.getString(R.string.notification_create_backup_title))
304 .setSmallIcon(R.drawable.ic_archive_24dp)
305 .setProgress(1, 0, false);
306 notification.setOngoing(true);
307 notification.setLocalOnly(true);
308 return notification;
309 }
310
311 private void throwIfWorkStopped(final Uri location) throws WorkStoppedException {
312 if (isStopped()) {
313 if ("file".equalsIgnoreCase(location.getScheme())) {
314 final var file = new File(location.getPath());
315 if (file.delete()) {
316 Log.d(Config.LOGTAG, "deleted " + file.getAbsolutePath());
317 }
318 } else {
319 final var documentFile =
320 DocumentFile.fromSingleUri(getApplicationContext(), location);
321 if (documentFile != null && documentFile.delete()) {
322 Log.d(Config.LOGTAG, "deleted " + location);
323 }
324 }
325 throw new WorkStoppedException();
326 }
327 }
328
329 private void mediaScannerScanFile(final File file) {
330 final Intent intent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
331 intent.setData(Uri.fromFile(file));
332 getApplicationContext().sendBroadcast(intent);
333 }
334
335 private void messageExport(SQLiteDatabase db, String uuid, PrintWriter writer, Progress progress) {
336 final var notificationManager = getApplicationContext().getSystemService(NotificationManager.class);
337 Cursor cursor = db.rawQuery("select messages.* from messages 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 + " messages for account " + uuid);
340 int i = 0;
341 int p = 0;
342 while (cursor != null && cursor.moveToNext()) {
343 writer.write(cursorToString(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
360 private void messageExportCheogram(SQLiteDatabase db, String uuid, PrintWriter writer, Progress progress) {
361 final var notificationManager = getApplicationContext().getSystemService(NotificationManager.class);
362 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});
363 int size = cursor != null ? cursor.getCount() : 0;
364 Log.d(Config.LOGTAG, "exporting " + size + " cheogram messages for account " + uuid);
365 int i = 0;
366 int p = 0;
367 while (cursor != null && cursor.moveToNext()) {
368 writer.write(cursorToString("cheogram." + Message.TABLENAME, cursor, PAGE_SIZE, false));
369 if (i + PAGE_SIZE > size) {
370 i = size;
371 } else {
372 i += PAGE_SIZE;
373 }
374 final int percentage = i * 100 / size;
375 if (p < percentage) {
376 p = percentage;
377 notificationManager.notify(NOTIFICATION_ID, progress.build(p));
378 }
379 }
380 if (cursor != null) {
381 cursor.close();
382 }
383
384 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});
385 size = cursor != null ? cursor.getCount() : 0;
386 Log.d(Config.LOGTAG, "exporting " + size + " WebXDC updates for account " + uuid);
387 while (cursor != null && cursor.moveToNext()) {
388 writer.write(cursorToString("cheogram.webxdc_updates", cursor, PAGE_SIZE, false));
389 if (i + PAGE_SIZE > size) {
390 i = size;
391 } else {
392 i += PAGE_SIZE;
393 }
394 final int percentage = i * 100 / size;
395 if (p < percentage) {
396 p = percentage;
397 notificationManager.notify(NOTIFICATION_ID, progress.build(p));
398 }
399 }
400 if (cursor != null) {
401 cursor.close();
402 }
403 }
404
405 private static void accountExport(final SQLiteDatabase db, final String uuid, final PrintWriter writer) {
406 final StringBuilder builder = new StringBuilder();
407 final Cursor accountCursor = db.query(Account.TABLENAME, null, Account.UUID + "=?", new String[]{uuid}, null, null, null);
408 while (accountCursor != null && accountCursor.moveToNext()) {
409 builder.append("INSERT INTO ").append(Account.TABLENAME).append("(");
410 for (int i = 0; i < accountCursor.getColumnCount(); ++i) {
411 if (i != 0) {
412 builder.append(',');
413 }
414 builder.append(accountCursor.getColumnName(i));
415 }
416 builder.append(") VALUES(");
417 for (int i = 0; i < accountCursor.getColumnCount(); ++i) {
418 if (i != 0) {
419 builder.append(',');
420 }
421 final String value = accountCursor.getString(i);
422 if (value == null || Account.ROSTERVERSION.equals(accountCursor.getColumnName(i))) {
423 builder.append("NULL");
424 } else if (Account.OPTIONS.equals(accountCursor.getColumnName(i)) && value.matches("\\d+")) {
425 int intValue = Integer.parseInt(value);
426 intValue |= 1 << Account.OPTION_DISABLED;
427 builder.append(intValue);
428 } else {
429 appendEscapedSQLString(builder, value);
430 }
431 }
432 builder.append(")");
433 builder.append(';');
434 builder.append('\n');
435 }
436 if (accountCursor != null) {
437 accountCursor.close();
438 }
439 writer.append(builder.toString());
440 }
441
442 private static void simpleExport(SQLiteDatabase db, String table, String column, String uuid, PrintWriter writer) {
443 final Cursor cursor = db.query(table, null, column + "=?", new String[]{uuid}, null, null, null);
444 while (cursor != null && cursor.moveToNext()) {
445 writer.write(cursorToString(table, cursor, PAGE_SIZE));
446 }
447 if (cursor != null) {
448 cursor.close();
449 }
450 }
451
452 private static String cursorToString(final String table, final Cursor cursor, final int max) {
453 return cursorToString(table, cursor, max, false);
454 }
455
456 private static String cursorToString(final String table, final Cursor cursor, int max, boolean ignore) {
457 final boolean identities = SQLiteAxolotlStore.IDENTITIES_TABLENAME.equals(table);
458 StringBuilder builder = new StringBuilder();
459 builder.append("INSERT ");
460 if (ignore) {
461 builder.append("OR IGNORE ");
462 }
463 builder.append("INTO ").append(table).append("(");
464 int skipColumn = -1;
465 for (int i = 0; i < cursor.getColumnCount(); ++i) {
466 final String name = cursor.getColumnName(i);
467 if (identities && SQLiteAxolotlStore.TRUSTED.equals(name)) {
468 skipColumn = i;
469 continue;
470 }
471 if (i != 0) {
472 builder.append(',');
473 }
474 builder.append(name);
475 }
476 builder.append(") VALUES");
477 for (int i = 0; i < max; ++i) {
478 if (i != 0) {
479 builder.append(',');
480 }
481 appendValues(cursor, builder, skipColumn);
482 if (i < max - 1 && !cursor.moveToNext()) {
483 break;
484 }
485 }
486 builder.append(';');
487 builder.append('\n');
488 return builder.toString();
489 }
490
491 private static void appendValues(final Cursor cursor, final StringBuilder builder, final int skipColumn) {
492 builder.append("(");
493 for (int i = 0; i < cursor.getColumnCount(); ++i) {
494 if (i == skipColumn) {
495 continue;
496 }
497 if (i != 0) {
498 builder.append(',');
499 }
500 final String value = cursor.getString(i);
501 if (value == null) {
502 builder.append("NULL");
503 } else if (value.matches("[0-9]+")) {
504 builder.append(value);
505 } else {
506 appendEscapedSQLString(builder, value);
507 }
508 }
509 builder.append(")");
510
511 }
512
513 private static void appendEscapedSQLString(final StringBuilder sb, final String sqlString) {
514 DatabaseUtils.appendEscapedSQLString(sb, CharMatcher.is('\u0000').removeFrom(sqlString));
515 }
516
517 public static byte[] getKey(final String password, final byte[] salt)
518 throws InvalidKeySpecException {
519 final SecretKeyFactory factory;
520 try {
521 factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
522 } catch (NoSuchAlgorithmException e) {
523 throw new IllegalStateException(e);
524 }
525 return factory.generateSecret(new PBEKeySpec(password.toCharArray(), salt, 1024, 128))
526 .getEncoded();
527 }
528
529 private void notifySuccess(final List<Uri> locations) {
530 final var context = getApplicationContext();
531 final var appSettings = new AppSettings(context);
532 final String path = appSettings.getBackupLocationAsPath();
533 final Intent intent = new Intent(Intent.ACTION_SEND_MULTIPLE);
534 final ArrayList<Uri> uris = new ArrayList<>();
535 for (final Uri uri : locations) {
536 if ("file".equalsIgnoreCase(uri.getScheme())) {
537 final var file = new File(uri.getPath());
538 uris.add(FileBackend.getUriForFile(context, file, file.getName()));
539 } else {
540 uris.add(uri);
541 }
542 }
543 intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris);
544 intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
545 intent.setType(MIME_TYPE);
546 final Intent chooser =
547 Intent.createChooser(intent, context.getString(R.string.share_backup_files));
548 final var shareFilesIntent =
549 PendingIntent.getActivity(context, 190, chooser, PENDING_INTENT_FLAGS);
550
551 NotificationCompat.Builder builder = new NotificationCompat.Builder(context, "backup");
552 builder.setContentTitle(context.getString(R.string.notification_backup_created_title))
553 .setContentText(
554 context.getString(R.string.notification_backup_created_subtitle, path))
555 .setStyle(
556 new NotificationCompat.BigTextStyle()
557 .bigText(
558 context.getString(
559 R.string.notification_backup_created_subtitle,
560 path)))
561 .setAutoCancel(true)
562 .setSmallIcon(R.drawable.ic_archive_24dp);
563
564 builder.addAction(
565 R.drawable.ic_share_24dp,
566 context.getString(R.string.share_backup_files),
567 shareFilesIntent);
568 builder.setLocalOnly(true);
569 final var notificationManager = context.getSystemService(NotificationManager.class);
570 notificationManager.notify(BACKUP_CREATED_NOTIFICATION_ID, builder.build());
571 }
572
573 private static class Progress {
574 private final NotificationCompat.Builder notification;
575 private final int max;
576 private final int count;
577
578 private Progress(
579 final NotificationCompat.Builder notification, final int max, final int count) {
580 this.notification = notification;
581 this.max = max;
582 this.count = count;
583 }
584
585 private Notification build(int percentage) {
586 notification.setProgress(max * 100, count * 100 + percentage, false);
587 return notification.build();
588 }
589 }
590
591 private static class WorkStoppedException extends Exception {}
592}