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