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

feat: render paged manga from leased original files

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

+ 107 - 6
app/src/main/java/io/legado/app/help/book/BookHelp.kt

@@ -16,6 +16,8 @@ import io.legado.app.domain.gateway.DownloadCacheSettingsGateway
 import io.legado.app.domain.gateway.ReadSettingsGateway
 import io.legado.app.domain.gateway.ReadSettingsGateway
 import io.legado.app.help.book.BookHelp.saveText
 import io.legado.app.help.book.BookHelp.saveText
 import io.legado.app.help.book.BookHelp.saveToLocalTxt
 import io.legado.app.help.book.BookHelp.saveToLocalTxt
+import io.legado.app.help.glide.progress.ProgressManager
+import io.legado.app.help.glide.progress.ProgressResponseBody
 import io.legado.app.model.analyzeRule.AnalyzeUrl
 import io.legado.app.model.analyzeRule.AnalyzeUrl
 import io.legado.app.model.localBook.LocalBook
 import io.legado.app.model.localBook.LocalBook
 import io.legado.app.model.localBook.TextFile
 import io.legado.app.model.localBook.TextFile
@@ -32,9 +34,14 @@ import io.legado.app.utils.exists
 import io.legado.app.utils.externalFiles
 import io.legado.app.utils.externalFiles
 import io.legado.app.utils.getFile
 import io.legado.app.utils.getFile
 import io.legado.app.utils.isContentScheme
 import io.legado.app.utils.isContentScheme
+import io.legado.app.utils.isWifiConnect
 import io.legado.app.utils.onEachParallel
 import io.legado.app.utils.onEachParallel
 import io.legado.app.utils.postEvent
 import io.legado.app.utils.postEvent
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.channels.Channel
 import kotlinx.coroutines.coroutineScope
 import kotlinx.coroutines.coroutineScope
 import kotlinx.coroutines.currentCoroutineContext
 import kotlinx.coroutines.currentCoroutineContext
 import kotlinx.coroutines.ensureActive
 import kotlinx.coroutines.ensureActive
@@ -54,6 +61,10 @@ import java.io.File
 import java.io.FileNotFoundException
 import java.io.FileNotFoundException
 import java.io.FileOutputStream
 import java.io.FileOutputStream
 import java.io.IOException
 import java.io.IOException
+import java.io.Closeable
+import java.io.InputStream
+import java.util.concurrent.atomic.AtomicBoolean
+import java.util.concurrent.ConcurrentHashMap
 import java.util.zip.ZipFile
 import java.util.zip.ZipFile
 import kotlin.math.abs
 import kotlin.math.abs
 import kotlin.math.max
 import kotlin.math.max
@@ -72,9 +83,33 @@ object BookHelp {
     private val imageFiles = BookImageFileStore(::checkImage)
     private val imageFiles = BookImageFileStore(::checkImage)
     private val imageDownloadSlots = Semaphore(2)
     private val imageDownloadSlots = Semaphore(2)
     private val imageDecodeSlots = Semaphore(1)
     private val imageDecodeSlots = Semaphore(1)
+    private const val ONLINE_IMAGE_BUDGET = 256L * 1024 * 1024
+    private val imageCleanupScope = CoroutineScope(SupervisorJob() + IO)
+    private val imageCleanupRequests = Channel<Unit>(Channel.CONFLATED)
+    private val pendingImageCacheMoves = ConcurrentHashMap<File, File>()
 
 
     val cachePath = FileUtils.getPath(downloadDir, cacheFolderName)
     val cachePath = FileUtils.getPath(downloadDir, cacheFolderName)
 
 
+    init {
+        imageCleanupScope.launch {
+            for (request in imageCleanupRequests) {
+                try {
+                    pendingImageCacheMoves.forEach { (source, target) ->
+                        if (source.exists() && imageFiles.moveIfIdle(source, target, FileUtils::move)) {
+                            pendingImageCacheMoves.remove(source, target)
+                            if (pendingImageCacheMoves.isNotEmpty()) imageCleanupRequests.trySend(Unit)
+                        } else if (!source.exists() && !pendingImageCacheMoves.containsValue(source)) {
+                            pendingImageCacheMoves.remove(source, target)
+                        }
+                    }
+                    imageFiles.trimOnlineCache(File(cachePath), ONLINE_IMAGE_BUDGET)
+                } catch (error: IOException) {
+                    AppLog.put("在线漫画缓存清理失败", error)
+                }
+            }
+        }
+    }
+
     fun clearCache() {
     fun clearCache() {
         deleteImageCacheDirectory(File(FileUtils.getPath(downloadDir, cacheFolderName)))
         deleteImageCacheDirectory(File(FileUtils.getPath(downloadDir, cacheFolderName)))
     }
     }
@@ -105,7 +140,14 @@ object BookHelp {
             cacheFolderName,
             cacheFolderName,
             newFolderName
             newFolderName
         )
         )
-        FileUtils.move(oldFolderPath, newFolderPath)
+        val source = File(oldFolderPath)
+        val target = File(newFolderPath)
+        if ((source.exists() || pendingImageCacheMoves.containsValue(source)) &&
+            !imageFiles.moveIfIdle(source, target, FileUtils::move)
+        ) {
+            pendingImageCacheMoves[source] = target
+            imageCleanupRequests.trySend(Unit)
+        }
     }
     }
 
 
     /**
     /**
@@ -170,7 +212,10 @@ object BookHelp {
         ).listFiles()?.forEach { imgFile ->
         ).listFiles()?.forEach { imgFile ->
             // 临时文件的所有权属于写入者;清理不能在校验/发布前删除它。
             // 临时文件的所有权属于写入者;清理不能在校验/发布前删除它。
             if (!imgNames.contains(imgFile.name) && !imgFile.name.endsWith(".tmp")) {
             if (!imgNames.contains(imgFile.name) && !imgFile.name.endsWith(".tmp")) {
-                imageFiles.deleteIfIdle(imgFile)
+                imageFiles.deleteIfIdle(imgFile) {
+                    // 普通在线缓存由字节预算淘汰,不能被章节窗口清理绕过。
+                    if (it.name == BookImageFileStore.ONLINE_MARKER_DIRECTORY || imageFiles.isOnline(it)) false else it.delete()
+                }
             }
             }
         }
         }
     }
     }
@@ -351,26 +396,40 @@ object BookHelp {
         bookSource: BookSource?,
         bookSource: BookSource?,
         book: Book,
         book: Book,
         src: String,
         src: String,
-        chapter: BookChapter? = null
+        chapter: BookChapter? = null,
+        onlineOnly: Boolean = false,
+        loadOnlyWifi: Boolean = false,
+        onDownload: () -> Unit = {},
     ): Boolean = withContext(IO) {
     ): Boolean = withContext(IO) {
         try {
         try {
             // 按实际书籍文件串行获取;等待者重新检查有效性,成功时不重复联网。
             // 按实际书籍文件串行获取;等待者重新检查有效性,成功时不重复联网。
             val image = getImage(book, src)
             val image = getImage(book, src)
             imageFiles.withFileLock(image) {
             imageFiles.withFileLock(image) {
-                if (imageFiles.isValid(image)) return@withFileLock true
+                if (imageFiles.isValid(image)) {
+                    if (!onlineOnly) imageFiles.setOnline(image, false)
+                    imageFiles.touch(image)
+                    return@withFileLock true
+                }
+                onDownload()
                 val context = currentCoroutineContext()
                 val context = currentCoroutineContext()
                 imageDownloadSlots.acquire()
                 imageDownloadSlots.acquire()
                 try {
                 try {
                     val analyzeUrl = AnalyzeUrl(src, source = bookSource, coroutineContext = context)
                     val analyzeUrl = AnalyzeUrl(src, source = bookSource, coroutineContext = context)
+                    if (onlineOnly && loadOnlyWifi && !appCtx.isWifiConnect &&
+                        !analyzeUrl.urlNoQuery.startsWith("data:", true)
+                    ) throw IOException("WiFi not available, loadOnlyWifi enabled")
+                    // 在首次发布前登记。历史未分类文件仍保守保留,下载则升级为保留文件。
+                    if (!onlineOnly) imageFiles.setOnline(image, false)
+                    else if (!image.exists()) imageFiles.setOnline(image, true)
                     if (ImageUtils.skipDecode(bookSource, isCover = false)) {
                     if (ImageUtils.skipDecode(bookSource, isCover = false)) {
-                        analyzeUrl.getInputStreamAwait().use { input ->
+                        imageInputStream(analyzeUrl, src, onlineOnly).use { input ->
                             imageFiles.write(image, input) { context.ensureActive() }
                             imageFiles.write(image, input) { context.ensureActive() }
                         }
                         }
                         true
                         true
                     } else {
                     } else {
                         imageDecodeSlots.acquire()
                         imageDecodeSlots.acquire()
                         try {
                         try {
-                            val bytes = analyzeUrl.getByteArrayAwait()
+                            val bytes = imageInputStream(analyzeUrl, src, onlineOnly).use(InputStream::readBytes)
                             val decoded = runScriptWithContext {
                             val decoded = runScriptWithContext {
                                 ImageUtils.decode(src, bytes, isCover = false, bookSource, book)
                                 ImageUtils.decode(src, bytes, isCover = false, bookSource, book)
                             }
                             }
@@ -399,6 +458,48 @@ object BookHelp {
         }
         }
     }
     }
 
 
+    private suspend fun imageInputStream(analyzeUrl: AnalyzeUrl, src: String, reportProgress: Boolean): InputStream {
+        if (!reportProgress || analyzeUrl.urlNoQuery.startsWith("data:", true)) {
+            return analyzeUrl.getInputStreamAwait()
+        }
+        val response = analyzeUrl.getResponseAwait()
+        if (!response.isSuccessful) {
+            response.close()
+            throw IOException("HTTP ${response.code}")
+        }
+        return ProgressResponseBody(src, ProgressManager.LISTENER, response.body).byteStream()
+    }
+
+    /** Android 图片适配器使用:先持有租约,再获取文件,避免发布与淘汰之间存在空窗。 */
+    suspend fun acquireReadingImage(
+        bookSource: BookSource?, book: Book, src: String, loadOnlyWifi: Boolean = false,
+        onDownload: () -> Unit = {},
+    ): Pair<File, Closeable> {
+        val file = getImage(book, src)
+        val lease = pinImageFile(file)
+        try {
+            if (!saveImage(bookSource, book, src, onlineOnly = true, loadOnlyWifi = loadOnlyWifi, onDownload = onDownload)) {
+                throw IOException("图片获取失败")
+            }
+            imageFiles.trimOnlineCache(File(cachePath), ONLINE_IMAGE_BUDGET)
+            return file to lease
+        } catch (error: Throwable) {
+            lease.close()
+            throw error
+        }
+    }
+
+    fun pinImageFile(file: File): Closeable {
+        val lease = imageFiles.pin(file)
+        val closed = AtomicBoolean()
+        return Closeable {
+            if (closed.compareAndSet(false, true)) {
+                lease.close()
+                imageCleanupRequests.trySend(Unit)
+            }
+        }
+    }
+
     /**
     /**
      * 一次 saveImages 批次是否可视为章节图片缓存完成。
      * 一次 saveImages 批次是否可视为章节图片缓存完成。
      */
      */

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

