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