Reorganize settings

This commit is contained in:
Koitharu
2025-10-21 17:46:47 +03:00
parent 35c158d35a
commit 9e5664da3a
29 changed files with 1207 additions and 1192 deletions

View File

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

View File

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

View File

@@ -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 {
BasePreferenceFragment(R.string.appearance),
SharedPreferences.OnSharedPreferenceChangeListener {
@Inject
lateinit var activityRecreationHandle: ActivityRecreationHandle
@Inject
lateinit var activityRecreationHandle: ActivityRecreationHandle
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_appearance)
findPreference<SliderPreference>(AppSettings.KEY_GRID_SIZE)?.summaryProvider = PercentSummaryProvider()
findPreference<ListPreference>(AppSettings.KEY_LIST_MODE)?.run {
entryValues = ListMode.entries.names()
setDefaultValueCompat(ListMode.GRID.name)
}
findPreference<ListPreference>(AppSettings.KEY_PROGRESS_INDICATORS)?.run {
entryValues = ProgressIndicatorMode.entries.names()
setDefaultValueCompat(ProgressIndicatorMode.PERCENT_READ.name)
}
findPreference<ActivityListPreference>(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<ActivityListPreference> {
val locale = AppCompatDelegate.getApplicationLocales().get(0)
locale?.getDisplayName(locale)?.toTitleCase(locale) ?: getString(R.string.follow_system)
}
setDefaultValueCompat("")
}
findPreference<MultiSelectListPreference>(AppSettings.KEY_MANGA_LIST_BADGES)?.run {
summaryProvider = MultiSummaryProvider(R.string.none)
}
bindNavSummary()
}
@Inject
lateinit var appShortcutManager: AppShortcutManager
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
settings.subscribe(this)
}
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_appearance)
findPreference<SliderPreference>(AppSettings.KEY_GRID_SIZE)?.summaryProvider = PercentSummaryProvider()
findPreference<ListPreference>(AppSettings.KEY_LIST_MODE)?.run {
entryValues = ListMode.entries.names()
setDefaultValueCompat(ListMode.GRID.name)
}
findPreference<ListPreference>(AppSettings.KEY_PROGRESS_INDICATORS)?.run {
entryValues = ProgressIndicatorMode.entries.names()
setDefaultValueCompat(ProgressIndicatorMode.PERCENT_READ.name)
}
findPreference<ActivityListPreference>(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<ActivityListPreference> {
val locale = AppCompatDelegate.getApplicationLocales().get(0)
locale?.getDisplayName(locale)?.toTitleCase(locale) ?: getString(R.string.follow_system)
}
setDefaultValueCompat("")
}
findPreference<MultiSelectListPreference>(AppSettings.KEY_MANGA_LIST_BADGES)?.run {
summaryProvider = MultiSummaryProvider(R.string.none)
}
findPreference<Preference>(AppSettings.KEY_SHORTCUTS)?.isVisible =
appShortcutManager.isDynamicShortcutsAvailable()
findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)
?.isChecked = !settings.appPassword.isNullOrEmpty()
findPreference<ListPreference>(AppSettings.KEY_SCREENSHOTS_POLICY)?.run {
entryValues = ScreenshotsPolicy.entries.names()
setDefaultValueCompat(ScreenshotsPolicy.ALLOW.name)
}
findPreference<MultiSelectListPreference>(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 onDestroyView() {
settings.unsubscribe(this)
super.onDestroyView()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
settings.subscribe(this)
}
override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) {
when (key) {
AppSettings.KEY_THEME -> {
AppCompatDelegate.setDefaultNightMode(settings.theme)
}
override fun onDestroyView() {
settings.unsubscribe(this)
super.onDestroyView()
}
AppSettings.KEY_COLOR_THEME,
AppSettings.KEY_THEME_AMOLED,
-> {
postRestart()
}
override fun onSharedPreferenceChanged(prefs: SharedPreferences?, key: String?) {
when (key) {
AppSettings.KEY_THEME -> {
AppCompatDelegate.setDefaultNightMode(settings.theme)
}
AppSettings.KEY_APP_LOCALE -> {
AppCompatDelegate.setApplicationLocales(settings.appLocales)
}
AppSettings.KEY_COLOR_THEME,
AppSettings.KEY_THEME_AMOLED,
-> {
postRestart()
}
AppSettings.KEY_NAV_MAIN -> {
bindNavSummary()
}
}
}
AppSettings.KEY_APP_LOCALE -> {
AppCompatDelegate.setApplicationLocales(settings.appLocales)
}
private fun postRestart() {
viewLifecycleOwner.lifecycle.postDelayed(400) {
activityRecreationHandle.recreateAll()
}
}
AppSettings.KEY_NAV_MAIN -> {
bindNavSummary()
}
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()
}
}
}
AppSettings.KEY_APP_PASSWORD -> {
findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)
?.isChecked = !settings.appPassword.isNullOrEmpty()
}
}
}
private fun bindNavSummary() {
val pref = findPreference<Preference>(AppSettings.KEY_NAV_MAIN) ?: return
pref.summary = settings.mainNavItems.joinToString {
getString(it.title)
}
}
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<Preference>(AppSettings.KEY_NAV_MAIN) ?: return
pref.summary = settings.mainNavItems.joinToString {
getString(it.title)
}
}
}

