Action to remove read local chapters

This commit is contained in:
Koitharu
2024-03-12 18:04:45 +02:00
parent 7e581a5ed7
commit 8313d6966f
10 changed files with 252 additions and 18 deletions

View File

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

View File

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

View File

@@ -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<LocalManga?>,
) {
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<Long>,
)
}

View File

@@ -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<DialogLocalInfoBinding>() {
class LocalInfoDialog : AlertDialogFragment<DialogLocalInfoBinding>(), 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<DialogLocalInfoBinding>() {
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<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),
)
}
Toast.makeText(c, text, Toast.LENGTH_SHORT).show()
}
private fun setSegments(size: Long, available: Long) {

View File

@@ -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<ParcelableManga>(LocalInfoDialog.ARG_MANGA).manga
val isCleaningUp = MutableStateFlow(false)
val onCleanedUp = MutableEventFlow<Pair<Int, Long>>()
val path = MutableStateFlow<String?>(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()
}
}

View File

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

View File

@@ -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<ReversibleAction>()
@@ -44,6 +47,8 @@ class UserDataSettingsViewModel @Inject constructor(
val cacheSizes = EnumMap<CacheDir, MutableStateFlow<Long>>(CacheDir::class.java)
val storageUsage = MutableStateFlow<StorageUsage?>(null)
val onChaptersCleanedUp = MutableEventFlow<Pair<Int, Long>>()
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
}
}
}

View File

@@ -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">
<TextView
android:id="@+id/textView_path_label"
@@ -51,4 +52,13 @@
app:drawableStartCompat="@drawable/bg_rounded_square"
app:drawableTint="?colorSecondaryContainer" />
<com.google.android.material.chip.Chip
android:id="@+id/chip_cleanup"
style="@style/Widget.Kotatsu.Chip.Assist"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/delete_read_chapters"
app:chipIcon="@drawable/ic_delete" />
</LinearLayout>

View File

@@ -623,4 +623,9 @@
<string name="migrate_confirmation">Manga \"%1$s\" from \"%2$s\" will be replaced with \"%3$s\" from \"%4$s\" in your history and favorites (if present)</string>
<string name="manga_migration">Manga migration</string>
<string name="migration_completed">Migration completed</string>
<string name="delete_read_chapters">Delete read chapters</string>
<string name="no_chapters_deleted">No chapters have been deleted</string>
<string name="chapters_deleted_pattern">Removed %1$s, cleared %2$s</string>
<string name="delete_read_chapters_summary">Delete chapters you have already read from local storage to free up space</string>
<string name="delete_read_chapters_prompt">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</string>
</resources>

View File

@@ -83,6 +83,12 @@
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" />
</PreferenceCategory>
</PreferenceScreen>