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