View File

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

View File

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

View File

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

View File

@@ -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<StorageAndNetworkSettingsViewModel>()
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_network_storage)
findPreference<ListPreference>(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<StorageUsagePreference>(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<Preference>(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"
}
}
}
}

View File

@@ -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<StorageUsage?> = 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(),
),
)
}
}

View File

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

View File

@@ -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<SettingsItem> {
val preferenceManager = PreferenceManager(context)
val result = ArrayList<SettingsItem>()
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<SettingsItem> {
val preferenceManager = PreferenceManager(context)
val result = ArrayList<SettingsItem>()
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<SettingsItem>,
@XmlRes resId: Int,
breadcrumbs: List<String>,
fragmentClass: Class<out PreferenceFragmentCompat>
) {
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<SettingsItem>,
@XmlRes resId: Int,
breadcrumbs: List<String>,
fragmentClass: Class<out PreferenceFragmentCompat>
) {
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<SettingsItem>,
breadcrumbs: List<String>,
fragmentClass: Class<out PreferenceFragmentCompat>
): 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<SettingsItem>,
breadcrumbs: List<String>,
fragmentClass: Class<out PreferenceFragmentCompat>
): 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,
),
)
}
}
}

View File

@@ -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<ListPreference>(AppSettings.KEY_INCOGNITO_NSFW)?.run {
entryValues = TriStateOption.entries.names()
setDefaultValueCompat(TriStateOption.ASK.name)
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

View File

@@ -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<Uri?> {
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<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)
}
}
}
}

View File

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

View File

@@ -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<Uri?> {
@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<Preference>(AppSettings.KEY_SHORTCUTS)?.isVisible =
appShortcutManager.isDynamicShortcutsAvailable()
findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)
?.isChecked = !settings.appPassword.isNullOrEmpty()
findPreference<ListPreference>(AppSettings.KEY_SCREENSHOTS_POLICY)?.run {
entryValues = ScreenshotsPolicy.entries.names()
setDefaultValueCompat(ScreenshotsPolicy.ALLOW.name)
}
findPreference<ListPreference>(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<MultiSelectListPreference>(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<Preference>(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<TwoStatePreference>(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<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)
}
}
}
}

View File

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

View File

@@ -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<DataCleanupSettingsViewModel>()
private val loadingPrefs = HashSet<String>()
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<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)
findPreference<Preference>(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<Preference>(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<Preference>(AppSettings.KEY_WEBVIEW_CLEAR)?.isVisible = viewModel.isBrowserDataCleanupEnabled
viewModel.loadingKeys.observe(viewLifecycleOwner) { keys ->
loadingPrefs.addAll(keys)
loadingPrefs.forEach { prefKey ->
findPreference<Preference>(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<Int, Long>) {
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<Long>) {
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()
}
}

View File

@@ -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<MangaDataRepository>,
private val coil: ImageLoader,
) : BaseViewModel() {
val onActionDone = MutableEventFlow<ReversibleAction>()
val loadingKeys = MutableStateFlow(emptySet<String>())
val searchHistoryCount = MutableStateFlow(-1)
val feedItemsCount = MutableStateFlow(-1)
val httpCacheSize = MutableStateFlow(-1L)
val cacheSizes = EnumMap<CacheDir, MutableStateFlow<Long>>(CacheDir::class.java)
val onChaptersCleanedUp = MutableEventFlow<Pair<Int, Long>>()
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 }
}
}
}
}

