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