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