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