ImportBackupService.java

  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}