diff options
8 files changed, 205 insertions, 6 deletions
diff --git a/app/src/main/java/de/danoeh/antennapod/activity/MainActivity.java b/app/src/main/java/de/danoeh/antennapod/activity/MainActivity.java index fe1db360b..d38d4e3ad 100644 --- a/app/src/main/java/de/danoeh/antennapod/activity/MainActivity.java +++ b/app/src/main/java/de/danoeh/antennapod/activity/MainActivity.java @@ -59,6 +59,7 @@ import de.danoeh.antennapod.fragment.TransitionEffect; import de.danoeh.antennapod.model.download.DownloadStatus; import de.danoeh.antennapod.net.download.serviceinterface.DownloadServiceInterface; import de.danoeh.antennapod.playback.cast.CastEnabledActivity; +import de.danoeh.antennapod.storage.importexport.AutomaticDatabaseExportWorker; import de.danoeh.antennapod.storage.preferences.UserPreferences; import de.danoeh.antennapod.ui.appstartintent.MainActivityStarter; import de.danoeh.antennapod.ui.common.ThemeUtils; @@ -167,6 +168,7 @@ public class MainActivity extends CastEnabledActivity { FeedUpdateManager.restartUpdateAlarm(this, false); SynchronizationQueueSink.syncNowIfNotSyncedRecently(); + AutomaticDatabaseExportWorker.enqueueIfNeeded(this, false); WorkManager.getInstance(this) .getWorkInfosByTagLiveData(FeedUpdateManager.WORK_TAG_FEED_UPDATE) diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/preferences/ImportExportPreferencesFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/preferences/ImportExportPreferencesFragment.java index 9191825aa..6c0e0d456 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/preferences/ImportExportPreferencesFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/preferences/ImportExportPreferencesFragment.java @@ -15,8 +15,10 @@ import androidx.activity.result.contract.ActivityResultContracts; import androidx.activity.result.contract.ActivityResultContracts.GetContent; import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.documentfile.provider.DocumentFile; +import androidx.preference.SwitchPreferenceCompat; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import androidx.core.app.ShareCompat; import androidx.core.content.FileProvider; @@ -30,6 +32,7 @@ import de.danoeh.antennapod.core.storage.DBReader; import de.danoeh.antennapod.model.feed.FeedItem; import de.danoeh.antennapod.model.feed.FeedItemFilter; import de.danoeh.antennapod.model.feed.SortOrder; +import de.danoeh.antennapod.storage.importexport.AutomaticDatabaseExportWorker; import de.danoeh.antennapod.storage.importexport.DatabaseExporter; import de.danoeh.antennapod.storage.importexport.FavoritesWriter; import de.danoeh.antennapod.storage.importexport.HtmlWriter; @@ -59,6 +62,7 @@ public class ImportExportPreferencesFragment extends PreferenceFragmentCompat { private static final String PREF_HTML_EXPORT = "prefHtmlExport"; private static final String PREF_DATABASE_IMPORT = "prefDatabaseImport"; private static final String PREF_DATABASE_EXPORT = "prefDatabaseExport"; + private static final String PREF_AUTOMATIC_DATABASE_EXPORT = "prefAutomaticDatabaseExport"; private static final String PREF_FAVORITE_EXPORT = "prefFavoritesExport"; private static final String DEFAULT_OPML_OUTPUT_NAME = "antennapod-feeds-%s.opml"; private static final String CONTENT_TYPE_OPML = "text/x-opml"; @@ -88,6 +92,8 @@ public class ImportExportPreferencesFragment extends PreferenceFragmentCompat { startActivity(intent); } }); + private final ActivityResultLauncher<Uri> automaticBackupLauncher = + registerForActivityResult(new PickWritableFolder(), this::setupAutomaticBackup); private Disposable disposable; private ProgressDialog progressDialog; @@ -143,7 +149,26 @@ public class ImportExportPreferencesFragment extends PreferenceFragmentCompat { }); findPreference(PREF_DATABASE_EXPORT).setOnPreferenceClickListener( preference -> { - exportDatabase(); + backupDatabaseLauncher.launch(dateStampFilename(DATABASE_EXPORT_FILENAME)); + return true; + }); + ((SwitchPreferenceCompat) findPreference(PREF_AUTOMATIC_DATABASE_EXPORT)) + .setChecked(UserPreferences.getAutomaticExportFolder() != null); + findPreference(PREF_AUTOMATIC_DATABASE_EXPORT).setOnPreferenceChangeListener( + (preference, newValue) -> { + if (Boolean.TRUE.equals(newValue)) { + try { + automaticBackupLauncher.launch(null); + } catch (ActivityNotFoundException e) { + e.printStackTrace(); + Snackbar.make(getView(), R.string.unable_to_start_system_file_manager, Snackbar.LENGTH_LONG) + .show(); + } + return false; + } else { + UserPreferences.setAutomaticExportFolder(null); + AutomaticDatabaseExportWorker.enqueueIfNeeded(getContext(), false); + } return true; }); findPreference(PREF_FAVORITE_EXPORT).setOnPreferenceClickListener( @@ -157,10 +182,6 @@ public class ImportExportPreferencesFragment extends PreferenceFragmentCompat { return String.format(fname, new SimpleDateFormat("yyyy-MM-dd", Locale.US).format(new Date())); } - private void exportDatabase() { - backupDatabaseLauncher.launch(dateStampFilename(DATABASE_EXPORT_FILENAME)); - } - private void importDatabase() { // setup the alert builder MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(getActivity()); @@ -330,6 +351,17 @@ public class ImportExportPreferencesFragment extends PreferenceFragmentCompat { } } + private void setupAutomaticBackup(Uri uri) { + if (uri == null) { + return; + } + getActivity().getContentResolver().takePersistableUriPermission(uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + UserPreferences.setAutomaticExportFolder(uri.toString()); + AutomaticDatabaseExportWorker.enqueueIfNeeded(getContext(), true); + ((SwitchPreferenceCompat) findPreference(PREF_AUTOMATIC_DATABASE_EXPORT)).setChecked(true); + } + private static class BackupDatabase extends ActivityResultContracts.CreateDocument { BackupDatabase() { @@ -345,6 +377,15 @@ public class ImportExportPreferencesFragment extends PreferenceFragmentCompat { } } + private static class PickWritableFolder extends ActivityResultContracts.OpenDocumentTree { + @NonNull + @Override + public Intent createIntent(@NonNull final Context context, @Nullable final Uri input) { + return super.createIntent(context, input) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + } + } + private enum Export { OPML(CONTENT_TYPE_OPML, DEFAULT_OPML_OUTPUT_NAME, R.string.opml_export_label), HTML(CONTENT_TYPE_HTML, DEFAULT_HTML_OUTPUT_NAME, R.string.html_export_label), diff --git a/app/src/main/res/xml/preferences_import_export.xml b/app/src/main/res/xml/preferences_import_export.xml index 383bff117..789c8c216 100644 --- a/app/src/main/res/xml/preferences_import_export.xml +++ b/app/src/main/res/xml/preferences_import_export.xml @@ -9,6 +9,11 @@ search:keywords="@string/import_export_search_keywords" android:title="@string/database_export_label" android:summary="@string/database_export_summary"/> + <SwitchPreferenceCompat + android:key="prefAutomaticDatabaseExport" + android:title="@string/automatic_database_export_label" + android:summary="@string/automatic_database_export_summary" + android:defaultValue="false" /> <Preference android:key="prefDatabaseImport" search:keywords="@string/import_export_search_keywords" diff --git a/storage/importexport/build.gradle b/storage/importexport/build.gradle index ddbbd1951..ae1faa284 100644 --- a/storage/importexport/build.gradle +++ b/storage/importexport/build.gradle @@ -8,14 +8,20 @@ android { } dependencies { + implementation project(':event') implementation project(':storage:database') implementation project(':storage:preferences') implementation project(':ui:i18n') + implementation project(':ui:notifications') implementation project(':model') annotationProcessor "androidx.annotation:annotation:$annotationVersion" + implementation "androidx.core:core:$coreVersion" + implementation 'androidx.documentfile:documentfile:1.0.1' + implementation "androidx.work:work-runtime:$workManagerVersion" + implementation "commons-io:commons-io:$commonsioVersion" implementation "io.reactivex.rxjava2:rxandroid:$rxAndroidVersion" implementation "io.reactivex.rxjava2:rxjava:$rxJavaVersion" - implementation 'androidx.documentfile:documentfile:1.0.1' + implementation "org.greenrobot:eventbus:$eventbusVersion" } diff --git a/storage/importexport/src/main/java/de/danoeh/antennapod/storage/importexport/AutomaticDatabaseExportWorker.java b/storage/importexport/src/main/java/de/danoeh/antennapod/storage/importexport/AutomaticDatabaseExportWorker.java new file mode 100644 index 000000000..6002c3bba --- /dev/null +++ b/storage/importexport/src/main/java/de/danoeh/antennapod/storage/importexport/AutomaticDatabaseExportWorker.java @@ -0,0 +1,127 @@ +package de.danoeh.antennapod.storage.importexport; + +import android.Manifest; +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Build; +import androidx.annotation.NonNull; +import androidx.core.app.NotificationCompat; +import androidx.core.content.ContextCompat; +import androidx.documentfile.provider.DocumentFile; +import androidx.work.ExistingPeriodicWorkPolicy; +import androidx.work.PeriodicWorkRequest; +import androidx.work.WorkManager; +import androidx.work.Worker; +import androidx.work.WorkerParameters; +import de.danoeh.antennapod.event.MessageEvent; +import de.danoeh.antennapod.storage.preferences.UserPreferences; +import de.danoeh.antennapod.ui.notifications.NotificationUtils; +import java.io.IOException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.TimeUnit; +import org.greenrobot.eventbus.EventBus; + +public class AutomaticDatabaseExportWorker extends Worker { + private static final String WORK_ID_AUTOMATIC_DATABASE_EXPORT = "de.danoeh.antennapod.AutomaticDbExport"; + + public static void enqueueIfNeeded(Context context, boolean replace) { + if (UserPreferences.getAutomaticExportFolder() == null) { + WorkManager.getInstance(context).cancelUniqueWork(WORK_ID_AUTOMATIC_DATABASE_EXPORT); + } else { + PeriodicWorkRequest workRequest = new PeriodicWorkRequest.Builder( + AutomaticDatabaseExportWorker.class, 1, TimeUnit.DAYS) + .build(); + WorkManager.getInstance(context).enqueueUniquePeriodicWork(WORK_ID_AUTOMATIC_DATABASE_EXPORT, + replace ? ExistingPeriodicWorkPolicy.REPLACE : ExistingPeriodicWorkPolicy.KEEP, workRequest); + } + } + + public AutomaticDatabaseExportWorker(@NonNull Context context, @NonNull WorkerParameters params) { + super(context, params); + } + + @Override + @NonNull + public Result doWork() { + String folderUri = UserPreferences.getAutomaticExportFolder(); + if (folderUri == null) { + return Result.success(); + } + try { + export(folderUri); + return Result.success(); + } catch (IOException e) { + showErrorNotification(e); + return Result.failure(); + } + } + + private void export(String folderUri) throws IOException { + DocumentFile documentFolder = DocumentFile.fromTreeUri(getApplicationContext(), Uri.parse(folderUri)); + if (documentFolder == null || !documentFolder.exists() || !documentFolder.canWrite()) { + throw new IOException("Unable to open export folder"); + } + String filename = String.format("AntennaPodBackup-%s.db", + new SimpleDateFormat("yyyy-MM-dd", Locale.US).format(new Date())); + DocumentFile exportFile = documentFolder.createFile("application/x-sqlite3", filename); + if (exportFile == null || !exportFile.canWrite()) { + throw new IOException("Unable to create export file"); + } + DatabaseExporter.exportToDocument(exportFile.getUri(), getApplicationContext()); + List<DocumentFile> files = new ArrayList<>(Arrays.asList(documentFolder.listFiles())); + Iterator<DocumentFile> itr = files.iterator(); + while (itr.hasNext()) { + DocumentFile file = itr.next(); + if (!file.getName().startsWith("AntennaPod")) { + itr.remove(); + } + } + Collections.sort(files, (o1, o2) -> Long.compare(o2.lastModified(), o1.lastModified())); + for (int i = 5; i < files.size(); i++) { + files.get(i).delete(); + } + } + + private void showErrorNotification(Exception exception) { + final String description = getApplicationContext().getString(R.string.automatic_database_export_error) + + " " + exception.getMessage(); + if (EventBus.getDefault().hasSubscriberForEvent(MessageEvent.class)) { + EventBus.getDefault().post(new MessageEvent(description)); + return; + } + + Intent intent = getApplicationContext().getPackageManager().getLaunchIntentForPackage( + getApplicationContext().getPackageName()); + PendingIntent pendingIntent = PendingIntent.getActivity(getApplicationContext(), + R.id.pending_intent_backup_error, intent, PendingIntent.FLAG_UPDATE_CURRENT + | (Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0)); + Notification notification = new NotificationCompat.Builder(getApplicationContext(), + NotificationUtils.CHANNEL_ID_SYNC_ERROR) + .setContentTitle(getApplicationContext().getString(R.string.automatic_database_export_error)) + .setContentText(exception.getMessage()) + .setStyle(new NotificationCompat.BigTextStyle().bigText(description)) + .setContentIntent(pendingIntent) + .setSmallIcon(R.drawable.ic_notification_sync_error) + .setAutoCancel(true) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .build(); + NotificationManager nm = (NotificationManager) getApplicationContext() + .getSystemService(Context.NOTIFICATION_SERVICE); + if (ContextCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.POST_NOTIFICATIONS) + == PackageManager.PERMISSION_GRANTED) { + nm.notify(R.id.notification_id_backup_error, notification); + } + } +} diff --git a/storage/importexport/src/main/res/values/ids.xml b/storage/importexport/src/main/res/values/ids.xml new file mode 100644 index 000000000..7e973d82a --- /dev/null +++ b/storage/importexport/src/main/res/values/ids.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <item name="pending_intent_backup_error" type="id" /> + <item name="notification_id_backup_error" type="id" /> +</resources> diff --git a/storage/preferences/src/main/java/de/danoeh/antennapod/storage/preferences/UserPreferences.java b/storage/preferences/src/main/java/de/danoeh/antennapod/storage/preferences/UserPreferences.java index a42493692..f2baf1242 100644 --- a/storage/preferences/src/main/java/de/danoeh/antennapod/storage/preferences/UserPreferences.java +++ b/storage/preferences/src/main/java/de/danoeh/antennapod/storage/preferences/UserPreferences.java @@ -114,6 +114,7 @@ public class UserPreferences { // Other private static final String PREF_DATA_FOLDER = "prefDataFolder"; public static final String PREF_DELETE_REMOVES_FROM_QUEUE = "prefDeleteRemovesFromQueue"; + private static final String PREF_AUTOMATIC_EXPORT_FOLDER = "prefAutomaticExportFolder"; // Mediaplayer private static final String PREF_PLAYBACK_SPEED = "prefPlaybackSpeed"; @@ -282,6 +283,15 @@ public class UserPreferences { prefs.edit().putBoolean(PREF_SHOW_TIME_LEFT, showRemain).apply(); } + @Nullable + public static String getAutomaticExportFolder() { + return prefs.getString(PREF_AUTOMATIC_EXPORT_FOLDER, null); + } + + public static void setAutomaticExportFolder(@Nullable String folder) { + prefs.edit().putString(PREF_AUTOMATIC_EXPORT_FOLDER, folder).apply(); + } + /** * Returns notification priority. * diff --git a/ui/i18n/src/main/res/values/strings.xml b/ui/i18n/src/main/res/values/strings.xml index b07d6c45f..964b85c77 100644 --- a/ui/i18n/src/main/res/values/strings.xml +++ b/ui/i18n/src/main/res/values/strings.xml @@ -579,6 +579,9 @@ <string name="opml_export_label">OPML export</string> <string name="html_export_label">HTML export</string> <string name="database_export_label">Database export</string> + <string name="automatic_database_export_label">Automatic database export</string> + <string name="automatic_database_export_summary">Automatically create daily backups of the AntennaPod database</string> + <string name="automatic_database_export_error">Error during automatic database backup</string> <string name="database_import_label">Database import</string> <string name="database_import_warning">Importing a database will replace all of your current subscriptions and playing history. You should export your current database as a backup. Do you want to replace?</string> <string name="please_wait">Please wait…</string> |