Explorar el Código

fix: preserve manga download upgrades and consistent tile rendering

HapeLee hace 1 mes
padre
commit
00b528d0d8

+ 21 - 10
app/src/main/java/io/legado/app/help/coil/MangaZoomableImageSource.kt

@@ -55,20 +55,31 @@ private class LeasedImageSource(
     override fun resolve(canvasSize: Flow<Size>): ZoomableImageSource.ResolveResult {
         val resolved = delegate.resolve(canvasSize)
         val tiles = resolved.delegate as? ZoomableImageSource.SubSamplingDelegate ?: return resolved
-        val leased = remember(tiles) {
-            owner.borrowForTiles()?.let { (file, lease) ->
-                RememberedTileSource(ZoomableImageSource.SubSamplingDelegate(
-                    RecoveringRegionImageSource(SubSamplingImageSource.file(file.toOkioPath(), tiles.source.preview) {
-                        try { tiles.source.close() } finally { lease.close() }
-                    }, owner::onRegionDecodeFailed),
-                    tiles.imageOptions,
-                ))
-            }
+        val recovered = remember(tiles, owner) {
+            RememberedTileSource(ZoomableImageSource.SubSamplingDelegate(
+                recoveringMangaTileSource(tiles.source, owner),
+                tiles.imageOptions,
+            ))
         }
-        return resolved.copy(delegate = leased?.delegate ?: tiles)
+        return resolved.copy(delegate = recovered.delegate)
     }
 }
 
+/** 本地/content source 不需要在线租约,也必须覆盖区域解码失败。 */
+internal fun recoveringMangaTileSource(
+    source: SubSamplingImageSource,
+    owner: MangaImageFileOwner,
+): SubSamplingImageSource {
+    val borrowed = owner.borrowForTiles()
+    val leased = if (borrowed == null) source else {
+        val (file, lease) = borrowed
+        SubSamplingImageSource.file(file.toOkioPath(), source.preview) {
+            try { source.close() } finally { lease.close() }
+        }
+    }
+    return RecoveringRegionImageSource(leased, owner::onRegionDecodeFailed)
+}
+
 /** 正常退出由瓦片解码器关闭;未提交的组合尚无解码器,需要回收已创建的租约。 */
 private class RememberedTileSource(val delegate: ZoomableImageSource.SubSamplingDelegate) : RememberObserver {
     override fun onRemembered() = Unit

+ 13 - 7
app/src/main/java/io/legado/app/model/CacheBookModel.kt

@@ -185,7 +185,7 @@ class CacheBookModel(
      */
     @Synchronized
     fun hasLaunchableChapters(): Boolean {
-        return !isPaused && (queue.waitingCount() > 0 || isLoading)
+        return !isPaused && (queue.hasLaunchableChapter(onDownloadSet) || isLoading)
     }
 
     @Synchronized
@@ -620,13 +620,19 @@ class CacheBookModel(
         content: String? = null,
     ) {
         task.onSuccess(IO) {
-            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
+            synchronized(this@CacheBookModel) {
+                // 与 addRequest 共用锁:用途复核和完成提交之间不能插入新的显式请求。
+                val explicitDownload = queue.isExplicitDownload(chapterIndex)
+                if ((chainImagesAfterContent || explicitDownload) &&
+                    !repository.hasImageContent(book, chapter, explicitDownload)) {
+                    startImageCacheTask(
+                        scope, context, chapter, chapterIndex,
+                        (it as? String) ?: content ?: BookHelp.getContent(book, chapter).orEmpty(),
+                    )
+                    return@onSuccess
+                }
+                completeChapterCache(chapter, content ?: (it as? String))
             }
-            completeChapterCache(chapter, content ?: (it as? String))
         }.onError(IO) {
             onPreError(chapter, it)
             try {

+ 16 - 4
app/src/main/java/io/legado/app/model/cache/CacheDownloadQueue.kt

@@ -138,10 +138,11 @@ class CacheDownloadQueue {
 
     fun next(bookUrl: String, runningIndices: Set<Int>): CacheDownloadCandidate? {
         while (indices.isNotEmpty()) {
-            val index = indices.first()
+            // 再次请求正在执行的章节必须等完成后复核,不能被调度器吞掉。
+            val index = indices.firstOrNull { it !in runningIndices } ?: break
             indices.remove(index)
             // indices 为显式排队:即使 range 上有 remove 孔也要出队
-            if (index in runningIndices || emittedIndices.contains(index)) continue
+            if (emittedIndices.contains(index)) continue
             emittedIndices.add(index)
             return CacheDownloadCandidate(bookUrl, index)
         }
@@ -152,11 +153,14 @@ class CacheDownloadQueue {
                 val index = cursor.next++
                 if (
                     removedIndices.contains(index) ||
-                    emittedIndices.contains(index) ||
-                    index in runningIndices
+                    emittedIndices.contains(index)
                 ) {
                     continue
                 }
+                if (index in runningIndices) {
+                    indices.add(index)
+                    continue
+                }
                 emittedIndices.add(index)
                 return CacheDownloadCandidate(bookUrl, index)
             }
@@ -165,6 +169,14 @@ class CacheDownloadQueue {
         return null
     }
 
+    fun hasLaunchableChapter(runningIndices: Set<Int>): Boolean =
+        indices.any { it !in runningIndices && !emittedIndices.contains(it) } ||
+            ranges.any { cursor ->
+                (cursor.next..cursor.end).any {
+                    it !in runningIndices && !removedIndices.contains(it) && !emittedIndices.contains(it)
+                }
+            }
+
     fun removeChapter(index: Int): Boolean {
         val removed = indices.remove(index) || isWaiting(index)
         removedIndices.add(index)

+ 0 - 8
app/src/main/java/io/legado/app/ui/book/manga/MangaCoilTransformations.kt

@@ -9,14 +9,6 @@ import android.graphics.Paint
 import coil3.size.Size
 import coil3.transform.Transformation
 
-internal object MangaGrayscaleTransformation : Transformation() {
-    override val cacheKey: String = "manga-grayscale-v1"
-
-    override suspend fun transform(input: Bitmap, size: Size): Bitmap {
-        return input.filteredBitmap(ColorMatrix().apply { setSaturation(0f) })
-    }
-}
-
 internal class MangaEInkTransformation(private val threshold: Int) : Transformation() {
     override val cacheKey: String = "manga-eink-v1-$threshold"
 

+ 19 - 13
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderScreen.kt

@@ -1579,7 +1579,7 @@ private fun MangaReaderItemUi.Page.imageRequest(
     onSuccess: () -> Unit = {},
     onError: (String?) -> Unit = {},
 ): ImageRequest {
-    val memoryCacheKey = "manga-page:$bookUrl:$imageUrl:${settings.sourceOrigin}:" +
+    val memoryCacheKey = "manga-page-v2:$bookUrl:$imageUrl:${settings.sourceOrigin}:" +
         "${settings.enableEInk}:${settings.eInkThreshold}:${settings.enableGray}:${fileOwner != null}:$retryRevision"
     return ImageRequest.Builder(context)
         .data(imageUrl)
@@ -1603,10 +1603,7 @@ private fun MangaReaderItemUi.Page.imageRequest(
             extras[CoverExtras.MangaFileTransferStarted] = onFileTransfer
         }
         .apply {
-            when {
-                settings.enableEInk -> transformations(MangaEInkTransformation(settings.eInkThreshold))
-                settings.enableGray -> transformations(MangaGrayscaleTransformation)
-            }
+            if (settings.enableEInk) transformations(MangaEInkTransformation(settings.eInkThreshold))
             crossfade(!settings.disableCrossFade)
         }
         .listener(onStart = { _ -> onStart() }, onError = { _, result ->
@@ -1779,16 +1776,25 @@ internal fun zoomStartOffset(
     }
 }
 
-private fun mangaColorFilter(settings: MangaReaderSettings): ColorFilter? {
-    if (settings.filterRed == 0 && settings.filterGreen == 0 &&
+private fun mangaColorFilter(settings: MangaReaderSettings): ColorFilter? =
+    mangaColorMatrix(settings)?.let(ColorFilter::colorMatrix)
+
+/** 灰度作用于最终绘制,预览、瓦片、动画和整图回退共用相同滤镜。 */
+internal fun mangaColorMatrix(settings: MangaReaderSettings): ColorMatrix? {
+    val grayscale = settings.enableGray && !settings.enableEInk
+    if (!grayscale && settings.filterRed == 0 && settings.filterGreen == 0 &&
         settings.filterBlue == 0 && settings.filterAlpha == 0
     ) return null
-    return ColorFilter.colorMatrix(ColorMatrix(floatArrayOf(
-        (255 - settings.filterRed) / 255f, 0f, 0f, 0f, 0f,
-        0f, (255 - settings.filterGreen) / 255f, 0f, 0f, 0f,
-        0f, 0f, (255 - settings.filterBlue) / 255f, 0f, 0f,
-        0f, 0f, 0f, (255 - settings.filterAlpha) / 255f, 0f,
-    )))
+    val matrix = ColorMatrix()
+    if (grayscale) matrix.setToSaturation(0f)
+    val scales = floatArrayOf(
+        (255 - settings.filterRed) / 255f,
+        (255 - settings.filterGreen) / 255f,
+        (255 - settings.filterBlue) / 255f,
+        (255 - settings.filterAlpha) / 255f,
+    )
+    for (row in 0..3) for (column in 0..4) matrix[row, column] *= scales[row]
+    return matrix
 }
 
 private fun clickAction(

+ 74 - 0
app/src/test/java/io/legado/app/help/coil/LocalMangaRegionRecoveryTest.kt

@@ -0,0 +1,74 @@
+package io.legado.app.help.coil
+
+import android.app.Application
+import androidx.compose.ui.unit.IntRect
+import androidx.compose.ui.unit.IntSize
+import kotlinx.coroutines.runBlocking
+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.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
+import java.io.IOException
+import kotlin.coroutines.cancellation.CancellationException
+
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [26, 35])
+class LocalMangaRegionRecoveryTest {
+    @Before
+    fun setUp() { RuntimeEnvironment.getApplication().injectAsAppCtx() }
+
+    private val params = object : ImageRegionDecoder.FactoryParams {
+        override val imageOptions = ImageBitmapOptions()
+    }
+
+    @Test
+    fun sourceWithoutOnlineFileLeaseStillFallsBackAndClosesOriginal() = runBlocking {
+        val owner = MangaImageFileOwner()
+        var closes = 0
+        val original = object : SubSamplingImageSource {
+            override val preview = null
+            override suspend fun decoder() = ImageRegionDecoder.Factory { throw IOException("local decoder failed") }
+            override fun close() { closes++ }
+        }
+        val recovered = recoveringMangaTileSource(original, owner)
+        recovered.decoder().create(params)
+        assertTrue(owner.regionDecodeFailed.value)
+        recovered.close()
+        assertEquals(1, closes)
+        owner.close()
+    }
+
+    @Test
+    fun localTileCancellationDoesNotRequestFallback() = runBlocking {
+        val owner = MangaImageFileOwner()
+        val original = object : SubSamplingImageSource {
+            override val preview = null
+            override suspend fun decoder() = ImageRegionDecoder.Factory {
+                object : ImageRegionDecoder {
+                    override val imageSize = IntSize(40, 60)
+                    override suspend fun decodeRegion(region: IntRect, sampleSize: Int): ImageRegionDecoder.DecodeResult =
+                        throw CancellationException("disposed")
+                }
+            }
+        }
+        val recovered = recoveringMangaTileSource(original, owner)
+        try {
+            recovered.decoder().create(params).decodeRegion(IntRect(0, 0, 10, 10), 1)
+            error("Cancellation should propagate")
+        } catch (_: CancellationException) {
+            assertFalse(owner.regionDecodeFailed.value)
+        } finally {
+            recovered.close()
+            owner.close()
+        }
+    }
+}

+ 3 - 1
app/src/test/java/io/legado/app/help/coil/MangaFileRequestTest.kt

@@ -13,6 +13,8 @@ import coil3.request.allowHardware
 import io.legado.app.data.entities.Book
 import io.legado.app.help.book.BookHelp
 import kotlinx.coroutines.runBlocking
+import me.saket.telephoto.subsamplingimage.SubSamplingImageSource
+import okio.Path.Companion.toOkioPath
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertFalse
 import org.junit.Assert.assertTrue
@@ -84,7 +86,7 @@ class MangaFileRequestTest {
             assertEquals(DataSource.MEMORY_CACHE, next.dataSource)
             assertEquals(1, transfers)
             // 模拟 Compose 请求先退出,区域解码器最后退出。
-            val tiles = requireNotNull(owner.borrowForTiles()).second
+            val tiles = recoveringMangaTileSource(SubSamplingImageSource.file(image.toOkioPath()), owner)
             owner.close()
             BookHelp.clearCache(book)
             assertTrue(image.exists())

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

@@ -8,6 +8,26 @@ import org.junit.Test
 
 class CacheDownloadQueueTest {
 
+    @Test
+    fun explicitUpgradeWaitsForRunningPreloadForSingleAndRange() {
+        for (selection in listOf(ChapterSelection.Single(3), ChapterSelection.Range(3, 4))) {
+            val queue = CacheDownloadQueue()
+            queue.enqueue(CacheDownloadRequest("book", ChapterSelection.Single(3), CacheDownloadSource.ReadPreload))
+            assertEquals(3, queue.next("book", emptySet())?.chapterIndex)
+            queue.enqueue(CacheDownloadRequest("book", selection, CacheDownloadSource.Manual))
+            val next = queue.next("book", setOf(3))
+            if (selection is ChapterSelection.Range) assertEquals(4, next?.chapterIndex)
+            else assertNull(next)
+            assertTrue(queue.isWaiting(3))
+            assertEquals(1, queue.waitingCount())
+            assertFalse(queue.hasLaunchableChapter(setOf(3)))
+            assertTrue(queue.hasLaunchableChapter(emptySet()))
+            assertNull(queue.next("book", setOf(3)))
+            assertEquals(3, queue.next("book", emptySet())?.chapterIndex)
+            assertTrue(queue.isExplicitDownload(3))
+        }
+    }
+
     @Test
     fun explicitPurposeSurvivesReadPreloadRetryAndPrioritization() {
         val queue = CacheDownloadQueue()
@@ -55,6 +75,8 @@ class CacheDownloadQueueTest {
         queue.enqueue(ChapterSelection.Indices(setOf(1, 2)))
 
         assertEquals(2, queue.next("book", setOf(1))?.chapterIndex)
+        assertNull(queue.next("book", setOf(1)))
+        assertEquals(1, queue.next("book", emptySet())?.chapterIndex)
         assertNull(queue.next("book", emptySet()))
     }
 

+ 44 - 0
app/src/test/java/io/legado/app/ui/book/manga/MangaColorFilterTest.kt

@@ -0,0 +1,44 @@
+package io.legado.app.ui.book.manga
+
+import androidx.compose.ui.graphics.ColorMatrix
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+
+class MangaColorFilterTest {
+    private fun ColorMatrix.apply(red: Float, green: Float, blue: Float, alpha: Float = 255f): List<Float> =
+        (0..3).map { row ->
+            this[row, 0] * red + this[row, 1] * green + this[row, 2] * blue +
+                this[row, 3] * alpha + this[row, 4]
+        }
+
+    @Test
+    fun grayscaleRemovesSaturationFromOriginalTilePixels() {
+        val matrix = requireNotNull(mangaColorMatrix(MangaReaderSettings(enableGray = true)))
+        for (pixel in listOf(Triple(255f, 0f, 0f), Triple(0f, 255f, 0f), Triple(50f, 120f, 200f))) {
+            val result = matrix.apply(pixel.first, pixel.second, pixel.third)
+            assertEquals(result[0], result[1], 0.001f)
+            assertEquals(result[1], result[2], 0.001f)
+            assertEquals(255f, result[3], 0.001f)
+        }
+    }
+
+    @Test
+    fun channelFiltersApplyAfterGrayscaleAsBefore() {
+        val gray = requireNotNull(mangaColorMatrix(MangaReaderSettings(enableGray = true)))
+        val tinted = requireNotNull(mangaColorMatrix(MangaReaderSettings(
+            enableGray = true, filterRed = 128, filterAlpha = 64,
+        )))
+        val original = gray.apply(255f, 0f, 0f)
+        val result = tinted.apply(255f, 0f, 0f)
+        assertEquals(original[0] * 127f / 255f, result[0], 0.001f)
+        assertEquals(original[1], result[1], 0.001f)
+        assertEquals(191f, result[3], 0.001f)
+    }
+
+    @Test
+    fun einkKeepsItsThresholdTransformationAndDefaultHasNoFilter() {
+        assertNull(mangaColorMatrix(MangaReaderSettings()))
+        assertNull(mangaColorMatrix(MangaReaderSettings(enableGray = true, enableEInk = true)))
+    }
+}

+ 16 - 6
docs/dev/manga-subsampling-plan.md

@@ -182,7 +182,10 @@ ViewModel 经现有 Gateway/Repository 使用它,不访问 BookHelp、DAO 或
 
 ### 格式、变换与失败降级
 
-- 灰度继续用已有渲染 ColorFilter,文件始终保存未应用展示变换的原图。
+- 灰度现在统一在最终渲染 ColorFilter 中处理(参考 Komikku 的容器绘制滤镜),
+  不再对 Coil preview 单独做灰度 bitmap 变换;预览、原图瓦片、动画与整图回退使用相同矩阵。
+  通道/透明度滤镜继续在灰度之后应用,EInk 仍优先走自己的二值化变换。
+  内存缓存键升级到 v2,防止沿用旧版已经灰度变换的 preview。文件始终保存原图。
 - EInk 首版使用明确的整图渲染分支:由 Coil 生成二值化 painter,配合现有缩放状态/手势,
   不把变换后的 preview 交给会读取未变换原图的自动瓦片路径。
   仅继续使用 `ZoomableAsyncImage(File)` 或设置 diskCachePolicy 不能关闭该区域解码路径。
@@ -195,6 +198,8 @@ ViewModel 经现有 Gateway/Repository 使用它,不访问 BookHelp、DAO 或
   也没有对应 CompositionLocal;不能假定直接调用它已经接入错误回退。
   现在在 `SubSamplingImageSource.decoder()` 返回的 factory/decoder 边界捕获初始化和瓦片异常,
   发出一次请求 owner 的降级状态,保留预览直到 Coil 整图 painter 接管;取消照常传播。
+  错误适配与在线文件租约独立:本地目录、CBZ 解压文件及 content URI 同样包装原 source,
+  在线原图额外获取瓦片租约。无租约不再跳过错误恢复,原 source 的格式/URI 能力与关闭语义保留。
   `ImageRegionDecoder` 在 0.19.0 虽处于 internal 包,声明为 public;没有复制库渲染代码,
   但升级依赖时必须复核此适配点。降级绑定当前请求 owner,重试建立新 owner 后允许再次区域解码;
   尚未实现跨请求的文件版本失败记忆。
@@ -288,18 +293,23 @@ Windows Robolectric 的 API 35 ImageDecoder 文件 JNI 返回 "Only supported on
 多个章节共享一张原图时追加引用;失败下载也保留已登记用途,但文件缺失/无效时绝不判定完成。
 Manual/Batch 队列请求保留显式用途,ReadPreload 不降级已有用途;暂停、优先级与失败重试保留用途。
 已有图片命中也会补登记索引,不因 `hasImageContent` 提前跳过升级;执行中的预加载收到显式请求,
-完成回调会再次复核用途并补登记。正文与真实有效原图仍决定章节完成,索引不能代替文件。
+完成回调会再次复核用途并补登记。用途复核和完成提交与入队共用模型锁;
+仍在执行的同章再次请求保留在队列,范围游标经过执行中章节时转为待复核索引,
+执行中请求不算可启动章节,避免调度循环空转。正文与真实有效原图仍决定章节完成,索引不能代替文件。
 在线预算与章节窗口均保护显式用途;整本缓存的主动清理仍可删除下载,读取租约继续保护活动文件。
 索引损坏时原图仍受保护,完成判定为 false,可通过显式重试补齐引用。
 这是原图→章节用途索引,尚非完整章节下载清单:稳定目录身份、源版本失效、下载取消后主动取消保留、
 逐章删除/共享引用回收与独立下载列表恢复留待后续;现有章节正文/数据库继续负责重建阅读页。
 
-本轮验证:11 个相关测试类、96 项测试全部通过(包含 API 26/35 native graphics 文件链路测试),
-覆盖区域解码初始化/瓦片失败与取消、索引重启/损坏恢复/共享引用、显式用途升级不联网、
-保留文件不占在线预算、队列用途与既有阅读交互。实际执行 `:app:testAppDebugUnitTest`
+本轮验证:13 个相关测试类、104 项测试全部通过(包含 API 26/35 native graphics 文件链路测试),
+覆盖区域解码初始化/瓦片失败与取消、本地无在线租约 source 的降级/关闭、在线生产适配器的瓦片租约、
+灰度矩阵与通道滤镜组合/EInk 优先级、索引重启/损坏恢复/共享引用、显式用途升级不联网、
+保留文件不占在线预算、执行中 Single/Range 请求的保留与可启动判断、既有阅读交互。
+复用审查阶段的编译产物队列复现程序,执行中的同章显式请求现可在预加载完成后再次出队。实际执行 `:app:testAppDebugUnitTest`
 (按上述测试类过滤)、`verifyConfigArchitecture`、`lintAppDebug`,并完成 Kotlin 编译与
 `git diff --check`;lint 仍为原有 118 个警告、13 个提示,未修改 baseline。
-区域错误使用可控 decoder 注入验证;真机原生瓦片失败、回退后的缩放体验和完整断网重建仍未验收。
+区域错误使用可控 decoder 注入验证,灰度验证矩阵对原图像素的结果与滤镜顺序;
+真机原生瓦片/CBZ/content URI 失败、实际灰度绘制、回退后的缩放体验和完整断网重建仍未验收。
 
 ### D. 优化与真机验收