Reorganize storage usage settings

This commit is contained in:
Koitharu
2025-02-03 18:15:23 +02:00
parent 008f2d705a
commit 8ca11b214c
11 changed files with 457 additions and 377 deletions

View File

@@ -745,6 +745,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_BACKUP_TG_OPEN = "backup_periodic_tg_open"
const val KEY_BACKUP_TG_TEST = "backup_periodic_tg_test"
const val KEY_CLEAR_MANGA_DATA = "manga_data_clear"
const val KEY_STORAGE_USAGE = "storage_usage"
// old keys are for migration only
private const val KEY_IMAGES_PROXY_OLD = "images_proxy"

View File

@@ -13,6 +13,7 @@ import kotlin.math.absoluteValue
object KotatsuColors {
@ColorInt
@Deprecated("")
fun segmentColor(context: Context, @AttrRes resId: Int): Int {
val colorHex = String.format("%06x", context.getThemeColor(resId))
val hue = getHue(colorHex)
@@ -21,6 +22,13 @@ object KotatsuColors {
return MaterialColors.harmonize(color, backgroundColor)
}
@ColorInt
fun segmentColorRandom(context: Context, seed: Any): Int {
val color = random(seed)
val backgroundColor = context.getThemeColor(R.attr.colorSurfaceContainerHigh)
return MaterialColors.harmonize(color, backgroundColor)
}
@ColorInt
fun random(seed: Any): Int {
val hue = (seed.hashCode() % 360).absoluteValue.toFloat()

View File

@@ -22,6 +22,7 @@ import org.koitharu.kotatsu.settings.backup.PeriodicalBackupSettingsFragment
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 javax.inject.Inject
@Reusable
@@ -38,6 +39,12 @@ class SettingsSearchHelper @Inject constructor(
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)

View File

@@ -7,17 +7,13 @@ import android.os.Bundle
import android.view.View
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.view.postDelayed
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.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.nav.router
@@ -26,14 +22,11 @@ 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.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
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.local.data.CacheDir
import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.names
import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity
@@ -48,11 +41,7 @@ class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privac
@Inject
lateinit var appShortcutManager: AppShortcutManager
@Inject
lateinit var activityRecreationHandle: ActivityRecreationHandle
private val viewModel: UserDataSettingsViewModel by viewModels()
private val loadingPrefs = HashSet<String>()
private val backupSelectCall = registerForActivityResult(
ActivityResultContracts.OpenDocument(),
@@ -73,46 +62,23 @@ class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privac
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)
bindPeriodicalBackupSummary()
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.getQuantityString(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.getQuantityString(R.plurals.items, it, it)
}
}
}
findPreference<StorageUsagePreference>("storage_usage")?.let { pref ->
viewModel.storageUsage.observe(viewLifecycleOwner, pref)
}
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 }
}
viewModel.loadingKeys.observe(viewLifecycleOwner) { keys ->
loadingPrefs.addAll(keys)
loadingPrefs.forEach { prefKey ->
findPreference<Preference>(prefKey)?.isEnabled = prefKey !in keys
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))
viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(listView))
viewModel.onChaptersCleanedUp.observeEvent(viewLifecycleOwner, ::onChaptersCleanedUp)
settings.subscribe(this)
}
@@ -123,46 +89,6 @@ class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privac
override fun onPreferenceTreeClick(preference: Preference): Boolean {
return when (preference.key) {
AppSettings.KEY_PAGES_CACHE_CLEAR -> {
viewModel.clearCache(preference.key, CacheDir.PAGES)
true
}
AppSettings.KEY_THUMBS_CACHE_CLEAR -> {
viewModel.clearCache(preference.key, CacheDir.THUMBS)
true
}
AppSettings.KEY_COOKIES_CLEAR -> {
clearCookies()
true
}
AppSettings.KEY_SEARCH_HISTORY_CLEAR -> {
clearSearchHistory()
true
}
AppSettings.KEY_HTTP_CACHE_CLEAR -> {
viewModel.clearHttpCache()
true
}
AppSettings.KEY_CHAPTERS_CLEAR -> {
cleanupChapters()
true
}
AppSettings.KEY_CLEAR_MANGA_DATA -> {
viewModel.clearMangaData()
true
}
AppSettings.KEY_UPDATES_FEED_CLEAR -> {
viewModel.clearUpdatesFeed()
true
}
AppSettings.KEY_BACKUP -> {
router.showBackupCreateDialog()
true
@@ -198,19 +124,6 @@ class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privac
findPreference<TwoStatePreference>(AppSettings.KEY_PROTECT_APP)
?.isChecked = !settings.appPassword.isNullOrEmpty()
}
AppSettings.KEY_THEME -> {
AppCompatDelegate.setDefaultNightMode(settings.theme)
}
AppSettings.KEY_COLOR_THEME,
AppSettings.KEY_THEME_AMOLED -> {
postRestart()
}
AppSettings.KEY_APP_LOCALE -> {
AppCompatDelegate.setApplicationLocales(settings.appLocales)
}
}
}
@@ -220,31 +133,6 @@ class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privac
}
}
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.getQuantityString(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 bindPeriodicalBackupSummary() {
val preference = findPreference<Preference>(AppSettings.KEY_BACKUP_PERIODICAL_ENABLED) ?: return
val entries = resources.getStringArray(R.array.backup_frequency)
@@ -258,41 +146,4 @@ class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privac
}
}
}
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()
}
private fun postRestart() {
view?.postDelayed(400) {
activityRecreationHandle.recreateAll()
}
}
}

