diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupJob.java index 6e64a2a7260..967a8b0cbde 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/LocalBackupJob.java @@ -75,6 +75,12 @@ public static void enqueueArchive() { jobManager.add(new LocalArchiveJob(parameters.build())); } + /** Sends a cancellation signal to all ongoing backup jobs. */ + public static void cancelRunningJobs() { + JobManager jobManager = AppDependencies.getJobManager(); + jobManager.cancelAllInQueue(QUEUE); + } + private LocalBackupJob(@NonNull Job.Parameters parameters) { super(parameters); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SettingsValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SettingsValues.java index e89e6dc2b1b..0dab197fb59 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SettingsValues.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/SettingsValues.java @@ -13,6 +13,7 @@ import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.dependencies.AppDependencies; import org.thoughtcrime.securesms.mms.SentMediaQuality; +import org.thoughtcrime.securesms.preferences.BackupFrequencyV1; import org.thoughtcrime.securesms.preferences.widgets.NotificationPrivacyPreference; import org.thoughtcrime.securesms.util.SingleLiveEvent; import org.thoughtcrime.securesms.util.TextSecurePreferences; @@ -45,6 +46,7 @@ public final class SettingsValues extends SignalStoreValues { public static final String PREFER_SYSTEM_EMOJI = "settings.use.system.emoji"; public static final String ENTER_KEY_SENDS = "settings.enter.key.sends"; public static final String BACKUPS_ENABLED = "settings.backups.enabled"; + public static final String BACKUPS_SCHEDULE_FREQUENCY = "settings.backups.schedule.frequency"; public static final String BACKUPS_SCHEDULE_HOUR = "settings.backups.schedule.hour"; public static final String BACKUPS_SCHEDULE_MINUTE = "settings.backups.schedule.minute"; public static final String SMS_DELIVERY_REPORTS_ENABLED = "settings.sms.delivery.reports.enabled"; @@ -73,8 +75,9 @@ public final class SettingsValues extends SignalStoreValues { private static final String SCREEN_LOCK_ENABLED = "settings.screen.lock.enabled"; private static final String SCREEN_LOCK_TIMEOUT = "settings.screen.lock.timeout"; - public static final int BACKUP_DEFAULT_HOUR = 2; - public static final int BACKUP_DEFAULT_MINUTE = 0; + public static final BackupFrequencyV1 BACKUP_DEFAULT_FREQUENCY = BackupFrequencyV1.MONTHLY; + public static final int BACKUP_DEFAULT_HOUR = 2; + public static final int BACKUP_DEFAULT_MINUTE = 0; private final SingleLiveEvent onConfigurationSettingChanged = new SingleLiveEvent<>(); @@ -106,7 +109,7 @@ void onFirstEverAppLaunch() { } if (!store.containsKey(BACKUPS_SCHEDULE_HOUR)) { // Initialize backup time to a 5min interval between 1-5am - setBackupSchedule(new Random().nextInt(5) + 1, new Random().nextInt(12) * 5); + setBackupSchedule(BACKUP_DEFAULT_FREQUENCY, new Random().nextInt(5) + 1, new Random().nextInt(12) * 5); } } @@ -307,6 +310,10 @@ public void setBackupEnabled(boolean backupEnabled) { putBoolean(BACKUPS_ENABLED, backupEnabled); } + public BackupFrequencyV1 getBackupFrequency() { + return BackupFrequencyV1.valueOf(getString(BACKUPS_SCHEDULE_FREQUENCY, BACKUP_DEFAULT_FREQUENCY.name())); + } + public int getBackupHour() { return getInteger(BACKUPS_SCHEDULE_HOUR, BACKUP_DEFAULT_HOUR); } @@ -315,7 +322,8 @@ public int getBackupMinute() { return getInteger(BACKUPS_SCHEDULE_MINUTE, BACKUP_DEFAULT_MINUTE); } - public void setBackupSchedule(int hour, int minute) { + public void setBackupSchedule(BackupFrequencyV1 frequency, int hour, int minute) { + putString(BACKUPS_SCHEDULE_FREQUENCY, frequency.name()); putInteger(BACKUPS_SCHEDULE_HOUR, hour); putInteger(BACKUPS_SCHEDULE_MINUTE, minute); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/BackupJitterMigrationJob.kt b/app/src/main/java/org/thoughtcrime/securesms/migrations/BackupJitterMigrationJob.kt index c4aca674bcc..f650d0c125d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/migrations/BackupJitterMigrationJob.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/BackupJitterMigrationJob.kt @@ -22,13 +22,14 @@ internal class BackupJitterMigrationJob(parameters: Parameters = Parameters.Buil override fun isUiBlocking(): Boolean = false override fun performMigration() { + val frequency = SignalStore.settings.backupFrequency val hour = SignalStore.settings.backupHour val minute = SignalStore.settings.backupMinute if (hour == SettingsValues.BACKUP_DEFAULT_HOUR && minute == SettingsValues.BACKUP_DEFAULT_MINUTE) { val rand = Random() val newHour = rand.nextInt(3) + 1 // between 1AM - 3AM val newMinute = rand.nextInt(12) * 5 // 5 minute intervals up to +55 minutes - SignalStore.settings.setBackupSchedule(newHour, newMinute) + SignalStore.settings.setBackupSchedule(frequency, newHour, newMinute) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/BackupFrequencyPickerDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/BackupFrequencyPickerDialogFragment.kt new file mode 100644 index 00000000000..c142f1744f2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/BackupFrequencyPickerDialogFragment.kt @@ -0,0 +1,38 @@ +package org.thoughtcrime.securesms.preferences + +import android.app.Dialog +import android.content.DialogInterface.OnClickListener +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.thoughtcrime.securesms.R +class BackupFrequencyPickerDialogFragment(private val defaultFrequency: BackupFrequencyV1) : DialogFragment() { + + private val frequencyOptions = BackupFrequencyV1.entries + private var selectedFrequency: BackupFrequencyV1 = defaultFrequency + private var callback: OnClickListener? = null + + override fun onCreateDialog(savedInstance: Bundle?): Dialog { + val context = requireContext() + val localizedFrequencyOptions = frequencyOptions + .map { it.getResourceId() } + .map { context.getString(it) } + .toTypedArray() + val defaultIndex = frequencyOptions.indexOf(defaultFrequency) + + return MaterialAlertDialogBuilder(context) + .setSingleChoiceItems(localizedFrequencyOptions, defaultIndex) { _, selectedIndex -> + selectedFrequency = frequencyOptions[selectedIndex] + } + .setTitle(R.string.BackupFrequencyPickerDialogFragment__set_backup_frequency) + .setPositiveButton(R.string.BackupFrequencyPickerDialogFragment__ok, callback) + .setNegativeButton(R.string.BackupFrequencyPickerDialogFragment__cancel, null) + .create() + } + + fun getValue(): BackupFrequencyV1 = selectedFrequency + + fun setOnPositiveButtonClickListener(cb: OnClickListener) { + this.callback = cb + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/BackupFrequencyV1.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/BackupFrequencyV1.kt new file mode 100644 index 00000000000..d369f531096 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/BackupFrequencyV1.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.preferences + +import androidx.annotation.StringRes +import org.thoughtcrime.securesms.R + +enum class BackupFrequencyV1(val days: Int) { + DAILY(1), + WEEKLY(7), + MONTHLY(30), + QUARTERLY(90), + NEVER(999); + + @StringRes + fun getResourceId(): Int { + return when (this) { + DAILY -> R.string.BackupsPreferenceFragment__frequency_label_daily + WEEKLY -> R.string.BackupsPreferenceFragment__frequency_label_weekly + MONTHLY -> R.string.BackupsPreferenceFragment__frequency_label_monthly + QUARTERLY -> R.string.BackupsPreferenceFragment__frequency_label_quarterly + NEVER -> R.string.BackupsPreferenceFragment__frequency_label_never + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/BackupsPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/BackupsPreferenceFragment.java index 22c30b4e0fe..13e82264b44 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/BackupsPreferenceFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/BackupsPreferenceFragment.java @@ -49,6 +49,7 @@ import java.time.LocalTime; import java.util.Locale; import java.util.Objects; +import java.util.Optional; public class BackupsPreferenceFragment extends Fragment { @@ -60,7 +61,9 @@ public class BackupsPreferenceFragment extends Fragment { private View folder; private View verify; private View timer; + private View frequencyView; private TextView timeLabel; + private TextView frequencyLabel; private TextView toggle; private TextView info; private TextView summary; @@ -85,6 +88,8 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat verify = view.findViewById(R.id.fragment_backup_verify); timer = view.findViewById(R.id.fragment_backup_time); timeLabel = view.findViewById(R.id.fragment_backup_time_value); + frequencyView = view.findViewById(R.id.fragment_backup_frequency); + frequencyLabel = view.findViewById(R.id.fragment_backup_frequency_value); toggle = view.findViewById(R.id.fragment_backup_toggle); info = view.findViewById(R.id.fragment_backup_info); summary = view.findViewById(R.id.fragment_backup_create_summary); @@ -96,6 +101,7 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat create.setOnClickListener(unused -> onCreateClicked()); verify.setOnClickListener(unused -> BackupDialog.showVerifyBackupPassphraseDialog(requireContext())); timer.setOnClickListener(unused -> pickTime()); + frequencyView.setOnClickListener(unused -> pickFrequency()); formatter.setMinimumFractionDigits(1); formatter.setMaximumFractionDigits(1); @@ -275,9 +281,10 @@ private void pickTime() { .setTitleText(R.string.BackupsPreferenceFragment__set_backup_time) .build(); timePickerFragment.addOnPositiveButtonClickListener(v -> { + BackupFrequencyV1 frequency = SignalStore.settings().getBackupFrequency(); int hour = timePickerFragment.getHour(); int minute = timePickerFragment.getMinute(); - SignalStore.settings().setBackupSchedule(hour, minute); + applyNewBackupScheduleSetting(frequency, hour, minute); updateTimeLabel(); TextSecurePreferences.setNextBackupTime(requireContext(), 0); LocalBackupListener.schedule(requireContext()); @@ -285,6 +292,41 @@ private void pickTime() { timePickerFragment.show(getChildFragmentManager(), "TIME_PICKER"); } + private void pickFrequency() { + final BackupFrequencyPickerDialogFragment frequencyPickerDialogFragment = new BackupFrequencyPickerDialogFragment(SignalStore.settings().getBackupFrequency()); + frequencyPickerDialogFragment.setOnPositiveButtonClickListener((unused1, unused2) -> { + BackupFrequencyV1 frequency = frequencyPickerDialogFragment.getValue(); + int hour = SignalStore.settings().getBackupHour(); + int minute = SignalStore.settings().getBackupMinute(); + applyNewBackupScheduleSetting(frequency, hour, minute); + updateDateLabel(); + }); + frequencyPickerDialogFragment.show(getChildFragmentManager(), "FREQUENCY_PICKER"); + } + + /** Update the settings on disk and then schedule a backup. + * + *

This method should be called when the user presses the buttons to set a new backup schedule with the given parameters. */ + private void applyNewBackupScheduleSetting(BackupFrequencyV1 frequency, int hour, int minute) { + Log.i(TAG, "Setting backup schedule: " + frequency.name() + " at" + hour + "h" + minute + "m"); + SignalStore.settings().setBackupSchedule(frequency, hour, minute); + if (frequency == BackupFrequencyV1.NEVER) { + LocalBackupListener.unschedule(requireContext()); + } else { + // Schedule the next backup using the newly set frequency, but relative to the time of the + // last backup. This should only kick off a new backup to be created immediately if the + // last backup was long enough ago (or doesn't exist at all). + long lastBackupTime = 0; + try { + lastBackupTime = Optional.ofNullable(BackupUtil.getLatestBackup()) + .map(BackupUtil.BackupInfo::getTimestamp) + .orElse(0L); + } catch (NoExternalStorageException ignored) {} + TextSecurePreferences.setNextBackupTime(requireContext(), lastBackupTime + frequency.getDays() * 24 * 60 * 60 * 1000L); + LocalBackupListener.schedule(requireContext()); + } + } + private void onCreateClickedLegacy() { Permissions.with(this) .request(Manifest.permission.WRITE_EXTERNAL_STORAGE) @@ -304,6 +346,10 @@ private void updateTimeLabel() { timeLabel.setText(JavaTimeExtensionsKt.formatHours(time, requireContext())); } + private void updateDateLabel() { + frequencyLabel.setText(getResources().getString(SignalStore.settings().getBackupFrequency().getResourceId())); + } + private void updateToggle() { boolean userUnregistered = TextSecurePreferences.isUnauthorizedReceived(AppDependencies.getApplication()) || !SignalStore.account().isRegistered(); boolean clientDeprecated = SignalStore.misc().isClientDeprecated(); @@ -317,8 +363,10 @@ private void setBackupsEnabled() { create.setVisibility(View.VISIBLE); verify.setVisibility(View.VISIBLE); timer.setVisibility(View.VISIBLE); + frequencyView.setVisibility(View.VISIBLE); updateToggle(); updateTimeLabel(); + updateDateLabel(); setBackupFolderName(); } @@ -328,6 +376,7 @@ private void setBackupsDisabled() { folder.setVisibility(View.GONE); verify.setVisibility(View.GONE); timer.setVisibility(View.GONE); + frequencyView.setVisibility(View.GONE); updateToggle(); AppDependencies.getJobManager().cancelAllInQueue(LocalBackupJob.QUEUE); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/LocalBackupListener.java b/app/src/main/java/org/thoughtcrime/securesms/service/LocalBackupListener.java index f7888530fbd..cbec6839ae6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/LocalBackupListener.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/LocalBackupListener.java @@ -7,6 +7,7 @@ import org.thoughtcrime.securesms.jobs.LocalBackupJob; import org.thoughtcrime.securesms.keyvalue.SignalStore; +import org.thoughtcrime.securesms.preferences.BackupFrequencyV1; import org.thoughtcrime.securesms.util.JavaTimeExtensionsKt; import org.thoughtcrime.securesms.util.TextSecurePreferences; @@ -30,7 +31,7 @@ protected long getNextScheduledExecutionTime(Context context) { @Override protected long onAlarm(Context context, long scheduledTime) { - if (SignalStore.settings().isBackupEnabled()) { + if (SignalStore.settings().isBackupEnabled() && SignalStore.settings().getBackupFrequency() != BackupFrequencyV1.NEVER) { LocalBackupJob.enqueue(false); } @@ -38,17 +39,31 @@ protected long onAlarm(Context context, long scheduledTime) { } public static void schedule(Context context) { - if (SignalStore.settings().isBackupEnabled()) { + if (SignalStore.settings().isBackupEnabled() && SignalStore.settings().getBackupFrequency() != BackupFrequencyV1.NEVER) { new LocalBackupListener().onReceive(context, getScheduleIntent()); } } + /** Cancels any future backup scheduled with AlarmManager and attempts to cancel any ongoing backup job. */ + public static void unschedule(Context context) { + new LocalBackupListener().cancel(context); + LocalBackupJob.cancelRunningJobs(); + } + public static long setNextBackupTimeToIntervalFromNow(@NonNull Context context) { - LocalDateTime now = LocalDateTime.now(); - int hour = SignalStore.settings().getBackupHour(); - int minute = SignalStore.settings().getBackupMinute(); - LocalDateTime next = MessageBackupListener.getNextDailyBackupTimeFromNowWithJitter(now, hour, minute, BACKUP_JITTER_WINDOW_SECONDS, new Random()); + BackupFrequencyV1 freq = SignalStore.settings().getBackupFrequency(); + + if (freq == BackupFrequencyV1.NEVER) { + TextSecurePreferences.setNextBackupTime(context, -1); + return -1; + } + + LocalDateTime now = LocalDateTime.now(); + int hour = SignalStore.settings().getBackupHour(); + int minute = SignalStore.settings().getBackupMinute(); + LocalDateTime next = MessageBackupListener.getNextDailyBackupTimeFromNowWithJitter(now, hour, minute, BACKUP_JITTER_WINDOW_SECONDS, new Random()); + next = next.plusDays(freq.getDays()); long nextTime = JavaTimeExtensionsKt.toMillis(next); TextSecurePreferences.setNextBackupTime(context, nextTime); diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/PersistentAlarmManagerListener.java b/app/src/main/java/org/thoughtcrime/securesms/service/PersistentAlarmManagerListener.java index c430089a382..09993cbbd02 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/PersistentAlarmManagerListener.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/PersistentAlarmManagerListener.java @@ -28,6 +28,15 @@ public abstract class PersistentAlarmManagerListener extends BroadcastReceiver { protected abstract long onAlarm(Context context, long scheduledTime); + public void cancel(Context context) { + AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + Intent alarmIntent = new Intent(context, getClass()); + PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, alarmIntent, PendingIntentFlags.immutable()); + + info("Cancelling alarm"); + alarmManager.cancel(pendingIntent); + } + @Override public void onReceive(Context context, Intent intent) { info(String.format("onReceive(%s)", intent.getAction())); @@ -47,6 +56,7 @@ public void onReceive(Context context, Intent intent) { return; } + // If we've already scheduled this alarm, cancel it so we can schedule it again with the new time. alarmManager.cancel(pendingIntent); if (shouldScheduleExact()) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/TextSecurePreferences.java b/app/src/main/java/org/thoughtcrime/securesms/util/TextSecurePreferences.java index be120e23fe2..c8893ebdb5a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/TextSecurePreferences.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/TextSecurePreferences.java @@ -107,7 +107,7 @@ public class TextSecurePreferences { public static final String BACKUP_ENABLED = "pref_backup_enabled"; private static final String BACKUP_PASSPHRASE = "pref_backup_passphrase"; private static final String ENCRYPTED_BACKUP_PASSPHRASE = "pref_encrypted_backup_passphrase"; - private static final String BACKUP_TIME = "pref_backup_next_time"; + private static final String BACKUP_TIME = "pref_backup_next_time"; // milliseconds since 1970 @Deprecated public static final String REGISTRATION_LOCK_PREF_V1 = "pref_registration_lock"; diff --git a/app/src/main/res/layout/fragment_backups.xml b/app/src/main/res/layout/fragment_backups.xml index 62f2ca53e2c..63f8786912c 100644 --- a/app/src/main/res/layout/fragment_backups.xml +++ b/app/src/main/res/layout/fragment_backups.xml @@ -158,6 +158,36 @@ tools:text="3:00" /> + + + + + + + Backup folder Backup time + + Backup frequency Verify backup passphrase Test your backup passphrase and verify that it matches Turn on @@ -937,7 +939,18 @@ Signal requires external storage permission in order to create backups, but it has been permanently denied. Please continue to app settings, select \"Permissions\" and enable \"Storage\". Set backup time - + + Daily + Weekly + Monthly + Quarterly + Never + + + + Set backup frequency + OK + Cancel Using custom: %s