diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/SourceSettings.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/SourceSettings.kt index 7a0180859..fde9b4455 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/SourceSettings.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/SourceSettings.kt @@ -13,10 +13,14 @@ import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty import org.koitharu.kotatsu.parsers.util.nullIfEmpty import org.koitharu.kotatsu.settings.utils.validation.DomainValidator +import java.io.File class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig { - private val prefs = context.getSharedPreferences(source.name, Context.MODE_PRIVATE) + private val prefs = context.getSharedPreferences( + source.name.replace(File.separatorChar, '$'), + Context.MODE_PRIVATE, + ) var defaultSortOrder: SortOrder? get() = prefs.getEnumValue(KEY_SORT_ORDER, SortOrder::class.java) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/data/SavedFiltersRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/data/SavedFiltersRepository.kt index a1cf9fafb..87971f7e0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/data/SavedFiltersRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/data/SavedFiltersRepository.kt @@ -1,6 +1,7 @@ package org.koitharu.kotatsu.filter.data import android.content.Context +import android.content.SharedPreferences import androidx.core.content.edit import dagger.Reusable import dagger.hilt.android.qualifiers.ApplicationContext @@ -17,6 +18,7 @@ import org.koitharu.kotatsu.core.util.ext.observeChanges import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaSource +import java.io.File import javax.inject.Inject @Reusable @@ -96,7 +98,10 @@ class SavedFiltersRepository @Inject constructor( } } - private fun getPrefs(source: MangaSource) = context.getSharedPreferences(source.name, Context.MODE_PRIVATE) + private fun getPrefs(source: MangaSource): SharedPreferences { + val key = source.name.replace(File.separatorChar, '$') + return context.getSharedPreferences(key, Context.MODE_PRIVATE) + } private companion object { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/AppearanceSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/AppearanceSettingsFragment.kt index 9a0b7b6fe..c60906fee 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/AppearanceSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/AppearanceSettingsFragment.kt @@ -11,11 +11,16 @@ import androidx.appcompat.app.AppCompatDelegate import androidx.preference.ListPreference import androidx.preference.MultiSelectListPreference import androidx.preference.Preference +import androidx.preference.TwoStatePreference import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.os.AppShortcutManager import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.ProgressIndicatorMode +import org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy +import org.koitharu.kotatsu.core.prefs.SearchSuggestionType +import org.koitharu.kotatsu.core.prefs.TriStateOption import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle import org.koitharu.kotatsu.core.util.LocaleComparator @@ -24,8 +29,10 @@ import org.koitharu.kotatsu.core.util.ext.postDelayed import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat import org.koitharu.kotatsu.core.util.ext.sortedWithSafe import org.koitharu.kotatsu.core.util.ext.toList +import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.names import org.koitharu.kotatsu.parsers.util.toTitleCase +import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity import org.koitharu.kotatsu.settings.utils.ActivityListPreference import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider import org.koitharu.kotatsu.settings.utils.PercentSummaryProvider @@ -34,106 +41,145 @@ import javax.inject.Inject @AndroidEntryPoint class AppearanceSettingsFragment : - BasePreferenceFragment(R.string.appearance), - SharedPreferences.OnSharedPreferenceChangeListener { - - @Inject - lateinit var activityRecreationHandle: ActivityRecreationHandle - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.pref_appearance) - findPreference(AppSettings.KEY_GRID_SIZE)?.summaryProvider = PercentSummaryProvider() - findPreference(AppSettings.KEY_LIST_MODE)?.run { - entryValues = ListMode.entries.names() - setDefaultValueCompat(ListMode.GRID.name) - } - findPreference(AppSettings.KEY_PROGRESS_INDICATORS)?.run { - entryValues = ProgressIndicatorMode.entries.names() - setDefaultValueCompat(ProgressIndicatorMode.PERCENT_READ.name) - } - findPreference(AppSettings.KEY_APP_LOCALE)?.run { - initLocalePicker(this) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - activityIntent = Intent( - Settings.ACTION_APP_LOCALE_SETTINGS, - Uri.fromParts("package", context.packageName, null), - ) - } - summaryProvider = Preference.SummaryProvider { - val locale = AppCompatDelegate.getApplicationLocales().get(0) - locale?.getDisplayName(locale)?.toTitleCase(locale) ?: getString(R.string.follow_system) - } - setDefaultValueCompat("") - } - findPreference(AppSettings.KEY_MANGA_LIST_BADGES)?.run { - summaryProvider = MultiSummaryProvider(R.string.none) - } - bindNavSummary() - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - settings.subscribe(this) - } - - override fun onDestroyView() { - settings.unsubscribe(this) - super.onDestroyView() - } - - override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) { - when (key) { - AppSettings.KEY_THEME -> { - AppCompatDelegate.setDefaultNightMode(settings.theme) - } - - AppSettings.KEY_COLOR_THEME, - AppSettings.KEY_THEME_AMOLED, - -> { - postRestart() - } - - AppSettings.KEY_APP_LOCALE -> { - AppCompatDelegate.setApplicationLocales(settings.appLocales) - } - - AppSettings.KEY_NAV_MAIN -> { - bindNavSummary() - } - } - } - - private fun postRestart() { - viewLifecycleOwner.lifecycle.postDelayed(400) { - activityRecreationHandle.recreateAll() - } - } - - private fun initLocalePicker(preference: ListPreference) { - val locales = preference.context.getLocalesConfig() - .toList() - .sortedWithSafe(LocaleComparator()) - preference.entries = Array(locales.size + 1) { i -> - if (i == 0) { - getString(R.string.follow_system) - } else { - val lc = locales[i - 1] - lc.getDisplayName(lc).toTitleCase(lc) - } - } - preference.entryValues = Array(locales.size + 1) { i -> - if (i == 0) { - "" - } else { - locales[i - 1].toLanguageTag() - } - } - } - - private fun bindNavSummary() { - val pref = findPreference(AppSettings.KEY_NAV_MAIN) ?: return - pref.summary = settings.mainNavItems.joinToString { - getString(it.title) - } - } + BasePreferenceFragment(R.string.appearance), + SharedPreferences.OnSharedPreferenceChangeListener { + + @Inject + lateinit var activityRecreationHandle: ActivityRecreationHandle + + @Inject + lateinit var appShortcutManager: AppShortcutManager + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.pref_appearance) + findPreference(AppSettings.KEY_GRID_SIZE)?.summaryProvider = PercentSummaryProvider() + findPreference(AppSettings.KEY_LIST_MODE)?.run { + entryValues = ListMode.entries.names() + setDefaultValueCompat(ListMode.GRID.name) + } + findPreference(AppSettings.KEY_PROGRESS_INDICATORS)?.run { + entryValues = ProgressIndicatorMode.entries.names() + setDefaultValueCompat(ProgressIndicatorMode.PERCENT_READ.name) + } + findPreference(AppSettings.KEY_APP_LOCALE)?.run { + initLocalePicker(this) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + activityIntent = Intent( + Settings.ACTION_APP_LOCALE_SETTINGS, + Uri.fromParts("package", context.packageName, null), + ) + } + summaryProvider = Preference.SummaryProvider { + val locale = AppCompatDelegate.getApplicationLocales().get(0) + locale?.getDisplayName(locale)?.toTitleCase(locale) ?: getString(R.string.follow_system) + } + setDefaultValueCompat("") + } + findPreference(AppSettings.KEY_MANGA_LIST_BADGES)?.run { + summaryProvider = MultiSummaryProvider(R.string.none) + } + findPreference(AppSettings.KEY_SHORTCUTS)?.isVisible = + appShortcutManager.isDynamicShortcutsAvailable() + findPreference(AppSettings.KEY_PROTECT_APP) + ?.isChecked = !settings.appPassword.isNullOrEmpty() + findPreference(AppSettings.KEY_SCREENSHOTS_POLICY)?.run { + entryValues = ScreenshotsPolicy.entries.names() + setDefaultValueCompat(ScreenshotsPolicy.ALLOW.name) + } + findPreference(AppSettings.KEY_SEARCH_SUGGESTION_TYPES)?.let { pref -> + pref.entryValues = SearchSuggestionType.entries.names() + pref.entries = SearchSuggestionType.entries.map { pref.context.getString(it.titleResId) }.toTypedArray() + pref.summaryProvider = MultiSummaryProvider(R.string.none) + pref.values = settings.searchSuggestionTypes.mapToSet { it.name } + } + bindNavSummary() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + settings.subscribe(this) + } + + override fun onDestroyView() { + settings.unsubscribe(this) + super.onDestroyView() + } + + override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) { + when (key) { + AppSettings.KEY_THEME -> { + AppCompatDelegate.setDefaultNightMode(settings.theme) + } + + AppSettings.KEY_COLOR_THEME, + AppSettings.KEY_THEME_AMOLED, + -> { + postRestart() + } + + AppSettings.KEY_APP_LOCALE -> { + AppCompatDelegate.setApplicationLocales(settings.appLocales) + } + + AppSettings.KEY_NAV_MAIN -> { + bindNavSummary() + } + + AppSettings.KEY_APP_PASSWORD -> { + findPreference(AppSettings.KEY_PROTECT_APP) + ?.isChecked = !settings.appPassword.isNullOrEmpty() + } + } + } + + override fun onPreferenceTreeClick(preference: Preference): Boolean { + return when (preference.key) { + AppSettings.KEY_PROTECT_APP -> { + val pref = (preference as? TwoStatePreference ?: return false) + if (pref.isChecked) { + pref.isChecked = false + startActivity(Intent(preference.context, ProtectSetupActivity::class.java)) + } else { + settings.appPassword = null + } + true + } + + else -> super.onPreferenceTreeClick(preference) + } + } + + private fun postRestart() { + viewLifecycleOwner.lifecycle.postDelayed(400) { + activityRecreationHandle.recreateAll() + } + } + + private fun initLocalePicker(preference: ListPreference) { + val locales = preference.context.getLocalesConfig() + .toList() + .sortedWithSafe(LocaleComparator()) + preference.entries = Array(locales.size + 1) { i -> + if (i == 0) { + getString(R.string.follow_system) + } else { + val lc = locales[i - 1] + lc.getDisplayName(lc).toTitleCase(lc) + } + } + preference.entryValues = Array(locales.size + 1) { i -> + if (i == 0) { + "" + } else { + locales[i - 1].toLanguageTag() + } + } + } + + private fun bindNavSummary() { + val pref = findPreference(AppSettings.KEY_NAV_MAIN) ?: return + pref.summary = settings.mainNavItems.joinToString { + getString(it.title) + } + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/NetworkSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/NetworkSettingsFragment.kt deleted file mode 100644 index cdb349ca9..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/NetworkSettingsFragment.kt +++ /dev/null @@ -1,66 +0,0 @@ -package org.koitharu.kotatsu.settings - -import android.content.SharedPreferences -import android.os.Bundle -import android.view.View -import androidx.preference.ListPreference -import androidx.preference.Preference -import com.google.android.material.snackbar.Snackbar -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.network.DoHProvider -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.ui.BasePreferenceFragment -import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat -import org.koitharu.kotatsu.parsers.util.names -import java.net.Proxy - -class NetworkSettingsFragment : - BasePreferenceFragment(R.string.network), - SharedPreferences.OnSharedPreferenceChangeListener { - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.pref_network) - findPreference(AppSettings.KEY_DOH)?.run { - entryValues = DoHProvider.entries.names() - setDefaultValueCompat(DoHProvider.NONE.name) - } - bindProxySummary() - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - settings.subscribe(this) - } - - override fun onDestroyView() { - settings.unsubscribe(this) - super.onDestroyView() - } - - override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) { - when (key) { - AppSettings.KEY_SSL_BYPASS -> { - Snackbar.make(listView, R.string.settings_apply_restart_required, Snackbar.LENGTH_INDEFINITE).show() - } - - AppSettings.KEY_PROXY_TYPE, - AppSettings.KEY_PROXY_ADDRESS, - AppSettings.KEY_PROXY_PORT -> { - bindProxySummary() - } - } - } - - private fun bindProxySummary() { - findPreference(AppSettings.KEY_PROXY)?.run { - val type = settings.proxyType - val address = settings.proxyAddress - val port = settings.proxyPort - summary = when { - type == Proxy.Type.DIRECT -> context.getString(R.string.disabled) - address.isNullOrEmpty() || port == 0 -> context.getString(R.string.invalid_proxy_configuration) - else -> "$address:$port" - } - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/RootSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/RootSettingsFragment.kt index fcc95134e..b884d9638 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/RootSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/RootSettingsFragment.kt @@ -28,8 +28,8 @@ class RootSettingsFragment : BasePreferenceFragment(0) { addPreferencesFromResource(R.xml.pref_root_debug) bindPreferenceSummary("appearance", R.string.theme, R.string.list_mode, R.string.language) bindPreferenceSummary("reader", R.string.read_mode, R.string.scale_mode, R.string.switch_pages) - bindPreferenceSummary("network", R.string.proxy, R.string.dns_over_https, R.string.prefetch_content) - bindPreferenceSummary("userdata", R.string.protect_application, R.string.backup_restore, R.string.data_deletion) + bindPreferenceSummary("network", R.string.storage_usage, R.string.proxy, R.string.prefetch_content) + bindPreferenceSummary("userdata", R.string.create_or_restore_backup, R.string.periodic_backups) bindPreferenceSummary("downloads", R.string.manga_save_location, R.string.downloads_wifi_only) bindPreferenceSummary("tracker", R.string.track_sources, R.string.notifications_settings) bindPreferenceSummary("services", R.string.suggestions, R.string.sync, R.string.tracking) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt index befd2b6dc..6d87030cf 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt @@ -39,7 +39,7 @@ import org.koitharu.kotatsu.settings.sources.SourceSettingsFragment import org.koitharu.kotatsu.settings.sources.SourcesSettingsFragment import org.koitharu.kotatsu.settings.sources.manage.SourcesManageFragment import org.koitharu.kotatsu.settings.tracker.TrackerSettingsFragment -import org.koitharu.kotatsu.settings.userdata.UserDataSettingsFragment +import org.koitharu.kotatsu.settings.userdata.BackupsSettingsFragment @AndroidEntryPoint class SettingsActivity : @@ -146,7 +146,7 @@ class SettingsActivity : val fragment = when (intent?.action) { AppRouter.ACTION_READER -> ReaderSettingsFragment() AppRouter.ACTION_SUGGESTIONS -> SuggestionsSettingsFragment() - AppRouter.ACTION_HISTORY -> UserDataSettingsFragment() + AppRouter.ACTION_HISTORY -> BackupsSettingsFragment() AppRouter.ACTION_TRACKER -> TrackerSettingsFragment() AppRouter.ACTION_PERIODIC_BACKUP -> PeriodicalBackupSettingsFragment() AppRouter.ACTION_SOURCES -> SourcesSettingsFragment() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/StorageAndNetworkSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/StorageAndNetworkSettingsFragment.kt new file mode 100644 index 000000000..1c835e64a --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/StorageAndNetworkSettingsFragment.kt @@ -0,0 +1,77 @@ +package org.koitharu.kotatsu.settings + +import android.content.SharedPreferences +import android.os.Bundle +import android.view.View +import androidx.fragment.app.viewModels +import androidx.preference.ListPreference +import androidx.preference.Preference +import com.google.android.material.snackbar.Snackbar +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver +import org.koitharu.kotatsu.core.network.DoHProvider +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.ui.BasePreferenceFragment +import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.observeEvent +import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat +import org.koitharu.kotatsu.parsers.util.names +import org.koitharu.kotatsu.settings.userdata.storage.StorageUsagePreference +import java.net.Proxy + +class StorageAndNetworkSettingsFragment : + BasePreferenceFragment(R.string.storage_and_network), + SharedPreferences.OnSharedPreferenceChangeListener { + + private val viewModel by viewModels() + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.pref_network_storage) + findPreference(AppSettings.KEY_DOH)?.run { + entryValues = DoHProvider.entries.names() + setDefaultValueCompat(DoHProvider.NONE.name) + } + bindProxySummary() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this)) + settings.subscribe(this) + findPreference(AppSettings.KEY_STORAGE_USAGE)?.let { pref -> + viewModel.storageUsage.observe(viewLifecycleOwner, pref) + } + } + + override fun onDestroyView() { + settings.unsubscribe(this) + super.onDestroyView() + } + + override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) { + when (key) { + AppSettings.KEY_SSL_BYPASS -> { + Snackbar.make(listView, R.string.settings_apply_restart_required, Snackbar.LENGTH_INDEFINITE).show() + } + + AppSettings.KEY_PROXY_TYPE, + AppSettings.KEY_PROXY_ADDRESS, + AppSettings.KEY_PROXY_PORT -> { + bindProxySummary() + } + } + } + + private fun bindProxySummary() { + findPreference(AppSettings.KEY_PROXY)?.run { + val type = settings.proxyType + val address = settings.proxyAddress + val port = settings.proxyPort + summary = when { + type == Proxy.Type.DIRECT -> context.getString(R.string.disabled) + address.isNullOrEmpty() || port == 0 -> context.getString(R.string.invalid_proxy_configuration) + else -> "$address:$port" + } + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/StorageAndNetworkSettingsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/StorageAndNetworkSettingsViewModel.kt new file mode 100644 index 000000000..17bc80ffd --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/StorageAndNetworkSettingsViewModel.kt @@ -0,0 +1,52 @@ +package org.koitharu.kotatsu.settings + +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus +import org.koitharu.kotatsu.core.ui.BaseViewModel +import org.koitharu.kotatsu.local.data.CacheDir +import org.koitharu.kotatsu.local.data.LocalStorageManager +import org.koitharu.kotatsu.settings.userdata.storage.StorageUsage +import javax.inject.Inject + +@HiltViewModel +class StorageAndNetworkSettingsViewModel @Inject constructor( + private val storageManager: LocalStorageManager, +) : BaseViewModel() { + + val storageUsage: StateFlow = flow { + emit(loadStorageUsage()) + }.withErrorHandling() + .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.WhileSubscribed(1000), null) + + private suspend fun loadStorageUsage(): StorageUsage { + val pagesCacheSize = storageManager.computeCacheSize(CacheDir.PAGES) + val otherCacheSize = storageManager.computeCacheSize() - pagesCacheSize + val storageSize = storageManager.computeStorageSize() + val availableSpace = storageManager.computeAvailableSize() + val totalBytes = pagesCacheSize + otherCacheSize + storageSize + availableSpace + return StorageUsage( + savedManga = StorageUsage.Item( + bytes = storageSize, + percent = (storageSize.toDouble() / totalBytes).toFloat(), + ), + pagesCache = StorageUsage.Item( + bytes = pagesCacheSize, + percent = (pagesCacheSize.toDouble() / totalBytes).toFloat(), + ), + otherCache = StorageUsage.Item( + bytes = otherCacheSize, + percent = (otherCacheSize.toDouble() / totalBytes).toFloat(), + ), + available = StorageUsage.Item( + bytes = availableSpace, + percent = (availableSpace.toDouble() / totalBytes).toFloat(), + ), + ) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsItem.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsItem.kt index f10c342aa..e47a0c78f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsItem.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsItem.kt @@ -11,6 +11,6 @@ data class SettingsItem( ) : ListModel { override fun areItemsTheSame(other: ListModel): Boolean { - return other is SettingsItem && other.key == key + return other is SettingsItem && other.key == key && other.fragmentClass == fragmentClass } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsSearchHelper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsSearchHelper.kt index decb2f3f2..bf952fddf 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsSearchHelper.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsSearchHelper.kt @@ -13,106 +13,118 @@ import org.koitharu.kotatsu.backups.ui.periodical.PeriodicalBackupSettingsFragme import org.koitharu.kotatsu.core.LocalizedAppContext import org.koitharu.kotatsu.settings.AppearanceSettingsFragment import org.koitharu.kotatsu.settings.DownloadsSettingsFragment -import org.koitharu.kotatsu.settings.NetworkSettingsFragment import org.koitharu.kotatsu.settings.ProxySettingsFragment import org.koitharu.kotatsu.settings.ReaderSettingsFragment import org.koitharu.kotatsu.settings.ServicesSettingsFragment +import org.koitharu.kotatsu.settings.StorageAndNetworkSettingsFragment import org.koitharu.kotatsu.settings.SuggestionsSettingsFragment import org.koitharu.kotatsu.settings.about.AboutSettingsFragment +import org.koitharu.kotatsu.settings.discord.DiscordSettingsFragment import org.koitharu.kotatsu.settings.sources.SourcesSettingsFragment import org.koitharu.kotatsu.settings.tracker.TrackerSettingsFragment -import org.koitharu.kotatsu.settings.userdata.UserDataSettingsFragment -import org.koitharu.kotatsu.settings.userdata.storage.StorageManageSettingsFragment +import org.koitharu.kotatsu.settings.userdata.BackupsSettingsFragment +import org.koitharu.kotatsu.settings.userdata.storage.DataCleanupSettingsFragment import javax.inject.Inject @Reusable @SuppressLint("RestrictedApi") class SettingsSearchHelper @Inject constructor( - @LocalizedAppContext private val context: Context, + @LocalizedAppContext private val context: Context, ) { - fun inflatePreferences(): List { - val preferenceManager = PreferenceManager(context) - val result = ArrayList() - preferenceManager.inflateTo(result, R.xml.pref_appearance, emptyList(), AppearanceSettingsFragment::class.java) - preferenceManager.inflateTo(result, R.xml.pref_sources, emptyList(), SourcesSettingsFragment::class.java) - preferenceManager.inflateTo(result, R.xml.pref_reader, emptyList(), ReaderSettingsFragment::class.java) - preferenceManager.inflateTo(result, R.xml.pref_network, emptyList(), NetworkSettingsFragment::class.java) - preferenceManager.inflateTo(result, R.xml.pref_user_data, emptyList(), UserDataSettingsFragment::class.java) - preferenceManager.inflateTo( - result, - R.xml.pref_storage, - listOf(context.getString(R.string.data_and_privacy)), - StorageManageSettingsFragment::class.java, - ) - preferenceManager.inflateTo(result, R.xml.pref_downloads, emptyList(), DownloadsSettingsFragment::class.java) - preferenceManager.inflateTo(result, R.xml.pref_tracker, emptyList(), TrackerSettingsFragment::class.java) - preferenceManager.inflateTo(result, R.xml.pref_services, emptyList(), ServicesSettingsFragment::class.java) - preferenceManager.inflateTo(result, R.xml.pref_about, emptyList(), AboutSettingsFragment::class.java) - preferenceManager.inflateTo( - result, - R.xml.pref_backup_periodic, - listOf(context.getString(R.string.data_and_privacy)), - PeriodicalBackupSettingsFragment::class.java, - ) - preferenceManager.inflateTo( - result, - R.xml.pref_proxy, - listOf(context.getString(R.string.proxy)), - ProxySettingsFragment::class.java, - ) - preferenceManager.inflateTo( - result, - R.xml.pref_suggestions, - listOf(context.getString(R.string.suggestions)), - SuggestionsSettingsFragment::class.java, - ) - preferenceManager.inflateTo( - result, - R.xml.pref_sources, - listOf(context.getString(R.string.remote_sources)), - SourcesSettingsFragment::class.java, - ) - return result - } + fun inflatePreferences(): List { + val preferenceManager = PreferenceManager(context) + val result = ArrayList() + preferenceManager.inflateTo(result, R.xml.pref_appearance, emptyList(), AppearanceSettingsFragment::class.java) + preferenceManager.inflateTo(result, R.xml.pref_sources, emptyList(), SourcesSettingsFragment::class.java) + preferenceManager.inflateTo(result, R.xml.pref_reader, emptyList(), ReaderSettingsFragment::class.java) + preferenceManager.inflateTo( + result, + R.xml.pref_network_storage, + emptyList(), + StorageAndNetworkSettingsFragment::class.java, + ) + preferenceManager.inflateTo(result, R.xml.pref_backups, emptyList(), BackupsSettingsFragment::class.java) + preferenceManager.inflateTo( + result, + R.xml.pref_data_cleanup, + listOf(context.getString(R.string.storage_and_network)), + DataCleanupSettingsFragment::class.java, + ) + preferenceManager.inflateTo(result, R.xml.pref_downloads, emptyList(), DownloadsSettingsFragment::class.java) + preferenceManager.inflateTo(result, R.xml.pref_tracker, emptyList(), TrackerSettingsFragment::class.java) + preferenceManager.inflateTo(result, R.xml.pref_services, emptyList(), ServicesSettingsFragment::class.java) + preferenceManager.inflateTo(result, R.xml.pref_about, emptyList(), AboutSettingsFragment::class.java) + preferenceManager.inflateTo( + result, + R.xml.pref_backup_periodic, + listOf(context.getString(R.string.backup_restore)), + PeriodicalBackupSettingsFragment::class.java, + ) + preferenceManager.inflateTo( + result, + R.xml.pref_proxy, + listOf(context.getString(R.string.storage_and_network)), + ProxySettingsFragment::class.java, + ) + preferenceManager.inflateTo( + result, + R.xml.pref_suggestions, + listOf(context.getString(R.string.services)), + SuggestionsSettingsFragment::class.java, + ) + preferenceManager.inflateTo( + result, + R.xml.pref_discord, + listOf(context.getString(R.string.services)), + DiscordSettingsFragment::class.java, + ) + preferenceManager.inflateTo( + result, + R.xml.pref_sources, + listOf(), + SourcesSettingsFragment::class.java, + ) + return result + } - private fun PreferenceManager.inflateTo( - result: MutableList, - @XmlRes resId: Int, - breadcrumbs: List, - fragmentClass: Class - ) { - val screen = inflateFromResource(context, resId, null) - val screenTitle = screen.title?.toString() - screen.inflateTo( - result = result, - breadcrumbs = if (screenTitle.isNullOrEmpty()) breadcrumbs else breadcrumbs + screenTitle, - fragmentClass = fragmentClass, - ) - } + private fun PreferenceManager.inflateTo( + result: MutableList, + @XmlRes resId: Int, + breadcrumbs: List, + fragmentClass: Class + ) { + val screen = inflateFromResource(context, resId, null) + val screenTitle = screen.title?.toString() + screen.inflateTo( + result = result, + breadcrumbs = if (screenTitle.isNullOrEmpty()) breadcrumbs else breadcrumbs + screenTitle, + fragmentClass = fragmentClass, + ) + } - private fun PreferenceScreen.inflateTo( - result: MutableList, - breadcrumbs: List, - fragmentClass: Class - ): Unit = repeat(preferenceCount) { i -> - val pref = this[i] - if (pref is PreferenceScreen) { - val screenTitle = pref.title?.toString() - pref.inflateTo( - result = result, - breadcrumbs = if (screenTitle.isNullOrEmpty()) breadcrumbs else breadcrumbs + screenTitle, - fragmentClass = fragmentClass, - ) - } else { - result.add( - SettingsItem( - key = pref.key ?: return@repeat, - title = pref.title ?: return@repeat, - breadcrumbs = breadcrumbs, - fragmentClass = fragmentClass, - ), - ) - } - } + private fun PreferenceScreen.inflateTo( + result: MutableList, + breadcrumbs: List, + fragmentClass: Class + ): Unit = repeat(preferenceCount) { i -> + val pref = this[i] + if (pref is PreferenceScreen) { + val screenTitle = pref.title?.toString() + pref.inflateTo( + result = result, + breadcrumbs = if (screenTitle.isNullOrEmpty()) breadcrumbs else breadcrumbs + screenTitle, + fragmentClass = fragmentClass, + ) + } else { + result.add( + SettingsItem( + key = pref.key ?: return@repeat, + title = pref.title ?: return@repeat, + breadcrumbs = breadcrumbs, + fragmentClass = fragmentClass, + ), + ) + } + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesSettingsFragment.kt index f59ca7015..1f842703c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesSettingsFragment.kt @@ -11,6 +11,7 @@ import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.prefs.TriStateOption import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe import org.koitharu.kotatsu.core.util.ext.observe @@ -31,6 +32,10 @@ class SourcesSettingsFragment : BasePreferenceFragment(R.string.remote_sources), entries = SourcesSortOrder.entries.map { context.getString(it.titleResId) }.toTypedArray() setDefaultValueCompat(SourcesSortOrder.MANUAL.name) } + findPreference(AppSettings.KEY_INCOGNITO_NSFW)?.run { + entryValues = TriStateOption.entries.names() + setDefaultValueCompat(TriStateOption.ASK.name) + } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/BackupsSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/BackupsSettingsFragment.kt new file mode 100644 index 000000000..53ddd4e08 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/BackupsSettingsFragment.kt @@ -0,0 +1,99 @@ +package org.koitharu.kotatsu.settings.userdata + +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.fragment.app.viewModels +import androidx.preference.Preference +import com.google.android.material.snackbar.Snackbar +import dagger.hilt.android.AndroidEntryPoint +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.backups.domain.BackupUtils +import org.koitharu.kotatsu.backups.ui.backup.BackupService +import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver +import org.koitharu.kotatsu.core.nav.router +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.ui.BasePreferenceFragment +import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.observeEvent +import org.koitharu.kotatsu.core.util.ext.tryLaunch + +@AndroidEntryPoint +class BackupsSettingsFragment : BasePreferenceFragment(R.string.backup_restore), + ActivityResultCallback { + + private val viewModel: BackupsSettingsViewModel by viewModels() + + private val backupSelectCall = registerForActivityResult( + ActivityResultContracts.OpenDocument(), + this, + ) + + private val backupCreateCall = registerForActivityResult( + ActivityResultContracts.CreateDocument("application/zip"), + ) { uri -> + if (uri != null) { + if (!BackupService.start(requireContext(), uri)) { + Snackbar.make( + listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT, + ).show() + } + } + } + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.pref_backups) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + bindPeriodicalBackupSummary() + viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this)) + } + + override fun onPreferenceTreeClick(preference: Preference): Boolean { + return when (preference.key) { + AppSettings.KEY_BACKUP -> { + if (!backupCreateCall.tryLaunch(BackupUtils.generateFileName(preference.context))) { + Snackbar.make( + listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT, + ).show() + } + true + } + + AppSettings.KEY_RESTORE -> { + if (!backupSelectCall.tryLaunch(arrayOf("*/*"))) { + Snackbar.make( + listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT, + ).show() + } + true + } + + else -> super.onPreferenceTreeClick(preference) + } + } + + override fun onActivityResult(result: Uri?) { + if (result != null) { + router.showBackupRestoreDialog(result) + } + } + + 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) + } + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/BackupsSettingsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/BackupsSettingsViewModel.kt new file mode 100644 index 000000000..77c40d705 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/BackupsSettingsViewModel.kt @@ -0,0 +1,29 @@ +package org.koitharu.kotatsu.settings.userdata + +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.prefs.observeAsFlow +import org.koitharu.kotatsu.core.ui.BaseViewModel +import javax.inject.Inject + +@HiltViewModel +class BackupsSettingsViewModel @Inject constructor( + private val settings: AppSettings, +) : BaseViewModel() { + + 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) + } + } +} 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 deleted file mode 100644 index 6af9d9eaf..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/UserDataSettingsFragment.kt +++ /dev/null @@ -1,172 +0,0 @@ -package org.koitharu.kotatsu.settings.userdata - -import android.content.Intent -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.fragment.app.viewModels -import androidx.preference.ListPreference -import androidx.preference.MultiSelectListPreference -import androidx.preference.Preference -import androidx.preference.TwoStatePreference -import com.google.android.material.snackbar.Snackbar -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.backups.domain.BackupUtils -import org.koitharu.kotatsu.backups.ui.backup.BackupService -import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver -import org.koitharu.kotatsu.core.nav.router -import org.koitharu.kotatsu.core.os.AppShortcutManager -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy -import org.koitharu.kotatsu.core.prefs.SearchSuggestionType -import org.koitharu.kotatsu.core.prefs.TriStateOption -import org.koitharu.kotatsu.core.ui.BasePreferenceFragment -import org.koitharu.kotatsu.core.util.FileSize -import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.core.util.ext.observeEvent -import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat -import org.koitharu.kotatsu.core.util.ext.tryLaunch -import org.koitharu.kotatsu.parsers.util.mapToSet -import org.koitharu.kotatsu.parsers.util.names -import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity -import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider -import javax.inject.Inject - -@AndroidEntryPoint -class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privacy), - SharedPreferences.OnSharedPreferenceChangeListener, - ActivityResultCallback { - - @Inject - lateinit var appShortcutManager: AppShortcutManager - - private val viewModel: UserDataSettingsViewModel by viewModels() - - private val backupSelectCall = registerForActivityResult( - ActivityResultContracts.OpenDocument(), - this, - ) - - private val backupCreateCall = registerForActivityResult( - ActivityResultContracts.CreateDocument("application/zip"), - ) { uri -> - if (uri != null) { - if (!BackupService.start(requireContext(), uri)) { - Snackbar.make( - listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT, - ).show() - } - } - } - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.pref_user_data) - findPreference(AppSettings.KEY_SHORTCUTS)?.isVisible = - appShortcutManager.isDynamicShortcutsAvailable() - findPreference(AppSettings.KEY_PROTECT_APP) - ?.isChecked = !settings.appPassword.isNullOrEmpty() - findPreference(AppSettings.KEY_SCREENSHOTS_POLICY)?.run { - entryValues = ScreenshotsPolicy.entries.names() - setDefaultValueCompat(ScreenshotsPolicy.ALLOW.name) - } - findPreference(AppSettings.KEY_INCOGNITO_NSFW)?.run { - entryValues = TriStateOption.entries.names() - setDefaultValueCompat(TriStateOption.ASK.name) - } - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - bindPeriodicalBackupSummary() - findPreference(AppSettings.KEY_SEARCH_SUGGESTION_TYPES)?.let { pref -> - pref.entryValues = SearchSuggestionType.entries.names() - pref.entries = SearchSuggestionType.entries.map { pref.context.getString(it.titleResId) }.toTypedArray() - pref.summaryProvider = MultiSummaryProvider(R.string.none) - pref.values = settings.searchSuggestionTypes.mapToSet { it.name } - } - findPreference(AppSettings.KEY_STORAGE_USAGE)?.let { pref -> - viewModel.storageUsage.observe(viewLifecycleOwner) { size -> - pref.summary = if (size < 0L) { - pref.context.getString(R.string.computing_) - } else { - FileSize.BYTES.format(pref.context, size) - } - } - } - viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this)) - settings.subscribe(this) - } - - override fun onDestroyView() { - settings.unsubscribe(this) - super.onDestroyView() - } - - override fun onPreferenceTreeClick(preference: Preference): Boolean { - return when (preference.key) { - AppSettings.KEY_BACKUP -> { - if (!backupCreateCall.tryLaunch(BackupUtils.generateFileName(preference.context))) { - Snackbar.make( - listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT, - ).show() - } - true - } - - AppSettings.KEY_RESTORE -> { - if (!backupSelectCall.tryLaunch(arrayOf("*/*"))) { - Snackbar.make( - listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT, - ).show() - } - true - } - - AppSettings.KEY_PROTECT_APP -> { - val pref = (preference as? TwoStatePreference ?: return false) - if (pref.isChecked) { - pref.isChecked = false - startActivity(Intent(preference.context, ProtectSetupActivity::class.java)) - } else { - settings.appPassword = null - } - true - } - - else -> super.onPreferenceTreeClick(preference) - } - } - - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { - when (key) { - AppSettings.KEY_APP_PASSWORD -> { - findPreference(AppSettings.KEY_PROTECT_APP) - ?.isChecked = !settings.appPassword.isNullOrEmpty() - } - } - } - - override fun onActivityResult(result: Uri?) { - if (result != null) { - router.showBackupRestoreDialog(result) - } - } - - 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) - } - } - } -} 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 deleted file mode 100644 index 75b57ad44..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/UserDataSettingsViewModel.kt +++ /dev/null @@ -1,54 +0,0 @@ -package org.koitharu.kotatsu.settings.userdata - -import dagger.hilt.android.lifecycle.HiltViewModel -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 org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.prefs.observeAsFlow -import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.local.data.LocalStorageManager -import javax.inject.Inject - -@HiltViewModel -class UserDataSettingsViewModel @Inject constructor( - private val storageManager: LocalStorageManager, - private val settings: AppSettings, -) : BaseViewModel() { - - val storageUsage = MutableStateFlow(-1L) - - 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 { - loadStorageUsage() - } - - private fun loadStorageUsage(): Job { - val prevJob = storageUsageJob - return launchJob(Dispatchers.Default) { - prevJob?.cancelAndJoin() - val totalBytes = storageManager.computeCacheSize() + storageManager.computeStorageSize() - storageUsage.value = totalBytes - }.also { - storageUsageJob = it - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/DataCleanupSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/DataCleanupSettingsFragment.kt new file mode 100644 index 000000000..4675ee25f --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/DataCleanupSettingsFragment.kt @@ -0,0 +1,173 @@ +package org.koitharu.kotatsu.settings.userdata.storage + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.viewModels +import androidx.preference.Preference +import com.google.android.material.snackbar.Snackbar +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.StateFlow +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.ui.BasePreferenceFragment +import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog +import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver +import org.koitharu.kotatsu.core.util.FileSize +import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe +import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.observeEvent +import org.koitharu.kotatsu.local.data.CacheDir + +@AndroidEntryPoint +class DataCleanupSettingsFragment : BasePreferenceFragment(R.string.data_removal) { + + private val viewModel by viewModels() + private val loadingPrefs = HashSet() + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.pref_data_cleanup) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + 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) + findPreference(AppSettings.KEY_SEARCH_HISTORY_CLEAR)?.let { pref -> + viewModel.searchHistoryCount.observe(viewLifecycleOwner) { + pref.summary = if (it < 0) { + view.context.getString(R.string.loading_) + } else { + pref.context.resources.getQuantityStringSafe(R.plurals.items, it, it) + } + } + } + findPreference(AppSettings.KEY_UPDATES_FEED_CLEAR)?.let { pref -> + viewModel.feedItemsCount.observe(viewLifecycleOwner) { + pref.summary = if (it < 0) { + view.context.getString(R.string.loading_) + } else { + pref.context.resources.getQuantityStringSafe(R.plurals.items, it, it) + } + } + } + findPreference(AppSettings.KEY_WEBVIEW_CLEAR)?.isVisible = viewModel.isBrowserDataCleanupEnabled + + viewModel.loadingKeys.observe(viewLifecycleOwner) { keys -> + loadingPrefs.addAll(keys) + loadingPrefs.forEach { prefKey -> + findPreference(prefKey)?.isEnabled = prefKey !in keys + } + } + viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this)) + viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(listView)) + viewModel.onChaptersCleanedUp.observeEvent(viewLifecycleOwner, ::onChaptersCleanedUp) + } + + override fun onPreferenceTreeClick(preference: Preference): Boolean = when (preference.key) { + AppSettings.KEY_COOKIES_CLEAR -> { + clearCookies() + true + } + + AppSettings.KEY_SEARCH_HISTORY_CLEAR -> { + clearSearchHistory() + true + } + + AppSettings.KEY_PAGES_CACHE_CLEAR -> { + viewModel.clearCache(preference.key, CacheDir.PAGES) + true + } + + AppSettings.KEY_THUMBS_CACHE_CLEAR -> { + viewModel.clearCache(preference.key, CacheDir.THUMBS, CacheDir.FAVICONS) + true + } + + AppSettings.KEY_HTTP_CACHE_CLEAR -> { + viewModel.clearHttpCache() + true + } + + AppSettings.KEY_CHAPTERS_CLEAR -> { + cleanupChapters() + true + } + + AppSettings.KEY_WEBVIEW_CLEAR -> { + viewModel.clearBrowserData() + true + } + + AppSettings.KEY_CLEAR_MANGA_DATA -> { + viewModel.clearMangaData() + true + } + + AppSettings.KEY_UPDATES_FEED_CLEAR -> { + viewModel.clearUpdatesFeed() + true + } + + else -> super.onPreferenceTreeClick(preference) + } + + private fun onChaptersCleanedUp(result: Pair) { + val c = context ?: return + val text = if (result.first == 0 && result.second == 0L) { + c.getString(R.string.no_chapters_deleted) + } else { + c.getString( + R.string.chapters_deleted_pattern, + c.resources.getQuantityStringSafe(R.plurals.chapters, result.first, result.first), + FileSize.BYTES.format(c, result.second), + ) + } + Snackbar.make(listView, text, Snackbar.LENGTH_SHORT).show() + } + + private fun Preference.bindBytesSizeSummary(stateFlow: StateFlow) { + stateFlow.observe(viewLifecycleOwner) { size -> + summary = if (size < 0) { + context.getString(R.string.computing_) + } else { + FileSize.BYTES.format(context, size) + } + } + } + + private fun clearSearchHistory() { + buildAlertDialog(context ?: return) { + setTitle(R.string.clear_search_history) + setMessage(R.string.text_clear_search_history_prompt) + setNegativeButton(android.R.string.cancel, null) + setPositiveButton(R.string.clear) { _, _ -> + viewModel.clearSearchHistory() + } + }.show() + } + + private fun clearCookies() { + buildAlertDialog(context ?: return) { + setTitle(R.string.clear_cookies) + setMessage(R.string.text_clear_cookies_prompt) + setNegativeButton(android.R.string.cancel, null) + setPositiveButton(R.string.clear) { _, _ -> + viewModel.clearCookies() + } + }.show() + } + + private fun cleanupChapters() { + buildAlertDialog(context ?: return) { + setTitle(R.string.delete_read_chapters) + setMessage(R.string.delete_read_chapters_prompt) + setNegativeButton(android.R.string.cancel, null) + setPositiveButton(R.string.delete) { _, _ -> + viewModel.cleanupChapters() + } + }.show() + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/DataCleanupSettingsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/DataCleanupSettingsViewModel.kt new file mode 100644 index 000000000..785935ecc --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/DataCleanupSettingsViewModel.kt @@ -0,0 +1,184 @@ +package org.koitharu.kotatsu.settings.userdata.storage + +import android.annotation.SuppressLint +import android.webkit.WebStorage +import androidx.webkit.WebStorageCompat +import androidx.webkit.WebViewFeature +import coil3.ImageLoader +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +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.parser.MangaDataRepository +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.ui.BaseViewModel +import org.koitharu.kotatsu.core.ui.util.ReversibleAction +import org.koitharu.kotatsu.core.util.ext.MutableEventFlow +import org.koitharu.kotatsu.core.util.ext.call +import org.koitharu.kotatsu.local.data.CacheDir +import org.koitharu.kotatsu.local.data.LocalStorageManager +import org.koitharu.kotatsu.local.domain.DeleteReadChaptersUseCase +import org.koitharu.kotatsu.search.domain.MangaSearchRepository +import org.koitharu.kotatsu.tracker.domain.TrackingRepository +import java.util.EnumMap +import javax.inject.Inject +import javax.inject.Provider +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +@HiltViewModel +class DataCleanupSettingsViewModel @Inject constructor( + private val storageManager: LocalStorageManager, + private val httpCache: Cache, + private val searchRepository: MangaSearchRepository, + private val trackingRepository: TrackingRepository, + private val cookieJar: MutableCookieJar, + private val deleteReadChaptersUseCase: DeleteReadChaptersUseCase, + private val mangaDataRepositoryProvider: Provider, + private val coil: ImageLoader, +) : BaseViewModel() { + + val onActionDone = MutableEventFlow() + val loadingKeys = MutableStateFlow(emptySet()) + + val searchHistoryCount = MutableStateFlow(-1) + val feedItemsCount = MutableStateFlow(-1) + val httpCacheSize = MutableStateFlow(-1L) + val cacheSizes = EnumMap>(CacheDir::class.java) + + val onChaptersCleanedUp = MutableEventFlow>() + + val isBrowserDataCleanupEnabled: Boolean + get() = WebViewFeature.isFeatureSupported(WebViewFeature.DELETE_BROWSING_DATA) + + init { + CacheDir.entries.forEach { + cacheSizes[it] = MutableStateFlow(-1L) + } + launchJob(Dispatchers.Default) { + searchHistoryCount.value = searchRepository.getSearchHistoryCount() + } + launchJob(Dispatchers.Default) { + feedItemsCount.value = trackingRepository.getLogsCount() + } + CacheDir.entries.forEach { cache -> + launchJob(Dispatchers.Default) { + checkNotNull(cacheSizes[cache]).value = storageManager.computeCacheSize(cache) + } + } + launchJob(Dispatchers.Default) { + httpCacheSize.value = runInterruptible { httpCache.size() } + } + } + + fun clearCache(key: String, vararg caches: CacheDir) { + launchJob(Dispatchers.Default) { + try { + loadingKeys.update { it + key } + for (cache in caches) { + storageManager.clearCache(cache) + checkNotNull(cacheSizes[cache]).value = storageManager.computeCacheSize(cache) + if (cache == CacheDir.THUMBS) { + coil.memoryCache?.clear() + } + } + } finally { + loadingKeys.update { it - key } + } + } + } + + fun clearHttpCache() { + launchJob(Dispatchers.Default) { + try { + loadingKeys.update { it + AppSettings.KEY_HTTP_CACHE_CLEAR } + val size = runInterruptible(Dispatchers.IO) { + httpCache.evictAll() + httpCache.size() + } + httpCacheSize.value = size + } finally { + loadingKeys.update { it - AppSettings.KEY_HTTP_CACHE_CLEAR } + } + } + } + + fun clearSearchHistory() { + launchJob(Dispatchers.Default) { + searchRepository.clearSearchHistory() + searchHistoryCount.value = searchRepository.getSearchHistoryCount() + onActionDone.call(ReversibleAction(R.string.search_history_cleared, null)) + } + } + + fun clearCookies() { + launchJob { + cookieJar.clear() + onActionDone.call(ReversibleAction(R.string.cookies_cleared, null)) + } + } + + @SuppressLint("RequiresFeature") + fun clearBrowserData() { + launchJob { + try { + loadingKeys.update { it + AppSettings.KEY_WEBVIEW_CLEAR } + val storage = WebStorage.getInstance() + suspendCoroutine { cont -> + WebStorageCompat.deleteBrowsingData(storage) { + cont.resume(Unit) + } + } + onActionDone.call(ReversibleAction(R.string.updates_feed_cleared, null)) + } finally { + loadingKeys.update { it - AppSettings.KEY_WEBVIEW_CLEAR } + } + } + } + + fun clearUpdatesFeed() { + launchJob(Dispatchers.Default) { + try { + loadingKeys.update { it + AppSettings.KEY_UPDATES_FEED_CLEAR } + trackingRepository.clearLogs() + feedItemsCount.value = trackingRepository.getLogsCount() + onActionDone.call(ReversibleAction(R.string.updates_feed_cleared, null)) + } finally { + loadingKeys.update { it - AppSettings.KEY_UPDATES_FEED_CLEAR } + } + } + } + + fun clearMangaData() { + launchJob(Dispatchers.Default) { + try { + loadingKeys.update { it + AppSettings.KEY_CLEAR_MANGA_DATA } + trackingRepository.gc() + val repository = mangaDataRepositoryProvider.get() + repository.cleanupLocalManga() + repository.cleanupDatabase() + onActionDone.call(ReversibleAction(R.string.updates_feed_cleared, null)) + } finally { + loadingKeys.update { it - AppSettings.KEY_CLEAR_MANGA_DATA } + } + } + } + + fun cleanupChapters() { + launchJob(Dispatchers.Default) { + try { + loadingKeys.update { it + AppSettings.KEY_CHAPTERS_CLEAR } + val oldSize = storageManager.computeStorageSize() + val chaptersCount = deleteReadChaptersUseCase.invoke() + val newSize = storageManager.computeStorageSize() + onChaptersCleanedUp.call(chaptersCount to oldSize - newSize) + } finally { + loadingKeys.update { it - AppSettings.KEY_CHAPTERS_CLEAR } + } + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageManageSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageManageSettingsFragment.kt deleted file mode 100644 index 7e0b946d6..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageManageSettingsFragment.kt +++ /dev/null @@ -1,173 +0,0 @@ -package org.koitharu.kotatsu.settings.userdata.storage - -import android.os.Bundle -import android.view.View -import androidx.fragment.app.viewModels -import androidx.preference.Preference -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.StateFlow -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.ui.BasePreferenceFragment -import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver -import org.koitharu.kotatsu.core.util.FileSize -import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe -import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.core.util.ext.observeEvent -import org.koitharu.kotatsu.local.data.CacheDir - -@AndroidEntryPoint -class StorageManageSettingsFragment : BasePreferenceFragment(R.string.storage_usage) { - - private val viewModel by viewModels() - private val loadingPrefs = HashSet() - - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { - addPreferencesFromResource(R.xml.pref_storage) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - 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) - findPreference(AppSettings.KEY_SEARCH_HISTORY_CLEAR)?.let { pref -> - viewModel.searchHistoryCount.observe(viewLifecycleOwner) { - pref.summary = if (it < 0) { - view.context.getString(R.string.loading_) - } else { - pref.context.resources.getQuantityStringSafe(R.plurals.items, it, it) - } - } - } - findPreference(AppSettings.KEY_UPDATES_FEED_CLEAR)?.let { pref -> - viewModel.feedItemsCount.observe(viewLifecycleOwner) { - pref.summary = if (it < 0) { - view.context.getString(R.string.loading_) - } else { - pref.context.resources.getQuantityStringSafe(R.plurals.items, it, it) - } - } - } - findPreference(AppSettings.KEY_STORAGE_USAGE)?.let { pref -> - viewModel.storageUsage.observe(viewLifecycleOwner, pref) - } - findPreference(AppSettings.KEY_WEBVIEW_CLEAR)?.isVisible = viewModel.isBrowserDataCleanupEnabled - - viewModel.loadingKeys.observe(viewLifecycleOwner) { keys -> - loadingPrefs.addAll(keys) - loadingPrefs.forEach { prefKey -> - findPreference(prefKey)?.isEnabled = prefKey !in keys - } - } - viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this)) - viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(listView)) - viewModel.onChaptersCleanedUp.observeEvent(viewLifecycleOwner, ::onChaptersCleanedUp) - } - - override fun onPreferenceTreeClick(preference: Preference): Boolean = when (preference.key) { - AppSettings.KEY_COOKIES_CLEAR -> { - clearCookies() - true - } - - AppSettings.KEY_SEARCH_HISTORY_CLEAR -> { - clearSearchHistory() - true - } - - AppSettings.KEY_PAGES_CACHE_CLEAR -> { - viewModel.clearCache(preference.key, CacheDir.PAGES) - true - } - - AppSettings.KEY_THUMBS_CACHE_CLEAR -> { - viewModel.clearCache(preference.key, CacheDir.THUMBS, CacheDir.FAVICONS) - true - } - - AppSettings.KEY_HTTP_CACHE_CLEAR -> { - viewModel.clearHttpCache() - true - } - - AppSettings.KEY_CHAPTERS_CLEAR -> { - cleanupChapters() - true - } - - AppSettings.KEY_WEBVIEW_CLEAR -> { - viewModel.clearBrowserData() - true - } - - AppSettings.KEY_CLEAR_MANGA_DATA -> { - viewModel.clearMangaData() - true - } - - AppSettings.KEY_UPDATES_FEED_CLEAR -> { - viewModel.clearUpdatesFeed() - true - } - - else -> super.onPreferenceTreeClick(preference) - } - - private fun onChaptersCleanedUp(result: Pair) { - val c = context ?: return - val text = if (result.first == 0 && result.second == 0L) { - c.getString(R.string.no_chapters_deleted) - } else { - c.getString( - R.string.chapters_deleted_pattern, - c.resources.getQuantityStringSafe(R.plurals.chapters, result.first, result.first), - FileSize.BYTES.format(c, result.second), - ) - } - Snackbar.make(listView, text, Snackbar.LENGTH_SHORT).show() - } - - private fun Preference.bindBytesSizeSummary(stateFlow: StateFlow) { - stateFlow.observe(viewLifecycleOwner) { size -> - summary = if (size < 0) { - context.getString(R.string.computing_) - } else { - FileSize.BYTES.format(context, size) - } - } - } - - private fun clearSearchHistory() { - MaterialAlertDialogBuilder(context ?: return) - .setTitle(R.string.clear_search_history) - .setMessage(R.string.text_clear_search_history_prompt) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.clear) { _, _ -> - viewModel.clearSearchHistory() - }.show() - } - - private fun clearCookies() { - MaterialAlertDialogBuilder(context ?: return) - .setTitle(R.string.clear_cookies) - .setMessage(R.string.text_clear_cookies_prompt) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.clear) { _, _ -> - viewModel.clearCookies() - }.show() - } - - private fun cleanupChapters() { - MaterialAlertDialogBuilder(context ?: return) - .setTitle(R.string.delete_read_chapters) - .setMessage(R.string.delete_read_chapters_prompt) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.delete) { _, _ -> - viewModel.cleanupChapters() - }.show() - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageManageSettingsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageManageSettingsViewModel.kt deleted file mode 100644 index 66868bac4..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageManageSettingsViewModel.kt +++ /dev/null @@ -1,226 +0,0 @@ -package org.koitharu.kotatsu.settings.userdata.storage - -import android.annotation.SuppressLint -import android.webkit.WebStorage -import androidx.webkit.WebStorageCompat -import androidx.webkit.WebViewFeature -import coil3.ImageLoader -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.flow.MutableStateFlow -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.parser.MangaDataRepository -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.ui.util.ReversibleAction -import org.koitharu.kotatsu.core.util.ext.MutableEventFlow -import org.koitharu.kotatsu.core.util.ext.call -import org.koitharu.kotatsu.core.util.ext.firstNotNull -import org.koitharu.kotatsu.local.data.CacheDir -import org.koitharu.kotatsu.local.data.LocalStorageManager -import org.koitharu.kotatsu.local.domain.DeleteReadChaptersUseCase -import org.koitharu.kotatsu.search.domain.MangaSearchRepository -import org.koitharu.kotatsu.tracker.domain.TrackingRepository -import java.util.EnumMap -import javax.inject.Inject -import javax.inject.Provider -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine - -@HiltViewModel -class StorageManageSettingsViewModel @Inject constructor( - private val storageManager: LocalStorageManager, - private val httpCache: Cache, - private val searchRepository: MangaSearchRepository, - private val trackingRepository: TrackingRepository, - private val cookieJar: MutableCookieJar, - private val deleteReadChaptersUseCase: DeleteReadChaptersUseCase, - private val mangaDataRepositoryProvider: Provider, - private val coil: ImageLoader, -) : BaseViewModel() { - - val onActionDone = MutableEventFlow() - val loadingKeys = MutableStateFlow(emptySet()) - - val searchHistoryCount = MutableStateFlow(-1) - val feedItemsCount = MutableStateFlow(-1) - val httpCacheSize = MutableStateFlow(-1L) - val cacheSizes = EnumMap>(CacheDir::class.java) - val storageUsage = MutableStateFlow(null) - - val onChaptersCleanedUp = MutableEventFlow>() - - val isBrowserDataCleanupEnabled: Boolean - get() = WebViewFeature.isFeatureSupported(WebViewFeature.DELETE_BROWSING_DATA) - - private var storageUsageJob: Job? = null - - init { - CacheDir.entries.forEach { - cacheSizes[it] = MutableStateFlow(-1L) - } - launchJob(Dispatchers.Default) { - searchHistoryCount.value = searchRepository.getSearchHistoryCount() - } - launchJob(Dispatchers.Default) { - feedItemsCount.value = trackingRepository.getLogsCount() - } - CacheDir.entries.forEach { cache -> - launchJob(Dispatchers.Default) { - checkNotNull(cacheSizes[cache]).value = storageManager.computeCacheSize(cache) - } - } - launchJob(Dispatchers.Default) { - httpCacheSize.value = runInterruptible { httpCache.size() } - } - loadStorageUsage() - } - - fun clearCache(key: String, vararg caches: CacheDir) { - launchJob(Dispatchers.Default) { - try { - loadingKeys.update { it + key } - for (cache in caches) { - storageManager.clearCache(cache) - checkNotNull(cacheSizes[cache]).value = storageManager.computeCacheSize(cache) - if (cache == CacheDir.THUMBS) { - coil.memoryCache?.clear() - } - } - loadStorageUsage() - } finally { - loadingKeys.update { it - key } - } - } - } - - fun clearHttpCache() { - launchJob(Dispatchers.Default) { - try { - loadingKeys.update { it + AppSettings.KEY_HTTP_CACHE_CLEAR } - val size = runInterruptible(Dispatchers.IO) { - httpCache.evictAll() - httpCache.size() - } - httpCacheSize.value = size - loadStorageUsage() - } finally { - loadingKeys.update { it - AppSettings.KEY_HTTP_CACHE_CLEAR } - } - } - } - - fun clearSearchHistory() { - launchJob(Dispatchers.Default) { - searchRepository.clearSearchHistory() - searchHistoryCount.value = searchRepository.getSearchHistoryCount() - onActionDone.call(ReversibleAction(R.string.search_history_cleared, null)) - } - } - - fun clearCookies() { - launchJob { - cookieJar.clear() - onActionDone.call(ReversibleAction(R.string.cookies_cleared, null)) - } - } - - @SuppressLint("RequiresFeature") - fun clearBrowserData() { - launchJob { - try { - loadingKeys.update { it + AppSettings.KEY_WEBVIEW_CLEAR } - val storage = WebStorage.getInstance() - suspendCoroutine { cont -> - WebStorageCompat.deleteBrowsingData(storage) { - cont.resume(Unit) - } - } - onActionDone.call(ReversibleAction(R.string.updates_feed_cleared, null)) - } finally { - loadingKeys.update { it - AppSettings.KEY_WEBVIEW_CLEAR } - } - } - } - - fun clearUpdatesFeed() { - launchJob(Dispatchers.Default) { - try { - loadingKeys.update { it + AppSettings.KEY_UPDATES_FEED_CLEAR } - trackingRepository.clearLogs() - feedItemsCount.value = trackingRepository.getLogsCount() - onActionDone.call(ReversibleAction(R.string.updates_feed_cleared, null)) - } finally { - loadingKeys.update { it - AppSettings.KEY_UPDATES_FEED_CLEAR } - } - } - } - - fun clearMangaData() { - launchJob(Dispatchers.Default) { - try { - loadingKeys.update { it + AppSettings.KEY_CLEAR_MANGA_DATA } - trackingRepository.gc() - val repository = mangaDataRepositoryProvider.get() - repository.cleanupLocalManga() - repository.cleanupDatabase() - onActionDone.call(ReversibleAction(R.string.updates_feed_cleared, null)) - } finally { - loadingKeys.update { it - AppSettings.KEY_CLEAR_MANGA_DATA } - } - } - } - - fun cleanupChapters() { - launchJob(Dispatchers.Default) { - try { - loadingKeys.update { it + AppSettings.KEY_CHAPTERS_CLEAR } - val oldSize = storageUsage.firstNotNull().savedManga.bytes - val chaptersCount = deleteReadChaptersUseCase.invoke() - loadStorageUsage().join() - val newSize = storageUsage.firstNotNull().savedManga.bytes - onChaptersCleanedUp.call(chaptersCount to oldSize - newSize) - } finally { - loadingKeys.update { it - AppSettings.KEY_CHAPTERS_CLEAR } - } - } - } - - private fun loadStorageUsage(): Job { - val prevJob = storageUsageJob - return launchJob(Dispatchers.Default) { - prevJob?.cancelAndJoin() - val pagesCacheSize = storageManager.computeCacheSize(CacheDir.PAGES) - val otherCacheSize = storageManager.computeCacheSize() - pagesCacheSize - val storageSize = storageManager.computeStorageSize() - val availableSpace = storageManager.computeAvailableSize() - val totalBytes = pagesCacheSize + otherCacheSize + storageSize + availableSpace - storageUsage.value = StorageUsage( - savedManga = StorageUsage.Item( - bytes = storageSize, - percent = (storageSize.toDouble() / totalBytes).toFloat(), - ), - pagesCache = StorageUsage.Item( - bytes = pagesCacheSize, - percent = (pagesCacheSize.toDouble() / totalBytes).toFloat(), - ), - otherCache = StorageUsage.Item( - bytes = otherCacheSize, - percent = (otherCacheSize.toDouble() / totalBytes).toFloat(), - ), - available = StorageUsage.Item( - bytes = availableSpace, - percent = (availableSpace.toDouble() / totalBytes).toFloat(), - ), - ) - }.also { - storageUsageJob = it - } - } -} diff --git a/app/src/main/res/drawable/ic_usage.xml b/app/src/main/res/drawable/ic_usage.xml new file mode 100644 index 000000000..9bc3c70aa --- /dev/null +++ b/app/src/main/res/drawable/ic_usage.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d85322073..bd2c1ad6d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -895,4 +895,8 @@ Save filter Overwrite A filter named \"%s\" already exists. Do you want to overwrite it? + Storage and network + Create or restore a backup + Data removal + Privacy diff --git a/app/src/main/res/xml/pref_appearance.xml b/app/src/main/res/xml/pref_appearance.xml index cafc23ee4..882367df7 100644 --- a/app/src/main/res/xml/pref_appearance.xml +++ b/app/src/main/res/xml/pref_appearance.xml @@ -1,122 +1,149 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:title="@string/appearance"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/xml/pref_backups.xml b/app/src/main/res/xml/pref_backups.xml new file mode 100644 index 000000000..3d23f5426 --- /dev/null +++ b/app/src/main/res/xml/pref_backups.xml @@ -0,0 +1,25 @@ + + + + + + + + + + diff --git a/app/src/main/res/xml/pref_storage.xml b/app/src/main/res/xml/pref_data_cleanup.xml similarity index 93% rename from app/src/main/res/xml/pref_storage.xml rename to app/src/main/res/xml/pref_data_cleanup.xml index 7ff1291e9..8efcb0e86 100644 --- a/app/src/main/res/xml/pref_storage.xml +++ b/app/src/main/res/xml/pref_data_cleanup.xml @@ -2,9 +2,7 @@ - - + android:title="@string/data_removal"> - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/xml/pref_network_storage.xml b/app/src/main/res/xml/pref_network_storage.xml new file mode 100644 index 000000000..08e51247e --- /dev/null +++ b/app/src/main/res/xml/pref_network_storage.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/xml/pref_root.xml b/app/src/main/res/xml/pref_root.xml index 285395aea..09d0dff2d 100644 --- a/app/src/main/res/xml/pref_root.xml +++ b/app/src/main/res/xml/pref_root.xml @@ -1,59 +1,59 @@ - - - - - - - - - - - - - - - - - - + xmlns:android="http://schemas.android.com/apk/res/android"> + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/xml/pref_sources.xml b/app/src/main/res/xml/pref_sources.xml index 9ed17f330..1571bf0a2 100644 --- a/app/src/main/res/xml/pref_sources.xml +++ b/app/src/main/res/xml/pref_sources.xml @@ -1,54 +1,66 @@ - - - - - - - - - - - - - - - - + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:title="@string/remote_sources"> + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/xml/pref_user_data.xml b/app/src/main/res/xml/pref_user_data.xml deleted file mode 100644 index f940423cc..000000000 --- a/app/src/main/res/xml/pref_user_data.xml +++ /dev/null @@ -1,64 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - -