Kaynağa Gözat

feat(reader): 优化全文页码计算与页眉页脚展示

5151561 3 ay önce
ebeveyn
işleme
092fac2b31

+ 0 - 3
app/src/main/AndroidManifest.xml

@@ -532,9 +532,6 @@
         <service
             android:name=".service.DownloadService"
             android:foregroundServiceType="dataSync" />
-        <service
-            android:name=".service.FullBookPageService"
-            android:foregroundServiceType="dataSync" />
 
         <service
             android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"

+ 0 - 1
app/src/main/java/io/legado/app/constant/NotificationId.kt

@@ -13,7 +13,6 @@ object NotificationId {
     const val WebService = 105
     const val DownloadService = 106
     const val CheckSourceService = 107
-    const val FullBookPageService = 108
     const val Download = 10000
     const val ExportBook = 201
 

+ 58 - 53
app/src/main/java/io/legado/app/model/FullBookPaginator.kt

@@ -3,53 +3,34 @@ package io.legado.app.model
 import io.legado.app.help.book.isLocal
 import io.legado.app.help.config.CustomTipPlaceholder
 import io.legado.app.help.config.ReadBookConfig
-import io.legado.app.service.FullBookPageService
-import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
-import splitties.init.appCtx
-
-data class FullBookPaginationState(
-    val isRunning: Boolean = false,
-    val completed: Int = 0,
-    val total: Int = 0,
-)
 
 /** Optional precision producer for local books; the coordinator remains the only page-count SSOT. */
 object FullBookPaginator {
     private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
     private var job: Job? = null
     private var activeBookUrl: String? = null
-    private val _state = MutableStateFlow(FullBookPaginationState())
-    val state = _state.asStateFlow()
+    private var activeLayoutGeneration: Long = -1L
 
-    fun startIfNeeded() {
+    fun startIfNeeded(layoutGeneration: Long) {
         val bookUrl = ReadBook.book?.takeIf { it.isLocal }?.bookUrl
-        if (bookUrl == null || !isFullPagePlaceholderActive()) {
+        if (bookUrl == null || !isNeeded()) {
             stop()
             return
         }
-        if (job?.isActive == true && activeBookUrl == bookUrl) return
+        if (job?.isActive == true &&
+            activeBookUrl == bookUrl &&
+            activeLayoutGeneration == layoutGeneration
+        ) return
         stop()
         activeBookUrl = bookUrl
+        activeLayoutGeneration = layoutGeneration
         job = scope.launch {
-            _state.value = FullBookPaginationState(isRunning = true)
-            runCatching { FullBookPageService.start(appCtx) }
-            try {
-                ReadBook.paginateLocalBookPages { completed, total ->
-                    _state.update { it.copy(completed = completed, total = total) }
-                }
-            } catch (error: CancellationException) {
-                throw error
-            } finally {
-                _state.update { it.copy(isRunning = false) }
-            }
+            ReadBook.paginateLocalBookPages(layoutGeneration)
         }
     }
 
@@ -57,35 +38,59 @@ object FullBookPaginator {
         job?.cancel()
         job = null
         activeBookUrl = null
-        _state.value = FullBookPaginationState()
+        activeLayoutGeneration = -1L
     }
 
-    private fun isFullPagePlaceholderActive(): Boolean {
-        val directTips = listOf(
-            ReadBookConfig.tipHeaderLeft,
-            ReadBookConfig.tipHeaderMiddle,
-            ReadBookConfig.tipHeaderRight,
-            ReadBookConfig.tipFooterLeft,
-            ReadBookConfig.tipFooterMiddle,
-            ReadBookConfig.tipFooterRight,
-        )
-        if (directTips.any {
-                it == ReadBookConfig.tipWholeBookPage ||
-                    it == ReadBookConfig.tipWholeBookPageAndProgress
-            }
-        ) return true
-        return listOf(
-        ReadBookConfig.customTipHeaderLeft,
-        ReadBookConfig.customTipHeaderMiddle,
-        ReadBookConfig.customTipHeaderRight,
-        ReadBookConfig.customTipFooterLeft,
-        ReadBookConfig.customTipFooterMiddle,
-        ReadBookConfig.customTipFooterRight,
-    ).any { template ->
-        CustomTipPlaceholder.extractPlaceholders(template).any {
+    fun isNeeded(): Boolean = hasActiveFullBookPageTip(
+        selections = listOf(
+            FullBookPageTipSelection(
+                ReadBookConfig.tipHeaderLeft,
+                ReadBookConfig.customTipHeaderLeft,
+            ),
+            FullBookPageTipSelection(
+                ReadBookConfig.tipHeaderMiddle,
+                ReadBookConfig.customTipHeaderMiddle,
+            ),
+            FullBookPageTipSelection(
+                ReadBookConfig.tipHeaderRight,
+                ReadBookConfig.customTipHeaderRight,
+            ),
+            FullBookPageTipSelection(
+                ReadBookConfig.tipFooterLeft,
+                ReadBookConfig.customTipFooterLeft,
+            ),
+            FullBookPageTipSelection(
+                ReadBookConfig.tipFooterMiddle,
+                ReadBookConfig.customTipFooterMiddle,
+            ),
+            FullBookPageTipSelection(
+                ReadBookConfig.tipFooterRight,
+                ReadBookConfig.customTipFooterRight,
+            ),
+        ),
+        customTipValue = ReadBookConfig.tipCustom,
+        wholeBookPageTipValue = ReadBookConfig.tipWholeBookPage,
+        wholeBookPageAndProgressTipValue = ReadBookConfig.tipWholeBookPageAndProgress,
+    )
+}
+
+internal data class FullBookPageTipSelection(
+    val value: Int,
+    val customTemplate: String,
+)
+
+internal fun hasActiveFullBookPageTip(
+    selections: List<FullBookPageTipSelection>,
+    customTipValue: Int,
+    wholeBookPageTipValue: Int,
+    wholeBookPageAndProgressTipValue: Int,
+): Boolean = selections.any { selection ->
+    when (selection.value) {
+        wholeBookPageTipValue, wholeBookPageAndProgressTipValue -> true
+        customTipValue -> CustomTipPlaceholder.extractPlaceholders(selection.customTemplate).any {
             it == CustomTipPlaceholder.FULL_PAGE_INDEX.key ||
                 it == CustomTipPlaceholder.FULL_PAGE_SIZE.key
         }
-    }
+        else -> false
     }
 }

+ 40 - 14
app/src/main/java/io/legado/app/model/ReadBook.kt

@@ -149,11 +149,6 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
             LocalPageEstimateMetrics.record(metric)
             AppLog.putDebug("PageEstimate $metric")
         },
-        onStateChanged = {
-            launch {
-                callBack?.upContent(resetPageOffset = false)
-            }
-        },
     )
     //占位
     private var currentReadLength: Long = 10L
@@ -228,7 +223,13 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
 
     fun requestWholeBookPageEstimate() {
         val book = book ?: return
+        if (!FullBookPaginator.isNeeded()) {
+            FullBookPaginator.stop()
+            wholeBookPageCoordinator.clear()
+            return
+        }
         if (book.isImage || book.isPdf) {
+            FullBookPaginator.stop()
             wholeBookPageCoordinator.clear()
             return
         }
@@ -276,7 +277,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         val showChapterTitle = ReadBookConfig.titleMode != 2
         val wholeBookAverageChapterLength = book.wordCount.toContentLength()
             ?.div(chapterSize.coerceAtLeast(1))
-        wholeBookPageCoordinator.requestEstimate(
+        val layoutGeneration = wholeBookPageCoordinator.requestEstimate(
             config = config,
             bookId = bookUrl,
             fallbackContentLength = wholeBookAverageChapterLength,
@@ -311,22 +312,35 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
                 )
             }
         }
-        FullBookPaginator.startIfNeeded()
+        FullBookPaginator.startIfNeeded(layoutGeneration)
+    }
+
+    fun updateWholeBookPageDemand() {
+        if (!FullBookPaginator.isNeeded()) {
+            FullBookPaginator.stop()
+            wholeBookPageCoordinator.clear()
+            return
+        }
+        if (wholeBookPageCoordinator.hasEstimate()) {
+            FullBookPaginator.startIfNeeded(wholeBookPageCoordinator.generation)
+        } else {
+            requestWholeBookPageEstimate()
+        }
     }
 
     fun getWholeBookPageState(chapterIndex: Int, localPageIndex: Int): WholeBookPageState? =
         wholeBookPageCoordinator.getState(chapterIndex, localPageIndex)
 
-    /** Performs local-book precision pagination and feeds the existing whole-book coordinator. */
-    suspend fun paginateLocalBookPages(onProgress: (completed: Int, total: Int) -> Unit) {
+    /** Silently paginates uncached local-book chapters and feeds the whole-book coordinator. */
+    suspend fun paginateLocalBookPages(layoutGeneration: Long) {
         val book = book?.takeIf { it.isLocal && !it.isImage && !it.isPdf } ?: return
-        val generation = wholeBookPageCoordinator.generation
+        if (!wholeBookPageCoordinator.awaitInitialized(layoutGeneration)) return
         val chapters = withContext(IO) { appDb.bookChapterDao.getChapterList(book.bookUrl) }
         val processor = ContentProcessor.get(book)
         val useReplaceRule = book.getUseReplaceRule(AppConfig.replaceEnableDefault)
-        for ((completed, chapter) in chapters.withIndex()) {
+        for (chapter in chapters) {
             ensureActive()
-            onProgress(completed, chapters.size)
+            if (wholeBookPageCoordinator.isChapterExact(chapter.index, layoutGeneration)) continue
             val content = withContext(IO) { BookHelp.getContent(book, chapter) } ?: continue
             val displayTitle = chapter.getDisplayTitle(
                 processor.getTitleReplaceRules(),
@@ -334,6 +348,19 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
                 chineseConverterType = AppConfig.chineseConverterType,
             )
             val processed = processor.getContent(book, chapter, content, includeTitle = false)
+            wholeBookPageCoordinator.updateChapterContent(
+                chapterIndex = chapter.index,
+                actualLength = processed.textList
+                    .sumOf { it.length.toLong() }
+                    .coerceAtMost(Int.MAX_VALUE.toLong())
+                    .toInt(),
+                contentHash = if (book.isLocalTxt) {
+                    ChapterContentHasher.fromLocalOffsets(chapter.start, chapter.end)
+                } else {
+                    ChapterContentHasher.fromContent(content)
+                },
+                layoutGeneration = layoutGeneration,
+            )
             val textChapter = ChapterProvider.getTextChapterAsync(
                 this, book, chapter, displayTitle, processed, chapters.size,
             )
@@ -348,13 +375,12 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
                 wholeBookPageCoordinator.correctChapter(
                     chapterIndex = chapter.index,
                     realPageCount = pageCount.coerceAtLeast(1),
-                    layoutGeneration = generation,
+                    layoutGeneration = layoutGeneration,
                 )
             } finally {
                 textChapter.cancelLayout()
             }
         }
-        onProgress(chapters.size, chapters.size)
     }
 
     private fun String?.toContentLength(): Int? {

+ 0 - 64
app/src/main/java/io/legado/app/service/FullBookPageService.kt

@@ -1,64 +0,0 @@
-package io.legado.app.service
-
-import android.content.Context
-import android.content.Intent
-import androidx.core.app.NotificationCompat
-import androidx.lifecycle.lifecycleScope
-import io.legado.app.R
-import io.legado.app.base.BaseService
-import io.legado.app.constant.AppConst
-import io.legado.app.constant.IntentAction
-import io.legado.app.constant.NotificationId
-import io.legado.app.model.FullBookPaginator
-import io.legado.app.model.ReadBook
-import io.legado.app.utils.servicePendingIntent
-import kotlinx.coroutines.flow.collectLatest
-import kotlinx.coroutines.launch
-import splitties.systemservices.notificationManager
-
-class FullBookPageService : BaseService() {
-    companion object {
-        fun start(context: Context) {
-            context.startForegroundService(Intent(context, FullBookPageService::class.java))
-        }
-    }
-
-    private val notificationBuilder by lazy {
-        NotificationCompat.Builder(this, AppConst.channelIdDownload)
-            .setSmallIcon(R.drawable.ic_book_has)
-            .setOngoing(true)
-            .setOnlyAlertOnce(true)
-            .setContentTitle(getString(R.string.full_book_pagination))
-            .addAction(
-                R.drawable.ic_stop_black_24dp,
-                getString(R.string.cancel),
-                servicePendingIntent<FullBookPageService>(IntentAction.stop),
-            )
-    }
-
-    override fun onCreate() {
-        super.onCreate()
-        lifecycleScope.launch {
-            FullBookPaginator.state.collectLatest { state ->
-                if (!state.isRunning) {
-                    stopSelf()
-                    return@collectLatest
-                }
-                notificationBuilder
-                    .setContentTitle("${getString(R.string.full_book_pagination)}: ${ReadBook.book?.name.orEmpty()}")
-                    .setContentText("${state.completed} / ${state.total}")
-                    .setProgress(state.total, state.completed, state.total == 0)
-                notificationManager.notify(NotificationId.FullBookPageService, notificationBuilder.build())
-            }
-        }
-    }
-
-    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
-        if (intent?.action == IntentAction.stop) FullBookPaginator.stop()
-        return super.onStartCommand(intent, flags, startId)
-    }
-
-    override fun startForegroundNotification() {
-        startForeground(NotificationId.FullBookPageService, notificationBuilder.build())
-    }
-}

+ 19 - 12
app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt

@@ -1050,10 +1050,17 @@ sealed interface ConfigUpdateAction {
     data object InvalidateTextPage : ConfigUpdateAction
     data object UpdateLayout : ConfigUpdateAction
     data object RebuildWholeBookPageIndex : ConfigUpdateAction
+    data object UpdateWholeBookPageDemand : ConfigUpdateAction
     data object SubmitRenderTask : ConfigUpdateAction
     data object UpdatePageAnim : ConfigUpdateAction
 }
 
+private val HEADER_FOOTER_TIP_ACTIONS = setOf(
+    ConfigUpdateAction.UpdateStyle,
+    ConfigUpdateAction.UpdateContent,
+    ConfigUpdateAction.UpdateWholeBookPageDemand,
+)
+
 /**
  * Typed config mutations replace direct writes to the legacy ReadBookConfig facade.
  * Each variant carries [actions] that describe which UI updates are needed.
@@ -1146,40 +1153,40 @@ sealed interface ConfigUpdate {
         override val actions = setOf(ConfigUpdateAction.UpdateStyle)
     }
     data class TipHeaderLeft(val value: Int) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.UpdateContent)
+        override val actions = HEADER_FOOTER_TIP_ACTIONS
     }
     data class TipHeaderMiddle(val value: Int) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.UpdateContent)
+        override val actions = HEADER_FOOTER_TIP_ACTIONS
     }
     data class TipHeaderRight(val value: Int) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.UpdateContent)
+        override val actions = HEADER_FOOTER_TIP_ACTIONS
     }
     data class TipFooterLeft(val value: Int) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.UpdateContent)
+        override val actions = HEADER_FOOTER_TIP_ACTIONS
     }
     data class TipFooterMiddle(val value: Int) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.UpdateContent)
+        override val actions = HEADER_FOOTER_TIP_ACTIONS
     }
     data class TipFooterRight(val value: Int) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.UpdateContent)
+        override val actions = HEADER_FOOTER_TIP_ACTIONS
     }
     data class CustomTipHeaderLeft(val value: String) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.UpdateContent)
+        override val actions = HEADER_FOOTER_TIP_ACTIONS
     }
     data class CustomTipHeaderMiddle(val value: String) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.UpdateContent)
+        override val actions = HEADER_FOOTER_TIP_ACTIONS
     }
     data class CustomTipHeaderRight(val value: String) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.UpdateContent)
+        override val actions = HEADER_FOOTER_TIP_ACTIONS
     }
     data class CustomTipFooterLeft(val value: String) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.UpdateContent)
+        override val actions = HEADER_FOOTER_TIP_ACTIONS
     }
     data class CustomTipFooterMiddle(val value: String) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.UpdateContent)
+        override val actions = HEADER_FOOTER_TIP_ACTIONS
     }
     data class CustomTipFooterRight(val value: String) : ConfigUpdate {
-        override val actions = setOf(ConfigUpdateAction.UpdateStyle, ConfigUpdateAction.UpdateContent)
+        override val actions = HEADER_FOOTER_TIP_ACTIONS
     }
     data class HeaderFont(val path: String) : ConfigUpdate {
         override val actions = setOf(ConfigUpdateAction.UpdateStyle)

+ 2 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt

@@ -857,6 +857,8 @@ class ReadBookController(
                         }
                         ConfigUpdateAction.RebuildWholeBookPageIndex ->
                             ReadBook.requestWholeBookPageEstimate()
+                        ConfigUpdateAction.UpdateWholeBookPageDemand ->
+                            ReadBook.updateWholeBookPageDemand()
                         ConfigUpdateAction.SubmitRenderTask -> r.readView.submitRenderTask()
                         ConfigUpdateAction.UpdatePageAnim -> r.readView.upPageAnim()
                     }

+ 16 - 0
app/src/main/java/io/legado/app/ui/book/read/page/CustomTipPageRemaining.kt

@@ -0,0 +1,16 @@
+package io.legado.app.ui.book.read.page
+
+internal fun formatCustomTipPageRemaining(
+    pageSize: Int,
+    pageIndex: Int,
+    isChapterCompleted: Boolean,
+    chapterCompleteText: String,
+): String {
+    val pageRemaining = (pageSize - pageIndex - 1).coerceAtLeast(0)
+    return when {
+        isChapterCompleted && pageRemaining == 0 -> chapterCompleteText
+        isChapterCompleted -> pageRemaining.toString()
+        pageSize <= 0 -> "-"
+        else -> pageRemaining.toString()
+    }
+}

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

@@ -545,14 +545,14 @@ class PageView(
             textPage.pageSize.toString()
         } else {
             val pageSizeInt = textPage.pageSize
-            if (pageSizeInt <= 0) "-" else "~$pageSizeInt"
-        }
-        val pageRemaining = (textPage.pageSize - textPage.index - 1).coerceAtLeast(0)
-        val pageRemainingDisplay = when {
-            textPage.textChapter.isCompleted -> pageRemaining.toString()
-            textPage.pageSize <= 0 -> "-"
-            else -> "~$pageRemaining"
+            if (pageSizeInt <= 0) "-" else pageSizeInt.toString()
         }
+        val pageRemainingDisplay = formatCustomTipPageRemaining(
+            pageSize = textPage.pageSize,
+            pageIndex = textPage.index,
+            isChapterCompleted = textPage.textChapter.isCompleted,
+            chapterCompleteText = context.getString(R.string.chapter_complete),
+        )
         val readProgressDisplay = textPage.readProgress
         val wholeBookPage = ReadBook.getWholeBookPageState(textPage.chapterIndex, textPage.index)
         val fullPageIndexDisplay = wholeBookPage?.currentPage?.toString() ?: pageIndexDisplay
@@ -638,7 +638,7 @@ class PageView(
             tvPage?.setTextIfNotEqual("${index.plus(1)}/$pageSize")
         } else {
             val pageSizeInt = pageSize
-            val pageSize = if (pageSizeInt <= 0) "-" else "~$pageSizeInt"
+            val pageSize = if (pageSizeInt <= 0) "-" else pageSizeInt.toString()
             tvPageAndTotal?.setTextIfNotEqual("${index.plus(1)}/$pageSize  $readProgress")
             tvPage?.setTextIfNotEqual("${index.plus(1)}/$pageSize")
         }

+ 36 - 1
app/src/main/java/io/legado/app/ui/book/read/pageestimate/WholeBookPageCoordinator.kt

@@ -1,5 +1,6 @@
 package io.legado.app.ui.book.read.pageestimate
 
+import kotlinx.coroutines.CompletableDeferred
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Job
@@ -23,6 +24,7 @@ class WholeBookPageCoordinator(
     private val pendingCorrections = mutableMapOf<Int, PendingCorrection>()
     private val pendingLengthUpdates = mutableMapOf<Int, PendingLengthUpdate>()
     private var estimateJob: Job? = null
+    private var estimateReady = CompletableDeferred(false)
     private var activeEstimate: ActiveEstimate? = null
 
     @Volatile
@@ -46,16 +48,19 @@ class WholeBookPageCoordinator(
             .takeIf(PageEstimateCalibration::fitted)
             ?: calibrationStore.get(layoutBucket)
         val chapterEstimator = estimator ?: HeuristicPageEstimator()
+        val taskReady = CompletableDeferred<Boolean>()
         synchronized(lock) {
             generation++
             taskGeneration = generation
             estimateJob?.cancel()
+            estimateReady.complete(false)
+            estimateReady = taskReady
             pageIndex = null
             activeEstimate = null
             pendingCorrections.clear()
             pendingLengthUpdates.clear()
         }
-        estimateJob = scope.launch(Dispatchers.Default) {
+        val job = scope.launch(Dispatchers.Default) {
             val chapters = withContext(Dispatchers.IO) { chapterLoader() }
                 .sortedBy(ChapterLengthInfo::chapterIndex)
             ensureActive()
@@ -169,6 +174,13 @@ class WholeBookPageCoordinator(
                 onStateChanged()
             }
         }
+        estimateJob = job
+        job.invokeOnCompletion {
+            val initialized = synchronized(lock) {
+                generation == taskGeneration && pageIndex?.isInitialized == true
+            }
+            taskReady.complete(initialized)
+        }
         return taskGeneration
     }
 
@@ -229,10 +241,33 @@ class WholeBookPageCoordinator(
             pageIndex?.getState(chapterIndex, localPageIndex)
         }
 
+    fun hasEstimate(): Boolean = synchronized(lock) {
+        pageIndex?.isInitialized == true || estimateJob?.isActive == true
+    }
+
+    suspend fun awaitInitialized(layoutGeneration: Long): Boolean {
+        val ready = synchronized(lock) {
+            if (generation != layoutGeneration) return false
+            if (pageIndex?.isInitialized == true) return true
+            estimateReady
+        }
+        if (!ready.await()) return false
+        return synchronized(lock) {
+            generation == layoutGeneration && pageIndex?.isInitialized == true
+        }
+    }
+
+    fun isChapterExact(chapterIndex: Int, layoutGeneration: Long): Boolean =
+        synchronized(lock) {
+            generation == layoutGeneration && pageIndex?.isExact(chapterIndex) == true
+        }
+
     fun clear() {
         synchronized(lock) {
             generation++
             estimateJob?.cancel()
+            estimateReady.complete(false)
+            estimateReady = CompletableDeferred(false)
             estimateJob = null
             pageIndex = null
             activeEstimate = null

+ 1 - 1
app/src/main/res/values-zh-rCN/strings.xml

@@ -2388,10 +2388,10 @@
     <string name="placeholder_page_index">当前页码</string>
     <string name="placeholder_page_size">本章页数</string>
     <string name="placeholder_page_remaining">本章剩余页数</string>
+    <string name="chapter_complete">本章完</string>
     <string name="placeholder_read_progress">阅读进度</string>
     <string name="placeholder_full_page_index">全文页码</string>
     <string name="placeholder_full_page_size">全文页数</string>
-    <string name="full_book_pagination">全文分页</string>
     <!-- Missing zh-rTW/HK translations (683 keys) -->
 
     <!-- 日夜间/深浅色主题切换提醒 -->

+ 1 - 0
app/src/main/res/values-zh-rHK/strings.xml

@@ -1667,6 +1667,7 @@
     <string name="placeholder_page_index">當前頁碼</string>
     <string name="placeholder_page_size">本章頁數</string>
     <string name="placeholder_page_remaining">本章剩餘頁數</string>
+    <string name="chapter_complete">本章完</string>
     <string name="placeholder_read_progress">閱讀進度</string>
     <string name="custom_regex_rule">自訂正則規則</string>
     <string name="custom_tag_colors">自訂標籤顏色</string>

+ 1 - 0
app/src/main/res/values-zh-rTW/strings.xml

@@ -1594,6 +1594,7 @@
     <string name="placeholder_page_index">當前頁碼</string>
     <string name="placeholder_page_size">本章頁數</string>
     <string name="placeholder_page_remaining">本章剩餘頁數</string>
+    <string name="chapter_complete">本章完</string>
     <string name="placeholder_read_progress">閱讀進度</string>
     <string name="cover_selected_images_count">已選擇 %1$d 張圖片</string>
     <string name="confirm_skip_to_chapter">是否跳轉到此章節?</string>

+ 1 - 1
app/src/main/res/values/strings.xml

@@ -1214,10 +1214,10 @@
     <string name="placeholder_page_index">PageIndex</string>
     <string name="placeholder_page_size">PageSize</string>
     <string name="placeholder_page_remaining">PageRemaining</string>
+    <string name="chapter_complete">End of chapter</string>
     <string name="placeholder_read_progress">ReadProgress</string>
     <string name="placeholder_full_page_index">FullPageIndex</string>
     <string name="placeholder_full_page_size">FullPageSize</string>
-    <string name="full_book_pagination">Full-book pagination</string>
     <string name="sort_by_size">Sort by size</string>
     <string name="welcome_style">Launch interface style</string>
     <string name="welcome_style_summary">Startup screen image and whether to display text, etc.</string>

+ 52 - 0
app/src/test/java/io/legado/app/model/FullBookPaginatorActivationTest.kt

@@ -0,0 +1,52 @@
+package io.legado.app.model
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class FullBookPaginatorActivationTest {
+
+    @Test
+    fun `direct whole-book page tips enable pagination`() {
+        assertTrue(isActive(selection(value = WHOLE_BOOK_PAGE)))
+        assertTrue(isActive(selection(value = WHOLE_BOOK_PAGE_AND_PROGRESS)))
+    }
+
+    @Test
+    fun `inactive historical custom template does not enable pagination`() {
+        assertFalse(
+            isActive(
+                selection(
+                    value = ORDINARY_TIP,
+                    template = "{BookName} {FullPageIndex}/{FullPageSize}",
+                )
+            )
+        )
+    }
+
+    @Test
+    fun `active custom template enables pagination only for full-book placeholders`() {
+        assertTrue(isActive(selection(CUSTOM_TIP, "{FullPageIndex}")))
+        assertTrue(isActive(selection(CUSTOM_TIP, "{FullPageSize}")))
+        assertFalse(isActive(selection(CUSTOM_TIP, "{PageIndex}/{PageSize}")))
+    }
+
+    private fun isActive(selection: FullBookPageTipSelection) = hasActiveFullBookPageTip(
+        selections = listOf(selection),
+        customTipValue = CUSTOM_TIP,
+        wholeBookPageTipValue = WHOLE_BOOK_PAGE,
+        wholeBookPageAndProgressTipValue = WHOLE_BOOK_PAGE_AND_PROGRESS,
+    )
+
+    private fun selection(
+        value: Int,
+        template: String = "",
+    ) = FullBookPageTipSelection(value, template)
+
+    private companion object {
+        const val ORDINARY_TIP = 1
+        const val WHOLE_BOOK_PAGE = 19
+        const val WHOLE_BOOK_PAGE_AND_PROGRESS = 20
+        const val CUSTOM_TIP = 21
+    }
+}

+ 21 - 0
app/src/test/java/io/legado/app/ui/book/read/WholeBookPageConfigInvalidationTest.kt

@@ -6,6 +6,27 @@ import org.junit.Test
 
 class WholeBookPageConfigInvalidationTest {
 
+    @Test
+    fun `header and footer page selections reevaluate whole-book page demand`() {
+        val updates = listOf(
+            ConfigUpdate.TipHeaderLeft(19),
+            ConfigUpdate.TipHeaderMiddle(19),
+            ConfigUpdate.TipHeaderRight(19),
+            ConfigUpdate.TipFooterLeft(19),
+            ConfigUpdate.TipFooterMiddle(19),
+            ConfigUpdate.TipFooterRight(19),
+            ConfigUpdate.CustomTipHeaderLeft("{FullPageIndex}"),
+            ConfigUpdate.CustomTipFooterRight("{FullPageSize}"),
+        )
+
+        updates.forEach { update ->
+            assertTrue(
+                update.javaClass.simpleName,
+                ConfigUpdateAction.UpdateWholeBookPageDemand in update.actions,
+            )
+        }
+    }
+
     @Test
     fun `pagination-affecting updates rebuild whole-book page index`() {
         val updates = listOf(

+ 55 - 0
app/src/test/java/io/legado/app/ui/book/read/page/CustomTipPageRemainingTest.kt

@@ -0,0 +1,55 @@
+package io.legado.app.ui.book.read.page
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class CustomTipPageRemainingTest {
+
+    @Test
+    fun `completed chapter shows chapter complete on last page`() {
+        val display = formatCustomTipPageRemaining(
+            pageSize = 10,
+            pageIndex = 9,
+            isChapterCompleted = true,
+            chapterCompleteText = "本章完",
+        )
+
+        assertEquals("本章完", display)
+    }
+
+    @Test
+    fun `completed chapter keeps remaining page count before last page`() {
+        val display = formatCustomTipPageRemaining(
+            pageSize = 10,
+            pageIndex = 5,
+            isChapterCompleted = true,
+            chapterCompleteText = "本章完",
+        )
+
+        assertEquals("4", display)
+    }
+
+    @Test
+    fun `incomplete chapter omits approximation marker without reporting chapter complete`() {
+        val display = formatCustomTipPageRemaining(
+            pageSize = 10,
+            pageIndex = 9,
+            isChapterCompleted = false,
+            chapterCompleteText = "本章完",
+        )
+
+        assertEquals("0", display)
+    }
+
+    @Test
+    fun `incomplete chapter without pages keeps unknown marker`() {
+        val display = formatCustomTipPageRemaining(
+            pageSize = 0,
+            pageIndex = 0,
+            isChapterCompleted = false,
+            chapterCompleteText = "本章完",
+        )
+
+        assertEquals("-", display)
+    }
+}

+ 19 - 1
app/src/test/java/io/legado/app/ui/book/read/pageestimate/WholeBookPageCoordinatorTest.kt

@@ -1,6 +1,7 @@
 package io.legado.app.ui.book.read.pageestimate
 
 import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.async
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.runBlocking
 import kotlinx.coroutines.withTimeout
@@ -72,6 +73,21 @@ class WholeBookPageCoordinatorTest {
         assertEquals(newGeneration + 1, coordinator.generation)
     }
 
+    @Test
+    fun `waiting paginator is released when estimate generation is replaced`() = runBlocking {
+        val loaderGate = CompletableDeferred<Unit>()
+        val coordinator = WholeBookPageCoordinator(this, ChapterPageEstimator { 3f })
+        val oldGeneration = coordinator.requestEstimate(config, BOOK_ID) {
+            loaderGate.await()
+            chapters(1)
+        }
+        val oldReady = async { coordinator.awaitInitialized(oldGeneration) }
+
+        coordinator.requestEstimate(config, BOOK_ID) { chapters(1) }
+
+        assertFalse(withTimeout(2_000) { oldReady.await() })
+    }
+
     @Test
     fun `book average length is fallback when every chapter length is unknown`() = runBlocking {
         val coordinator = WholeBookPageCoordinator(this, ChapterPageEstimator { it.contentLength.toFloat() })
@@ -231,10 +247,12 @@ class WholeBookPageCoordinatorTest {
                 ChapterLengthInfo(1, "chapter-1", 10, contentLength = 100, contentHash = 12L),
             )
         }
-        awaitState(coordinator)
+        assertTrue(coordinator.awaitInitialized(generation))
 
         assertEquals(27, coordinator.getState(0, 0)?.totalPages)
         assertTrue(coordinator.getState(0, 0)?.currentChapterExact == true)
+        assertTrue(coordinator.isChapterExact(0, generation))
+        assertFalse(coordinator.isChapterExact(1, generation))
         coordinator.correctChapter(0, realPageCount = 7, layoutGeneration = generation)
         assertTrue(calibrationStore.recorded.isEmpty())
         assertEquals(1, exactStore.values.size)