add ability to cancel in-progress one-off backup

This commit is contained in:
Daniel Gultsch 2024-05-03 08:51:09 +02:00
parent 45b9c4dcc9
commit 5853f57f0a
No known key found for this signature in database
GPG key ID: F43D18AD2A0982C2
11 changed files with 208 additions and 111 deletions

View file

@ -143,7 +143,11 @@
</service>
<receiver
android:name=".services.EventReceiver"
android:name=".receiver.WorkManagerEventReceiver"
android:exported="false" />
<receiver
android:name=".receiver.SystemEventReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
@ -157,7 +161,7 @@
</receiver>
<receiver
android:name=".services.UnifiedPushDistributor"
android:name=".receiver.UnifiedPushDistributor"
android:enabled="false"
android:exported="true">
<intent-filter>

View file

@ -1,4 +1,4 @@
package eu.siacs.conversations.services;
package eu.siacs.conversations.receiver;
import android.content.BroadcastReceiver;
import android.content.Context;
@ -10,9 +10,10 @@ import android.util.Log;
import com.google.common.base.Strings;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.services.XmppConnectionService;
import eu.siacs.conversations.utils.Compatibility;
public class EventReceiver extends BroadcastReceiver {
public class SystemEventReceiver extends BroadcastReceiver {
public static final String SETTING_ENABLED_ACCOUNTS = "enabled_accounts";
public static final String EXTRA_NEEDS_FOREGROUND_SERVICE = "needs_foreground_service";

View file

@ -1,4 +1,4 @@
package eu.siacs.conversations.services;
package eu.siacs.conversations.receiver;
import android.app.PendingIntent;
import android.content.BroadcastReceiver;
@ -25,6 +25,7 @@ import java.util.List;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.persistance.UnifiedPushDatabase;
import eu.siacs.conversations.services.XmppConnectionService;
import eu.siacs.conversations.utils.Compatibility;
public class UnifiedPushDistributor extends BroadcastReceiver {

View file

@ -0,0 +1,32 @@
package eu.siacs.conversations.receiver;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.util.Log;
import androidx.work.WorkManager;
import com.google.common.base.Strings;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.ui.fragment.settings.BackupSettingsFragment;
public class WorkManagerEventReceiver extends BroadcastReceiver {
public static final String ACTION_STOP_BACKUP = "eu.siacs.conversations.receiver.STOP_BACKUP";
@Override
public void onReceive(final Context context, final Intent intent) {
final var action = Strings.nullToEmpty(intent == null ? null : intent.getAction());
if (action.equals(ACTION_STOP_BACKUP)) {
stopBackup(context);
}
}
private void stopBackup(final Context context) {
Log.d(Config.LOGTAG, "trying to stop one-off backup worker");
final var workManager = WorkManager.getInstance(context);
workManager.cancelUniqueWork(BackupSettingsFragment.CREATE_ONE_OFF_BACKUP);
}
}

View file

@ -21,6 +21,7 @@ import java.util.List;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.entities.Conversation;
import eu.siacs.conversations.receiver.SystemEventReceiver;
import eu.siacs.conversations.ui.ConversationsActivity;
import eu.siacs.conversations.utils.Compatibility;
@ -44,7 +45,7 @@ public class ContactChooserTargetService extends ChooserTargetService implements
@Override
public List<ChooserTarget> onGetChooserTargets(
final ComponentName targetActivityName, final IntentFilter matchedFilter) {
if (!EventReceiver.hasEnabledAccounts(this)) {
if (!SystemEventReceiver.hasEnabledAccounts(this)) {
return Collections.emptyList();
}
final Intent intent = new Intent(this, XmppConnectionService.class);

View file

@ -28,6 +28,7 @@ import eu.siacs.conversations.R;
import eu.siacs.conversations.entities.Account;
import eu.siacs.conversations.parser.AbstractParser;
import eu.siacs.conversations.persistance.UnifiedPushDatabase;
import eu.siacs.conversations.receiver.UnifiedPushDistributor;
import eu.siacs.conversations.xml.Element;
import eu.siacs.conversations.xml.Namespace;
import eu.siacs.conversations.xmpp.Jid;

View file

@ -128,6 +128,7 @@ import eu.siacs.conversations.parser.PresenceParser;
import eu.siacs.conversations.persistance.DatabaseBackend;
import eu.siacs.conversations.persistance.FileBackend;
import eu.siacs.conversations.persistance.UnifiedPushDatabase;
import eu.siacs.conversations.receiver.SystemEventReceiver;
import eu.siacs.conversations.ui.ChooseAccountForProfilePictureActivity;
import eu.siacs.conversations.ui.ConversationsActivity;
import eu.siacs.conversations.ui.RtpSessionActivity;
@ -678,7 +679,7 @@ public class XmppConnectionService extends Service {
@Override
public int onStartCommand(final Intent intent, int flags, int startId) {
final String action = Strings.nullToEmpty(intent == null ? null : intent.getAction());
final boolean needsForegroundService = intent != null && intent.getBooleanExtra(EventReceiver.EXTRA_NEEDS_FOREGROUND_SERVICE, false);
final boolean needsForegroundService = intent != null && intent.getBooleanExtra(SystemEventReceiver.EXTRA_NEEDS_FOREGROUND_SERVICE, false);
if (needsForegroundService) {
Log.d(Config.LOGTAG, "toggle forced foreground service after receiving event (action=" + action + ")");
toggleForegroundService(true);
@ -1286,7 +1287,7 @@ public class XmppConnectionService extends Service {
this.accounts = databaseBackend.getAccounts();
final SharedPreferences.Editor editor = getPreferences().edit();
final boolean hasEnabledAccounts = hasEnabledAccounts();
editor.putBoolean(EventReceiver.SETTING_ENABLED_ACCOUNTS, hasEnabledAccounts).apply();
editor.putBoolean(SystemEventReceiver.SETTING_ENABLED_ACCOUNTS, hasEnabledAccounts).apply();
editor.apply();
toggleSetProfilePictureActivity(hasEnabledAccounts);
reconfigurePushDistributor();
@ -1582,7 +1583,7 @@ public class XmppConnectionService extends Service {
return;
}
final long triggerAtMillis = SystemClock.elapsedRealtime() + (Config.POST_CONNECTIVITY_CHANGE_PING_INTERVAL * 1000);
final Intent intent = new Intent(this, EventReceiver.class);
final Intent intent = new Intent(this, SystemEventReceiver.class);
intent.setAction(ACTION_POST_CONNECTIVITY_CHANGE);
try {
final PendingIntent pendingIntent = PendingIntent.getBroadcast(this, 1, intent, s()
@ -1604,7 +1605,7 @@ public class XmppConnectionService extends Service {
if (alarmManager == null) {
return;
}
final Intent intent = new Intent(this, EventReceiver.class);
final Intent intent = new Intent(this, SystemEventReceiver.class);
intent.setAction(ACTION_PING);
try {
final PendingIntent pendingIntent =
@ -1623,7 +1624,7 @@ public class XmppConnectionService extends Service {
if (alarmManager == null) {
return;
}
final Intent intent = new Intent(this, EventReceiver.class);
final Intent intent = new Intent(this, SystemEventReceiver.class);
intent.setAction(ACTION_IDLE_PING);
try {
final PendingIntent pendingIntent = PendingIntent.getBroadcast(this, 0, intent, s()
@ -2573,7 +2574,7 @@ public class XmppConnectionService extends Service {
private void syncEnabledAccountSetting() {
final boolean hasEnabledAccounts = hasEnabledAccounts();
getPreferences().edit().putBoolean(EventReceiver.SETTING_ENABLED_ACCOUNTS, hasEnabledAccounts).apply();
getPreferences().edit().putBoolean(SystemEventReceiver.SETTING_ENABLED_ACCOUNTS, hasEnabledAccounts).apply();
toggleSetProfilePictureActivity(hasEnabledAccounts);
}

View file

@ -36,7 +36,7 @@ import java.util.concurrent.TimeUnit;
public class BackupSettingsFragment extends XmppPreferenceFragment {
private static final String CREATE_ONE_OFF_BACKUP = "create_one_off_backup";
public static final String CREATE_ONE_OFF_BACKUP = "create_one_off_backup";
private static final String RECURRING_BACKUP = "recurring_backup";
private final ActivityResultLauncher<String> requestStorageForBackupLauncher =

View file

@ -13,7 +13,7 @@ import com.google.common.collect.ImmutableList;
import com.google.common.collect.Lists;
import eu.siacs.conversations.R;
import eu.siacs.conversations.services.UnifiedPushDistributor;
import eu.siacs.conversations.receiver.UnifiedPushDistributor;
import eu.siacs.conversations.xmpp.Jid;
import java.net.URI;

View file

@ -1,6 +1,6 @@
package eu.siacs.conversations.utils;
import static eu.siacs.conversations.services.EventReceiver.EXTRA_NEEDS_FOREGROUND_SERVICE;
import static eu.siacs.conversations.receiver.SystemEventReceiver.EXTRA_NEEDS_FOREGROUND_SERVICE;
import android.annotation.SuppressLint;
import android.app.ActivityOptions;

View file

@ -11,6 +11,7 @@ import android.content.pm.ServiceInfo;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.net.Uri;
import android.os.SystemClock;
import android.util.Log;
import androidx.annotation.NonNull;
@ -21,6 +22,7 @@ import androidx.work.WorkerParameters;
import com.google.common.base.Optional;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.gson.stream.JsonWriter;
import eu.siacs.conversations.Config;
@ -31,6 +33,7 @@ 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.receiver.WorkManagerEventReceiver;
import eu.siacs.conversations.utils.BackupFileHeader;
import eu.siacs.conversations.utils.Compatibility;
@ -65,7 +68,7 @@ import javax.crypto.spec.SecretKeySpec;
public class ExportBackupWorker extends Worker {
private static final SimpleDateFormat DATE_FORMAT =
new SimpleDateFormat("yyyy-MM-dd", Locale.US);
new SimpleDateFormat("yyyy-MM-dd-HH-mm", Locale.US);
public static final String KEYTYPE = "AES";
public static final String CIPHERMODE = "AES/GCM/NoPadding";
@ -76,6 +79,11 @@ public class ExportBackupWorker extends Worker {
private static final int NOTIFICATION_ID = 19;
private static final int BACKUP_CREATED_NOTIFICATION_ID = 23;
private static final int PENDING_INTENT_FLAGS =
s()
? PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
: PendingIntent.FLAG_UPDATE_CURRENT;
private final boolean recurringBackup;
public ExportBackupWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
@ -99,9 +107,12 @@ public class ExportBackupWorker extends Worker {
| NoSuchProviderException e) {
Log.d(Config.LOGTAG, "could not create backup", e);
return Result.failure();
} finally {
getApplicationContext()
.getSystemService(NotificationManager.class)
.cancel(NOTIFICATION_ID);
}
Log.d(Config.LOGTAG, "done creating " + files.size() + " backup files");
getApplicationContext().getSystemService(NotificationManager.class).cancel(NOTIFICATION_ID);
if (files.isEmpty() || recurringBackup) {
return Result.success();
}
@ -113,13 +124,7 @@ public class ExportBackupWorker extends Worker {
@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);
final NotificationCompat.Builder notification = getNotification();
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
return new ForegroundInfo(
NOTIFICATION_ID,
@ -144,10 +149,13 @@ public class ExportBackupWorker extends Worker {
int count = 0;
final int max = accounts.size();
final SecureRandom secureRandom = new SecureRandom();
final List<File> files = new ArrayList<>();
final ImmutableList.Builder<File> files = new ImmutableList.Builder<>();
Log.d(Config.LOGTAG, "starting backup for " + max + " accounts");
for (final Account account : accounts) {
if (isStopped()) {
Log.d(Config.LOGTAG, "ExportBackupWorker has stopped. Returning what we have");
return files.build();
}
final String password = account.getPassword();
if (Strings.nullToEmpty(password).trim().isEmpty()) {
Log.d(
@ -155,84 +163,140 @@ public class ExportBackupWorker extends Worker {
String.format(
"skipping backup for %s because password is empty. unable to encrypt",
account.getJid().asBareJid()));
count++;
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);
try {
export(database, account, password, file, max, count);
} catch (final WorkStoppedException e) {
if (file.delete()) {
Log.d(
Config.LOGTAG,
"deleted in progress backup file " + file.getAbsolutePath());
}
Log.d(Config.LOGTAG, "ExportBackupWorker has stopped. Returning what we have");
return files.build();
}
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;
return files.build();
}
private void export(
final DatabaseBackend database,
final Account account,
final String password,
final File file,
final int max,
final int count)
throws IOException,
InvalidKeySpecException,
InvalidAlgorithmParameterException,
InvalidKeyException,
NoSuchPaddingException,
NoSuchAlgorithmException,
NoSuchProviderException,
WorkStoppedException {
final var context = getApplicationContext();
final SecureRandom secureRandom = new SecureRandom();
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 var notification = getNotification();
if (!recurringBackup) {
final var cancel = new Intent(context, WorkManagerEventReceiver.class);
cancel.setAction(WorkManagerEventReceiver.ACTION_STOP_BACKUP);
final var cancelPendingIntent =
PendingIntent.getBroadcast(context, 197, cancel, PENDING_INTENT_FLAGS);
notification.addAction(
new NotificationCompat.Action.Builder(
R.drawable.ic_cancel_24dp,
context.getString(R.string.cancel),
cancelPendingIntent)
.build());
}
final Progress progress = new Progress(notification, max, count);
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)) {
throwIfWorkStopped();
simpleExport(db, table, SQLiteAxolotlStore.ACCOUNT, uuid, jsonWriter);
}
jsonWriter.endArray();
jsonWriter.flush();
jsonWriter.close();
mediaScannerScanFile(file);
Log.d(Config.LOGTAG, "written backup to " + file.getAbsoluteFile());
}
private NotificationCompat.Builder getNotification() {
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);
notification.setOngoing(true);
notification.setLocalOnly(true);
return notification;
}
private void throwIfWorkStopped() throws WorkStoppedException {
if (isStopped()) {
throw new WorkStoppedException();
}
}
private void mediaScannerScanFile(final File file) {
@ -313,7 +377,7 @@ public class ExportBackupWorker extends Worker {
final String uuid,
final JsonWriter writer,
final Progress progress)
throws IOException {
throws IOException, WorkStoppedException {
final var notificationManager =
getApplicationContext().getSystemService(NotificationManager.class);
try (final Cursor cursor =
@ -322,9 +386,11 @@ public class ExportBackupWorker extends Worker {
new String[] {uuid})) {
final int size = cursor != null ? cursor.getCount() : 0;
Log.d(Config.LOGTAG, "exporting " + size + " messages for account " + uuid);
long lastUpdate = 0;
int i = 0;
int p = 0;
int p = Integer.MIN_VALUE;
while (cursor != null && cursor.moveToNext()) {
throwIfWorkStopped();
writer.beginObject();
writer.name("table");
writer.value(Message.TABLENAME);
@ -339,8 +405,9 @@ public class ExportBackupWorker extends Worker {
writer.endObject();
writer.endObject();
final int percentage = i * 100 / size;
if (p < percentage) {
if (p < percentage && (SystemClock.elapsedRealtime() - lastUpdate) > 2_000) {
p = percentage;
lastUpdate = SystemClock.elapsedRealtime();
notificationManager.notify(NOTIFICATION_ID, progress.build(p));
}
i++;
@ -377,13 +444,7 @@ public class ExportBackupWorker extends Worker {
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);
PendingIntent.getActivity(context, 190, chooser, PENDING_INTENT_FLAGS);
NotificationCompat.Builder mBuilder = new NotificationCompat.Builder(context, "backup");
mBuilder.setContentTitle(context.getString(R.string.notification_backup_created_title))
@ -418,14 +479,7 @@ public class ExportBackupWorker extends Worker {
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));
PendingIntent.getActivity(context, 189, intent, PENDING_INTENT_FLAGS));
}
}
return Optional.absent();
@ -474,4 +528,6 @@ public class ExportBackupWorker extends Worker {
return notification.build();
}
}
private static class WorkStoppedException extends Exception {}
}