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