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 26fcc1a4c..3c3194a21 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 @@ -27,7 +27,6 @@ import org.koitharu.kotatsu.explore.data.SourcesSortOrder import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.util.find -import org.koitharu.kotatsu.parsers.util.isNumeric import org.koitharu.kotatsu.parsers.util.mapNotNullToSet import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.reader.domain.ReaderColorFilter @@ -508,6 +507,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { const val KEY_PAGES_CACHE_CLEAR = "pages_cache_clear" const val KEY_HTTP_CACHE_CLEAR = "http_cache_clear" const val KEY_COOKIES_CLEAR = "cookies_clear" + const val KEY_CHAPTERS_CLEAR = "chapters_clear" const val KEY_THUMBS_CACHE_CLEAR = "thumbs_cache_clear" const val KEY_SEARCH_HISTORY_CLEAR = "search_history_clear" const val KEY_UPDATES_FEED_CLEAR = "updates_feed_clear" diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/View.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/View.kt index 9701ca082..196269e1a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/View.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/View.kt @@ -17,6 +17,7 @@ import androidx.recyclerview.widget.RecyclerView.ViewHolder import androidx.swiperefreshlayout.widget.CircularProgressDrawable import androidx.viewpager2.widget.ViewPager2 import com.google.android.material.button.MaterialButton +import com.google.android.material.chip.Chip import com.google.android.material.progressindicator.BaseProgressIndicator import com.google.android.material.slider.Slider import com.google.android.material.tabs.TabLayout @@ -169,3 +170,11 @@ fun MaterialButton.setProgressIcon() { icon = progressDrawable progressDrawable.start() } + +fun Chip.setProgressIcon() { + val progressDrawable = CircularProgressDrawable(context) + progressDrawable.strokeWidth = resources.resolveDp(2f) + progressDrawable.setColorSchemeColors(currentTextColor) + chipIcon = progressDrawable + progressDrawable.start() +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/DeleteReadChaptersUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/DeleteReadChaptersUseCase.kt new file mode 100644 index 000000000..c484f949c --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/domain/DeleteReadChaptersUseCase.kt @@ -0,0 +1,96 @@ +package org.koitharu.kotatsu.local.domain + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.fold +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import org.koitharu.kotatsu.core.model.findById +import org.koitharu.kotatsu.core.model.ids +import org.koitharu.kotatsu.core.model.isLocal +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.history.data.HistoryRepository +import org.koitharu.kotatsu.local.data.LocalMangaRepository +import org.koitharu.kotatsu.local.data.LocalStorageChanges +import org.koitharu.kotatsu.local.domain.model.LocalManga +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import javax.inject.Inject + +class DeleteReadChaptersUseCase @Inject constructor( + private val localMangaRepository: LocalMangaRepository, + private val historyRepository: HistoryRepository, + @LocalStorageChanges private val localStorageChanges: MutableSharedFlow, +) { + + suspend operator fun invoke(manga: Manga): Int { + val localManga = if (manga.isLocal) { + LocalManga(manga) + } else { + checkNotNull(localMangaRepository.findSavedManga(manga)) { "Cannot find local manga" } + } + val task = getDeletionTask(localManga) ?: return 0 + localMangaRepository.deleteChapters(task.manga.manga, task.chaptersIds) + emitUpdate(localManga) + return task.chaptersIds.size + } + + suspend operator fun invoke(): Int { + val list = localMangaRepository.getList(0, null) + if (list.isEmpty()) { + return 0 + } + return channelFlow { + for (manga in list) { + launch(Dispatchers.Default) { + val task = runCatchingCancellable { + getDeletionTask(LocalManga(manga)) + }.onFailure { + it.printStackTraceDebug() + }.getOrNull() + if (task != null) { + send(task) + } + } + } + }.buffer().map { + runCatchingCancellable { + localMangaRepository.deleteChapters(it.manga.manga, it.chaptersIds) + emitUpdate(it.manga) + it.chaptersIds.size + }.onFailure { + it.printStackTraceDebug() + }.getOrDefault(0) + }.fold(0) { acc, x -> acc + x } + } + + private suspend fun getDeletionTask(manga: LocalManga): DeletionTask? { + val history = historyRepository.getOne(manga.manga) ?: return null + val chapters = manga.manga.chapters ?: localMangaRepository.getDetails(manga.manga).chapters + if (chapters.isNullOrEmpty()) { + return null + } + val branch = (chapters.findById(history.chapterId) ?: return null).branch + val filteredChapters = manga.manga.getChapters(branch)?.takeWhile { it.id != history.chapterId } + return if (filteredChapters.isNullOrEmpty()) { + null + } else { + DeletionTask( + manga = manga, + chaptersIds = filteredChapters.ids(), + ) + } + } + + private suspend fun emitUpdate(subject: LocalManga) { + val updated = localMangaRepository.getDetails(subject.manga) + localStorageChanges.emit(subject.copy(manga = updated)) + } + + private class DeletionTask( + val manga: LocalManga, + val chaptersIds: Set, + ) +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/info/LocalInfoDialog.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/info/LocalInfoDialog.kt index 27afdd571..4afdb3ecb 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/info/LocalInfoDialog.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/info/LocalInfoDialog.kt @@ -3,7 +3,9 @@ package org.koitharu.kotatsu.local.ui.info import android.content.res.ColorStateList import android.os.Bundle import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup +import android.widget.Toast import androidx.core.widget.TextViewCompat import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels @@ -14,9 +16,11 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.ui.AlertDialogFragment 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.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.observeEvent +import org.koitharu.kotatsu.core.util.ext.setProgressIcon import org.koitharu.kotatsu.core.util.ext.showDistinct import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.DialogLocalInfoBinding @@ -24,14 +28,12 @@ import org.koitharu.kotatsu.parsers.model.Manga import com.google.android.material.R as materialR @AndroidEntryPoint -class LocalInfoDialog : AlertDialogFragment() { +class LocalInfoDialog : AlertDialogFragment(), View.OnClickListener { private val viewModel: LocalInfoViewModel by viewModels() override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder { - return super.onBuildDialog(builder) - .setTitle(R.string.saved_manga) - .setNegativeButton(R.string.close, null) + return super.onBuildDialog(builder).setTitle(R.string.saved_manga).setNegativeButton(R.string.close, null) } override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): DialogLocalInfoBinding { @@ -43,13 +45,44 @@ class LocalInfoDialog : AlertDialogFragment() { viewModel.path.observe(this) { binding.textViewPath.text = it } - combine(viewModel.size, viewModel.availableSize, ::Pair).observe(this) { + binding.chipCleanup.setOnClickListener(this) + combine(viewModel.size, viewModel.availableSize, ::Pair).observe(viewLifecycleOwner) { if (it.first >= 0 && it.second >= 0) { setSegments(it.first, it.second) } else { binding.barView.animateSegments(emptyList()) } } + viewModel.onCleanedUp.observeEvent(viewLifecycleOwner, ::onCleanedUp) + viewModel.isCleaningUp.observe(viewLifecycleOwner) { loading -> + binding.chipCleanup.isClickable = !loading + dialog?.setCancelable(!loading) + if (loading) { + binding.chipCleanup.setProgressIcon() + } else { + binding.chipCleanup.setChipIconResource(R.drawable.ic_delete) + } + } + } + + override fun onClick(v: View) { + when (v.id) { + R.id.chip_cleanup -> viewModel.cleanup() + } + } + + private fun onCleanedUp(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), + ) + } + Toast.makeText(c, text, Toast.LENGTH_SHORT).show() } private fun setSegments(size: Long, available: Long) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/info/LocalInfoViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/info/LocalInfoViewModel.kt index 81436b7ed..648a8383c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/info/LocalInfoViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/info/LocalInfoViewModel.kt @@ -1,6 +1,5 @@ package org.koitharu.kotatsu.local.ui.info -import androidx.core.net.toFile import androidx.core.net.toUri import androidx.lifecycle.SavedStateHandle import dagger.hilt.android.lifecycle.HiltViewModel @@ -8,12 +7,14 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.ui.widgets.SegmentedBarView +import org.koitharu.kotatsu.core.util.ext.MutableEventFlow +import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.computeSize import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.core.util.ext.toFileOrNull import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalStorageManager +import org.koitharu.kotatsu.local.domain.DeleteReadChaptersUseCase import javax.inject.Inject @HiltViewModel @@ -21,21 +22,42 @@ class LocalInfoViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val localMangaRepository: LocalMangaRepository, private val storageManager: LocalStorageManager, + private val deleteReadChaptersUseCase: DeleteReadChaptersUseCase, ) : BaseViewModel() { private val manga = savedStateHandle.require(LocalInfoDialog.ARG_MANGA).manga + val isCleaningUp = MutableStateFlow(false) + val onCleanedUp = MutableEventFlow>() + val path = MutableStateFlow(null) val size = MutableStateFlow(-1L) val availableSize = MutableStateFlow(-1L) init { - launchLoadingJob(Dispatchers.Default) { - val file = manga.url.toUri().toFileOrNull() ?: localMangaRepository.findSavedManga(manga)?.file - requireNotNull(file) - path.value = file.path - size.value = file.computeSize() - availableSize.value = storageManager.computeAvailableSize() + computeSize() + } + + fun cleanup() { + launchJob(Dispatchers.Default) { + try { + isCleaningUp.value = true + val oldSize = size.value + val chaptersCount = deleteReadChaptersUseCase.invoke(manga) + computeSize().join() + val newSize = size.value + onCleanedUp.call(chaptersCount to oldSize - newSize) + } finally { + isCleaningUp.value = false + } } } + + private fun computeSize() = launchLoadingJob(Dispatchers.Default) { + val file = manga.url.toUri().toFileOrNull() ?: localMangaRepository.findSavedManga(manga)?.file + requireNotNull(file) + path.value = file.path + size.value = file.computeSize() + availableSize.value = storageManager.computeAvailableSize() + } } 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 2e5776ebc..97eafb935 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 @@ -94,6 +94,7 @@ class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privac } viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this)) viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(listView)) + viewModel.onChaptersCleanedUp.observeEvent(viewLifecycleOwner, ::onChaptersCleanedUp) settings.subscribe(this) } @@ -129,6 +130,11 @@ class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privac true } + AppSettings.KEY_CHAPTERS_CLEAR -> { + cleanupChapters() + true + } + AppSettings.KEY_UPDATES_FEED_CLEAR -> { viewModel.clearUpdatesFeed() true @@ -191,6 +197,21 @@ 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) { @@ -235,6 +256,16 @@ class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privac }.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 6f6e4c294..533658a32 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 @@ -18,8 +18,10 @@ 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 @@ -33,6 +35,7 @@ class UserDataSettingsViewModel @Inject constructor( private val trackingRepository: TrackingRepository, private val cookieJar: MutableCookieJar, private val settings: AppSettings, + private val deleteReadChaptersUseCase: DeleteReadChaptersUseCase, ) : BaseViewModel() { val onActionDone = MutableEventFlow() @@ -44,6 +47,8 @@ class UserDataSettingsViewModel @Inject constructor( val cacheSizes = EnumMap>(CacheDir::class.java) val storageUsage = MutableStateFlow(null) + val onChaptersCleanedUp = MutableEventFlow>() + val periodicalBackupFrequency = settings.observeAsFlow( key = AppSettings.KEY_BACKUP_PERIODICAL_ENABLED, valueProducer = { isPeriodicalBackupEnabled }, @@ -133,9 +138,24 @@ class UserDataSettingsViewModel @Inject constructor( } } - private fun loadStorageUsage() { + 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 - storageUsageJob = launchJob(Dispatchers.Default) { + return launchJob(Dispatchers.Default) { prevJob?.cancelAndJoin() val pagesCacheSize = storageManager.computeCacheSize(CacheDir.PAGES) val otherCacheSize = storageManager.computeCacheSize() - pagesCacheSize @@ -160,6 +180,8 @@ class UserDataSettingsViewModel @Inject constructor( percent = (availableSpace.toDouble() / totalBytes).toFloat(), ), ) + }.also { + storageUsageJob = it } } } diff --git a/app/src/main/res/layout/dialog_local_info.xml b/app/src/main/res/layout/dialog_local_info.xml index eecb46c07..de5aab84e 100644 --- a/app/src/main/res/layout/dialog_local_info.xml +++ b/app/src/main/res/layout/dialog_local_info.xml @@ -6,7 +6,8 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" - android:padding="?dialogPreferredPadding"> + android:paddingHorizontal="?dialogPreferredPadding" + android:paddingTop="?dialogPreferredPadding"> + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0aa7ee55a..2edea8b92 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -623,4 +623,9 @@ Manga \"%1$s\" from \"%2$s\" will be replaced with \"%3$s\" from \"%4$s\" in your history and favorites (if present) Manga migration Migration completed + Delete read chapters + No chapters have been deleted + Removed %1$s, cleared %2$s + Delete chapters you have already read from local storage to free up space + This will permanently delete all chapters marked as read from your local storage. You can re-download it later, but the imported chapters may be lost forever diff --git a/app/src/main/res/xml/pref_user_data.xml b/app/src/main/res/xml/pref_user_data.xml index f8dc07234..b972a9bc3 100644 --- a/app/src/main/res/xml/pref_user_data.xml +++ b/app/src/main/res/xml/pref_user_data.xml @@ -83,6 +83,12 @@ android:summary="@string/clear_cookies_summary" android:title="@string/clear_cookies" /> + +