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