@@ -6,18 +6,81 @@ import java.io.File
 import java.io.FileOutputStream
 import java.io.FileOutputStream
 import java.io.IOException
 import java.io.IOException
 import java.io.InputStream
 import java.io.InputStream
+import java.io.Closeable
 import java.nio.file.Files
 import java.nio.file.Files
 import java.nio.file.StandardCopyOption.ATOMIC_MOVE
 import java.nio.file.StandardCopyOption.ATOMIC_MOVE
 import java.nio.file.StandardCopyOption.REPLACE_EXISTING
 import java.nio.file.StandardCopyOption.REPLACE_EXISTING
+import java.util.concurrent.atomic.AtomicBoolean
 
 
 /** BookHelp 图片文件的发布边界;临时文件永远不作为可读图片暴露。 */
 /** BookHelp 图片文件的发布边界;临时文件永远不作为可读图片暴露。 */
 internal class BookImageFileStore(private val validate: (File) -> Boolean) {
 internal class BookImageFileStore(private val validate: (File) -> Boolean) {
+    companion object {
+        const val ONLINE_MARKER_DIRECTORY = ".online"
+    }
     private class Entry {
     private class Entry {
         val mutex = Mutex()
         val mutex = Mutex()
         var users = 0
         var users = 0
     }
     }
 
 
     private val entries = mutableMapOf<String, Entry>()
     private val entries = mutableMapOf<String, Entry>()
+    private val evictionLock = Any()
+
+    /** 读取租约与写入共用保护登记,close 可在任意线程重复调用。 */
+    fun pin(file: File): Closeable {
+        val key = file.absolutePath
+        val entry = synchronized(entries) {
+            entries.getOrPut(key, ::Entry).also { it.users++ }
+        }
+        val closed = AtomicBoolean()
+        return Closeable {
+            if (closed.compareAndSet(false, true)) synchronized(entries) {
+                if (--entry.users == 0) entries.remove(key)
+            }
+        }
+    }
+
+    private fun onlineMarker(file: File) = File(File(file.parentFile, ONLINE_MARKER_DIRECTORY), file.name)
+
+    fun isOnline(file: File): Boolean = onlineMarker(file).isFile
+
+    /** 未分类的历史文件不自动纳入预算;现有章节下载可将在线文件升级为保留文件。 */
+    fun setOnline(file: File, online: Boolean) {
+        val marker = onlineMarker(file)
+        if (online) {
+            requireNotNull(marker.parentFile).mkdirs()
+            if (!marker.exists() && !marker.createNewFile()) throw IOException("无法登记在线图片")
+            marker.setLastModified(System.currentTimeMillis())
+        } else if (marker.exists() && !marker.delete()) {
+            throw IOException("无法保留图片缓存")
+        }
+    }
+
+    fun touch(file: File) {
+        onlineMarker(file).takeIf(File::isFile)?.setLastModified(System.currentTimeMillis())
+    }
+
+    /** 只淘汰明确登记的在线文件;活动读取/写入可暂时超过预算,释放后再次整理。 */
+    fun trimOnlineCache(root: File, maxBytes: Long) = synchronized(evictionLock) {
+        require(maxBytes >= 0)
+        val candidates = root.walkTopDown().filter {
+            it.isFile && it.parentFile?.name == ONLINE_MARKER_DIRECTORY
+        }
+            .map { marker -> File(requireNotNull(marker.parentFile).parentFile, marker.name) to marker }
+            .toList().sortedBy { (_, marker) -> marker.lastModified() }
+        var total = candidates.sumOf { (file, _) -> file.length() }
+        for ((file, marker) in candidates) {
+            if (total <= maxBytes && file.exists()) continue
+            val size = file.length()
+            if (deleteIfIdle(file) {
+                    // 下载可能已在枚举之后升级保留用途,必须在删除前复核。
+                    if (!marker.isFile) false
+                    else if (!file.exists() || file.delete()) {
+                        marker.delete()
+                        true
+                    } else false
+                }) total -= size
+        }
+    }
 
 
     /** 与获取写入锁原子协调;目录包含正在写入或等待写入的文件时整次清理跳过。 */
     /** 与获取写入锁原子协调;目录包含正在写入或等待写入的文件时整次清理跳过。 */
     fun deleteIfIdle(file: File, delete: (File) -> Boolean = File::delete): Boolean =
     fun deleteIfIdle(file: File, delete: (File) -> Boolean = File::delete): Boolean =
@@ -31,6 +94,10 @@ internal class BookImageFileStore(private val validate: (File) -> Boolean) {
             }
             }
         }
         }
 
 
