Explorar o código

fix: 书签快照带书键隔离串台,toggle 只删最近一条并串行化

- ReaderBookmarkState 快照携带 bookName/bookAuthor 书键:换书后、新书
  书签流第一条到达前,上一本残留的旧快照在书键失配时不再误供给新书
- toggleForCurrentPage 不再整页删除:只删离当前阅读位置最近的一条,
  同页多条书签不会误删;用 Mutex 串行化读-查-写,快速连滑不再重复插入
- toggle 补消息页/空页守卫(与角标判定一致),避免空页 chapterPosition 抛异常
HapeLee hai 3 meses
pai
achega
cabe8ccd7f

+ 32 - 8
app/src/main/java/io/legado/app/model/ReaderBookmarkState.kt

@@ -6,31 +6,55 @@ import io.legado.app.data.entities.Bookmark
  * 当前阅读会话的书签位置快照,供渲染层同步判定「本页是否已有书签」(右上角角标)。
  * 当前阅读会话的书签位置快照,供渲染层同步判定「本页是否已有书签」(右上角角标)。
  *
  *
  * 渲染层(`PageView.setProgress`)在主线程热路径上按页查询,不能起协程查库;
  * 渲染层(`PageView.setProgress`)在主线程热路径上按页查询,不能起协程查库;
- * 由 `ReadBookViewModel` 收集 `BookmarkRepository.flowByBook` 后写入。
+ * 由 `ReadBookmarkDelegate` 收集 `BookmarkRepository.flowByBook` 后写入。
  * 只读缓存,不参与持久化——与 [ReadSessionState] 同层。
  * 只读缓存,不参与持久化——与 [ReadSessionState] 同层。
+ *
+ * 快照携带 `bookName to bookAuthor` 书键:查库/写快照都带键,键不一致视为无书签。
+ * 这样换书后、新书书签流第一条数据到达之前,上一本残留的旧快照不会误供给新书
+ * (旧键 vs 新查询键 → 失配 → 不显示角标)。
  */
  */
 object ReaderBookmarkState {
 object ReaderBookmarkState {
 
 
-    /** chapterIndex → 该章内所有书签的 chapterPos。整体替换,读侧无需加锁。 */
+    private data class Snapshot(
+        val bookName: String,
+        val bookAuthor: String,
+        /** chapterIndex → 该章内所有书签的 chapterPos。整体替换,读侧无需加锁。 */
+        val positionsByChapter: Map<Int, List<Int>>,
+    )
+
     @Volatile
     @Volatile
-    private var positionsByChapter: Map<Int, List<Int>> = emptyMap()
+    private var snapshot: Snapshot? = null
 
 
     /**
     /**
      * 判定 `[startPos, endPos)` 这段章节内位置区间是否落有书签。
      * 判定 `[startPos, endPos)` 这段章节内位置区间是否落有书签。
      *
      *
+     * @param bookName 当前书书名(`ReadBook.book.name`),须与快照书键一致
+     * @param bookAuthor 当前书作者
      * @param startPos 页首字符在章节内的位置(`TextPage.chapterPosition`)
      * @param startPos 页首字符在章节内的位置(`TextPage.chapterPosition`)
      * @param endPos 页尾之后一个字符的位置
      * @param endPos 页尾之后一个字符的位置
      */
      */
-    fun hasBookmarkInRange(chapterIndex: Int, startPos: Int, endPos: Int): Boolean {
-        val positions = positionsByChapter[chapterIndex] ?: return false
+    fun hasBookmarkInRange(
+        bookName: String,
+        bookAuthor: String,
+        chapterIndex: Int,
+        startPos: Int,
+        endPos: Int,
+    ): Boolean {
+        val snap = snapshot ?: return false
+        if (snap.bookName != bookName || snap.bookAuthor != bookAuthor) return false
+        val positions = snap.positionsByChapter[chapterIndex] ?: return false
         return positions.any { it >= startPos && it < endPos }
         return positions.any { it >= startPos && it < endPos }
     }
     }
 
 
-    fun update(bookmarks: List<Bookmark>) {
-        positionsByChapter = bookmarks.groupBy({ it.chapterIndex }, { it.chapterPos })
+    fun update(bookName: String, bookAuthor: String, bookmarks: List<Bookmark>) {
+        snapshot = Snapshot(
+            bookName = bookName,
+            bookAuthor = bookAuthor,
+            positionsByChapter = bookmarks.groupBy({ it.chapterIndex }, { it.chapterPos }),
+        )
     }
     }
 
 
     fun clear() {
     fun clear() {
-        positionsByChapter = emptyMap()
+        snapshot = null
     }
     }
 }
 }

