From beba818f5794bfcb3b444205adbb6b9ac0b27891 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Thu, 26 Oct 2023 17:24:11 +0300 Subject: [PATCH] Periodic backups --- .../kotatsu/core/backup/BackupZipOutput.kt | 2 +- .../kotatsu/core/prefs/AppSettings.kt | 13 +++ .../settings/backup/BackupViewModel.kt | 1 - .../PeriodicalBackupSettingsFragment.kt | 74 ++++++++++++++ .../settings/backup/PeriodicalBackupWorker.kt | 98 +++++++++++++++++++ .../userdata/UserDataSettingsFragment.kt | 15 +++ .../userdata/UserDataSettingsViewModel.kt | 18 ++++ .../settings/work/WorkScheduleManager.kt | 10 ++ app/src/main/res/values/arrays.xml | 35 ++++--- app/src/main/res/values/constants.xml | 7 ++ app/src/main/res/values/strings.xml | 9 ++ app/src/main/res/xml/pref_backup_periodic.xml | 26 +++++ app/src/main/res/xml/pref_user_data.xml | 6 ++ 13 files changed, 298 insertions(+), 16 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupWorker.kt create mode 100644 app/src/main/res/xml/pref_backup_periodic.xml diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt index c06f45a76..199a751ae 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt @@ -29,7 +29,7 @@ class BackupZipOutput(val file: File) : Closeable { } } -private const val DIR_BACKUPS = "backups" +const val DIR_BACKUPS = "backups" suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) { val dir = context.run { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt index 30d772688..dd039b641 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -354,6 +354,16 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { val is32BitColorsEnabled: Boolean get() = prefs.getBoolean(KEY_32BIT_COLOR, false) + val isPeriodicalBackupEnabled: Boolean + get() = prefs.getBoolean(KEY_BACKUP_PERIODICAL_ENABLED, false) + + val periodicalBackupFrequency: Long + get() = prefs.getString(KEY_BACKUP_PERIODICAL_FREQUENCY, null)?.toLongOrNull() ?: 7L + + var periodicalBackupOutput: Uri? + get() = prefs.getString(KEY_BACKUP_PERIODICAL_OUTPUT, null)?.toUriOrNull() + set(value) = prefs.edit { putString(KEY_BACKUP_PERIODICAL_OUTPUT, value?.toString()) } + fun isTipEnabled(tip: String): Boolean { return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true } @@ -458,6 +468,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { const val KEY_ZOOM_MODE = "zoom_mode" const val KEY_BACKUP = "backup" const val KEY_RESTORE = "restore" + const val KEY_BACKUP_PERIODICAL_ENABLED = "backup_periodic" + const val KEY_BACKUP_PERIODICAL_FREQUENCY = "backup_periodic_freq" + const val KEY_BACKUP_PERIODICAL_OUTPUT = "backup_periodic_output" const val KEY_HISTORY_GROUPING = "history_grouping" const val KEY_READING_INDICATORS = "reading_indicators" const val KEY_REVERSE_CHAPTERS = "reverse_chapters" diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt index 2a0db9416..90933985f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt @@ -43,7 +43,6 @@ class BackupViewModel @Inject constructor( backup.finish() progress.value = 1f - backup.close() backup.file } onBackupDone.call(file) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt new file mode 100644 index 000000000..cdbff719a --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt @@ -0,0 +1,74 @@ +package org.koitharu.kotatsu.settings.backup + +import android.content.SharedPreferences +import android.net.Uri +import android.os.Bundle +import android.view.View +import androidx.activity.result.ActivityResultCallback +import androidx.activity.result.contract.ActivityResultContracts +import androidx.preference.Preference +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.backup.DIR_BACKUPS +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.ui.BasePreferenceFragment +import org.koitharu.kotatsu.core.util.ext.tryLaunch +import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope +import java.io.File + +class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodic_backups), + ActivityResultCallback, SharedPreferences.OnSharedPreferenceChangeListener { + + private val outputSelectCall = registerForActivityResult( + ActivityResultContracts.OpenDocumentTree(), + this, + ) + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.pref_backup_periodic) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + settings.subscribe(this) + bindOutputSummary() + } + + override fun onPreferenceTreeClick(preference: Preference): Boolean { + return when (preference.key) { + AppSettings.KEY_BACKUP_PERIODICAL_OUTPUT -> outputSelectCall.tryLaunch(null) + else -> super.onPreferenceTreeClick(preference) + } + } + + override fun onDestroyView() { + super.onDestroyView() + settings.unsubscribe(this) + } + + override fun onActivityResult(result: Uri?) { + if (result != null) { + settings.periodicalBackupOutput = result + } + } + + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { + when (key) { + AppSettings.KEY_BACKUP_PERIODICAL_OUTPUT -> bindOutputSummary() + } + } + + private fun bindOutputSummary() { + val preference = findPreference(AppSettings.KEY_BACKUP_PERIODICAL_OUTPUT) ?: return + viewLifecycleScope.launch { + preference.summary = withContext(Dispatchers.Default) { + val value = settings.periodicalBackupOutput + value?.toString() ?: preference.context.run { + getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS) + }.path + } + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupWorker.kt new file mode 100644 index 000000000..7c55b75ff --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupWorker.kt @@ -0,0 +1,98 @@ +package org.koitharu.kotatsu.settings.backup + +import android.content.Context +import android.os.Build +import androidx.hilt.work.HiltWorker +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.await +import dagger.Reusable +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import okio.buffer +import okio.sink +import okio.source +import org.koitharu.kotatsu.core.backup.BackupRepository +import org.koitharu.kotatsu.core.backup.BackupZipOutput +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.util.ext.awaitUniqueWorkInfoByName +import org.koitharu.kotatsu.core.util.ext.writeAllCancellable +import org.koitharu.kotatsu.settings.work.PeriodicWorkScheduler +import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +@HiltWorker +class PeriodicalBackupWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted params: WorkerParameters, + private val repository: BackupRepository, + private val settings: AppSettings, +) : CoroutineWorker(appContext, params) { + + override suspend fun doWork(): Result { + val file = BackupZipOutput(applicationContext).use { backup -> + backup.put(repository.createIndex()) + backup.put(repository.dumpHistory()) + backup.put(repository.dumpCategories()) + backup.put(repository.dumpFavourites()) + backup.put(repository.dumpBookmarks()) + backup.put(repository.dumpSettings()) + backup.finish() + backup.file + } + return settings.periodicalBackupOutput?.let { + applicationContext.contentResolver.openOutputStream(it)?.use { output -> + file.source().use { input -> + output.sink().buffer().writeAllCancellable(input) + } + Result.success() + } ?: Result.failure() + } ?: Result.success() + } + + @Reusable + class Scheduler @Inject constructor( + private val workManager: WorkManager, + private val settings: AppSettings, + ) : PeriodicWorkScheduler { + + override suspend fun schedule() { + val constraints = Constraints.Builder() + .setRequiresStorageNotLow(true) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + constraints.setRequiresDeviceIdle(true) + } + val request = PeriodicWorkRequestBuilder( + settings.periodicalBackupFrequency, + TimeUnit.HOURS, + ).setConstraints(constraints.build()) + .addTag(TAG) + .build() + workManager + .enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.UPDATE, request) + .await() + } + + override suspend fun unschedule() { + workManager + .cancelUniqueWork(TAG) + .await() + } + + override suspend fun isScheduled(): Boolean { + return workManager + .awaitUniqueWorkInfoByName(TAG) + .any { !it.state.isFinished } + } + } + + private companion object { + + const val TAG = "backups" + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/UserDataSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/UserDataSettingsFragment.kt index d3694aa6a..2e5776ebc 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/UserDataSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/UserDataSettingsFragment.kt @@ -65,6 +65,7 @@ class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privac findPreference(AppSettings.KEY_PAGES_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.PAGES])) findPreference(AppSettings.KEY_THUMBS_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.THUMBS])) findPreference(AppSettings.KEY_HTTP_CACHE_CLEAR)?.bindBytesSizeSummary(viewModel.httpCacheSize) + bindPeriodicalBackupSummary() findPreference(AppSettings.KEY_SEARCH_HISTORY_CLEAR)?.let { pref -> viewModel.searchHistoryCount.observe(viewLifecycleOwner) { pref.summary = if (it < 0) { @@ -200,6 +201,20 @@ class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privac } } + private fun bindPeriodicalBackupSummary() { + val preference = findPreference(AppSettings.KEY_BACKUP_PERIODICAL_ENABLED) ?: return + val entries = resources.getStringArray(R.array.backup_frequency) + val entryValues = resources.getStringArray(R.array.values_backup_frequency) + viewModel.periodicalBackupFrequency.observe(viewLifecycleOwner) { freq -> + preference.summary = if (freq == 0L) { + getString(R.string.disabled) + } else { + val index = entryValues.indexOf(freq.toString()) + entries.getOrNull(index) + } + } + } + private fun clearSearchHistory() { MaterialAlertDialogBuilder(context ?: return) .setTitle(R.string.clear_search_history) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/UserDataSettingsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/UserDataSettingsViewModel.kt index ff4b3a87a..6f6e4c294 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/UserDataSettingsViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/UserDataSettingsViewModel.kt @@ -5,12 +5,15 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.update import kotlinx.coroutines.runInterruptible import okhttp3.Cache import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.util.ext.MutableEventFlow @@ -29,6 +32,7 @@ class UserDataSettingsViewModel @Inject constructor( private val searchRepository: MangaSearchRepository, private val trackingRepository: TrackingRepository, private val cookieJar: MutableCookieJar, + private val settings: AppSettings, ) : BaseViewModel() { val onActionDone = MutableEventFlow() @@ -40,6 +44,20 @@ class UserDataSettingsViewModel @Inject constructor( val cacheSizes = EnumMap>(CacheDir::class.java) val storageUsage = MutableStateFlow(null) + val periodicalBackupFrequency = settings.observeAsFlow( + key = AppSettings.KEY_BACKUP_PERIODICAL_ENABLED, + valueProducer = { isPeriodicalBackupEnabled }, + ).flatMapLatest { isEnabled -> + if (isEnabled) { + settings.observeAsFlow( + key = AppSettings.KEY_BACKUP_PERIODICAL_FREQUENCY, + valueProducer = { periodicalBackupFrequency }, + ) + } else { + flowOf(0) + } + } + private var storageUsageJob: Job? = null init { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/work/WorkScheduleManager.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/work/WorkScheduleManager.kt index a941c9847..47b9eb6b3 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/work/WorkScheduleManager.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/work/WorkScheduleManager.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.ext.processLifecycleScope +import org.koitharu.kotatsu.settings.backup.PeriodicalBackupWorker import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker import org.koitharu.kotatsu.tracker.work.TrackWorker import javax.inject.Inject @@ -13,6 +14,7 @@ class WorkScheduleManager @Inject constructor( private val settings: AppSettings, private val suggestionScheduler: SuggestionsWorker.Scheduler, private val trackerScheduler: TrackWorker.Scheduler, + private val periodicalBackupScheduler: PeriodicalBackupWorker.Scheduler, ) : SharedPreferences.OnSharedPreferenceChangeListener { override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { @@ -30,6 +32,13 @@ class WorkScheduleManager @Inject constructor( isEnabled = settings.isSuggestionsEnabled, force = key != AppSettings.KEY_SUGGESTIONS, ) + + AppSettings.KEY_BACKUP_PERIODICAL_ENABLED, + AppSettings.KEY_BACKUP_PERIODICAL_FREQUENCY -> updateWorker( + scheduler = periodicalBackupScheduler, + isEnabled = settings.isPeriodicalBackupEnabled, + force = key != AppSettings.KEY_BACKUP_PERIODICAL_ENABLED, + ) } } @@ -38,6 +47,7 @@ class WorkScheduleManager @Inject constructor( processLifecycleScope.launch(Dispatchers.Default) { updateWorkerImpl(trackerScheduler, settings.isTrackerEnabled, false) updateWorkerImpl(suggestionScheduler, settings.isSuggestionsEnabled, false) + updateWorkerImpl(periodicalBackupScheduler, settings.isPeriodicalBackupEnabled, false) } } diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 1f0cb2988..1caa686e9 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -1,51 +1,51 @@ - + @string/automatic @string/light @string/dark - + @string/taps_on_edges @string/volume_buttons - + @string/zoom_mode_fit_center @string/zoom_mode_fit_height @string/zoom_mode_fit_width @string/zoom_mode_keep_start - + @string/favourites @string/history - + @string/list @string/detailed_list @string/grid - + @string/screenshots_allow @string/screenshots_block_nsfw @string/screenshots_block_all - + @string/always @string/only_using_wifi @string/never - + @string/disabled Google CloudFlare AdGuard - + @string/standard @string/right_to_left @string/webtoon - + @string/status_planned @string/status_reading @string/status_re_reading @@ -53,25 +53,32 @@ @string/status_on_hold @string/status_dropped - + @string/disabled HTTP SOCKS (v4/v5) - + @string/system_default @string/color_light @string/color_dark @string/color_white @string/color_black - + @string/disabled @string/system_default @string/advanced - + @string/history @string/favourites + + @string/frequency_every_day + @string/frequency_every_2_days + @string/frequency_once_per_week + @string/frequency_twice_per_month + @string/frequency_once_per_month + diff --git a/app/src/main/res/values/constants.xml b/app/src/main/res/values/constants.xml index ad070d5a4..3802b534a 100644 --- a/app/src/main/res/values/constants.xml +++ b/app/src/main/res/values/constants.xml @@ -64,4 +64,11 @@ 0 1 + + 1 + 2 + 7 + 14 + 30 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 18a981f0f..632dc226e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -500,4 +500,13 @@ Relevance Categories Online variant + Periodic backups + Backup creation frequency + Every day + Every 2 days + Once per week + Twice per month + Once per month + Enable periodic backups + Backups output directory diff --git a/app/src/main/res/xml/pref_backup_periodic.xml b/app/src/main/res/xml/pref_backup_periodic.xml new file mode 100644 index 000000000..d536a5aea --- /dev/null +++ b/app/src/main/res/xml/pref_backup_periodic.xml @@ -0,0 +1,26 @@ + + + + + + + + + + diff --git a/app/src/main/res/xml/pref_user_data.xml b/app/src/main/res/xml/pref_user_data.xml index 3146da28e..f8dc07234 100644 --- a/app/src/main/res/xml/pref_user_data.xml +++ b/app/src/main/res/xml/pref_user_data.xml @@ -34,6 +34,12 @@ android:summary="@string/restore_summary" android:title="@string/restore_backup" /> + +