refactor backup restore to use worker instead of service

Daniel Gultsch created

Change summary

src/conversations/AndroidManifest.xml                                           |   9 
src/conversations/java/eu/siacs/conversations/services/ImportBackupService.java | 503 
src/main/AndroidManifest.xml                                                    |  63 
src/main/java/eu/siacs/conversations/entities/Account.java                      |  19 
src/main/java/eu/siacs/conversations/ui/ImportBackupActivity.java               | 343 
src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java                 |   1 
src/main/java/eu/siacs/conversations/ui/adapter/BackupFileAdapter.java          |  66 
src/main/java/eu/siacs/conversations/utils/BackupFile.java                      | 140 
src/main/java/eu/siacs/conversations/worker/ExportBackupWorker.java             |  17 
src/main/java/eu/siacs/conversations/worker/ImportBackupWorker.java             | 389 
src/main/res/layout/activity_import_backup.xml                                  |   0 
src/main/res/layout/dialog_enter_password.xml                                   |  47 
src/main/res/values/strings.xml                                                 |   6 
13 files changed, 867 insertions(+), 736 deletions(-)

Detailed changes

src/conversations/AndroidManifest.xml ๐Ÿ”—

@@ -5,8 +5,8 @@
         <activity
             android:name=".ui.ManageAccountActivity"
             android:label="@string/title_activity_manage_accounts"
-            android:theme="@style/Theme.Conversations3"
-            android:launchMode="singleTask" />
+            android:launchMode="singleTask"
+            android:theme="@style/Theme.Conversations3" />
         <activity
             android:name=".ui.WelcomeActivity"
             android:label="@string/app_name"
@@ -23,10 +23,5 @@
             android:name=".ui.EasyOnboardingInviteActivity"
             android:label="@string/invite_to_app"
             android:launchMode="singleTask" />
-        <activity
-            android:name=".ui.ImportBackupActivity"
-            android:exported="false"
-            android:label="@string/restore_backup"
-            android:launchMode="singleTask" />
     </application>
 </manifest>

src/conversations/java/eu/siacs/conversations/services/ImportBackupService.java ๐Ÿ”—

