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