View File

@@ -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<StorageManageSettingsViewModel>()
private val loadingPrefs = HashSet<String>()
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_storage)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
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)
findPreference<Preference>(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<Preference>(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<StorageUsagePreference>(AppSettings.KEY_STORAGE_USAGE)?.let { pref ->
viewModel.storageUsage.observe(viewLifecycleOwner, pref)
}
findPreference<Preference>(AppSettings.KEY_WEBVIEW_CLEAR)?.isVisible = viewModel.isBrowserDataCleanupEnabled
viewModel.loadingKeys.observe(viewLifecycleOwner) { keys ->
loadingPrefs.addAll(keys)
loadingPrefs.forEach { prefKey ->
findPreference<Preference>(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<Int, Long>) {
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<Long>) {
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()
}
}

View File

@@ -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<MangaDataRepository>,
private val coil: ImageLoader,
) : BaseViewModel() {
val onActionDone = MutableEventFlow<ReversibleAction>()
val loadingKeys = MutableStateFlow(emptySet<String>())
val searchHistoryCount = MutableStateFlow(-1)
val feedItemsCount = MutableStateFlow(-1)
val httpCacheSize = MutableStateFlow(-1L)
val cacheSizes = EnumMap<CacheDir, MutableStateFlow<Long>>(CacheDir::class.java)
val storageUsage = MutableStateFlow<StorageUsage?>(null)
val onChaptersCleanedUp = MutableEventFlow<Pair<Int, Long>>()
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
}
}
}

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000"
android:pathData="M13,2.05V5.08C16.39,5.57 19,8.47 19,12C19,12.9 18.82,13.75 18.5,14.54L21.12,16.07C21.68,14.83 22,13.45 22,12C22,6.82 18.05,2.55 13,2.05M12,19A7,7 0 0,1 5,12C5,8.47 7.61,5.57 11,5.08V2.05C5.94,2.55 2,6.81 2,12A10,10 0 0,0 12,22C15.3,22 18.23,20.39 20.05,17.91L17.45,16.38C16.17,18 14.21,19 12,19Z" />
</vector>

View File

@@ -895,4 +895,8 @@
<string name="save_filter">Save filter</string>
<string name="overwrite">Overwrite</string>
<string name="filter_overwrite_confirm">A filter named \"%s\" already exists. Do you want to overwrite it?</string>
<string name="storage_and_network">Storage and network</string>
<string name="create_or_restore_backup">Create or restore a backup</string>
<string name="data_removal">Data removal</string>
<string name="privacy">Privacy</string>
</resources>

View File

