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