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;
16
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.Optional;
25import com.google.common.base.Strings;
26import com.google.common.collect.ImmutableList;
27import com.google.gson.stream.JsonWriter;
28
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;
39
40import java.io.DataOutputStream;
41import java.io.File;
42import java.io.FileOutputStream;
43import java.io.IOException;
44import java.io.OutputStreamWriter;
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;
59
60import javax.crypto.Cipher;
61import javax.crypto.CipherOutputStream;
62import javax.crypto.NoSuchPaddingException;
63import javax.crypto.SecretKeyFactory;
64import javax.crypto.spec.IvParameterSpec;
65import javax.crypto.spec.PBEKeySpec;
66import javax.crypto.spec.SecretKeySpec;
67
68public class ExportBackupWorker extends Worker {
69
70 private static final SimpleDateFormat DATE_FORMAT =
71 new SimpleDateFormat("yyyy-MM-dd-HH-mm", Locale.US);
72
73 public static final String KEYTYPE = "AES";
74 public static final String CIPHERMODE = "AES/GCM/NoPadding";
75 public static final String PROVIDER = "BC";
76
77 public static final String MIME_TYPE = "application/vnd.conversations.backup";
78
79 private static final int NOTIFICATION_ID = 19;
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 encrypt",
166 account.getJid().asBareJid()));
167 count++;
168 continue;
169 }
170 final String filename =
171 String.format(
172 "%s.%s.ceb",
173 account.getJid().asBareJid().toEscapedString(),
174 DATE_FORMAT.format(new Date()));
175 final File file = new File(FileBackend.getBackupDirectory(context), filename);
176 try {
177 export(database, account, password, file, max, count);
178 } catch (final WorkStoppedException e) {
179 if (file.delete()) {
180 Log.d(
181 Config.LOGTAG,
182 "deleted in progress backup file " + file.getAbsolutePath());
183 }
184 Log.d(Config.LOGTAG, "ExportBackupWorker has stopped. Returning what we have");
185 return files.build();
186 }
187 files.add(file);
188 count++;
189 }
190 return files.build();
191 }
192
193 private void export(
194 final DatabaseBackend database,
195 final Account account,
196 final String password,
197 final File file,
198 final int max,
199 final int count)
200 throws IOException,
201 InvalidKeySpecException,
202 InvalidAlgorithmParameterException,
203 InvalidKeyException,
204 NoSuchPaddingException,
205 NoSuchAlgorithmException,
206 NoSuchProviderException,
207 WorkStoppedException {
208 final var context = getApplicationContext();
209 final SecureRandom secureRandom = new SecureRandom();
210 Log.d(
211 Config.LOGTAG,
212 String.format(
213 "exporting data for account %s (%s)",
214 account.getJid().asBareJid(), account.getUuid()));
215 final byte[] IV = new byte[12];
216 final byte[] salt = new byte[16];
217 secureRandom.nextBytes(IV);
218 secureRandom.nextBytes(salt);
219 final BackupFileHeader backupFileHeader =
220 new BackupFileHeader(
221 context.getString(R.string.app_name),
222 account.getJid(),
223 System.currentTimeMillis(),
224 IV,
225 salt);
226 final var notification = getNotification();
227 final var cancelPendingIntent =
228 WorkManager.getInstance(context).createCancelPendingIntent(getId());
229 notification.addAction(
230 new NotificationCompat.Action.Builder(
231 R.drawable.ic_cancel_24dp,
232 context.getString(R.string.cancel),
233 cancelPendingIntent)
234 .build());
235 final Progress progress = new Progress(notification, max, count);
236 final File directory = file.getParentFile();
237 if (directory != null && directory.mkdirs()) {
238 Log.d(Config.LOGTAG, "created backup directory " + directory.getAbsolutePath());
239 }
240 final FileOutputStream fileOutputStream = new FileOutputStream(file);
241 final DataOutputStream dataOutputStream = new DataOutputStream(fileOutputStream);
242 backupFileHeader.write(dataOutputStream);
243 dataOutputStream.flush();
244
245 final Cipher cipher =
246 Compatibility.twentyEight()
247 ? Cipher.getInstance(CIPHERMODE)
248 : Cipher.getInstance(CIPHERMODE, PROVIDER);
249 final byte[] key = getKey(password, salt);
250 SecretKeySpec keySpec = new SecretKeySpec(key, KEYTYPE);
251 IvParameterSpec ivSpec = new IvParameterSpec(IV);
252 cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
253 CipherOutputStream cipherOutputStream = new CipherOutputStream(fileOutputStream, cipher);
254
255 final GZIPOutputStream gzipOutputStream = new GZIPOutputStream(cipherOutputStream);
256 final JsonWriter jsonWriter =
257 new JsonWriter(new OutputStreamWriter(gzipOutputStream, StandardCharsets.UTF_8));
258 jsonWriter.beginArray();
259 final SQLiteDatabase db = database.getReadableDatabase();
260 final String uuid = account.getUuid();
261 accountExport(db, uuid, jsonWriter);
262 simpleExport(db, Conversation.TABLENAME, Conversation.ACCOUNT, uuid, jsonWriter);
263 messageExport(db, uuid, jsonWriter, 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, jsonWriter);
272 }
273 jsonWriter.endArray();
274 jsonWriter.flush();
275 jsonWriter.close();
276 mediaScannerScanFile(file);
277 Log.d(Config.LOGTAG, "written backup to " + file.getAbsoluteFile());
278 }
279
280 private NotificationCompat.Builder getNotification() {
281 final var context = getApplicationContext();
282 final NotificationCompat.Builder notification =
283 new NotificationCompat.Builder(context, "backup");
284 notification
285 .setContentTitle(context.getString(R.string.notification_create_backup_title))
286 .setSmallIcon(R.drawable.ic_archive_24dp)
287 .setProgress(1, 0, false);
288 notification.setOngoing(true);
289 notification.setLocalOnly(true);
290 return notification;
291 }
292
293 private void throwIfWorkStopped() throws WorkStoppedException {
294 if (isStopped()) {
295 throw new WorkStoppedException();
296 }
297 }
298
299 private void mediaScannerScanFile(final File file) {
300 final Intent intent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
301 intent.setData(Uri.fromFile(file));
302 getApplicationContext().sendBroadcast(intent);
303 }
304
305 private static void accountExport(
306 final SQLiteDatabase db, final String uuid, final JsonWriter writer)
307 throws IOException {
308 try (final Cursor accountCursor =
309 db.query(
310 Account.TABLENAME,
311 null,
312 Account.UUID + "=?",
313 new String[] {uuid},
314 null,
315 null,
316 null)) {
317 while (accountCursor != null && accountCursor.moveToNext()) {
318 writer.beginObject();
319 writer.name("table");
320 writer.value(Account.TABLENAME);
321 writer.name("values");
322 writer.beginObject();
323 for (int i = 0; i < accountCursor.getColumnCount(); ++i) {
324 final String name = accountCursor.getColumnName(i);
325 writer.name(name);
326 final String value = accountCursor.getString(i);
327 if (value == null
328 || Account.ROSTERVERSION.equals(accountCursor.getColumnName(i))) {
329 writer.nullValue();
330 } else if (Account.OPTIONS.equals(accountCursor.getColumnName(i))
331 && value.matches("\\d+")) {
332 int intValue = Integer.parseInt(value);
333 intValue |= 1 << Account.OPTION_DISABLED;
334 writer.value(intValue);
335 } else {
336 writer.value(value);
337 }
338 }
339 writer.endObject();
340 writer.endObject();
341 }
342 }
343 }
344
345 private static void simpleExport(
346 final SQLiteDatabase db,
347 final String table,
348 final String column,
349 final String uuid,
350 final JsonWriter writer)
351 throws IOException {
352 try (final Cursor cursor =
353 db.query(table, null, column + "=?", new String[] {uuid}, null, null, null)) {
354 while (cursor != null && cursor.moveToNext()) {
355 writer.beginObject();
356 writer.name("table");
357 writer.value(table);
358 writer.name("values");
359 writer.beginObject();
360 for (int i = 0; i < cursor.getColumnCount(); ++i) {
361 final String name = cursor.getColumnName(i);
362 writer.name(name);
363 final String value = cursor.getString(i);
364 writer.value(value);
365 }
366 writer.endObject();
367 writer.endObject();
368 }
369 }
370 }
371
372 private void messageExport(
373 final SQLiteDatabase db,
374 final String uuid,
375 final JsonWriter writer,
376 final Progress progress)
377 throws IOException, WorkStoppedException {
378 final var notificationManager =
379 getApplicationContext().getSystemService(NotificationManager.class);
380 try (final Cursor cursor =
381 db.rawQuery(
382 "select messages.* from messages join conversations on conversations.uuid=messages.conversationUuid where conversations.accountUuid=?",
383 new String[] {uuid})) {
384 final int size = cursor != null ? cursor.getCount() : 0;
385 Log.d(Config.LOGTAG, "exporting " + size + " messages for account " + uuid);
386 long lastUpdate = 0;
387 int i = 0;
388 int p = Integer.MIN_VALUE;
389 while (cursor != null && cursor.moveToNext()) {
390 throwIfWorkStopped();
391 writer.beginObject();
392 writer.name("table");
393 writer.value(Message.TABLENAME);
394 writer.name("values");
395 writer.beginObject();
396 for (int j = 0; j < cursor.getColumnCount(); ++j) {
397 final String name = cursor.getColumnName(j);
398 writer.name(name);
399 final String value = cursor.getString(j);
400 writer.value(value);
401 }
402 writer.endObject();
403 writer.endObject();
404 final int percentage = i * 100 / size;
405 if (p < percentage && (SystemClock.elapsedRealtime() - lastUpdate) > 2_000) {
406 p = percentage;
407 lastUpdate = SystemClock.elapsedRealtime();
408 notificationManager.notify(NOTIFICATION_ID, progress.build(p));
409 }
410 i++;
411 }
412 }
413 }
414
415 public static byte[] getKey(final String password, final byte[] salt)
416 throws InvalidKeySpecException {
417 final SecretKeyFactory factory;
418 try {
419 factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
420 } catch (NoSuchAlgorithmException e) {
421 throw new IllegalStateException(e);
422 }
423 return factory.generateSecret(new PBEKeySpec(password.toCharArray(), salt, 1024, 128))
424 .getEncoded();
425 }
426
427 private void notifySuccess(final List<File> files) {
428 final var context = getApplicationContext();
429 final String path = FileBackend.getBackupDirectory(context).getAbsolutePath();
430
431 final var openFolderIntent = getOpenFolderIntent(path);
432
433 final Intent intent = new Intent(Intent.ACTION_SEND_MULTIPLE);
434 final ArrayList<Uri> uris = new ArrayList<>();
435 for (final File file : files) {
436 uris.add(FileBackend.getUriForFile(context, file));
437 }
438 intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris);
439 intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
440 intent.setType(MIME_TYPE);
441 final Intent chooser =
442 Intent.createChooser(intent, context.getString(R.string.share_backup_files));
443 final var shareFilesIntent =
444 PendingIntent.getActivity(context, 190, chooser, PENDING_INTENT_FLAGS);
445
446 NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(context, "backup");
447 mBuilder.setContentTitle(context.getString(R.string.notification_backup_created_title))
448 .setContentText(
449 context.getString(R.string.notification_backup_created_subtitle, path))
450 .setStyle(
451 new NotificationCompat.BigTextStyle()
452 .bigText(
453 context.getString(
454 R.string.notification_backup_created_subtitle,
455 FileBackend.getBackupDirectory(context)
456 .getAbsolutePath())))
457 .setAutoCancel(true)
458 .setSmallIcon(R.drawable.ic_archive_24dp);
459
460 if (openFolderIntent.isPresent()) {
461 mBuilder.setContentIntent(openFolderIntent.get());
462 } else {
463 Log.w(Config.LOGTAG, "no app can display folders");
464 }
465
466 mBuilder.addAction(
467 R.drawable.ic_share_24dp,
468 context.getString(R.string.share_backup_files),
469 shareFilesIntent);
470 final var notificationManager = context.getSystemService(NotificationManager.class);
471 notificationManager.notify(BACKUP_CREATED_NOTIFICATION_ID, mBuilder.build());
472 }
473
474 private Optional<PendingIntent> getOpenFolderIntent(final String path) {
475 final var context = getApplicationContext();
476 for (final Intent intent : getPossibleFileOpenIntents(context, path)) {
477 if (intent.resolveActivityInfo(context.getPackageManager(), 0) != null) {
478 return Optional.of(
479 PendingIntent.getActivity(context, 189, intent, PENDING_INTENT_FLAGS));
480 }
481 }
482 return Optional.absent();
483 }
484
485 private static List<Intent> getPossibleFileOpenIntents(
486 final Context context, final String path) {
487
488 // http://www.openintents.org/action/android-intent-action-view/file-directory
489 // do not use 'vnd.android.document/directory' since this will trigger system file manager
490 final Intent openIntent = new Intent(Intent.ACTION_VIEW);
491 openIntent.addCategory(Intent.CATEGORY_DEFAULT);
492 if (Compatibility.runsAndTargetsTwentyFour(context)) {
493 openIntent.setType("resource/folder");
494 } else {
495 openIntent.setDataAndType(Uri.parse("file://" + path), "resource/folder");
496 }
497 openIntent.putExtra("org.openintents.extra.ABSOLUTE_PATH", path);
498
499 final Intent amazeIntent = new Intent(Intent.ACTION_VIEW);
500 amazeIntent.setDataAndType(Uri.parse("com.amaze.filemanager:" + path), "resource/folder");
501
502 // will open a file manager at root and user can navigate themselves
503 final Intent systemFallBack = new Intent(Intent.ACTION_VIEW);
504 systemFallBack.addCategory(Intent.CATEGORY_DEFAULT);
505 systemFallBack.setData(
506 Uri.parse("content://com.android.externalstorage.documents/root/primary"));
507
508 return Arrays.asList(openIntent, amazeIntent, systemFallBack);
509 }
510
511 private static class Progress {
512 private final NotificationCompat.Builder notification;
513 private final int max;
514 private final int count;
515
516 private Progress(
517 final NotificationCompat.Builder notification, final int max, final int count) {
518 this.notification = notification;
519 this.max = max;
520 this.count = count;
521 }
522
523 private Notification build(int percentage) {
524 notification.setProgress(max * 100, count * 100 + percentage, false);
525 return notification.build();
526 }
527 }
528
529 private static class WorkStoppedException extends Exception {}
530}