@@ -1,122 +1,149 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:title="@string/appearance">
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:title="@string/appearance">
<org.koitharu.kotatsu.settings.utils.ThemeChooserPreference
android:key="color_theme"
android:title="@string/color_theme" />
<org.koitharu.kotatsu.settings.utils.ThemeChooserPreference
android:key="color_theme"
android:title="@string/color_theme" />
<ListPreference
android:defaultValue="-1"
android:entries="@array/themes"
android:entryValues="@array/values_theme"
android:key="theme"
android:title="@string/theme"
app:useSimpleSummaryProvider="true" />
<ListPreference
android:defaultValue="-1"
android:entries="@array/themes"
android:entryValues="@array/values_theme"
android:key="theme"
android:title="@string/theme"
app:useSimpleSummaryProvider="true" />
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="amoled_theme"
android:summary="@string/black_dark_theme_summary"
android:title="@string/black_dark_theme" />
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="amoled_theme"
android:summary="@string/black_dark_theme_summary"
android:title="@string/black_dark_theme" />
<org.koitharu.kotatsu.settings.utils.ActivityListPreference
android:key="app_locale"
android:title="@string/language" />
<org.koitharu.kotatsu.settings.utils.ActivityListPreference
android:key="app_locale"
android:title="@string/language" />
<PreferenceCategory android:title="@string/manga_list">
<PreferenceCategory android:title="@string/manga_list">
<ListPreference
android:entries="@array/list_modes"
android:key="list_mode_2"
android:title="@string/list_mode"
app:useSimpleSummaryProvider="true" />
<ListPreference
android:entries="@array/list_modes"
android:key="list_mode_2"
android:title="@string/list_mode"
app:useSimpleSummaryProvider="true" />
<org.koitharu.kotatsu.settings.utils.SliderPreference
android:key="grid_size"
android:stepSize="5"
android:title="@string/grid_size"
android:valueFrom="50"
android:valueTo="150"
app:defaultValue="100" />
<org.koitharu.kotatsu.settings.utils.SliderPreference
android:key="grid_size"
android:stepSize="5"
android:title="@string/grid_size"
android:valueFrom="50"
android:valueTo="150"
app:defaultValue="100" />
<SwitchPreferenceCompat
android:defaultValue="true"
android:key="quick_filter"
android:summary="@string/show_quick_filters_summary"
android:title="@string/show_quick_filters" />
<SwitchPreferenceCompat
android:defaultValue="true"
android:key="quick_filter"
android:summary="@string/show_quick_filters_summary"
android:title="@string/show_quick_filters" />
<ListPreference
android:entries="@array/progress_indicators"
android:key="progress_indicators"
android:title="@string/show_reading_indicators"
app:useSimpleSummaryProvider="true" />
<ListPreference
android:entries="@array/progress_indicators"
android:key="progress_indicators"
android:title="@string/show_reading_indicators"
app:useSimpleSummaryProvider="true" />
<MultiSelectListPreference
android:defaultValue="@array/values_list_badges"
android:entries="@array/list_badges"
android:entryValues="@array/values_list_badges"
android:key="manga_list_badges"
android:title="@string/badges_in_lists" />
<MultiSelectListPreference
android:defaultValue="@array/values_list_badges"
android:entries="@array/list_badges"
android:entryValues="@array/values_list_badges"
android:key="manga_list_badges"
android:title="@string/badges_in_lists" />
</PreferenceCategory>
</PreferenceCategory>
<PreferenceCategory android:title="@string/details">
<PreferenceCategory android:title="@string/details">
<SwitchPreferenceCompat
android:defaultValue="true"
android:key="description_collapse"
android:title="@string/collapse_long_description" />
<SwitchPreferenceCompat
android:defaultValue="true"
android:key="description_collapse"
android:title="@string/collapse_long_description" />
<SwitchPreferenceCompat
android:defaultValue="true"
android:key="pages_tab"
android:summary="@string/show_pages_thumbs_summary"
android:title="@string/show_pages_thumbs" />
<SwitchPreferenceCompat
android:defaultValue="true"
android:key="pages_tab"
android:summary="@string/show_pages_thumbs_summary"
android:title="@string/show_pages_thumbs" />
<ListPreference
android:defaultValue="-1"
android:dependency="pages_tab"
android:entries="@array/details_tabs"
android:entryValues="@array/details_tabs_values"
android:key="details_tab"
android:title="@string/default_tab"
app:useSimpleSummaryProvider="true" />
<ListPreference
android:defaultValue="-1"
android:dependency="pages_tab"
android:entries="@array/details_tabs"
android:entryValues="@array/details_tabs_values"
android:key="details_tab"
android:title="@string/default_tab"
app:useSimpleSummaryProvider="true" />
</PreferenceCategory>
</PreferenceCategory>
<PreferenceCategory android:title="@string/main_screen">
<PreferenceCategory android:title="@string/main_screen">
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.nav.NavConfigFragment"
android:key="nav_main"
android:title="@string/main_screen_sections" />
<MultiSelectListPreference
android:key="search_suggest_types"
android:title="@string/search_suggestions" />
<SwitchPreferenceCompat
android:defaultValue="true"
android:key="main_fab"
android:summary="@string/main_screen_fab_summary"
android:title="@string/main_screen_fab" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.nav.NavConfigFragment"
android:key="nav_main"
android:title="@string/main_screen_sections" />
<SwitchPreferenceCompat
android:defaultValue="true"
android:key="nav_labels"
android:title="@string/show_labels_in_navbar" />
<SwitchPreferenceCompat
android:defaultValue="true"
android:key="main_fab"
android:summary="@string/main_screen_fab_summary"
android:title="@string/main_screen_fab" />
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="nav_pinned"
android:summary="@string/pin_navigation_ui_summary"
android:title="@string/pin_navigation_ui" />
<SwitchPreferenceCompat
android:defaultValue="true"
android:key="nav_labels"
android:title="@string/show_labels_in_navbar" />
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="exit_confirm"
android:summary="@string/exit_confirmation_summary"
android:title="@string/exit_confirmation" />
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="nav_pinned"
android:summary="@string/pin_navigation_ui_summary"
android:title="@string/pin_navigation_ui" />
</PreferenceCategory>
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="exit_confirm"
android:summary="@string/exit_confirmation_summary"
android:title="@string/exit_confirmation" />
<SwitchPreferenceCompat
android:defaultValue="true"
android:key="dynamic_shortcuts"
android:summary="@string/history_shortcuts_summary"
android:title="@string/history_shortcuts" />
</PreferenceCategory>
<PreferenceCategory android:title="@string/privacy">
<SwitchPreferenceCompat
android:key="protect_app"
android:persistent="false"
android:summary="@string/protect_application_summary"
android:title="@string/protect_application" />
<ListPreference
android:defaultValue="allow"
android:entries="@array/screenshots_policy"
android:key="screenshots_policy"
android:title="@string/screenshots_policy"
app:useSimpleSummaryProvider="true" />
</PreferenceCategory>
</PreferenceScreen>

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:title="@string/backup_restore">
<Preference
android:key="backup"
android:persistent="false"
android:summary="@string/backup_information"
android:title="@string/create_backup" />
<Preference
android:key="restore"
android:persistent="false"
android:summary="@string/restore_summary"
android:title="@string/restore_backup" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.backups.ui.periodical.PeriodicalBackupSettingsFragment"
android:key="backup_periodic"
android:persistent="false"
android:title="@string/periodic_backups" />
</PreferenceScreen>