View File

@@ -7,50 +7,19 @@ import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.runInterruptible
import okhttp3.Cache
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
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
@HiltViewModel
class UserDataSettingsViewModel @Inject constructor(
private val storageManager: LocalStorageManager,
private val httpCache: Cache,
private val searchRepository: MangaSearchRepository,
private val trackingRepository: TrackingRepository,
private val cookieJar: MutableCookieJar,
private val settings: AppSettings,
private val deleteReadChaptersUseCase: DeleteReadChaptersUseCase,
private val mangaDataRepositoryProvider: Provider<MangaDataRepository>,
) : 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 storageUsage = MutableStateFlow(-1L)
val periodicalBackupFrequency = settings.observeAsFlow(
key = AppSettings.KEY_BACKUP_PERIODICAL_ENABLED,
@@ -69,135 +38,15 @@ class UserDataSettingsViewModel @Inject constructor(
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, cache: CacheDir) {
launchJob(Dispatchers.Default) {
try {
loadingKeys.update { it + key }
storageManager.clearCache(cache)
checkNotNull(cacheSizes[cache]).value = storageManager.computeCacheSize(cache)
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))
}
}
fun clearUpdatesFeed() {
launchJob(Dispatchers.Default) {
trackingRepository.clearLogs()
feedItemsCount.value = trackingRepository.getLogsCount()
onActionDone.call(ReversibleAction(R.string.updates_feed_cleared, null))
}
}
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(),
),
)
val totalBytes = storageManager.computeCacheSize() + storageManager.computeStorageSize()
storageUsage.value = totalBytes
}.also {
storageUsageJob = it
}

View File

@@ -0,0 +1,167 @@
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.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.getQuantityString(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.getQuantityString(R.plurals.items, it, it)
}
}
}
findPreference<StorageUsagePreference>(AppSettings.KEY_STORAGE_USAGE)?.let { pref ->
viewModel.storageUsage.observe(viewLifecycleOwner, pref)
}
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)
true
}
AppSettings.KEY_HTTP_CACHE_CLEAR -> {
viewModel.clearHttpCache()
true
}
AppSettings.KEY_CHAPTERS_CLEAR -> {
cleanupChapters()
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.getQuantityString(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

@@ -0,0 +1,187 @@
package org.koitharu.kotatsu.settings.userdata.storage
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
@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>,
) : 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>>()
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, cache: CacheDir) {
launchJob(Dispatchers.Default) {
try {
loadingKeys.update { it + key }
storageManager.clearCache(cache)
checkNotNull(cacheSizes[cache]).value = storageManager.computeCacheSize(cache)
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))
}
}
fun clearUpdatesFeed() {
launchJob(Dispatchers.Default) {
trackingRepository.clearLogs()
feedItemsCount.value = trackingRepository.getLogsCount()
onActionDone.call(ReversibleAction(R.string.updates_feed_cleared, null))
}
}
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

