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.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}