diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt index d95fea671..29132e098 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -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" diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/KotatsuColors.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/KotatsuColors.kt index 915afdb7c..9d758f827 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/KotatsuColors.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/KotatsuColors.kt @@ -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() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsSearchHelper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsSearchHelper.kt index d0411d663..03a444eb2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsSearchHelper.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsSearchHelper.kt @@ -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) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/UserDataSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/UserDataSettingsFragment.kt index 6cb013de8..2c9863100 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/UserDataSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/UserDataSettingsFragment.kt @@ -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() 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(AppSettings.KEY_PAGES_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.PAGES])) - findPreference(AppSettings.KEY_THUMBS_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.THUMBS])) - findPreference(AppSettings.KEY_HTTP_CACHE_CLEAR)?.bindBytesSizeSummary(viewModel.httpCacheSize) bindPeriodicalBackupSummary() - findPreference(AppSettings.KEY_SEARCH_HISTORY_CLEAR)?.let { pref -> - viewModel.searchHistoryCount.observe(viewLifecycleOwner) { - pref.summary = if (it < 0) { - view.context.getString(R.string.loading_) - } else { - pref.context.resources.getQuantityString(R.plurals.items, it, it) - } - } - } - findPreference(AppSettings.KEY_UPDATES_FEED_CLEAR)?.let { pref -> - viewModel.feedItemsCount.observe(viewLifecycleOwner) { - pref.summary = if (it < 0) { - view.context.getString(R.string.loading_) - } else { - pref.context.resources.getQuantityString(R.plurals.items, it, it) - } - } - } - findPreference("storage_usage")?.let { pref -> - viewModel.storageUsage.observe(viewLifecycleOwner, pref) - } findPreference(AppSettings.KEY_SEARCH_SUGGESTION_TYPES)?.let { pref -> pref.entryValues = SearchSuggestionType.entries.names() pref.entries = SearchSuggestionType.entries.map { pref.context.getString(it.titleResId) }.toTypedArray() pref.summaryProvider = MultiSummaryProvider(R.string.none) pref.values = settings.searchSuggestionTypes.mapToSet { it.name } } - viewModel.loadingKeys.observe(viewLifecycleOwner) { keys -> - loadingPrefs.addAll(keys) - loadingPrefs.forEach { prefKey -> - findPreference(prefKey)?.isEnabled = prefKey !in keys + findPreference(AppSettings.KEY_STORAGE_USAGE)?.let { pref -> + viewModel.storageUsage.observe(viewLifecycleOwner) { size -> + pref.summary = if (size < 0L) { + pref.context.getString(R.string.computing_) + } else { + FileSize.BYTES.format(pref.context, size) + } } } viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this)) - 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(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) { - 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) { - 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(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() - } - } - } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/UserDataSettingsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/UserDataSettingsViewModel.kt index ad46ef202..75b57ad44 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/UserDataSettingsViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/UserDataSettingsViewModel.kt @@ -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, ) : BaseViewModel() { - val onActionDone = MutableEventFlow() - val loadingKeys = MutableStateFlow(emptySet()) - - val searchHistoryCount = MutableStateFlow(-1) - val feedItemsCount = MutableStateFlow(-1) - val httpCacheSize = MutableStateFlow(-1L) - val cacheSizes = EnumMap>(CacheDir::class.java) - val storageUsage = MutableStateFlow(null) - - val onChaptersCleanedUp = MutableEventFlow>() + val 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 } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageManageSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageManageSettingsFragment.kt new file mode 100644 index 000000000..d4dac487d --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageManageSettingsFragment.kt @@ -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() + private val loadingPrefs = HashSet() + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.pref_storage) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + findPreference(AppSettings.KEY_PAGES_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.PAGES])) + findPreference(AppSettings.KEY_THUMBS_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.THUMBS])) + findPreference(AppSettings.KEY_HTTP_CACHE_CLEAR)?.bindBytesSizeSummary(viewModel.httpCacheSize) + findPreference(AppSettings.KEY_SEARCH_HISTORY_CLEAR)?.let { pref -> + viewModel.searchHistoryCount.observe(viewLifecycleOwner) { + pref.summary = if (it < 0) { + view.context.getString(R.string.loading_) + } else { + pref.context.resources.getQuantityString(R.plurals.items, it, it) + } + } + } + findPreference(AppSettings.KEY_UPDATES_FEED_CLEAR)?.let { pref -> + viewModel.feedItemsCount.observe(viewLifecycleOwner) { + pref.summary = if (it < 0) { + view.context.getString(R.string.loading_) + } else { + pref.context.resources.getQuantityString(R.plurals.items, it, it) + } + } + } + findPreference(AppSettings.KEY_STORAGE_USAGE)?.let { pref -> + viewModel.storageUsage.observe(viewLifecycleOwner, pref) + } + + viewModel.loadingKeys.observe(viewLifecycleOwner) { keys -> + loadingPrefs.addAll(keys) + loadingPrefs.forEach { prefKey -> + findPreference(prefKey)?.isEnabled = prefKey !in keys + } + } + viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this)) + viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(listView)) + viewModel.onChaptersCleanedUp.observeEvent(viewLifecycleOwner, ::onChaptersCleanedUp) + } + + override fun onPreferenceTreeClick(preference: Preference): Boolean = when (preference.key) { + AppSettings.KEY_COOKIES_CLEAR -> { + clearCookies() + true + } + + AppSettings.KEY_SEARCH_HISTORY_CLEAR -> { + clearSearchHistory() + true + } + + AppSettings.KEY_PAGES_CACHE_CLEAR -> { + viewModel.clearCache(preference.key, CacheDir.PAGES) + true + } + + AppSettings.KEY_THUMBS_CACHE_CLEAR -> { + viewModel.clearCache(preference.key, CacheDir.THUMBS) + 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) { + 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) { + stateFlow.observe(viewLifecycleOwner) { size -> + summary = if (size < 0) { + context.getString(R.string.computing_) + } else { + FileSize.BYTES.format(context, size) + } + } + } + + private fun clearSearchHistory() { + MaterialAlertDialogBuilder(context ?: return) + .setTitle(R.string.clear_search_history) + .setMessage(R.string.text_clear_search_history_prompt) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.clear) { _, _ -> + viewModel.clearSearchHistory() + }.show() + } + + private fun clearCookies() { + MaterialAlertDialogBuilder(context ?: return) + .setTitle(R.string.clear_cookies) + .setMessage(R.string.text_clear_cookies_prompt) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.clear) { _, _ -> + viewModel.clearCookies() + }.show() + } + + private fun cleanupChapters() { + MaterialAlertDialogBuilder(context ?: return) + .setTitle(R.string.delete_read_chapters) + .setMessage(R.string.delete_read_chapters_prompt) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.delete) { _, _ -> + viewModel.cleanupChapters() + }.show() + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageManageSettingsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageManageSettingsViewModel.kt new file mode 100644 index 000000000..6ab399c35 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageManageSettingsViewModel.kt @@ -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, +) : BaseViewModel() { + + val onActionDone = MutableEventFlow() + val loadingKeys = MutableStateFlow(emptySet()) + + val searchHistoryCount = MutableStateFlow(-1) + val feedItemsCount = MutableStateFlow(-1) + val httpCacheSize = MutableStateFlow(-1L) + val cacheSizes = EnumMap>(CacheDir::class.java) + val storageUsage = MutableStateFlow(null) + + val onChaptersCleanedUp = MutableEventFlow>() + + 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 + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/StorageUsage.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageUsage.kt similarity index 77% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/StorageUsage.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageUsage.kt index 6ed2e4748..2b70279c2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/StorageUsage.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageUsage.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.settings.userdata +package org.koitharu.kotatsu.settings.userdata.storage data class StorageUsage( val savedManga: Item, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/StorageUsagePreference.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageUsagePreference.kt similarity index 89% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/StorageUsagePreference.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageUsagePreference.kt index 6f12d7069..6e993f580 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/StorageUsagePreference.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageUsagePreference.kt @@ -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) { diff --git a/app/src/main/res/xml/pref_storage.xml b/app/src/main/res/xml/pref_storage.xml new file mode 100644 index 000000000..87fb9bf24 --- /dev/null +++ b/app/src/main/res/xml/pref_storage.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/xml/pref_user_data.xml b/app/src/main/res/xml/pref_user_data.xml index ecac847a0..529a902e3 100644 --- a/app/src/main/res/xml/pref_user_data.xml +++ b/app/src/main/res/xml/pref_user_data.xml @@ -54,65 +54,10 @@ - - - - - - - - - - - - - - - - - - - - - - - +