+    /** 同时保护迁移的源和目标,不能移动瓦片仍通过路径打开的目录。 */
+    fun moveIfIdle(source: File, target: File, move: (File, File) -> Boolean): Boolean =
+        deleteIfIdle(source) { deleteIfIdle(target) { move(source, target) } }
+
     /** users 包括等待者,最后一位退出后才回收,避免同一文件同时出现两把锁。 */
     /** users 包括等待者,最后一位退出后才回收,避免同一文件同时出现两把锁。 */
     suspend fun <T> withFileLock(file: File, block: suspend () -> T): T {
     suspend fun <T> withFileLock(file: File, block: suspend () -> T): T {
         val key = file.absolutePath
         val key = file.absolutePath

+ 4 - 0
app/src/main/java/io/legado/app/help/coil/CoverExtras.kt

@@ -27,6 +27,10 @@ object CoverExtras {
     /** 漫画图片所属书籍。图片解密必须显式携带,不能读取全局阅读会话。 */
     /** 漫画图片所属书籍。图片解密必须显式携带,不能读取全局阅读会话。 */
     val MangaBookUrl = Extras.Key<String?>(null)
     val MangaBookUrl = Extras.Key<String?>(null)
 
 
+    /** 分页原图请求的生命周期;存在时先获取 BookHelp 文件,再交给 Coil 解码。 */
+    val MangaFileOwner = Extras.Key<MangaImageFileOwner?>(null)
+    val MangaFileTransferStarted = Extras.Key<(() -> Unit)?>(null)
+
     /**
     /**
      * CoverInterceptor 在把 request.data 改写为最终解析 URL 之前,把书架/详情页
      * CoverInterceptor 在把 request.data 改写为最终解析 URL 之前,把书架/详情页
      * 存下的原始封面地址放进来。CoverFetcher 回写持久文件缓存时用这个稳定键
      * 存下的原始封面地址放进来。CoverFetcher 回写持久文件缓存时用这个稳定键

+ 35 - 1
app/src/main/java/io/legado/app/help/coil/CoverInterceptor.kt

@@ -3,12 +3,30 @@ package io.legado.app.help.coil
 import coil3.intercept.Interceptor
 import coil3.intercept.Interceptor
 import coil3.request.CachePolicy
 import coil3.request.CachePolicy
 import coil3.request.ImageResult
 import coil3.request.ImageResult
+import coil3.request.ImageRequest
 import io.legado.app.help.source.SourceHelp
 import io.legado.app.help.source.SourceHelp
+import io.legado.app.data.appDb
+import io.legado.app.help.book.BookHelp
+import io.legado.app.utils.isAbsUrl
 import io.legado.app.model.analyzeRule.AnalyzeUrl
 import io.legado.app.model.analyzeRule.AnalyzeUrl
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.withContext
 import kotlinx.coroutines.withContext
+import java.io.Closeable
+import java.io.File
 
 
-class CoverInterceptor : Interceptor {
+class CoverInterceptor(
+    private val acquireMangaFile: suspend (ImageRequest, String) -> Pair<File, Closeable> = { request, data ->
+        val bookUrl = requireNotNull(request.extras[CoverExtras.MangaBookUrl])
+        val book = requireNotNull(appDb.bookDao.getBook(bookUrl)) { "Manga book missing" }
+        val source = appDb.bookSourceDao.getBookSource(
+            request.extras[CoverExtras.SourceOrigin] ?: book.origin,
+        )
+        BookHelp.acquireReadingImage(
+            source, book, data, request.extras[CoverExtras.LoadOnlyWifi] == true,
+            request.extras[CoverExtras.MangaFileTransferStarted] ?: {},
+        )
+    },
+) : Interceptor {
 
 
     companion object {
     companion object {
         private const val RESOLVED_URL_CACHE_MAX_SIZE = 100
         private const val RESOLVED_URL_CACHE_MAX_SIZE = 100
@@ -35,6 +53,22 @@ class CoverInterceptor : Interceptor {
         val request = chain.request
         val request = chain.request
         val data = request.data
         val data = request.data
 
 
+        val fileOwner = request.extras[CoverExtras.MangaFileOwner]
+        // 本地漫画与 content/file URI 保持现有平台加载器;仅在线页采用共享原图链路。
+        if (fileOwner != null && data is String &&
+            (data.isAbsUrl() || data.startsWith("data:", true)) &&
+            !data.startsWith("file:", true) && !data.startsWith("content:", true)
+        ) {
+            val file = withContext(Dispatchers.IO) {
+                val (file, lease) = acquireMangaFile(request, data)
+                fileOwner.attach(file, lease)
+                file
+            }
+            return chain.withRequest(
+                request.newBuilder().data(file).diskCachePolicy(CachePolicy.DISABLED).build()
+            ).proceed()
+        }
+
         if (data is String && data.isNotBlank()) {
         if (data is String && data.isNotBlank()) {
             // 本地封面缓存快速路径:命中就改写为本地文件,跳过 AnalyzeUrl 的书源规则调用
             // 本地封面缓存快速路径:命中就改写为本地文件,跳过 AnalyzeUrl 的书源规则调用
             // (部分书源的 headerRule/coverUrl 会执行 JS/Java 检查脚本,冷启动或重载封面时
             // (部分书源的 headerRule/coverUrl 会执行 JS/Java 检查脚本,冷启动或重载封面时

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

@@ -0,0 +1,50 @@
+package io.legado.app.help.coil
+
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.RememberObserver
+import io.legado.app.help.book.BookHelp
+import java.io.Closeable
+import java.io.File
+import kotlin.coroutines.cancellation.CancellationException
+
+/** 一个渲染请求的文件所有权。仅管理平台资源生命周期,不持有 UI 或阅读会话。 */
+@Stable
+class MangaImageFileOwner : Closeable, RememberObserver {
+    private var closed = false
+    private var file: File? = null
+    private var lease: Closeable? = null
+
+    @Synchronized
+    fun attach(file: File, lease: Closeable) {
+        if (closed) {
+            lease.close()
+            throw CancellationException("Image request disposed")
+        }
+        if (this.file != null && this.file != file) {
+            lease.close()
+            error("Image request changed its original file")
+        }
+        if (this.lease != null) lease.close()
+        else {
+            this.file = file
+            this.lease = lease
+        }
+    }
+
+    /** 瓦片具有独立租约;释放 Compose 请求不提前释放解码器正在使用的文件。 */
+    @Synchronized
+    fun borrowForTiles(): Pair<File, Closeable>? =
+        file?.takeUnless { closed }?.let { it to BookHelp.pinImageFile(it) }
+
+    @Synchronized
+    override fun close() {
+        if (closed) return
+        closed = true
+        lease?.close()
+        lease = null
+    }
+
+    override fun onRemembered() = Unit
+    override fun onForgotten() = close()
+    override fun onAbandoned() = close()
+}

+ 71 - 0
app/src/main/java/io/legado/app/help/coil/MangaZoomableImageSource.kt

@@ -0,0 +1,71 @@
+package io.legado.app.help.coil
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.RememberObserver
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.key
+import androidx.compose.ui.geometry.Size
+import coil3.ImageLoader
+import coil3.compose.rememberAsyncImagePainter
+import coil3.request.ImageRequest
+import kotlinx.coroutines.flow.Flow
+import me.saket.telephoto.subsamplingimage.SubSamplingImageSource
+import me.saket.telephoto.zoomable.ZoomableImageSource
+import me.saket.telephoto.zoomable.coil3.coil
+import me.saket.telephoto.zoomable.copy
+import okio.Path.Companion.toOkioPath
+
+/** 保留 Coil 的格式判断与预览选择,仅补齐原图的读取租约和 EInk 整图边界。 */
+@Composable
+fun rememberMangaZoomableImageSource(
+    request: ImageRequest,
+    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)
+    }
+}
+
+@Stable
+private class PainterImageSource(private val painter: androidx.compose.ui.graphics.painter.Painter) : ZoomableImageSource {
+    @Composable
+    override fun resolve(canvasSize: Flow<Size>) =
+        ZoomableImageSource.ResolveResult(ZoomableImageSource.PainterDelegate(painter))
+}
+
+@Stable
+private class LeasedImageSource(
+    private val delegate: ZoomableImageSource,
+    private val owner: MangaImageFileOwner,
+) : ZoomableImageSource {
+    @Composable
+    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(
+                    SubSamplingImageSource.file(file.toOkioPath(), tiles.source.preview) {
+                        try { tiles.source.close() } finally { lease.close() }
+                    },
+                    tiles.imageOptions,
+                ))
+            }
+        }
+        return resolved.copy(delegate = leased?.delegate ?: tiles)
+    }
+}
+
+/** 正常退出由瓦片解码器关闭;未提交的组合尚无解码器,需要回收已创建的租约。 */
+private class RememberedTileSource(val delegate: ZoomableImageSource.SubSamplingDelegate) : RememberObserver {
+    override fun onRemembered() = Unit
+    override fun onForgotten() = Unit
+    override fun onAbandoned() = delegate.source.close()
+}

