refactor ExportBackupService to worker

Daniel Gultsch created

Change summary

build.gradle                                                                            |   1 
src/conversations/java/eu/siacs/conversations/services/ImportBackupService.java         |   3 
src/main/AndroidManifest.xml                                                            |   6 
src/main/java/eu/siacs/conversations/ui/adapter/MediaAdapter.java                       |   4 
src/main/java/eu/siacs/conversations/ui/fragment/settings/BackupSettingsFragment.java   | 166 
src/main/java/eu/siacs/conversations/ui/fragment/settings/MainSettingsFragment.java     |  63 
src/main/java/eu/siacs/conversations/ui/fragment/settings/SecuritySettingsFragment.java |  24 
src/main/java/eu/siacs/conversations/ui/fragment/settings/XmppPreferenceFragment.java   |  30 
src/main/java/eu/siacs/conversations/utils/MimeUtils.java                               |   4 
src/main/java/eu/siacs/conversations/utils/UIHelper.java                                |   7 
src/main/java/eu/siacs/conversations/worker/ExportBackupWorker.java                     | 565 
src/main/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, 549 insertions(+), 373 deletions(-)

Detailed changes

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"

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(

src/main/AndroidManifest.xml 🔗

@@ -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"

src/main/java/eu/siacs/conversations/ui/adapter/MediaAdapter.java 🔗

@@ -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;

src/main/java/eu/siacs/conversations/ui/fragment/settings/BackupSettingsFragment.java 🔗

@@ -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();
+    }
+}

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<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();

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<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> {

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<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);
+        }
+    }
 }

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");

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);

src/main/java/eu/siacs/conversations/services/ExportBackupService.java → src/main/java/eu/siacs/conversations/worker/ExportBackupWorker.java 🔗

@@ -1,67 +1,71 @@
-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 eu.siacs.conversations.Config;
+import eu.siacs.conversations.R;
+import eu.siacs.conversations.crypto.axolotl.SQLiteAxolotlStore;
+import eu.siacs.conversations.entities.Account;
+import eu.siacs.conversations.entities.Conversation;
+import eu.siacs.conversations.entities.Message;
+import eu.siacs.conversations.persistance.DatabaseBackend;
+import eu.siacs.conversations.persistance.FileBackend;
+import eu.siacs.conversations.utils.BackupFileHeader;
+import eu.siacs.conversations.utils.Compatibility;
+
 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.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.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.NoSuchPaddingException;
 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;
