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