Explorar el Código

fix: preserve manga progress identity and decode dimensions

HapeLee hace 1 mes
padre
commit
4cf9a21492

+ 4 - 2
app/src/main/java/io/legado/app/help/glide/progress/ProgressManager.kt

@@ -18,7 +18,7 @@ import java.util.concurrent.CopyOnWriteArrayList
  * 1. [progress] 全局事件流,供阅读页 ViewModel 订阅(无需注册/注销,生命周期安全);
  * 2. [addListener] / [removeListener] 单个 URL 的回调,兼容旧的 View 侧用法。
  *
- * 只有当至少一方在监听时才向主线程投递事件,避免预取 N 张图时刷爆主线程消息队列。
+ * 只有当至少一方在监听时才分发事件;主线程投递由响应体按时间节流。
  */
 object ProgressManager {
     private val listenersMap = ConcurrentHashMap<String, CopyOnWriteArrayList<OnProgressListener>>()
@@ -43,7 +43,9 @@ object ProgressManager {
             listeners?.forEach { it.invoke(isComplete, percentage, bytesRead, totalBytes) }
             _progress.tryEmit(
                 DownloadProgress(
-                    url = key,
+                    // Flow 按完整请求身份匹配页面,不能丢掉书源 header 等选项。
+                    // 只有旧 View 监听器使用上面的规范化 key。
+                    url = url,
                     percentage = percentage,
                     bytesRead = bytesRead,
                     totalBytes = totalBytes,

+ 47 - 20
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderScreen.kt

@@ -86,6 +86,7 @@ import coil3.request.allowHardware
 import coil3.request.crossfade
 import coil3.request.transformations
 import coil3.size.Dimension
+import coil3.size.Scale
 import coil3.size.Size
 import coil3.toBitmap
 import com.kyant.backdrop.backdrops.layerBackdrop
@@ -1489,8 +1490,8 @@ private fun MangaImageLoadOverlay(
 /**
  * 漫画正文的目标解码尺寸。
  *
- * 只按屏幕宽度推导,不读布局约束:条漫页的高度会在拿到宽高比后从「整屏高度」跳到
- * 「图片真实高度」,用约束推导会让同一张图在不同时机解出不同分辨率。
+ * 使用稳定的窗口尺寸,不读单页布局约束:条漫页的高度在拿到宽高比后会变化。
+ * 预取与展示共用此规则,分页按显示模式选择需要满足的宽度或高度。
  */
 @Composable
 private fun mangaPageDecodeSize(
@@ -1499,18 +1500,46 @@ private fun mangaPageDecodeSize(
     pageScaleType: Int,
     widePageMode: Int,
 ): Size {
-    // 「原始尺寸」模式按位图固有尺寸绘制(ContentScale.None),降采样会直接把页面画小,
-    // 所以这里必须保持不缩放,沿用改动前的语义。
-    if (paged && pageScaleType == MangaPageScaleType.ORIGINAL) return Size.ORIGINAL
     val density = LocalDensity.current
     val configuration = LocalConfiguration.current
-    val screenWidthPx = with(density) { configuration.screenWidthDp.dp.roundToPx() }
-    // 「旋转适配」把跨页图旋转 90°,图片的长边落在屏幕高度上(rotateWidePage 会交换约束)。
-    // 目标宽度必须换成屏幕高度,否则解码结果只有屏幕宽、显示时被放大两倍以上而发糊。
-    val contentWidthPx = if (paged && widePageMode == MangaWidePageMode.ROTATE_TO_FIT) {
-        maxOf(screenWidthPx, with(density) { configuration.screenHeightDp.dp.roundToPx() })
-    } else {
-        screenWidthPx
+    return mangaPageDecodeSize(
+        viewport = with(density) {
+            IntSize(
+                configuration.screenWidthDp.dp.roundToPx(),
+                configuration.screenHeightDp.dp.roundToPx(),
+            )
+        },
+        paged = paged,
+        sidePaddingPercent = sidePaddingPercent,
+        pageScaleType = pageScaleType,
+        widePageMode = widePageMode,
+    )
+}
+
+internal fun mangaPageDecodeSize(
+    viewport: IntSize,
+    paged: Boolean,
+    sidePaddingPercent: Int,
+    pageScaleType: Int,
+    widePageMode: Int,
+): Size {
+    // ContentScale.None 必须保留原图固有尺寸。
+    if (paged && pageScaleType == MangaPageScaleType.ORIGINAL) return Size.ORIGINAL
+    val width = viewport.width.coerceAtLeast(1)
+    val height = viewport.height.coerceAtLeast(1)
+    val rotated = paged && widePageMode == MangaWidePageMode.ROTATE_TO_FIT
+    // 宽图才旋转,但预取时尚无宽高比;取两种方向的上界,避免加载后重发请求。
+    val targetWidth = if (rotated) maxOf(width, height) else width
+    val targetHeight = if (rotated) maxOf(width, height) else height
+    if (paged && pageScaleType == MangaPageScaleType.FIT_HEIGHT) {
+        // 宽图的 FIT_WIDTH 优先于普通页缩放。未知宽高比时同时满足两条显示路径。
+        if (widePageMode == MangaWidePageMode.FIT_WIDTH) {
+            return Size(Dimension(targetWidth), Dimension(targetHeight))
+        }
+        return Size(Dimension.Undefined, Dimension(targetHeight))
+    }
+    if (paged && pageScaleType == MangaPageScaleType.STRETCH) {
+        return Size(Dimension(targetWidth), Dimension(targetHeight))
     }
     val fraction = if (paged) {
         1f
@@ -1518,7 +1547,7 @@ private fun mangaPageDecodeSize(
         1f - sidePaddingPercent.coerceIn(0, 45) * 2f / 100f
     }
     return Size(
-        width = Dimension((contentWidthPx * fraction).roundToInt().coerceAtLeast(1)),
+        width = Dimension((targetWidth * fraction).roundToInt().coerceAtLeast(1)),
         height = Dimension.Undefined,
     )
 }
@@ -1537,14 +1566,12 @@ private fun MangaReaderItemUi.Page.imageRequest(
     return ImageRequest.Builder(context)
         .data(imageUrl)
         .allowHardware(true)
-        // 解码尺寸必须显式钉住,不能交给 AsyncImage 的 ConstraintsSizeResolver:
-        // 它只在请求启动时取一次当时的布局约束,而条漫页的约束会在拿到宽高比后突变,
-        // 于是同一张高画质图每次进入可能解出不同分辨率;又因为内存缓存键相同,
-        // 预取那轮按 Size.ORIGINAL 解出的全图与展示那轮按视口降采样解出的图会互相覆盖,
-        // 命中哪一份取决于谁先完成 —— 这才是“概率性发糊”的来源。
-        // 预取与展示共用这里钉死的尺寸后,缓存里只会存在同一种分辨率。
-        // 同时它等价于旧版 Glide 的 override(widthPixels, SIZE_ORIGINAL)。
+        // 稳定解码目标避免条漫首次占位高度参与降采样,也让预取与展示的尺寸一致。
+        // Coil 仍会检查缓存位图是否满足目标尺寸;分页放大清晰度需要另外接入区域解码。
         .size(decodeSize)
+        // STRETCH 的宽、高都必须满足,否则 FIT 降采样后会在另一个轴上被放大。
+        // 只有一个轴有目标值时(适应宽/高、条漫),Coil 按该轴计算缩放。
+        .scale(Scale.FILL)
         // The preload request has no view-size resolver while the displayed request does. A
         // shared key lets the displayed request reuse it immediately, then crossfade only if a
         // better-sized decode is needed.

+ 75 - 0
app/src/test/java/io/legado/app/help/glide/progress/ProgressManagerTest.kt

@@ -0,0 +1,75 @@
+package io.legado.app.help.glide.progress
+
+import kotlinx.coroutines.CoroutineStart
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class ProgressManagerTest {
+    @Test
+    fun `events retain source options while legacy listeners accept plain URLs`() = runTest {
+        val plainUrl = "https://example.org/page.jpg"
+        val originalUrl = "$plainUrl,{\"headers\":{\"Referer\":\"https://example.org/\"}}"
+        val events = mutableListOf<DownloadProgress>()
+        val callbacks = mutableListOf<Int>()
+        val collector = backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) {
+            ProgressManager.progress.collect { events.add(it) }
+        }
+        ProgressManager.addListener(plainUrl) { _, percentage, _, _ ->
+            callbacks.add(percentage)
+        }
+        try {
+            ProgressManager.LISTENER.onProgress(originalUrl, 50, 100)
+            ProgressManager.LISTENER.onProgress(originalUrl, 100, 100)
+            runCurrent()
+            assertEquals(listOf(originalUrl, originalUrl), events.map { it.url })
+            assertEquals(listOf(50, 100), events.map { it.percentage })
+            assertEquals(listOf(1, 50, 100), callbacks)
+            assertTrue(events.last().isComplete)
+            assertEquals(null, ProgressManager.getProgressListener(plainUrl))
+        } finally {
+            ProgressManager.removeListener(plainUrl)
+            collector.cancel()
+        }
+    }
+
+    @Test
+    fun `requests sharing a URL retain distinct source options in events`() = runTest {
+        val urls = listOf(
+            "https://example.org/page.jpg,{\"headers\":{\"Authorization\":\"one\"}}",
+            "https://example.org/page.jpg,{\"headers\":{\"Authorization\":\"two\"}}",
+        )
+        val events = mutableListOf<DownloadProgress>()
+        val collector = backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) {
+            ProgressManager.progress.collect { events.add(it) }
+        }
+        try {
+            urls.forEach { ProgressManager.LISTENER.onProgress(it, 50, 100) }
+            runCurrent()
+            assertEquals(urls, events.map { it.url })
+        } finally {
+            collector.cancel()
+        }
+    }
+
+    @Test
+    fun `unknown content length does not emit a percentage or report completion`() = runTest {
+        val url = "https://example.org/unknown.jpg"
+        val events = mutableListOf<DownloadProgress>()
+        val collector = backgroundScope.launch(start = CoroutineStart.UNDISPATCHED) {
+            ProgressManager.progress.collect { events.add(it) }
+        }
+        try {
+            ProgressManager.LISTENER.onProgress(url, 50, -1)
+            runCurrent()
+            assertTrue(events.isEmpty())
+        } finally {
+            collector.cancel()
+        }
+    }
+}

+ 65 - 0
app/src/test/java/io/legado/app/ui/book/manga/MangaPageDecodeSizeTest.kt

@@ -0,0 +1,65 @@
+package io.legado.app.ui.book.manga
+
+import androidx.compose.ui.unit.IntSize
+import coil3.size.Dimension
+import coil3.size.Size
+import io.legado.app.ui.book.manga.config.MangaPageScaleType
+import io.legado.app.ui.book.manga.config.MangaWidePageMode
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class MangaPageDecodeSizeTest {
+    private fun decodeSize(
+        scale: Int,
+        paged: Boolean = true,
+        wideMode: Int = MangaWidePageMode.NORMAL,
+        padding: Int = 0,
+    ) = mangaPageDecodeSize(IntSize(1080, 2400), paged, padding, scale, wideMode)
+
+    @Test
+    fun `fit height does not constrain a landscape page to portrait window width`() {
+        assertEquals(
+            Size(Dimension.Undefined, Dimension(2400)),
+            decodeSize(MangaPageScaleType.FIT_HEIGHT),
+        )
+    }
+
+    @Test
+    fun `stretch supplies both target axes and original preserves intrinsic dimensions`() {
+        assertEquals(Size(1080, 2400), decodeSize(MangaPageScaleType.STRETCH))
+        assertEquals(Size.ORIGINAL, decodeSize(MangaPageScaleType.ORIGINAL))
+    }
+
+    @Test
+    fun `rotated wide page has sufficient width before its aspect ratio is known`() {
+        assertEquals(
+            Size(Dimension(2400), Dimension.Undefined),
+            decodeSize(MangaPageScaleType.FIT_WIDTH, wideMode = MangaWidePageMode.ROTATE_TO_FIT),
+        )
+    }
+
+    @Test
+    fun `wide page width override and ordinary page height fit are both covered`() {
+        assertEquals(
+            Size(2400, 1080),
+            mangaPageDecodeSize(
+                IntSize(2400, 1080), true, 0,
+                MangaPageScaleType.FIT_HEIGHT, MangaWidePageMode.FIT_WIDTH,
+            ),
+        )
+    }
+
+    @Test
+    fun `webtoon ignores paged scale and uses padded width with unconstrained height`() {
+        for (scale in listOf(MangaPageScaleType.FIT_HEIGHT, MangaPageScaleType.STRETCH, MangaPageScaleType.ORIGINAL)) {
+            assertEquals(
+                Size(Dimension(864), Dimension.Undefined),
+                decodeSize(scale, paged = false, padding = 10),
+            )
+        }
+        assertEquals(
+            Size(Dimension(108), Dimension.Undefined),
+            decodeSize(MangaPageScaleType.FIT_WIDTH, paged = false, padding = 90),
+        )
+    }
+}

+ 224 - 0
docs/dev/manga-subsampling-plan.md

@@ -0,0 +1,224 @@
+# 分页漫画区域解码与下载共用文件链路
+
+> 状态:实施设计,尚未接入区域解码。2026-09-26 对照仓库源码、Coil 3.6.3 和
+> Telephoto 0.19.0 校正。当前补丁只修正完整 URL 进度身份与分页解码目标。
+>
+> 目标:分页缩放时按需读取原图细节;阅读与后续漫画下载共用同一份有效文件,
+> 已有文件不重复获取,已保留的章节断网可读。
+> “清晰”以源图分辨率为上限,不承诺放大超过原图仍有新增细节;
+> “复用”不等于不再解码,preview、瓦片和重开后的解码仍是正常工作。
+
+## 1. 当前事实与方案差异
+
+- `MangaReaderScreen` 的预取与展示都以 URL 请求图片,共用显式解码尺寸。
+  条漫按窗口宽度与侧边留白解码;分页适应高度按高度解码,拉伸同时满足宽高,
+  原始尺寸保留原图,旋转适配保守覆盖交换后的窗口尺寸。
+- `CoverFetcher` 返回网络流或内存 Buffer;即使来自 OkHttp HTTP 缓存,也没有
+  Telephoto 所需的 Coil 磁盘键或可映射的本地文件模型。分页通常仍走整图回退。
+- 不能仅凭相同内存键断言随机命中低分辨率图片。Coil `MemoryCacheService`
+  会验证尺寸、采样状态和 precision;稳定目标减少布局引起的差异,但不提供缩放细节。
+- `MangaChapterPageLoader` 目前只加载章节文本和图片 URL,未向阅读页提供缓存图片文件。
+- `BookHelp.getImage(book, src)` 的路径是
+  `externalFiles/book_cache/<bookFolderName>/images/<md5Encode16(src)>.<suffix>`。
+  `flowImages` 与下载使用同一章节基地址解析图片地址,应该沿用完整解析后原始 `src`
+  (包含书源选项)作为身份,不能用带临时 token 的最终网络 URL 替代。
+- `isImageExist` 只看 exists;字节数组 `writeImage` 直接写目标文件。
+  `saveImage` 会保留部分坏数据,下次见文件存在就返回成功;流式分支校验失败也仍发布文件。
+  流式写入已有临时文件,但 rename 失败会退回直接复制到目标,不保证读者看不到半文件。
+- `saveImage` 的锁按 src 而不是书籍+src 建立,退出时直接移除;等待者尚在时,新请求可取得
+  新锁。阅读若在 Fetcher 中另写文件,也不会参与这个锁。共用路径不等于共用并发控制。
+- `clearInvalidCache` 在应用启动及主页面清理时执行;`clearComicCache` 按阅读章节窗口删除图片。
+  现有规则不识别显式下载、活动阅读和瓦片解码中的文件。
+- `MangaSettings.autoOfflineCache` 已存在,不要新增同名设置。当前主要控制章节预取,
+  不是图片已持久化或章节可离线的完成证明。
+
+## 2. 区域解码的依赖依据
+
+Telephoto 0.19.0 的 `Coil3ImageSource.kt` 在成功结果为 BitmapImage 时,尝试:
+
+1. 用 `result.diskCacheKey` 打开 Coil DiskCache snapshot;
+2. 或在 `dataSource == DISK || MEMORY_CACHE` 时,把 **结果请求的 data** 映射为本地 URI,
+   构造文件/content URI 区域解码源;内存命中时还检查文件存在。
+
+之后还要通过 `canBeSubSampled`,否则回退普通 painter。拿不到源时会重试一次请求,
+第二次关闭内存读取;自定义 Fetcher 不会因设置 `diskCachePolicy` 自动接上 Coil 磁盘缓存。
+`EngineInterceptor` 只从文件型 ImageSource 提取 diskCacheKey,任意 Buffer 不会自动写入磁盘。
+
+因此 `ImageRequest.data = File(path)` 配合默认 FileUriFetcher,可以自动走区域解码,
+无需手写瓦片组件。这里复用的文件必须是**完成书源解密但未应用灰度/EInk 等展示变换的原图**。
+
+来源:本机相应版本 sources.jar;
+[Telephoto 0.19.0 Coil3ImageSource](https://github.com/saket/telephoto/blob/0.19.0/zoomable-image/coil3/src/main/kotlin/me/saket/telephoto/zoomable/coil3/Coil3ImageSource.kt)。
+
+## 3. 推荐决策:一份原图,独立保留策略
+
+所有分页阅读都先取得可用于随机读取的文件,再交给 `ZoomableAsyncImage`。
+**自动离线开关不控制是否生成解码文件,只控制章节预取与长期保留。**
+关闭开关仍应清晰缩放,不能退回现在的 URL/Buffer 整图路径。
+
+沿用 BookHelp 图片路径,文件内容为存储真相;增加最小的章节索引与保留记录,管理用途和有效性。
+阅读、自动离线和显式下载使用同一获取入口、同一去重机制、同一文件,不复制到第二个 Coil 缓存。
+不要为此新建 Gradle 模块或通用存储框架。
+
+| 用途 | 获取方式 | 保留与淘汰 |
+|---|---|---|
+| 普通在线阅读 | 当前页优先,有限预取;完成后发布本地文件 | 可淘汰,受阅读缓存预算限制 |
+| 自动离线 | 同一入口补齐配置范围内章节及元数据 | 受明确的自动离线保留窗口约束 |
+| 用户显式下载 | 同一入口补齐指定章节 | 持久保留,不受阅读 LRU 或阅读位置窗口删除 |
+| 活动渲染/获取 | 持有文件使用凭证 | 使用期间不可删除,不等于永久保留 |
+
+同一张图可关联多个章节与用途。取消一个用途不能删除仍被其他用途保留的文件。
+下载成功只是保留用途升级,不再获取或再复制已有有效图片。
+
+BookHelp 外部专属目录通常不被系统作为 cache 回收,但可能因应用清数据、卸载、
+外部存储不可用或应用自己的清理而失效,不能写成“只有卸载才会删除”。
+
+## 4. 先补文件契约,再改阅读模型
+
+### 4.1 统一获取入口
+
+在现有 Android 数据层 manga owner 内形成获取能力;读者是漫画会话,下载者是现有图片下载调用链。
+ViewModel 经现有 Gateway/Repository 使用它,不访问 BookHelp、DAO 或 File。
+仅在接入真实调用者的同一切片增加窄契约,不先生成无调用方接口。
+
+一次获取的身份至少包含书籍存储身份、书源身份与完整原始 src;最终网络 URL 只是请求结果。
+落盘保持现有路径兼容,迁移时确认书籍改名/来源切换与 `updateCacheFolder` 的语义。
+如果书源变化可能改变解密结果,必须使旧有效记录失效,而不是只靠路径存在。
+
+获取步骤:
+
+1. 在同一身份的获取协调器中检查有效文件;有效才复用。
+2. 命中历史文件但没有有效记录时,读取格式/尺寸并校验后采纳。无效文件进入失败/重试流程,
+   不因 exists 判成功。对需完整性证明的旧文件无法仅凭尺寸判断截断,应保守重取或进一步校验。
+3. miss 时以原始 src 调用 AnalyzeUrl,沿用 header、cookie、书源限流、WiFi 限制和 JS 解密语义。
+   解密必须使用与 BookHelp 下载一致的参数,不同时引入第二套规则实现。
+4. 在目标目录写唯一 `.tmp` 文件,完成读取与解密后校验,再以原子替换发布最终文件。
+   不把临时文件交给 Coil;不允许退回向可见最终路径逐字节复制。
+5. 写入失败、取消、解密失败或校验失败,清临时文件并返回错误,不覆盖原有有效文件。
+   遇到平台不支持原子发布时明确失败或采用经过验证的安全发布方式,不静默降低保证。
+6. 成功后更新有效记录、章节关联与保留用途,通知等待者取得同一文件。
+
+“有效”至少要求文件非空、格式受支持、头部尺寸有效,且本次完整读取成功;
+已知 Content-Length 应匹配,解密后重新校验。服务端未知长度不伪造百分比。
+格式校验通过也不保证每台设备都能区域解码,设备解码失败属于渲染降级流程。
+失败冷却/坏图诊断与有效文件分开,不能把失败占位放进下载完成判定。
+
+### 4.2 并发、取消与文件生命周期
+
+- 使用有明确生命周期的同键协调器;锁条目不能在等待者仍可能使用时被删除并重建。
+  首版可用有界生命周期的协调 owner,之后再按引用计数回收,禁止裸 `remove(key)`。
+- 同时阅读与下载同图,只做一次网络获取/解密/写入。等待者取消只释放自己的订阅;
+  仍有下载/阅读消费者时不能取消共用任务。最后一个消费者离开后的取消规则写入契约测试。
+- 复用现有 imageDownloadSlots / imageDecodeSlots 限流,不让 Coil 预取旁路再开一组无限任务。
+- 渲染取得文件使用凭证,从交给 Telephoto 起到其解码源关闭为止保护该文件;
+  不能只在预览 Coil 请求结束时释放。优先保护整个活动章节,避免与可见页、预取页生命周期竞争。
+- 清理、重试、有效记录变更与发布共享协调规则;主线程不做 exists/格式校验或磁盘删除。
+- 进度关联原始请求身份,并区分书籍/书源/尝试次数,过期尝试的完成与失败不能覆盖新尝试。
+  当前全局 URL 进度只是现有展示兼容机制,未来共享获取任务应自己发布有身份的状态。
+
+### 4.3 章节与保留记录
+
+在同一书籍目录保存版本化、原子更新的最小索引(具体模型以契约测试为准):
+章节 URL/index、章节文本文件、预期图片完整 src 列表、有效文件关联、保留用途和最后访问时间。
+不在元数据里保存最终带 token URL 或认证 header 明文副本;完整身份需要持久化时评估敏感选项。
+
+下载完成 = 章节列表/正文足以重建阅读页 + 所有预期图片有效且被保留。
+单个图片下载完、预取请求成功或 UI Ready 均不能单独标为整章可离线。
+索引不代替真实文件;打开时发现缺失应降级为未完成/可重试,并修正下载状态。
+索引损坏时重建可重建部分,不清空整本书原图,更不能无声删除已有下载。
+
+旧图片缺少用途记录时,不能直接当阅读缓存淘汰;先保守保留,再由明确迁移规则或用户清理分类。
+同一补丁必须接管 `clearComicCache`、`clearInvalidCache` 与改名/移除书籍清理,
+不能新策略之外仍有旧删除旁路。
+
+## 5. 阅读渲染接法
+
+1. 会话数据层按可见窗口调度文件获取,当前页先于预取页;页面只持有语义状态及 Android 渲染标识。
+   不向共享领域契约暴露 File、Uri 或 Context。现有 Android-only 渲染标识可在 adapter/UI 层承载。
+2. 加载时显示下载进度/占位;成功后一次性发布已就绪文件,构建 `.data(File(path))` 请求。
+   不默认先走 URL 图片请求再切 File,避免两条获取链路与重复 preview 解码。
+3. Telephoto 负责 preview、瓦片、缩放和平移。保留项目点击、旋转、拆页、灰度与九宫格行为。
+4. 首版 preview 保持当前保守显示尺寸。确认瓦片路径与首次清晰过渡正常后,才降低 preview 尺寸。
+   `ContentScale.None` 必须单独验证:缩小 preview 不应改变原图固有尺寸或初始缩放。
+5. 内存键包含渲染模式、变换参数和文件内容版本/重试代次;尺寸复用服从 Coil 校验。
+   不能让已修复文件继续命中旧坏图/旧变换结果。自动背景提色也改用同一个文件。
+6. 条漫暂保留 AsyncImage 整页渲染,避免重引入页边界细线;可复用同一文件获取层,
+   不在本次切片重写条漫全局缩放或阅读器手势。
+
+### 格式、变换与失败降级
+
+- 灰度继续用已有渲染 ColorFilter,文件始终保存未应用展示变换的原图。
+- EInk 首版使用明确的整图渲染分支:由 Coil 生成二值化 painter,配合现有缩放状态/手势,
+  不把变换后的 preview 交给会读取未变换原图的自动瓦片路径。
+  仅继续使用 `ZoomableAsyncImage(File)` 或设置 diskCachePolicy 不能关闭该区域解码路径。
+  此模式暂不承诺区域解码清晰度;之后验证等价渲染滤镜再统一,不能悄悄失去二值化。
+- GIF/SVG/不支持区域解码的格式保留整图或动画行为,不因命名后缀误判原图格式。
+- 原图格式嗅探优先于 URL 后缀;FileUriFetcher 的 MIME 可能来自后缀,需覆盖伪后缀样本,
+  必要时只对漫画本地文件提供窄 fetcher,但仍确保结果请求 data 是可映射的本地文件。
+- 接 `SubSamplingImageErrorReporter` 记录失败;对当前文件版本降级至整图,
+  不立即删下载或无限重新联网。重试/文件版本更新后允许再次尝试区域解码。
+- 低存储、外部存储不可用显式报错并允许重试;不得回退到巨型原图加载并冒充清晰模式成功。
+
+## 6. 容量与清理
+
+- 首版为可淘汰阅读文件设置字节预算,初始可考虑 256 MiB,最终值需按典型漫画与低存储设备验证。
+  该预算不包含用户显式保留下载;下载界面单独显示占用。
+- 淘汰只针对无显式保留、无自动离线保留、无活动使用的文件,以访问时间排序。
+  共享图片的所有章节关联都解除后才可删除。不能按整本目录 LRU 一删了之。
+- 超大单图可临时超出预算以供当前阅读,结束后清理;磁盘不足则获取失败。
+  容量检查与失败路径不得删除仍被读取的文件。
+- `imageRetainNum` 是现有章节窗口语义,不改名为字节上限。用于自动离线时需说明保留范围,
+  不能影响显式下载或替代字节预算。
+- “清 HTTP 缓存”只清 HTTP 数据;“清阅读缓存”只清可淘汰文件;
+  “删除下载/清全部书籍数据”才解除显式保留。活动文件延迟删除。
+- 新获取入口不再为同一原图另写 Coil DiskCache;现有 OkHttp `manga_cache` 仅作过渡。
+  是否还能利用旧 HTTP 缓存、如何避免新网络获取的双份落盘,必须按实际 AnalyzeUrl 客户端验证,
+  不凭修改一个容量数字宣称已消除双份存储。
+
+## 7. 实施切片与门禁
+
+每个切片可独立验证;不要一次同时更换网络、数据库、DI、导航和 UI。
+
+### A. 文件获取契约与历史兼容
+
+先写测试,再修正 BookHelp 发布与有效性语义;接入现有 saveImage/saveImages 真实调用方。
+测试:取消/IO 失败无半文件、坏文件可重试、同图并发一次获取、等待者取消、锁回收竞争、
+原始 src/headers/解密参数兼容、历史文件采纳、失败批次不标下载完成。
+
+### B. 漫画会话到本地文件渲染
+
+接入当前页与有限预取,替换 URL 图片请求与自动背景提色;接 File 模型、错误降级、
+EInk 整图分支及活动文件保护。首版保持 preview 尺寸,不先做 1/4 优化。
+这一切片启用在线落盘时必须同时提供可淘汰预算与安全清理,不能先交付无上限版本。
+
+### C. 离线保留与后续显式下载
+
+接章节索引/完成判定、现有 autoOfflineCache 和受保护清理,验证断网重建;
+后续下载 UI/队列只补齐同一文件并升级保留用途。已有下载缓存从 A 开始保守保护,
+不能等新 UI 做完才保护旧文件。
+
+### D. 优化与真机验收
+
+确认支持格式下 `subSamplingState != null` 且最终达到 full-quality 状态,
+再比较 preview 尺寸、内存、首次清晰时间及是否闪烁。不把正常瓦片解码计为多余完整请求。
+
+必须覆盖:
+
+- 分页六种缩放、四种宽图模式、横竖窗口、双页/拆页、双击与九宫格。
+- 支持格式的原图细节放大对比、长图内存、渐进 JPEG、伪后缀、GIF/SVG、灰度与 EInk。
+- 开关自动离线后仍能区域解码;重开有效文件无网络请求,显式下载与阅读共用同一文件。
+- 应用重启断网、章节元数据缺失、动态 token/header、清理与瓦片并发、低存储、下载保留。
+- 弱网百分比、未知长度转圈、解密/解码阶段保持 Loading,陈旧完成事件不覆盖新请求。
+
+按修改运行真实任务:
+
+```powershell
+.\gradlew.bat :app:compileAppDebugKotlin
+.\gradlew.bat :app:testAppDebugUnitTest --tests <受影响测试类>
+.\gradlew.bat verifyConfigArchitecture lintAppDebug --continue --no-configuration-cache
+# 涉及资源、Manifest 或打包时
+.\gradlew.bat :app:assembleAppDebug
+git diff --check
+```
+
+不承诺尚未运行的真机、性能与离线兼容验证;不以“文件已经落盘”作为区域解码和下载完成证明。