1package eu.siacs.conversations.services;
2
3import android.app.Notification;
4import android.app.NotificationManager;
5import android.app.Service;
6import android.content.Context;
7import android.content.Intent;
8import android.database.Cursor;
9import android.database.DatabaseUtils;
10import android.database.sqlite.SQLiteDatabase;
11import android.os.IBinder;
12import android.support.v4.app.NotificationCompat;
13import android.util.Log;
14
15import java.io.DataOutputStream;
16import java.io.File;
17import java.io.FileOutputStream;
18import java.io.PrintWriter;
19import java.security.NoSuchAlgorithmException;
20import java.security.SecureRandom;
21import java.security.spec.InvalidKeySpecException;
22import java.util.Arrays;
23import java.util.List;
24import java.util.concurrent.atomic.AtomicBoolean;
25import java.util.zip.GZIPOutputStream;
26
27import javax.crypto.Cipher;
28import javax.crypto.CipherOutputStream;
29import javax.crypto.SecretKeyFactory;
30import javax.crypto.spec.IvParameterSpec;
31import javax.crypto.spec.PBEKeySpec;
32import javax.crypto.spec.SecretKeySpec;
33
34import eu.siacs.conversations.Config;
35import eu.siacs.conversations.R;
36import eu.siacs.conversations.crypto.axolotl.SQLiteAxolotlStore;
37import eu.siacs.conversations.entities.Account;
38import eu.siacs.conversations.entities.Conversation;
39import eu.siacs.conversations.entities.Message;
40import eu.siacs.conversations.persistance.DatabaseBackend;
41import eu.siacs.conversations.persistance.FileBackend;
42import eu.siacs.conversations.utils.BackupFileHeader;
43import eu.siacs.conversations.utils.Compatibility;
44
45public class ExportBackupService extends Service {
46
47 public static final String KEYTYPE = "AES";
48 public static final String CIPHERMODE = "AES/GCM/NoPadding";
49 public static final String PROVIDER = "BC";
50
51 private static final int NOTIFICATION_ID = 19;
52 private static final int PAGE_SIZE = 20;
53 private static AtomicBoolean running = new AtomicBoolean(false);
54 private DatabaseBackend mDatabaseBackend;
55 private List<Account> mAccounts;
56 private NotificationManager notificationManager;
57
58 private static void accountExport(SQLiteDatabase db, String uuid, PrintWriter writer) {
59 final StringBuilder builder = new StringBuilder();
60 final Cursor accountCursor = db.query(Account.TABLENAME, null, Account.UUID + "=?", new String[]{uuid}, null, null, null);
61 while (accountCursor != null && accountCursor.moveToNext()) {
62 builder.append("INSERT INTO ").append(Account.TABLENAME).append("(");
63 for (int i = 0; i < accountCursor.getColumnCount(); ++i) {
64 if (i != 0) {
65 builder.append(',');
66 }
67 builder.append(accountCursor.getColumnName(i));
68 }
69 builder.append(") VALUES(");
70 for (int i = 0; i < accountCursor.getColumnCount(); ++i) {
71 if (i != 0) {
72 builder.append(',');
73 }
74 final String value = accountCursor.getString(i);
75 if (value == null || Account.ROSTERVERSION.equals(accountCursor.getColumnName(i))) {
76 builder.append("NULL");
77 } else if (value.matches("\\d+")) {
78 int intValue = Integer.parseInt(value);
79 if (Account.OPTIONS.equals(accountCursor.getColumnName(i))) {
80 intValue |= 1 << Account.OPTION_DISABLED;
81 }
82 builder.append(intValue);
83 } else {
84 DatabaseUtils.appendEscapedSQLString(builder, value);
85 }
86 }
87 builder.append(")");
88 builder.append(';');
89 builder.append('\n');
90 }
91 if (accountCursor != null) {
92 accountCursor.close();
93 }
94 writer.append(builder.toString());
95 }
96
97 private static void simpleExport(SQLiteDatabase db, String table, String column, String uuid, PrintWriter writer) {
98 final Cursor cursor = db.query(table, null, column + "=?", new String[]{uuid}, null, null, null);
99 while (cursor != null && cursor.moveToNext()) {
100 writer.write(cursorToString(table, cursor, PAGE_SIZE));
101 }
102 if (cursor != null) {
103 cursor.close();
104 }
105 }
106
107 public static byte[] getKey(String password, byte[] salt) {
108 try {
109 SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
110 return factory.generateSecret(new PBEKeySpec(password.toCharArray(), salt, 1024, 128)).getEncoded();
111 } catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
112 throw new AssertionError(e);
113 }
114 }
115
116 private static String cursorToString(String tablename, Cursor cursor, int max) {
117 return cursorToString(tablename, cursor, max, false);
118 }
119
120 private static String cursorToString(String tablename, Cursor cursor, int max, boolean ignore) {
121 StringBuilder builder = new StringBuilder();
122 builder.append("INSERT ");
123 if (ignore) {
124 builder.append("OR IGNORE ");
125 }
126 builder.append("INTO ").append(tablename).append("(");
127 for (int i = 0; i < cursor.getColumnCount(); ++i) {
128 if (i != 0) {
129 builder.append(',');
130 }
131 builder.append(cursor.getColumnName(i));
132 }
133 builder.append(") VALUES");
134 for (int i = 0; i < max; ++i) {
135 if (i != 0) {
136 builder.append(',');
137 }
138 appendValues(cursor, builder);
139 if (i < max - 1 && !cursor.moveToNext()) {
140 break;
141 }
142 }
143 builder.append(';');
144 builder.append('\n');
145 return builder.toString();
146 }
147
148 private static void appendValues(Cursor cursor, StringBuilder builder) {
149 builder.append("(");
150 for (int i = 0; i < cursor.getColumnCount(); ++i) {
151 if (i != 0) {
152 builder.append(',');
153 }
154 final String value = cursor.getString(i);
155 if (value == null) {
156 builder.append("NULL");
157 } else if (value.matches("\\d+")) {
158 builder.append(value);
159 } else {
160 DatabaseUtils.appendEscapedSQLString(builder, value);
161 }
162 }
163 builder.append(")");
164
165 }
166
167 @Override
168 public void onCreate() {
169 mDatabaseBackend = DatabaseBackend.getInstance(getBaseContext());
170 mAccounts = mDatabaseBackend.getAccounts();
171 notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
172 }
173
174 @Override
175 public int onStartCommand(Intent intent, int flags, int startId) {
176 if (running.compareAndSet(false, true)) {
177 new Thread(() -> {
178 export();
179 stopForeground(true);
180 running.set(false);
181 stopSelf();
182 }).start();
183 return START_STICKY;
184 }
185 return START_NOT_STICKY;
186 }
187
188 private void messageExport(SQLiteDatabase db, String uuid, PrintWriter writer, Progress progress) {
189 Cursor cursor = db.rawQuery("select messages.* from messages join conversations on conversations.uuid=messages.conversationUuid where conversations.accountUuid=?", new String[]{uuid});
190 int size = cursor != null ? cursor.getCount() : 0;
191 Log.d(Config.LOGTAG, "exporting " + size + " messages");
192 int i = 0;
193 int p = 0;
194 while (cursor != null && cursor.moveToNext()) {
195 writer.write(cursorToString(Message.TABLENAME, cursor, PAGE_SIZE, false));
196 if (i + PAGE_SIZE > size) {
197 i = size;
198 } else {
199 i += PAGE_SIZE;
200 }
201 final int percentage = i * 100 / size;
202 if (p < percentage) {
203 p = percentage;
204 notificationManager.notify(NOTIFICATION_ID, progress.build(p));
205 }
206 }
207 if (cursor != null) {
208 cursor.close();
209 }
210 }
211
212 private void export() {
213 NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(getBaseContext(), "backup");
214 mBuilder.setContentTitle(getString(R.string.notification_create_backup_title))
215 .setSmallIcon(R.drawable.ic_archive_white_24dp)
216 .setProgress(1, 0, false);
217 startForeground(NOTIFICATION_ID, mBuilder.build());
218 try {
219 int count = 0;
220 final int max = this.mAccounts.size();
221 final SecureRandom secureRandom = new SecureRandom();
222 for (Account account : this.mAccounts) {
223 final byte[] IV = new byte[12];
224 final byte[] salt = new byte[16];
225 secureRandom.nextBytes(IV);
226 secureRandom.nextBytes(salt);
227 final BackupFileHeader backupFileHeader = new BackupFileHeader(getString(R.string.app_name), account.getJid(), System.currentTimeMillis(), IV, salt);
228 final Progress progress = new Progress(mBuilder, max, count);
229 final File file = new File(FileBackend.getBackupDirectory(this) + account.getJid().asBareJid().toEscapedString() + ".ceb");
230 if (file.getParentFile().mkdirs()) {
231 Log.d(Config.LOGTAG, "created backup directory " + file.getParentFile().getAbsolutePath());
232 }
233 final FileOutputStream fileOutputStream = new FileOutputStream(file);
234 final DataOutputStream dataOutputStream = new DataOutputStream(fileOutputStream);
235 backupFileHeader.write(dataOutputStream);
236 dataOutputStream.flush();
237
238 final Cipher cipher = Compatibility.twentyEight() ? Cipher.getInstance(CIPHERMODE) : Cipher.getInstance(CIPHERMODE, PROVIDER);
239 byte[] key = getKey(account.getPassword(), salt);
240 Log.d(Config.LOGTAG, backupFileHeader.toString());
241 SecretKeySpec keySpec = new SecretKeySpec(key, KEYTYPE);
242 IvParameterSpec ivSpec = new IvParameterSpec(IV);
243 cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
244 CipherOutputStream cipherOutputStream = new CipherOutputStream(fileOutputStream, cipher);
245
246 GZIPOutputStream gzipOutputStream = new GZIPOutputStream(cipherOutputStream);
247 PrintWriter writer = new PrintWriter(gzipOutputStream);
248 SQLiteDatabase db = this.mDatabaseBackend.getReadableDatabase();
249 final String uuid = account.getUuid();
250 accountExport(db, uuid, writer);
251 simpleExport(db, Conversation.TABLENAME, Conversation.ACCOUNT, uuid, writer);
252 messageExport(db, uuid, writer, progress);
253 for (String table : Arrays.asList(SQLiteAxolotlStore.PREKEY_TABLENAME, SQLiteAxolotlStore.SIGNED_PREKEY_TABLENAME, SQLiteAxolotlStore.SESSION_TABLENAME, SQLiteAxolotlStore.IDENTITIES_TABLENAME)) {
254 simpleExport(db, table, SQLiteAxolotlStore.ACCOUNT, uuid, writer);
255 }
256 writer.flush();
257 writer.close();
258 Log.d(Config.LOGTAG, "written backup to " + file.getAbsoluteFile());
259 count++;
260 }
261 } catch (Exception e) {
262 Log.d(Config.LOGTAG, "unable to create backup ", e);
263 }
264 }
265
266 @Override
267 public IBinder onBind(Intent intent) {
268 return null;
269 }
270
271 private class Progress {
272 private final NotificationCompat.Builder builder;
273 private final int max;
274 private final int count;
275
276 private Progress(NotificationCompat.Builder builder, int max, int count) {
277 this.builder = builder;
278 this.max = max;
279 this.count = count;
280 }
281
282 private Notification build(int percentage) {
283 builder.setProgress(max * 100, count * 100 + percentage, false);
284 return builder.build();
285 }
286 }
287}