feat: Mark duplicate read chapters as read
Some checks are pending
Build app / Build app (push) Waiting to run
Mirror Repository / mirror (push) Waiting to run

This also refactor how chapters progress are saved. Chapters' progress now save when user "flipped" the page.

Closes GH-409

Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
This commit is contained in:
Ahmad Ansori Palembani 2025-05-27 21:47:41 +07:00
parent d3050d5799
commit 850151720b
Signed by: null2264
GPG key ID: BA64F8B60AF3EFB6
7 changed files with 134 additions and 81 deletions

View file

@ -14,10 +14,12 @@ The format is simplified version of [Keep a Changelog](https://keepachangelog.co
- Add random library sort - Add random library sort
- Add the ability to save search queries - Add the ability to save search queries
- Add toggle to enable/disable hide source on swipe (@Hiirbaf) - Add toggle to enable/disable hide source on swipe (@Hiirbaf)
- Add the ability to mark duplicate read chapters as read (@AntsyLich)
### Changes ### Changes
- Temporarily disable log file - Temporarily disable log file
- Categories' header now show filtered count when you search the library when you have "Show number of items" enabled (@LeeSF03) - Categories' header now show filtered count when you search the library when you have "Show number of items" enabled (@LeeSF03)
- Chapter progress now saved everything the page is changed
### Fixes ### Fixes
- Allow users to bypass onboarding's permission step if Shizuku is installed - Allow users to bypass onboarding's permission step if Shizuku is installed

View file

@ -148,6 +148,14 @@ import eu.kanade.tachiyomi.util.view.setMessage
import eu.kanade.tachiyomi.util.view.snack import eu.kanade.tachiyomi.util.view.snack
import eu.kanade.tachiyomi.widget.doOnEnd import eu.kanade.tachiyomi.widget.doOnEnd
import eu.kanade.tachiyomi.widget.doOnStart import eu.kanade.tachiyomi.widget.doOnStart
import java.io.ByteArrayOutputStream
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.util.Collections
import java.util.Locale
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.roundToInt
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -167,13 +175,6 @@ import yokai.domain.ui.settings.ReaderPreferences
import yokai.domain.ui.settings.ReaderPreferences.LandscapeCutoutBehaviour import yokai.domain.ui.settings.ReaderPreferences.LandscapeCutoutBehaviour
import yokai.i18n.MR import yokai.i18n.MR
import yokai.util.lang.getString import yokai.util.lang.getString
import java.io.ByteArrayOutputStream
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.util.*
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.roundToInt
import android.R as AR import android.R as AR
/** /**
@ -512,7 +513,6 @@ class ReaderActivity : BaseActivity<ReaderActivityBinding>() {
} }
} }
} }
viewModel.onSaveInstanceState()
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)
} }
@ -1304,13 +1304,13 @@ class ReaderActivity : BaseActivity<ReaderActivityBinding>() {
} }
override fun onPause() { override fun onPause() {
viewModel.saveCurrentChapterReadingProgress() viewModel.flushReadTimer()
super.onPause() super.onPause()
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
viewModel.setReadStartTime() viewModel.restartReadTimer()
} }
fun reloadChapters(doublePages: Boolean, force: Boolean = false) { fun reloadChapters(doublePages: Boolean, force: Boolean = false) {

View file

@ -55,9 +55,7 @@ import eu.kanade.tachiyomi.util.system.withIOContext
import eu.kanade.tachiyomi.util.system.withUIContext import eu.kanade.tachiyomi.util.system.withUIContext
import java.util.Date import java.util.Date
import java.util.concurrent.CancellationException import java.util.concurrent.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -81,6 +79,7 @@ import yokai.domain.chapter.models.ChapterUpdate
import yokai.domain.download.DownloadPreferences import yokai.domain.download.DownloadPreferences
import yokai.domain.history.interactor.GetHistory import yokai.domain.history.interactor.GetHistory
import yokai.domain.history.interactor.UpsertHistory import yokai.domain.history.interactor.UpsertHistory
import yokai.domain.library.LibraryPreferences
import yokai.domain.manga.interactor.GetManga import yokai.domain.manga.interactor.GetManga
import yokai.domain.manga.interactor.InsertManga import yokai.domain.manga.interactor.InsertManga
import yokai.domain.manga.interactor.UpdateManga import yokai.domain.manga.interactor.UpdateManga
@ -102,6 +101,7 @@ class ReaderViewModel(
private val chapterFilter: ChapterFilter = Injekt.get(), private val chapterFilter: ChapterFilter = Injekt.get(),
private val storageManager: StorageManager = Injekt.get(), private val storageManager: StorageManager = Injekt.get(),
private val downloadPreferences: DownloadPreferences = Injekt.get(), private val downloadPreferences: DownloadPreferences = Injekt.get(),
private val libraryPreferences: LibraryPreferences = Injekt.get(),
) : ViewModel() { ) : ViewModel() {
private val getCategories: GetCategories by injectLazy() private val getCategories: GetCategories by injectLazy()
private val getChapter: GetChapter by injectLazy() private val getChapter: GetChapter by injectLazy()
@ -160,8 +160,6 @@ class ReaderViewModel(
private var chapterItems = emptyList<ReaderChapterItem>() private var chapterItems = emptyList<ReaderChapterItem>()
private var scope = CoroutineScope(Job() + Dispatchers.Default)
private var hasTrackers: Boolean = false private var hasTrackers: Boolean = false
private suspend fun checkTrackers(manga: Manga) = getTrack.awaitAllByMangaId(manga.id).isNotEmpty() private suspend fun checkTrackers(manga: Manga) = getTrack.awaitAllByMangaId(manga.id).isNotEmpty()
@ -192,24 +190,12 @@ class ReaderViewModel(
val currentChapters = state.value.viewerChapters val currentChapters = state.value.viewerChapters
if (currentChapters != null) { if (currentChapters != null) {
currentChapters.unref() currentChapters.unref()
saveReadingProgress(currentChapters.currChapter)
chapterToDownload?.let { chapterToDownload?.let {
downloadManager.addDownloadsToStartOfQueue(listOf(it)) downloadManager.addDownloadsToStartOfQueue(listOf(it))
} }
} }
} }
/**
* Called when the activity is saved and not changing configurations. It updates the database
* to persist the current progress of the active chapter.
*/
fun onSaveInstanceState() {
val currentChapter = getCurrentChapter() ?: return
viewModelScope.launchNonCancellableIO {
saveChapterProgress(currentChapter)
}
}
/** /**
* Whether this presenter is initialized yet. * Whether this presenter is initialized yet.
*/ */
@ -375,12 +361,15 @@ class ReaderViewModel(
* Called when the user changed to the given [chapter] when changing pages from the viewer. * Called when the user changed to the given [chapter] when changing pages from the viewer.
* It's used only to set this chapter as active. * It's used only to set this chapter as active.
*/ */
private suspend fun loadNewChapter(chapter: ReaderChapter) { private fun loadNewChapter(chapter: ReaderChapter) {
val loader = loader ?: return val loader = loader ?: return
Logger.d { "Loading ${chapter.chapter.url}" } viewModelScope.launchIO {
Logger.d { "Loading ${chapter.chapter.url}" }
flushReadTimer()
restartReadTimer()
withIOContext {
try { try {
loadChapter(loader, chapter) loadChapter(loader, chapter)
} catch (e: Throwable) { } catch (e: Throwable) {
@ -511,28 +500,15 @@ class ReaderViewModel(
val selectedChapter = page.chapter val selectedChapter = page.chapter
// Save last page read and mark as read if needed // Save last page read and mark as read if needed
selectedChapter.chapter.last_page_read = page.index viewModelScope.launchNonCancellableIO {
selectedChapter.chapter.pages_left = saveChapterProgress(selectedChapter, page, hasExtraPage)
(selectedChapter.pages?.size ?: page.index) - page.index
val shouldTrack = !preferences.incognitoMode().get() || hasTrackers
if (shouldTrack &&
// For double pages, check if the second to last page is doubled up
(
(selectedChapter.pages?.lastIndex == page.index && page.firstHalf != true) ||
(hasExtraPage && selectedChapter.pages?.lastIndex?.minus(1) == page.index)
)
) {
selectedChapter.chapter.read = true
updateTrackChapterAfterReading(selectedChapter)
deleteChapterIfNeeded(selectedChapter)
} }
if (selectedChapter != currentChapters.currChapter) { if (selectedChapter != currentChapters.currChapter) {
Logger.d { "Setting ${selectedChapter.chapter.url} as active" } Logger.d { "Setting ${selectedChapter.chapter.url} as active" }
saveReadingProgress(currentChapters.currChapter) loadNewChapter(selectedChapter)
setReadStartTime()
scope.launch { loadNewChapter(selectedChapter) }
} }
val pages = page.chapter.pages ?: return val pages = page.chapter.pages ?: return
val inDownloadRange = page.number.toDouble() / pages.size > 0.2 val inDownloadRange = page.number.toDouble() / pages.size > 0.2
if (inDownloadRange) { if (inDownloadRange) {
@ -620,28 +596,28 @@ class ReaderViewModel(
} }
} }
/**
* Called when reader chapter is changed in reader or when activity is paused.
*/
private fun saveReadingProgress(readerChapter: ReaderChapter) {
viewModelScope.launchNonCancellableIO {
saveChapterProgress(readerChapter)
saveChapterHistory(readerChapter)
}
}
fun saveCurrentChapterReadingProgress() = getCurrentChapter()?.let { saveReadingProgress(it) }
/** /**
* Saves this [readerChapter]'s progress (last read page and whether it's read). * Saves this [readerChapter]'s progress (last read page and whether it's read).
* If incognito mode isn't on or has at least 1 tracker * If incognito mode isn't on or has at least 1 tracker
*/ */
private suspend fun saveChapterProgress(readerChapter: ReaderChapter) { private suspend fun saveChapterProgress(readerChapter: ReaderChapter, page: ReaderPage, hasExtraPage: Boolean) {
readerChapter.requestedPage = readerChapter.chapter.last_page_read readerChapter.requestedPage = readerChapter.chapter.last_page_read
getChapter.awaitById(readerChapter.chapter.id!!)?.let { dbChapter -> getChapter.awaitById(readerChapter.chapter.id!!)?.let { dbChapter ->
readerChapter.chapter.bookmark = dbChapter.bookmark readerChapter.chapter.bookmark = dbChapter.bookmark
} }
if (!preferences.incognitoMode().get() || hasTrackers) {
val shouldTrack = !preferences.incognitoMode().get() || hasTrackers
if (shouldTrack && page.status != Page.State.ERROR) {
readerChapter.chapter.last_page_read = page.index
readerChapter.chapter.pages_left = (readerChapter.pages?.size ?: page.index) - page.index
// For double pages, check if the second to last page is doubled up
if (
(readerChapter.pages?.lastIndex == page.index && page.firstHalf != true) ||
(hasExtraPage && readerChapter.pages?.lastIndex?.minus(1) == page.index)
) {
onChapterReadComplete(readerChapter)
}
updateChapter.await( updateChapter.await(
ChapterUpdate( ChapterUpdate(
id = readerChapter.chapter.id!!, id = readerChapter.chapter.id!!,
@ -654,24 +630,57 @@ class ReaderViewModel(
} }
} }
private suspend fun onChapterReadComplete(readerChapter: ReaderChapter) {
readerChapter.chapter.read = true
updateTrackChapterAfterReading(readerChapter)
deleteChapterIfNeeded(readerChapter)
val markDuplicateAsRead = libraryPreferences.markDuplicateReadChapterAsRead().get()
.contains(LibraryPreferences.MARK_DUPLICATE_READ_CHAPTER_READ_EXISTING)
if (!markDuplicateAsRead) return
val duplicateUnreadChapters = chapterList
.mapNotNull {
val chapter = it.chapter
if (
!chapter.read &&
chapter.isRecognizedNumber &&
chapter.chapter_number == readerChapter.chapter.chapter_number
) {
ChapterUpdate(id = chapter.id!!, read = true)
} else {
null
}
}
updateChapter.awaitAll(duplicateUnreadChapters)
}
fun restartReadTimer() {
chapterReadStartTime = Date().time
}
fun flushReadTimer() {
getCurrentChapter()?.let {
viewModelScope.launchNonCancellableIO {
saveChapterHistory(it)
}
}
}
/** /**
* Saves this [readerChapter] last read history. * Saves this [readerChapter] last read history.
*/ */
private suspend fun saveChapterHistory(readerChapter: ReaderChapter) { private suspend fun saveChapterHistory(readerChapter: ReaderChapter) {
if (!preferences.incognitoMode().get()) { if (preferences.incognitoMode().get()) return
val readAt = Date().time
val sessionReadDuration = chapterReadStartTime?.let { readAt - it } ?: 0
val history = History.create(readerChapter.chapter).apply {
last_read = readAt
time_read = sessionReadDuration
}
upsertHistory.await(history)
chapterReadStartTime = null
}
}
fun setReadStartTime() { val endTime = Date().time
chapterReadStartTime = Date().time val sessionReadDuration = chapterReadStartTime?.let { endTime - it } ?: 0
val history = History.create(readerChapter.chapter).apply {
last_read = endTime
time_read = sessionReadDuration
}
upsertHistory.await(history)
chapterReadStartTime = null
} }
/** /**
@ -876,7 +885,7 @@ class ReaderViewModel(
} }
fun saveImages(firstPage: ReaderPage, secondPage: ReaderPage, isLTR: Boolean, @ColorInt bg: Int) { fun saveImages(firstPage: ReaderPage, secondPage: ReaderPage, isLTR: Boolean, @ColorInt bg: Int) {
scope.launch { viewModelScope.launch {
if (firstPage.status != Page.State.READY) return@launch if (firstPage.status != Page.State.READY) return@launch
if (secondPage.status != Page.State.READY) return@launch if (secondPage.status != Page.State.READY) return@launch
val manga = manga ?: return@launch val manga = manga ?: return@launch
@ -925,7 +934,7 @@ class ReaderViewModel(
} }
fun shareImages(firstPage: ReaderPage, secondPage: ReaderPage, isLTR: Boolean, @ColorInt bg: Int) { fun shareImages(firstPage: ReaderPage, secondPage: ReaderPage, isLTR: Boolean, @ColorInt bg: Int) {
scope.launch { viewModelScope.launch {
if (firstPage.status != Page.State.READY) return@launch if (firstPage.status != Page.State.READY) return@launch
if (secondPage.status != Page.State.READY) return@launch if (secondPage.status != Page.State.READY) return@launch
val manga = manga ?: return@launch val manga = manga ?: return@launch

View file

@ -34,6 +34,7 @@ import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import yokai.domain.category.interactor.GetCategories import yokai.domain.category.interactor.GetCategories
import yokai.domain.library.LibraryPreferences
import yokai.domain.manga.interactor.GetLibraryManga import yokai.domain.manga.interactor.GetLibraryManga
import yokai.domain.ui.UiPreferences import yokai.domain.ui.UiPreferences
import yokai.i18n.MR import yokai.i18n.MR
@ -48,6 +49,7 @@ class SettingsLibraryController : SettingsLegacyController() {
private val getCategories: GetCategories by injectLazy() private val getCategories: GetCategories by injectLazy()
private val uiPreferences: UiPreferences by injectLazy() private val uiPreferences: UiPreferences by injectLazy()
private val libraryPreferences: LibraryPreferences by injectLazy()
override fun setupPreferenceScreen(screen: PreferenceScreen) = screen.apply { override fun setupPreferenceScreen(screen: PreferenceScreen) = screen.apply {
titleRes = MR.strings.library titleRes = MR.strings.library
@ -212,12 +214,25 @@ class SettingsLibraryController : SettingsLegacyController() {
} }
preferenceCategory { preferenceCategory {
titleRes = MR.strings.chapters titleRes = MR.strings.pref_behavior
switchPreference { switchPreference {
bindTo(uiPreferences.enableChapterSwipeAction()) bindTo(uiPreferences.enableChapterSwipeAction())
titleRes = MR.strings.enable_chapter_swipe_action titleRes = MR.strings.enable_chapter_swipe_action
} }
multiSelectListPreferenceMat(activity) {
bindTo(preferences.libraryUpdateMangaRestriction())
titleRes = MR.strings.pref_mark_as_read_duplicate_read_chapter
val entries = mapOf(
MR.strings.pref_mark_as_read_duplicate_read_chapter_existing to
LibraryPreferences.MARK_DUPLICATE_READ_CHAPTER_READ_EXISTING,
MR.strings.pref_mark_as_read_duplicate_read_chapter_new to
LibraryPreferences.MARK_DUPLICATE_READ_CHAPTER_READ_NEW,
)
entriesRes = entries.keys.toTypedArray()
entryValues = entries.values.toList()
noSelectionRes = MR.strings.none
}
} }
} }
} }

View file

@ -17,6 +17,7 @@ import yokai.domain.chapter.interactor.InsertChapter
import yokai.domain.chapter.interactor.UpdateChapter import yokai.domain.chapter.interactor.UpdateChapter
import yokai.domain.chapter.models.ChapterUpdate import yokai.domain.chapter.models.ChapterUpdate
import yokai.domain.chapter.services.ChapterRecognition import yokai.domain.chapter.services.ChapterRecognition
import yokai.domain.library.LibraryPreferences
import yokai.domain.manga.interactor.UpdateManga import yokai.domain.manga.interactor.UpdateManga
import yokai.domain.manga.models.MangaUpdate import yokai.domain.manga.models.MangaUpdate
@ -39,6 +40,7 @@ suspend fun syncChaptersWithSource(
updateChapter: UpdateChapter = Injekt.get(), updateChapter: UpdateChapter = Injekt.get(),
updateManga: UpdateManga = Injekt.get(), updateManga: UpdateManga = Injekt.get(),
handler: DatabaseHandler = Injekt.get(), handler: DatabaseHandler = Injekt.get(),
libraryPreferences: LibraryPreferences = Injekt.get(),
): Pair<List<Chapter>, List<Chapter>> { ): Pair<List<Chapter>, List<Chapter>> {
if (rawSourceChapters.isEmpty()) { if (rawSourceChapters.isEmpty()) {
throw Exception("No chapters found") throw Exception("No chapters found")
@ -122,11 +124,18 @@ suspend fun syncChaptersWithSource(
return Pair(emptyList(), emptyList()) return Pair(emptyList(), emptyList())
} }
val reAdded = mutableListOf<Chapter>() val changedOrDuplicateReadUrls = mutableSetOf<String>()
val deletedChapterNumbers = TreeSet<Float>() val deletedChapterNumbers = TreeSet<Float>()
val deletedReadChapterNumbers = TreeSet<Float>() val deletedReadChapterNumbers = TreeSet<Float>()
val deletedBookmarkedChapterNumbers = TreeSet<Float>() val deletedBookmarkedChapterNumbers = TreeSet<Float>()
val readChapterNumbers = dbChapters
.asSequence()
.filter { it.read && it.isRecognizedNumber }
.map { it.chapter_number }
.toSet()
toDelete.forEach { toDelete.forEach {
if (it.read) deletedReadChapterNumbers.add(it.chapter_number) if (it.read) deletedReadChapterNumbers.add(it.chapter_number)
if (it.bookmark) deletedBookmarkedChapterNumbers.add(it.chapter_number) if (it.bookmark) deletedBookmarkedChapterNumbers.add(it.chapter_number)
@ -135,6 +144,9 @@ suspend fun syncChaptersWithSource(
val now = Date().time val now = Date().time
val markDuplicateAsRead = libraryPreferences.markDuplicateReadChapterAsRead().get()
.contains(LibraryPreferences.MARK_DUPLICATE_READ_CHAPTER_READ_NEW)
// Date fetch is set in such a way that the upper ones will have bigger value than the lower ones // Date fetch is set in such a way that the upper ones will have bigger value than the lower ones
// Sources MUST return the chapters from most to less recent, which is common. // Sources MUST return the chapters from most to less recent, which is common.
var itemCount = toAdd.size var itemCount = toAdd.size
@ -143,6 +155,11 @@ suspend fun syncChaptersWithSource(
chapter.date_fetch = now + itemCount-- chapter.date_fetch = now + itemCount--
if (chapter.chapter_number in readChapterNumbers && markDuplicateAsRead) {
changedOrDuplicateReadUrls.add(chapter.url)
chapter.read = true
}
if (!chapter.isRecognizedNumber || chapter.chapter_number !in deletedChapterNumbers) return@map chapter if (!chapter.isRecognizedNumber || chapter.chapter_number !in deletedChapterNumbers) return@map chapter
chapter.read = chapter.chapter_number in deletedReadChapterNumbers chapter.read = chapter.chapter_number in deletedReadChapterNumbers
@ -154,7 +171,7 @@ suspend fun syncChaptersWithSource(
chapter.date_fetch = it.date_fetch chapter.date_fetch = it.date_fetch
} }
reAdded.add(chapter) changedOrDuplicateReadUrls.add(chapter.url)
chapter chapter
} }
@ -192,14 +209,13 @@ suspend fun syncChaptersWithSource(
manga.last_update = Date().time manga.last_update = Date().time
updateManga.await(MangaUpdate(manga.id!!, lastUpdate = manga.last_update)) updateManga.await(MangaUpdate(manga.id!!, lastUpdate = manga.last_update))
val reAddedUrls = reAdded.map { it.url }.toHashSet()
val filteredScanlators = ChapterUtil.getScanlators(manga.filtered_scanlators).toHashSet() val filteredScanlators = ChapterUtil.getScanlators(manga.filtered_scanlators).toHashSet()
return Pair( return Pair(
updatedToAdd.filterNot { updatedToAdd.filterNot {
it.url in reAddedUrls || it.scanlator in filteredScanlators it.url in changedOrDuplicateReadUrls || it.scanlator in filteredScanlators
}, },
toDelete.filterNot { it.url in reAddedUrls }, toDelete.filterNot { it.url in changedOrDuplicateReadUrls },
) )
} }

View file

@ -4,4 +4,11 @@ import eu.kanade.tachiyomi.core.preference.PreferenceStore
class LibraryPreferences(private val preferenceStore: PreferenceStore) { class LibraryPreferences(private val preferenceStore: PreferenceStore) {
fun randomSortSeed() = preferenceStore.getInt("library_random_sort_seed", 0) fun randomSortSeed() = preferenceStore.getInt("library_random_sort_seed", 0)
fun markDuplicateReadChapterAsRead() = preferenceStore.getStringSet("mark_duplicate_read_chapter_read", emptySet())
companion object {
const val MARK_DUPLICATE_READ_CHAPTER_READ_NEW = "new"
const val MARK_DUPLICATE_READ_CHAPTER_READ_EXISTING = "existing"
}
} }

View file

@ -167,6 +167,10 @@
<string name="ungrouped">Ungrouped</string> <string name="ungrouped">Ungrouped</string>
<string name="pref_use_compose_library">Use experimental compose library</string> <string name="pref_use_compose_library">Use experimental compose library</string>
<string name="pref_behavior">Behavior</string>
<string name="pref_mark_as_read_duplicate_read_chapter">Mark duplicate read chapter as read</string>
<string name="pref_mark_as_read_duplicate_read_chapter_existing">After reading a chapter</string>
<string name="pref_mark_as_read_duplicate_read_chapter_new">After fetching new chapter</string>
<!-- Library Sort --> <!-- Library Sort -->
<string name="sort_by">Sort by</string> <string name="sort_by">Sort by</string>