refactor ExportBackupService to worker

This commit is contained in:
Daniel Gultsch 2024-05-02 19:12:39 +02:00
parent cbd8fb3488
commit 45b9c4dcc9
No known key found for this signature in database
GPG key ID: F43D18AD2A0982C2
16 changed files with 656 additions and 480 deletions

View file

@ -50,6 +50,7 @@ dependencies {
implementation "androidx.preference:preference:1.2.1"
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'com.google.android.material:material:1.11.0'
implementation 'androidx.work:work-runtime:2.9.0'
implementation "androidx.emoji2:emoji2:1.4.0"
freeImplementation "androidx.emoji2:emoji2-bundled:1.4.0"

View file

@ -37,6 +37,7 @@ 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 org.bouncycastle.crypto.engines.AESEngine;
@ -273,7 +274,7 @@ public class ImportBackupService extends Service {
return false;
}
final byte[] key = ExportBackupService.getKey(password, backupFileHeader.getSalt());
final byte[] key = ExportBackupWorker.getKey(password, backupFileHeader.getSalt());
final AEADBlockCipher cipher = new GCMBlockCipher(new AESEngine());
cipher.init(

View file

@ -116,9 +116,9 @@
</service>
<service
android:name=".services.ExportBackupService"
android:exported="false"
android:foregroundServiceType="dataSync" />
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
<service
android:name=".services.ImportBackupService"

View file

@ -23,10 +23,10 @@ import com.google.common.base.Strings;
import eu.siacs.conversations.R;
import eu.siacs.conversations.databinding.ItemMediaBinding;
import eu.siacs.conversations.services.ExportBackupService;
import eu.siacs.conversations.ui.XmppActivity;
import eu.siacs.conversations.ui.util.Attachment;
import eu.siacs.conversations.ui.util.ViewUtil;
import eu.siacs.conversations.worker.ExportBackupWorker;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
@ -99,7 +99,7 @@ public class MediaAdapter extends RecyclerView.Adapter<MediaAdapter.MediaViewHol
} else if (mime.equals("application/epub+zip")
|| mime.equals("application/vnd.amazon.mobi8-ebook")) {
return R.drawable.ic_book_48dp;
} else if (mime.equals(ExportBackupService.MIME_TYPE)) {
} else if (mime.equals(ExportBackupWorker.MIME_TYPE)) {
return R.drawable.ic_backup_48dp;
} else if (DOCUMENT_MIMES.contains(mime)) {
return R.drawable.ic_description_48dp;

View file

@ -0,0 +1,166 @@
package eu.siacs.conversations.ui.fragment.settings;
import android.Manifest;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import android.widget.Toast;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.preference.ListPreference;
import androidx.preference.Preference;
import androidx.work.Constraints;
import androidx.work.Data;
import androidx.work.ExistingPeriodicWorkPolicy;
import androidx.work.ExistingWorkPolicy;
import androidx.work.OneTimeWorkRequest;
import androidx.work.OutOfQuotaPolicy;
import androidx.work.PeriodicWorkRequest;
import androidx.work.WorkManager;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.google.common.base.Strings;
import com.google.common.primitives.Longs;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.R;
import eu.siacs.conversations.persistance.FileBackend;
import eu.siacs.conversations.worker.ExportBackupWorker;
import java.util.concurrent.TimeUnit;
public class BackupSettingsFragment extends XmppPreferenceFragment {
private static final String CREATE_ONE_OFF_BACKUP = "create_one_off_backup";
private static final String RECURRING_BACKUP = "recurring_backup";
private final ActivityResultLauncher<String> requestStorageForBackupLauncher =
registerForActivityResult(
new ActivityResultContracts.RequestPermission(),
isGranted -> {
if (isGranted) {
startOneOffBackup();
} else {
Toast.makeText(
requireActivity(),
getString(
R.string.no_storage_permission,
getString(R.string.app_name)),
Toast.LENGTH_LONG)
.show();
}
});
@Override
public void onCreatePreferences(@Nullable Bundle savedInstanceState, @Nullable String rootKey) {
setPreferencesFromResource(R.xml.preferences_backup, rootKey);
final var createOneOffBackup = findPreference(CREATE_ONE_OFF_BACKUP);
final ListPreference recurringBackup = findPreference(RECURRING_BACKUP);
final var backupDirectory = findPreference("backup_directory");
if (createOneOffBackup == null || recurringBackup == null || backupDirectory == null) {
throw new IllegalStateException(
"The preference resource file is missing some preferences");
}
backupDirectory.setSummary(
getString(
R.string.pref_create_backup_summary,
FileBackend.getBackupDirectory(requireContext()).getAbsolutePath()));
createOneOffBackup.setOnPreferenceClickListener(this::onBackupPreferenceClicked);
final int[] choices = getResources().getIntArray(R.array.recurring_backup_values);
final CharSequence[] entries = new CharSequence[choices.length];
final CharSequence[] entryValues = new CharSequence[choices.length];
for (int i = 0; i < choices.length; ++i) {
entryValues[i] = String.valueOf(choices[i]);
entries[i] = timeframeValueToName(requireContext(), choices[i]);
}
recurringBackup.setEntries(entries);
recurringBackup.setEntryValues(entryValues);
recurringBackup.setSummaryProvider(new TimeframeSummaryProvider());
}
@Override
protected void onSharedPreferenceChanged(@NonNull String key) {
super.onSharedPreferenceChanged(key);
if (RECURRING_BACKUP.equals(key)) {
final var sharedPreferences = getPreferenceManager().getSharedPreferences();
if (sharedPreferences == null) {
return;
}
final Long recurringBackupInterval =
Longs.tryParse(
Strings.nullToEmpty(
sharedPreferences.getString(RECURRING_BACKUP, null)));
if (recurringBackupInterval == null) {
return;
}
Log.d(
Config.LOGTAG,
"recurring backup interval changed to: " + recurringBackupInterval);
final var workManager = WorkManager.getInstance(requireContext());
if (recurringBackupInterval <= 0) {
workManager.cancelUniqueWork(RECURRING_BACKUP);
} else {
final Constraints constraints =
new Constraints.Builder()
.setRequiresBatteryNotLow(true)
.setRequiresStorageNotLow(true)
.build();
final PeriodicWorkRequest periodicWorkRequest =
new PeriodicWorkRequest.Builder(
ExportBackupWorker.class,
recurringBackupInterval,
TimeUnit.SECONDS)
.setConstraints(constraints)
.setInputData(
new Data.Builder()
.putBoolean("recurring_backup", true)
.build())
.build();
workManager.enqueueUniquePeriodicWork(
RECURRING_BACKUP, ExistingPeriodicWorkPolicy.UPDATE, periodicWorkRequest);
}
}
}
@Override
public void onStart() {
super.onStart();
requireActivity().setTitle(R.string.backup);
}
private boolean onBackupPreferenceClicked(final Preference preference) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(
requireContext(), Manifest.permission.WRITE_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED) {
requestStorageForBackupLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE);
} else {
startOneOffBackup();
}
} else {
startOneOffBackup();
}
return true;
}
private void startOneOffBackup() {
final OneTimeWorkRequest exportBackupWorkRequest =
new OneTimeWorkRequest.Builder(ExportBackupWorker.class)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build();
WorkManager.getInstance(requireContext())
.enqueueUniqueWork(
CREATE_ONE_OFF_BACKUP, ExistingWorkPolicy.KEEP, exportBackupWorkRequest);
final MaterialAlertDialogBuilder builder =
new MaterialAlertDialogBuilder(requireActivity());
builder.setMessage(R.string.backup_started_message);
builder.setPositiveButton(R.string.ok, null);
builder.create().show();
}
}

