Periodic backups

This commit is contained in:
Koitharu
2023-10-26 17:24:11 +03:00
parent beb17ef442
commit beba818f57
13 changed files with 298 additions and 16 deletions

View File

@@ -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 {

View File

@@ -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"

View File

@@ -43,7 +43,6 @@ class BackupViewModel @Inject constructor(
backup.finish()
progress.value = 1f
backup.close()
backup.file
}
onBackupDone.call(file)

View File

@@ -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<Uri?>, 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<Preference>(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
}
}
}
}

View File

@@ -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<SuggestionsWorker>(
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"
}
}

View File

@@ -65,6 +65,7 @@ class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privac
findPreference<Preference>(AppSettings.KEY_PAGES_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.PAGES]))
findPreference<Preference>(AppSettings.KEY_THUMBS_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.THUMBS]))
findPreference<Preference>(AppSettings.KEY_HTTP_CACHE_CLEAR)?.bindBytesSizeSummary(viewModel.httpCacheSize)
bindPeriodicalBackupSummary()
findPreference<Preference>(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<Preference>(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)

View File

@@ -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<ReversibleAction>()
@@ -40,6 +44,20 @@ class UserDataSettingsViewModel @Inject constructor(
val cacheSizes = EnumMap<CacheDir, MutableStateFlow<Long>>(CacheDir::class.java)
val storageUsage = MutableStateFlow<StorageUsage?>(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 {

View File

@@ -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)
}
}

View File

@@ -1,51 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string-array name="themes">
<string-array name="themes" translatable="false">
<item>@string/automatic</item>
<item>@string/light</item>
<item>@string/dark</item>
</string-array>
<string-array name="reader_switchers">
<string-array name="reader_switchers" translatable="false">
<item>@string/taps_on_edges</item>
<item>@string/volume_buttons</item>
</string-array>
<string-array name="zoom_modes">
<string-array name="zoom_modes" translatable="false">
<item>@string/zoom_mode_fit_center</item>
<item>@string/zoom_mode_fit_height</item>
<item>@string/zoom_mode_fit_width</item>
<item>@string/zoom_mode_keep_start</item>
</string-array>
<string-array name="track_sources">
<string-array name="track_sources" translatable="false">
<item>@string/favourites</item>
<item>@string/history</item>
</string-array>
<string-array name="list_modes">
<string-array name="list_modes" translatable="false">
<item>@string/list</item>
<item>@string/detailed_list</item>
<item>@string/grid</item>
</string-array>
<string-array name="screenshots_policy">
<string-array name="screenshots_policy" translatable="false">
<item>@string/screenshots_allow</item>
<item>@string/screenshots_block_nsfw</item>
<item>@string/screenshots_block_all</item>
</string-array>
<string-array name="network_policy">
<string-array name="network_policy" translatable="false">
<item>@string/always</item>
<item>@string/only_using_wifi</item>
<item>@string/never</item>
</string-array>
<string-array name="doh_providers">
<string-array name="doh_providers" translatable="false">
<item>@string/disabled</item>
<item>Google</item>
<item>CloudFlare</item>
<item>AdGuard</item>
</string-array>
<string-array name="reader_modes">
<string-array name="reader_modes" translatable="false">
<item>@string/standard</item>
<item>@string/right_to_left</item>
<item>@string/webtoon</item>
</string-array>
<string-array name="scrobbling_statuses">
<string-array name="scrobbling_statuses" translatable="false">
<item>@string/status_planned</item>
<item>@string/status_reading</item>
<item>@string/status_re_reading</item>
@@ -53,25 +53,32 @@
<item>@string/status_on_hold</item>
<item>@string/status_dropped</item>
</string-array>
<string-array name="proxy_types">
<string-array name="proxy_types" translatable="false">
<item>@string/disabled</item>
<item>HTTP</item>
<item>SOCKS (v4/v5)</item>
</string-array>
<string-array name="reader_backgrounds">
<string-array name="reader_backgrounds" translatable="false">
<item>@string/system_default</item>
<item>@string/color_light</item>
<item>@string/color_dark</item>
<item>@string/color_white</item>
<item>@string/color_black</item>
</string-array>
<string-array name="reader_animation">
<string-array name="reader_animation" translatable="false">
<item>@string/disabled</item>
<item>@string/system_default</item>
<item>@string/advanced</item>
</string-array>
<string-array name="first_nav_item">
<string-array name="first_nav_item" translatable="false">
<item>@string/history</item>
<item>@string/favourites</item>
</string-array>
<string-array name="backup_frequency" translatable="false">
<item>@string/frequency_every_day</item>
<item>@string/frequency_every_2_days</item>
<item>@string/frequency_once_per_week</item>
<item>@string/frequency_twice_per_month</item>
<item>@string/frequency_once_per_month</item>
</string-array>
</resources>

View File

@@ -64,4 +64,11 @@
<item>0</item>
<item>1</item>
</string-array>
<string-array name="values_backup_frequency" translatable="false">
<item>1</item>
<item>2</item>
<item>7</item>
<item>14</item>
<item>30</item>
</string-array>
</resources>

View File

@@ -500,4 +500,13 @@
<string name="by_relevance">Relevance</string>
<string name="categories">Categories</string>
<string name="online_variant">Online variant</string>
<string name="periodic_backups">Periodic backups</string>
<string name="backup_frequency">Backup creation frequency</string>
<string name="frequency_every_day">Every day</string>
<string name="frequency_every_2_days">Every 2 days</string>
<string name="frequency_once_per_week">Once per week</string>
<string name="frequency_twice_per_month">Twice per month</string>
<string name="frequency_once_per_month">Once per month</string>
<string name="periodic_backups_enable">Enable periodic backups</string>
<string name="backups_output_directory">Backups output directory</string>
</resources>

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="backup_periodic"
android:layout="@layout/preference_toggle_header"
android:title="@string/periodic_backups_enable" />
<ListPreference
android:defaultValue="7"
android:dependency="backup_periodic"
android:entries="@array/backup_frequency"
android:entryValues="@array/values_backup_frequency"
android:key="backup_periodic_freq"
android:title="@string/backup_frequency"
app:useSimpleSummaryProvider="true" />
<Preference
android:dependency="backup_periodic"
android:key="backup_periodic_output"
android:title="@string/backups_output_directory" />
</androidx.preference.PreferenceScreen>

View File

@@ -34,6 +34,12 @@
android:summary="@string/restore_summary"
android:title="@string/restore_backup" />
<Preference
android:fragment="org.koitharu.kotatsu.settings.backup.PeriodicalBackupSettingsFragment"
android:key="backup_periodic"
android:persistent="false"
android:title="@string/periodic_backups" />
</PreferenceCategory>
<PreferenceCategory android:title="@string/storage_usage">