Просмотр исходного кода

fix: 修复媒体进度条灰化与朗读定时结束后章节回退 (#2324)

* Bump web v3.26.092618

* fix: 修复媒体进度条灰化与朗读定时结束后章节回退
lingran7031 1 месяц назад
Родитель
Сommit
52cb79c536
23 измененных файлов с 916 добавлено и 36 удалено
  1. 0 0
      app/src/main/assets/web/vue/assets/BookChapter-C_r10bjD.css
  2. 0 0
      app/src/main/assets/web/vue/assets/BookChapter-TY5kYBzr.css
  3. 0 0
      app/src/main/assets/web/vue/assets/BookChapter-yBwb9Kte.js
  4. 0 0
      app/src/main/assets/web/vue/assets/BookShelf-00b2QCsd.css
  5. 0 0
      app/src/main/assets/web/vue/assets/BookShelf-Brh2uMWa.css
  6. 0 0
      app/src/main/assets/web/vue/assets/BookShelf-GcJ06Gem.js
  7. 0 0
      app/src/main/assets/web/vue/assets/index-CpkaNQMA.css
  8. 0 0
      app/src/main/assets/web/vue/assets/index-CrxHVQK7.css
  9. 0 1
      app/src/main/assets/web/vue/assets/index-D6gPu1yr.js
  10. 2 2
      app/src/main/assets/web/vue/index.html
  11. 3 0
      app/src/main/java/io/legado/app/data/dao/BookDao.kt
  12. 14 0
      app/src/main/java/io/legado/app/feature/reader/core/navigation/ReaderPageNavigator.kt
  13. 19 6
      app/src/main/java/io/legado/app/model/ReadBook.kt
  14. 100 4
      app/src/main/java/io/legado/app/service/BaseReadAloudService.kt
  15. 29 7
      app/src/main/java/io/legado/app/service/HttpReadAloudService.kt
  16. 16 5
      app/src/main/java/io/legado/app/service/TTSReadAloudService.kt
  17. 10 11
      app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt
  18. 82 0
      app/src/test/java/io/legado/app/feature/reader/core/navigation/ReaderPageNavigatorTest.kt
  19. 102 0
      app/src/test/java/io/legado/app/model/ReadBookProgressUploadTest.kt
  20. 217 0
      app/src/test/java/io/legado/app/service/ReadAloudChapterStopProgressTest.kt
  21. 205 0
      app/src/test/java/io/legado/app/service/ReadAloudMediaSeekServiceTest.kt
  22. 85 0
      app/src/test/java/io/legado/app/service/ReadAloudMediaSeekTest.kt
  23. 32 0
      app/src/test/java/io/legado/app/support/InMemoryAppDatabaseFixture.kt

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
app/src/main/assets/web/vue/assets/BookChapter-C_r10bjD.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
app/src/main/assets/web/vue/assets/BookChapter-TY5kYBzr.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
app/src/main/assets/web/vue/assets/BookChapter-yBwb9Kte.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
app/src/main/assets/web/vue/assets/BookShelf-00b2QCsd.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
app/src/main/assets/web/vue/assets/BookShelf-Brh2uMWa.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
app/src/main/assets/web/vue/assets/BookShelf-GcJ06Gem.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
app/src/main/assets/web/vue/assets/index-CpkaNQMA.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
app/src/main/assets/web/vue/assets/index-CrxHVQK7.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 1
app/src/main/assets/web/vue/assets/index-D6gPu1yr.js


+ 2 - 2
app/src/main/assets/web/vue/index.html

@@ -4,10 +4,10 @@
         <meta charset="UTF-8" />
         <link rel="icon" href="./favicon.ico" />
     <meta name="viewport" content="width=device-width,initial-scale=1.0" />
-      <script type="module" crossorigin src="./assets/index-D-9cumFp.js"></script>
+      <script type="module" crossorigin src="./assets/index-D6gPu1yr.js"></script>
       <link rel="modulepreload" crossorigin href="./assets/vendor-DQqeohSF.js">
       <link rel="stylesheet" crossorigin href="./assets/vendor-CXe1BRiH.css">
-      <link rel="stylesheet" crossorigin href="./assets/index-CpkaNQMA.css">
+      <link rel="stylesheet" crossorigin href="./assets/index-CrxHVQK7.css">
     </head>
 
     <body>

+ 3 - 0
app/src/main/java/io/legado/app/data/dao/BookDao.kt

@@ -832,6 +832,9 @@ interface BookDao {
     )
     fun upReadProgress(bookUrl: String, durChapterIndex: Int, durChapterPos: Int, durChapterTime: Long)
 
+    @Query("UPDATE books SET syncTime = :syncTime WHERE bookUrl = :bookUrl AND syncTime < :syncTime")
+    fun upSyncTime(bookUrl: String, syncTime: Long)
+
     @Query("update books set `group` = :newGroupId where `group` = :oldGroupId")
     fun upGroup(oldGroupId: Long, newGroupId: Long)
 

+ 14 - 0
app/src/main/java/io/legado/app/feature/reader/core/navigation/ReaderPageNavigator.kt

@@ -2,6 +2,7 @@ package io.legado.app.feature.reader.core.navigation
 
 import io.legado.app.feature.reader.core.model.ReaderElement
 import io.legado.app.feature.reader.core.model.ReaderPage
+import io.legado.app.feature.reader.core.model.ReaderPageId
 import io.legado.app.feature.reader.core.model.ReaderPageWindow
 import io.legado.app.feature.reader.core.navigation.ReaderPageNavigator.locate
 
@@ -119,6 +120,19 @@ object ReaderPageNavigator {
     fun locate(pages: List<ReaderPage>, chapterIndex: Int, chapterPosition: Int): Int =
         locateOrNull(pages, chapterIndex, chapterPosition) ?: 0
 
+    /**
+     * Pagination may finish for a chapter that is no longer current. Follow the reading
+     * anchor, or keep the previously visible page while the current chapter is still loading.
+     * Page identity is required for the fallback: inserting preceding pages changes indexes.
+     */
+    fun locateAfterPagination(
+        pages: List<ReaderPage>,
+        chapterIndex: Int,
+        chapterPosition: Int,
+        previousPageId: ReaderPageId?,
+    ): Int? = locateOrNull(pages, chapterIndex, chapterPosition)
+        ?: previousPageId?.let { id -> pages.indexOfFirst { it.id == id }.takeIf { it >= 0 } }
+
     fun chapterPosition(pages: List<ReaderPage>, pageIndex: Int): ReaderChapterPagePosition? {
         val page = pages.getOrNull(pageIndex) ?: return null
         val chapterIndex = page.id.chapterIndex

+ 19 - 6
app/src/main/java/io/legado/app/model/ReadBook.kt

@@ -781,13 +781,24 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     }
 
     fun uploadProgress(toast: Boolean = false, successAction: (() -> Unit)? = null) {
-        book?.let {
+        book?.let { uploadingBook ->
             launch(IO) {
-                AppWebDav.uploadBookProgress(it, toast) {
+                AppWebDav.uploadBookProgress(uploadingBook, toast) {
+                    ensureActive()
+                    onProgressUploaded(uploadingBook)
                     successAction?.invoke()
                 }
-                ensureActive()
-                it.update()
+            }
+        }
+    }
+
+    /** 上传期间可能重入阅读器并替换 Book 实例,完成时只能保存上传实际修改的同步时间。 */
+    internal fun onProgressUploaded(uploadedBook: Book) {
+        val syncTime = uploadedBook.syncTime
+        appDb.bookDao.upSyncTime(uploadedBook.bookUrl, syncTime)
+        synchronized(this) {
+            book?.takeIf { it.bookUrl == uploadedBook.bookUrl }?.let { currentBook ->
+                currentBook.syncTime = maxOf(currentBook.syncTime, syncTime)
             }
         }
     }
@@ -814,8 +825,10 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
             ) {
                 // 服务器没有进度或者进度比服务器快,上传现有进度
                 Coroutine.async {
-                    AppWebDav.uploadBookProgress(book, onSuccess = uploadSuccessAction)
-                    book.update()
+                    AppWebDav.uploadBookProgress(book) {
+                        onProgressUploaded(book)
+                        uploadSuccessAction?.invoke()
+                    }
                 }
             } else if (progress.durChapterIndex > book.durChapterIndex ||
                 progress.durChapterPos > book.durChapterPos

+ 100 - 4
app/src/main/java/io/legado/app/service/BaseReadAloudService.kt

@@ -174,6 +174,7 @@ abstract class BaseReadAloudService : BaseService(),
                     or PlaybackStateCompat.ACTION_PAUSE
                     or PlaybackStateCompat.ACTION_PLAY_PAUSE
                     or PlaybackStateCompat.ACTION_STOP
+                    or PlaybackStateCompat.ACTION_SEEK_TO
                     or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
                     or PlaybackStateCompat.ACTION_SKIP_TO_NEXT)
 
@@ -478,6 +479,7 @@ abstract class BaseReadAloudService : BaseService(),
                 val target = preparedParagraphs[preparedNowSpeak]
                 pos = (preparedReadAloudNumber - target.chapterPosition)
                     .coerceIn(0, target.text.length)
+                preparedReadAloudNumber = target.chapterPosition
             }
             var preparedParagraphStartPos = pos
             if (usePreparedPlaybackQueue) {
@@ -521,7 +523,7 @@ abstract class BaseReadAloudService : BaseService(),
             nowSpeak = preparedNowSpeak
             readAloudNumber = preparedReadAloudNumber
             paragraphStartPos = preparedParagraphStartPos
-            updateReadAloudProgressSnapshot(preparedReadAloudNumber + 1)
+            updateReadAloudProgressSnapshot(preparedReadAloudNumber + preparedParagraphStartPos + 1)
             onPlaybackStateReplaced()
             if (moveToLast) toLast = false
             preparedPlaybackCursor?.takeIf { hasSpeechPlaybackQueue }?.let(::publishPlaybackInfo)
@@ -1073,6 +1075,51 @@ abstract class BaseReadAloudService : BaseService(),
     private fun currentCharsPerSecond(): Float =
         (ESTIMATED_CHARS_PER_SECOND * currentSpeechRate).coerceAtLeast(0.1f)
 
+    /** 在已准备好的本章队列中定位,保留暂停状态和章节定时,不重新分析或切换章节。 */
+    internal fun seekToMediaPosition(positionMs: Long) {
+        if (stopRequested) return
+        val chapter = readerReadAloudChapter ?: return
+        val target = resolveReadAloudMediaSeek(
+            positionMs = positionMs,
+            charsPerSecond = currentCharsPerSecond(),
+            chapterLength = chapter.chapterLength,
+            content = contentList,
+            chapterPositions = contentChapterPositions,
+        ) ?: return
+        val wasPaused = pause
+        ++prepareReadAloudGeneration
+        prepareReadAloudJob?.cancel()
+        playStop()
+        onPlaybackStateReplaced()
+        nowSpeak = target.paragraphIndex
+        paragraphStartPos = target.offset
+        readAloudNumber = target.chapterPosition - target.offset
+        playbackCursor = if (hasSpeechPlaybackQueue) {
+            ReadAloudPlaybackCursor(target.paragraphIndex, target.offset)
+        } else {
+            null
+        }
+        toLast = false
+        pageIndex = chapter.pageIndexAt(target.chapterPosition)
+        // 主动定位允许前进或后退;暂停时也必须舍弃原来冻结的媒体时间。
+        lastMediaSessionPositionMs = -1L
+        updateReadAloudProgressSnapshot(target.chapterPosition)
+        upTtsProgress(target.chapterPosition)
+        upMediaMetadata(showContent = true)
+        if (sessionStore.state.value.followReadAloudPosition &&
+            ReadBook.durChapterIndex == chapter.chapterIndex
+        ) {
+            ReadBook.syncReadAloudPage(chapter.chapterIndex, chapter.pageStart(pageIndex))
+        }
+        if (wasPaused) {
+            // HTTP 引擎恢复时重新生成目标段音频,不能恢复已作废的播放器队列。
+            pageChanged = true
+            upReadAloudNotification()
+        } else {
+            play()
+        }
+    }
+
     /**
      * 更新媒体元数据, 用于车机蓝牙显示
      * @param showContent 是否显示当前朗读内容作为歌词
@@ -1129,6 +1176,10 @@ abstract class BaseReadAloudService : BaseService(),
                 stopReadAloudService()
             }
 
+            override fun onSeekTo(pos: Long) {
+                if (androidMediaControlEnabled) seekToMediaPosition(pos)
+            }
+
             override fun onCustomAction(action: String, extras: Bundle?) {
                 if (action == ACTION_ADD_TIMER) addTimer()
             }
@@ -1466,8 +1517,18 @@ abstract class BaseReadAloudService : BaseService(),
             }
             when (decision.action) {
                 ChapterCompletionAction.STOP -> {
-                    pause = true
-                    stopReadAloudService()
+                    // 先标记停止,避免提交末页触发重排时重新启动朗读。章末不是最后一次
+                    // TTS/音频进度回调的位置:引擎可能只上报段首,或已把下一段游标归零。
+                    requestStop()
+                    if (sessionStore.state.value.followReadAloudPosition) {
+                        readerReadAloudChapter?.let { chapter ->
+                            ReadBook.syncReadAloudPage(
+                                chapterIndex = chapter.chapterIndex,
+                                chapterPos = (chapter.chapterLength - 1).coerceAtLeast(0),
+                            )
+                        }
+                    }
+                    stopSelf()
                 }
 
                 ChapterCompletionAction.ADVANCE -> {
@@ -1595,6 +1656,41 @@ internal fun estimatedReadAloudTimeMs(
     return (chars * 1000.0 / charsPerSecond).toLong()
 }
 
+internal data class ReadAloudMediaSeekPosition(
+    val paragraphIndex: Int,
+    val offset: Int,
+    val chapterPosition: Int,
+)
+
+/** 时间轴只对应正文;换行间隙落到下一段,章末落到最后一个字符,避免提交空发言。 */
+internal fun resolveReadAloudMediaSeek(
+    positionMs: Long,
+    charsPerSecond: Float,
+    chapterLength: Int,
+    content: List<String>,
+    chapterPositions: List<Int?>,
+): ReadAloudMediaSeekPosition? {
+    if (chapterLength <= 0 || !charsPerSecond.isFinite() || charsPerSecond <= 0f) return null
+    val position = (positionMs.coerceAtLeast(0) * charsPerSecond.toDouble() / 1000.0)
+        .coerceAtMost((chapterLength - 1).toDouble()).toInt()
+    var lastBodyIndex = -1
+    for (index in content.indices) {
+        val start = chapterPositions.getOrNull(index) ?: continue
+        if (content[index].isEmpty()) continue
+        lastBodyIndex = index
+        if (start + content[index].length > position) break
+    }
+    if (lastBodyIndex < 0) return null
+    val text = content[lastBodyIndex]
+    val start = chapterPositions[lastBodyIndex] ?: return null
+    var offset = (position - start).coerceIn(0, text.lastIndex)
+    // 媒体毫秒值可以落在 UTF-16 代理项中间,保留完整字符供引擎合成。
+    if (offset > 0 && text[offset].isLowSurrogate() && text[offset - 1].isHighSurrogate()) {
+        offset--
+    }
+    return ReadAloudMediaSeekPosition(lastBodyIndex, offset, start + offset)
+}
+
 /**
  * 计算推送给系统媒体播放器的进度位置。
  * 播放中保持单调不后退: 字符估算值领先时跟随估算值, 落后时按墙钟推进,
@@ -1616,7 +1712,7 @@ internal fun nextMediaSessionPositionMs(
             if (lastPosition < 0) estimate
             else lastPosition + (nowElapsedRealtime - lastUpdateElapsedRealtime)
 
-        state == paused -> lastPosition.coerceAtLeast(0)
+        state == paused -> if (lastPosition < 0) estimate else lastPosition
 
         state == playing && lastState == paused ->
             if (lastPosition < 0) estimate else lastPosition

+ 29 - 7
app/src/main/java/io/legado/app/service/HttpReadAloudService.kt

@@ -165,6 +165,7 @@ class HttpReadAloudService : BaseReadAloudService(),
     private var downloadTask: Coroutine<*>? = null
     private var preDownloadJob: Job? = null
     private var playIndexJob: Job? = null
+    private var paragraphIntervalJob: Coroutine<*>? = null
     private var downloadErrorNo: Int = 0
     private var playErrorNo = 0
     private val downloadTaskActiveLock = Mutex()
@@ -222,9 +223,12 @@ class HttpReadAloudService : BaseReadAloudService(),
     }
 
     override fun playStop() {
-        exoPlayer.stop()
+        downloadTask?.cancel()
         playIndexJob?.cancel()
         preDownloadJob?.cancel()
+        paragraphIntervalJob?.cancel()
+        exoPlayer.stop()
+        exoPlayer.clearMediaItems()
     }
 
     private fun updateNextPos(naturalCompletion: Boolean = false) {
@@ -1094,18 +1098,22 @@ class HttpReadAloudService : BaseReadAloudService(),
         playIndexJob = lifecycleScope.launch {
             if (isChapterTitleAt(nowSpeak)) return@launch
             if (exoPlayer.duration <= 0) {
-                upTtsProgress(readAloudNumber + 1)
+                upTtsProgress(readAloudNumber + paragraphStartPos + 1)
                 return@launch
             }
-            val speakTextLength = contentList[nowSpeak].length
+            val startOffset = paragraphStartPos
+            val speakTextLength = contentList[nowSpeak].length - startOffset
             if (speakTextLength <= 0) {
                 return@launch
             }
             val sleep = exoPlayer.duration / speakTextLength
-            val start = speakTextLength * exoPlayer.currentPosition / exoPlayer.duration
-            upTtsProgress(readAloudNumber + start.toInt())
+            val start = httpReadAloudParagraphOffset(
+                contentList[nowSpeak].length, startOffset,
+                exoPlayer.currentPosition, exoPlayer.duration,
+            )
+            upTtsProgress(readAloudNumber + start)
             for (i in start until contentList[nowSpeak].length) {
-                val chapterPosition = readAloudNumber + i.toInt()
+                val chapterPosition = readAloudNumber + i
                 updateReadAloudProgressSnapshot(chapterPosition)
                 if (moveToReadAloudPage(chapterPosition)) {
                     upTtsProgress(chapterPosition)
@@ -1153,7 +1161,8 @@ class HttpReadAloudService : BaseReadAloudService(),
                     exoPlayer.clearMediaItems()
                     if (!pause && !isLastParagraph) {
                         AppLog.putDebug("HttpTTS段落开始停顿: $interval 毫秒")
-                        execute {
+                        paragraphIntervalJob?.cancel()
+                        paragraphIntervalJob = execute {
                             delay(interval)
                             if (!pause) {
                                 launch(Main) {
@@ -1237,5 +1246,18 @@ class HttpReadAloudService : BaseReadAloudService(),
 
 }
 
+/** 音频仅合成定位点后的文字,其时间轴须映射到裁剪后的段落范围。 */
+internal fun httpReadAloudParagraphOffset(
+    paragraphLength: Int,
+    startOffset: Int,
+    positionMs: Long,
+    durationMs: Long,
+): Int {
+    val offset = startOffset.coerceIn(0, paragraphLength.coerceAtLeast(0))
+    if (durationMs <= 0) return offset
+    val remaining = (paragraphLength - offset).coerceAtLeast(0)
+    return offset + (remaining * (positionMs.coerceIn(0, durationMs).toDouble() / durationMs)).toInt()
+}
+
 /** 源级语速默认值, 对应 1 倍速, 与全局语速共用 0..80 的刻度 */
 private const val DEFAULT_TTS_SPEED = 5

+ 16 - 5
app/src/main/java/io/legado/app/service/TTSReadAloudService.kt

@@ -237,7 +237,10 @@ class TTSReadAloudService : BaseReadAloudService(), KoinComponent {
                 }
                 LogUtils.d(TAG, "朗读内容添加完成")
                 if (!isAddedText) {
-                    playStop()
+                    // 本协程仍需执行延迟后的章末处理,不能通过 playStop 取消自身。
+                    speakSession++
+                    needParagraphInterval = false
+                    tts.runCatching { stop() }
                     delay(1000)
                     completeCurrentChapter()
                 }
@@ -298,6 +301,8 @@ class TTSReadAloudService : BaseReadAloudService(), KoinComponent {
 
     override fun playStop() {
         speakSession++
+        speakJob?.cancel()
+        needParagraphInterval = false
         textToSpeech?.runCatching {
             stop()
         }
@@ -364,18 +369,21 @@ class TTSReadAloudService : BaseReadAloudService(), KoinComponent {
                 return
             }
             readerReadAloudChapter?.let {
+                val position = currentRangePosition(
+                    utteranceStartReadAloudNumber, 0, utteranceStartPos
+                )
                 if (contentList[nowSpeak].matches(AppPattern.notReadAloudRegex)) {
                     nextParagraph(naturalCompletion = true)
                 }
                 if (pageIndex + 1 < it.pageCount
-                    && readAloudNumber + 1 > it.pageStart(pageIndex + 1)
+                    && position + 1 > it.pageStart(pageIndex + 1)
                 ) {
                     pageIndex++
                     // This is the TTS engine advancing across a page boundary, not a user turn.
                     // Mark it so ReadBook neither detaches the session nor restarts TTS at page two.
                     withSpeechNavigation { ReadBook.moveToNextPage() }
                 }
-                upTtsProgress(readAloudNumber + 1)
+                upTtsProgress(position + 1)
                 upMediaMetadata(showContent = true)
             }
         }
@@ -401,7 +409,9 @@ class TTSReadAloudService : BaseReadAloudService(), KoinComponent {
             paragraphStartPos = utteranceStartPos + start
             if (isChapterTitleAt(nowSpeak)) return
             // 正在朗读的精确章内位置(段起点 + 段内偏移),保持 readAloudNumber 的"段起点"语义不被污染
-            val position = currentRangePosition(utteranceStartReadAloudNumber, start)
+            val position = currentRangePosition(
+                utteranceStartReadAloudNumber, start, utteranceStartPos
+            )
             updateReadAloudProgressSnapshot(position)
             val msg =
                 "onRangeStart nowSpeak:$nowSpeak pageIndex:$pageIndex utteranceId:$utteranceId start:$start end:$end frame:$frame"
@@ -493,7 +503,8 @@ internal fun nextParagraphPosition(
 internal fun currentRangePosition(
     utteranceStartPosition: Int,
     rangeStart: Int,
-): Int = utteranceStartPosition + rangeStart
+    utteranceStartOffset: Int = 0,
+): Int = utteranceStartPosition + utteranceStartOffset + rangeStart
 
 /**
  * TTS 回调只回传 utteranceId, 播放会话号必须编码进 id,

+ 10 - 11
app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt

@@ -1547,6 +1547,8 @@ class ReadBookController(
                 AppLog.putDebug("Compose reader pagination unsupported: chapter=$index reason=$reason")
             }
             updateReaderPaginationError(batch.failureReasonFor(chapterIndex))
+            val previousPageId = directReaderPageIndex
+                ?.let { directReaderPages.getOrNull(it)?.id }
             val previousPages = directReaderPages.associateBy { it.id }
             // 每个任务只负责自己那一章:其它章的页原样保留。旧 View 各章的 `TextChapter.textPages`
             // 也是各自独立累积的,一章重排不会牵动别章的页,因此这里不再需要"整窗重建"分支。
@@ -1570,17 +1572,14 @@ class ReadBookController(
             directReaderPageContexts.clear()
             directReaderChapterPageCounts =
                 directReaderPages.groupingBy { it.id.chapterIndex }.eachCount()
-            directReaderPageIndex = directReaderPages.takeIf { it.isNotEmpty() }?.let { pages ->
-                // 当前章在批次结果中缺失时 locate 会折叠成 0(全书首页):保留原下标,
-                // 由下面的 publishDirectReaderWindow 重新收敛到合法范围,避免跳回书首。
-                ReaderPageNavigator.locateOrNull(
-                    pages,
-                    chapterIndex,
-                    ReadBook.durChapterPos
-                )
-                    ?: directReaderPageIndex?.coerceIn(pages.indices)
-                    ?: 0
-            }
+            // 邻章或已经读过的章节可能晚于当前章完成排版,不能用任务的 chapterIndex
+            // 回拉可见页。当前章尚未排好时按页 ID 保留旧页,前章页数变化会使旧下标失效。
+            directReaderPageIndex = ReaderPageNavigator.locateAfterPagination(
+                pages = directReaderPages,
+                chapterIndex = ReadBook.durChapterIndex,
+                chapterPosition = ReadBook.durChapterPos,
+                previousPageId = previousPageId,
+            )
             val snapshotRange = ReadBook.durChapterIndex.let { it - 1..it + 1 }
             ReadBook.publishReaderPagination(
                 directReaderPages.groupBy { it.id.chapterIndex }.mapNotNull { (index, pages) ->

+ 82 - 0
app/src/test/java/io/legado/app/feature/reader/core/navigation/ReaderPageNavigatorTest.kt

@@ -29,6 +29,88 @@ class ReaderPageNavigatorTest {
         assertEquals(2, window.next?.id?.pageIndex)
     }
 
+    @Test
+    fun previousChapterPaginationCompletingAfterChapterAdvanceKeepsCurrentChapter() {
+        val currentPage = page(index = 1, start = 40, chapterIndex = 5)
+        val pages = listOf(
+            page(index = 0, start = 0, chapterIndex = 4),
+            page(index = 1, start = 30, chapterIndex = 4),
+            page(index = 2, start = 60, chapterIndex = 4),
+            page(index = 0, start = 0, chapterIndex = 5),
+            currentPage,
+        )
+
+        val index = ReaderPageNavigator.locateAfterPagination(
+            pages, chapterIndex = 5, chapterPosition = 55, previousPageId = currentPage.id,
+        )
+
+        assertEquals(currentPage.id, index?.let { pages[it].id })
+    }
+
+    @Test
+    fun nextChapterPaginationCompletingAfterPlaybackStopsKeepsFinishedChapter() {
+        val finishedPage = page(index = 1, start = 40, chapterIndex = 5)
+        val pages = listOf(
+            page(index = 0, start = 0, chapterIndex = 5),
+            finishedPage,
+            page(index = 0, start = 0, chapterIndex = 6),
+            page(index = 1, start = 30, chapterIndex = 6),
+        )
+
+        val index = ReaderPageNavigator.locateAfterPagination(
+            pages, chapterIndex = 5, chapterPosition = 59, previousPageId = finishedPage.id,
+        )
+
+        assertEquals(finishedPage.id, index?.let { pages[it].id })
+    }
+
+    @Test
+    fun lateCurrentChapterPaginationReplacesThePreviousChapterFallback() {
+        val previousPage = page(index = 1, start = 40, chapterIndex = 4)
+        val currentPage = page(index = 1, start = 50, chapterIndex = 5)
+        val pages = listOf(
+            page(index = 0, start = 0, chapterIndex = 4), previousPage,
+            page(index = 0, start = 0, chapterIndex = 5), currentPage,
+        )
+
+        val index = ReaderPageNavigator.locateAfterPagination(
+            pages, chapterIndex = 5, chapterPosition = 55, previousPageId = previousPage.id,
+        )
+
+        assertEquals(currentPage.id, index?.let { pages[it].id })
+    }
+
+    @Test
+    fun missingCurrentChapterKeepsVisiblePageIdentityWhenPrecedingPagesAreInserted() {
+        val visiblePage = page(index = 1, start = 40, chapterIndex = 4)
+        val pages = listOf(
+            page(index = 0, start = 0, chapterIndex = 3),
+            page(index = 1, start = 30, chapterIndex = 3),
+            page(index = 0, start = 0, chapterIndex = 4),
+            visiblePage,
+        )
+
+        val index = ReaderPageNavigator.locateAfterPagination(
+            pages, chapterIndex = 5, chapterPosition = 0, previousPageId = visiblePage.id,
+        )
+
+        assertEquals(3, index)
+        assertEquals(visiblePage.id, index?.let { pages[it].id })
+    }
+
+    @Test
+    fun missingCurrentAndPreviousPageDoesNotPublishTheFirstChapter() {
+        val pages = listOf(page(index = 0, start = 0, chapterIndex = 3))
+
+        assertNull(ReaderPageNavigator.locateAfterPagination(
+            pages, chapterIndex = 5, chapterPosition = 0,
+            previousPageId = ReaderPageId(chapterIndex = 4, pageIndex = 1),
+        ))
+        assertNull(ReaderPageNavigator.locateAfterPagination(
+            emptyList(), chapterIndex = 5, chapterPosition = 0, previousPageId = null,
+        ))
+    }
+
     @Test
     fun reportsBoundaryWithoutDroppingCurrentPage() {
         val pages = listOf(page(0, 0), page(1, 20))

+ 102 - 0
app/src/test/java/io/legado/app/model/ReadBookProgressUploadTest.kt

@@ -0,0 +1,102 @@
+package io.legado.app.model
+
+import android.app.Application
+import io.legado.app.data.appDb
+import io.legado.app.data.entities.Book
+import io.legado.app.service.BaseReadAloudService
+import io.legado.app.support.InMemoryAppDatabaseFixture
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertSame
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+import splitties.init.injectAsAppCtx
+
+/** A late upload must not write its old Book snapshot over newer Room reading progress. */
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [35])
+class ReadBookProgressUploadTest {
+
+    private lateinit var uploadingBook: Book
+    private lateinit var currentBook: Book
+    private lateinit var databaseFixture: InMemoryAppDatabaseFixture
+
+    @Before
+    fun setUp() {
+        RuntimeEnvironment.getApplication().injectAsAppCtx()
+        databaseFixture = InMemoryAppDatabaseFixture(RuntimeEnvironment.getApplication())
+        BaseReadAloudService.requestStop()
+        uploadingBook = Book(
+            bookUrl = "https://example.test/upload-progress",
+            name = "Upload progress",
+            durChapterIndex = 5,
+            durChapterPos = 100,
+            durChapterTime = 1_000,
+            syncTime = 500,
+        )
+        currentBook = uploadingBook.copy(
+            durChapterIndex = 8,
+            durChapterPos = 850,
+            durChapterTime = 2_000,
+            durChapterTitle = "Chapter 8",
+        )
+        appDb.bookDao.insert(currentBook)
+        ReadBook.replaceCurrentBook(currentBook)
+    }
+
+    @After
+    fun tearDown() {
+        try {
+            ReadBook.clearCurrentBook()
+        } finally {
+            if (::databaseFixture.isInitialized) databaseFixture.close()
+        }
+    }
+
+    @Test
+    fun lateUploadOfOldChapterOnlySavesItsSyncTime() {
+        // The network completes after the reader has replaced the original Book instance.
+        uploadingBook.syncTime = 3_000
+
+        ReadBook.onProgressUploaded(uploadingBook)
+
+        assertEquals(currentBook.copy(syncTime = 3_000), appDb.bookDao.getBook(currentBook.bookUrl))
+        assertSame(currentBook, ReadBook.book)
+        assertEquals(8, currentBook.durChapterIndex)
+        assertEquals(850, currentBook.durChapterPos)
+        assertEquals(3_000L, currentBook.syncTime)
+    }
+
+    @Test
+    fun olderUploadCompletionDoesNotDecreaseSyncTimeOrReadingProgress() {
+        uploadingBook.syncTime = 3_000
+        ReadBook.onProgressUploaded(uploadingBook)
+
+        ReadBook.onProgressUploaded(uploadingBook.copy(syncTime = 2_500))
+
+        val saved = appDb.bookDao.getBook(currentBook.bookUrl)!!
+        assertEquals(3_000L, saved.syncTime)
+        assertEquals(8, saved.durChapterIndex)
+        assertEquals(850, saved.durChapterPos)
+        assertEquals(3_000L, currentBook.syncTime)
+    }
+
+    @Test
+    fun uploadForPreviousBookDoesNotChangeTheActiveBook() {
+        val nextBook = currentBook.copy(bookUrl = "https://example.test/another-book")
+        ReadBook.replaceCurrentBook(nextBook)
+        uploadingBook.syncTime = 3_000
+
+        ReadBook.onProgressUploaded(uploadingBook)
+
+        assertSame(nextBook, ReadBook.book)
+        assertEquals(500L, nextBook.syncTime)
+        assertEquals(8, nextBook.durChapterIndex)
+        assertEquals(850, nextBook.durChapterPos)
+        assertEquals(3_000L, appDb.bookDao.getBook(currentBook.bookUrl)?.syncTime)
+    }
+}

+ 217 - 0
app/src/test/java/io/legado/app/service/ReadAloudChapterStopProgressTest.kt

@@ -0,0 +1,217 @@
+package io.legado.app.service
+
+import android.app.Application
+import android.app.PendingIntent
+import io.legado.app.data.appDb
+import io.legado.app.data.entities.Book
+import io.legado.app.domain.gateway.ReadAloudSettingsGateway
+import io.legado.app.domain.model.settings.ReadAloudSettings
+import io.legado.app.feature.reader.core.readaloud.ReaderReadAloudChapter
+import io.legado.app.model.ReadAloudSessionStore
+import io.legado.app.model.ReadBook
+import io.legado.app.support.InMemoryAppDatabaseFixture
+import kotlinx.coroutines.flow.MutableStateFlow
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.koin.core.context.startKoin
+import org.koin.core.context.stopKoin
+import org.koin.dsl.module
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.Shadows
+import org.robolectric.annotation.Config
+import splitties.init.injectAsAppCtx
+import java.util.concurrent.TimeUnit
+
+/** Exercises the real service completion -> reader progress -> Room save path. */
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [35])
+class ReadAloudChapterStopProgressTest {
+
+    private lateinit var service: CompletionService
+    private lateinit var book: Book
+    private lateinit var databaseFixture: InMemoryAppDatabaseFixture
+    private val sessionStore = ReadAloudSessionStore()
+    private var renderCount = 0
+
+    @Before
+    fun setUp() {
+        RuntimeEnvironment.getApplication().injectAsAppCtx()
+        databaseFixture = InMemoryAppDatabaseFixture(RuntimeEnvironment.getApplication())
+        startKoin {
+            modules(module {
+                single { sessionStore }
+                single<ReadAloudSettingsGateway> { TestReadAloudSettingsGateway() }
+            })
+        }
+        setServiceFlag("isRun", false)
+        setServiceFlag("stopRequested", false)
+        setServiceFlag("pause", false)
+        setReaderField("durChapterIndex", 5)
+        setReaderField("lastProgressSaveAt", 0L)
+        book = Book(
+            bookUrl = "https://example.test/read-aloud-timer",
+            name = "Timer progress",
+            durChapterIndex = 5,
+            durChapterPos = 20,
+        )
+        appDb.bookDao.insert(book)
+        ReadBook.replaceCurrentBook(book)
+        ReadBook.updateReadingPosition(20)
+        ReadBook.clearReaderPagination()
+        service = Robolectric.buildService(CompletionService::class.java).get()
+        service.readerReadAloudChapter = ReaderReadAloudChapter.create(
+            chapterIndex = 5,
+            title = "Current chapter",
+            semanticContent = "正文".repeat(100),
+            pageStarts = listOf(0, 60, 120, 180),
+        )
+        // HTTP completion resets its next-paragraph cursor; it must not become the stop anchor.
+        service.readAloudNumber = 0
+        ReadBook.renderCallBack = object : ReadBook.ReaderRenderCallback {
+            override fun upContent(
+                relativePosition: Int,
+                resetPageOffset: Boolean,
+                success: (() -> Unit)?,
+            ) {
+                assertFalse("The redraw must not restart playback", BaseReadAloudService.isRun)
+                renderCount++
+            }
+
+            override suspend fun upContentAwait(
+                relativePosition: Int,
+                resetPageOffset: Boolean,
+                success: (() -> Unit)?,
+            ) = Unit
+
+            override fun pageChanged() = Unit
+            override fun contentLoadFinish() = Unit
+            override fun upPageAnim(upRecorder: Boolean) = Unit
+            override fun cancelSelect() = Unit
+        }
+        setServiceFlag("isRun", true)
+    }
+
+    @After
+    fun tearDown() {
+        try {
+            BaseReadAloudService.requestStop()
+            awaitProgressSave()
+        } finally {
+            try {
+                ReadBook.renderCallBack = null
+                ReadBook.clearCurrentBook()
+            } finally {
+                try {
+                    if (::databaseFixture.isInitialized) databaseFixture.close()
+                } finally {
+                    stopKoin()
+                }
+            }
+        }
+    }
+
+    @Test
+    fun minuteTimerFinishesAtChapterEndAndPersistsAfterStopRequest() {
+        setTimerField("finishChapterAtIndex", 5)
+
+        service.finishChapter()
+        awaitProgressSave()
+
+        assertStoppedAtChapterEnd()
+    }
+
+    @Test
+    fun chapterQuotaFinishesAtChapterEndWithoutPaginationOrFinalProgressCallback() {
+        setTimerField("chapterQuota", 1)
+
+        service.finishChapter()
+        awaitProgressSave()
+
+        assertStoppedAtChapterEnd()
+    }
+
+    @Test
+    fun detachedReaderKeepsItsManuallySelectedPositionWhenTimerStops() {
+        sessionStore.detachReadAloudFollow()
+        setTimerField("finishChapterAtIndex", 5)
+
+        service.finishChapter()
+        awaitProgressSave()
+
+        assertTrue(Shadows.shadowOf(service).isStoppedBySelf)
+        assertEquals(20, ReadBook.durChapterPos)
+        assertEquals(20, appDb.bookDao.getBook(book.bookUrl)?.durChapterPos)
+        assertEquals(0, renderCount)
+    }
+
+    @Test
+    fun finishingAnOlderChapterDoesNotOverwriteTheNewReaderChapter() {
+        service.readerReadAloudChapter = service.readerReadAloudChapter!!.copy(chapterIndex = 4)
+        setTimerField("finishChapterAtIndex", 4)
+
+        service.finishChapter()
+        awaitProgressSave()
+
+        assertTrue(Shadows.shadowOf(service).isStoppedBySelf)
+        assertEquals(5, ReadBook.durChapterIndex)
+        assertEquals(20, ReadBook.durChapterPos)
+        assertEquals(5, appDb.bookDao.getBook(book.bookUrl)?.durChapterIndex)
+        assertEquals(0, renderCount)
+    }
+
+    private fun assertStoppedAtChapterEnd() {
+        assertTrue(Shadows.shadowOf(service).isStoppedBySelf)
+        assertFalse(BaseReadAloudService.isRun)
+        assertEquals(5, ReadBook.durChapterIndex)
+        assertEquals(199, ReadBook.durChapterPos)
+        assertEquals(199, appDb.bookDao.getBook(book.bookUrl)?.durChapterPos)
+        assertEquals(1, renderCount)
+    }
+
+    private fun awaitProgressSave() {
+        ReadBook.executor.submit {}.get(5, TimeUnit.SECONDS)
+    }
+
+    private fun setTimerField(name: String, value: Any) {
+        BaseReadAloudService::class.java.getDeclaredField(name).apply {
+            isAccessible = true
+            set(service, value)
+        }
+    }
+
+    private fun setServiceFlag(name: String, value: Boolean) {
+        BaseReadAloudService::class.java.getDeclaredField(name).apply {
+            isAccessible = true
+            setBoolean(null, value)
+        }
+    }
+
+    private fun setReaderField(name: String, value: Any) {
+        ReadBook::class.java.getDeclaredField(name).apply {
+            isAccessible = true
+            set(null, value)
+        }
+    }
+
+    class CompletionService : BaseReadAloudService() {
+        fun finishChapter() = completeCurrentChapter()
+        override fun playStop() = Unit
+        override fun upSpeechRate(reset: Boolean) = Unit
+        override fun aloudServicePendingIntent(actionStr: String): PendingIntent? = null
+    }
+
+    private class TestReadAloudSettingsGateway : ReadAloudSettingsGateway {
+        override val settings = MutableStateFlow(ReadAloudSettings())
+        override val currentSettings get() = settings.value
+        override suspend fun update(transform: (ReadAloudSettings) -> ReadAloudSettings) {
+            settings.value = transform(settings.value)
+        }
+    }
+}

+ 205 - 0
app/src/test/java/io/legado/app/service/ReadAloudMediaSeekServiceTest.kt

@@ -0,0 +1,205 @@
+package io.legado.app.service
+
+import android.app.Application
+import android.app.PendingIntent
+import android.support.v4.media.session.MediaSessionCompat
+import android.support.v4.media.session.PlaybackStateCompat
+import androidx.lifecycle.lifecycleScope
+import io.legado.app.domain.gateway.ReadAloudSettingsGateway
+import io.legado.app.domain.model.readaloud.ReadAloudSessionStatus
+import io.legado.app.domain.model.settings.ReadAloudSettings
+import io.legado.app.feature.reader.core.readaloud.ReaderReadAloudChapter
+import io.legado.app.model.ReadAloudSessionStore
+import io.legado.app.model.ReadBook
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.flow.MutableStateFlow
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.koin.core.context.startKoin
+import org.koin.core.context.stopKoin
+import org.koin.dsl.module
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+import splitties.init.injectAsAppCtx
+
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [35])
+class ReadAloudMediaSeekServiceTest {
+
+    private lateinit var service: SeekService
+    private val sessionStore = ReadAloudSessionStore()
+
+    @Before
+    fun setUp() {
+        stopKoin()
+        RuntimeEnvironment.getApplication().injectAsAppCtx()
+        startKoin {
+            modules(module {
+                single { sessionStore }
+                single<ReadAloudSettingsGateway> { TestReadAloudSettingsGateway() }
+            })
+        }
+        setStaticField("isRun", false)
+        ReadBook.clearCurrentBook()
+        ReadBook::class.java.getDeclaredField("durChapterIndex").apply {
+            isAccessible = true
+            setInt(null, 8)
+        }
+        ReadBook.updateReadingPosition(20)
+        setStaticField("isRun", true)
+        setStaticField("stopRequested", false)
+        setStaticField("pause", false)
+        setStaticField("timeMinute", 17)
+        service = Robolectric.buildService(SeekService::class.java).get()
+        service.readerReadAloudChapter = ReaderReadAloudChapter.create(
+            chapterIndex = 5,
+            title = "Reading chapter",
+            semanticContent = "abcdef\nghij",
+            pageStarts = listOf(0, 4, 7),
+        )
+        service.contentList = listOf("Reading chapter", "abcdef", "ghij")
+        setServiceField("contentChapterPositions", listOf(null, 0, 7))
+        setServiceField("finishChapterAtIndex", 5)
+        setServiceField("chapterQuota", 3)
+        setServiceField("androidMediaControlEnabled", true)
+        sessionStore.updateTimer(17)
+    }
+
+    @After
+    fun tearDown() {
+        try {
+            if (::service.isInitialized) {
+                service.lifecycleScope.cancel()
+                mediaSession().release()
+            }
+            BaseReadAloudService.requestStop()
+            setStaticField("timeMinute", 0)
+            ReadBook.stopAutoSaveSession()
+            ReadBook.clearCurrentBook()
+        } finally {
+            stopKoin()
+        }
+    }
+
+    @Test
+    fun playingSeekUsesServiceChapterAndPreservesBothTimerModes() {
+        service.seekToMediaPosition(2_000)
+
+        assertEquals(1, service.stopCount)
+        assertEquals(1, service.playCount)
+        assertEquals("hij", service.lastPlayedText)
+        assertEquals(2, service.nowSpeak)
+        assertEquals(7, service.readAloudNumber)
+        assertEquals(1, service.paragraphStartPos)
+        assertEquals(8, BaseReadAloudService.currentProgress)
+        assertEquals(5, BaseReadAloudService.currentChapterIndex)
+        assertTrue(
+            requireNotNull(mediaSession().controller.playbackState).actions and
+                PlaybackStateCompat.ACTION_SEEK_TO != 0L
+        )
+        // The visible reader is browsing a different chapter; a media seek must not move it.
+        assertEquals(8, ReadBook.durChapterIndex)
+        assertEquals(20, ReadBook.durChapterPos)
+        assertTrue(sessionStore.state.value.followReadAloudPosition)
+        assertTimersUnchanged()
+    }
+
+    @Test
+    fun pausedSeekDoesNotPlayAndResumeUsesTheNewBodyOffset() {
+        setStaticField("pause", true)
+        sessionStore.setStatus(ReadAloudSessionStatus.Paused)
+        sessionStore.detachReadAloudFollow()
+
+        service.seekToMediaPosition(500)
+
+        assertEquals(1, service.stopCount)
+        assertEquals(0, service.playCount)
+        assertTrue(BaseReadAloudService.pause)
+        assertTrue(service.pageChanged)
+        assertFalse(sessionStore.state.value.followReadAloudPosition)
+        assertEquals(2, BaseReadAloudService.currentProgress)
+        assertEquals(500L, getServiceField("lastMediaSessionPositionMs"))
+        assertEquals(ReadAloudSessionStatus.Paused, sessionStore.state.value.status)
+        assertTimersUnchanged()
+
+        service.resumeReadAloud()
+
+        assertFalse(BaseReadAloudService.pause)
+        assertEquals(1, service.playCount)
+        assertEquals("cdef", service.lastPlayedText)
+        assertEquals(8, ReadBook.durChapterIndex)
+        assertEquals(20, ReadBook.durChapterPos)
+        assertTimersUnchanged()
+    }
+
+    private fun assertTimersUnchanged() {
+        assertEquals(17, BaseReadAloudService.timeMinute)
+        assertEquals(17, sessionStore.state.value.timerMinutes)
+        assertEquals(5, getServiceField("finishChapterAtIndex"))
+        assertEquals(3, getServiceField("chapterQuota"))
+    }
+
+    private fun setStaticField(name: String, value: Any) {
+        BaseReadAloudService::class.java.getDeclaredField(name).apply {
+            isAccessible = true
+            set(null, value)
+        }
+    }
+
+    private fun setServiceField(name: String, value: Any) {
+        BaseReadAloudService::class.java.getDeclaredField(name).apply {
+            isAccessible = true
+            set(service, value)
+        }
+    }
+
+    private fun getServiceField(name: String): Any? =
+        BaseReadAloudService::class.java.getDeclaredField(name).run {
+            isAccessible = true
+            get(service)
+        }
+
+    private fun mediaSession(): MediaSessionCompat =
+        BaseReadAloudService::class.java.getDeclaredMethod("getMediaSessionCompat").run {
+            isAccessible = true
+            invoke(service) as MediaSessionCompat
+        }
+
+    class SeekService : BaseReadAloudService() {
+        var stopCount = 0
+        var playCount = 0
+        var lastPlayedText = ""
+
+        override fun playStop() {
+            stopCount++
+        }
+
+        override fun play() {
+            playCount++
+            lastPlayedText = contentList[nowSpeak].substring(paragraphStartPos)
+        }
+
+        override fun resumeReadAloud() {
+            super.resumeReadAloud()
+            play()
+        }
+
+        override fun upSpeechRate(reset: Boolean) = Unit
+        override fun aloudServicePendingIntent(actionStr: String): PendingIntent? = null
+    }
+
+    private class TestReadAloudSettingsGateway : ReadAloudSettingsGateway {
+        override val settings = MutableStateFlow(ReadAloudSettings())
+        override val currentSettings get() = settings.value
+        override suspend fun update(transform: (ReadAloudSettings) -> ReadAloudSettings) {
+            settings.value = transform(settings.value)
+        }
+    }
+}

+ 85 - 0
app/src/test/java/io/legado/app/service/ReadAloudMediaSeekTest.kt

@@ -0,0 +1,85 @@
+package io.legado.app.service
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class ReadAloudMediaSeekTest {
+
+    private val content = listOf("章节标题", "abcdef", "ghij")
+    private val chapterPositions = listOf(null, 0, 7)
+
+    @Test
+    fun seekUsesTheSameSpeechRateAsTheEstimatedTimeline() {
+        assertEquals(ReadAloudMediaSeekPosition(2, 1, 8), seek(2_000, 4f))
+        assertEquals(ReadAloudMediaSeekPosition(2, 1, 8), seek(1_000, 8f))
+        assertEquals(ReadAloudMediaSeekPosition(1, 2, 2), seek(500, 4f))
+    }
+
+    @Test
+    fun bodyTimelineDoesNotSeekIntoTheChapterTitle() {
+        assertEquals(ReadAloudMediaSeekPosition(1, 0, 0), seek(0))
+        assertEquals(ReadAloudMediaSeekPosition(1, 0, 0), seek(Long.MIN_VALUE))
+    }
+
+    @Test
+    fun newlineGapResolvesToTheNextParagraph() {
+        assertEquals(ReadAloudMediaSeekPosition(2, 0, 7), seek(1_500))
+    }
+
+    @Test
+    fun seekAtOrAfterChapterEndKeepsAPlayableCharacterInTheCurrentChapter() {
+        assertEquals(ReadAloudMediaSeekPosition(2, 3, 10), seek(2_750))
+        assertEquals(ReadAloudMediaSeekPosition(2, 3, 10), seek(Long.MAX_VALUE))
+    }
+
+    @Test
+    fun adjacentPageOrVoiceCuesDoNotInventANewlineOffset() {
+        assertEquals(
+            ReadAloudMediaSeekPosition(1, 0, 4),
+            resolveReadAloudMediaSeek(1_000, 4f, 8, listOf("abcd", "efgh"), listOf(0, 4)),
+        )
+    }
+
+    @Test
+    fun seekPreservesSurrogatePairs() {
+        assertEquals(
+            ReadAloudMediaSeekPosition(0, 0, 0),
+            resolveReadAloudMediaSeek(250, 4f, 3, listOf("\uD83D\uDE00a"), listOf(0)),
+        )
+    }
+
+    @Test
+    fun unavailableContentOrTimelineDoesNotProduceASeek() {
+        assertNull(resolveReadAloudMediaSeek(0, 4f, 0, emptyList(), emptyList()))
+        assertNull(resolveReadAloudMediaSeek(0, 4f, 4, listOf("标题"), listOf(null)))
+        assertNull(seek(1_000, 0f))
+        assertNull(seek(1_000, Float.NaN))
+    }
+
+    @Test
+    fun seekingWhilePausedReplacesTheFrozenPositionAndResumeKeepsIt() {
+        val newPosition = nextMediaSessionPositionMs(2, 2, 2_000, -1, 50_000, 40_000)
+        assertEquals(2_000L, newPosition)
+        assertEquals(2_000L, nextMediaSessionPositionMs(2, 2, 2_000, newPosition, 60_000, 50_000))
+        assertEquals(2_000L, nextMediaSessionPositionMs(3, 2, 2_000, newPosition, 70_000, 50_000))
+    }
+
+    @Test
+    fun ttsRangeStartsAtTheSeekOffsetWithinTheParagraph() {
+        assertEquals(240, currentRangePosition(200, 0, 40))
+        assertEquals(245, currentRangePosition(200, 5, 40))
+    }
+
+    @Test
+    fun httpAudioTimeMapsOnlyToTheTextSynthesizedAfterSeek() {
+        assertEquals(40, httpReadAloudParagraphOffset(100, 40, 0, 6_000))
+        assertEquals(60, httpReadAloudParagraphOffset(100, 40, 2_000, 6_000))
+        assertEquals(100, httpReadAloudParagraphOffset(100, 40, 6_000, 6_000))
+        assertEquals(40, httpReadAloudParagraphOffset(100, 40, 2_000, 0))
+    }
+
+    private fun seek(positionMs: Long, charsPerSecond: Float = 4f) = resolveReadAloudMediaSeek(
+        positionMs, charsPerSecond, 11, content, chapterPositions,
+    )
+}

+ 32 - 0
app/src/test/java/io/legado/app/support/InMemoryAppDatabaseFixture.kt

@@ -0,0 +1,32 @@
+package io.legado.app.support
+
+import android.content.Context
+import androidx.room.Room
+import io.legado.app.data.AppDatabase
+
+/** Installs a real Room database without the production asset-import callback. */
+class InMemoryAppDatabaseFixture(context: Context) : AutoCloseable {
+    val database = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
+        .allowMainThreadQueries()
+        .build()
+
+    // Legacy model/service code uses the top-level lazy appDb. Override only its cached
+    // value for this test, then restore it; no production test hook or callback is needed.
+    private val delegate = Class.forName("io.legado.app.data.AppDatabaseKt")
+        .getDeclaredField("appDb\$delegate")
+        .apply { isAccessible = true }
+        .get(null)
+    private val valueField = delegate.javaClass.getDeclaredField("_value")
+        .apply { isAccessible = true }
+    private val previousValue = synchronized(delegate) {
+        valueField.get(delegate).also { valueField.set(delegate, database) }
+    }
+
+    override fun close() {
+        try {
+            synchronized(delegate) { valueField.set(delegate, previousValue) }
+        } finally {
+            database.close()
+        }
+    }
+}

Некоторые файлы не были показаны из-за большого количества измененных файлов