Detailed changes
@@ -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>
@@ -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;
- }
- }
-}
@@ -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>
@@ -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 {
@@ -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);
- }
}
@@ -1191,6 +1191,7 @@ public class RtpSessionActivity extends XmppActivity
}
},
MainThreadExecutor.getInstance());
+ // TODO ^ replace with ContextCompat.getMainExecutor(getApplication())
}
private void enableVideo(final View view) {
@@ -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();
}
}
-
-}
+}
@@ -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();
+ }
+}
@@ -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);
@@ -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;
+ }
+ }
+ }
+}
@@ -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>
@@ -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 & 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>