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

fix: publish validated manga image files atomically

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

+ 58 - 127
app/src/main/java/io/legado/app/help/book/BookHelp.kt

@@ -28,7 +28,6 @@ import io.legado.app.utils.NetworkUtils
 import io.legado.app.utils.StringUtils
 import io.legado.app.utils.SvgUtils
 import io.legado.app.utils.UrlUtil
-import io.legado.app.utils.createFileIfNotExist
 import io.legado.app.utils.exists
 import io.legado.app.utils.externalFiles
 import io.legado.app.utils.getFile
@@ -51,13 +50,10 @@ import kotlinx.coroutines.withContext
 import org.apache.commons.text.similarity.JaccardSimilarity
 import org.koin.core.context.GlobalContext
 import splitties.init.appCtx
-import java.io.ByteArrayInputStream
 import java.io.File
 import java.io.FileNotFoundException
 import java.io.FileOutputStream
 import java.io.IOException
-import java.io.InputStream
-import java.util.concurrent.ConcurrentHashMap
 import java.util.zip.ZipFile
 import kotlin.math.abs
 import kotlin.math.max
@@ -73,21 +69,26 @@ object BookHelp {
     private const val cacheFolderName = "book_cache"
     private const val cacheImageFolderName = "images"
     private const val cacheEpubFolderName = "epub"
-    private val downloadImages = ConcurrentHashMap<String, Mutex>()
+    private val imageFiles = BookImageFileStore(::checkImage)
     private val imageDownloadSlots = Semaphore(2)
     private val imageDecodeSlots = Semaphore(1)
 
     val cachePath = FileUtils.getPath(downloadDir, cacheFolderName)
 
     fun clearCache() {
-        FileUtils.delete(
-            FileUtils.getPath(downloadDir, cacheFolderName)
-        )
+        deleteImageCacheDirectory(File(FileUtils.getPath(downloadDir, cacheFolderName)))
     }
 
     fun clearCache(book: Book) {
         val filePath = FileUtils.getPath(downloadDir, cacheFolderName, book.getFolderName())
-        FileUtils.delete(filePath)
+        deleteImageCacheDirectory(File(filePath))
+    }
+
+    private fun deleteImageCacheDirectory(directory: File) {
+        imageFiles.deleteIfIdle(directory) {
+            FileUtils.delete(it.absolutePath)
+            !it.exists()
+        }
     }
 
     fun updateCacheFolder(oldBook: Book, newBook: Book) {
@@ -122,7 +123,7 @@ object BookHelp {
             downloadDir.getFile(cacheFolderName)
                 .listFiles()?.forEach { bookFile ->
                     if (!bookFolderNames.contains(bookFile.name)) {
-                        FileUtils.delete(bookFile.absolutePath)
+                        deleteImageCacheDirectory(bookFile)
                     }
                 }
             downloadDir.getFile(cacheEpubFolderName)
@@ -167,8 +168,9 @@ object BookHelp {
             book.getFolderName(),
             cacheImageFolderName
         ).listFiles()?.forEach { imgFile ->
-            if (!imgNames.contains(imgFile.name)) {
-                imgFile.delete()
+            // 临时文件的所有权属于写入者;清理不能在校验/发布前删除它。
+            if (!imgNames.contains(imgFile.name) && !imgFile.name.endsWith(".tmp")) {
+                imageFiles.deleteIfIdle(imgFile)
             }
         }
     }
@@ -344,74 +346,56 @@ object BookHelp {
         failures
     }
 
-    /**
-     * @return true 表示图片已存在,或本次下载且校验通过。
-     * 数据异常时仍会落盘(避免反复拉坏图),但返回 false 以便调用方记失败。
-     */
+    /** @return true 表示已复用有效图片,或本次获取、校验并原子发布成功。 */
     suspend fun saveImage(
         bookSource: BookSource?,
         book: Book,
         src: String,
         chapter: BookChapter? = null
-    ): Boolean {
-        if (isImageExist(book, src)) {
-            return true
-        }
-        val mutex = synchronized(this) {
-            downloadImages.getOrPut(src) { Mutex() }
-        }
-        mutex.lock()
+    ): Boolean = withContext(IO) {
         try {
-            if (isImageExist(book, src)) {
-                return true
-            }
-            imageDownloadSlots.acquire()
-            try {
-                val analyzeUrl = AnalyzeUrl(
-                    src, source = bookSource, coroutineContext = currentCoroutineContext()
-                )
-                if (ImageUtils.skipDecode(bookSource, isCover = false)) {
-                    analyzeUrl.getInputStreamAwait().use {
-                        writeImage(book, src, it)
-                    }
-                    return true
-                } else {
-                    imageDecodeSlots.acquire()
-                    try {
-                        val bytes = analyzeUrl.getByteArrayAwait()
-                        //某些图片被加密,需要进一步解密
-                        val decoded = runScriptWithContext {
-                            ImageUtils.decode(
-                                src, bytes, isCover = false, bookSource, book
-                            )
-                        }
-                        if (decoded == null) {
-                            AppLog.put("${book.name} ${chapter?.title} 图片 $src 下载失败 解码为空")
-                            return false
+            // 按实际书籍文件串行获取;等待者重新检查有效性,成功时不重复联网。
+            val image = getImage(book, src)
+            imageFiles.withFileLock(image) {
+                if (imageFiles.isValid(image)) return@withFileLock true
+                val context = currentCoroutineContext()
+                imageDownloadSlots.acquire()
+                try {
+                    val analyzeUrl = AnalyzeUrl(src, source = bookSource, coroutineContext = context)
+                    if (ImageUtils.skipDecode(bookSource, isCover = false)) {
+                        analyzeUrl.getInputStreamAwait().use { input ->
+                            imageFiles.write(image, input) { context.ensureActive() }
                         }
-                        // 如果部分图片失效,每次进入正文都会花很长时间再次获取图片数据
-                        // 所以无论如何都要将数据写入到文件里;但仍记失败,避免章节被标为已缓存
-                        writeImage(book, src, decoded)
-                        if (!checkImage(decoded)) {
-                            AppLog.put("${book.name} ${chapter?.title} 图片 $src 下载错误 数据异常")
-                            return false
+                        true
+                    } else {
+                        imageDecodeSlots.acquire()
+                        try {
+                            val bytes = analyzeUrl.getByteArrayAwait()
+                            val decoded = runScriptWithContext {
+                                ImageUtils.decode(src, bytes, isCover = false, bookSource, book)
+                            }
+                            if (decoded == null) {
+                                AppLog.put("${book.name} ${chapter?.title} 图片 $src 下载失败 解码为空")
+                                false
+                            } else {
+                                decoded.inputStream().use { input ->
+                                    imageFiles.write(image, input) { context.ensureActive() }
+                                }
+                                true
+                            }
+                        } finally {
+                            imageDecodeSlots.release()
                         }
-                        return true
-                    } finally {
-                        imageDecodeSlots.release()
                     }
+                } finally {
+                    imageDownloadSlots.release()
                 }
-            } finally {
-                imageDownloadSlots.release()
             }
         } catch (e: Exception) {
             currentCoroutineContext().ensureActive()
             val msg = "${book.name} ${chapter?.title} 图片 $src 下载失败\n${e.localizedMessage}"
             AppLog.put(msg, e)
-            return false
-        } finally {
-            downloadImages.remove(src)
-            mutex.unlock()
+            false
         }
     }
 
@@ -429,7 +413,7 @@ object BookHelp {
     ): Boolean {
         return io.legado.app.help.book.isChapterImageCacheComplete(
             failures,
-            hasImageFilesCached(book, bookChapter),
+            failures == 0 && hasImageContent(book, bookChapter),
         )
     }
 
@@ -444,36 +428,7 @@ object BookHelp {
 
     @Synchronized
     fun writeImage(book: Book, src: String, bytes: ByteArray) {
-        getImage(book, src).createFileIfNotExist().writeBytes(bytes)
-    }
-
-    private fun writeImage(book: Book, src: String, inputStream: InputStream) {
-        val image = getImage(book, src)
-        val parent = image.parentFile ?: return
-        parent.mkdirs()
-        val temp = File(parent, "${image.name}.${System.nanoTime()}.tmp")
-        try {
-            FileOutputStream(temp).use { output ->
-                inputStream.copyTo(output, 16 * 1024)
-            }
-            if (!checkImage(temp)) {
-                AppLog.put("${book.name} 图片 $src 下载错误 数据异常")
-            }
-            if (image.exists()) {
-                image.delete()
-            }
-            if (!temp.renameTo(image)) {
-                image.createFileIfNotExist().outputStream().use { output ->
-                    temp.inputStream().use { input ->
-                        input.copyTo(output, 16 * 1024)
-                    }
-                }
-                temp.delete()
-            }
-        } catch (e: Exception) {
-            temp.delete()
-            throw e
-        }
+        bytes.inputStream().use { imageFiles.write(getImage(book, src), it) }
     }
 
     @Synchronized
@@ -592,22 +547,8 @@ object BookHelp {
             return false
         }
         var ret = true
-        val op = BitmapFactory.Options()
-        op.inJustDecodeBounds = true
         forEachImageSrc(book, bookChapter) { src ->
-            val image = getImage(book, src)
-            if (!image.exists()) {
-                ret = false
-                return@forEachImageSrc
-            }
-            BitmapFactory.decodeFile(image.absolutePath, op)
-            if (op.outWidth < 1 && op.outHeight < 1) {
-                if (SvgUtils.getSize(image.absolutePath) != null) {
-                    return@forEachImageSrc
-                }
-                ret = false
-                image.delete()
-            }
+            if (!imageFiles.isValid(getImage(book, src))) ret = false
         }
         return ret
     }
@@ -677,24 +618,14 @@ object BookHelp {
         buffer.delete(0, keepFrom)
     }
 
-    private fun checkImage(bytes: ByteArray): Boolean {
-        val op = BitmapFactory.Options()
-        op.inJustDecodeBounds = true
-        BitmapFactory.decodeByteArray(bytes, 0, bytes.size, op)
-        if (op.outWidth < 1 && op.outHeight < 1) {
-            return SvgUtils.getSize(ByteArrayInputStream(bytes)) != null
-        }
-        return true
-    }
-
     private fun checkImage(file: File): Boolean {
-        val op = BitmapFactory.Options()
-        op.inJustDecodeBounds = true
-        BitmapFactory.decodeFile(file.absolutePath, op)
-        if (op.outWidth < 1 && op.outHeight < 1) {
-            return SvgUtils.getSize(file.absolutePath) != null
+        val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
+        file.inputStream().use { BitmapFactory.decodeStream(it, null, options) }
+        if (options.outWidth > 0 && options.outHeight > 0) return true
+        // SvgUtils 的路径重载不负责关闭输入流;这里明确拥有并关闭。
+        return file.inputStream().use { input ->
+            SvgUtils.getSize(input)?.let { it.width > 0 && it.height > 0 } == true
         }
-        return true
     }
 
     /**

+ 96 - 0
app/src/main/java/io/legado/app/help/book/BookImageFileStore.kt

@@ -0,0 +1,96 @@
+package io.legado.app.help.book
+
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+import java.io.InputStream
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption.ATOMIC_MOVE
+import java.nio.file.StandardCopyOption.REPLACE_EXISTING
+
+/** BookHelp 图片文件的发布边界;临时文件永远不作为可读图片暴露。 */
+internal class BookImageFileStore(private val validate: (File) -> Boolean) {
+    private class Entry {
+        val mutex = Mutex()
+        var users = 0
+    }
+
+    private val entries = mutableMapOf<String, Entry>()
+
+    /** 与获取写入锁原子协调;目录包含正在写入或等待写入的文件时整次清理跳过。 */
+    fun deleteIfIdle(file: File, delete: (File) -> Boolean = File::delete): Boolean =
+        synchronized(entries) {
+            val path = file.absolutePath
+            val prefix = path + File.separator
+            if (entries.keys.any { it == path || it.startsWith(prefix) }) {
+                false
+            } else {
+                delete(file)
+            }
+        }
+
+    /** users 包括等待者,最后一位退出后才回收,避免同一文件同时出现两把锁。 */
+    suspend fun <T> withFileLock(file: File, block: suspend () -> T): T {
+        val key = file.absolutePath
+        val entry = synchronized(entries) {
+            entries.getOrPut(key, ::Entry).also { it.users++ }
+        }
+        try {
+            return entry.mutex.withLock { block() }
+        } finally {
+            synchronized(entries) {
+                if (--entry.users == 0) entries.remove(key)
+            }
+        }
+    }
+
+    fun isValid(file: File): Boolean = try {
+        file.isFile && file.length() > 0 && validate(file)
+    } catch (_: IOException) {
+        // 外部存储不可读或检查期间被清理,不能当作有效缓存。
+        false
+    }
+
+    /** 输入流由调用方关闭;失败保留原来的有效文件,不降级为直接复制到目标。 */
+    fun write(file: File, input: InputStream, ensureActive: () -> Unit = {}) {
+        // 同步 writeImage 也经过此入口,不能只保护 suspend saveImage 的写入。
+        val key = file.absolutePath
+        val entry = synchronized(entries) {
+            entries.getOrPut(key, ::Entry).also { it.users++ }
+        }
+        try {
+            writeToTemporaryFile(file, input, ensureActive)
+        } finally {
+            synchronized(entries) {
+                if (--entry.users == 0) entries.remove(key)
+            }
+        }
+    }
+
+    private fun writeToTemporaryFile(file: File, input: InputStream, ensureActive: () -> Unit) {
+        val parent = file.absoluteFile.parentFile ?: throw IOException("图片目录不存在")
+        if (!parent.isDirectory && !parent.mkdirs()) throw IOException("无法创建图片目录: $parent")
+        val temp = File.createTempFile("${file.name}.", ".tmp", parent)
+        try {
+            FileOutputStream(temp).use { output ->
+                val buffer = ByteArray(16 * 1024)
+                while (true) {
+                    ensureActive()
+                    val count = input.read(buffer)
+                    if (count == -1) break
+                    output.write(buffer, 0, count)
+                }
+                output.fd.sync()
+            }
+            ensureActive()
+            if (!isValid(temp)) throw IOException("图片数据无效")
+            ensureActive()
+            // 同目录原子替换:外部存储若不支持则报错,不暴露复制中的半文件。
+            Files.move(temp.toPath(), file.toPath(), ATOMIC_MOVE, REPLACE_EXISTING)
+        } finally {
+            temp.delete()
+        }
+    }
+}

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

@@ -0,0 +1,128 @@
+package io.legado.app.help.book
+
+import android.app.Application
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.util.Base64
+import io.legado.app.constant.AppLog
+import io.legado.app.data.entities.Book
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertArrayEquals
+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 org.robolectric.annotation.GraphicsMode
+import splitties.init.injectAsAppCtx
+import java.io.ByteArrayOutputStream
+import java.io.IOException
+
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [26, 35])
+@GraphicsMode(GraphicsMode.Mode.NATIVE)
+class BookHelpImageFileTest {
+    private val book = Book(bookUrl = "book-image-contract", name = "Image contract")
+
+    @Before
+    fun setUp() {
+        RuntimeEnvironment.getApplication().injectAsAppCtx()
+    }
+
+    private fun png(): ByteArray {
+        val bitmap = Bitmap.createBitmap(4, 6, Bitmap.Config.ARGB_8888)
+        return try {
+            ByteArrayOutputStream().use { output ->
+                assertTrue(bitmap.compress(Bitmap.CompressFormat.PNG, 100, output))
+                output.toByteArray()
+            }
+        } finally {
+            bitmap.recycle()
+        }
+    }
+
+    @Test
+    fun `valid cached original is reused without resolving its network URL`() = runBlocking {
+        val src = "https://invalid.example/original.jpg,{\"headers\":{\"Referer\":\"original\"}}"
+        val image = BookHelp.getImage(book, src)
+        try {
+            val bytes = png()
+            BookHelp.writeImage(book, src, bytes)
+            assertTrue(BookHelp.saveImage(null, book, src))
+            assertArrayEquals(bytes, image.readBytes())
+            val dimensions = BitmapFactory.Options().apply { inJustDecodeBounds = true }
+            image.inputStream().use { BitmapFactory.decodeStream(it, null, dimensions) }
+            assertEquals(4, dimensions.outWidth)
+            assertEquals(6, dimensions.outHeight)
+        } finally {
+            image.delete()
+        }
+    }
+
+    @Test
+    fun `invalid bytes are rejected without replacing the cached original`() {
+        val src = "https://invalid.example/replacement.jpg"
+        val image = BookHelp.getImage(book, src)
+        try {
+            val original = png()
+            BookHelp.writeImage(book, src, original)
+            try {
+                BookHelp.writeImage(book, src, "not an image".toByteArray())
+                error("Expected invalid image to fail")
+            } catch (_: IOException) {
+                assertArrayEquals(original, image.readBytes())
+            }
+        } finally {
+            image.delete()
+        }
+    }
+
+    @Test
+    fun `valid SVG survives the shared file validation path`() {
+        val src = "https://invalid.example/page.svg"
+        val image = BookHelp.getImage(book, src)
+        try {
+            val svg = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"8\" height=\"12\"><rect width=\"8\" height=\"12\"/></svg>".toByteArray()
+            BookHelp.writeImage(book, src, svg)
+            assertArrayEquals(svg, image.readBytes())
+        } finally {
+            image.delete()
+        }
+    }
+
+    @Test
+    fun `inline transfer replaces invalid legacy file with a validated original`() = runBlocking {
+        val bytes = png()
+        val src = "data:image/png;base64,${Base64.encodeToString(bytes, Base64.NO_WRAP)}"
+        val image = BookHelp.getImage(book, src)
+        try {
+            image.parentFile!!.mkdirs()
+            image.writeBytes("old broken image".toByteArray())
+            assertTrue(BookHelp.saveImage(null, book, src))
+            assertArrayEquals(bytes, image.readBytes())
+            assertTrue(BookHelp.saveImage(null, book, src))
+        } finally {
+            image.delete()
+        }
+    }
+
+    @Test
+    fun `failed inline image cannot leave a file that the next attempt treats as ready`() = runBlocking {
+        val src = "data:image/png;base64,bm90IGFuIGltYWdl"
+        val image = BookHelp.getImage(book, src)
+        try {
+            image.delete()
+            assertFalse(BookHelp.saveImage(null, book, src))
+            assertTrue(AppLog.logs.first().second.contains("图片数据无效"))
+            assertFalse(image.exists())
+            assertFalse(BookHelp.saveImage(null, book, src))
+            assertFalse(image.exists())
+        } finally {
+            image.delete()
+        }
+    }
+}

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

@@ -0,0 +1,251 @@
+package io.legado.app.help.book
+
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertArrayEquals
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.io.File
+import java.io.IOException
+import java.io.InputStream
+import java.nio.file.Files
+import kotlin.coroutines.cancellation.CancellationException
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class BookImageFileStoreTest {
+    private val image = byteArrayOf(1, 2, 3, 4)
+    private val store = BookImageFileStore { it.readBytes().contentEquals(image) }
+
+    private fun withDirectory(block: (File) -> Unit) {
+        val root = Files.createTempDirectory("book-image-test").toFile()
+        try {
+            block(root)
+        } finally {
+            root.deleteRecursively()
+        }
+    }
+
+    @Test
+    fun `synchronous publication is protected from cleanup during validation`() = withDirectory { root ->
+        val target = root.resolve("page.jpg")
+        lateinit var validatingStore: BookImageFileStore
+        validatingStore = BookImageFileStore {
+            assertFalse(validatingStore.deleteIfIdle(root, File::deleteRecursively))
+            it.readBytes().contentEquals(image)
+        }
+        image.inputStream().use { validatingStore.write(target, it) }
+        assertArrayEquals(image, target.readBytes())
+        assertTrue(validatingStore.deleteIfIdle(root, File::deleteRecursively))
+    }
+
+    @Test
+    fun `cleanup cannot delete an active target or its parent directory`() = runTest {
+        val root = Files.createTempDirectory("book-image-cleanup").toFile()
+        try {
+            val target = root.resolve("images/page.jpg").apply {
+                requireNotNull(parentFile).mkdirs()
+                writeBytes(image)
+            }
+            store.withFileLock(target) {
+                assertFalse(store.deleteIfIdle(target))
+                assertFalse(store.deleteIfIdle(root, File::deleteRecursively))
+                assertArrayEquals(image, target.readBytes())
+                val unrelated = root.resolve("other.jpg").apply { writeBytes(image) }
+                assertTrue(store.deleteIfIdle(unrelated))
+            }
+            assertTrue(store.deleteIfIdle(root, File::deleteRecursively))
+            assertFalse(root.exists())
+        } finally {
+            root.deleteRecursively()
+        }
+    }
+
+    @Test
+    fun `queued writer protects target until it finishes`() = runTest {
+        val root = Files.createTempDirectory("book-image-cleanup-waiter").toFile()
+        try {
+            val target = root.resolve("page.jpg").apply { writeBytes(image) }
+            val ownerStarted = CompletableDeferred<Unit>()
+            val ownerRelease = CompletableDeferred<Unit>()
+            val waiterStarted = CompletableDeferred<Unit>()
+            val waiterRelease = CompletableDeferred<Unit>()
+            val owner = launch {
+                store.withFileLock(target) {
+                    ownerStarted.complete(Unit)
+                    ownerRelease.await()
+                }
+            }
+            ownerStarted.await()
+            val waiter = launch {
+                store.withFileLock(target) {
+                    waiterStarted.complete(Unit)
+                    waiterRelease.await()
+                }
+            }
+            runCurrent()
+            ownerRelease.complete(Unit)
+            owner.join()
+            assertFalse(store.deleteIfIdle(target))
+            waiterStarted.await()
+            waiterRelease.complete(Unit)
+            waiter.join()
+            assertTrue(store.deleteIfIdle(target))
+        } finally {
+            root.deleteRecursively()
+        }
+    }
+
+    @Test
+    fun `publish exposes only the complete validated image and replaces atomically`() = withDirectory { root ->
+        val oldImage = byteArrayOf(5, 6, 7, 8)
+        val target = root.resolve("image.jpg").apply { writeBytes(oldImage) }
+        var observedOldFile = false
+        val validatingStore = BookImageFileStore {
+            assertArrayEquals(oldImage, target.readBytes())
+            observedOldFile = true
+            it.readBytes().contentEquals(image)
+        }
+        image.inputStream().use { validatingStore.write(target, it) }
+        assertTrue(observedOldFile)
+        assertArrayEquals(image, target.readBytes())
+        assertEquals(listOf(target.name), root.listFiles()!!.map { it.name })
+    }
+
+    @Test
+    fun `failed transfer leaves no visible image or temporary file`() = withDirectory { root ->
+        val target = root.resolve("image.jpg")
+        val brokenStream = object : InputStream() {
+            private var reads = 0
+            override fun read(): Int {
+                if (reads++ < 2) return 1
+                throw IOException("connection lost")
+            }
+        }
+        try {
+            brokenStream.use { store.write(target, it) }
+            error("Expected transfer failure")
+        } catch (_: IOException) {
+            assertFalse(target.exists())
+            assertTrue(root.listFiles()!!.isEmpty())
+        }
+    }
+
+    @Test
+    fun `invalid bytes cannot replace an existing valid image`() = withDirectory { root ->
+        val target = root.resolve("image.jpg").apply { writeBytes(image) }
+        try {
+            byteArrayOf(9).inputStream().use { store.write(target, it) }
+            error("Expected validation failure")
+        } catch (_: IOException) {
+            assertTrue(store.isValid(target))
+            assertEquals(1, root.listFiles()!!.size)
+        }
+    }
+
+    @Test
+    fun `cancellation before publication preserves previous image and cleans temporary file`() = withDirectory { root ->
+        val target = root.resolve("image.jpg").apply { writeBytes(image) }
+        var checks = 0
+        try {
+            image.inputStream().use { input ->
+                store.write(target, input) {
+                    if (++checks == 3) throw CancellationException("cancelled")
+                }
+            }
+            error("Expected cancellation")
+        } catch (_: CancellationException) {
+            assertArrayEquals(image, target.readBytes())
+            assertEquals(1, root.listFiles()!!.size)
+        }
+    }
+
+    @Test
+    fun `existence is insufficient and failed data can be replaced on retry`() = withDirectory { root ->
+        val target = root.resolve("image.jpg").apply { writeBytes(byteArrayOf(9)) }
+        assertFalse(store.isValid(target))
+        image.inputStream().use { store.write(target, it) }
+        assertTrue(store.isValid(target))
+        target.writeBytes(byteArrayOf())
+        assertFalse(store.isValid(target))
+        assertFalse(store.isValid(root))
+    }
+
+    @Test
+    fun `concurrent consumers recheck validity under the same file lock`() = runTest {
+        val root = Files.createTempDirectory("book-image-concurrent").toFile()
+        try {
+            val target = root.resolve("image.jpg")
+            val acquired = CompletableDeferred<Unit>()
+            val finishTransfer = CompletableDeferred<Unit>()
+            var transfers = 0
+            val owner = async {
+                store.withFileLock(target) {
+                    if (!store.isValid(target)) {
+                        transfers++
+                        acquired.complete(Unit)
+                        finishTransfer.await()
+                        image.inputStream().use { store.write(target, it) }
+                    }
+                }
+            }
+            acquired.await()
+            val waiter = async {
+                store.withFileLock(target) {
+                    if (!store.isValid(target)) transfers++
+                }
+            }
+            runCurrent()
+            finishTransfer.complete(Unit)
+            awaitAll(owner, waiter)
+            assertEquals(1, transfers)
+        } finally {
+            root.deleteRecursively()
+        }
+    }
+
+    @Test
+    fun `finishing owner and cancelling waiter cannot create a second lock`() = runTest {
+        val target = File("book-image-lock-test.jpg")
+        val acquired = CompletableDeferred<Unit>()
+        val releaseOwner = CompletableDeferred<Unit>()
+        val waiterAcquired = CompletableDeferred<Unit>()
+        val releaseWaiter = CompletableDeferred<Unit>()
+        val owner = launch {
+            store.withFileLock(target) {
+                acquired.complete(Unit)
+                releaseOwner.await()
+            }
+        }
+        acquired.await()
+        val cancelledWaiter = launch { store.withFileLock(target) { error("Cancelled waiter entered") } }
+        val waiter = launch {
+            store.withFileLock(target) {
+                waiterAcquired.complete(Unit)
+                releaseWaiter.await()
+            }
+        }
+        runCurrent()
+        cancelledWaiter.cancelAndJoin()
+        releaseOwner.complete(Unit)
+        owner.join()
+        waiterAcquired.await()
+        var entered = false
+        val newcomer = launch { store.withFileLock(target) { entered = true } }
+        runCurrent()
+        assertFalse(entered)
+        releaseWaiter.complete(Unit)
+        waiter.join()
+        newcomer.join()
+        assertTrue(entered)
+        // 最后一位退出后,同一身份仍可以重新获取。
+        store.withFileLock(target) { assertTrue(entered) }
+    }
+}

+ 52 - 7
docs/dev/manga-subsampling-plan.md

@@ -1,7 +1,8 @@
 # 分页漫画区域解码与下载共用文件链路
 
-> 状态:实施设计,尚未接入区域解码。2026-09-26 对照仓库源码、Coil 3.6.3 和
-> Telephoto 0.19.0 校正。当前补丁只修正完整 URL 进度身份与分页解码目标。
+> 状态:实施中,尚未接入区域解码。2026-09-26 对照仓库源码、Coil 3.6.3、
+> Telephoto 0.19.0 与本机 Komikku 校正。进度与尺寸修复已提交为 `9f6f87872`;
+> 首个存储切片接入 BookHelp 的有效性检查、原子发布及等待者安全的文件锁。
 >
 > 目标:分页缩放时按需读取原图细节;阅读与后续漫画下载共用同一份有效文件,
 > 已有文件不重复获取,已保留的章节断网可读。
@@ -22,11 +23,13 @@
   `externalFiles/book_cache/<bookFolderName>/images/<md5Encode16(src)>.<suffix>`。
   `flowImages` 与下载使用同一章节基地址解析图片地址,应该沿用完整解析后原始 `src`
   (包含书源选项)作为身份,不能用带临时 token 的最终网络 URL 替代。
-- `isImageExist` 只看 exists;字节数组 `writeImage` 直接写目标文件。
-  `saveImage` 会保留部分坏数据,下次见文件存在就返回成功;流式分支校验失败也仍发布文件。
-  流式写入已有临时文件,但 rename 失败会退回直接复制到目标,不保证读者看不到半文件。
-- `saveImage` 的锁按 src 而不是书籍+src 建立,退出时直接移除;等待者尚在时,新请求可取得
-  新锁。阅读若在 Fetcher 中另写文件,也不会参与这个锁。共用路径不等于共用并发控制。
+- `isImageExist` 仍只看 exists,保留旧 UI/队列的低成本提示语义,不是下载完成证明。
+  首个存储切片让 `saveImage` 在文件锁内检查文件格式/尺寸,字节与流写入都先写临时文件、
+  校验后原子替换;不再发布新坏图或降级为直接复制到可见目标。
+  历史文件目前只检查结构,不具备完整性/书源版本的持久有效记录。
+- `saveImage` 的锁现在按实际书籍文件路径建立,引用数包括等待者,最后一个退出才移除。
+  当前是串行获取:成功后的等待者复用文件;首位取消时下一位接手获取。
+  尚未实现独立于任一订阅者的共享任务,也未接入阅读 Fetcher;不能宣称完整跨消费者去重已完成。
 - `clearInvalidCache` 在应用启动及主页面清理时执行;`clearComicCache` 按阅读章节窗口删除图片。
   现有规则不识别显式下载、活动阅读和瓦片解码中的文件。
 - `MangaSettings.autoOfflineCache` 已存在,不要新增同名设置。当前主要控制章节预取,
@@ -50,6 +53,26 @@ Telephoto 0.19.0 的 `Coil3ImageSource.kt` 在成功结果为 BitmapImage 时,
 来源:本机相应版本 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)。
 
+### Komikku 可以参考什么
+
+对照仓库 `D:/Project/komikku` 的下列实际调用链(只读参考,不修改该工程):
+
+- `HttpPageLoader.internalLoadPage`:优先查 ChapterCache,miss 才请求网络;
+  `putImageToCache` 完成后才设置 page.stream 与 Ready。加载顺序可借鉴:完整文件在先,渲染在后。
+- `HttpPageLoader` 的 PriorityBlockingQueue:当前页/重试优先于普通预取,线程数有限。
+  可借鉴调度语义,不照搬独立的 readerThreads 设置与全局协程管理。
+- `ChapterCache`:保存章节 page list 和原图,DiskLruCache editor commit 后才发布。
+  在线缓存具有明确字节容量,支持重开、识别已淘汰图片并重新排队。
+- `Downloader.getOrDownloadImage`:已有下载优先,否则使用 ChapterCache 字节,最后才联网。
+  `copyImageFromCache` 把缓存复制到下载目录并删缓存文件;因此这是获取复用,**不是同一文件存储**,
+  也不能把“使用了缓存”当作无复制、无额外磁盘占用的证明。
+- `ReaderPageImageView`:静态图使用 SubsamplingScaleImageView,动画图单独渲染,
+  设置 onReady/onImageLoadError 再更新阅读器状态。可参考就绪与解码失败的阶段划分。
+
+结论:plan 的文件优先/区域解码方向与成熟阅读器一致;“阅读与下载永远共用同一份文件”
+是本项目自己的增强目标,Komikku 没有替我们证明它。不能照搬其在线 LRU 清理来删除保留下载,
+也不在本项目引入 SSIV、Voyager、SQLDelight 或其 source extension 架构。
+
 ## 3. 推荐决策:一份原图,独立保留策略
 
 所有分页阅读都先取得可用于随机读取的文件,再交给 `ZoomableAsyncImage`。
@@ -185,6 +208,28 @@ ViewModel 经现有 Gateway/Repository 使用它,不访问 BookHelp、DAO 或
 测试:取消/IO 失败无半文件、坏文件可重试、同图并发一次获取、等待者取消、锁回收竞争、
 原始 src/headers/解密参数兼容、历史文件采纳、失败批次不标下载完成。
 
+首个已实现子切片:`BookImageFileStore` 由 BookHelp 实际使用,负责非空/格式校验、
+同目录临时写入、原子发布与按文件串行获取;下载完成检查改为实际图片有效性检查。
+`hasImageContent` 不再边检查边删除坏图,避免检查后新文件已发布却被旧检查删除的竞争。
+图片及目录清理与写入登记原子协调:存在写入者或排队等待者时跳过清理,
+章节窗口清理不删除写入中的临时文件;同步 `writeImage` 也登记写入保护。
+目前是写入保护,不代表已经保护未来瓦片解码器的读取生命周期。
+此时还没有引入阅读自动落盘,所以不会增加无限增长的在线阅读存储。
+
+仍未完成:持久有效记录/源版本失效、完整传输长度记录、共享任务订阅取消语义、
+章节索引与保留用途、活动读取保护及保留感知清理。目录迁移仍需协调活动资源。
+格式/尺寸检查不是整图所有像素可解码的证明;
+后续渲染错误必须保持可重试,不能因已有文件无条件标 Ready。
+
+首个子切片的验证:`BookImageFileStoreTest` 10 项、`BookHelpImageFileTest` 在 API 26/35
+各 5 项 native graphics 测试、`BookHelpImageCacheCompleteTest` 1 项,共 21 项通过。
+`compileAppDebugKotlin`、`verifyConfigArchitecture`、`lintAppDebug` 与 `git diff --check` 通过;
+lint 仍有现有的 118 个警告、13 个提示,本切片没有放宽 baseline。
+覆盖并发、等待者取消、传输失败、发布前取消、坏数据不覆盖原图、PNG/SVG、
+历史坏文件重试、已有有效文件不解析网络 URL、写入与清理竞争。
+校验通过显式关闭的输入流读取图片头部。
+这些测试不替代真实外部存储、认证书源 JS 解密、断网章节恢复和瓦片生命周期的验证。
+
 ### B. 漫画会话到本地文件渲染
 
 接入当前页与有限预取,替换 URL 图片请求与自动背景提色;接 File 模型、错误降级、