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