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

feat: recover manga region decoding and retain explicit downloads

HapeLee 1 месяц назад
Родитель
Сommit
6fe571ef0d

+ 25 - 5
app/src/main/java/io/legado/app/help/book/BookHelp.kt

@@ -214,7 +214,9 @@ object BookHelp {
             if (!imgNames.contains(imgFile.name) && !imgFile.name.endsWith(".tmp")) {
                 imageFiles.deleteIfIdle(imgFile) {
                     // 普通在线缓存由字节预算淘汰,不能被章节窗口清理绕过。
-                    if (it.name == BookImageFileStore.ONLINE_MARKER_DIRECTORY || imageFiles.isOnline(it)) false else it.delete()
+                    if (it.name == BookImageFileStore.ONLINE_MARKER_DIRECTORY ||
+                        it.name == BookImageFileStore.DOWNLOAD_INDEX_DIRECTORY ||
+                        imageFiles.isOnline(it) || imageFiles.isExplicitDownload(it)) false else it.delete()
                 }
             }
         }
@@ -362,9 +364,19 @@ object BookHelp {
         return count
     }
 
-    /**
-     * @return 失败的图片数量(0 表示全部成功或无需下载)
-     */
+    /** 显式下载还需要当前章节的保留引用,不能仅凭在线文件命中跳过升级。 */
+    fun hasExplicitImageContent(book: Book, chapter: BookChapter): Boolean {
+        if (!hasContent(book, chapter)) return false
+        var complete = true
+        forEachImageSrc(book, chapter) { src ->
+            val image = getImage(book, src)
+            if (!imageFiles.hasDownloadChapter(image, "${chapter.index}.${MD5Utils.md5Encode16(chapter.url)}") ||
+                !imageFiles.isValid(image)) complete = false
+        }
+        return complete
+    }
+
+    /** @return 失败的图片数量(0 表示全部成功或无需下载) */
     suspend fun saveImages(
         bookSource: BookSource,
         book: Book,
@@ -372,6 +384,7 @@ object BookHelp {
         content: String,
         concurrency: Int = cacheGateway.currentSettings.threadCount,
         onProgress: (suspend (completed: Int, total: Int) -> Unit)? = null,
+        explicitDownload: Boolean = false,
     ): Int = coroutineScope {
         val imageUrls = flowImages(bookChapter, content).toList()
         val total = imageUrls.size
@@ -381,7 +394,12 @@ object BookHelp {
         var completed = 0
         var failures = 0
         imageUrls.asFlow().onEachParallel(concurrency) { mSrc ->
-            val ok = saveImage(bookSource, book, mSrc, bookChapter)
+            val ok = saveImage(
+                bookSource, book, mSrc, bookChapter,
+                explicitDownloadChapter = if (explicitDownload) {
+                    "${bookChapter.index}.${MD5Utils.md5Encode16(bookChapter.url)}"
+                } else null,
+            )
             progressMutex.withLock {
                 if (!ok) failures++
                 completed++
@@ -400,11 +418,13 @@ object BookHelp {
         onlineOnly: Boolean = false,
         loadOnlyWifi: Boolean = false,
         onDownload: () -> Unit = {},
+        explicitDownloadChapter: String? = null,
     ): Boolean = withContext(IO) {
         try {
             // 按实际书籍文件串行获取;等待者重新检查有效性,成功时不重复联网。
             val image = getImage(book, src)
             imageFiles.withFileLock(image) {
+                explicitDownloadChapter?.let { imageFiles.retainDownload(image, it) }
                 if (imageFiles.isValid(image)) {
                     if (!onlineOnly) imageFiles.setOnline(image, false)
                     imageFiles.touch(image)

+ 48 - 1
app/src/main/java/io/legado/app/help/book/BookImageFileStore.kt

@@ -16,6 +16,7 @@ import java.util.concurrent.atomic.AtomicBoolean
 internal class BookImageFileStore(private val validate: (File) -> Boolean) {
     companion object {
         const val ONLINE_MARKER_DIRECTORY = ".online"
+        const val DOWNLOAD_INDEX_DIRECTORY = ".downloads"
     }
     private class Entry {
         val mutex = Mutex()
@@ -43,8 +44,49 @@ internal class BookImageFileStore(private val validate: (File) -> Boolean) {
 
     fun isOnline(file: File): Boolean = onlineMarker(file).isFile
 
+    private fun downloadIndex(file: File) = File(File(file.parentFile, DOWNLOAD_INDEX_DIRECTORY), file.name)
+
+    fun isExplicitDownload(file: File): Boolean = downloadIndex(file).isFile
+
+    fun hasDownloadChapter(file: File, chapterKey: String): Boolean = try {
+        val properties = java.util.Properties()
+        downloadIndex(file).inputStream().use(properties::load)
+        properties.getProperty("version") == "1" && properties.getProperty("chapter.$chapterKey") == "explicit"
+    } catch (_: IOException) {
+        false
+    } catch (_: IllegalArgumentException) {
+        false
+    }
+
+    /** 每张原图记录引用它的显式下载章节;失败的部分下载同样保留,完成状态仍验证原图。 */
+    fun retainDownload(file: File, chapterKey: String) = synchronized(entries) {
+        val index = downloadIndex(file)
+        val properties = java.util.Properties()
+        if (index.isFile) try {
+            index.inputStream().use(properties::load)
+        } catch (_: IllegalArgumentException) {
+            // 损坏索引仍保护原图,本次显式下载恢复可验证的引用。
+        }
+        properties.setProperty("version", "1")
+        properties.setProperty("chapter.$chapterKey", "explicit")
+        val parent = requireNotNull(index.parentFile)
+        if (!parent.isDirectory && !parent.mkdirs()) throw IOException("无法创建下载用途索引")
+        val temp = File.createTempFile("${file.name}.", ".tmp", parent)
+        try {
+            FileOutputStream(temp).use {
+                properties.store(it, null)
+                it.fd.sync()
+            }
+            Files.move(temp.toPath(), index.toPath(), ATOMIC_MOVE, REPLACE_EXISTING)
+            setOnline(file, false)
+        } finally {
+            temp.delete()
+        }
+    }
+
     /** 未分类的历史文件不自动纳入预算;现有章节下载可将在线文件升级为保留文件。 */
     fun setOnline(file: File, online: Boolean) {
+        if (online && isExplicitDownload(file)) return
         val marker = onlineMarker(file)
         if (online) {
             requireNotNull(marker.parentFile).mkdirs()
@@ -66,14 +108,19 @@ internal class BookImageFileStore(private val validate: (File) -> Boolean) {
             it.isFile && it.parentFile?.name == ONLINE_MARKER_DIRECTORY
         }
             .map { marker -> File(requireNotNull(marker.parentFile).parentFile, marker.name) to marker }
+            .filter { (file, _) -> !isExplicitDownload(file) }
             .toList().sortedBy { (_, marker) -> marker.lastModified() }
         var total = candidates.sumOf { (file, _) -> file.length() }
         for ((file, marker) in candidates) {
+            if (!marker.isFile || isExplicitDownload(file)) {
+                total -= file.length()
+                continue
+            }
             if (total <= maxBytes && file.exists()) continue
             val size = file.length()
             if (deleteIfIdle(file) {
                     // 下载可能已在枚举之后升级保留用途,必须在删除前复核。
-                    if (!marker.isFile) false
+                    if (!marker.isFile || isExplicitDownload(file)) false
                     else if (!file.exists() || file.delete()) {
                         marker.delete()
                         true

+ 13 - 0
app/src/main/java/io/legado/app/help/coil/MangaImageFileOwner.kt

@@ -3,6 +3,9 @@ package io.legado.app.help.coil
 import androidx.compose.runtime.Stable
 import androidx.compose.runtime.RememberObserver
 import io.legado.app.help.book.BookHelp
+import io.legado.app.constant.AppLog
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
 import java.io.Closeable
 import java.io.File
 import kotlin.coroutines.cancellation.CancellationException
@@ -10,6 +13,16 @@ import kotlin.coroutines.cancellation.CancellationException
 /** 一个渲染请求的文件所有权。仅管理平台资源生命周期,不持有 UI 或阅读会话。 */
 @Stable
 class MangaImageFileOwner : Closeable, RememberObserver {
+    private val regionFailure = MutableStateFlow(false)
+    val regionDecodeFailed = regionFailure.asStateFlow()
+
+    @Synchronized
+    fun onRegionDecodeFailed(error: Exception) {
+        if (!closed && regionFailure.compareAndSet(false, true)) {
+            AppLog.put("漫画区域解码失败,回退整图", error)
+        }
+    }
+
     private var closed = false
     private var file: File? = null
     private var lease: Closeable? = null

+ 15 - 9
app/src/main/java/io/legado/app/help/coil/MangaZoomableImageSource.kt

@@ -5,6 +5,8 @@ import androidx.compose.runtime.Stable
 import androidx.compose.runtime.RememberObserver
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.key
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
 import androidx.compose.ui.geometry.Size
 import coil3.ImageLoader
 import coil3.compose.rememberAsyncImagePainter
@@ -23,13 +25,17 @@ fun rememberMangaZoomableImageSource(
     imageLoader: ImageLoader,
     owner: MangaImageFileOwner,
     wholeImage: Boolean,
-): ZoomableImageSource = key(owner, wholeImage) {
-    val delegate = if (wholeImage) {
-        val painter = rememberAsyncImagePainter(request, imageLoader)
-        remember(painter) { PainterImageSource(painter) }
-    } else ZoomableImageSource.coil(request, imageLoader)
-    remember(delegate, owner, wholeImage) {
-        if (wholeImage) delegate else LeasedImageSource(delegate, owner)
+): ZoomableImageSource {
+    val failed by owner.regionDecodeFailed.collectAsState()
+    val usePainter = wholeImage || failed
+    return key(owner, usePainter) {
+        val delegate = if (usePainter) {
+            val painter = rememberAsyncImagePainter(request, imageLoader)
+            remember(painter) { PainterImageSource(painter) }
+        } else ZoomableImageSource.coil(request, imageLoader)
+        remember(delegate, owner, usePainter) {
+            if (usePainter) delegate else LeasedImageSource(delegate, owner)
+        }
     }
 }
 
@@ -52,9 +58,9 @@ private class LeasedImageSource(
         val leased = remember(tiles) {
             owner.borrowForTiles()?.let { (file, lease) ->
                 RememberedTileSource(ZoomableImageSource.SubSamplingDelegate(
-                    SubSamplingImageSource.file(file.toOkioPath(), tiles.source.preview) {
+                    RecoveringRegionImageSource(SubSamplingImageSource.file(file.toOkioPath(), tiles.source.preview) {
                         try { tiles.source.close() } finally { lease.close() }
-                    },
+                    }, owner::onRegionDecodeFailed),
                     tiles.imageOptions,
                 ))
             }

+ 50 - 0
app/src/main/java/io/legado/app/help/coil/RecoveringRegionImageSource.kt

@@ -0,0 +1,50 @@
+package io.legado.app.help.coil
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.painter.ColorPainter
+import androidx.compose.ui.unit.IntRect
+import androidx.compose.ui.unit.IntSize
+import me.saket.telephoto.subsamplingimage.SubSamplingImageSource
+import me.saket.telephoto.subsamplingimage.internal.ImageRegionDecoder
+import java.io.IOException
+import java.util.concurrent.atomic.AtomicBoolean
+import kotlin.coroutines.cancellation.CancellationException
+
+/** 保留预览至整图接管;区域解码失败不删除原图,也不触发重新下载。 */
+internal class RecoveringRegionImageSource(
+    private val source: SubSamplingImageSource,
+    private val onFailure: (Exception) -> Unit,
+) : SubSamplingImageSource by source {
+    private val failed = AtomicBoolean()
+
+    private fun recover(error: Exception) {
+        if (error is CancellationException) throw error
+        if (error !is IOException && error !is RuntimeException) throw error
+        if (failed.compareAndSet(false, true)) onFailure(error)
+    }
+
+    private fun transparent() = ImageRegionDecoder.DecodeResult(ColorPainter(Color.Transparent), false)
+
+    override suspend fun decoder(): ImageRegionDecoder.Factory = ImageRegionDecoder.Factory { params ->
+        val decoder = try {
+            source.decoder().create(params)
+        } catch (error: Exception) {
+            recover(error)
+            return@Factory object : ImageRegionDecoder {
+                override val imageSize = source.preview?.let { IntSize(it.width, it.height) } ?: IntSize(1, 1)
+                override suspend fun decodeRegion(region: IntRect, sampleSize: Int) = transparent()
+            }
+        }
+        object : ImageRegionDecoder by decoder {
+            override suspend fun decodeRegion(region: IntRect, sampleSize: Int): ImageRegionDecoder.DecodeResult {
+                if (failed.get()) return transparent()
+                return try {
+                    decoder.decodeRegion(region, sampleSize)
+                } catch (error: Exception) {
+                    recover(error)
+                    transparent()
+                }
+            }
+        }
+    }
+}

+ 9 - 4
app/src/main/java/io/legado/app/model/CacheBookModel.kt

@@ -508,7 +508,7 @@ class CacheBookModel(
             onSkipped(chapterIndex)
             return
         }
-        if (repository.hasImageContent(book, chapter)) {
+        if (repository.hasImageContent(book, chapter, queue.isExplicitDownload(chapter.index))) {
             onSkipped(chapterIndex)
             return
         }
@@ -521,6 +521,7 @@ class CacheBookModel(
                 bookSource = bookSource,
                 book = book,
                 chapter = chapter,
+                explicitDownload = queue.isExplicitDownload(chapter.index),
                 start = CoroutineStart.LAZY,
                 onProgress = { completed, total ->
                     reportImageDownloadProgress(chapter, completed, total)
@@ -619,8 +620,10 @@ class CacheBookModel(
         content: String? = null,
     ) {
         task.onSuccess(IO) {
-            if (chainImagesAfterContent && !repository.hasImageContent(book, chapter)) {
-                startImageCacheTask(scope, context, chapter, chapterIndex, it as String)
+            if ((chainImagesAfterContent || queue.isExplicitDownload(chapterIndex)) &&
+                !repository.hasImageContent(book, chapter, queue.isExplicitDownload(chapterIndex))) {
+                startImageCacheTask(scope, context, chapter, chapterIndex,
+                    (it as? String) ?: content ?: BookHelp.getContent(book, chapter).orEmpty())
                 return@onSuccess
             }
             completeChapterCache(chapter, content ?: (it as? String))
@@ -676,6 +679,7 @@ class CacheBookModel(
             bookSource = bookSource,
             book = book,
             chapter = chapter,
+            explicitDownload = queue.isExplicitDownload(chapter.index),
             start = CoroutineStart.LAZY,
             onProgress = { completed, total ->
                 reportImageDownloadProgress(chapter, completed, total)
@@ -721,12 +725,13 @@ class CacheBookModel(
     }
 
     private suspend fun ensureChapterImagesCached(chapter: BookChapter) {
-        if (repository.hasImageContent(book, chapter)) return
+        if (repository.hasImageContent(book, chapter, queue.isExplicitDownload(chapter.index))) return
         reportImageDownloadProgress(chapter, completed = 0)
         repository.saveCachedImagesAwait(
             bookSource = bookSource,
             book = book,
             chapter = chapter,
+            explicitDownload = queue.isExplicitDownload(chapter.index),
             onProgress = { completed, total ->
                 reportImageDownloadProgress(chapter, completed, total)
             },

+ 14 - 0
app/src/main/java/io/legado/app/model/cache/CacheDownloadQueue.kt

@@ -106,12 +106,25 @@ class CacheDownloadQueue {
         }
     }
 
+    private val explicitIndices = IntRangeSet()
+
+    fun isExplicitDownload(index: Int): Boolean = synchronized(explicitIndices) { explicitIndices.contains(index) }
+
     private val ranges = ArrayDeque<RangeCursor>()
     private val indices = linkedSetOf<Int>()
     private val emittedIndices = IntRangeSet()
     private val removedIndices = IntRangeSet()
 
     fun enqueue(request: CacheDownloadRequest) {
+        if (request.source != CacheDownloadSource.ReadPreload) {
+            synchronized(explicitIndices) {
+                when (val selection = request.selection) {
+                    is ChapterSelection.Range -> explicitIndices.addRange(selection.start, selection.end)
+                    is ChapterSelection.Indices -> selection.values.forEach(explicitIndices::add)
+                    is ChapterSelection.Single -> explicitIndices.add(selection.index)
+                }
+            }
+        }
         enqueue(request.selection)
     }
 
@@ -159,6 +172,7 @@ class CacheDownloadQueue {
     }
 
     fun clear() {
+        synchronized(explicitIndices) { explicitIndices.clear() }
         ranges.clear()
         indices.clear()
         emittedIndices.clear()

+ 6 - 4
app/src/main/java/io/legado/app/model/cache/CacheDownloadRepository.kt

@@ -19,8 +19,8 @@ class CacheDownloadRepository {
         return appDb.bookChapterDao.getChapter(bookUrl, index)
     }
 
-    fun hasImageContent(book: Book, chapter: BookChapter): Boolean {
-        return BookHelp.hasImageContent(book, chapter)
+    fun hasImageContent(book: Book, chapter: BookChapter, explicitDownload: Boolean = false): Boolean {
+        return if (explicitDownload) BookHelp.hasExplicitImageContent(book, chapter) else BookHelp.hasImageContent(book, chapter)
     }
 
     fun hasContent(book: Book, chapter: BookChapter): Boolean {
@@ -35,9 +35,10 @@ class CacheDownloadRepository {
         chapter: BookChapter,
         start: CoroutineStart = CoroutineStart.LAZY,
         onProgress: (suspend (completed: Int, total: Int) -> Unit)? = null,
+        explicitDownload: Boolean = false,
     ): Coroutine<Unit> {
         return Coroutine.async(scope, context, start = start, executeContext = context) {
-            saveCachedImagesAwait(bookSource, book, chapter, onProgress)
+            saveCachedImagesAwait(bookSource, book, chapter, onProgress, explicitDownload)
         }
     }
 
@@ -46,10 +47,11 @@ class CacheDownloadRepository {
         book: Book,
         chapter: BookChapter,
         onProgress: (suspend (completed: Int, total: Int) -> Unit)? = null,
+        explicitDownload: Boolean = false,
     ) {
         val content = BookHelp.getContent(book, chapter)
             ?: throw NoStackTraceException("${book.name} ${chapter.title} 图片缓存未完成:缺少正文")
-        val failures = BookHelp.saveImages(bookSource, book, chapter, content, 1, onProgress)
+        val failures = BookHelp.saveImages(bookSource, book, chapter, content, 1, onProgress, explicitDownload)
         if (!BookHelp.isChapterImageCacheComplete(book, chapter, failures)) {
             throw NoStackTraceException("${book.name} ${chapter.title} 图片缓存未完成")
         }

+ 23 - 0
app/src/test/java/io/legado/app/help/book/BookHelpImageFileTest.kt

@@ -47,6 +47,29 @@ class BookHelpImageFileTest {
         }
     }
 
+    @Test
+    fun `explicit download promotes the same original and retains chapter references`() = runBlocking {
+        val src = "https://invalid.example/explicit.jpg"
+        val image = BookHelp.getImage(book, src)
+        val index = java.io.File(java.io.File(image.parentFile, ".downloads"), image.name)
+        try {
+            val bytes = png()
+            BookHelp.writeImage(book, src, bytes)
+            var transfers = 0
+            assertTrue(BookHelp.saveImage(null, book, src, onDownload = { transfers++ }, explicitDownloadChapter = "2.hash"))
+            assertTrue(BookHelp.saveImage(null, book, src, onDownload = { transfers++ }, explicitDownloadChapter = "3.hash"))
+            // 自动离线/阅读复用不会降级显式下载记录。
+            assertTrue(BookHelp.saveImage(null, book, src))
+            val properties = java.util.Properties().apply { index.inputStream().use { load(it) } }
+            assertEquals("explicit", properties.getProperty("chapter.2.hash"))
+            assertEquals("explicit", properties.getProperty("chapter.3.hash"))
+            assertEquals(0, transfers)
+            assertArrayEquals(bytes, image.readBytes())
+        } finally {
+            BookHelp.clearCache(book)
+        }
+    }
+
     @Test
     fun `book rename defers physical migration until image reader releases its file`() = runBlocking {
         val renamed = book.copy(name = "Renamed image contract")

+ 46 - 0
app/src/test/java/io/legado/app/help/book/BookImageFileStoreTest.kt

@@ -33,6 +33,52 @@ class BookImageFileStoreTest {
         }
     }
 
+    @Test
+    fun `explicit index survives restart and protects shared original from online eviction`() = withDirectory { root ->
+        val file = root.resolve("page.jpg").apply { writeBytes(image) }
+        store.setOnline(file, true)
+        store.retainDownload(file, "3.hash")
+        store.retainDownload(file, "4.hash")
+        val restarted = BookImageFileStore { true }
+        assertTrue(restarted.hasDownloadChapter(file, "3.hash"))
+        assertTrue(restarted.hasDownloadChapter(file, "4.hash"))
+        assertFalse(restarted.hasDownloadChapter(file, "5.hash"))
+        // 即使遗留在线标记重新出现,也必须复核显式下载用途。
+        root.resolve(".online").mkdirs()
+        root.resolve(".online/${file.name}").writeText("")
+        val online = root.resolve("online.jpg").apply { writeBytes(image) }
+        restarted.setOnline(online, true)
+        restarted.trimOnlineCache(root, image.size.toLong())
+        assertTrue(online.isFile)
+        restarted.trimOnlineCache(root, 0)
+        assertFalse(online.isFile)
+        assertTrue(file.isFile)
+        assertArrayEquals(image, file.readBytes())
+    }
+
+    @Test
+    fun `corrupt download index protects bytes and explicit retry repairs its reference`() = withDirectory { root ->
+        val file = root.resolve("page.jpg").apply { writeBytes(image) }
+        val index = root.resolve(".downloads/page.jpg")
+        index.parentFile!!.mkdirs()
+        index.writeText("chapter.bad=\\uQQQQ")
+        assertTrue(store.isExplicitDownload(file))
+        assertFalse(store.hasDownloadChapter(file, "1.hash"))
+        store.retainDownload(file, "1.hash")
+        assertTrue(store.hasDownloadChapter(file, "1.hash"))
+        store.setOnline(file, true)
+        assertFalse(store.isOnline(file))
+        assertArrayEquals(image, file.readBytes())
+    }
+
+    @Test
+    fun `partial explicit download is retained but is not valid content`() = withDirectory { root ->
+        val file = root.resolve("missing.jpg")
+        store.retainDownload(file, "1.hash")
+        assertTrue(store.isExplicitDownload(file))
+        assertFalse(store.isValid(file))
+    }
+
     @Test
     fun `directory migration waits for readers of both source and target`() = withDirectory { root ->
         val source = root.resolve("old").apply { mkdirs() }

+ 66 - 0
app/src/test/java/io/legado/app/help/coil/RecoveringRegionImageSourceTest.kt

@@ -0,0 +1,66 @@
+package io.legado.app.help.coil
+
+import androidx.compose.ui.unit.IntRect
+import androidx.compose.ui.unit.IntSize
+import kotlinx.coroutines.test.runTest
+import me.saket.telephoto.subsamplingimage.ImageBitmapOptions
+import me.saket.telephoto.subsamplingimage.SubSamplingImageSource
+import me.saket.telephoto.subsamplingimage.internal.ImageRegionDecoder
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.io.IOException
+import kotlin.coroutines.cancellation.CancellationException
+
+class RecoveringRegionImageSourceTest {
+    private val params = object : ImageRegionDecoder.FactoryParams {
+        override val imageOptions = ImageBitmapOptions()
+    }
+
+    private fun source(factory: ImageRegionDecoder.Factory) = object : SubSamplingImageSource {
+        override val preview = null
+        override suspend fun decoder() = factory
+        override fun close() = Unit
+    }
+
+    @Test
+    fun initializationFailureFallsBackOnce() = runTest {
+        var failures = 0
+        val wrapped = RecoveringRegionImageSource(source { throw IOException("unsupported") }) { failures++ }
+        val decoder = wrapped.decoder().create(params)
+        decoder.decodeRegion(IntRect(0, 0, 1, 1), 1)
+        assertEquals(1, failures)
+    }
+
+    @Test
+    fun tileFailurePreservesDimensionsAndClosesDecoder() = runTest {
+        var failures = 0
+        var closed = false
+        val original = object : ImageRegionDecoder {
+            override val imageSize = IntSize(200, 300)
+            override suspend fun decodeRegion(region: IntRect, sampleSize: Int): ImageRegionDecoder.DecodeResult =
+                throw RuntimeException("native region decoding failed")
+            override fun close() { closed = true }
+        }
+        val wrapped = RecoveringRegionImageSource(source { original }) { failures++ }
+        val decoder = wrapped.decoder().create(params)
+        repeat(2) { decoder.decodeRegion(IntRect(0, 0, 10, 10), 1) }
+        assertEquals(original.imageSize, decoder.imageSize)
+        assertEquals(1, failures)
+        decoder.close()
+        assertTrue(closed)
+    }
+
+    @Test
+    fun cancellationDoesNotBecomeDecodeFailure() = runTest {
+        var failed = false
+        val wrapped = RecoveringRegionImageSource(source { throw CancellationException("disposed") }) { failed = true }
+        try {
+            wrapped.decoder().create(params)
+            error("Cancellation should propagate")
+        } catch (_: CancellationException) {
+            assertFalse(failed)
+        }
+    }
+}

+ 15 - 0
app/src/test/java/io/legado/app/model/cache/CacheDownloadQueueTest.kt

@@ -8,6 +8,21 @@ import org.junit.Test
 
 class CacheDownloadQueueTest {
 
+    @Test
+    fun explicitPurposeSurvivesReadPreloadRetryAndPrioritization() {
+        val queue = CacheDownloadQueue()
+        queue.enqueue(CacheDownloadRequest("book", ChapterSelection.Range(2, 4), CacheDownloadSource.Manual))
+        queue.enqueue(CacheDownloadRequest("book", ChapterSelection.Single(3), CacheDownloadSource.ReadPreload))
+        queue.prioritize(3)
+        assertEquals(3, queue.next("book", emptySet())?.chapterIndex)
+        queue.enqueue(ChapterSelection.Single(3))
+        assertTrue(queue.isExplicitDownload(3))
+        queue.enqueue(CacheDownloadRequest("book", ChapterSelection.Single(8), CacheDownloadSource.ReadPreload))
+        assertFalse(queue.isExplicitDownload(8))
+        queue.clear()
+        assertFalse(queue.isExplicitDownload(3))
+    }
+
     @Test
     fun rangeReturnsChaptersLazily() {
         val queue = CacheDownloadQueue()

+ 29 - 8
docs/dev/manga-subsampling-plan.md

@@ -193,8 +193,11 @@ ViewModel 经现有 Gateway/Repository 使用它,不访问 BookHelp、DAO 或
 - 区域解码失败应记录并对当前文件版本降级至整图,不立即删下载或无限重新联网。
   Telephoto 0.19.0 的公开 `ZoomableImage` 未暴露 `SubSamplingImageErrorReporter`,
   也没有对应 CompositionLocal;不能假定直接调用它已经接入错误回退。
-  下一步需要通过公开 `rememberSubSamplingImageState(errorReporter = ...)` 的窄渲染适配器
-  或库的明确 API 扩展接入,该切片尚未解决区域解码初始化/瓦片失败的自动降级。
+  现在在 `SubSamplingImageSource.decoder()` 返回的 factory/decoder 边界捕获初始化和瓦片异常,
+  发出一次请求 owner 的降级状态,保留预览直到 Coil 整图 painter 接管;取消照常传播。
+  `ImageRegionDecoder` 在 0.19.0 虽处于 internal 包,声明为 public;没有复制库渲染代码,
+  但升级依赖时必须复核此适配点。降级绑定当前请求 owner,重试建立新 owner 后允许再次区域解码;
+  尚未实现跨请求的文件版本失败记忆。
 - 低存储、外部存储不可用显式报错并允许重试;不得回退到巨型原图加载并冒充清晰模式成功。
 
 ## 6. 容量与清理
@@ -237,7 +240,7 @@ ViewModel 经现有 Gateway/Repository 使用它,不访问 BookHelp、DAO 或
 此时还没有引入阅读自动落盘,所以不会增加无限增长的在线阅读存储。
 
 仍未完成:持久有效记录/源版本失效、完整传输长度记录、共享任务订阅取消语义、
-章节索引与保留用途、保留感知清理。读取保护、预算与目录迁移协调由 B 补齐。
+完整离线清单。读取保护、预算与目录迁移协调由 B 补齐;用途保护由 C 补齐。
 格式/尺寸检查不是整图所有像素可解码的证明;
 后续渲染错误必须保持可重试,不能因已有文件无条件标 Ready。
 
@@ -261,9 +264,8 @@ EInk painter 分支;未降低 preview 尺寸,也未向共享领域泄漏 Fil
 原图被预算淘汰后,即使预览还在内存缓存,拦截器也先恢复原图并发出真实获取事件,
 让之前 Ready 的页面恢复 Loading;有效原图命中不强制降级 Ready。
 解析器按书籍/渲染请求 owner 重建,重试代次进入内存键,避免新请求接着使用旧 owner 的瓦片源。
-格式回退沿用 Telephoto/Coil;区域解码错误自动降级、源/文件版本失效仍未完成。
-预算外保留暂沿用既有章节窗口,尚不能区分自动离线与显式下载;这属于 C 的用途索引工作,
-不能把“升级预算外”描述为已经完成显式下载永久保留。
+格式回退沿用 Telephoto/Coil;区域解码初始化/瓦片错误现已自动降级,源/文件版本失效仍未完成。
+自动离线仍沿用既有章节窗口;显式下载由 C 的用途索引保护,不参与窗口淘汰。
 
 新增验证覆盖在线预算的重启恢复/LRU/历史保护、活动瓦片阻止清理、请求销毁后迟到获取释放、
 在线文件升级章节保留以及 Coil DISK/MEMORY_CACHE 两种结果的 File 身份。
@@ -277,8 +279,27 @@ Windows Robolectric 的 API 35 ImageDecoder 文件 JNI 返回 "Only supported on
 ### C. 离线保留与后续显式下载
 
 接章节索引/完成判定、现有 autoOfflineCache 和受保护清理,验证断网重建;
-后续下载 UI/队列只补齐同一文件并升级保留用途。已有下载缓存从 A 开始保守保护,
-不能等新 UI 做完才保护旧文件。
+后续下载 UI/队列只补齐同一文件并升级保留用途。历史未分类文件不纳入新增在线预算;
+现有章节窗口清理语义继续保留,无法从旧文件本身推断此前是否显式下载。
+旧文件再次经过显式下载队列时会无网络补登记索引,之后受显式用途保护。
+
+当前 C 的最小实现:每张原图在 `images/.downloads/<原图文件名>` 有版本化 Properties 索引,
+原子记录 `chapter.<index>.<章节 URL 哈希>=explicit`。索引只保存引用身份,不复制 src/token/header。
+多个章节共享一张原图时追加引用;失败下载也保留已登记用途,但文件缺失/无效时绝不判定完成。
+Manual/Batch 队列请求保留显式用途,ReadPreload 不降级已有用途;暂停、优先级与失败重试保留用途。
+已有图片命中也会补登记索引,不因 `hasImageContent` 提前跳过升级;执行中的预加载收到显式请求,
+完成回调会再次复核用途并补登记。正文与真实有效原图仍决定章节完成,索引不能代替文件。
+在线预算与章节窗口均保护显式用途;整本缓存的主动清理仍可删除下载,读取租约继续保护活动文件。
+索引损坏时原图仍受保护,完成判定为 false,可通过显式重试补齐引用。
+这是原图→章节用途索引,尚非完整章节下载清单:稳定目录身份、源版本失效、下载取消后主动取消保留、
+逐章删除/共享引用回收与独立下载列表恢复留待后续;现有章节正文/数据库继续负责重建阅读页。
+
+本轮验证:11 个相关测试类、96 项测试全部通过(包含 API 26/35 native graphics 文件链路测试),
+覆盖区域解码初始化/瓦片失败与取消、索引重启/损坏恢复/共享引用、显式用途升级不联网、
+保留文件不占在线预算、队列用途与既有阅读交互。实际执行 `:app:testAppDebugUnitTest`
+(按上述测试类过滤)、`verifyConfigArchitecture`、`lintAppDebug`,并完成 Kotlin 编译与
+`git diff --check`;lint 仍为原有 118 个警告、13 个提示,未修改 baseline。
+区域错误使用可控 decoder 注入验证;真机原生瓦片失败、回退后的缩放体验和完整断网重建仍未验收。
 
 ### D. 优化与真机验收