@@ -1,503 +0,0 @@
-package eu.siacs.conversations.services;
-
-import static eu.siacs.conversations.utils.Compatibility.s;
-
-import android.app.Notification;
-import android.app.NotificationManager;
-import android.app.PendingIntent;
-import android.app.Service;
-import android.content.ContentValues;
-import android.content.Context;
-import android.content.Intent;
-import android.database.Cursor;
-import android.database.sqlite.SQLiteDatabase;
-import android.net.Uri;
-import android.os.Binder;
-import android.os.IBinder;
-import android.provider.OpenableColumns;
-import android.util.Log;
-import androidx.core.app.NotificationCompat;
-import androidx.core.app.NotificationManagerCompat;
-import com.google.common.base.Charsets;
-import com.google.common.base.Stopwatch;
-import com.google.common.io.CountingInputStream;
-import com.google.common.util.concurrent.Futures;
-import com.google.common.util.concurrent.ListenableFuture;
-import com.google.gson.stream.JsonReader;
-import com.google.gson.stream.JsonToken;
-import eu.siacs.conversations.Config;
-import eu.siacs.conversations.R;
-import eu.siacs.conversations.crypto.axolotl.SQLiteAxolotlStore;
-import eu.siacs.conversations.entities.Account;
-import eu.siacs.conversations.entities.Conversation;
-import eu.siacs.conversations.entities.Message;
-import eu.siacs.conversations.persistance.DatabaseBackend;
-import eu.siacs.conversations.persistance.FileBackend;
-import eu.siacs.conversations.ui.ManageAccountActivity;
-import eu.siacs.conversations.utils.BackupFileHeader;
-import eu.siacs.conversations.utils.SerialSingleThreadExecutor;
-import eu.siacs.conversations.worker.ExportBackupWorker;
-import eu.siacs.conversations.xmpp.Jid;
-import java.io.BufferedReader;
-import java.io.DataInputStream;
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.FileNotFoundException;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-import java.util.WeakHashMap;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-import java.util.concurrent.atomic.AtomicBoolean;
-import java.util.regex.Pattern;
-import java.util.zip.GZIPInputStream;
-import java.util.zip.ZipException;
-import javax.crypto.BadPaddingException;
-import org.bouncycastle.crypto.engines.AESEngine;
-import org.bouncycastle.crypto.io.CipherInputStream;
-import org.bouncycastle.crypto.modes.AEADBlockCipher;
-import org.bouncycastle.crypto.modes.GCMBlockCipher;
-import org.bouncycastle.crypto.params.AEADParameters;
-import org.bouncycastle.crypto.params.KeyParameter;
-
-public class ImportBackupService extends Service {
-
-    private static final ExecutorService BACKUP_FILE_READER_EXECUTOR =
-            Executors.newSingleThreadExecutor();
-
-    private static final int NOTIFICATION_ID = 21;
-    private static final AtomicBoolean running = new AtomicBoolean(false);
-    private final ImportBackupServiceBinder binder = new ImportBackupServiceBinder();
-    private final SerialSingleThreadExecutor executor =
-            new SerialSingleThreadExecutor(getClass().getSimpleName());
-    private final Set<OnBackupProcessed> mOnBackupProcessedListeners =
-            Collections.newSetFromMap(new WeakHashMap<>());
-    private DatabaseBackend mDatabaseBackend;
-    private NotificationManager notificationManager;
-
-    private static final Collection<String> TABLE_ALLOW_LIST =
-            Arrays.asList(
-                    Account.TABLENAME,
-                    Conversation.TABLENAME,
-                    Message.TABLENAME,
-                    SQLiteAxolotlStore.PREKEY_TABLENAME,
-                    SQLiteAxolotlStore.SIGNED_PREKEY_TABLENAME,
-                    SQLiteAxolotlStore.SESSION_TABLENAME,
-                    SQLiteAxolotlStore.IDENTITIES_TABLENAME);
-    private static final Pattern COLUMN_PATTERN = Pattern.compile("^[a-zA-Z_]+$");
-
-    @Override
-    public void onCreate() {
-        mDatabaseBackend = DatabaseBackend.getInstance(getBaseContext());
-        notificationManager =
-                (android.app.NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
-    }
-
-    @Override
-    public int onStartCommand(Intent intent, int flags, int startId) {
-        if (intent == null) {
-            return START_NOT_STICKY;
-        }
-        final String password = intent.getStringExtra("password");
-        final Uri data = intent.getData();
-        final Uri uri;
-        if (data == null) {
-            final String file = intent.getStringExtra("file");
-            uri = file == null ? null : Uri.fromFile(new File(file));
-        } else {
-            uri = data;
-        }
-
-        if (password == null || password.isEmpty() || uri == null) {
-            return START_NOT_STICKY;
-        }
-        if (running.compareAndSet(false, true)) {
-            executor.execute(
-                    () -> {
-                        startForegroundService();
-                        final boolean success = importBackup(uri, password);
-                        stopForeground(true);
-                        running.set(false);
-                        if (success) {
-                            notifySuccess();
-                        }
-                        stopSelf();
-                    });
-        } else {
-            Log.d(Config.LOGTAG, "backup already running");
-        }
-        return START_NOT_STICKY;
-    }
-
-    public boolean getLoadingState() {
-        return running.get();
-    }
-
-    public void loadBackupFiles(final OnBackupFilesLoaded onBackupFilesLoaded) {
-        executor.execute(
-                () -> {
-                    final List<Jid> accounts = mDatabaseBackend.getAccountJids(false);
-                    final ArrayList<BackupFile> backupFiles = new ArrayList<>();
-                    final Set<String> apps =
-                            new HashSet<>(
-                                    Arrays.asList(
-                                            "Conversations",
-                                            "Quicksy",
-                                            getString(R.string.app_name)));
-                    final List<File> directories = new ArrayList<>();
-                    for (final String app : apps) {
-                        directories.add(FileBackend.getLegacyBackupDirectory(app));
-                    }
-                    directories.add(FileBackend.getBackupDirectory(this));
-                    for (final File directory : directories) {
-                        if (!directory.exists() || !directory.isDirectory()) {
-                            Log.d(
-                                    Config.LOGTAG,
-                                    "directory not found: " + directory.getAbsolutePath());
-                            continue;
-                        }
-                        final File[] files = directory.listFiles();
-                        if (files == null) {
-                            continue;
-                        }
-                        Log.d(Config.LOGTAG, "looking for backups in " + directory);
-                        for (final File file : files) {
-                            if (file.isFile() && file.getName().endsWith(".ceb")) {
-                                try {
-                                    final BackupFile backupFile = BackupFile.read(file);
-                                    if (accounts.contains(backupFile.getHeader().getJid())) {
-                                        Log.d(
-                                                Config.LOGTAG,
-                                                "skipping backup for "
-                                                        + backupFile.getHeader().getJid());
-                                    } else {
-                                        backupFiles.add(backupFile);
-                                    }
-                                } catch (final IOException
-                                        | IllegalArgumentException
-                                        | BackupFileHeader.OutdatedBackupFileVersion e) {
-                                    Log.d(Config.LOGTAG, "unable to read backup file ", e);
-                                }
-                            }
-                        }
-                    }
-                    Collections.sort(
-                            backupFiles, Comparator.comparing(a -> a.header.getJid().toString()));
-                    onBackupFilesLoaded.onBackupFilesLoaded(backupFiles);
-                });
-    }
-
-    private void startForegroundService() {
-        startForeground(NOTIFICATION_ID, createImportBackupNotification(1, 0));
-    }
-
-    private void updateImportBackupNotification(final long total, final long current) {
-        final int max;
-        final int progress;
-        if (total == 0) {
-            max = 1;
-            progress = 0;
-        } else {
-            max = 100;
-            progress = (int) (current * 100 / total);
-        }
-        final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this);
-        try {
-            notificationManager.notify(
-                    NOTIFICATION_ID, createImportBackupNotification(max, progress));
-        } catch (final RuntimeException e) {
-            Log.d(Config.LOGTAG, "unable to make notification", e);
-        }
-    }
-
-    private Notification createImportBackupNotification(final int max, final int progress) {
-        NotificationCompat.Builder mBuilder =
-                new NotificationCompat.Builder(getBaseContext(), "backup");
-        mBuilder.setContentTitle(getString(R.string.restoring_backup))
-                .setSmallIcon(R.drawable.ic_unarchive_24dp)
-                .setProgress(max, progress, max == 1 && progress == 0);
-        return mBuilder.build();
-    }
-
-    private boolean importBackup(final Uri uri, final String password) {
-        Log.d(Config.LOGTAG, "importing backup from " + uri);
-        final Stopwatch stopwatch = Stopwatch.createStarted();
-        try {
-            final SQLiteDatabase db = mDatabaseBackend.getWritableDatabase();
-            final InputStream inputStream;
-            final String path = uri.getPath();
-            final long fileSize;
-            if ("file".equals(uri.getScheme()) && path != null) {
-                final File file = new File(path);
-                inputStream = new FileInputStream(file);
-                fileSize = file.length();
-            } else {
-                final Cursor returnCursor = getContentResolver().query(uri, null, null, null, null);
-                if (returnCursor == null) {
-                    fileSize = 0;
-                } else {
-                    returnCursor.moveToFirst();
-                    fileSize =
-                            returnCursor.getLong(
-                                    returnCursor.getColumnIndexOrThrow(OpenableColumns.SIZE));
-                    returnCursor.close();
-                }
-                inputStream = getContentResolver().openInputStream(uri);
-            }
-            if (inputStream == null) {
-                synchronized (mOnBackupProcessedListeners) {
-                    for (final OnBackupProcessed l : mOnBackupProcessedListeners) {
-                        l.onBackupRestoreFailed();
-                    }
-                }
-                return false;
-            }
-            final CountingInputStream countingInputStream = new CountingInputStream(inputStream);
-            final DataInputStream dataInputStream = new DataInputStream(countingInputStream);
-            final BackupFileHeader backupFileHeader = BackupFileHeader.read(dataInputStream);
-            Log.d(Config.LOGTAG, backupFileHeader.toString());
-
-            if (mDatabaseBackend.getAccountJids(false).contains(backupFileHeader.getJid())) {
-                synchronized (mOnBackupProcessedListeners) {
-                    for (OnBackupProcessed l : mOnBackupProcessedListeners) {
-                        l.onAccountAlreadySetup();
-                    }
-                }
-                return false;
-            }
-
-            final byte[] key = ExportBackupWorker.getKey(password, backupFileHeader.getSalt());
-
-            final AEADBlockCipher cipher = new GCMBlockCipher(new AESEngine());
-            cipher.init(
-                    false,
-                    new AEADParameters(new KeyParameter(key), 128, backupFileHeader.getIv()));
-            final CipherInputStream cipherInputStream =
-                    new CipherInputStream(countingInputStream, cipher);
-
-            final GZIPInputStream gzipInputStream = new GZIPInputStream(cipherInputStream);
-            final BufferedReader reader =
-                    new BufferedReader(new InputStreamReader(gzipInputStream, Charsets.UTF_8));
-            final JsonReader jsonReader = new JsonReader(reader);
-            if (jsonReader.peek() == JsonToken.BEGIN_ARRAY) {
-                jsonReader.beginArray();
-            } else {
-                throw new IllegalStateException("Backup file did not begin with array");
-            }
-            db.beginTransaction();
-            while (jsonReader.hasNext()) {
-                if (jsonReader.peek() == JsonToken.BEGIN_OBJECT) {
-                    importRow(db, jsonReader, backupFileHeader.getJid(), password);
-                } else if (jsonReader.peek() == JsonToken.END_ARRAY) {
-                    jsonReader.endArray();
-                    continue;
-                }
-                updateImportBackupNotification(fileSize, countingInputStream.getCount());
-            }
-            db.setTransactionSuccessful();
-            db.endTransaction();
-            final Jid jid = backupFileHeader.getJid();
-            final Cursor countCursor =
-                    db.rawQuery(
-                            "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=?",
-                            new String[] {jid.getLocal(), jid.getDomain().toString()});
-            countCursor.moveToFirst();
-            final int count = countCursor.getInt(0);
-            Log.d(
-                    Config.LOGTAG,
-                    String.format(
-                            "restored %d messages in %s", count, stopwatch.stop().toString()));
-            countCursor.close();
-            stopBackgroundService();
-            synchronized (mOnBackupProcessedListeners) {
-                for (OnBackupProcessed l : mOnBackupProcessedListeners) {
-                    l.onBackupRestored();
-                }
-            }
-            return true;
-        } catch (final Exception e) {
-            final Throwable throwable = e.getCause();
-            final boolean reasonWasCrypto =
-                    throwable instanceof BadPaddingException || e instanceof ZipException;
-            synchronized (mOnBackupProcessedListeners) {
-                for (OnBackupProcessed l : mOnBackupProcessedListeners) {
-                    if (reasonWasCrypto) {
-                        l.onBackupDecryptionFailed();
-                    } else {
-                        l.onBackupRestoreFailed();
-                    }
-                }
-            }
-            Log.d(Config.LOGTAG, "error restoring backup " + uri, e);
-            return false;
-        }
-    }
-
-    private void importRow(
-            final SQLiteDatabase db,
-            final JsonReader jsonReader,
-            final Jid account,
-            final String passphrase)
-            throws IOException {
-        jsonReader.beginObject();
-        final String firstParameter = jsonReader.nextName();
-        if (!firstParameter.equals("table")) {
-            throw new IllegalStateException("Expected key 'table'");
-        }
-        final String table = jsonReader.nextString();
-        if (!TABLE_ALLOW_LIST.contains(table)) {
-            throw new IOException(String.format("%s is not recognized for import", table));
-        }
-        final ContentValues contentValues = new ContentValues();
-        final String secondParameter = jsonReader.nextName();
-        if (!secondParameter.equals("values")) {
-            throw new IllegalStateException("Expected key 'values'");
-        }
-        jsonReader.beginObject();
-        while (jsonReader.peek() != JsonToken.END_OBJECT) {
-            final String name = jsonReader.nextName();
-            if (COLUMN_PATTERN.matcher(name).matches()) {
-                if (jsonReader.peek() == JsonToken.NULL) {
-                    jsonReader.nextNull();
-                    contentValues.putNull(name);
-                } else if (jsonReader.peek() == JsonToken.NUMBER) {
-                    contentValues.put(name, jsonReader.nextLong());
-                } else {
-                    contentValues.put(name, jsonReader.nextString());
-                }
-            } else {
-                throw new IOException(String.format("Unexpected column name %s", name));
-            }
-        }
-        jsonReader.endObject();
-        jsonReader.endObject();
-        if (Account.TABLENAME.equals(table)) {
-            final Jid jid =
-                    Jid.of(
-                            contentValues.getAsString(Account.USERNAME),
-                            contentValues.getAsString(Account.SERVER),
-                            null);
-            final String password = contentValues.getAsString(Account.PASSWORD);
-            if (jid.equals(account) && passphrase.equals(password)) {
-                Log.d(Config.LOGTAG, "jid and password from backup header had matching row");
-            } else {
-                throw new IOException("jid or password in table did not match backup");
-            }
-        }
-        db.insert(table, null, contentValues);
-    }
-
-    private void notifySuccess() {
-        NotificationCompat.Builder mBuilder =
-                new NotificationCompat.Builder(getBaseContext(), "backup");
-        mBuilder.setContentTitle(getString(R.string.notification_restored_backup_title))
-                .setContentText(getString(R.string.notification_restored_backup_subtitle))
-                .setAutoCancel(true)
-                .setContentIntent(
-                        PendingIntent.getActivity(
-                                this,
-                                145,
-                                new Intent(this, ManageAccountActivity.class),
-                                s()
-                                        ? PendingIntent.FLAG_IMMUTABLE
-                                                | PendingIntent.FLAG_UPDATE_CURRENT
-                                        : PendingIntent.FLAG_UPDATE_CURRENT))
-                .setSmallIcon(R.drawable.ic_unarchive_24dp);
-        notificationManager.notify(NOTIFICATION_ID, mBuilder.build());
-    }
-
-    private void stopBackgroundService() {
-        Intent intent = new Intent(this, XmppConnectionService.class);
-        stopService(intent);
-    }
-
-    public void removeOnBackupProcessedListener(OnBackupProcessed listener) {
-        synchronized (mOnBackupProcessedListeners) {
-            mOnBackupProcessedListeners.remove(listener);
-        }
-    }
-
-    public void addOnBackupProcessedListener(OnBackupProcessed listener) {
-        synchronized (mOnBackupProcessedListeners) {
-            mOnBackupProcessedListeners.add(listener);
-        }
-    }
-
-    public static ListenableFuture<BackupFile> read(final Context context, final Uri uri) {
-        return Futures.submit(() -> BackupFile.read(context, uri), BACKUP_FILE_READER_EXECUTOR);
-    }
-
-    @Override
-    public IBinder onBind(Intent intent) {
-        return this.binder;
-    }
-
-    public interface OnBackupFilesLoaded {
-        void onBackupFilesLoaded(List<BackupFile> files);
-    }
-
-    public interface OnBackupProcessed {
-        void onBackupRestored();
-
-        void onBackupDecryptionFailed();
-
-        void onBackupRestoreFailed();
-
-        void onAccountAlreadySetup();
-    }
-
-    public static class BackupFile {
-        private final Uri uri;
-        private final BackupFileHeader header;
-
-        private BackupFile(Uri uri, BackupFileHeader header) {
-            this.uri = uri;
-            this.header = header;
-        }
-
-        private static BackupFile read(File file) throws IOException {
-            final FileInputStream fileInputStream = new FileInputStream(file);
-            final DataInputStream dataInputStream = new DataInputStream(fileInputStream);
-            BackupFileHeader backupFileHeader = BackupFileHeader.read(dataInputStream);
-            fileInputStream.close();
-            return new BackupFile(Uri.fromFile(file), backupFileHeader);
-        }
-
-        public static BackupFile read(final Context context, final Uri uri) throws IOException {
-            final InputStream inputStream = context.getContentResolver().openInputStream(uri);
-            if (inputStream == null) {
-                throw new FileNotFoundException();
-            }
-            final DataInputStream dataInputStream = new DataInputStream(inputStream);
-            final BackupFileHeader backupFileHeader = BackupFileHeader.read(dataInputStream);
-            inputStream.close();
-            return new BackupFile(uri, backupFileHeader);
-        }
-
-        public BackupFileHeader getHeader() {
-            return header;
-        }
-
-        public Uri getUri() {
-            return uri;
-        }
-    }
-
-    public class ImportBackupServiceBinder extends Binder {
-        public ImportBackupService getService() {
-            return ImportBackupService.this;
-        }
-    }
-}