+ 60 - 38
app/src/main/java/io/legado/app/ui/book/read/ReadBookmarkDelegate.kt

@@ -13,9 +13,13 @@ import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.distinctUntilChanged
 import kotlinx.coroutines.flow.distinctUntilChanged
 import kotlinx.coroutines.flow.flatMapLatest
 import kotlinx.coroutines.flow.flatMapLatest
 import kotlinx.coroutines.flow.flowOf
 import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
 import kotlinx.coroutines.withContext
 import kotlinx.coroutines.withContext
 import splitties.init.appCtx
 import splitties.init.appCtx
+import kotlin.math.abs
 
 
 /**
 /**
  * 书签域(R2.2 续批)。
  * 书签域(R2.2 续批)。
@@ -34,6 +38,12 @@ class ReadBookmarkDelegate(
     private val bookKey: Flow<Pair<String, String>?>,
     private val bookKey: Flow<Pair<String, String>?>,
 ) {
 ) {
 
 
+    /**
+     * 串行化下滑手势的切换:先查再写不是原子的,快速连滑会双双看到「空」而重复插入。
+     * 锁住读-查-写整段后,两次滑动退化成正确的两次 toggle(加一条再删一条),不会出现重复。
+     */
+    private val toggleMutex = Mutex()
+
     interface Host {
     interface Host {
         /** 打开/关闭书签弹层的同时收起阅读菜单。 */
         /** 打开/关闭书签弹层的同时收起阅读菜单。 */
         fun setActiveSheet(sheet: ReadBookSheet?)
         fun setActiveSheet(sheet: ReadBookSheet?)
@@ -55,13 +65,17 @@ class ReadBookmarkDelegate(
                     .distinctUntilChanged()
                     .distinctUntilChanged()
                     .flatMapLatest { key ->
                     .flatMapLatest { key ->
                         if (key == null) {
                         if (key == null) {
-                            flowOf(emptyList())
+                            flowOf(key to emptyList())
                         } else {
                         } else {
-                            bookmarkRepository.flowByBook(key.first, key.second)
+                            bookmarkRepository.flowByBook(key.first, key.second).map { key to it }
                         }
                         }
                     }
                     }
-                    .collect { bookmarks ->
-                        ReaderBookmarkState.update(bookmarks)
+                    .collect { (key, bookmarks) ->
+                        if (key == null) {
+                            ReaderBookmarkState.clear()
+                        } else {
+                            ReaderBookmarkState.update(key.first, key.second, bookmarks)
+                        }
                         host.emitEffect(ReadBookEffect.UpBookmarkBadge)
                         host.emitEffect(ReadBookEffect.UpBookmarkBadge)
                     }
                     }
             } finally {
             } finally {
@@ -71,47 +85,55 @@ class ReadBookmarkDelegate(
     }
     }
 
 
     /**
     /**
-     * 下滑手势:本页无书签则直接存一条(不弹编辑器),已有则删掉本页范围内的全部书签。
+     * 下滑手势:本页无书签则直接存一条(不弹编辑器),已有则删掉离当前阅读位置最近的一条。
      *
      *
      * 页范围取 `[页首位置, 下一页页首位置)`——末页取到章节已排版长度,
      * 页范围取 `[页首位置, 下一页页首位置)`——末页取到章节已排版长度,
      * 与 [addForCurrentPage] 存入的 `ReadBook.durChapterPos` 落点一致。
      * 与 [addForCurrentPage] 存入的 `ReadBook.durChapterPos` 落点一致。
+     * 消息页/空页与角标判定一致地跳过(页首位置取 `textLines.first()`,空页会抛异常)。
+     * 整体串行化:先查后写不是原子的,快速连滑会双双看到「空」而重复插入。
      */
      */
     fun toggleForCurrentPage() {
     fun toggleForCurrentPage() {
         scope.launch(IO) {
         scope.launch(IO) {
-            val book = ReadBook.book ?: return@launch
-            val chapter = ReadBook.curTextChapter ?: return@launch
-            val pageIndex = ReadBook.durPageIndex
-            val page = chapter.getPage(pageIndex) ?: return@launch
-            val startPos = page.chapterPosition
-            val endPos = chapter.getPage(pageIndex + 1)?.chapterPosition
-                ?: (startPos + page.charSize)
-            val existing = bookmarkRepository.getByChapterRange(
-                bookName = book.name,
-                bookAuthor = book.author,
-                chapterIndex = chapter.chapter.index,
-                startPos = startPos,
-                endPos = endPos,
-            )
-            if (existing.isEmpty()) {
-                bookmarkRepository.save(
-                    Bookmark(
-                        bookName = book.name,
-                        bookAuthor = book.author,
-                        chapterIndex = chapter.chapter.index,
-                        chapterName = chapter.title,
-                        chapterPos = ReadBook.durChapterPos,
-                        bookText = page.text.replace(BOOK_TEXT_MARKS, "").trim(),
-                        content = "",
-                    )
-                )
-                host.emitEffect(
-                    ReadBookEffect.ShowToast(appCtx.getString(R.string.bookmark_added))
-                )
-            } else {
-                bookmarkRepository.deleteAll(existing)
-                host.emitEffect(
-                    ReadBookEffect.ShowToast(appCtx.getString(R.string.bookmark_removed))
+            toggleMutex.withLock {
+                val book = ReadBook.book ?: return@withLock
+                val chapter = ReadBook.curTextChapter ?: return@withLock
+                val pageIndex = ReadBook.durPageIndex
+                val page = chapter.getPage(pageIndex) ?: return@withLock
+                if (page.isMsgPage || page.lineSize <= 0) return@withLock
+                val startPos = page.chapterPosition
+                val endPos = chapter.getPage(pageIndex + 1)?.chapterPosition
+                    ?: (startPos + page.charSize)
+                val existing = bookmarkRepository.getByChapterRange(
+                    bookName = book.name,
+                    bookAuthor = book.author,
+                    chapterIndex = chapter.chapter.index,
+                    startPos = startPos,
+                    endPos = endPos,
                 )
                 )
+                if (existing.isEmpty()) {
+                    bookmarkRepository.save(
+                        Bookmark(
+                            bookName = book.name,
+                            bookAuthor = book.author,
+                            chapterIndex = chapter.chapter.index,
+                            chapterName = chapter.title,
+                            chapterPos = ReadBook.durChapterPos,
+                            bookText = page.text.replace(BOOK_TEXT_MARKS, "").trim(),
+                            content = "",
+                        )
+                    )
+                    host.emitEffect(
+                        ReadBookEffect.ShowToast(appCtx.getString(R.string.bookmark_added))
+                    )
+                } else {
+                    // 只删离当前阅读位置最近的一条:同一页可能有多条书签,不应整页误删。
+                    val nearest = existing.minByOrNull { abs(it.chapterPos - ReadBook.durChapterPos) }
+                        ?: return@withLock
+                    bookmarkRepository.delete(nearest)
+                    host.emitEffect(
+                        ReadBookEffect.ShowToast(appCtx.getString(R.string.bookmark_removed))
+                    )
+                }
             }
             }
         }
         }
     }
     }

+ 4 - 1
app/src/main/java/io/legado/app/ui/book/read/page/PageView.kt

@@ -87,8 +87,11 @@ class PageView(
      */
      */
     fun upBookmarkBadge() {
     fun upBookmarkBadge() {
         val page = currentTextPage
         val page = currentTextPage
-        val visible = page != null && !isScroll && !page.isMsgPage && page.lineSize > 0 &&
+        val book = ReadBook.book
+        val visible = book != null && page != null && !isScroll && !page.isMsgPage && page.lineSize > 0 &&
                 ReaderBookmarkState.hasBookmarkInRange(
                 ReaderBookmarkState.hasBookmarkInRange(
+                    bookName = book.name,
+                    bookAuthor = book.author,
                     chapterIndex = page.chapterIndex,
                     chapterIndex = page.chapterIndex,
                     startPos = page.chapterPosition,
                     startPos = page.chapterPosition,
                     endPos = page.chapterPosition + page.charSize,
                     endPos = page.chapterPosition + page.charSize,

+ 31 - 16
app/src/test/java/io/legado/app/model/ReaderBookmarkStateTest.kt

@@ -13,48 +13,63 @@ class ReaderBookmarkStateTest {
         ReaderBookmarkState.clear()
         ReaderBookmarkState.clear()
     }
     }
 
 
-    private fun bookmark(chapterIndex: Int, chapterPos: Int) = Bookmark(
+    private fun bookmark(
+        bookName: String = "book",
+        bookAuthor: String = "author",
+        chapterIndex: Int,
+        chapterPos: Int,
+    ) = Bookmark(
         time = chapterIndex * 1000L + chapterPos,
         time = chapterIndex * 1000L + chapterPos,
-        bookName = "book",
-        bookAuthor = "author",
+        bookName = bookName,
+        bookAuthor = bookAuthor,
         chapterIndex = chapterIndex,
         chapterIndex = chapterIndex,
         chapterPos = chapterPos,
         chapterPos = chapterPos,
     )
     )
 
 
     @Test
     @Test
     fun `page start is inclusive and page end is exclusive`() {
     fun `page start is inclusive and page end is exclusive`() {
-        ReaderBookmarkState.update(listOf(bookmark(chapterIndex = 3, chapterPos = 200)))
+        ReaderBookmarkState.update("book", "author", listOf(bookmark(chapterIndex = 3, chapterPos = 200)))
 
 
         // 页首命中
         // 页首命中
-        assertTrue(ReaderBookmarkState.hasBookmarkInRange(3, startPos = 200, endPos = 400))
+        assertTrue(ReaderBookmarkState.hasBookmarkInRange("book", "author", 3, startPos = 200, endPos = 400))
         // 上一页的尾界不应吞掉下一页的页首书签
         // 上一页的尾界不应吞掉下一页的页首书签
-        assertFalse(ReaderBookmarkState.hasBookmarkInRange(3, startPos = 0, endPos = 200))
+        assertFalse(ReaderBookmarkState.hasBookmarkInRange("book", "author", 3, startPos = 0, endPos = 200))
         // 下一页不应命中
         // 下一页不应命中
-        assertFalse(ReaderBookmarkState.hasBookmarkInRange(3, startPos = 400, endPos = 600))
+        assertFalse(ReaderBookmarkState.hasBookmarkInRange("book", "author", 3, startPos = 400, endPos = 600))
     }
     }
 
 
     @Test
     @Test
     fun `bookmarks are isolated per chapter`() {
     fun `bookmarks are isolated per chapter`() {
-        ReaderBookmarkState.update(listOf(bookmark(chapterIndex = 3, chapterPos = 200)))
+        ReaderBookmarkState.update("book", "author", listOf(bookmark(chapterIndex = 3, chapterPos = 200)))
 
 
-        assertFalse(ReaderBookmarkState.hasBookmarkInRange(4, startPos = 200, endPos = 400))
-        assertFalse(ReaderBookmarkState.hasBookmarkInRange(2, startPos = 200, endPos = 400))
+        assertFalse(ReaderBookmarkState.hasBookmarkInRange("book", "author", 4, startPos = 200, endPos = 400))
+        assertFalse(ReaderBookmarkState.hasBookmarkInRange("book", "author", 2, startPos = 200, endPos = 400))
     }
     }
 
 
     @Test
     @Test
     fun `update replaces the previous snapshot`() {
     fun `update replaces the previous snapshot`() {
-        ReaderBookmarkState.update(listOf(bookmark(chapterIndex = 3, chapterPos = 200)))
-        ReaderBookmarkState.update(listOf(bookmark(chapterIndex = 5, chapterPos = 10)))
+        ReaderBookmarkState.update("book", "author", listOf(bookmark(chapterIndex = 3, chapterPos = 200)))
+        ReaderBookmarkState.update("book", "author", listOf(bookmark(chapterIndex = 5, chapterPos = 10)))
 
 
-        assertFalse(ReaderBookmarkState.hasBookmarkInRange(3, startPos = 200, endPos = 400))
-        assertTrue(ReaderBookmarkState.hasBookmarkInRange(5, startPos = 0, endPos = 100))
+        assertFalse(ReaderBookmarkState.hasBookmarkInRange("book", "author", 3, startPos = 200, endPos = 400))
+        assertTrue(ReaderBookmarkState.hasBookmarkInRange("book", "author", 5, startPos = 0, endPos = 100))
+    }
+
+    @Test
+    fun `book key mismatch does not serve bookmarks`() {
+        ReaderBookmarkState.update("book", "author", listOf(bookmark(chapterIndex = 3, chapterPos = 200)))
+
+        // 另一本书在旧快照到达前查询:键不一致 → 视为无书签,不串台
+        assertFalse(ReaderBookmarkState.hasBookmarkInRange("otherBook", "author", 3, startPos = 200, endPos = 400))
+        // 同书同作者 → 命中
+        assertTrue(ReaderBookmarkState.hasBookmarkInRange("book", "author", 3, startPos = 200, endPos = 400))
     }
     }
 
 
     @Test
     @Test
     fun `clear drops every bookmark`() {
     fun `clear drops every bookmark`() {
-        ReaderBookmarkState.update(listOf(bookmark(chapterIndex = 3, chapterPos = 200)))
+        ReaderBookmarkState.update("book", "author", listOf(bookmark(chapterIndex = 3, chapterPos = 200)))
         ReaderBookmarkState.clear()
         ReaderBookmarkState.clear()
 
 
-        assertFalse(ReaderBookmarkState.hasBookmarkInRange(3, startPos = 200, endPos = 400))
+        assertFalse(ReaderBookmarkState.hasBookmarkInRange("book", "author", 3, startPos = 200, endPos = 400))
     }
     }
 }
 }