1package eu.siacs.conversations.services;
2
3import static eu.siacs.conversations.utils.Compatibility.s;
4
5import android.app.Notification;
6import android.app.NotificationManager;
7import android.app.PendingIntent;
8import android.app.Service;
9import android.content.ContentValues;
10import android.content.Context;
11import android.content.Intent;
12import android.database.Cursor;
13import android.database.sqlite.SQLiteDatabase;
14import android.net.Uri;
15import android.os.Binder;
16import android.os.IBinder;
17import android.provider.OpenableColumns;
18import android.util.Log;
19
20import androidx.core.app.NotificationCompat;
21import androidx.core.app.NotificationManagerCompat;
22
23import com.google.common.base.Charsets;
24import com.google.common.base.Stopwatch;
25import com.google.common.io.CountingInputStream;
26import com.google.gson.stream.JsonReader;
27import com.google.gson.stream.JsonToken;
28
29import eu.siacs.conversations.Config;
30import eu.siacs.conversations.R;
31import eu.siacs.conversations.crypto.axolotl.SQLiteAxolotlStore;
32import eu.siacs.conversations.entities.Account;
33import eu.siacs.conversations.entities.Conversation;
34import eu.siacs.conversations.entities.Message;
35import eu.siacs.conversations.persistance.DatabaseBackend;
36import eu.siacs.conversations.persistance.FileBackend;
37import eu.siacs.conversations.ui.ManageAccountActivity;
38import eu.siacs.conversations.utils.BackupFileHeader;
39import eu.siacs.conversations.utils.SerialSingleThreadExecutor;
40import eu.siacs.conversations.xmpp.Jid;
41
42import org.bouncycastle.crypto.engines.AESEngine;
43import org.bouncycastle.crypto.io.CipherInputStream;
44import org.bouncycastle.crypto.modes.AEADBlockCipher;
45import org.bouncycastle.crypto.modes.GCMBlockCipher;
46import org.bouncycastle.crypto.params.AEADParameters;
47import org.bouncycastle.crypto.params.KeyParameter;
48
49import java.io.BufferedReader;
50import java.io.DataInputStream;
51import java.io.File;
52import java.io.FileInputStream;
53import java.io.FileNotFoundException;
54import java.io.IOException;
55import java.io.InputStream;
56import java.io.InputStreamReader;
57import java.util.ArrayList;
58import java.util.Arrays;
59import java.util.Collection;
60import java.util.Collections;
61import java.util.HashSet;
62import java.util.List;
63import java.util.Set;
64import java.util.WeakHashMap;
65import java.util.concurrent.atomic.AtomicBoolean;
66import java.util.regex.Pattern;
67import java.util.zip.GZIPInputStream;
68import java.util.zip.ZipException;
69
70import javax.crypto.BadPaddingException;
71
72public class ImportBackupService extends Service {
73
74 private static final int NOTIFICATION_ID = 21;
75 private static final AtomicBoolean running = new AtomicBoolean(false);
76 private final ImportBackupServiceBinder binder = new ImportBackupServiceBinder();
77 private final SerialSingleThreadExecutor executor =
78 new SerialSingleThreadExecutor(getClass().getSimpleName());
79 private final Set<OnBackupProcessed> mOnBackupProcessedListeners =
80 Collections.newSetFromMap(new WeakHashMap<>());
81 private DatabaseBackend mDatabaseBackend;
82 private NotificationManager notificationManager;
83
84 private static final Collection<String> TABLE_ALLOW_LIST =
85 Arrays.asList(
86 Account.TABLENAME,
87 Conversation.TABLENAME,
88 Message.TABLENAME,
89 SQLiteAxolotlStore.PREKEY_TABLENAME,
90 SQLiteAxolotlStore.SIGNED_PREKEY_TABLENAME,
91 SQLiteAxolotlStore.SESSION_TABLENAME,
92 SQLiteAxolotlStore.IDENTITIES_TABLENAME);
93 private static final Pattern COLUMN_PATTERN = Pattern.compile("^[a-zA-Z_]+$");
94
95 @Override
96 public void onCreate() {
97 mDatabaseBackend = DatabaseBackend.getInstance(getBaseContext());
98 notificationManager =
99 (android.app.NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
100 }
101
102 @Override
103 public int onStartCommand(Intent intent, int flags, int startId) {
104 if (intent == null) {
105 return START_NOT_STICKY;
106 }
107 final String password = intent.getStringExtra("password");
108 final Uri data = intent.getData();
109 final Uri uri;
110 if (data == null) {
111 final String file = intent.getStringExtra("file");
112 uri = file == null ? null : Uri.fromFile(new File(file));
113 } else {
114 uri = data;
115 }
116
117 if (password == null || password.isEmpty() || uri == null) {
118 return START_NOT_STICKY;
119 }
120 if (running.compareAndSet(false, true)) {
121 executor.execute(
122 () -> {
123 startForegroundService();
124 final boolean success = importBackup(uri, password);
125 stopForeground(true);
126 running.set(false);
127 if (success) {
128 notifySuccess();
129 }
130 stopSelf();
131 });
132 } else {
133 Log.d(Config.LOGTAG, "backup already running");
134 }
135 return START_NOT_STICKY;
136 }
137
138 public boolean getLoadingState() {
139 return running.get();
140 }
141
142 public void loadBackupFiles(final OnBackupFilesLoaded onBackupFilesLoaded) {
143 executor.execute(
144 () -> {
145 final List<Jid> accounts = mDatabaseBackend.getAccountJids(false);
146 final ArrayList<BackupFile> backupFiles = new ArrayList<>();
147 final Set<String> apps =
148 new HashSet<>(
149 Arrays.asList(
150 "Conversations",
151 "Quicksy",
152 getString(R.string.app_name)));
153 final List<File> directories = new ArrayList<>();
154 for (final String app : apps) {
155 directories.add(FileBackend.getLegacyBackupDirectory(app));
156 }
157 directories.add(FileBackend.getBackupDirectory(this));
158 for (final File directory : directories) {
159 if (!directory.exists() || !directory.isDirectory()) {
160 Log.d(
161 Config.LOGTAG,
162 "directory not found: " + directory.getAbsolutePath());
163 continue;
164 }
165 final File[] files = directory.listFiles();
166 if (files == null) {
167 continue;
168 }
169 Log.d(Config.LOGTAG, "looking for backups in " + directory);
170 for (final File file : files) {
171 if (file.isFile() && file.getName().endsWith(".ceb")) {
172 try {
173 final BackupFile backupFile = BackupFile.read(file);
174 if (accounts.contains(backupFile.getHeader().getJid())) {
175 Log.d(
176 Config.LOGTAG,
177 "skipping backup for "
178 + backupFile.getHeader().getJid());
179 } else {
180 backupFiles.add(backupFile);
181 }
182 } catch (final IOException
183 | IllegalArgumentException
184 | BackupFileHeader.OutdatedBackupFileVersion e) {
185 Log.d(Config.LOGTAG, "unable to read backup file ", e);
186 }
187 }
188 }
189 }
190 Collections.sort(
191 backupFiles,
192 (a, b) ->
193 a.header
194 .getJid()
195 .toString()
196 .compareTo(b.header.getJid().toString()));
197 onBackupFilesLoaded.onBackupFilesLoaded(backupFiles);
198 });
199 }
200
201 private void startForegroundService() {
202 startForeground(NOTIFICATION_ID, createImportBackupNotification(1, 0));
203 }
204
205 private void updateImportBackupNotification(final long total, final long current) {
206 final int max;
207 final int progress;
208 if (total == 0) {
209 max = 1;
210 progress = 0;
211 } else {
212 max = 100;
213 progress = (int) (current * 100 / total);
214 }
215 final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this);
216 try {
217 notificationManager.notify(
218 NOTIFICATION_ID, createImportBackupNotification(max, progress));
219 } catch (final RuntimeException e) {
220 Log.d(Config.LOGTAG, "unable to make notification", e);
221 }
222 }
223
224 private Notification createImportBackupNotification(final int max, final int progress) {
225 NotificationCompat.Builder mBuilder =
226 new NotificationCompat.Builder(getBaseContext(), "backup");
227 mBuilder.setContentTitle(getString(R.string.restoring_backup))
228 .setSmallIcon(R.drawable.ic_unarchive_white_24dp)
229 .setProgress(max, progress, max == 1 && progress == 0);
230 return mBuilder.build();
231 }
232
233 private boolean importBackup(final Uri uri, final String password) {
234 Log.d(Config.LOGTAG, "importing backup from " + uri);
235 final Stopwatch stopwatch = Stopwatch.createStarted();
236 try {
237 final SQLiteDatabase db = mDatabaseBackend.getWritableDatabase();
238 final InputStream inputStream;
239 final String path = uri.getPath();
240 final long fileSize;
241 if ("file".equals(uri.getScheme()) && path != null) {
242 final File file = new File(path);
243 inputStream = new FileInputStream(file);
244 fileSize = file.length();
245 } else {
246 final Cursor returnCursor = getContentResolver().query(uri, null, null, null, null);
247 if (returnCursor == null) {
248 fileSize = 0;
249 } else {
250 returnCursor.moveToFirst();
251 fileSize =
252 returnCursor.getLong(
253 returnCursor.getColumnIndexOrThrow(OpenableColumns.SIZE));
254 returnCursor.close();
255 }
256 inputStream = getContentResolver().openInputStream(uri);
257 }
258 if (inputStream == null) {
259 synchronized (mOnBackupProcessedListeners) {
260 for (final OnBackupProcessed l : mOnBackupProcessedListeners) {
261 l.onBackupRestoreFailed();
262 }
263 }
264 return false;
265 }
266 final CountingInputStream countingInputStream = new CountingInputStream(inputStream);
267 final DataInputStream dataInputStream = new DataInputStream(countingInputStream);
268 final BackupFileHeader backupFileHeader = BackupFileHeader.read(dataInputStream);
269 Log.d(Config.LOGTAG, backupFileHeader.toString());
270
271 if (mDatabaseBackend.getAccountJids(false).contains(backupFileHeader.getJid())) {
272 synchronized (mOnBackupProcessedListeners) {
273 for (OnBackupProcessed l : mOnBackupProcessedListeners) {
274 l.onAccountAlreadySetup();
275 }
276 }
277 return false;
278 }
279
280 final byte[] key = ExportBackupService.getKey(password, backupFileHeader.getSalt());
281
282 final AEADBlockCipher cipher = new GCMBlockCipher(new AESEngine());
283 cipher.init(
284 false,
285 new AEADParameters(new KeyParameter(key), 128, backupFileHeader.getIv()));
286 final CipherInputStream cipherInputStream =
287 new CipherInputStream(countingInputStream, cipher);
288
289 final GZIPInputStream gzipInputStream = new GZIPInputStream(cipherInputStream);
290 final BufferedReader reader =
291 new BufferedReader(new InputStreamReader(gzipInputStream, Charsets.UTF_8));
292 final JsonReader jsonReader = new JsonReader(reader);
293 if (jsonReader.peek() == JsonToken.BEGIN_ARRAY) {
294 jsonReader.beginArray();
295 } else {
296 throw new IllegalStateException("Backup file did not begin with array");
297 }
298 db.beginTransaction();
299 while (jsonReader.hasNext()) {
300 if (jsonReader.peek() == JsonToken.BEGIN_OBJECT) {
301 importRow(db, jsonReader, backupFileHeader.getJid(), password);
302 } else if (jsonReader.peek() == JsonToken.END_ARRAY) {
303 jsonReader.endArray();
304 continue;
305 }
306 updateImportBackupNotification(fileSize, countingInputStream.getCount());
307 }
308 db.setTransactionSuccessful();
309 db.endTransaction();
310 final Jid jid = backupFileHeader.getJid();
311 final Cursor countCursor =
312 db.rawQuery(
313 "select count(messages.uuid) from messages join conversations on conversations.uuid=messages.conversationUuid join accounts on conversations.accountUuid=accounts.uuid where accounts.username=? and accounts.server=?",
314 new String[] {
315 jid.getEscapedLocal(), jid.getDomain().toEscapedString()
316 });
317 countCursor.moveToFirst();
318 final int count = countCursor.getInt(0);
319 Log.d(
320 Config.LOGTAG,
321 String.format(
322 "restored %d messages in %s", count, stopwatch.stop().toString()));
323 countCursor.close();
324 stopBackgroundService();
325 synchronized (mOnBackupProcessedListeners) {
326 for (OnBackupProcessed l : mOnBackupProcessedListeners) {
327 l.onBackupRestored();
328 }
329 }
330 return true;
331 } catch (final Exception e) {
332 final Throwable throwable = e.getCause();
333 final boolean reasonWasCrypto =
334 throwable instanceof BadPaddingException || e instanceof ZipException;
335 synchronized (mOnBackupProcessedListeners) {
336 for (OnBackupProcessed l : mOnBackupProcessedListeners) {
337 if (reasonWasCrypto) {
338 l.onBackupDecryptionFailed();
339 } else {
340 l.onBackupRestoreFailed();
341 }
342 }
343 }
344 Log.d(Config.LOGTAG, "error restoring backup " + uri, e);
345 return false;
346 }
347 }
348
349 private void importRow(
350 final SQLiteDatabase db,
351 final JsonReader jsonReader,
352 final Jid account,
353 final String passphrase)
354 throws IOException {
355 jsonReader.beginObject();
356 final String firstParameter = jsonReader.nextName();
357 if (!firstParameter.equals("table")) {
358 throw new IllegalStateException("Expected key 'table'");
359 }
360 final String table = jsonReader.nextString();
361 if (!TABLE_ALLOW_LIST.contains(table)) {
362 throw new IOException(String.format("%s is not recognized for import", table));
363 }
364 final ContentValues contentValues = new ContentValues();
365 final String secondParameter = jsonReader.nextName();
366 if (!secondParameter.equals("values")) {
367 throw new IllegalStateException("Expected key 'values'");
368 }
369 jsonReader.beginObject();
370 while (jsonReader.peek() != JsonToken.END_OBJECT) {
371 final String name = jsonReader.nextName();
372 if (COLUMN_PATTERN.matcher(name).matches()) {
373 if (jsonReader.peek() == JsonToken.NULL) {
374 jsonReader.nextNull();
375 contentValues.putNull(name);
376 } else if (jsonReader.peek() == JsonToken.NUMBER) {
377 contentValues.put(name, jsonReader.nextLong());
378 } else {
379 contentValues.put(name, jsonReader.nextString());
380 }
381 } else {
382 throw new IOException(String.format("Unexpected column name %s", name));
383 }
384 }
385 jsonReader.endObject();
386 jsonReader.endObject();
387 if (Account.TABLENAME.equals(table)) {
388 final Jid jid =
389 Jid.of(
390 contentValues.getAsString(Account.USERNAME),
391 contentValues.getAsString(Account.SERVER),
392 null);
393 final String password = contentValues.getAsString(Account.PASSWORD);
394 if (jid.equals(account) && passphrase.equals(password)) {
395 Log.d(Config.LOGTAG, "jid and password from backup header had matching row");
396 } else {
397 throw new IOException("jid or password in table did not match backup");
398 }
399 }
400 db.insert(table, null, contentValues);
401 }
402
403 private void notifySuccess() {
404 NotificationCompat.Builder mBuilder =
405 new NotificationCompat.Builder(getBaseContext(), "backup");
406 mBuilder.setContentTitle(getString(R.string.notification_restored_backup_title))
407 .setContentText(getString(R.string.notification_restored_backup_subtitle))
408 .setAutoCancel(true)
409 .setContentIntent(
410 PendingIntent.getActivity(
411 this,
412 145,
413 new Intent(this, ManageAccountActivity.class),
414 s()
415 ? PendingIntent.FLAG_IMMUTABLE
416 | PendingIntent.FLAG_UPDATE_CURRENT
417 : PendingIntent.FLAG_UPDATE_CURRENT))
418 .setSmallIcon(R.drawable.ic_unarchive_white_24dp);
419 notificationManager.notify(NOTIFICATION_ID, mBuilder.build());
420 }
421
422 private void stopBackgroundService() {
423 Intent intent = new Intent(this, XmppConnectionService.class);
424 stopService(intent);
425 }
426
427 public void removeOnBackupProcessedListener(OnBackupProcessed listener) {
428 synchronized (mOnBackupProcessedListeners) {
429 mOnBackupProcessedListeners.remove(listener);
430 }
431 }
432
433 public void addOnBackupProcessedListener(OnBackupProcessed listener) {
434 synchronized (mOnBackupProcessedListeners) {
435 mOnBackupProcessedListeners.add(listener);
436 }
437 }
438
439 @Override
440 public IBinder onBind(Intent intent) {
441 return this.binder;
442 }
443
444 public interface OnBackupFilesLoaded {
445 void onBackupFilesLoaded(List<BackupFile> files);
446 }
447
448 public interface OnBackupProcessed {
449 void onBackupRestored();
450
451 void onBackupDecryptionFailed();
452
453 void onBackupRestoreFailed();
454
455 void onAccountAlreadySetup();
456 }
457
458 public static class BackupFile {
459 private final Uri uri;
460 private final BackupFileHeader header;
461
462 private BackupFile(Uri uri, BackupFileHeader header) {
463 this.uri = uri;
464 this.header = header;
465 }
466
467 private static BackupFile read(File file) throws IOException {
468 final FileInputStream fileInputStream = new FileInputStream(file);
469 final DataInputStream dataInputStream = new DataInputStream(fileInputStream);
470 BackupFileHeader backupFileHeader = BackupFileHeader.read(dataInputStream);
471 fileInputStream.close();
472 return new BackupFile(Uri.fromFile(file), backupFileHeader);
473 }
474
475 public static BackupFile read(final Context context, final Uri uri) throws IOException {
476 final InputStream inputStream = context.getContentResolver().openInputStream(uri);
477 if (inputStream == null) {
478 throw new FileNotFoundException();
479 }
480 final DataInputStream dataInputStream = new DataInputStream(inputStream);
481 BackupFileHeader backupFileHeader = BackupFileHeader.read(dataInputStream);
482 inputStream.close();
483 return new BackupFile(uri, backupFileHeader);
484 }
485
486 public BackupFileHeader getHeader() {
487 return header;
488 }
489
490 public Uri getUri() {
491 return uri;
492 }
493 }
494
495 public class ImportBackupServiceBinder extends Binder {
496 public ImportBackupService getService() {
497 return ImportBackupService.this;
498 }
499 }
500}