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.documentfile.provider.DocumentFile;
19import androidx.work.ForegroundInfo;
20import androidx.work.WorkManager;
21import androidx.work.Worker;
22import androidx.work.WorkerParameters;
23import com.google.common.base.Strings;
24import com.google.common.collect.ImmutableList;
25import com.google.gson.stream.JsonWriter;
26import eu.siacs.conversations.AppSettings;
27import eu.siacs.conversations.Config;
28import eu.siacs.conversations.R;
29import eu.siacs.conversations.crypto.axolotl.SQLiteAxolotlStore;
30import eu.siacs.conversations.entities.Account;
31import eu.siacs.conversations.entities.Conversation;
32import eu.siacs.conversations.entities.Message;
33import eu.siacs.conversations.persistance.DatabaseBackend;
34import eu.siacs.conversations.persistance.FileBackend;
35import eu.siacs.conversations.services.QuickConversationsService;
36import eu.siacs.conversations.utils.BackupFileHeader;
37import eu.siacs.conversations.utils.Compatibility;
38import java.io.DataOutputStream;
39import java.io.File;
40import java.io.FileOutputStream;
41import java.io.IOException;
42import java.io.OutputStream;
43import java.io.OutputStreamWriter;
44import java.nio.charset.StandardCharsets;
45import java.security.InvalidAlgorithmParameterException;
46import java.security.InvalidKeyException;
47import java.security.NoSuchAlgorithmException;
48import java.security.NoSuchProviderException;
49import java.security.SecureRandom;
50import java.security.spec.InvalidKeySpecException;
51import java.text.SimpleDateFormat;
52import java.util.ArrayList;
53import java.util.Arrays;
54import java.util.Date;
55import java.util.List;
56import java.util.Locale;
57import java.util.zip.GZIPOutputStream;
58import javax.crypto.Cipher;
59import javax.crypto.CipherOutputStream;
60import javax.crypto.NoSuchPaddingException;
61import javax.crypto.SecretKeyFactory;
62import javax.crypto.spec.IvParameterSpec;
63import javax.crypto.spec.PBEKeySpec;
64import javax.crypto.spec.SecretKeySpec;
65
66public class ExportBackupWorker extends Worker {
67
68 private static final SimpleDateFormat DATE_FORMAT =
69 new SimpleDateFormat("yyyy-MM-dd-HH-mm", Locale.US);
70
71 private static final String KEY_TYPE = "AES";
72 private static final String CIPHER_MODE = "AES/GCM/NoPadding";
73 private static final String PROVIDER = "BC";
74
75 public static final String MIME_TYPE = "application/vnd.conversations.backup";
76
77 private static final int NOTIFICATION_ID = 19;
78 private static final int BACKUP_CREATED_NOTIFICATION_ID = 23;
79
80 private static final int PENDING_INTENT_FLAGS =
81 s()
82 ? PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
83 : PendingIntent.FLAG_UPDATE_CURRENT;
84
85 private final boolean recurringBackup;
86
87 public ExportBackupWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
88 super(context, workerParams);
89 final var inputData = workerParams.getInputData();
90 this.recurringBackup = inputData.getBoolean("recurring_backup", false);
91 }
92
93 @NonNull
94 @Override
95 public Result doWork() {
96 setForegroundAsync(getForegroundInfo());
97 final List<Uri> files;
98 try {
99 files = export();
100 } catch (final IOException
101 | InvalidKeySpecException
102 | InvalidAlgorithmParameterException
103 | InvalidKeyException
104 | NoSuchPaddingException
105 | NoSuchAlgorithmException
106 | NoSuchProviderException e) {
107 Log.d(Config.LOGTAG, "could not create backup", e);
108 return Result.failure();
109 } finally {
110 getApplicationContext()
111 .getSystemService(NotificationManager.class)
112 .cancel(NOTIFICATION_ID);
113 }
114 Log.d(Config.LOGTAG, "done creating " + files.size() + " backup files");
115 if (files.isEmpty() || recurringBackup) {
116 return Result.success();
117 }
118 notifySuccess(files);
119 return Result.success();
120 }
121
122 @NonNull
123 @Override
124 public ForegroundInfo getForegroundInfo() {
125 Log.d(Config.LOGTAG, "getForegroundInfo()");
126 final NotificationCompat.Builder notification = getNotification();
127 if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
128 return new ForegroundInfo(
129 NOTIFICATION_ID,
130 notification.build(),
131 ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC);
132 } else {
133 return new ForegroundInfo(NOTIFICATION_ID, notification.build());
134 }
135 }
136
137 private List<Uri> export()
138 throws IOException,
139 InvalidKeySpecException,
140 InvalidAlgorithmParameterException,
141 InvalidKeyException,
142 NoSuchPaddingException,
143 NoSuchAlgorithmException,
144 NoSuchProviderException {
145 final Context context = getApplicationContext();
146 final var appSettings = new AppSettings(context);
147 final var backupLocation = appSettings.getBackupLocation();
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<Uri> locations = 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 locations.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"
166 + " encrypt",
167 account.getJid().asBareJid()));
168 count++;
169 continue;
170 }
171 final Uri uri;
172 try {
173 uri = export(database, account, password, backupLocation, max, count);
174 } catch (final WorkStoppedException e) {
175 Log.d(Config.LOGTAG, "ExportBackupWorker has stopped. Returning what we have");
176 return locations.build();
177 }
178 locations.add(uri);
179 count++;
180 }
181 return locations.build();
182 }
183
184 private Uri export(
185 final DatabaseBackend database,
186 final Account account,
187 final String password,
188 final Uri backupLocation,
189 final int max,
190 final int count)
191 throws IOException,
192 InvalidKeySpecException,
193 InvalidAlgorithmParameterException,
194 InvalidKeyException,
195 NoSuchPaddingException,
196 NoSuchAlgorithmException,
197 NoSuchProviderException,
198 WorkStoppedException {
199 final var context = getApplicationContext();
200 final SecureRandom secureRandom = new SecureRandom();
201 Log.d(
202 Config.LOGTAG,
203 String.format(
204 "exporting data for account %s (%s)",
205 account.getJid().asBareJid(), account.getUuid()));
206 final byte[] IV = new byte[12];
207 final byte[] salt = new byte[16];
208 secureRandom.nextBytes(IV);
209 secureRandom.nextBytes(salt);
210 final BackupFileHeader backupFileHeader =
211 new BackupFileHeader(
212 context.getString(R.string.app_name),
213 account.getJid(),
214 System.currentTimeMillis(),
215 IV,
216 salt);
217 final var notification = getNotification();
218 final var cancelPendingIntent =
219 WorkManager.getInstance(context).createCancelPendingIntent(getId());
220 notification.addAction(
221 new NotificationCompat.Action.Builder(
222 R.drawable.ic_cancel_24dp,
223 context.getString(R.string.cancel),
224 cancelPendingIntent)
225 .build());
226 final Progress progress = new Progress(notification, max, count);
227 final String filename =
228 String.format(
229 "%s.%s.ceb",
230 account.getJid().asBareJid().toString(), DATE_FORMAT.format(new Date()));
231 final OutputStream outputStream;
232 final Uri location;
233 if ("file".equalsIgnoreCase(backupLocation.getScheme())) {
234 final File file = new File(backupLocation.getPath(), filename);
235 final File directory = file.getParentFile();
236 if (directory != null && directory.mkdirs()) {
237 Log.d(Config.LOGTAG, "created backup directory " + directory.getAbsolutePath());
238 }
239 outputStream = new FileOutputStream(file);
240 location = Uri.fromFile(file);
241 } else {
242 final var tree = DocumentFile.fromTreeUri(context, backupLocation);
243 if (tree == null) {
244 throw new IOException(
245 String.format(
246 "DocumentFile.fromTreeUri returned null for %s", backupLocation));
247 }
248 final var file = tree.createFile(MIME_TYPE, filename);
249 if (file == null) {
250 throw new IOException(
251 String.format("Could not create %s in %s", filename, backupLocation));
252 }
253 location = file.getUri();
254 outputStream = context.getContentResolver().openOutputStream(location);
255 }
256 final DataOutputStream dataOutputStream = new DataOutputStream(outputStream);
257 backupFileHeader.write(dataOutputStream);
258 dataOutputStream.flush();
259
260 final Cipher cipher =
261 Compatibility.twentyEight()
262 ? Cipher.getInstance(CIPHER_MODE)
263 : Cipher.getInstance(CIPHER_MODE, PROVIDER);
264 final byte[] key = getKey(password, salt);
265 SecretKeySpec keySpec = new SecretKeySpec(key, KEY_TYPE);
266 IvParameterSpec ivSpec = new IvParameterSpec(IV);
267 cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
268 CipherOutputStream cipherOutputStream = new CipherOutputStream(outputStream, cipher);
269
270 final GZIPOutputStream gzipOutputStream = new GZIPOutputStream(cipherOutputStream);
271 final JsonWriter jsonWriter =
272 new JsonWriter(new OutputStreamWriter(gzipOutputStream, StandardCharsets.UTF_8));
273 jsonWriter.beginArray();
274 final SQLiteDatabase db = database.getReadableDatabase();
275 final String uuid = account.getUuid();
276 accountExport(db, uuid, jsonWriter);
277 simpleExport(db, Conversation.TABLENAME, Conversation.ACCOUNT, uuid, jsonWriter);
278 messageExport(db, uuid, location, jsonWriter, progress);
279 for (final String table :
280 Arrays.asList(
281 SQLiteAxolotlStore.PREKEY_TABLENAME,
282 SQLiteAxolotlStore.SIGNED_PREKEY_TABLENAME,
283 SQLiteAxolotlStore.SESSION_TABLENAME,
284 SQLiteAxolotlStore.IDENTITIES_TABLENAME)) {
285 throwIfWorkStopped(location);
286 simpleExport(db, table, SQLiteAxolotlStore.ACCOUNT, uuid, jsonWriter);
287 }
288 jsonWriter.endArray();
289 jsonWriter.flush();
290 jsonWriter.close();
291 if ("file".equalsIgnoreCase(location.getScheme())) {
292 mediaScannerScanFile(new File(location.getPath()));
293 }
294 Log.d(Config.LOGTAG, "written backup to " + location);
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 static void accountExport(
336 final SQLiteDatabase db, final String uuid, final JsonWriter writer)
337 throws IOException {
338 try (final Cursor accountCursor =
339 db.query(
340 Account.TABLENAME,
341 null,
342 Account.UUID + "=?",
343 new String[] {uuid},
344 null,
345 null,
346 null)) {
347 while (accountCursor != null && accountCursor.moveToNext()) {
348 writer.beginObject();
349 writer.name("table");
350 writer.value(Account.TABLENAME);
351 writer.name("values");
352 writer.beginObject();
353 for (int i = 0; i < accountCursor.getColumnCount(); ++i) {
354 final String name = accountCursor.getColumnName(i);
355 writer.name(name);
356 final String value = accountCursor.getString(i);
357 if (value == null
358 || Account.ROSTERVERSION.equals(accountCursor.getColumnName(i))) {
359 writer.nullValue();
360 } else if (Account.OPTIONS.equals(accountCursor.getColumnName(i))
361 && value.matches("\\d+")) {
362 int intValue = Integer.parseInt(value);
363 if (QuickConversationsService.isConversations()) {
364 intValue |= 1 << Account.OPTION_DISABLED;
365 }
366 writer.value(intValue);
367 } else {
368 writer.value(value);
369 }
370 }
371 writer.endObject();
372 writer.endObject();
373 }
374 }
375 }
376
377 private static void simpleExport(
378 final SQLiteDatabase db,
379 final String table,
380 final String column,
381 final String uuid,
382 final JsonWriter writer)
383 throws IOException {
384 try (final Cursor cursor =
385 db.query(table, null, column + "=?", new String[] {uuid}, null, null, null)) {
386 while (cursor != null && cursor.moveToNext()) {
387 writer.beginObject();
388 writer.name("table");
389 writer.value(table);
390 writer.name("values");
391 writer.beginObject();
392 for (int i = 0; i < cursor.getColumnCount(); ++i) {
393 final String name = cursor.getColumnName(i);
394 writer.name(name);
395 final String value = cursor.getString(i);
396 writer.value(value);
397 }
398 writer.endObject();
399 writer.endObject();
400 }
401 }
402 }
403
404 private void messageExport(
405 final SQLiteDatabase db,
406 final String uuid,
407 final Uri location,
408 final JsonWriter writer,
409 final Progress progress)
410 throws IOException, WorkStoppedException {
411 final var notificationManager =
412 getApplicationContext().getSystemService(NotificationManager.class);
413 try (final Cursor cursor =
414 db.rawQuery(
415 "select messages.* from messages join conversations on"
416 + " conversations.uuid=messages.conversationUuid where"
417 + " conversations.accountUuid=?",
418 new String[] {uuid})) {
419 final int size = cursor != null ? cursor.getCount() : 0;
420 Log.d(Config.LOGTAG, "exporting " + size + " messages for account " + uuid);
421 long lastUpdate = 0;
422 int i = 0;
423 int p = Integer.MIN_VALUE;
424 while (cursor != null && cursor.moveToNext()) {
425 throwIfWorkStopped(location);
426 writer.beginObject();
427 writer.name("table");
428 writer.value(Message.TABLENAME);
429 writer.name("values");
430 writer.beginObject();
431 for (int j = 0; j < cursor.getColumnCount(); ++j) {
432 final String name = cursor.getColumnName(j);
433 writer.name(name);
434 final String value = cursor.getString(j);
435 writer.value(value);
436 }
437 writer.endObject();
438 writer.endObject();
439 final int percentage = i * 100 / size;
440 if (p < percentage && (SystemClock.elapsedRealtime() - lastUpdate) > 2_000) {
441 p = percentage;
442 lastUpdate = SystemClock.elapsedRealtime();
443 notificationManager.notify(NOTIFICATION_ID, progress.build(p));
444 }
445 i++;
446 }
447 }
448 }
449
450 public static byte[] getKey(final String password, final byte[] salt)
451 throws InvalidKeySpecException {
452 final SecretKeyFactory factory;
453 try {
454 factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
455 } catch (NoSuchAlgorithmException e) {
456 throw new IllegalStateException(e);
457 }
458 return factory.generateSecret(new PBEKeySpec(password.toCharArray(), salt, 1024, 128))
459 .getEncoded();
460 }
461
462 private void notifySuccess(final List<Uri> locations) {
463 final var context = getApplicationContext();
464 final var appSettings = new AppSettings(context);
465 final String path = appSettings.getBackupLocationAsPath();
466 final Intent intent = new Intent(Intent.ACTION_SEND_MULTIPLE);
467 final ArrayList<Uri> uris = new ArrayList<>();
468 for (final Uri uri : locations) {
469 if ("file".equalsIgnoreCase(uri.getScheme())) {
470 uris.add(FileBackend.getUriForFile(context, new File(uri.getPath())));
471 } else {
472 uris.add(uri);
473 }
474 }
475 intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris);
476 intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
477 intent.setType(MIME_TYPE);
478 final Intent chooser =
479 Intent.createChooser(intent, context.getString(R.string.share_backup_files));
480 final var shareFilesIntent =
481 PendingIntent.getActivity(context, 190, chooser, PENDING_INTENT_FLAGS);
482
483 NotificationCompat.Builder builder = new NotificationCompat.Builder(context, "backup");
484 builder.setContentTitle(context.getString(R.string.notification_backup_created_title))
485 .setContentText(
486 context.getString(R.string.notification_backup_created_subtitle, path))
487 .setStyle(
488 new NotificationCompat.BigTextStyle()
489 .bigText(
490 context.getString(
491 R.string.notification_backup_created_subtitle,
492 path)))
493 .setAutoCancel(true)
494 .setSmallIcon(R.drawable.ic_archive_24dp);
495
496 builder.addAction(
497 R.drawable.ic_share_24dp,
498 context.getString(R.string.share_backup_files),
499 shareFilesIntent);
500 builder.setLocalOnly(true);
501 final var notificationManager = context.getSystemService(NotificationManager.class);
502 notificationManager.notify(BACKUP_CREATED_NOTIFICATION_ID, builder.build());
503 }
504
505 private static class Progress {
506 private final NotificationCompat.Builder notification;
507 private final int max;
508 private final int count;
509
510 private Progress(
511 final NotificationCompat.Builder notification, final int max, final int count) {
512 this.notification = notification;
513 this.max = max;
514 this.count = count;
515 }
516
517 private Notification build(int percentage) {
518 notification.setProgress(max * 100, count * 100 + percentage, false);
519 return notification.build();
520 }
521 }
522
523 private static class WorkStoppedException extends Exception {}
524}