From 45b9c4dcc9f3fd0acacf1993e8248ede213773d4 Mon Sep 17 00:00:00 2001 From: Daniel Gultsch Date: Thu, 2 May 2024 19:12:39 +0200 Subject: [PATCH] refactor ExportBackupService to worker --- build.gradle | 1 + .../services/ImportBackupService.java | 3 +- src/main/AndroidManifest.xml | 6 +- .../ui/adapter/MediaAdapter.java | 4 +- .../settings/BackupSettingsFragment.java | 166 ++++ .../settings/MainSettingsFragment.java | 63 +- .../settings/SecuritySettingsFragment.java | 24 +- .../settings/XmppPreferenceFragment.java | 30 +- .../siacs/conversations/utils/MimeUtils.java | 4 +- .../siacs/conversations/utils/UIHelper.java | 7 +- .../ExportBackupWorker.java} | 779 +++++++++--------- .../res/drawable/ic_calendar_month_24dp.xml | 12 + src/main/res/values/arrays.xml | 7 + src/main/res/values/strings.xml | 3 + src/main/res/xml/preferences_backup.xml | 20 + src/main/res/xml/preferences_main.xml | 7 +- 16 files changed, 656 insertions(+), 480 deletions(-) create mode 100644 src/main/java/eu/siacs/conversations/ui/fragment/settings/BackupSettingsFragment.java rename src/main/java/eu/siacs/conversations/{services/ExportBackupService.java => worker/ExportBackupWorker.java} (51%) create mode 100644 src/main/res/drawable/ic_calendar_month_24dp.xml create mode 100644 src/main/res/xml/preferences_backup.xml diff --git a/build.gradle b/build.gradle index de803bbfc..700fd54e3 100644 --- a/build.gradle +++ b/build.gradle @@ -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" diff --git a/src/conversations/java/eu/siacs/conversations/services/ImportBackupService.java b/src/conversations/java/eu/siacs/conversations/services/ImportBackupService.java index 4c7387200..17c76d167 100644 --- a/src/conversations/java/eu/siacs/conversations/services/ImportBackupService.java +++ b/src/conversations/java/eu/siacs/conversations/services/ImportBackupService.java @@ -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( diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index fa89034ae..3d0ce91b0 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -116,9 +116,9 @@ + android:name="androidx.work.impl.foreground.SystemForegroundService" + android:foregroundServiceType="dataSync" + tools:node="merge" /> 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(); + } +} diff --git a/src/main/java/eu/siacs/conversations/ui/fragment/settings/MainSettingsFragment.java b/src/main/java/eu/siacs/conversations/ui/fragment/settings/MainSettingsFragment.java index 049523aa5..4ab8ade3c 100644 --- a/src/main/java/eu/siacs/conversations/ui/fragment/settings/MainSettingsFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/fragment/settings/MainSettingsFragment.java @@ -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 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(); diff --git a/src/main/java/eu/siacs/conversations/ui/fragment/settings/SecuritySettingsFragment.java b/src/main/java/eu/siacs/conversations/ui/fragment/settings/SecuritySettingsFragment.java index 2890db7ab..0ccf2679e 100644 --- a/src/main/java/eu/siacs/conversations/ui/fragment/settings/SecuritySettingsFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/fragment/settings/SecuritySettingsFragment.java @@ -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 { - - @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 { diff --git a/src/main/java/eu/siacs/conversations/ui/fragment/settings/XmppPreferenceFragment.java b/src/main/java/eu/siacs/conversations/ui/fragment/settings/XmppPreferenceFragment.java index f3b1dabca..76364526d 100644 --- a/src/main/java/eu/siacs/conversations/ui/fragment/settings/XmppPreferenceFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/fragment/settings/XmppPreferenceFragment.java @@ -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 { @@ -25,7 +34,7 @@ public abstract class XmppPreferenceFragment extends PreferenceFragmentCompat { }; protected void onSharedPreferenceChanged(@NonNull String key) { - Log.d(Config.LOGTAG,"onSharedPreferenceChanged("+key+")"); + Log.d(Config.LOGTAG, "onSharedPreferenceChanged(" + key + ")"); } public void onBackendConnected() {} @@ -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 { + + @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); + } + } } diff --git a/src/main/java/eu/siacs/conversations/utils/MimeUtils.java b/src/main/java/eu/siacs/conversations/utils/MimeUtils.java index 6273e2b43..ce919897e 100644 --- a/src/main/java/eu/siacs/conversations/utils/MimeUtils.java +++ b/src/main/java/eu/siacs/conversations/utils/MimeUtils.java @@ -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"); diff --git a/src/main/java/eu/siacs/conversations/utils/UIHelper.java b/src/main/java/eu/siacs/conversations/utils/UIHelper.java index e1996440c..245f196a6 100644 --- a/src/main/java/eu/siacs/conversations/utils/UIHelper.java +++ b/src/main/java/eu/siacs/conversations/utils/UIHelper.java @@ -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); diff --git a/src/main/java/eu/siacs/conversations/services/ExportBackupService.java b/src/main/java/eu/siacs/conversations/worker/ExportBackupWorker.java similarity index 51% rename from src/main/java/eu/siacs/conversations/services/ExportBackupService.java rename to src/main/java/eu/siacs/conversations/worker/ExportBackupWorker.java index 1a7ac070e..167a1f240 100644 --- a/src/main/java/eu/siacs/conversations/services/ExportBackupService.java +++ b/src/main/java/eu/siacs/conversations/worker/ExportBackupWorker.java @@ -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 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 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 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 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 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 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 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 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 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 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 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 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 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(); } } } diff --git a/src/main/res/drawable/ic_calendar_month_24dp.xml b/src/main/res/drawable/ic_calendar_month_24dp.xml new file mode 100644 index 000000000..007f1fa45 --- /dev/null +++ b/src/main/res/drawable/ic_calendar_month_24dp.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/src/main/res/values/arrays.xml b/src/main/res/values/arrays.xml index 6dc8caca6..1c00e9e73 100644 --- a/src/main/res/values/arrays.xml +++ b/src/main/res/values/arrays.xml @@ -84,6 +84,13 @@ 2592000 15811200 + + 0 + 86400 + 172800 + 604800 + 2592000 + always default_on diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index bb1e1120f..356e828cf 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -1059,5 +1059,8 @@ Increase font size in message bubbles Invites from strangers Accept invites to group chats from strangers + Create one-off, Schedule recurring + Create one-off backup + Recurring backup diff --git a/src/main/res/xml/preferences_backup.xml b/src/main/res/xml/preferences_backup.xml new file mode 100644 index 000000000..b0362c785 --- /dev/null +++ b/src/main/res/xml/preferences_backup.xml @@ -0,0 +1,20 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/main/res/xml/preferences_main.xml b/src/main/res/xml/preferences_main.xml index 2de980c40..013f2506c 100644 --- a/src/main/res/xml/preferences_main.xml +++ b/src/main/res/xml/preferences_main.xml @@ -34,9 +34,10 @@ app:title="@string/pref_connection_options" /> + android:key="backup" + app:fragment="eu.siacs.conversations.ui.fragment.settings.BackupSettingsFragment" + android:summary="@string/pref_backup_summary" + android:title="@string/backup" />