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