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