@@ -1,4 +1,4 @@
package org.koitharu.kotatsu.settings.userdata
package org.koitharu.kotatsu.settings.userdata.storage
data class StorageUsage(
val savedManga: Item,

View File

@@ -1,7 +1,8 @@
package org.koitharu.kotatsu.settings.userdata
package org.koitharu.kotatsu.settings.userdata.storage
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Color
import android.util.AttributeSet
import androidx.annotation.StringRes
import androidx.core.widget.TextViewCompat
@@ -10,10 +11,9 @@ import androidx.preference.PreferenceViewHolder
import kotlinx.coroutines.flow.FlowCollector
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.widgets.SegmentedBarView
import org.koitharu.kotatsu.core.util.KotatsuColors
import org.koitharu.kotatsu.core.util.FileSize
import org.koitharu.kotatsu.core.util.KotatsuColors
import org.koitharu.kotatsu.databinding.PreferenceMemoryUsageBinding
import com.google.android.material.R as materialR
class StorageUsagePreference @JvmOverloads constructor(
context: Context,
@@ -34,15 +34,15 @@ class StorageUsagePreference @JvmOverloads constructor(
val binding = PreferenceMemoryUsageBinding.bind(holder.itemView)
val storageSegment = SegmentedBarView.Segment(
usage?.savedManga?.percent ?: 0f,
KotatsuColors.segmentColor(context, materialR.attr.colorPrimary),
KotatsuColors.segmentColorRandom(context, Color.BLUE),
)
val pagesSegment = SegmentedBarView.Segment(
usage?.pagesCache?.percent ?: 0f,
KotatsuColors.segmentColor(context, materialR.attr.colorSecondary),
KotatsuColors.segmentColorRandom(context, Color.GREEN),
)
val otherSegment = SegmentedBarView.Segment(
usage?.otherCache?.percent ?: 0f,
KotatsuColors.segmentColor(context, materialR.attr.colorTertiary),
KotatsuColors.segmentColorRandom(context, Color.GRAY),
)
with(binding) {

View File

@@ -0,0 +1,65 @@
<?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/storage_usage">
<org.koitharu.kotatsu.settings.userdata.storage.StorageUsagePreference android:key="storage_usage" />
<Preference
android:key="search_history_clear"
android:persistent="false"
android:summary="@string/loading_"
android:title="@string/clear_search_history"
app:allowDividerAbove="true" />
<Preference
android:key="updates_feed_clear"
android:persistent="false"
android:summary="@string/loading_"
android:title="@string/clear_updates_feed" />
<Preference
android:key="thumbs_cache_clear"
android:persistent="false"
android:summary="@string/computing_"
android:title="@string/clear_thumbs_cache" />
<Preference
android:key="pages_cache_clear"
android:persistent="false"
android:summary="@string/computing_"
android:title="@string/clear_pages_cache" />
<Preference
android:key="http_cache_clear"
android:persistent="false"
android:summary="@string/loading_"
android:title="@string/clear_network_cache" />
<Preference
android:key="manga_data_clear"
android:persistent="false"
android:summary="@string/clear_database_summary"
android:title="@string/clear_database" />
<Preference
android:key="cookies_clear"
android:persistent="false"
android:summary="@string/clear_cookies_summary"
android:title="@string/clear_cookies" />
<Preference
android:key="chapters_clear"
android:persistent="false"
android:summary="@string/delete_read_chapters_summary"
android:title="@string/delete_read_chapters"
app:allowDividerAbove="true" />
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="chapters_clear_auto"
android:summary="@string/runs_on_app_start"
android:title="@string/delete_read_chapters_auto" />
</PreferenceScreen>

View File

@@ -54,65 +54,10 @@
</PreferenceCategory>
<PreferenceCategory android:title="@string/storage_usage">
<org.koitharu.kotatsu.settings.userdata.StorageUsagePreference android:key="storage_usage" />
<Preference
android:key="search_history_clear"
android:persistent="false"
android:summary="@string/loading_"
android:title="@string/clear_search_history"
app:allowDividerAbove="true" />
<Preference
android:key="updates_feed_clear"
android:persistent="false"
android:summary="@string/loading_"
android:title="@string/clear_updates_feed" />
<Preference
android:key="thumbs_cache_clear"
android:persistent="false"
android:summary="@string/computing_"
android:title="@string/clear_thumbs_cache" />
<Preference
android:key="pages_cache_clear"
android:persistent="false"
android:summary="@string/computing_"
android:title="@string/clear_pages_cache" />
<Preference
android:key="http_cache_clear"
android:persistent="false"
android:summary="@string/loading_"
android:title="@string/clear_network_cache" />
<Preference
android:key="manga_data_clear"
android:persistent="false"
android:summary="@string/clear_database_summary"
android:title="@string/clear_database" />
<Preference
android:key="cookies_clear"
android:persistent="false"
android:summary="@string/clear_cookies_summary"
android:title="@string/clear_cookies" />
<Preference
android:key="chapters_clear"
android:persistent="false"
android:summary="@string/delete_read_chapters_summary"
android:title="@string/delete_read_chapters" />
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="chapters_clear_auto"
android:summary="@string/runs_on_app_start"
android:title="@string/delete_read_chapters_auto" />
</PreferenceCategory>
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.userdata.storage.StorageManageSettingsFragment"
android:key="storage_usage"
android:title="@string/storage_usage"
app:allowDividerAbove="true" />
</PreferenceScreen>