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