View File

@@ -2,9 +2,7 @@
<PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:title="@string/storage_usage">
<org.koitharu.kotatsu.settings.userdata.storage.StorageUsagePreference android:key="storage_usage" />
android:title="@string/data_removal">
<Preference
android:key="search_history_clear"

View File

@@ -1,67 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:title="@string/network">
<ListPreference
android:defaultValue="0"
android:entries="@array/network_policy"
android:entryValues="@array/values_network_policy"
android:key="prefetch_content"
android:title="@string/prefetch_content"
app:useSimpleSummaryProvider="true"
tools:isPreferenceVisible="true" />
<ListPreference
android:defaultValue="2"
android:entries="@array/network_policy"
android:entryValues="@array/values_network_policy"
android:key="pages_preload"
android:title="@string/preload_pages"
app:useSimpleSummaryProvider="true" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.ProxySettingsFragment"
android:key="proxy"
android:title="@string/proxy"
app:allowDividerAbove="true" />
<ListPreference
android:entries="@array/doh_providers"
android:key="doh"
android:title="@string/dns_over_https"
app:useSimpleSummaryProvider="true" />
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="adblock"
android:summary="@string/adblock_summary"
android:title="@string/adblock" />
<ListPreference
android:defaultValue="-1"
android:entries="@array/image_proxies"
android:entryValues="@array/values_image_proxies"
android:key="images_proxy_2"
android:title="@string/images_proxy_title"
app:useSimpleSummaryProvider="true" />
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="mirror_switching"
android:summary="@string/mirror_switching_summary"
android:title="@string/mirror_switching" />
<SwitchPreferenceCompat
android:key="ssl_bypass"
android:summary="@string/ignore_ssl_errors_summary"
android:title="@string/ignore_ssl_errors" />
<SwitchPreferenceCompat
android:key="no_offline"
android:summary="@string/disable_connectivity_check_summary"
android:title="@string/disable_connectivity_check" />
</PreferenceScreen>

View File

