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