View file

@ -1,63 +1,27 @@
package eu.siacs.conversations.ui.fragment.settings;
import android.Manifest;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle;
import android.widget.Toast;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.preference.Preference;
import androidx.preference.PreferenceFragmentCompat;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.google.common.base.Strings;
import eu.siacs.conversations.BuildConfig;
import eu.siacs.conversations.R;
import eu.siacs.conversations.persistance.FileBackend;
import eu.siacs.conversations.services.ExportBackupService;
public class MainSettingsFragment extends PreferenceFragmentCompat {
private static final String CREATE_BACKUP = "create_backup";
private final ActivityResultLauncher<String> requestStorageForBackupLauncher =
registerForActivityResult(
new ActivityResultContracts.RequestPermission(),
isGranted -> {
if (isGranted) {
startBackup();
} else {
Toast.makeText(
requireActivity(),
getString(
R.string.no_storage_permission,
getString(R.string.app_name)),
Toast.LENGTH_LONG)
.show();
}
});
@Override
public void onCreatePreferences(@Nullable Bundle savedInstanceState, @Nullable String rootKey) {
setPreferencesFromResource(R.xml.preferences_main, rootKey);
final var about = findPreference("about");
final var connection = findPreference("connection");
final var backup = findPreference(CREATE_BACKUP);
if (about == null || connection == null || backup == null) {
if (about == null || connection == null) {
throw new IllegalStateException(
"The preference resource file is missing some preferences");
}
backup.setSummary(
getString(
R.string.pref_create_backup_summary,
FileBackend.getBackupDirectory(requireContext()).getAbsolutePath()));
backup.setOnPreferenceClickListener(this::onBackupPreferenceClicked);
about.setTitle(getString(R.string.title_activity_about_x, BuildConfig.APP_NAME));
about.setSummary(
String.format(
@ -73,31 +37,6 @@ public class MainSettingsFragment extends PreferenceFragmentCompat {
}
}
private boolean onBackupPreferenceClicked(final Preference preference) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(
requireContext(), Manifest.permission.WRITE_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED) {
requestStorageForBackupLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE);
} else {
startBackup();
}
} else {
startBackup();
}
return true;
}
private void startBackup() {
ContextCompat.startForegroundService(
requireContext(), new Intent(requireContext(), ExportBackupService.class));
final MaterialAlertDialogBuilder builder =
new MaterialAlertDialogBuilder(requireActivity());
builder.setMessage(R.string.backup_started_message);
builder.setPositiveButton(R.string.ok, null);
builder.create().show();
}
@Override
public void onStart() {
super.onStart();

View file

@ -1,6 +1,5 @@
package eu.siacs.conversations.ui.fragment.settings;
import android.content.Context;
import android.content.DialogInterface;
import android.os.Bundle;
import android.widget.Toast;
@ -13,13 +12,11 @@ import androidx.preference.Preference;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.google.common.base.Strings;
import com.google.common.primitives.Ints;
import eu.siacs.conversations.AppSettings;
import eu.siacs.conversations.R;
import eu.siacs.conversations.crypto.OmemoSetting;
import eu.siacs.conversations.services.MemorizingTrustManager;
import eu.siacs.conversations.utils.TimeFrameUtils;
import java.security.KeyStoreException;
import java.util.ArrayList;
@ -44,20 +41,13 @@ public class SecuritySettingsFragment extends XmppPreferenceFragment {
final CharSequence[] entryValues = new CharSequence[choices.length];
for (int i = 0; i < choices.length; ++i) {
entryValues[i] = String.valueOf(choices[i]);
entries[i] = messageDeletionValueToName(requireContext(), choices[i]);
entries[i] = timeframeValueToName(requireContext(), choices[i]);
}
automaticMessageDeletion.setEntries(entries);
automaticMessageDeletion.setEntryValues(entryValues);
automaticMessageDeletion.setSummaryProvider(new MessageDeletionSummaryProvider());
automaticMessageDeletion.setSummaryProvider(new TimeframeSummaryProvider());
}
private static String messageDeletionValueToName(final Context context, final int value) {
if (value == 0) {
return context.getString(R.string.never);
} else {
return TimeFrameUtils.resolve(context, 1000L * value);
}
}
@Override
protected void onSharedPreferenceChanged(@NonNull String key) {
@ -161,16 +151,6 @@ public class SecuritySettingsFragment extends XmppPreferenceFragment {
.show();
}
private static class MessageDeletionSummaryProvider
implements Preference.SummaryProvider<ListPreference> {
@Nullable
@Override
public CharSequence provideSummary(@NonNull ListPreference preference) {
final Integer value = Ints.tryParse(Strings.nullToEmpty(preference.getValue()));
return messageDeletionValueToName(preference.getContext(), value == null ? 0 : value);
}
}
private static class OmemoSummaryProvider
implements Preference.SummaryProvider<ListPreference> {

View file

@ -1,15 +1,24 @@
package eu.siacs.conversations.ui.fragment.settings;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.preference.ListPreference;
import androidx.preference.Preference;
import androidx.preference.PreferenceFragmentCompat;
import com.google.common.base.Strings;
import com.google.common.primitives.Ints;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.R;
import eu.siacs.conversations.entities.Account;
import eu.siacs.conversations.services.XmppConnectionService;
import eu.siacs.conversations.ui.XmppActivity;
import eu.siacs.conversations.utils.TimeFrameUtils;
public abstract class XmppPreferenceFragment extends PreferenceFragmentCompat {
@ -83,4 +92,23 @@ public abstract class XmppPreferenceFragment extends PreferenceFragmentCompat {
protected void runOnUiThread(final Runnable runnable) {
requireActivity().runOnUiThread(runnable);
}
protected static String timeframeValueToName(final Context context, final int value) {
if (value == 0) {
return context.getString(R.string.never);
} else {
return TimeFrameUtils.resolve(context, 1000L * value);
}
}
protected static class TimeframeSummaryProvider
implements Preference.SummaryProvider<ListPreference> {
@Nullable
@Override
public CharSequence provideSummary(@NonNull ListPreference preference) {
final Integer value = Ints.tryParse(Strings.nullToEmpty(preference.getValue()));
return timeframeValueToName(preference.getContext(), value == null ? 0 : value);
}
}
}

View file

@ -37,7 +37,7 @@ import java.util.Properties;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.entities.Transferable;
import eu.siacs.conversations.services.ExportBackupService;
import eu.siacs.conversations.worker.ExportBackupWorker;
/**
* Utilities for dealing with MIME types.
@ -91,7 +91,7 @@ public final class MimeUtils {
add("application/vnd.amazon.mobi8-ebook", "kfx");
add("application/vnd.android.package-archive", "apk");
add("application/vnd.cinderella", "cdy");
add(ExportBackupService.MIME_TYPE, "ceb");
add(ExportBackupWorker.MIME_TYPE, "ceb");
add("application/vnd.ms-pki.stl", "stl");
add("application/vnd.oasis.opendocument.database", "odb");
add("application/vnd.oasis.opendocument.formula", "odf");

View file

@ -16,8 +16,6 @@ import androidx.core.content.ContextCompat;
import com.google.android.material.color.MaterialColors;
import com.google.common.base.Strings;
import java.math.BigInteger;
import java.security.MessageDigest;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Date;
@ -31,14 +29,13 @@ import eu.siacs.conversations.entities.Account;
import eu.siacs.conversations.entities.Contact;
import eu.siacs.conversations.entities.Conversation;
import eu.siacs.conversations.entities.Conversational;
import eu.siacs.conversations.entities.ListItem;
import eu.siacs.conversations.entities.Message;
import eu.siacs.conversations.entities.MucOptions;
import eu.siacs.conversations.entities.Presence;
import eu.siacs.conversations.entities.RtpSessionStatus;
import eu.siacs.conversations.entities.Transferable;
import eu.siacs.conversations.services.ExportBackupService;
import eu.siacs.conversations.ui.util.QuoteHelper;
import eu.siacs.conversations.worker.ExportBackupWorker;
import eu.siacs.conversations.xmpp.Jid;
public class UIHelper {
@ -410,7 +407,7 @@ public class UIHelper {
return context.getString(R.string.pdf_document);
} else if (mime.equals("application/vnd.android.package-archive")) {
return context.getString(R.string.apk);
} else if (mime.equals(ExportBackupService.MIME_TYPE)) {
} else if (mime.equals(ExportBackupWorker.MIME_TYPE)) {
return context.getString(R.string.conversations_backup);
} else if (mime.contains("vcard")) {
return context.getString(R.string.vcard);

View file

@ -1,53 +1,28 @@
package eu.siacs.conversations.services;
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.app.Service;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ServiceInfo;
import android.database.Cursor;
import android.database.DatabaseUtils;
import android.database.sqlite.SQLiteDatabase;
import android.net.Uri;
import android.os.IBinder;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.core.app.NotificationCompat;
import androidx.work.ForegroundInfo;
import androidx.work.Worker;
import androidx.work.WorkerParameters;
import com.google.common.base.CharMatcher;
import com.google.common.base.Optional;
import com.google.common.base.Strings;
import com.google.gson.stream.JsonWriter;
import java.io.DataOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.spec.InvalidKeySpecException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.zip.GZIPOutputStream;
import javax.crypto.Cipher;
import javax.crypto.CipherOutputStream;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.SecretKeySpec;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.R;
import eu.siacs.conversations.crypto.axolotl.SQLiteAxolotlStore;
@ -59,9 +34,38 @@ import eu.siacs.conversations.persistance.FileBackend;
import eu.siacs.conversations.utils.BackupFileHeader;
import eu.siacs.conversations.utils.Compatibility;
public class ExportBackupService extends Service {
import java.io.DataOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets;
import java.security.InvalidAlgorithmParameterException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.NoSuchProviderException;
import java.security.SecureRandom;
import java.security.spec.InvalidKeySpecException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.zip.GZIPOutputStream;
private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd", Locale.US);
import javax.crypto.Cipher;
import javax.crypto.CipherOutputStream;
import javax.crypto.NoSuchPaddingException;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.SecretKeySpec;
public class ExportBackupWorker extends Worker {
private static final SimpleDateFormat DATE_FORMAT =
new SimpleDateFormat("yyyy-MM-dd", Locale.US);
public static final String KEYTYPE = "AES";
public static final String CIPHERMODE = "AES/GCM/NoPadding";
@ -70,10 +74,362 @@ public class ExportBackupService extends Service {
public static final String MIME_TYPE = "application/vnd.conversations.backup";
private static final int NOTIFICATION_ID = 19;
private static final AtomicBoolean RUNNING = new AtomicBoolean(false);
private DatabaseBackend mDatabaseBackend;
private List<Account> mAccounts;
private NotificationManager notificationManager;
private static final int BACKUP_CREATED_NOTIFICATION_ID = 23;
private final boolean recurringBackup;
public ExportBackupWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
super(context, workerParams);
final var inputData = workerParams.getInputData();
this.recurringBackup = inputData.getBoolean("recurring_backup", false);
}
@NonNull
@Override
public Result doWork() {
final List<File> files;
try {
files = export();
} catch (final IOException
| InvalidKeySpecException
| InvalidAlgorithmParameterException
| InvalidKeyException
| NoSuchPaddingException
| NoSuchAlgorithmException
| NoSuchProviderException e) {
Log.d(Config.LOGTAG, "could not create backup", e);
return Result.failure();
}
Log.d(Config.LOGTAG, "done creating " + files.size() + " backup files");
getApplicationContext().getSystemService(NotificationManager.class).cancel(NOTIFICATION_ID);
if (files.isEmpty() || recurringBackup) {
return Result.success();
}
notifySuccess(files);
return Result.success();
}
@NonNull
@Override
public ForegroundInfo getForegroundInfo() {
Log.d(Config.LOGTAG, "getForegroundInfo()");
final var context = getApplicationContext();
final NotificationCompat.Builder notification =
new NotificationCompat.Builder(context, "backup");
notification
.setContentTitle(context.getString(R.string.notification_create_backup_title))
.setSmallIcon(R.drawable.ic_archive_24dp)
.setProgress(1, 0, false);
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
return new ForegroundInfo(
NOTIFICATION_ID,
notification.build(),
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC);
} else {
return new ForegroundInfo(NOTIFICATION_ID, notification.build());
}
}
private List<File> export()
throws IOException,
InvalidKeySpecException,
InvalidAlgorithmParameterException,
InvalidKeyException,
NoSuchPaddingException,
NoSuchAlgorithmException,
NoSuchProviderException {
final Context context = getApplicationContext();
final var database = DatabaseBackend.getInstance(context);
final var accounts = database.getAccounts();
int count = 0;
final int max = accounts.size();
final SecureRandom secureRandom = new SecureRandom();
final List<File> files = new ArrayList<>();
Log.d(Config.LOGTAG, "starting backup for " + max + " accounts");
for (final Account account : accounts) {
final String password = account.getPassword();
if (Strings.nullToEmpty(password).trim().isEmpty()) {
Log.d(
Config.LOGTAG,
String.format(
"skipping backup for %s because password is empty. unable to encrypt",
account.getJid().asBareJid()));
continue;
}
Log.d(
Config.LOGTAG,
String.format(
"exporting data for account %s (%s)",
account.getJid().asBareJid(), account.getUuid()));
final byte[] IV = new byte[12];
final byte[] salt = new byte[16];
secureRandom.nextBytes(IV);
secureRandom.nextBytes(salt);
final BackupFileHeader backupFileHeader =
new BackupFileHeader(
context.getString(R.string.app_name),
account.getJid(),
System.currentTimeMillis(),
IV,
salt);
final NotificationCompat.Builder notification =
new NotificationCompat.Builder(context, "backup");
notification
.setContentTitle(context.getString(R.string.notification_create_backup_title))
.setSmallIcon(R.drawable.ic_archive_24dp)
.setProgress(1, 0, false);
final Progress progress = new Progress(notification, max, count);
final String filename =
String.format(
"%s.%s.ceb",
account.getJid().asBareJid().toEscapedString(),
DATE_FORMAT.format(new Date()));
final File file = new File(FileBackend.getBackupDirectory(context), filename);
files.add(file);
final File directory = file.getParentFile();
if (directory != null && directory.mkdirs()) {
Log.d(Config.LOGTAG, "created backup directory " + directory.getAbsolutePath());
}
final FileOutputStream fileOutputStream = new FileOutputStream(file);
final DataOutputStream dataOutputStream = new DataOutputStream(fileOutputStream);
backupFileHeader.write(dataOutputStream);
dataOutputStream.flush();
final Cipher cipher =
Compatibility.twentyEight()
? Cipher.getInstance(CIPHERMODE)
: Cipher.getInstance(CIPHERMODE, PROVIDER);
final byte[] key = getKey(password, salt);
SecretKeySpec keySpec = new SecretKeySpec(key, KEYTYPE);
IvParameterSpec ivSpec = new IvParameterSpec(IV);
cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
CipherOutputStream cipherOutputStream =
new CipherOutputStream(fileOutputStream, cipher);
final GZIPOutputStream gzipOutputStream = new GZIPOutputStream(cipherOutputStream);
final JsonWriter jsonWriter =
new JsonWriter(
new OutputStreamWriter(gzipOutputStream, StandardCharsets.UTF_8));
jsonWriter.beginArray();
final SQLiteDatabase db = database.getReadableDatabase();
final String uuid = account.getUuid();
accountExport(db, uuid, jsonWriter);
simpleExport(db, Conversation.TABLENAME, Conversation.ACCOUNT, uuid, jsonWriter);
messageExport(db, uuid, jsonWriter, progress);
for (final String table :
Arrays.asList(
SQLiteAxolotlStore.PREKEY_TABLENAME,
SQLiteAxolotlStore.SIGNED_PREKEY_TABLENAME,
SQLiteAxolotlStore.SESSION_TABLENAME,
SQLiteAxolotlStore.IDENTITIES_TABLENAME)) {
simpleExport(db, table, SQLiteAxolotlStore.ACCOUNT, uuid, jsonWriter);
}
jsonWriter.endArray();
jsonWriter.flush();
jsonWriter.close();
mediaScannerScanFile(file);
Log.d(Config.LOGTAG, "written backup to " + file.getAbsoluteFile());
count++;
}
return files;
}
private void mediaScannerScanFile(final File file) {
final Intent intent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
intent.setData(Uri.fromFile(file));
getApplicationContext().sendBroadcast(intent);
}
private static void accountExport(
final SQLiteDatabase db, final String uuid, final JsonWriter writer)
throws IOException {
try (final Cursor accountCursor =
db.query(
Account.TABLENAME,
null,
Account.UUID + "=?",
new String[] {uuid},
null,
null,
null)) {
while (accountCursor != null && accountCursor.moveToNext()) {
writer.beginObject();
writer.name("table");
writer.value(Account.TABLENAME);
writer.name("values");
writer.beginObject();
for (int i = 0; i < accountCursor.getColumnCount(); ++i) {
final String name = accountCursor.getColumnName(i);
writer.name(name);
final String value = accountCursor.getString(i);
if (value == null
|| Account.ROSTERVERSION.equals(accountCursor.getColumnName(i))) {
writer.nullValue();
} else if (Account.OPTIONS.equals(accountCursor.getColumnName(i))
&& value.matches("\\d+")) {
int intValue = Integer.parseInt(value);
intValue |= 1 << Account.OPTION_DISABLED;
writer.value(intValue);
} else {
writer.value(value);
}
}
writer.endObject();
writer.endObject();
}
}
}
private static void simpleExport(
final SQLiteDatabase db,
final String table,
final String column,
final String uuid,
final JsonWriter writer)
throws IOException {
try (final Cursor cursor =
db.query(table, null, column + "=?", new String[] {uuid}, null, null, null)) {
while (cursor != null && cursor.moveToNext()) {
writer.beginObject();
writer.name("table");
writer.value(table);
writer.name("values");
writer.beginObject();
for (int i = 0; i < cursor.getColumnCount(); ++i) {
final String name = cursor.getColumnName(i);
writer.name(name);
final String value = cursor.getString(i);
writer.value(value);
}
writer.endObject();
writer.endObject();
}
}
}
private void messageExport(
final SQLiteDatabase db,
final String uuid,
final JsonWriter writer,
final Progress progress)
throws IOException {
final var notificationManager =
getApplicationContext().getSystemService(NotificationManager.class);
try (final Cursor cursor =
db.rawQuery(
"select messages.* from messages join conversations on conversations.uuid=messages.conversationUuid where conversations.accountUuid=?",
new String[] {uuid})) {
final int size = cursor != null ? cursor.getCount() : 0;
Log.d(Config.LOGTAG, "exporting " + size + " messages for account " + uuid);
int i = 0;
int p = 0;
while (cursor != null && cursor.moveToNext()) {
writer.beginObject();
writer.name("table");
writer.value(Message.TABLENAME);
writer.name("values");
writer.beginObject();
for (int j = 0; j < cursor.getColumnCount(); ++j) {
final String name = cursor.getColumnName(j);
writer.name(name);
final String value = cursor.getString(j);
writer.value(value);
}
writer.endObject();
writer.endObject();
final int percentage = i * 100 / size;
if (p < percentage) {
p = percentage;
notificationManager.notify(NOTIFICATION_ID, progress.build(p));
}
i++;
}
}
}
public static byte[] getKey(final String password, final byte[] salt)
throws InvalidKeySpecException {
final SecretKeyFactory factory;
try {
factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
return factory.generateSecret(new PBEKeySpec(password.toCharArray(), salt, 1024, 128))
.getEncoded();
}
private void notifySuccess(final List<File> files) {
final var context = getApplicationContext();
final String path = FileBackend.getBackupDirectory(context).getAbsolutePath();
final var openFolderIntent = getOpenFolderIntent(path);
final Intent intent = new Intent(Intent.ACTION_SEND_MULTIPLE);
final ArrayList<Uri> uris = new ArrayList<>();
for (final File file : files) {
uris.add(FileBackend.getUriForFile(context, file));
}
intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
intent.setType(MIME_TYPE);
final Intent chooser =
Intent.createChooser(intent, context.getString(R.string.share_backup_files));
final var shareFilesIntent =
PendingIntent.getActivity(
context,
190,
chooser,
s()
? PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
: PendingIntent.FLAG_UPDATE_CURRENT);
NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(context, "backup");
mBuilder.setContentTitle(context.getString(R.string.notification_backup_created_title))
.setContentText(
context.getString(R.string.notification_backup_created_subtitle, path))
.setStyle(
new NotificationCompat.BigTextStyle()
.bigText(
context.getString(
R.string.notification_backup_created_subtitle,
FileBackend.getBackupDirectory(context)
.getAbsolutePath())))
.setAutoCancel(true)
.setSmallIcon(R.drawable.ic_archive_24dp);
if (openFolderIntent.isPresent()) {
mBuilder.setContentIntent(openFolderIntent.get());
} else {
Log.w(Config.LOGTAG, "no app can display folders");
}
mBuilder.addAction(
R.drawable.ic_share_24dp,
context.getString(R.string.share_backup_files),
shareFilesIntent);
final var notificationManager = context.getSystemService(NotificationManager.class);
notificationManager.notify(BACKUP_CREATED_NOTIFICATION_ID, mBuilder.build());
}
private Optional<PendingIntent> getOpenFolderIntent(final String path) {
final var context = getApplicationContext();
for (final Intent intent : getPossibleFileOpenIntents(context, path)) {
if (intent.resolveActivityInfo(context.getPackageManager(), 0) != null) {
return Optional.of(
PendingIntent.getActivity(
context,
189,
intent,
s()
? PendingIntent.FLAG_IMMUTABLE
| PendingIntent.FLAG_UPDATE_CURRENT
: PendingIntent.FLAG_UPDATE_CURRENT));
}
}
return Optional.absent();
}
private static List<Intent> getPossibleFileOpenIntents(
final Context context, final String path) {
@ -101,356 +457,21 @@ public class ExportBackupService extends Service {
return Arrays.asList(openIntent, amazeIntent, systemFallBack);
}
private static void accountExport(
final SQLiteDatabase db, final String uuid, final JsonWriter writer)
throws IOException {
final Cursor accountCursor =
db.query(
Account.TABLENAME,
null,
Account.UUID + "=?",
new String[] {uuid},
null,
null,
null);
while (accountCursor != null && accountCursor.moveToNext()) {
writer.beginObject();
writer.name("table");
writer.value(Account.TABLENAME);
writer.name("values");
writer.beginObject();
for (int i = 0; i < accountCursor.getColumnCount(); ++i) {
final String name = accountCursor.getColumnName(i);
writer.name(name);
final String value = accountCursor.getString(i);
if (value == null || Account.ROSTERVERSION.equals(accountCursor.getColumnName(i))) {
writer.nullValue();
} else if (Account.OPTIONS.equals(accountCursor.getColumnName(i))
&& value.matches("\\d+")) {
int intValue = Integer.parseInt(value);
intValue |= 1 << Account.OPTION_DISABLED;
writer.value(intValue);
} else {
writer.value(value);
}
}
writer.endObject();
writer.endObject();
}
if (accountCursor != null) {
accountCursor.close();
}
}
private static void simpleExport(
final SQLiteDatabase db,
final String table,
final String column,
final String uuid,
final JsonWriter writer)
throws IOException {
final Cursor cursor =
db.query(table, null, column + "=?", new String[] {uuid}, null, null, null);
while (cursor != null && cursor.moveToNext()) {
writer.beginObject();
writer.name("table");
writer.value(table);
writer.name("values");
writer.beginObject();
for (int i = 0; i < cursor.getColumnCount(); ++i) {
final String name = cursor.getColumnName(i);
writer.name(name);
final String value = cursor.getString(i);
writer.value(value);
}
writer.endObject();
writer.endObject();
}
if (cursor != null) {
cursor.close();
}
}
public static byte[] getKey(final String password, final byte[] salt)
throws InvalidKeySpecException {
final SecretKeyFactory factory;
try {
factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
return factory.generateSecret(new PBEKeySpec(password.toCharArray(), salt, 1024, 128))
.getEncoded();
}
@Override
public void onCreate() {
mDatabaseBackend = DatabaseBackend.getInstance(getBaseContext());
mAccounts = mDatabaseBackend.getAccounts();
notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
if (RUNNING.compareAndSet(false, true)) {
new Thread(
() -> {
boolean success;
List<File> files;
try {
files = export();
success = true;
} catch (final Exception e) {
Log.d(Config.LOGTAG, "unable to create backup", e);
success = false;
files = Collections.emptyList();
}
stopForeground(true);
RUNNING.set(false);
if (success) {
notifySuccess(files);
}
stopSelf();
})
.start();
return START_STICKY;
} else {
Log.d(
Config.LOGTAG,
"ExportBackupService. ignoring start command because already running");
}
return START_NOT_STICKY;
}
private void messageExport(
final SQLiteDatabase db,
final String uuid,
final JsonWriter writer,
final Progress progress)
throws IOException {
Cursor cursor =
db.rawQuery(
"select messages.* from messages join conversations on conversations.uuid=messages.conversationUuid where conversations.accountUuid=?",
new String[] {uuid});
int size = cursor != null ? cursor.getCount() : 0;
Log.d(Config.LOGTAG, "exporting " + size + " messages for account " + uuid);
int i = 0;
int p = 0;
while (cursor != null && cursor.moveToNext()) {
writer.beginObject();
writer.name("table");
writer.value(Message.TABLENAME);
writer.name("values");
writer.beginObject();
for (int j = 0; j < cursor.getColumnCount(); ++j) {
final String name = cursor.getColumnName(j);
writer.name(name);
final String value = cursor.getString(j);
writer.value(value);
}
writer.endObject();
writer.endObject();
final int percentage = i * 100 / size;
if (p < percentage) {
p = percentage;
notificationManager.notify(NOTIFICATION_ID, progress.build(p));
}
i++;
}
if (cursor != null) {
cursor.close();
}
}
private List<File> export() throws Exception {
NotificationCompat.Builder mBuilder =
new NotificationCompat.Builder(getBaseContext(), "backup");
mBuilder.setContentTitle(getString(R.string.notification_create_backup_title))
.setSmallIcon(R.drawable.ic_archive_24dp)
.setProgress(1, 0, false);
startForeground(NOTIFICATION_ID, mBuilder.build());
int count = 0;
final int max = this.mAccounts.size();
final SecureRandom secureRandom = new SecureRandom();
final List<File> files = new ArrayList<>();
Log.d(Config.LOGTAG, "starting backup for " + max + " accounts");
for (final Account account : this.mAccounts) {
final String password = account.getPassword();
if (Strings.nullToEmpty(password).trim().isEmpty()) {
Log.d(
Config.LOGTAG,
String.format(
"skipping backup for %s because password is empty. unable to encrypt",
account.getJid().asBareJid()));
continue;
}
Log.d(
Config.LOGTAG,
String.format(
"exporting data for account %s (%s)",
account.getJid().asBareJid(), account.getUuid()));
final byte[] IV = new byte[12];
final byte[] salt = new byte[16];
secureRandom.nextBytes(IV);
secureRandom.nextBytes(salt);
final BackupFileHeader backupFileHeader =
new BackupFileHeader(
getString(R.string.app_name),
account.getJid(),
System.currentTimeMillis(),
IV,
salt);
final Progress progress = new Progress(mBuilder, max, count);
final String filename =
String.format(
"%s.%s.ceb",
account.getJid().asBareJid().toEscapedString(),
DATE_FORMAT.format(new Date()));
final File file =
new File(
FileBackend.getBackupDirectory(this), filename);
files.add(file);
final File directory = file.getParentFile();
if (directory != null && directory.mkdirs()) {
Log.d(Config.LOGTAG, "created backup directory " + directory.getAbsolutePath());
}
final FileOutputStream fileOutputStream = new FileOutputStream(file);
final DataOutputStream dataOutputStream = new DataOutputStream(fileOutputStream);
backupFileHeader.write(dataOutputStream);
dataOutputStream.flush();
final Cipher cipher =
Compatibility.twentyEight()
? Cipher.getInstance(CIPHERMODE)
: Cipher.getInstance(CIPHERMODE, PROVIDER);
final byte[] key = getKey(password, salt);
SecretKeySpec keySpec = new SecretKeySpec(key, KEYTYPE);
IvParameterSpec ivSpec = new IvParameterSpec(IV);
cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
CipherOutputStream cipherOutputStream =
new CipherOutputStream(fileOutputStream, cipher);
final GZIPOutputStream gzipOutputStream = new GZIPOutputStream(cipherOutputStream);
final JsonWriter jsonWriter =
new JsonWriter(
new OutputStreamWriter(gzipOutputStream, StandardCharsets.UTF_8));
jsonWriter.beginArray();
final SQLiteDatabase db = this.mDatabaseBackend.getReadableDatabase();
final String uuid = account.getUuid();
accountExport(db, uuid, jsonWriter);
simpleExport(db, Conversation.TABLENAME, Conversation.ACCOUNT, uuid, jsonWriter);
messageExport(db, uuid, jsonWriter, progress);
for (final String table :
Arrays.asList(
SQLiteAxolotlStore.PREKEY_TABLENAME,
SQLiteAxolotlStore.SIGNED_PREKEY_TABLENAME,
SQLiteAxolotlStore.SESSION_TABLENAME,
SQLiteAxolotlStore.IDENTITIES_TABLENAME)) {
simpleExport(db, table, SQLiteAxolotlStore.ACCOUNT, uuid, jsonWriter);
}
jsonWriter.endArray();
jsonWriter.flush();
jsonWriter.close();
mediaScannerScanFile(file);
Log.d(Config.LOGTAG, "written backup to " + file.getAbsoluteFile());
count++;
}
return files;
}
private void mediaScannerScanFile(final File file) {
final Intent intent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
intent.setData(Uri.fromFile(file));
sendBroadcast(intent);
}
private void notifySuccess(final List<File> files) {
final String path = FileBackend.getBackupDirectory(this).getAbsolutePath();
PendingIntent openFolderIntent = null;
for (final Intent intent : getPossibleFileOpenIntents(this, path)) {
if (intent.resolveActivityInfo(getPackageManager(), 0) != null) {
openFolderIntent =
PendingIntent.getActivity(
this,
189,
intent,
s()
? PendingIntent.FLAG_IMMUTABLE
| PendingIntent.FLAG_UPDATE_CURRENT
: PendingIntent.FLAG_UPDATE_CURRENT);
break;
}
}
PendingIntent shareFilesIntent = null;
if (files.size() > 0) {
final Intent intent = new Intent(Intent.ACTION_SEND_MULTIPLE);
ArrayList<Uri> uris = new ArrayList<>();
for (File file : files) {
uris.add(FileBackend.getUriForFile(this, file));
}
intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
intent.setType(MIME_TYPE);
final Intent chooser =
Intent.createChooser(intent, getString(R.string.share_backup_files));
shareFilesIntent =
PendingIntent.getActivity(
this,
190,
chooser,
s()
? PendingIntent.FLAG_IMMUTABLE
| PendingIntent.FLAG_UPDATE_CURRENT
: PendingIntent.FLAG_UPDATE_CURRENT);
}
NotificationCompat.Builder mBuilder =
new NotificationCompat.Builder(getBaseContext(), "backup");
mBuilder.setContentTitle(getString(R.string.notification_backup_created_title))
.setContentText(getString(R.string.notification_backup_created_subtitle, path))
.setStyle(
new NotificationCompat.BigTextStyle()
.bigText(
getString(
R.string.notification_backup_created_subtitle,
FileBackend.getBackupDirectory(this)
.getAbsolutePath())))
.setAutoCancel(true)
.setContentIntent(openFolderIntent)
.setSmallIcon(R.drawable.ic_archive_24dp);
if (shareFilesIntent != null) {
mBuilder.addAction(
R.drawable.ic_share_24dp,
getString(R.string.share_backup_files),
shareFilesIntent);
}
notificationManager.notify(NOTIFICATION_ID, mBuilder.build());
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
private static class Progress {
private final NotificationCompat.Builder builder;
private final NotificationCompat.Builder notification;
private final int max;
private final int count;
private Progress(NotificationCompat.Builder builder, int max, int count) {
this.builder = builder;
private Progress(
final NotificationCompat.Builder notification, final int max, final int count) {
this.notification = notification;
this.max = max;
this.count = count;
}
private Notification build(int percentage) {
builder.setProgress(max * 100, count * 100 + percentage, false);
return builder.build();
notification.setProgress(max * 100, count * 100 + percentage, false);
return notification.build();
}
}
}

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M19,4h-1V2h-2v2H8V2H6v2H5C3.89,4 3.01,4.9 3.01,6L3,20c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2V6C21,4.9 20.1,4 19,4zM19,20H5V10h14V20zM9,14H7v-2h2V14zM13,14h-2v-2h2V14zM17,14h-2v-2h2V14zM9,18H7v-2h2V18zM13,18h-2v-2h2V18zM17,18h-2v-2h2V18z" />
</vector>

View file

@ -84,6 +84,13 @@
<item>2592000</item>
<item>15811200</item>
</integer-array>
<integer-array name="recurring_backup_values">
<item>0</item>
<item>86400</item>
<item>172800</item>
<item>604800</item>
<item>2592000</item>
</integer-array>
<string-array name="omemo_setting_entry_values">
<item>always</item>
<item>default_on</item>

View file

@ -1059,5 +1059,8 @@
<string name="pref_large_font_summary">Increase font size in message bubbles</string>
<string name="pref_accept_invites_from_strangers">Invites from strangers</string>
<string name="pref_accept_invites_from_strangers_summary">Accept invites to group chats from strangers</string>
<string name="pref_backup_summary">Create one-off, Schedule recurring</string>
<string name="pref_create_backup_one_off_summary">Create one-off backup</string>
<string name="pref_backup_recurring">Recurring backup</string>
</resources>

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<ListPreference
android:defaultValue="@integer/automatic_message_deletion"
android:icon="@drawable/ic_calendar_month_24dp"
android:key="recurring_backup"
android:title="@string/pref_backup_recurring" />
<Preference
android:icon="@drawable/ic_archive_24dp"
android:key="create_one_off_backup"
android:summary="@string/pref_create_backup_one_off_summary"
android:title="@string/pref_create_backup" />
<Preference
android:key="backup_directory"
android:summary="@string/pref_create_backup_summary" />
</PreferenceScreen>

View file

@ -34,9 +34,10 @@
app:title="@string/pref_connection_options" />
<Preference
android:icon="@drawable/ic_archive_24dp"
android:key="create_backup"
android:summary="@string/pref_create_backup_summary"
android:title="@string/pref_create_backup" />
android:key="backup"
app:fragment="eu.siacs.conversations.ui.fragment.settings.BackupSettingsFragment"
android:summary="@string/pref_backup_summary"
android:title="@string/backup" />
<Preference
android:icon="@drawable/ic_cloud_sync_24dp"
app:fragment="eu.siacs.conversations.ui.fragment.settings.UpSettingsFragment"