@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:title="@string/network">
<PreferenceCategory android:title="@string/storage_usage">
<org.koitharu.kotatsu.settings.userdata.storage.StorageUsagePreference android:key="storage_usage" />
<Preference
android:fragment="org.koitharu.kotatsu.settings.userdata.storage.DataCleanupSettingsFragment"
android:persistent="false"
android:title="@string/data_removal" />
</PreferenceCategory>
<ListPreference
android:defaultValue="0"
android:entries="@array/network_policy"
android:entryValues="@array/values_network_policy"
android:key="prefetch_content"
android:title="@string/prefetch_content"
app:allowDividerAbove="true"
app:useSimpleSummaryProvider="true"
tools:isPreferenceVisible="true" />
<ListPreference
android:defaultValue="2"
android:entries="@array/network_policy"
android:entryValues="@array/values_network_policy"
android:key="pages_preload"
android:title="@string/preload_pages"
app:useSimpleSummaryProvider="true" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.ProxySettingsFragment"
android:key="proxy"
android:title="@string/proxy"
app:allowDividerAbove="true" />
<ListPreference
android:entries="@array/doh_providers"
android:key="doh"
android:title="@string/dns_over_https"
app:useSimpleSummaryProvider="true" />
<ListPreference
android:defaultValue="-1"
android:entries="@array/image_proxies"
android:entryValues="@array/values_image_proxies"
android:key="images_proxy_2"
android:title="@string/images_proxy_title"
app:useSimpleSummaryProvider="true" />
<SwitchPreferenceCompat
android:key="ssl_bypass"
android:summary="@string/ignore_ssl_errors_summary"
android:title="@string/ignore_ssl_errors" />
<SwitchPreferenceCompat
android:key="no_offline"
android:summary="@string/disable_connectivity_check_summary"
android:title="@string/disable_connectivity_check" />
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="adblock"
android:summary="@string/adblock_summary"
android:title="@string/adblock" />
</PreferenceScreen>

View File

@@ -1,59 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android">
xmlns:android="http://schemas.android.com/apk/res/android">
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.AppearanceSettingsFragment"
android:icon="@drawable/ic_appearance"
android:key="appearance"
android:title="@string/appearance" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.AppearanceSettingsFragment"
android:icon="@drawable/ic_appearance"
android:key="appearance"
android:title="@string/appearance" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.sources.SourcesSettingsFragment"
android:icon="@drawable/ic_manga_source"
android:key="remote_sources"
android:title="@string/remote_sources" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.sources.SourcesSettingsFragment"
android:icon="@drawable/ic_manga_source"
android:key="remote_sources"
android:title="@string/remote_sources" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.ReaderSettingsFragment"
android:icon="@drawable/ic_book_page"
android:key="reader"
android:title="@string/reader_settings" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.ReaderSettingsFragment"
android:icon="@drawable/ic_book_page"
android:key="reader"
android:title="@string/reader_settings" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.NetworkSettingsFragment"
android:icon="@drawable/ic_web"
android:key="network"
android:title="@string/network" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.StorageAndNetworkSettingsFragment"
android:icon="@drawable/ic_usage"
android:key="network"
android:title="@string/storage_and_network" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.userdata.UserDataSettingsFragment"
android:icon="@drawable/ic_data_privacy"
android:key="userdata"
android:title="@string/data_and_privacy" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.DownloadsSettingsFragment"
android:icon="@drawable/ic_download"
android:key="downloads"
android:title="@string/downloads" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.DownloadsSettingsFragment"
android:icon="@drawable/ic_download"
android:key="downloads"
android:title="@string/downloads" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.tracker.TrackerSettingsFragment"
android:icon="@drawable/ic_feed"
android:key="tracker"
android:title="@string/check_for_new_chapters" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.tracker.TrackerSettingsFragment"
android:icon="@drawable/ic_feed"
android:key="tracker"
android:title="@string/check_for_new_chapters" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.ServicesSettingsFragment"
android:icon="@drawable/ic_services"
android:key="services"
android:title="@string/services" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.ServicesSettingsFragment"
android:icon="@drawable/ic_services"
android:key="services"
android:title="@string/services" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.userdata.BackupsSettingsFragment"
android:icon="@drawable/ic_backup_restore"
android:key="userdata"
android:title="@string/backup_restore" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.about.AboutSettingsFragment"
android:icon="@drawable/ic_info_outline"
android:key="about"
android:title="@string/about" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.about.AboutSettingsFragment"
android:icon="@drawable/ic_info_outline"
android:key="about"
android:title="@string/about" />
</PreferenceScreen>

View File