src/main/AndroidManifest.xml ๐Ÿ”—

@@ -120,11 +120,6 @@
             android:foregroundServiceType="dataSync"
             tools:node="merge" />
 
-        <service
-            android:name=".services.ImportBackupService"
-            android:exported="false"
-            android:foregroundServiceType="dataSync" />
-
         <service
             android:name=".services.CallIntegrationConnectionService"
             android:exported="true"
@@ -335,10 +330,10 @@
             android:windowSoftInputMode="stateAlwaysHidden" />
         <activity
             android:name=".ui.AboutActivity"
-            android:parentActivityName=".ui.SettingsActivity">
+            android:parentActivityName=".ui.activity.SettingsActivity">
             <meta-data
                 android:name="android.support.PARENT_ACTIVITY"
-                android:value="eu.siacs.conversations.ui.SettingsActivity" />
+                android:value="eu.siacs.conversations.ui.activity.SettingsActivity" />
         </activity>
         <activity
             android:name="com.canhub.cropper.CropImageActivity"
@@ -384,6 +379,60 @@
             android:autoRemoveFromRecents="true"
             android:launchMode="singleInstance"
             android:supportsPictureInPicture="true" />
+        <activity
+            android:name=".ui.ImportBackupActivity"
+            android:exported="true"
+            android:label="@string/restore_backup"
+            android:launchMode="singleTask">
+            <intent-filter>
+                <action android:name="android.intent.action.VIEW" />
+                <category android:name="android.intent.category.DEFAULT" />
+
+                <data android:mimeType="application/vnd.conversations.backup" />
+                <data android:scheme="content" />
+            </intent-filter>
+            <intent-filter>
+                <action android:name="android.intent.action.VIEW" />
+                <category android:name="android.intent.category.DEFAULT" />
+
+                <data android:mimeType="application/vnd.conversations.backup" />
+                <data android:scheme="file" />
+            </intent-filter>
+            <intent-filter>
+                <action android:name="android.intent.action.VIEW" />
+
+                <category android:name="android.intent.category.DEFAULT" />
+                <category android:name="android.intent.category.BROWSABLE" />
+
+                <data android:scheme="content" />
+                <data android:host="*" />
+                <data android:mimeType="*/*" />
+                <data android:pathPattern=".*\\.ceb" />
+                <data android:pathPattern=".*\\..*\\.ceb" />
+                <data android:pathPattern=".*\\..*\\..*\\.ceb" />
+                <data android:pathPattern=".*\\..*\\..*\\..*\\.ceb" />
+                <data android:pathPattern=".*\\..*\\..*\\..*\\..*\\.ceb" />
+                <data android:pathPattern=".*\\..*\\..*\\..*\\..*\\..*\\.ceb" />
+                <data android:pathPattern=".*\\..*\\..*\\..*\\..*\\..*\\..*\\.ceb" />
+            </intent-filter>
+            <intent-filter>
+                <action android:name="android.intent.action.VIEW" />
+
+                <category android:name="android.intent.category.DEFAULT" />
+                <category android:name="android.intent.category.BROWSABLE" />
+
+                <data android:scheme="file" />
+                <data android:host="*" />
+                <data android:mimeType="*/*" />
+                <data android:pathPattern=".*\\.ceb" />
+                <data android:pathPattern=".*\\..*\\.ceb" />
+                <data android:pathPattern=".*\\..*\\..*\\.ceb" />
+                <data android:pathPattern=".*\\..*\\..*\\..*\\.ceb" />
+                <data android:pathPattern=".*\\..*\\..*\\..*\\..*\\.ceb" />
+                <data android:pathPattern=".*\\..*\\..*\\..*\\..*\\..*\\.ceb" />
+                <data android:pathPattern=".*\\..*\\..*\\..*\\..*\\..*\\..*\\.ceb" />
+            </intent-filter>
+        </activity>
     </application>
 
 </manifest>

src/main/java/eu/siacs/conversations/entities/Account.java ๐Ÿ”—

@@ -148,13 +148,7 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
         this.password = password;
         this.options = options;
         this.rosterVersion = rosterVersion;
-        JSONObject tmp;
-        try {
-            tmp = new JSONObject(keys);
-        } catch (JSONException e) {
-            tmp = new JSONObject();
-        }
-        this.keys = tmp;
+        this.keys = parseKeys(keys);
         this.avatar = avatar;
         this.displayName = displayName;
         this.hostname = hostname;
@@ -167,6 +161,17 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
         this.fastToken = fastToken;
     }
 