-import eu.siacs.conversations.entities.Account;
-import eu.siacs.conversations.entities.Conversation;
-import eu.siacs.conversations.entities.Message;
-import eu.siacs.conversations.persistance.DatabaseBackend;
-import eu.siacs.conversations.persistance.FileBackend;
-import eu.siacs.conversations.utils.BackupFileHeader;
-import eu.siacs.conversations.utils.Compatibility;
-
-public class ExportBackupService extends Service {
+public class ExportBackupWorker extends Worker {
 
-    private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd", Locale.US);
+    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,211 +74,80 @@ 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 static List<Intent> getPossibleFileOpenIntents(
-            final Context context, final String path) {
+    private final boolean recurringBackup;
 
-        // http://www.openintents.org/action/android-intent-action-view/file-directory
-        // do not use 'vnd.android.document/directory' since this will trigger system file manager
-        final Intent openIntent = new Intent(Intent.ACTION_VIEW);
-        openIntent.addCategory(Intent.CATEGORY_DEFAULT);
-        if (Compatibility.runsAndTargetsTwentyFour(context)) {
-            openIntent.setType("resource/folder");
-        } else {
-            openIntent.setDataAndType(Uri.parse("file://" + path), "resource/folder");
-        }
-        openIntent.putExtra("org.openintents.extra.ABSOLUTE_PATH", path);
-
-        final Intent amazeIntent = new Intent(Intent.ACTION_VIEW);
-        amazeIntent.setDataAndType(Uri.parse("com.amaze.filemanager:" + path), "resource/folder");
-
-        // will open a file manager at root and user can navigate themselves
-        final Intent systemFallBack = new Intent(Intent.ACTION_VIEW);
-        systemFallBack.addCategory(Intent.CATEGORY_DEFAULT);
-        systemFallBack.setData(
-                Uri.parse("content://com.android.externalstorage.documents/root/primary"));
-
-        return Arrays.asList(openIntent, amazeIntent, systemFallBack);
+    public ExportBackupWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
+        super(context, workerParams);
+        final var inputData = workerParams.getInputData();
+        this.recurringBackup = inputData.getBoolean("recurring_backup", false);
     }
 
-    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;
+    @NonNull
+    @Override
+    public Result doWork() {
+        final List<File> files;
         try {
-            factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
-        } catch (NoSuchAlgorithmException e) {
-            throw new IllegalStateException(e);
+            files = export();
+        } catch (final IOException
+                | InvalidKeySpecException
+                | InvalidAlgorithmParameterException
+                | InvalidKeyException
+                | NoSuchPaddingException
+                | NoSuchAlgorithmException
+                | NoSuchProviderException e) {
+            Log.d(Config.LOGTAG, "could not create backup", e);
+            return Result.failure();
         }
-        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);
+        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 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;
+    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 {
-            Log.d(
-                    Config.LOGTAG,
-                    "ExportBackupService. ignoring start command because already running");
+            return new ForegroundInfo(NOTIFICATION_ID, notification.build());
         }
-        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 IOException,
+                    InvalidKeySpecException,
+                    InvalidAlgorithmParameterException,
+                    InvalidKeyException,
+                    NoSuchPaddingException,
+                    NoSuchAlgorithmException,
+                    NoSuchProviderException {
+        final Context context = getApplicationContext();
+        final var database = DatabaseBackend.getInstance(context);
+        final var accounts = database.getAccounts();
 
-    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 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 : this.mAccounts) {
+        for (final Account account : accounts) {
             final String password = account.getPassword();
             if (Strings.nullToEmpty(password).trim().isEmpty()) {
                 Log.d(
@@ -295,20 +168,24 @@ public class ExportBackupService extends Service {
             secureRandom.nextBytes(salt);
             final BackupFileHeader backupFileHeader =
                     new BackupFileHeader(
-                            getString(R.string.app_name),
+                            context.getString(R.string.app_name),
                             account.getJid(),
                             System.currentTimeMillis(),
                             IV,
                             salt);
-            final Progress progress = new Progress(mBuilder, max, count);
+            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(this), filename);
+            final File file = new File(FileBackend.getBackupDirectory(context), filename);
             files.add(file);
             final File directory = file.getParentFile();
             if (directory != null && directory.mkdirs()) {
@@ -335,7 +212,7 @@ public class ExportBackupService extends Service {
                     new JsonWriter(
                             new OutputStreamWriter(gzipOutputStream, StandardCharsets.UTF_8));
             jsonWriter.beginArray();
-            final SQLiteDatabase db = this.mDatabaseBackend.getReadableDatabase();
+            final SQLiteDatabase db = database.getReadableDatabase();
             final String uuid = account.getUuid();
             accountExport(db, uuid, jsonWriter);
             simpleExport(db, Conversation.TABLENAME, Conversation.ACCOUNT, uuid, jsonWriter);
@@ -361,96 +238,240 @@ public class ExportBackupService extends Service {
     private void mediaScannerScanFile(final File file) {
         final Intent intent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
         intent.setData(Uri.fromFile(file));
-        sendBroadcast(intent);
+        getApplicationContext().sendBroadcast(intent);
     }
 
-    private void notifySuccess(final List<File> files) {
-        final String path = FileBackend.getBackupDirectory(this).getAbsolutePath();
-
-        PendingIntent openFolderIntent = null;
+    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();
+            }
+        }
+    }
 
-        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;
+    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();
             }
         }
+    }
 
-        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));
+    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++;
             }
-            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))
+    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(
-                                        getString(
+                                        context.getString(
                                                 R.string.notification_backup_created_subtitle,
-                                                FileBackend.getBackupDirectory(this)
+                                                FileBackend.getBackupDirectory(context)
                                                         .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);
+        if (openFolderIntent.isPresent()) {
+            mBuilder.setContentIntent(openFolderIntent.get());
+        } else {
+            Log.w(Config.LOGTAG, "no app can display folders");
         }
 
-        notificationManager.notify(NOTIFICATION_ID, mBuilder.build());
+        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());
     }
 
-    @Override
-    public IBinder onBind(Intent intent) {
-        return null;
+    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) {
+
+        // http://www.openintents.org/action/android-intent-action-view/file-directory
+        // do not use 'vnd.android.document/directory' since this will trigger system file manager
+        final Intent openIntent = new Intent(Intent.ACTION_VIEW);
+        openIntent.addCategory(Intent.CATEGORY_DEFAULT);
+        if (Compatibility.runsAndTargetsTwentyFour(context)) {
+            openIntent.setType("resource/folder");
+        } else {
+            openIntent.setDataAndType(Uri.parse("file://" + path), "resource/folder");
+        }
+        openIntent.putExtra("org.openintents.extra.ABSOLUTE_PATH", path);
+
+        final Intent amazeIntent = new Intent(Intent.ACTION_VIEW);
+        amazeIntent.setDataAndType(Uri.parse("com.amaze.filemanager:" + path), "resource/folder");
+
+        // will open a file manager at root and user can navigate themselves
+        final Intent systemFallBack = new Intent(Intent.ACTION_VIEW);
+        systemFallBack.addCategory(Intent.CATEGORY_DEFAULT);
+        systemFallBack.setData(
+                Uri.parse("content://com.android.externalstorage.documents/root/primary"));
+
+        return Arrays.asList(openIntent, amazeIntent, systemFallBack);
     }
 
     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();
         }
     }
 }

src/main/res/drawable/ic_calendar_month_24dp.xml 🔗

@@ -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>

src/main/res/values/arrays.xml 🔗

@@ -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>

src/main/res/values/strings.xml 🔗

@@ -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>

src/main/res/xml/preferences_backup.xml 🔗

@@ -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>

src/main/res/xml/preferences_main.xml 🔗

@@ -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"