@@ -1,54 +1,66 @@
<?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"
android:title="@string/remote_sources">
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:title="@string/remote_sources">
<ListPreference
android:key="sources_sort_order"
android:title="@string/sort_order"
app:useSimpleSummaryProvider="true" />
<ListPreference
android:key="sources_sort_order"
android:title="@string/sort_order"
app:useSimpleSummaryProvider="true" />
<SwitchPreferenceCompat
android:defaultValue="true"
android:key="sources_grid"
android:title="@string/show_in_grid_view" />
<SwitchPreferenceCompat
android:defaultValue="true"
android:key="sources_grid"
android:title="@string/show_in_grid_view" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.sources.manage.SourcesManageFragment"
android:key="remote_sources"
android:persistent="false"
android:title="@string/manage_sources" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.sources.manage.SourcesManageFragment"
android:key="remote_sources"
android:persistent="false"
android:title="@string/manage_sources" />
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="sources_enabled_all"
android:summary="@string/enable_all_sources_summary"
android:title="@string/enable_all_sources"
app:allowDividerAbove="true" />
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="sources_enabled_all"
android:summary="@string/enable_all_sources_summary"
android:title="@string/enable_all_sources"
app:allowDividerAbove="true" />
<Preference
android:key="sources_catalog"
android:persistent="false"
android:title="@string/sources_catalog" />
<Preference
android:key="sources_catalog"
android:persistent="false"
android:title="@string/sources_catalog" />
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="no_nsfw"
android:summary="@string/disable_nsfw_summary"
android:title="@string/disable_nsfw" />
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="no_nsfw"
android:summary="@string/disable_nsfw_summary"
android:title="@string/disable_nsfw" />
<SwitchPreferenceCompat
android:defaultValue="true"
android:key="tags_warnings"
android:summary="@string/tags_warnings_summary"
android:title="@string/tags_warnings" />
<ListPreference
android:entries="@array/incognito_nsfw_options"
android:key="incognito_nsfw"
android:title="@string/incognito_for_nsfw"
app:useSimpleSummaryProvider="true" />
<SwitchPreferenceCompat
android:key="handle_links"
android:persistent="false"
android:summary="@string/handle_links_summary"
android:title="@string/handle_links"
app:allowDividerAbove="true" />
<SwitchPreferenceCompat
android:defaultValue="true"
android:key="tags_warnings"
android:summary="@string/tags_warnings_summary"
android:title="@string/tags_warnings" />
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="mirror_switching"
android:summary="@string/mirror_switching_summary"
android:title="@string/mirror_switching"
app:allowDividerAbove="true" />
<SwitchPreferenceCompat
android:key="handle_links"
android:persistent="false"
android:summary="@string/handle_links_summary"
android:title="@string/handle_links" />
</androidx.preference.PreferenceScreen>

View File

@@ -1,64 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:title="@string/data_and_privacy">
<SwitchPreferenceCompat
android:key="protect_app"
android:persistent="false"
android:summary="@string/protect_application_summary"
android:title="@string/protect_application" />
<ListPreference
android:defaultValue="allow"
android:entries="@array/screenshots_policy"
android:key="screenshots_policy"
android:title="@string/screenshots_policy"
app:useSimpleSummaryProvider="true" />
<ListPreference
android:entries="@array/incognito_nsfw_options"
android:key="incognito_nsfw"
android:title="@string/incognito_for_nsfw"
app:useSimpleSummaryProvider="true" />
<SwitchPreferenceCompat
android:defaultValue="true"
android:key="dynamic_shortcuts"
android:summary="@string/history_shortcuts_summary"
android:title="@string/history_shortcuts" />
<MultiSelectListPreference
android:key="search_suggest_types"
android:title="@string/search_suggestions" />
<PreferenceCategory android:title="@string/backup_restore">
<Preference
android:key="backup"
android:persistent="false"
android:summary="@string/backup_information"
android:title="@string/create_backup" />
<Preference
android:key="restore"
android:persistent="false"
android:summary="@string/restore_summary"
android:title="@string/restore_backup" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.backups.ui.periodical.PeriodicalBackupSettingsFragment"
android:key="backup_periodic"
android:persistent="false"
android:title="@string/periodic_backups" />
</PreferenceCategory>
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.userdata.storage.StorageManageSettingsFragment"
android:key="storage_usage"
android:title="@string/storage_usage"
app:allowDividerAbove="true" />
</PreferenceScreen>