+ 1 - 1
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderContract.kt

@@ -241,7 +241,7 @@ sealed interface MangaReaderIntent {
     data class UpdateMenuPaletteStyle(val value: String) : MangaReaderIntent
     data class UpdateMenuPaletteStyle(val value: String) : MangaReaderIntent
     data class UpdateClickAction(val index: Int, val action: Int) : MangaReaderIntent
     data class UpdateClickAction(val index: Int, val action: Int) : MangaReaderIntent
     data class RetryChapter(val chapterIndex: Int) : MangaReaderIntent
     data class RetryChapter(val chapterIndex: Int) : MangaReaderIntent
-    data class PageLoadStarted(val key: String) : MangaReaderIntent
+    data class PageLoadStarted(val key: String, val force: Boolean = false) : MangaReaderIntent
     data class PageLoadSucceeded(val key: String) : MangaReaderIntent
     data class PageLoadSucceeded(val key: String) : MangaReaderIntent
     data class PageLoadFailed(val key: String, val message: String?) : MangaReaderIntent
     data class PageLoadFailed(val key: String, val message: String?) : MangaReaderIntent
     data class RetryPage(val key: String) : MangaReaderIntent
     data class RetryPage(val key: String) : MangaReaderIntent

+ 29 - 7
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderScreen.kt

@@ -89,6 +89,8 @@ import coil3.size.Dimension
 import coil3.size.Scale
 import coil3.size.Scale
 import coil3.size.Size
 import coil3.size.Size
 import coil3.toBitmap
 import coil3.toBitmap
+import io.legado.app.help.coil.MangaImageFileOwner
+import io.legado.app.help.coil.rememberMangaZoomableImageSource
 import com.kyant.backdrop.backdrops.layerBackdrop
 import com.kyant.backdrop.backdrops.layerBackdrop
 import com.kyant.backdrop.backdrops.rememberLayerBackdrop
 import com.kyant.backdrop.backdrops.rememberLayerBackdrop
 import dev.chrisbanes.haze.HazeState
 import dev.chrisbanes.haze.HazeState
@@ -112,7 +114,7 @@ import kotlinx.coroutines.launch
 import me.saket.telephoto.zoomable.DoubleClickToZoomListener
 import me.saket.telephoto.zoomable.DoubleClickToZoomListener
 import me.saket.telephoto.zoomable.EnabledZoomGestures
 import me.saket.telephoto.zoomable.EnabledZoomGestures
 import me.saket.telephoto.zoomable.ZoomSpec
 import me.saket.telephoto.zoomable.ZoomSpec
-import me.saket.telephoto.zoomable.coil3.ZoomableAsyncImage
+import me.saket.telephoto.zoomable.ZoomableImage
 import me.saket.telephoto.zoomable.rememberZoomableImageState
 import me.saket.telephoto.zoomable.rememberZoomableImageState
 import me.saket.telephoto.zoomable.rememberZoomableState
 import me.saket.telephoto.zoomable.rememberZoomableState
 import me.saket.telephoto.zoomable.zoomable
 import me.saket.telephoto.zoomable.zoomable
@@ -226,6 +228,7 @@ fun MangaReaderScreen(
         pagePrefetchRevision,
         pagePrefetchRevision,
         state.settings.preDownloadCount,
         state.settings.preDownloadCount,
         state.settings.sourceOrigin,
         state.settings.sourceOrigin,
+        state.settings.scrollMode,
         state.settings.enableEInk,
         state.settings.enableEInk,
         state.settings.enableGray,
         state.settings.enableGray,
         pageDecodeSize,
         pageDecodeSize,
@@ -242,11 +245,16 @@ fun MangaReaderScreen(
             .filterIsInstance<MangaReaderItemUi.Page>()
             .filterIsInstance<MangaReaderItemUi.Page>()
             .filterNot { it.loadState == MangaPageLoadState.Ready }
             .filterNot { it.loadState == MangaPageLoadState.Ready }
             .map { page ->
             .map { page ->
+                val owner = if (state.settings.scrollMode != MangaScrollMode.WEBTOON &&
+                    state.settings.scrollMode != MangaScrollMode.WEBTOON_WITH_GAP
+                ) MangaImageFileOwner() else null
                 imageLoader.enqueue(
                 imageLoader.enqueue(
                     page.imageRequest(
                     page.imageRequest(
                         settings = state.settings,
                         settings = state.settings,
                         context = context,
                         context = context,
                         decodeSize = pageDecodeSize,
                         decodeSize = pageDecodeSize,
+                        fileOwner = owner,
+                        onFileTransfer = { onIntent(MangaReaderIntent.PageLoadStarted(page.key, force = true)) },
                         onAspectRatio = { aspectRatios[page.key] = it },
                         onAspectRatio = { aspectRatios[page.key] = it },
                         onStart = { onIntent(MangaReaderIntent.PageLoadStarted(page.key)) },
                         onStart = { onIntent(MangaReaderIntent.PageLoadStarted(page.key)) },
                         onSuccess = { onIntent(MangaReaderIntent.PageLoadSucceeded(page.key)) },
                         onSuccess = { onIntent(MangaReaderIntent.PageLoadSucceeded(page.key)) },
@@ -258,9 +266,9 @@ fun MangaReaderScreen(
                         // caused a slider jump to decode the same image again from scratch.
                         // caused a slider jump to decode the same image again from scratch.
                         .memoryCachePolicy(CachePolicy.ENABLED)
                         .memoryCachePolicy(CachePolicy.ENABLED)
                         .build()
                         .build()
-                )
+                ) to owner
             }
             }
-        onDispose { requests.forEach { it.dispose() } }
+        onDispose { requests.forEach { (request, owner) -> request.dispose(); owner?.close() } }
     }
     }
     CompositionLocalProvider(
     CompositionLocalProvider(
         LocalReaderViewportSize provides viewportSize,
         LocalReaderViewportSize provides viewportSize,
@@ -1251,6 +1259,8 @@ private fun MangaPageImage(
     }
     }
     val imagePipelineKey = remember(
     val imagePipelineKey = remember(
         page.key,
         page.key,
+        page.bookUrl,
+        paged,
         settings.sourceOrigin,
         settings.sourceOrigin,
         settings.enableEInk,
         settings.enableEInk,
         settings.eInkThreshold,
         settings.eInkThreshold,
@@ -1261,6 +1271,8 @@ private fun MangaPageImage(
     ) {
     ) {
         listOf(
         listOf(
             page.key,
             page.key,
+            page.bookUrl,
+            paged,
             settings.sourceOrigin,
             settings.sourceOrigin,
             settings.enableEInk,
             settings.enableEInk,
             settings.eInkThreshold,
             settings.eInkThreshold,
@@ -1270,11 +1282,14 @@ private fun MangaPageImage(
             decodeSize,
             decodeSize,
         )
         )
     }
     }
+    val fileOwner = remember(imagePipelineKey) { MangaImageFileOwner() }
     val request = remember(imagePipelineKey) {
     val request = remember(imagePipelineKey) {
         page.imageRequest(
         page.imageRequest(
             settings = settings,
             settings = settings,
             context = context,
             context = context,
             decodeSize = decodeSize,
             decodeSize = decodeSize,
+            fileOwner = fileOwner.takeIf { paged },
+            onFileTransfer = { onIntent(MangaReaderIntent.PageLoadStarted(page.key, force = true)) },
             onAspectRatio = { ratio -> aspectRatios[page.key] = ratio },
             onAspectRatio = { ratio -> aspectRatios[page.key] = ratio },
             onStart = { onIntent(MangaReaderIntent.PageLoadStarted(page.key)) },
             onStart = { onIntent(MangaReaderIntent.PageLoadStarted(page.key)) },
             onSuccess = { onIntent(MangaReaderIntent.PageLoadSucceeded(page.key)) },
             onSuccess = { onIntent(MangaReaderIntent.PageLoadSucceeded(page.key)) },
@@ -1288,6 +1303,7 @@ private fun MangaPageImage(
         settings.scrollMode == MangaScrollMode.PAGE_RIGHT_TO_LEFT
         settings.scrollMode == MangaScrollMode.PAGE_RIGHT_TO_LEFT
     DisposableEffect(
     DisposableEffect(
         page.key,
         page.key,
+        imagePipelineKey,
         settings.autoBackground,
         settings.autoBackground,
         settings.menuColorSource,
         settings.menuColorSource,
         settings.sourceOrigin,
         settings.sourceOrigin,
@@ -1306,6 +1322,7 @@ private fun MangaPageImage(
                     sourceOrigin = settings.sourceOrigin,
                     sourceOrigin = settings.sourceOrigin,
                     fallbackColor = settings.backgroundColor,
                     fallbackColor = settings.backgroundColor,
                     aspectRatio = imageRatio,
                     aspectRatio = imageRatio,
+                    fileOwner = fileOwner.takeIf { paged },
                 ) { colors ->
                 ) { colors ->
                     automaticBackgrounds[page.key] = colors
                     automaticBackgrounds[page.key] = colors
                 },
                 },
@@ -1375,9 +1392,8 @@ private fun MangaPageImage(
             }
             }
         }
         }
         Box(imageModifier) {
         Box(imageModifier) {
-            ZoomableAsyncImage(
-                model = request,
-                imageLoader = imageLoader,
+            ZoomableImage(
+                image = rememberMangaZoomableImageSource(request, imageLoader, fileOwner, settings.enableEInk),
                 contentDescription = contentDescription,
                 contentDescription = contentDescription,
                 state = zoomableImageState,
                 state = zoomableImageState,
                 gestures = if (settings.disableScale || !interactionsEnabled) {
                 gestures = if (settings.disableScale || !interactionsEnabled) {
@@ -1556,13 +1572,15 @@ private fun MangaReaderItemUi.Page.imageRequest(
     settings: MangaReaderSettings,
     settings: MangaReaderSettings,
     context: android.content.Context,
     context: android.content.Context,
     decodeSize: Size,
     decodeSize: Size,
+    fileOwner: MangaImageFileOwner? = null,
+    onFileTransfer: () -> Unit = {},
     onAspectRatio: (Float) -> Unit = {},
     onAspectRatio: (Float) -> Unit = {},
     onStart: () -> Unit = {},
     onStart: () -> Unit = {},
     onSuccess: () -> Unit = {},
     onSuccess: () -> Unit = {},
     onError: (String?) -> Unit = {},
     onError: (String?) -> Unit = {},
 ): ImageRequest {
 ): ImageRequest {
     val memoryCacheKey = "manga-page:$bookUrl:$imageUrl:${settings.sourceOrigin}:" +
     val memoryCacheKey = "manga-page:$bookUrl:$imageUrl:${settings.sourceOrigin}:" +
-        "${settings.enableEInk}:${settings.eInkThreshold}:${settings.enableGray}"
+        "${settings.enableEInk}:${settings.eInkThreshold}:${settings.enableGray}:${fileOwner != null}:$retryRevision"
     return ImageRequest.Builder(context)
     return ImageRequest.Builder(context)
         .data(imageUrl)
         .data(imageUrl)
         .allowHardware(true)
         .allowHardware(true)
@@ -1581,6 +1599,8 @@ private fun MangaReaderItemUi.Page.imageRequest(
             extras[CoverExtras.Manga] = true
             extras[CoverExtras.Manga] = true
             extras[CoverExtras.SourceOrigin] = settings.sourceOrigin
             extras[CoverExtras.SourceOrigin] = settings.sourceOrigin
             extras[CoverExtras.MangaBookUrl] = bookUrl
             extras[CoverExtras.MangaBookUrl] = bookUrl
+            extras[CoverExtras.MangaFileOwner] = fileOwner
+            extras[CoverExtras.MangaFileTransferStarted] = onFileTransfer
         }
         }
         .apply {
         .apply {
             when {
             when {
@@ -1606,6 +1626,7 @@ private fun MangaReaderItemUi.Page.backgroundColorRequest(
     sourceOrigin: String?,
     sourceOrigin: String?,
     fallbackColor: Color,
     fallbackColor: Color,
     aspectRatio: Float,
     aspectRatio: Float,
+    fileOwner: MangaImageFileOwner? = null,
     onColors: (MangaPageEdgeColors) -> Unit,
     onColors: (MangaPageEdgeColors) -> Unit,
 ): ImageRequest = ImageRequest.Builder(context)
 ): ImageRequest = ImageRequest.Builder(context)
     .data(imageUrl)
     .data(imageUrl)
@@ -1619,6 +1640,7 @@ private fun MangaReaderItemUi.Page.backgroundColorRequest(
         extras[CoverExtras.Manga] = true
         extras[CoverExtras.Manga] = true
         extras[CoverExtras.SourceOrigin] = sourceOrigin
         extras[CoverExtras.SourceOrigin] = sourceOrigin
         extras[CoverExtras.MangaBookUrl] = bookUrl
         extras[CoverExtras.MangaBookUrl] = bookUrl
+        extras[CoverExtras.MangaFileOwner] = fileOwner
     }
     }
     .listener(onSuccess = { _, result ->
     .listener(onSuccess = { _, result ->
         onColors(extractMangaEdgeColors(result.image.toBitmap(), fallbackColor))
         onColors(extractMangaEdgeColors(result.image.toBitmap(), fallbackColor))

+ 3 - 3
app/src/main/java/io/legado/app/ui/book/manga/MangaReaderViewModel.kt

@@ -274,7 +274,7 @@ class MangaReaderViewModel(
             is MangaReaderIntent.RetryChapter -> executeSession(
             is MangaReaderIntent.RetryChapter -> executeSession(
                 MangaSessionCommand.RetryChapter(intent.chapterIndex)
                 MangaSessionCommand.RetryChapter(intent.chapterIndex)
             )
             )
-            is MangaReaderIntent.PageLoadStarted -> markPageLoading(intent.key)
+            is MangaReaderIntent.PageLoadStarted -> markPageLoading(intent.key, intent.force)
 
 
             is MangaReaderIntent.PageLoadSucceeded -> updatePageLoadState(
             is MangaReaderIntent.PageLoadSucceeded -> updatePageLoadState(
                 intent.key,
                 intent.key,
@@ -1232,12 +1232,12 @@ class MangaReaderViewModel(
      * 置为 Loading,但保留已有百分比:预取请求与展示请求的 onStart 会先后到达,
      * 置为 Loading,但保留已有百分比:预取请求与展示请求的 onStart 会先后到达,
      * 直接塞 [MangaPageLoadState.Loading] 会把已经走到的进度清回“不确定”。
      * 直接塞 [MangaPageLoadState.Loading] 会把已经走到的进度清回“不确定”。
      */
      */
-    private fun markPageLoading(key: String) {
+    private fun markPageLoading(key: String, force: Boolean = false) {
         _uiState.update { state ->
         _uiState.update { state ->
             val index = state.pages.indexOfFirst { it.key == key }
             val index = state.pages.indexOfFirst { it.key == key }
             val page =
             val page =
                 state.pages.getOrNull(index) as? MangaReaderItemUi.Page ?: return@update state
                 state.pages.getOrNull(index) as? MangaReaderItemUi.Page ?: return@update state
-            if (page.loadState == MangaPageLoadState.Ready) return@update state
+            if (!force && page.loadState == MangaPageLoadState.Ready) return@update state
             val progress = (page.loadState as? MangaPageLoadState.Loading)?.progress
             val progress = (page.loadState as? MangaPageLoadState.Loading)?.progress
             val next = MangaPageLoadState.Loading(progress)
             val next = MangaPageLoadState.Loading(progress)
             if (page.loadState == next) return@update state
             if (page.loadState == next) return@update state

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

@@ -7,6 +7,8 @@ import android.util.Base64
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.AppLog
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.Book
 import kotlinx.coroutines.runBlocking
 import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.withTimeout
 import org.junit.Assert.assertArrayEquals
 import org.junit.Assert.assertArrayEquals
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertFalse
 import org.junit.Assert.assertFalse
@@ -45,6 +47,53 @@ class BookHelpImageFileTest {
         }
         }
     }
     }
 
 
+    @Test
+    fun `book rename defers physical migration until image reader releases its file`() = runBlocking {
+        val renamed = book.copy(name = "Renamed image contract")
+        val src = "https://invalid.example/rename.jpg"
+        val original = BookHelp.getImage(book, src)
+        val destination = BookHelp.getImage(renamed, src)
+        BookHelp.clearCache(book)
+        BookHelp.clearCache(renamed)
+        BookHelp.writeImage(book, src, png())
+        val lease = BookHelp.pinImageFile(original)
+        try {
+            BookHelp.updateCacheFolder(book, renamed)
+            assertTrue(original.exists())
+            assertFalse(destination.exists())
+            lease.close()
+            withTimeout(5_000) { while (!destination.exists()) delay(10) }
+            assertFalse(original.exists())
+        } finally {
+            lease.close()
+            BookHelp.clearCache(book)
+            BookHelp.clearCache(renamed)
+        }
+    }
+
+    @Test
+    fun `chapter download upgrades online original without creating a second image`() = runBlocking {
+        val bytes = png()
+        val src = "data:image/png;base64,${Base64.encodeToString(bytes, Base64.NO_WRAP)}"
+        val expected = BookHelp.getImage(book, src)
+        val marker = java.io.File(java.io.File(expected.parentFile, ".online"), expected.name)
+        expected.delete()
+        marker.delete()
+        var lease: java.io.Closeable? = null
+        try {
+            val (image, acquired) = BookHelp.acquireReadingImage(null, book, src)
+            lease = acquired
+            assertEquals(expected, image)
+            assertTrue(marker.exists())
+            assertTrue(BookHelp.saveImage(null, book, src))
+            assertFalse(marker.exists())
+            assertArrayEquals(bytes, image.readBytes())
+        } finally {
+            lease?.close()
+            BookHelp.clearCache(book)
+        }
+    }
+
     @Test
     @Test
     fun `valid cached original is reused without resolving its network URL`() = runBlocking {
     fun `valid cached original is reused without resolving its network URL`() = runBlocking {
         val src = "https://invalid.example/original.jpg,{\"headers\":{\"Referer\":\"original\"}}"
         val src = "https://invalid.example/original.jpg,{\"headers\":{\"Referer\":\"original\"}}"

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

@@ -33,6 +33,68 @@ class BookImageFileStoreTest {
         }
         }
     }
     }
 
 
+    @Test
+    fun `directory migration waits for readers of both source and target`() = withDirectory { root ->
+        val source = root.resolve("old").apply { mkdirs() }
+        val target = root.resolve("new").apply { mkdirs() }
+        var moves = 0
+        val move: (File, File) -> Boolean = { _, _ -> moves++; true }
+        store.pin(source.resolve("page.jpg")).use {
+            assertFalse(store.moveIfIdle(source, target, move))
+        }
+        store.pin(target.resolve("page.jpg")).use {
+            assertFalse(store.moveIfIdle(source, target, move))
+        }
+        assertEquals(0, moves)
+        assertTrue(store.moveIfIdle(source, target, move))
+        assertEquals(1, moves)
+    }
+
+    @Test
+    fun `online budget evicts least recently used files but preserves history and retained files`() = withDirectory { root ->
+        val old = root.resolve("old.jpg").apply { writeBytes(image) }
+        val recent = root.resolve("recent.jpg").apply { writeBytes(image) }
+        val history = root.resolve("history.jpg").apply { writeBytes(image) }
+        val retained = root.resolve("retained.jpg").apply { writeBytes(image) }
+        store.setOnline(old, true)
+        store.setOnline(recent, true)
+        store.setOnline(retained, true)
+        store.setOnline(retained, false)
+        root.resolve(".online/old.jpg").setLastModified(1_000)
+        root.resolve(".online/recent.jpg").setLastModified(2_000)
+        // 模拟重启:用途和最近访问时间不依赖进程内的 map。
+        BookImageFileStore { true }.trimOnlineCache(root, image.size.toLong())
+        assertFalse(old.exists())
+        assertFalse(root.resolve(".online/old.jpg").exists())
+        assertTrue(recent.exists())
+        assertTrue(history.exists())
+        assertTrue(retained.exists())
+    }
+
+    @Test
+    fun `active online file survives zero budget until last reader releases it`() = withDirectory { root ->
+        val target = root.resolve("active.jpg").apply { writeBytes(image) }
+        store.setOnline(target, true)
+        val request = store.pin(target)
+        val tiles = store.pin(target)
+        request.close()
+        request.close()
+        store.trimOnlineCache(root, 0)
+        assertTrue(target.exists())
+        assertFalse(store.deleteIfIdle(root, File::deleteRecursively))
+        tiles.close()
+        store.trimOnlineCache(root, 0)
+        assertFalse(target.exists())
+    }
+
+    @Test
+    fun `orphan online marker is cleaned even below budget`() = withDirectory { root ->
+        val missing = root.resolve("missing.jpg")
+        store.setOnline(missing, true)
+        store.trimOnlineCache(root, 1_024)
+        assertFalse(root.resolve(".online/missing.jpg").exists())
+    }
+
     @Test
     @Test
     fun `synchronous publication is protected from cleanup during validation`() = withDirectory { root ->
     fun `synchronous publication is protected from cleanup during validation`() = withDirectory { root ->
         val target = root.resolve("page.jpg")
         val target = root.resolve("page.jpg")

+ 110 - 0
app/src/test/java/io/legado/app/help/coil/MangaFileRequestTest.kt

@@ -0,0 +1,110 @@
+package io.legado.app.help.coil
+
+import android.app.Application
+import android.graphics.Bitmap
+import android.util.Base64
+import coil3.ImageLoader
+import coil3.decode.DataSource
+import coil3.decode.BitmapFactoryDecoder
+import coil3.request.ImageRequest
+import coil3.request.SuccessResult
+import coil3.request.ErrorResult
+import coil3.request.allowHardware
+import io.legado.app.data.entities.Book
+import io.legado.app.help.book.BookHelp
+import kotlinx.coroutines.runBlocking
+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
+
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [26, 35])
+@GraphicsMode(GraphicsMode.Mode.NATIVE)
+class MangaFileRequestTest {
+    private val book = Book(bookUrl = "manga-file-render", name = "File render")
+
+    @Before
+    fun setUp() {
+        RuntimeEnvironment.getApplication().injectAsAppCtx()
+    }
+
+    @Test
+    fun `Coil reports local original even when preview is reused from memory`() = runBlocking {
+        val bitmap = Bitmap.createBitmap(40, 60, Bitmap.Config.ARGB_8888)
+        val bytes = try {
+            ByteArrayOutputStream().use {
+                bitmap.compress(Bitmap.CompressFormat.PNG, 100, it)
+                it.toByteArray()
+            }
+        } finally { bitmap.recycle() }
+        val src = "data:image/png;base64,${Base64.encodeToString(bytes, Base64.NO_WRAP)}"
+        val image = BookHelp.getImage(book, src)
+        val owner = MangaImageFileOwner()
+        val replacementOwner = MangaImageFileOwner()
+        var transfers = 0
+        val loader = ImageLoader.Builder(RuntimeEnvironment.getApplication())
+            .components {
+                // Robolectric Windows 的 ImageDecoder 文件 JNI 返回 "Only supported on Android"。
+                // 使用真实 BitmapFactory 解码验证相同的 FileFetcher/缓存/拦截器链路。
+                add(BitmapFactoryDecoder.Factory())
+                add(CoverInterceptor { request, data ->
+                    BookHelp.acquireReadingImage(
+                        null, book, data, onDownload = request.extras[CoverExtras.MangaFileTransferStarted] ?: {},
+                    )
+                })
+            }.build()
+        try {
+            val request = ImageRequest.Builder(RuntimeEnvironment.getApplication())
+                .data(src).size(20, 30).allowHardware(false)
+                .apply {
+                    extras[CoverExtras.Manga] = true
+                    extras[CoverExtras.MangaBookUrl] = book.bookUrl
+                    extras[CoverExtras.MangaFileOwner] = owner
+                    extras[CoverExtras.MangaFileTransferStarted] = { transfers++ }
+                }.build()
+            val firstResult = loader.execute(request)
+            if (firstResult is ErrorResult) throw firstResult.throwable
+            val first = firstResult as SuccessResult
+            assertEquals(image, first.request.data)
+            assertEquals(DataSource.DISK, first.dataSource)
+            assertTrue(image.isFile)
+            val nextResult = loader.execute(request)
+            if (nextResult is ErrorResult) throw nextResult.throwable
+            val next = nextResult as SuccessResult
+            assertEquals(image, next.request.data)
+            assertEquals(DataSource.MEMORY_CACHE, next.dataSource)
+            assertEquals(1, transfers)
+            // 模拟 Compose 请求先退出,区域解码器最后退出。
+            val tiles = requireNotNull(owner.borrowForTiles()).second
+            owner.close()
+            BookHelp.clearCache(book)
+            assertTrue(image.exists())
+            tiles.close()
+            BookHelp.clearCache(book)
+            assertFalse(image.exists())
+            // 预览在内存中也必须先恢复被淘汰的原图,并报告真实获取,不能继续冒充 Ready。
+            val replacement = request.newBuilder().apply {
+                extras[CoverExtras.MangaFileOwner] = replacementOwner
+            }.build()
+            val restored = loader.execute(replacement)
+            if (restored is ErrorResult) throw restored.throwable
+            assertEquals(image, restored.request.data)
+            assertTrue(image.exists())
+            assertEquals(2, transfers)
+        } finally {
+            owner.close()
+            replacementOwner.close()
+            loader.shutdown()
+            BookHelp.clearCache(book)
+        }
+    }
+}

+ 35 - 0
app/src/test/java/io/legado/app/help/coil/MangaImageFileOwnerTest.kt

@@ -0,0 +1,35 @@
+package io.legado.app.help.coil
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import java.io.Closeable
+import java.io.File
+import kotlin.coroutines.cancellation.CancellationException
+
+class MangaImageFileOwnerTest {
+    @Test
+    fun `late acquisition after disposal closes its lease`() {
+        val owner = MangaImageFileOwner()
+        var closes = 0
+        owner.close()
+        try {
+            owner.attach(File("page.jpg"), Closeable { closes++ })
+            error("Disposed owner accepted image")
+        } catch (_: CancellationException) {
+            assertEquals(1, closes)
+        }
+    }
+
+    @Test
+    fun `abandoned composition closes request lease once and duplicate acquisition is released`() {
+        val owner = MangaImageFileOwner()
+        var closes = 0
+        val file = File("page.jpg")
+        owner.attach(file, Closeable { closes++ })
+        owner.attach(file, Closeable { closes++ })
+        assertEquals(1, closes)
+        owner.onAbandoned()
+        owner.close()
+        assertEquals(2, closes)
+    }
+}

+ 50 - 12
docs/dev/manga-subsampling-plan.md

@@ -1,8 +1,9 @@
 # 分页漫画区域解码与下载共用文件链路
 # 分页漫画区域解码与下载共用文件链路
 
 
-> 状态:实施中,尚未接入区域解码。2026-09-26 对照仓库源码、Coil 3.6.3、
+> 状态:实施中,分页已接入文件与区域解码适配器,尚未完成真机验收。2026-09-26 对照仓库源码、Coil 3.6.3、
 > Telephoto 0.19.0 与本机 Komikku 校正。进度与尺寸修复已提交为 `9f6f87872`;
 > Telephoto 0.19.0 与本机 Komikku 校正。进度与尺寸修复已提交为 `9f6f87872`;
-> 首个存储切片接入 BookHelp 的有效性检查、原子发布及等待者安全的文件锁。
+> 存储切片 `dea63568d` 接入有效性检查、原子发布及等待者安全的文件锁;
+> 当前切片接入分页 File 请求、读取租约与在线文件字节预算。
 >
 >
 > 目标:分页缩放时按需读取原图细节;阅读与后续漫画下载共用同一份有效文件,
 > 目标:分页缩放时按需读取原图细节;阅读与后续漫画下载共用同一份有效文件,
 > 已有文件不重复获取,已保留的章节断网可读。
 > 已有文件不重复获取,已保留的章节断网可读。
@@ -11,11 +12,16 @@
 
 
 ## 1. 当前事实与方案差异
 ## 1. 当前事实与方案差异
 
 
-- `MangaReaderScreen` 的预取与展示都以 URL 请求图片,共用显式解码尺寸。
+- `MangaReaderScreen` 的请求保留完整原始 URL 身份,共用显式解码尺寸。
+  分页预取、展示与背景提色携带 `MangaImageFileOwner`;`CoverInterceptor` 在首次解码前
+  经 BookHelp 获取原图并把请求改为 File,返回结果的 request.data 也是 File。
+  条漫继续使用旧 URL/AsyncImage 路径。
   条漫按窗口宽度与侧边留白解码;分页适应高度按高度解码,拉伸同时满足宽高,
   条漫按窗口宽度与侧边留白解码;分页适应高度按高度解码,拉伸同时满足宽高,
   原始尺寸保留原图,旋转适配保守覆盖交换后的窗口尺寸。
   原始尺寸保留原图,旋转适配保守覆盖交换后的窗口尺寸。
-- `CoverFetcher` 返回网络流或内存 Buffer;即使来自 OkHttp HTTP 缓存,也没有
-  Telephoto 所需的 Coil 磁盘键或可映射的本地文件模型。分页通常仍走整图回退。
+- 旧 `CoverFetcher` 的网络流或内存 Buffer 无法提供区域解码所需的文件模型。
+  分页现在绕过该路径,Telephoto 可从 File 的 DISK/MEMORY_CACHE 结果建立区域解码源。
+  `MangaZoomableImageSource` 保留其格式判断,并为实际瓦片源补上独立读取租约;
+  EInk 明确使用 Coil painter,避免未变换原图瓦片覆盖二值化预览。
 - 不能仅凭相同内存键断言随机命中低分辨率图片。Coil `MemoryCacheService`
 - 不能仅凭相同内存键断言随机命中低分辨率图片。Coil `MemoryCacheService`
   会验证尺寸、采样状态和 precision;稳定目标减少布局引起的差异,但不提供缩放细节。
   会验证尺寸、采样状态和 precision;稳定目标减少布局引起的差异,但不提供缩放细节。
 - `MangaChapterPageLoader` 目前只加载章节文本和图片 URL,未向阅读页提供缓存图片文件。
 - `MangaChapterPageLoader` 目前只加载章节文本和图片 URL,未向阅读页提供缓存图片文件。
@@ -151,12 +157,18 @@ ViewModel 经现有 Gateway/Repository 使用它,不访问 BookHelp、DAO 或
 索引损坏时重建可重建部分,不清空整本书原图,更不能无声删除已有下载。
 索引损坏时重建可重建部分,不清空整本书原图,更不能无声删除已有下载。
 
 
 旧图片缺少用途记录时,不能直接当阅读缓存淘汰;先保守保留,再由明确迁移规则或用户清理分类。
 旧图片缺少用途记录时,不能直接当阅读缓存淘汰;先保守保留,再由明确迁移规则或用户清理分类。
-同一补丁必须接管 `clearComicCache`、`clearInvalidCache` 与改名/移除书籍清理,
-不能新策略之外仍有旧删除旁路。
+新策略必须接管 `clearComicCache`、`clearInvalidCache` 与改名/移除书籍清理。
+当前删除路径已协调读写租约,章节窗口不会清理预算管理的在线文件;
+`updateCacheFolder` 同时检查源/目标目录的使用登记,忙时延迟迁移,释放租约后在 IO 上重试。
+连续改名的待迁移依赖也保留;这不是持久化迁移队列,进程中断后的旧目录恢复仍需后续索引支持。
+迁移仍沿用既有 rename 语义,目标目录已存在时不会合并;需要稳定目录身份/迁移索引解决
+活动阅读期间改名后,新路径暂未迁移而产生的缓存 miss。
 
 
 ## 5. 阅读渲染接法
 ## 5. 阅读渲染接法
 
 
-1. 会话数据层按可见窗口调度文件获取,当前页先于预取页;页面只持有语义状态及 Android 渲染标识。
+1. 当前切片沿用 Coil 的展示/有限预取调度,在平台拦截器中取得文件;
+   不为适配 File 而向共享章节模型新增本地路径。后续需要严格的当前页优先和共享任务取消语义时,
+   再把调度上移到会话数据层;当前只按当前页、后续页、前页的顺序提交请求。
    不向共享领域契约暴露 File、Uri 或 Context。现有 Android-only 渲染标识可在 adapter/UI 层承载。
    不向共享领域契约暴露 File、Uri 或 Context。现有 Android-only 渲染标识可在 adapter/UI 层承载。
 2. 加载时显示下载进度/占位;成功后一次性发布已就绪文件,构建 `.data(File(path))` 请求。
 2. 加载时显示下载进度/占位;成功后一次性发布已就绪文件,构建 `.data(File(path))` 请求。
    不默认先走 URL 图片请求再切 File,避免两条获取链路与重复 preview 解码。
    不默认先走 URL 图片请求再切 File,避免两条获取链路与重复 preview 解码。
@@ -178,13 +190,21 @@ ViewModel 经现有 Gateway/Repository 使用它,不访问 BookHelp、DAO 或
 - GIF/SVG/不支持区域解码的格式保留整图或动画行为,不因命名后缀误判原图格式。
 - GIF/SVG/不支持区域解码的格式保留整图或动画行为,不因命名后缀误判原图格式。
 - 原图格式嗅探优先于 URL 后缀;FileUriFetcher 的 MIME 可能来自后缀,需覆盖伪后缀样本,
 - 原图格式嗅探优先于 URL 后缀;FileUriFetcher 的 MIME 可能来自后缀,需覆盖伪后缀样本,
   必要时只对漫画本地文件提供窄 fetcher,但仍确保结果请求 data 是可映射的本地文件。
   必要时只对漫画本地文件提供窄 fetcher,但仍确保结果请求 data 是可映射的本地文件。
-- 接 `SubSamplingImageErrorReporter` 记录失败;对当前文件版本降级至整图,
-  不立即删下载或无限重新联网。重试/文件版本更新后允许再次尝试区域解码。
+- 区域解码失败应记录并对当前文件版本降级至整图,不立即删下载或无限重新联网。
+  Telephoto 0.19.0 的公开 `ZoomableImage` 未暴露 `SubSamplingImageErrorReporter`,
+  也没有对应 CompositionLocal;不能假定直接调用它已经接入错误回退。
+  下一步需要通过公开 `rememberSubSamplingImageState(errorReporter = ...)` 的窄渲染适配器
+  或库的明确 API 扩展接入,该切片尚未解决区域解码初始化/瓦片失败的自动降级。
 - 低存储、外部存储不可用显式报错并允许重试;不得回退到巨型原图加载并冒充清晰模式成功。
 - 低存储、外部存储不可用显式报错并允许重试;不得回退到巨型原图加载并冒充清晰模式成功。
 
 
 ## 6. 容量与清理
 ## 6. 容量与清理
 
 
-- 首版为可淘汰阅读文件设置字节预算,初始可考虑 256 MiB,最终值需按典型漫画与低存储设备验证。
+- 当前为可淘汰阅读文件设置 256 MiB 字节预算,最终值需按典型漫画与低存储设备验证。
+  用 `images/.online/<原图文件名>` 登记用途,marker 的修改时间记录访问时间;
+  避免用图片后缀判断用途,也不保存 URL/token/header 副本。新获取文件在发布前登记,
+  历史未分类文件保守保留;现有章节下载复用时删除 marker,升级为预算外保留文件。
+  这是可淘汰文件的软预算:活动请求/瓦片暂不删除,释放租约后在 IO 上合并清理请求并再次淘汰。
+  不是整本书缓存的硬上限,不能靠超预算删除正在使用的文件。
   该预算不包含用户显式保留下载;下载界面单独显示占用。
   该预算不包含用户显式保留下载;下载界面单独显示占用。
 - 淘汰只针对无显式保留、无自动离线保留、无活动使用的文件,以访问时间排序。
 - 淘汰只针对无显式保留、无自动离线保留、无活动使用的文件,以访问时间排序。
   共享图片的所有章节关联都解除后才可删除。不能按整本目录 LRU 一删了之。
   共享图片的所有章节关联都解除后才可删除。不能按整本目录 LRU 一删了之。
@@ -217,7 +237,7 @@ ViewModel 经现有 Gateway/Repository 使用它,不访问 BookHelp、DAO 或
 此时还没有引入阅读自动落盘,所以不会增加无限增长的在线阅读存储。
 此时还没有引入阅读自动落盘,所以不会增加无限增长的在线阅读存储。
 
 
 仍未完成:持久有效记录/源版本失效、完整传输长度记录、共享任务订阅取消语义、
 仍未完成:持久有效记录/源版本失效、完整传输长度记录、共享任务订阅取消语义、
-章节索引与保留用途、活动读取保护及保留感知清理。目录迁移仍需协调活动资源。
+章节索引与保留用途、保留感知清理。读取保护、预算与目录迁移协调由 B 补齐。
 格式/尺寸检查不是整图所有像素可解码的证明;
 格式/尺寸检查不是整图所有像素可解码的证明;
 后续渲染错误必须保持可重试,不能因已有文件无条件标 Ready。
 后续渲染错误必须保持可重试,不能因已有文件无条件标 Ready。
 
 
@@ -236,6 +256,24 @@ lint 仍有现有的 118 个警告、13 个提示,本切片没有放宽 baseli
 EInk 整图分支及活动文件保护。首版保持 preview 尺寸,不先做 1/4 优化。
 EInk 整图分支及活动文件保护。首版保持 preview 尺寸,不先做 1/4 优化。
 这一切片启用在线落盘时必须同时提供可淘汰预算与安全清理,不能先交付无上限版本。
 这一切片启用在线落盘时必须同时提供可淘汰预算与安全清理,不能先交付无上限版本。
 
 
+当前已接入分页展示/预取/背景提色的 File 请求、256 MiB 预算、请求租约与瓦片独立租约、
+EInk painter 分支;未降低 preview 尺寸,也未向共享领域泄漏 File。
+原图被预算淘汰后,即使预览还在内存缓存,拦截器也先恢复原图并发出真实获取事件,
+让之前 Ready 的页面恢复 Loading;有效原图命中不强制降级 Ready。
+解析器按书籍/渲染请求 owner 重建,重试代次进入内存键,避免新请求接着使用旧 owner 的瓦片源。
+格式回退沿用 Telephoto/Coil;区域解码错误自动降级、源/文件版本失效仍未完成。
+预算外保留暂沿用既有章节窗口,尚不能区分自动离线与显式下载;这属于 C 的用途索引工作,
+不能把“升级预算外”描述为已经完成显式下载永久保留。
+
+新增验证覆盖在线预算的重启恢复/LRU/历史保护、活动瓦片阻止清理、请求销毁后迟到获取释放、
+在线文件升级章节保留以及 Coil DISK/MEMORY_CACHE 两种结果的 File 身份。
+本切片编译、架构门禁与 69 项相关测试通过:存储 14 项、BookHelp 在 API 26/35 各 7 项、
+Coil 集成在 API 26/35 各 1 项、owner 2 项、下载完成 1 项、交互 31 项、尺寸 5 项。
+`lintAppDebug` 通过,仍为现有 118 个警告与 13 个提示;未放宽 baseline。
+Windows Robolectric 的 API 35 ImageDecoder 文件 JNI 返回 "Only supported on Android";
+集成测试指定真实 BitmapFactoryDecoder,生产解码器配置不变。
+这些测试尚不证明真实 Compose 页面已出现瓦片或达到 full-quality,需要设备验收。
+
 ### C. 离线保留与后续显式下载
 ### C. 离线保留与后续显式下载
 
 
 接章节索引/完成判定、现有 autoOfflineCache 和受保护清理,验证断网重建;
 接章节索引/完成判定、现有 autoOfflineCache 和受保护清理,验证断网重建;