diff --git a/src/conversations/AndroidManifest.xml b/src/conversations/AndroidManifest.xml
index 5b111101e32d68a31b66cac0df703df02a0dd514..0f5834b49a61c1018d6c29feb82888d923933e7d 100644
--- a/src/conversations/AndroidManifest.xml
+++ b/src/conversations/AndroidManifest.xml
@@ -5,8 +5,8 @@
+ android:launchMode="singleTask"
+ android:theme="@style/Theme.Conversations3" />
-
diff --git a/src/conversations/java/eu/siacs/conversations/services/ImportBackupService.java b/src/conversations/java/eu/siacs/conversations/services/ImportBackupService.java
deleted file mode 100644
index c15208bfa1b8c0fc383dadbcce3d92ff95292aed..0000000000000000000000000000000000000000
--- a/src/conversations/java/eu/siacs/conversations/services/ImportBackupService.java
+++ /dev/null
@@ -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 mOnBackupProcessedListeners =
- Collections.newSetFromMap(new WeakHashMap<>());
- private DatabaseBackend mDatabaseBackend;
- private NotificationManager notificationManager;
-
- private static final Collection 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 accounts = mDatabaseBackend.getAccountJids(false);
- final ArrayList backupFiles = new ArrayList<>();
- final Set apps =
- new HashSet<>(
- Arrays.asList(
- "Conversations",
- "Quicksy",
- getString(R.string.app_name)));
- final List 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 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 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;
- }
- }
-}
diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml
index ddc93f285f4393df4950420a44525b281cf870cd..f765d5dfb58082ae7dc92c10e8b412d77eb92ee3 100644
--- a/src/main/AndroidManifest.xml
+++ b/src/main/AndroidManifest.xml
@@ -120,11 +120,6 @@
android:foregroundServiceType="dataSync"
tools:node="merge" />
-
-
+ android:parentActivityName=".ui.activity.SettingsActivity">
+ android:value="eu.siacs.conversations.ui.activity.SettingsActivity" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/java/eu/siacs/conversations/entities/Account.java b/src/main/java/eu/siacs/conversations/entities/Account.java
index ef5e4152d6be0b700ea10b6d4acb743565cfb0e8..075ee301aa08380e072f67cf3b94dfdb02b5cf07 100644
--- a/src/main/java/eu/siacs/conversations/entities/Account.java
+++ b/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 {
diff --git a/src/conversations/java/eu/siacs/conversations/ui/ImportBackupActivity.java b/src/main/java/eu/siacs/conversations/ui/ImportBackupActivity.java
similarity index 50%
rename from src/conversations/java/eu/siacs/conversations/ui/ImportBackupActivity.java
rename to src/main/java/eu/siacs/conversations/ui/ImportBackupActivity.java
index 331857e29fb2242060ee8a77d8afd4b29f3b9e50..3a81fcd0a4921c4d55f97e71e47c0df07c209d41 100644
--- a/src/conversations/java/eu/siacs/conversations/ui/ImportBackupActivity.java
+++ b/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 inProgressImport;
+ private UUID currentWorkRequest;
+
private final ActivityResultLauncher 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 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 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 files) {
+ runOnUiThread(() -> backupFileAdapter.setFiles(files));
+ }
- @Override
- public void onBackupFilesLoaded(final List 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);
- }
}
diff --git a/src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java b/src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java
index 8435a4da1804df5887555be80a7d6b310984f077..d88a77b4cf45a63eb7abc8521c7e4fde7ebd3651 100644
--- a/src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java
+++ b/src/main/java/eu/siacs/conversations/ui/RtpSessionActivity.java
@@ -1191,6 +1191,7 @@ public class RtpSessionActivity extends XmppActivity
}
},
MainThreadExecutor.getInstance());
+ // TODO ^ replace with ContextCompat.getMainExecutor(getApplication())
}
private void enableVideo(final View view) {
diff --git a/src/conversations/java/eu/siacs/conversations/ui/adapter/BackupFileAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/BackupFileAdapter.java
similarity index 75%
rename from src/conversations/java/eu/siacs/conversations/ui/adapter/BackupFileAdapter.java
rename to src/main/java/eu/siacs/conversations/ui/adapter/BackupFileAdapter.java
index 9f32352eed913b313667e48ed21f968e92e4c094..7ffde3cd3e6280fb433ec74c1fed240aaf76a4a4 100644
--- a/src/conversations/java/eu/siacs/conversations/ui/adapter/BackupFileAdapter.java
+++ b/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 {
+public class BackupFileAdapter
+ extends RecyclerView.Adapter {
private OnItemClickedListener listener;
- private final List files = new ArrayList<>();
-
+ private final List 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 files) {
+ public void setFiles(List files) {
this.files.clear();
this.files.addAll(files);
notifyDataSetChanged();
@@ -79,22 +92,21 @@ public class BackupFileAdapter extends RecyclerView.Adapter {
private final WeakReference 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 {
+
+ 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 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> listAsync(final Context context) {
+ return Futures.submit(() -> list(context), BACKUP_FILE_READER_EXECUTOR);
+ }
+
+ private static List list(final Context context) {
+ final var database = DatabaseBackend.getInstance(context);
+ final List accounts = database.getAccountJids(false);
+ final var backupFiles = new ImmutableList.Builder();
+ final var apps =
+ ImmutableSet.of("Conversations", "Quicksy", context.getString(R.string.app_name));
+ final List 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();
+ }
+}
diff --git a/src/main/java/eu/siacs/conversations/worker/ExportBackupWorker.java b/src/main/java/eu/siacs/conversations/worker/ExportBackupWorker.java
index d848ca54ec2d8a4c1282160d251ad46e0bf67c14..c5178e0adfa91c251b8afa028711964fd1ba5891 100644
--- a/src/main/java/eu/siacs/conversations/worker/ExportBackupWorker.java
+++ b/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);
diff --git a/src/main/java/eu/siacs/conversations/worker/ImportBackupWorker.java b/src/main/java/eu/siacs/conversations/worker/ImportBackupWorker.java
new file mode 100644
index 0000000000000000000000000000000000000000..556748a72362b2b04abe6c98f4bde5ad9035b6c9
--- /dev/null
+++ b/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 OMEMO_TABLE_LIST =
+ Arrays.asList(
+ SQLiteAxolotlStore.PREKEY_TABLENAME,
+ SQLiteAxolotlStore.SIGNED_PREKEY_TABLENAME,
+ SQLiteAxolotlStore.SESSION_TABLENAME,
+ SQLiteAxolotlStore.IDENTITIES_TABLENAME);
+
+ private static final List TABLE_ALLOW_LIST =
+ new ImmutableList.Builder()
+ .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;
+ }
+ }
+ }
+}
diff --git a/src/conversations/res/layout/activity_import_backup.xml b/src/main/res/layout/activity_import_backup.xml
similarity index 100%
rename from src/conversations/res/layout/activity_import_backup.xml
rename to src/main/res/layout/activity_import_backup.xml
diff --git a/src/conversations/res/layout/dialog_enter_password.xml b/src/main/res/layout/dialog_enter_password.xml
similarity index 54%
rename from src/conversations/res/layout/dialog_enter_password.xml
rename to src/main/res/layout/dialog_enter_password.xml
index 623168aa4f40a3c0994a995677d2a0f2f00f26d5..220ef0e6f808cab35d4e945376e6a3eab2ba01c2 100644
--- a/src/conversations/res/layout/dialog_enter_password.xml
+++ b/src/main/res/layout/dialog_enter_password.xml
@@ -19,25 +19,40 @@
android:text="@string/enter_password_to_restore"
android:textAppearance="?textAppearanceBodyMedium" />
-
+
+
+
+
+
+
+
+
+
-
+
\ No newline at end of file
diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml
index 6bf79ca139943c41aa2d439f1203b19e73bdcccd..a7df4b226bd6e4a304b2a2a3d49b77034692c088 100644
--- a/src/main/res/values/strings.xml
+++ b/src/main/res/values/strings.xml
@@ -848,8 +848,9 @@
Restore backup
Restore
Enter your password for the account %s to restore the backup.
- 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.
- Do not attempt to restore backups that you have not created yourself!
+ Restore OMEMO keys
+ 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.
+ Only restore backups you’ve personally created.
Could not restore backup.
Could not decrypt backup. Is the password correct?
Backup & Restore
@@ -898,6 +899,7 @@
Open backup
The file you selected is not a Conversations backup file
You are trying to import an outdated backup file format
+ Quicksy can only restore backups for quicksy.im accounts
This account has already been setup
Please enter the password for this account
Could not perform this action