Detailed changes
@@ -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"
@@ -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(
@@ -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"
@@ -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;
@@ -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();
+ }
+}
@@ -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();
@@ -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> {
@@ -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);
+ }
+ }
}
@@ -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");
@@ -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);
@@ -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();
}
}
}
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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"