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