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