+    public static JSONObject parseKeys(final String keys) {
+        if (Strings.isNullOrEmpty(keys)) {
+            return new JSONObject();
+        }
+        try {
+            return new JSONObject(keys);
+        } catch (final JSONException e) {
+            return new JSONObject();
+        }
+    }
+
     public static Account fromCursor(final Cursor cursor) {
         final Jid jid;
         try {

src/conversations/java/eu/siacs/conversations/ui/ImportBackupActivity.java โ†’ src/main/java/eu/siacs/conversations/ui/ImportBackupActivity.java ๐Ÿ”—

@@ -1,84 +1,100 @@
 package eu.siacs.conversations.ui;
 
 import android.Manifest;
-import android.content.ComponentName;
-import android.content.Context;
 import android.content.DialogInterface;
 import android.content.Intent;
-import android.content.ServiceConnection;
 import android.content.pm.PackageManager;
 import android.net.Uri;
 import android.os.Build;
 import android.os.Bundle;
-import android.os.IBinder;
 import android.util.Log;
 import android.view.LayoutInflater;
 import android.view.Menu;
 import android.view.MenuItem;
 import android.view.View;
-
 import androidx.activity.result.ActivityResultLauncher;
 import androidx.activity.result.contract.ActivityResultContracts;
 import androidx.annotation.NonNull;
 import androidx.appcompat.app.AlertDialog;
 import androidx.core.content.ContextCompat;
 import androidx.databinding.DataBindingUtil;
-
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.Transformations;
+import androidx.work.OneTimeWorkRequest;
+import androidx.work.OutOfQuotaPolicy;
+import androidx.work.WorkInfo;
+import androidx.work.WorkManager;
 import com.google.android.material.dialog.MaterialAlertDialogBuilder;
 import com.google.android.material.snackbar.Snackbar;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
 import com.google.common.util.concurrent.FutureCallback;
 import com.google.common.util.concurrent.Futures;
-
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.databinding.ActivityImportBackupBinding;
 import eu.siacs.conversations.databinding.DialogEnterPasswordBinding;
-import eu.siacs.conversations.services.ImportBackupService;
+import eu.siacs.conversations.services.QuickConversationsService;
 import eu.siacs.conversations.ui.adapter.BackupFileAdapter;
-import eu.siacs.conversations.ui.util.MainThreadExecutor;
+import eu.siacs.conversations.utils.BackupFile;
 import eu.siacs.conversations.utils.BackupFileHeader;
-
+import eu.siacs.conversations.worker.ImportBackupWorker;
 import java.io.IOException;
 import java.util.Collections;
 import java.util.List;
 import java.util.Set;
+import java.util.UUID;
 
 public class ImportBackupActivity extends ActionBarActivity
-        implements ServiceConnection,
-                ImportBackupService.OnBackupFilesLoaded,
-                BackupFileAdapter.OnItemClickedListener,
-                ImportBackupService.OnBackupProcessed {
+        implements BackupFileAdapter.OnItemClickedListener {
 
     private ActivityImportBackupBinding binding;
 
     private BackupFileAdapter backupFileAdapter;
-    private ImportBackupService service;
 
-    private boolean mLoadingState = false;
+    private LiveData<Boolean> inProgressImport;
+    private UUID currentWorkRequest;
+
     private final ActivityResultLauncher<String[]> requestPermissions =
             registerForActivityResult(
                     new ActivityResultContracts.RequestMultiplePermissions(),
                     results -> {
                         if (results.containsValue(Boolean.TRUE)) {
-                            final var service = this.service;
-                            if (service == null) {
-                                return;
-                            }
-                            service.loadBackupFiles(this);
+                            loadBackupFiles();
                         }
                     });
 
+    private final ActivityResultLauncher<String> openBackup =
+            registerForActivityResult(
+                    new ActivityResultContracts.GetContent(),
+                    uri -> openBackupFileFromUri(uri, false));
+
     @Override
     protected void onCreate(final Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
         binding = DataBindingUtil.setContentView(this, R.layout.activity_import_backup);
         Activities.setStatusAndNavigationBarColors(this, binding.getRoot());
         setSupportActionBar(binding.toolbar);
-        setLoadingState(
-                savedInstanceState != null
-                        && savedInstanceState.getBoolean("loading_state", false));
+
+        final var workManager = WorkManager.getInstance(this);
+        final var imports =
+                workManager.getWorkInfosByTagLiveData(ImportBackupWorker.TAG_IMPORT_BACKUP);
+        this.inProgressImport =
+                Transformations.map(
+                        imports, infos -> Iterables.any(infos, i -> !i.getState().isFinished()));
+
+        this.inProgressImport.observe(
+                this, inProgress -> setLoadingState(Boolean.TRUE.equals(inProgress)));
+
+        if (savedInstanceState != null) {
+            final var currentWorkRequest = savedInstanceState.getString("current-work-request");
+            if (currentWorkRequest != null) {
+                this.currentWorkRequest = UUID.fromString(currentWorkRequest);
+            }
+        }
+        monitorWorkRequest(this.currentWorkRequest);
+
         this.backupFileAdapter = new BackupFileAdapter();
         this.binding.list.setAdapter(this.backupFileAdapter);
         this.backupFileAdapter.setOnItemClickedListener(this);
@@ -88,31 +104,33 @@ public class ImportBackupActivity extends ActionBarActivity
     public boolean onCreateOptionsMenu(final Menu menu) {
         getMenuInflater().inflate(R.menu.import_backup, menu);
         final MenuItem openBackup = menu.findItem(R.id.action_open_backup_file);
-        openBackup.setVisible(!this.mLoadingState);
+        final var inProgress =
+                this.inProgressImport == null ? null : this.inProgressImport.getValue();
+        openBackup.setVisible(!Boolean.TRUE.equals(inProgress));
         return true;
     }
 
     @Override
-    public void onSaveInstanceState(Bundle bundle) {
-        bundle.putBoolean("loading_state", this.mLoadingState);
+    public void onSaveInstanceState(@NonNull final Bundle bundle) {
+        if (this.currentWorkRequest != null) {
+            bundle.putString("current-work-request", this.currentWorkRequest.toString());
+        }
         super.onSaveInstanceState(bundle);
     }
 
     @Override
     public void onStart() {
-
         super.onStart();
-        bindService(new Intent(this, ImportBackupService.class), this, Context.BIND_AUTO_CREATE);
-        final Intent intent = getIntent();
-        if (intent != null
-                && Intent.ACTION_VIEW.equals(intent.getAction())
-                && !this.mLoadingState) {
-            Uri uri = intent.getData();
-            if (uri != null) {
-                openBackupFileFromUri(uri, true);
-                return;
-            }
+
+        final var intent = getIntent();
+        final var action = intent == null ? null : intent.getAction();
+        final var data = intent == null ? null : intent.getData();
+        if (Intent.ACTION_VIEW.equals(action) && data != null) {
+            openBackupFileFromUri(data, true);
+            setIntent(new Intent(Intent.ACTION_MAIN));
+            return;
         }
+
         final List<String> desiredPermission;
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
             desiredPermission =
@@ -154,44 +172,50 @@ public class ImportBackupActivity extends ActionBarActivity
     @Override
     public void onStop() {
         super.onStop();
-        if (this.service != null) {
-            this.service.removeOnBackupProcessedListener(this);
-        }
-        unbindService(this);
     }
 
-    @Override
-    public void onServiceConnected(ComponentName name, IBinder service) {
-        ImportBackupService.ImportBackupServiceBinder binder =
-                (ImportBackupService.ImportBackupServiceBinder) service;
-        this.service = binder.getService();
-        this.service.addOnBackupProcessedListener(this);
-        setLoadingState(this.service.getLoadingState());
-        this.service.loadBackupFiles(this);
-    }
-
-    @Override
-    public void onServiceDisconnected(ComponentName name) {
-        this.service = null;
-    }
+    private void loadBackupFiles() {
+        final var future = BackupFile.listAsync(getApplicationContext());
+        Futures.addCallback(
+                future,
+                new FutureCallback<>() {
+                    @Override
+                    public void onSuccess(List<BackupFile> files) {
+                        runOnUiThread(() -> backupFileAdapter.setFiles(files));
+                    }
 
-    @Override
-    public void onBackupFilesLoaded(final List<ImportBackupService.BackupFile> files) {
-        runOnUiThread(() -> backupFileAdapter.setFiles(files));
+                    @Override
+                    public void onFailure(@NonNull Throwable t) {}
+                },
+                ContextCompat.getMainExecutor(getApplication()));
     }
 
     @Override
-    public void onClick(final ImportBackupService.BackupFile backupFile) {
+    public void onClick(final BackupFile backupFile) {
         showEnterPasswordDialog(backupFile, false);
     }
 
     private void openBackupFileFromUri(final Uri uri, final boolean finishOnCancel) {
-        final var backupFileFuture = ImportBackupService.read(this, uri);
+        final var backupFileFuture = BackupFile.readAsync(this, uri);
         Futures.addCallback(
                 backupFileFuture,
                 new FutureCallback<>() {
                     @Override
-                    public void onSuccess(final ImportBackupService.BackupFile backupFile) {
+                    public void onSuccess(final BackupFile backupFile) {
+                        if (QuickConversationsService.isQuicksy()) {
+                            if (!backupFile
+                                    .getHeader()
+                                    .getJid()
+                                    .getDomain()
+                                    .equals(Config.QUICKSY_DOMAIN)) {
+                                Snackbar.make(
+                                                binding.coordinator,
+                                                R.string.non_quicksy_backup,
+                                                Snackbar.LENGTH_LONG)
+                                        .show();
+                                return;
+                            }
+                        }
                         showEnterPasswordDialog(backupFile, finishOnCancel);
                     }
 
@@ -201,7 +225,7 @@ public class ImportBackupActivity extends ActionBarActivity
                         showBackupThrowable(throwable);
                     }
                 },
-                MainThreadExecutor.getInstance());
+                ContextCompat.getMainExecutor(getApplication()));
     }
 
     private void showBackupThrowable(final Throwable throwable) {
@@ -216,6 +240,7 @@ public class ImportBackupActivity extends ActionBarActivity
             Snackbar.make(binding.coordinator, R.string.not_a_backup_file, Snackbar.LENGTH_LONG)
                     .show();
         } else if (throwable instanceof SecurityException e) {
+            Log.d(Config.LOGTAG, "not able to parse backup file", e);
             Snackbar.make(
                             binding.coordinator,
                             R.string.sharing_application_not_grant_permission,
@@ -225,7 +250,7 @@ public class ImportBackupActivity extends ActionBarActivity
     }
 
     private void showEnterPasswordDialog(
-            final ImportBackupService.BackupFile backupFile, final boolean finishOnCancel) {
+            final BackupFile backupFile, final boolean finishOnCancel) {
         final DialogEnterPasswordBinding enterPasswordBinding =
                 DataBindingUtil.inflate(
                         LayoutInflater.from(this), R.layout.dialog_enter_password, null, false);
@@ -247,133 +272,125 @@ public class ImportBackupActivity extends ActionBarActivity
         builder.setPositiveButton(R.string.restore, null);
         builder.setCancelable(false);
         final AlertDialog dialog = builder.create();
-        dialog.setOnShowListener(
-                (d) -> {
-                    dialog.getButton(DialogInterface.BUTTON_POSITIVE)
-                            .setOnClickListener(
-                                    v -> {
-                                        final String password =
-                                                enterPasswordBinding
-                                                        .accountPassword
-                                                        .getEditableText()
-                                                        .toString();
-                                        if (password.isEmpty()) {
-                                            enterPasswordBinding.accountPasswordLayout.setError(
-                                                    getString(R.string.please_enter_password));
-                                            return;
-                                        }
-                                        final Intent intent = getIntent(backupFile, password);
-                                        setLoadingState(true);
-                                        ContextCompat.startForegroundService(this, intent);
-                                        d.dismiss();
-                                    });
-                });
+        dialog.setOnShowListener((d) -> onDialogShow(backupFile, d, enterPasswordBinding));
         dialog.show();
     }
 
-    @NonNull
-    private Intent getIntent(ImportBackupService.BackupFile backupFile, String password) {
-        final Uri uri = backupFile.getUri();
-        Intent intent = new Intent(this, ImportBackupService.class);
-        intent.setAction(Intent.ACTION_SEND);
-        intent.putExtra("password", password);
-        if ("file".equals(uri.getScheme())) {
-            intent.putExtra("file", uri.getPath());
-        } else {
-            intent.setData(uri);
-            intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+    private void onDialogShow(
+            final BackupFile backupFile,
+            final DialogInterface d,
+            final DialogEnterPasswordBinding enterPasswordBinding) {
+        if (d instanceof AlertDialog alertDialog) {
+            alertDialog
+                    .getButton(DialogInterface.BUTTON_POSITIVE)
+                    .setOnClickListener(v -> onRestoreClick(backupFile, d, enterPasswordBinding));
         }
-        return intent;
+    }
+
+    private void onRestoreClick(
+            final BackupFile backupFile,
+            final DialogInterface d,
+            final DialogEnterPasswordBinding enterPasswordBinding) {
+        final String password = enterPasswordBinding.accountPassword.getEditableText().toString();
+        if (password.isEmpty()) {
+            enterPasswordBinding.accountPasswordLayout.setError(
+                    getString(R.string.please_enter_password));
+            return;
+        }
+
+        importBackup(backupFile, password, enterPasswordBinding.includeKeys.isChecked());
+        d.dismiss();
+    }
+
+    private void importBackup(
+            final BackupFile backupFile, final String password, final boolean includeOmemo) {
+        final OneTimeWorkRequest importBackupWorkRequest =
+                new OneTimeWorkRequest.Builder(ImportBackupWorker.class)
+                        .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
+                        .setInputData(
+                                ImportBackupWorker.data(
+                                        password, backupFile.getUri(), includeOmemo))
+                        .addTag(ImportBackupWorker.TAG_IMPORT_BACKUP)
+                        .build();
+
+        final var id = importBackupWorkRequest.getId();
+        this.currentWorkRequest = id;
+        monitorWorkRequest(id);
+
+        final var workManager = WorkManager.getInstance(this);
+        workManager.enqueue(importBackupWorkRequest);
+    }
+
+    private void monitorWorkRequest(final UUID uuid) {
+        if (uuid == null) {
+            return;
+        }
+        Log.d(Config.LOGTAG, "monitorWorkRequest(" + uuid + ")");
+        final var workInfoLiveData = WorkManager.getInstance(this).getWorkInfoByIdLiveData(uuid);
+        workInfoLiveData.observe(
+                this,
+                workInfo -> {
+                    final var state = workInfo.getState();
+                    if (state.isFinished()) {
+                        this.currentWorkRequest = null;
+                    }
+                    if (state == WorkInfo.State.FAILED) {
+                        final var data = workInfo.getOutputData();
+                        final var reason =
+                                ImportBackupWorker.Reason.valueOfOrGeneric(
+                                        data.getString("reason"));
+                        switch (reason) {
+                            case DECRYPTION_FAILED -> onBackupDecryptionFailed();
+                            case ACCOUNT_ALREADY_EXISTS -> onAccountAlreadySetup();
+                            default -> onBackupRestoreFailed();
+                        }
+                    } else if (state == WorkInfo.State.SUCCEEDED) {
+                        onBackupRestored();
+                    }
+                });
     }
 
     private void setLoadingState(final boolean loadingState) {
+        Log.d(Config.LOGTAG, "setLoadingState(" + loadingState + ")");
         binding.coordinator.setVisibility(loadingState ? View.GONE : View.VISIBLE);
         binding.inProgress.setVisibility(loadingState ? View.VISIBLE : View.GONE);
         setTitle(loadingState ? R.string.restoring_backup : R.string.restore_backup);
         Activities.setStatusAndNavigationBarColors(this, binding.getRoot());
         configureActionBar(getSupportActionBar(), !loadingState);
-        this.mLoadingState = loadingState;
         invalidateOptionsMenu();
     }
 
-    @Override
-    public void onActivityResult(int requestCode, int resultCode, Intent intent) {
-        super.onActivityResult(requestCode, resultCode, intent);
-        if (resultCode == RESULT_OK) {
-            if (requestCode == 0xbac) {
-                openBackupFileFromUri(intent.getData(), false);
-            }
-        }
-    }
-
-    @Override
-    public void onAccountAlreadySetup() {
-        runOnUiThread(
-                () -> {
-                    setLoadingState(false);
-                    Snackbar.make(
-                                    binding.coordinator,
-                                    R.string.account_already_setup,
-                                    Snackbar.LENGTH_LONG)
-                            .show();
-                });
+    private void onAccountAlreadySetup() {
+        Snackbar.make(binding.coordinator, R.string.account_already_setup, Snackbar.LENGTH_LONG)
+                .show();
     }
 
-    @Override
-    public void onBackupRestored() {
-        runOnUiThread(
-                () -> {
-                    Intent intent = new Intent(this, ConversationActivity.class);
-                    intent.addFlags(
-                            Intent.FLAG_ACTIVITY_CLEAR_TOP
-                                    | Intent.FLAG_ACTIVITY_NEW_TASK
-                                    | Intent.FLAG_ACTIVITY_CLEAR_TASK);
-                    startActivity(intent);
-                    finish();
-                });
+    private void onBackupRestored() {
+        final Intent intent = new Intent(this, ConversationActivity.class);
+        intent.addFlags(
+                Intent.FLAG_ACTIVITY_CLEAR_TOP
+                        | Intent.FLAG_ACTIVITY_NEW_TASK
+                        | Intent.FLAG_ACTIVITY_CLEAR_TASK);
+        startActivity(intent);
+        finish();
     }
 
-    @Override
-    public void onBackupDecryptionFailed() {
-        runOnUiThread(
-                () -> {
-                    setLoadingState(false);
-                    Snackbar.make(
-                                    binding.coordinator,
-                                    R.string.unable_to_decrypt_backup,
-                                    Snackbar.LENGTH_LONG)
-                            .show();
-                });
+    private void onBackupDecryptionFailed() {
+        Snackbar.make(binding.coordinator, R.string.unable_to_decrypt_backup, Snackbar.LENGTH_LONG)
+                .show();
     }
 
-    @Override
-    public void onBackupRestoreFailed() {
-        runOnUiThread(
-                () -> {
-                    setLoadingState(false);
-                    Snackbar.make(
-                                    binding.coordinator,
-                                    R.string.unable_to_restore_backup,
-                                    Snackbar.LENGTH_LONG)
-                            .show();
-                });
+    private void onBackupRestoreFailed() {
+        Snackbar.make(binding.coordinator, R.string.unable_to_restore_backup, Snackbar.LENGTH_LONG)
+                .show();
     }
 
     @Override
     public boolean onOptionsItemSelected(MenuItem item) {
         if (item.getItemId() == R.id.action_open_backup_file) {
-            openBackupFile();
+            this.openBackup.launch("*/*");
             return true;
         }
         return super.onOptionsItemSelected(item);
     }
-
-    private void openBackupFile() {
-        final Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
-        intent.setType("*/*");
-        intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, false);
-        intent.addCategory(Intent.CATEGORY_OPENABLE);
-        startActivityForResult(
-                Intent.createChooser(intent, getString(R.string.open_backup)), 0xbac);
-    }
 }

src/conversations/java/eu/siacs/conversations/ui/adapter/BackupFileAdapter.java โ†’ src/main/java/eu/siacs/conversations/ui/adapter/BackupFileAdapter.java ๐Ÿ”—

@@ -11,49 +11,62 @@ import android.view.LayoutInflater;
 import android.view.View;
 import android.view.ViewGroup;
 import android.widget.ImageView;
-
 import androidx.annotation.NonNull;
 import androidx.databinding.DataBindingUtil;
 import androidx.recyclerview.widget.RecyclerView;
-
-import java.lang.ref.WeakReference;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.concurrent.RejectedExecutionException;
-
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.databinding.ItemAccountBinding;
 import eu.siacs.conversations.services.AvatarService;
-import eu.siacs.conversations.services.ImportBackupService;
+import eu.siacs.conversations.utils.BackupFile;
 import eu.siacs.conversations.utils.BackupFileHeader;
 import eu.siacs.conversations.utils.UIHelper;
 import eu.siacs.conversations.xmpp.Jid;
+import java.lang.ref.WeakReference;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.RejectedExecutionException;
 
-public class BackupFileAdapter extends RecyclerView.Adapter<BackupFileAdapter.BackupFileViewHolder> {
+public class BackupFileAdapter
+        extends RecyclerView.Adapter<BackupFileAdapter.BackupFileViewHolder> {
 
     private OnItemClickedListener listener;
 
-    private final List<ImportBackupService.BackupFile> files = new ArrayList<>();
-
+    private final List<BackupFile> files = new ArrayList<>();
 
     @NonNull
     @Override
     public BackupFileViewHolder onCreateViewHolder(@NonNull ViewGroup viewGroup, int i) {
-        return new BackupFileViewHolder(DataBindingUtil.inflate(LayoutInflater.from(viewGroup.getContext()), R.layout.item_account, viewGroup, false));
+        return new BackupFileViewHolder(
+                DataBindingUtil.inflate(
+                        LayoutInflater.from(viewGroup.getContext()),
+                        R.layout.item_account,
+                        viewGroup,
+                        false));
     }
 
     @Override
     public void onBindViewHolder(@NonNull BackupFileViewHolder backupFileViewHolder, int position) {
-        final ImportBackupService.BackupFile backupFile = files.get(position);
+        final BackupFile backupFile = files.get(position);
         final BackupFileHeader header = backupFile.getHeader();
         backupFileViewHolder.binding.accountJid.setText(header.getJid().asBareJid().toString());
-        backupFileViewHolder.binding.accountStatus.setText(String.format("%s ยท %s",header.getApp(), DateUtils.formatDateTime(backupFileViewHolder.binding.getRoot().getContext(), header.getTimestamp(), DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_YEAR)));
+        backupFileViewHolder.binding.accountStatus.setText(
+                String.format(
+                        "%s ยท %s",
+                        header.getApp(),
+                        DateUtils.formatDateTime(
+                                backupFileViewHolder.binding.getRoot().getContext(),
+                                header.getTimestamp(),
+                                DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_YEAR)));
         backupFileViewHolder.binding.tglAccountStatus.setVisibility(View.GONE);
-        backupFileViewHolder.binding.getRoot().setOnClickListener(v -> {
-            if (listener != null) {
-                listener.onClick(backupFile);
-            }
-        });
+        backupFileViewHolder
+                .binding
+                .getRoot()
+                .setOnClickListener(
+                        v -> {
+                            if (listener != null) {
+                                listener.onClick(backupFile);
+                            }
+                        });
         loadAvatar(header.getJid(), backupFileViewHolder.binding.accountImage);
     }
 
@@ -62,7 +75,7 @@ public class BackupFileAdapter extends RecyclerView.Adapter<BackupFileAdapter.Ba
         return files.size();
     }
 
-    public void setFiles(List<ImportBackupService.BackupFile> files) {
+    public void setFiles(List<BackupFile> files) {
         this.files.clear();
         this.files.addAll(files);
         notifyDataSetChanged();
@@ -79,22 +92,21 @@ public class BackupFileAdapter extends RecyclerView.Adapter<BackupFileAdapter.Ba
             super(binding.getRoot());
             this.binding = binding;
         }
-
     }
 
     public interface OnItemClickedListener {
-        void onClick(ImportBackupService.BackupFile backupFile);
+        void onClick(BackupFile backupFile);
     }
 
     static class BitmapWorkerTask extends AsyncTask<Jid, Void, Bitmap> {
         private final WeakReference<ImageView> imageViewReference;
-        private Jid jid  = null;
+        private Jid jid = null;
         private final int size;
 
         BitmapWorkerTask(final ImageView imageView) {
             imageViewReference = new WeakReference<>(imageView);
             DisplayMetrics metrics = imageView.getContext().getResources().getDisplayMetrics();
-		this.size = ((int) (48 * metrics.density));
+            this.size = ((int) (48 * metrics.density));
         }
 
         @Override
@@ -120,7 +132,8 @@ public class BackupFileAdapter extends RecyclerView.Adapter<BackupFileAdapter.Ba
             imageView.setBackgroundColor(UIHelper.getColorForName(jid.asBareJid().toString()));
             imageView.setImageDrawable(null);
             final BitmapWorkerTask task = new BitmapWorkerTask(imageView);
-            final AsyncDrawable asyncDrawable = new AsyncDrawable(imageView.getContext().getResources(), null, task);
+            final AsyncDrawable asyncDrawable =
+                    new AsyncDrawable(imageView.getContext().getResources(), null, task);
             imageView.setImageDrawable(asyncDrawable);
             try {
                 task.execute(jid);
@@ -165,5 +178,4 @@ public class BackupFileAdapter extends RecyclerView.Adapter<BackupFileAdapter.Ba
             return bitmapWorkerTaskReference.get();
         }
     }
-
-}
+}

src/main/java/eu/siacs/conversations/utils/BackupFile.java ๐Ÿ”—

@@ -0,0 +1,140 @@
+package eu.siacs.conversations.utils;
+
+import android.content.Context;
+import android.net.Uri;
+import android.util.Log;
+import com.google.common.collect.Collections2;
+import com.google.common.collect.ComparisonChain;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Ordering;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import eu.siacs.conversations.Config;
+import eu.siacs.conversations.R;
+import eu.siacs.conversations.persistance.DatabaseBackend;
+import eu.siacs.conversations.persistance.FileBackend;
+import eu.siacs.conversations.services.QuickConversationsService;
+import eu.siacs.conversations.xmpp.Jid;
+import java.io.DataInputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+public class BackupFile implements Comparable<BackupFile> {
+
+    private static final ExecutorService BACKUP_FILE_READER_EXECUTOR =
+            Executors.newSingleThreadExecutor();
+
+    private final Uri uri;
+    private final BackupFileHeader header;
+
+    private BackupFile(Uri uri, BackupFileHeader header) {
+        this.uri = uri;
+        this.header = header;
+    }
+
+    public static ListenableFuture<BackupFile> readAsync(final Context context, final Uri uri) {
+        return Futures.submit(() -> read(context, uri), BACKUP_FILE_READER_EXECUTOR);
+    }
+
+    private static BackupFile read(final File file) throws IOException {
+        final FileInputStream fileInputStream = new FileInputStream(file);
+        final DataInputStream dataInputStream = new DataInputStream(fileInputStream);
+        BackupFileHeader backupFileHeader = BackupFileHeader.read(dataInputStream);
+        fileInputStream.close();
+        return new BackupFile(Uri.fromFile(file), backupFileHeader);
+    }
+
+    public static BackupFile read(final Context context, final Uri uri) throws IOException {
+        final InputStream inputStream = context.getContentResolver().openInputStream(uri);
+        if (inputStream == null) {
+            throw new FileNotFoundException();
+        }
+        final DataInputStream dataInputStream = new DataInputStream(inputStream);
+        final BackupFileHeader backupFileHeader = BackupFileHeader.read(dataInputStream);
+        inputStream.close();
+        return new BackupFile(uri, backupFileHeader);
+    }
+
+    public BackupFileHeader getHeader() {
+        return header;
+    }
+
+    public Uri getUri() {
+        return uri;
+    }
+
+    public static ListenableFuture<List<BackupFile>> listAsync(final Context context) {
+        return Futures.submit(() -> list(context), BACKUP_FILE_READER_EXECUTOR);
+    }
+
+    private static List<BackupFile> list(final Context context) {
+        final var database = DatabaseBackend.getInstance(context);
+        final List<Jid> accounts = database.getAccountJids(false);
+        final var backupFiles = new ImmutableList.Builder<BackupFile>();
+        final var apps =
+                ImmutableSet.of("Conversations", "Quicksy", context.getString(R.string.app_name));
+        final List<File> directories = new ArrayList<>();
+        for (final String app : apps) {
+            directories.add(FileBackend.getLegacyBackupDirectory(app));
+        }
+        directories.add(FileBackend.getBackupDirectory(context));
+        for (final File directory : directories) {
+            if (!directory.exists() || !directory.isDirectory()) {
+                Log.d(Config.LOGTAG, "directory not found: " + directory.getAbsolutePath());
+                continue;
+            }
+            final File[] files = directory.listFiles();
+            if (files == null) {
+                continue;
+            }
+            Log.d(Config.LOGTAG, "looking for backups in " + directory);
+            for (final File file : files) {
+                if (file.isFile() && file.getName().endsWith(".ceb")) {
+                    try {
+                        final BackupFile backupFile = BackupFile.read(file);
+                        if (accounts.contains(backupFile.getHeader().getJid())) {
+                            Log.d(
+                                    Config.LOGTAG,
+                                    "skipping backup for " + backupFile.getHeader().getJid());
+                        } else {
+                            backupFiles.add(backupFile);
+                        }
+                    } catch (final IOException
+                            | IllegalArgumentException
+                            | BackupFileHeader.OutdatedBackupFileVersion e) {
+                        Log.d(Config.LOGTAG, "unable to read backup file ", e);
+                    }
+                }
+            }
+        }
+        final var list = backupFiles.build();
+        if (QuickConversationsService.isQuicksy()) {
+            return Ordering.natural()
+                    .immutableSortedCopy(
+                            Collections2.filter(
+                                    list,
+                                    b ->
+                                            b.header
+                                                    .getJid()
+                                                    .getDomain()
+                                                    .equals(Config.QUICKSY_DOMAIN)));
+        }
+        return Ordering.natural().immutableSortedCopy(backupFiles.build());
+    }
+
+    @Override
+    public int compareTo(final BackupFile o) {
+        return ComparisonChain.start()
+                .compare(header.getJid(), o.header.getJid())
+                .compare(header.getTimestamp(), o.header.getTimestamp())
+                .result();
+    }
+}

src/main/java/eu/siacs/conversations/worker/ExportBackupWorker.java ๐Ÿ”—

@@ -31,6 +31,7 @@ import eu.siacs.conversations.entities.Conversation;
 import eu.siacs.conversations.entities.Message;
 import eu.siacs.conversations.persistance.DatabaseBackend;
 import eu.siacs.conversations.persistance.FileBackend;
+import eu.siacs.conversations.services.QuickConversationsService;
 import eu.siacs.conversations.utils.BackupFileHeader;
 import eu.siacs.conversations.utils.Compatibility;
 import java.io.DataOutputStream;
@@ -65,9 +66,9 @@ public class ExportBackupWorker extends Worker {
     private static final SimpleDateFormat DATE_FORMAT =
             new SimpleDateFormat("yyyy-MM-dd-HH-mm", Locale.US);
 
-    public static final String KEYTYPE = "AES";
-    public static final String CIPHERMODE = "AES/GCM/NoPadding";
-    public static final String PROVIDER = "BC";
+    private static final String KEY_TYPE = "AES";
+    private static final String CIPHER_MODE = "AES/GCM/NoPadding";
+    private static final String PROVIDER = "BC";
 
     public static final String MIME_TYPE = "application/vnd.conversations.backup";
 
@@ -240,10 +241,10 @@ public class ExportBackupWorker extends Worker {
 
         final Cipher cipher =
                 Compatibility.twentyEight()
-                        ? Cipher.getInstance(CIPHERMODE)
-                        : Cipher.getInstance(CIPHERMODE, PROVIDER);
+                        ? Cipher.getInstance(CIPHER_MODE)
+                        : Cipher.getInstance(CIPHER_MODE, PROVIDER);
         final byte[] key = getKey(password, salt);
-        SecretKeySpec keySpec = new SecretKeySpec(key, KEYTYPE);
+        SecretKeySpec keySpec = new SecretKeySpec(key, KEY_TYPE);
         IvParameterSpec ivSpec = new IvParameterSpec(IV);
         cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
         CipherOutputStream cipherOutputStream = new CipherOutputStream(fileOutputStream, cipher);
@@ -326,7 +327,9 @@ public class ExportBackupWorker extends Worker {
                     } else if (Account.OPTIONS.equals(accountCursor.getColumnName(i))
                             && value.matches("\\d+")) {
                         int intValue = Integer.parseInt(value);
-                        intValue |= 1 << Account.OPTION_DISABLED;
+                        if (QuickConversationsService.isConversations()) {
+                            intValue |= 1 << Account.OPTION_DISABLED;
+                        }
                         writer.value(intValue);
                     } else {
                         writer.value(value);

src/main/java/eu/siacs/conversations/worker/ImportBackupWorker.java ๐Ÿ”—

@@ -0,0 +1,389 @@
+package eu.siacs.conversations.worker;
+
+import static eu.siacs.conversations.utils.Compatibility.s;
+
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.Intent;
+import android.database.Cursor;
+import android.database.sqlite.SQLiteDatabase;
+import android.net.Uri;
+import android.provider.OpenableColumns;
+import android.util.Log;
+import androidx.annotation.NonNull;
+import androidx.core.app.NotificationCompat;
+import androidx.work.Data;
+import androidx.work.ForegroundInfo;
+import androidx.work.Worker;
+import androidx.work.WorkerParameters;
+import com.google.common.base.Stopwatch;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.io.CountingInputStream;
+import com.google.gson.stream.JsonReader;
+import com.google.gson.stream.JsonToken;
+import eu.siacs.conversations.Config;
+import eu.siacs.conversations.R;
+import eu.siacs.conversations.crypto.axolotl.SQLiteAxolotlStore;
+import eu.siacs.conversations.entities.Account;
+import eu.siacs.conversations.entities.Conversation;
+import eu.siacs.conversations.entities.Message;
+import eu.siacs.conversations.persistance.DatabaseBackend;
+import eu.siacs.conversations.services.QuickConversationsService;
+import eu.siacs.conversations.services.XmppConnectionService;
+import eu.siacs.conversations.utils.AccountUtils;
+import eu.siacs.conversations.utils.BackupFileHeader;
+import eu.siacs.conversations.xmpp.Jid;
+import java.io.BufferedReader;
+import java.io.DataInputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.security.spec.InvalidKeySpecException;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.regex.Pattern;
+import java.util.zip.GZIPInputStream;
+import java.util.zip.ZipException;
+import javax.crypto.BadPaddingException;
+import org.bouncycastle.crypto.engines.AESEngine;
+import org.bouncycastle.crypto.io.CipherInputStream;
+import org.bouncycastle.crypto.modes.AEADBlockCipher;
+import org.bouncycastle.crypto.modes.GCMBlockCipher;
+import org.bouncycastle.crypto.params.AEADParameters;
+import org.bouncycastle.crypto.params.KeyParameter;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+public class ImportBackupWorker extends Worker {
+
+    public static final String TAG_IMPORT_BACKUP = "tag-import-backup";
+
+    private static final String DATA_KEY_PASSWORD = "password";
+    private static final String DATA_KEY_URI = "uri";
+    private static final String DATA_KEY_INCLUDE_OMEMO = "omemo";
+
+    private static final Collection<String> OMEMO_TABLE_LIST =
+            Arrays.asList(
+                    SQLiteAxolotlStore.PREKEY_TABLENAME,
+                    SQLiteAxolotlStore.SIGNED_PREKEY_TABLENAME,
+                    SQLiteAxolotlStore.SESSION_TABLENAME,
+                    SQLiteAxolotlStore.IDENTITIES_TABLENAME);
+
+    private static final List<String> TABLE_ALLOW_LIST =
+            new ImmutableList.Builder<String>()
+                    .add(Account.TABLENAME, Conversation.TABLENAME, Message.TABLENAME)
+                    .addAll(OMEMO_TABLE_LIST)
+                    .build();
+
+    private static final Pattern COLUMN_PATTERN = Pattern.compile("^[a-zA-Z_]+$");
+
+    private static final int NOTIFICATION_ID = 21;
+
+    private final String password;
+    private final Uri uri;
+    private final boolean includeOmemo;
+
+    public ImportBackupWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
+        super(context, workerParams);
+        final var inputData = workerParams.getInputData();
+        this.password = inputData.getString(DATA_KEY_PASSWORD);
+        this.uri = Uri.parse(inputData.getString(DATA_KEY_URI));
+        this.includeOmemo = inputData.getBoolean(DATA_KEY_INCLUDE_OMEMO, true);
+    }
+
+    @NonNull
+    @Override
+    public Result doWork() {
+        setForegroundAsync(
+                new ForegroundInfo(NOTIFICATION_ID, createImportBackupNotification(1, 0)));
+        final Result result;
+        try {
+            result = importBackup(this.uri, this.password);
+        } catch (final FileNotFoundException e) {
+            return failure(Reason.FILE_NOT_FOUND);
+        } catch (final Exception e) {
+            Log.d(Config.LOGTAG, "error restoring backup " + uri, e);
+            final Throwable throwable = e.getCause();
+            if (throwable instanceof BadPaddingException || e instanceof ZipException) {
+                return failure(Reason.DECRYPTION_FAILED);
+            } else {
+                return failure(Reason.GENERIC);
+            }
+        } finally {
+            getApplicationContext()
+                    .getSystemService(NotificationManager.class)
+                    .cancel(NOTIFICATION_ID);
+        }
+
+        return result;
+    }
+
+    private Result importBackup(final Uri uri, final String password)
+            throws IOException, InvalidKeySpecException {
+        final var context = getApplicationContext();
+        final var database = DatabaseBackend.getInstance(context);
+        Log.d(Config.LOGTAG, "importing backup from " + uri);
+        final Stopwatch stopwatch = Stopwatch.createStarted();
+        final SQLiteDatabase db = database.getWritableDatabase();
+        final InputStream inputStream;
+        final String path = uri.getPath();
+        final long fileSize;
+        if ("file".equals(uri.getScheme()) && path != null) {
+            final File file = new File(path);
+            inputStream = new FileInputStream(file);
+            fileSize = file.length();
+        } else {
+            final Cursor returnCursor =
+                    context.getContentResolver().query(uri, null, null, null, null);
+            if (returnCursor == null) {
+                fileSize = 0;
+            } else {
+                returnCursor.moveToFirst();
+                fileSize =
+                        returnCursor.getLong(
+                                returnCursor.getColumnIndexOrThrow(OpenableColumns.SIZE));
+                returnCursor.close();
+            }
+            inputStream = context.getContentResolver().openInputStream(uri);
+        }
+        if (inputStream == null) {
+            return failure(Reason.FILE_NOT_FOUND);
+        }
+        final CountingInputStream countingInputStream = new CountingInputStream(inputStream);
+        final DataInputStream dataInputStream = new DataInputStream(countingInputStream);
+        final BackupFileHeader backupFileHeader = BackupFileHeader.read(dataInputStream);
+        Log.d(Config.LOGTAG, backupFileHeader.toString());
+
+        final var accounts = database.getAccountJids(false);
+
+        if (QuickConversationsService.isQuicksy() && !accounts.isEmpty()) {
+            return failure(Reason.ACCOUNT_ALREADY_EXISTS);
+        }
+
+        if (accounts.contains(backupFileHeader.getJid())) {
+            return failure(Reason.ACCOUNT_ALREADY_EXISTS);
+        }
+
+        final byte[] key = ExportBackupWorker.getKey(password, backupFileHeader.getSalt());
+
+        final AEADBlockCipher cipher = GCMBlockCipher.newInstance(AESEngine.newInstance());
+        cipher.init(
+                false, new AEADParameters(new KeyParameter(key), 128, backupFileHeader.getIv()));
+        final CipherInputStream cipherInputStream =
+                new CipherInputStream(countingInputStream, cipher);
+
+        final GZIPInputStream gzipInputStream = new GZIPInputStream(cipherInputStream);
+        final BufferedReader reader =
+                new BufferedReader(new InputStreamReader(gzipInputStream, StandardCharsets.UTF_8));
+        final JsonReader jsonReader = new JsonReader(reader);
+        if (jsonReader.peek() == JsonToken.BEGIN_ARRAY) {
+            jsonReader.beginArray();
+        } else {
+            throw new IllegalStateException("Backup file did not begin with array");
+        }
+        db.beginTransaction();
+        while (jsonReader.hasNext()) {
+            if (jsonReader.peek() == JsonToken.BEGIN_OBJECT) {
+                importRow(db, jsonReader, backupFileHeader.getJid(), password);
+            } else if (jsonReader.peek() == JsonToken.END_ARRAY) {
+                jsonReader.endArray();
+                continue;
+            }
+            updateImportBackupNotification(fileSize, countingInputStream.getCount());
+        }
+        db.setTransactionSuccessful();
+        db.endTransaction();
+        final Jid jid = backupFileHeader.getJid();
+        final Cursor countCursor =
+                db.rawQuery(
+                        "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=?",
+                        new String[] {jid.getLocal(), jid.getDomain().toString()});
+        countCursor.moveToFirst();
+        final int count = countCursor.getInt(0);
+        Log.d(Config.LOGTAG, String.format("restored %d messages in %s", count, stopwatch.stop()));
+        countCursor.close();
+        stopBackgroundService();
+        notifySuccess();
+        return Result.success();
+    }
+
+    private void importRow(
+            final SQLiteDatabase db,
+            final JsonReader jsonReader,
+            final Jid account,
+            final String passphrase)
+            throws IOException {
+        jsonReader.beginObject();
+        final String firstParameter = jsonReader.nextName();
+        if (!firstParameter.equals("table")) {
+            throw new IllegalStateException("Expected key 'table'");
+        }
+        final String table = jsonReader.nextString();
+        if (!TABLE_ALLOW_LIST.contains(table)) {
+            throw new IOException(String.format("%s is not recognized for import", table));
+        }
+        final ContentValues contentValues = new ContentValues();
+        final String secondParameter = jsonReader.nextName();
+        if (!secondParameter.equals("values")) {
+            throw new IllegalStateException("Expected key 'values'");
+        }
+        jsonReader.beginObject();
+        while (jsonReader.peek() != JsonToken.END_OBJECT) {
+            final String name = jsonReader.nextName();
+            if (COLUMN_PATTERN.matcher(name).matches()) {
+                if (jsonReader.peek() == JsonToken.NULL) {
+                    jsonReader.nextNull();
+                    contentValues.putNull(name);
+                } else if (jsonReader.peek() == JsonToken.NUMBER) {
+                    contentValues.put(name, jsonReader.nextLong());
+                } else {
+                    contentValues.put(name, jsonReader.nextString());
+                }
+            } else {
+                throw new IOException(String.format("Unexpected column name %s", name));
+            }
+        }
+        jsonReader.endObject();
+        jsonReader.endObject();
+        if (Account.TABLENAME.equals(table)) {
+            final Jid jid =
+                    Jid.of(
+                            contentValues.getAsString(Account.USERNAME),
+                            contentValues.getAsString(Account.SERVER),
+                            null);
+            final String password = contentValues.getAsString(Account.PASSWORD);
+            if (QuickConversationsService.isQuicksy()) {
+                if (!jid.getDomain().equals(Config.QUICKSY_DOMAIN)) {
+                    throw new IOException("Trying to restore non Quicksy account on Quicksy");
+                }
+            }
+            if (jid.equals(account) && passphrase.equals(password)) {
+                Log.d(Config.LOGTAG, "jid and password from backup header had matching row");
+            } else {
+                throw new IOException("jid or password in table did not match backup");
+            }
+            final var keys = Account.parseKeys(contentValues.getAsString(Account.KEYS));
+            final var deviceId = keys.optString(SQLiteAxolotlStore.JSONKEY_REGISTRATION_ID);
+            final var importReadyKeys = new JSONObject();
+            if (!Strings.isNullOrEmpty(deviceId) && this.includeOmemo) {
+                try {
+                    importReadyKeys.put(SQLiteAxolotlStore.JSONKEY_REGISTRATION_ID, deviceId);
+                } catch (final JSONException e) {
+                    Log.e(Config.LOGTAG, "error writing omemo registration id", e);
+                }
+            }
+            contentValues.put(Account.KEYS, importReadyKeys.toString());
+        }
+        if (this.includeOmemo) {
+            db.insert(table, null, contentValues);
+        } else {
+            if (OMEMO_TABLE_LIST.contains(table)) {
+                if (SQLiteAxolotlStore.IDENTITIES_TABLENAME.equals(table)
+                        && contentValues.getAsInteger(SQLiteAxolotlStore.OWN) == 0) {
+                    db.insert(table, null, contentValues);
+                } else {
+                    Log.d(Config.LOGTAG, "skipping over omemo key material in table " + table);
+                }
+            } else {
+                db.insert(table, null, contentValues);
+            }
+        }
+    }
+
+    private void stopBackgroundService() {
+        final var intent = new Intent(getApplicationContext(), XmppConnectionService.class);
+        getApplicationContext().stopService(intent);
+    }
+
+    private void updateImportBackupNotification(final long total, final long current) {
+        final int max;
+        final int progress;
+        if (total == 0) {
+            max = 1;
+            progress = 0;
+        } else {
+            max = 100;
+            progress = (int) (current * 100 / total);
+        }
+        getApplicationContext()
+                .getSystemService(NotificationManager.class)
+                .notify(NOTIFICATION_ID, createImportBackupNotification(max, progress));
+    }
+
+    private Notification createImportBackupNotification(final int max, final int progress) {
+        final var context = getApplicationContext();
+        final var builder = new NotificationCompat.Builder(getApplicationContext(), "backup");
+        builder.setContentTitle(context.getString(R.string.restoring_backup))
+                .setSmallIcon(R.drawable.ic_unarchive_24dp)
+                .setProgress(max, progress, max == 1 && progress == 0);
+        return builder.build();
+    }
+
+    private void notifySuccess() {
+        final var context = getApplicationContext();
+        final var builder = new NotificationCompat.Builder(context, "backup");
+        builder.setContentTitle(context.getString(R.string.notification_restored_backup_title))
+                .setContentText(context.getString(R.string.notification_restored_backup_subtitle))
+                .setAutoCancel(true)
+                .setSmallIcon(R.drawable.ic_unarchive_24dp);
+        if (QuickConversationsService.isConversations()
+                && AccountUtils.MANAGE_ACCOUNT_ACTIVITY != null) {
+            builder.setContentText(
+                    context.getString(R.string.notification_restored_backup_subtitle));
+            builder.setContentIntent(
+                    PendingIntent.getActivity(
+                            context,
+                            145,
+                            new Intent(context, AccountUtils.MANAGE_ACCOUNT_ACTIVITY),
+                            s()
+                                    ? PendingIntent.FLAG_IMMUTABLE
+                                            | PendingIntent.FLAG_UPDATE_CURRENT
+                                    : PendingIntent.FLAG_UPDATE_CURRENT));
+        }
+        getApplicationContext()
+                .getSystemService(NotificationManager.class)
+                .notify(NOTIFICATION_ID + 2, builder.build());
+    }
+
+    public static Data data(final String password, final Uri uri, final boolean includeOmemo) {
+        return new Data.Builder()
+                .putString(DATA_KEY_PASSWORD, password)
+                .putString(DATA_KEY_URI, uri.toString())
+                .putBoolean(DATA_KEY_INCLUDE_OMEMO, includeOmemo)
+                .build();
+    }
+
+    private static Result failure(final Reason reason) {
+        return Result.failure(new Data.Builder().putString("reason", reason.toString()).build());
+    }
+
+    public enum Reason {
+        ACCOUNT_ALREADY_EXISTS,
+        DECRYPTION_FAILED,
+        FILE_NOT_FOUND,
+        GENERIC;
+
+        public static Reason valueOfOrGeneric(final String value) {
+            if (Strings.isNullOrEmpty(value)) {
+                return GENERIC;
+            }
+            try {
+                return valueOf(value);
+            } catch (final IllegalArgumentException e) {
+                return GENERIC;
+            }
+        }
+    }
+}

src/conversations/res/layout/dialog_enter_password.xml โ†’ src/main/res/layout/dialog_enter_password.xml ๐Ÿ”—

@@ -19,25 +19,40 @@
                 android:text="@string/enter_password_to_restore"
                 android:textAppearance="?textAppearanceBodyMedium" />
 
-            <TextView
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_marginTop="18sp"
-                android:text="@string/restore_warning"
-                android:textAppearance="?textAppearanceBodyMedium" />
+            <com.google.android.material.card.MaterialCardView
+                android:layout_marginTop="16sp"
+                style="?attr/materialCardViewFilledStyle"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content">
+
+                <LinearLayout
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:orientation="vertical"
+                    android:padding="16dp">
+                    <com.google.android.material.materialswitch.MaterialSwitch
+                        android:id="@+id/include_keys"
+                        android:checked="true"
+
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:text="@string/restore_omemo_key"/>
+
+                    <TextView
+                        android:layout_width="wrap_content"
+                        android:layout_height="wrap_content"
+                        android:text="@string/restore_warning"
+                        android:textAppearance="?textAppearanceBodyMedium" />
+                </LinearLayout>
+
+            </com.google.android.material.card.MaterialCardView>
 
-            <TextView
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"
-                android:layout_marginTop="18sp"
-                android:text="@string/restore_warning_continued"
-                android:textAppearance="?textAppearanceBodyMedium" />
 
             <com.google.android.material.textfield.TextInputLayout
                 android:id="@+id/account_password_layout"
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content"
-                android:layout_marginTop="8dp"
+                android:layout_marginTop="16sp"
                 app:endIconMode="password_toggle">
 
                 <eu.siacs.conversations.ui.widget.TextInputEditText
@@ -48,6 +63,12 @@
                     android:inputType="textPassword" />
 
             </com.google.android.material.textfield.TextInputLayout>
+            <TextView
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_marginTop="16sp"
+                android:text="@string/restore_warning_continued"
+                android:textAppearance="?textAppearanceBodySmall" />
         </LinearLayout>
     </ScrollView>
 </layout>

src/main/res/values/strings.xml ๐Ÿ”—

@@ -848,8 +848,9 @@
     <string name="restore_backup">Restore backup</string>
     <string name="restore">Restore</string>
     <string name="enter_password_to_restore">Enter your password for the account %s to restore the backup.</string>
-    <string name="restore_warning">Do not use the restore backup feature in an attempt to clone (run simultaneously) an installation. Restoring a backup is only meant for migrations or in case youโ€™ve lost the original device.</string>
-    <string name="restore_warning_continued">Do not attempt to restore backups that you have not created yourself!</string>
+    <string name="restore_omemo_key">Restore OMEMO keys</string>
+    <string name="restore_warning">Do not restore OMEMO keys in an attempt to clone (run simultaneously) an installation. Restoring OMEMO keys is only meant for migrations or in case youโ€™ve lost the original device.</string>
+    <string name="restore_warning_continued">Only restore backups youโ€™ve personally created.</string>
     <string name="unable_to_restore_backup">Could not restore backup.</string>
     <string name="unable_to_decrypt_backup">Could not decrypt backup. Is the password correct?</string>
     <string name="backup_channel_name">Backup &amp; Restore</string>
@@ -898,6 +899,7 @@
     <string name="open_backup">Open backup</string>
     <string name="not_a_backup_file">The file you selected is not a Conversations backup file</string>
     <string name="outdated_backup_file_format">You are trying to import an outdated backup file format</string>
+    <string name="non_quicksy_backup">Quicksy can only restore backups for quicksy.im accounts</string>
     <string name="account_already_setup">This account has already been setup</string>
     <string name="please_enter_password">Please enter the password for this account</string>
     <string name="unable_to_perform_this_action">Could not perform this action</string>