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