From 6a57bc601f70a22ccd3f65bd4981bb689a94bca5 Mon Sep 17 00:00:00 2001 From: BookOasis Reader Date: Mon, 21 Sep 2026 16:23:42 +0900 Subject: [PATCH] Fix reading downloaded EPUB and TXT books --- .../data/download/DownloadProvider.kt | 31 ++++++ .../ui/reader/loader/ChapterLoader.kt | 28 +++++ .../ui/reader/loader/DownloadPageLoader.kt | 103 +++++++++++++++++- 3 files changed, 156 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt index 1d14df4b1..fa65d7039 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadProvider.kt @@ -117,6 +117,37 @@ class DownloadProvider( .firstOrNull() } + /** + * Returns a downloaded EPUB/TXT document for a chapter if it exists. + * + * Document files are checked explicitly before the generic chapter path so + * a stale image directory with the same chapter name cannot shadow them. + */ + fun findChapterDocument( + chapterName: String, + chapterScanlator: String?, + chapterUrl: String, + mangaTitle: String, + source: Source, + ): UniFile? { + val mangaDir = findMangaDir(mangaTitle, source) ?: return null + val chapterDirName = getChapterDirName(chapterName, chapterScanlator, chapterUrl) + val legacyChapterDirNames = getLegacyChapterDirNames(chapterName, chapterScanlator, chapterUrl) + + return buildList { + DOCUMENT_DOWNLOAD_EXTENSIONS.forEach { extension -> + add("$chapterDirName.$extension") + } + legacyChapterDirNames.forEach { legacyName -> + DOCUMENT_DOWNLOAD_EXTENSIONS.forEach { extension -> + add("$legacyName.$extension") + } + } + }.asSequence() + .mapNotNull { mangaDir.findFile(it) } + .firstOrNull { it.isFile } + } + /** * Returns a list of downloaded directories for the chapters that exist. * diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt index 6f96fef64..a30f2863c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/ChapterLoader.kt @@ -130,6 +130,33 @@ class ChapterLoader( ) val bookOasisRef = dbChapter.url.toBookOasisChapterRef() val kavitaChapterId = dbChapter.url.toKavitaChapterId() + val httpSource = source as? HttpSource + val documentFallback: (() -> PageLoader)? = when { + httpSource != null && httpSource.isBookOasisSource() && bookOasisRef != null && + (bookOasisRef.format == "epub" || bookOasisRef.format == "txt") -> { + { + BookOasisFilePageLoader( + context, + httpSource, + bookOasisRef, + readerPreferences.textReaderStyle(context), + ) + } + } + httpSource != null && httpSource.isKavitaSource() && kavitaChapterId != null -> { + { + KavitaCompatPageLoader( + context = context, + chapter = chapter, + source = httpSource, + chapterCache = chapterCache, + chapterId = kavitaChapterId, + textStyle = readerPreferences.textReaderStyle(context), + ) + } + } + else -> null + } return when { isDownloaded -> DownloadPageLoader( @@ -138,6 +165,7 @@ class ChapterLoader( source, downloadManager, downloadProvider, + documentFallback, ) source is LocalSource -> source.getFormat(chapter.chapter).let { format -> when (format) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt index 73d8ea52d..ba886764f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/loader/DownloadPageLoader.kt @@ -10,12 +10,14 @@ import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderPage +import kotlinx.coroutines.CancellationException import mihon.core.archive.archiveReader import mihon.core.archive.epubReader import tachiyomi.core.common.storage.extension import tachiyomi.domain.manga.model.Manga import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences import uy.kohesive.injekt.injectLazy +import java.io.File /** * Loader used to load a chapter from the downloaded chapters. @@ -26,6 +28,7 @@ internal class DownloadPageLoader( private val source: Source, private val downloadManager: DownloadManager, private val downloadProvider: DownloadProvider, + private val documentFallback: (() -> PageLoader)? = null, ) : PageLoader() { private val context: Context by injectLazy() @@ -35,9 +38,35 @@ internal class DownloadPageLoader( override var isLocal: Boolean = true override val usesTextReaderStyle: Boolean - get() = pageLoader?.usesTextReaderStyle == true + get() = pageLoader?.usesTextReaderStyle == true || findDownloadedDocument() != null override suspend fun getPages(): List { + check(!isRecycled) + + return try { + val pages = getDownloadedPages() + if (pages.isNotEmpty() || documentFallback == null) { + pages + } else { + getFallbackPages() + } + } catch (error: Throwable) { + if (error is CancellationException || documentFallback == null) throw error + getFallbackPages() + } + } + + private suspend fun getDownloadedPages(): List { + val downloadedDocument = findDownloadedDocument() + if (downloadedDocument != null) { + val readableDocument = prepareDownloadedDocument(downloadedDocument) + return when (readableDocument.extension?.lowercase()) { + "epub" -> getPagesFromEpub(readableDocument) + "txt" -> getPagesFromText(readableDocument) + else -> error("Unsupported downloaded document: ${downloadedDocument.name}") + } + } + val dbChapter = chapter.chapter val chapterPath = downloadProvider.findChapterDir( dbChapter.name, @@ -46,17 +75,79 @@ internal class DownloadPageLoader( manga.title, source, ) + return if (chapterPath?.isFile == true) { - when (chapterPath.extension?.lowercase()) { - "epub" -> getPagesFromEpub(chapterPath) - "txt" -> getPagesFromText(chapterPath) - else -> getPagesFromArchive(chapterPath) - } + getPagesFromArchive(chapterPath) } else { getPagesFromDirectory() } } + private fun findDownloadedDocument(): UniFile? { + val dbChapter = chapter.chapter + return downloadProvider.findChapterDocument( + dbChapter.name, + dbChapter.scanlator, + dbChapter.url, + manga.title, + source, + ) + } + + /** + * Mirror SAF-backed documents into the app cache before opening them. + * EPUB uses mmap internally, which is not guaranteed to work with every + * document provider even though regular stream reads/writes succeed. + */ + private fun prepareDownloadedDocument(file: UniFile): UniFile { + val extension = file.extension?.lowercase() + ?: error("Downloaded document has no extension: ${file.name}") + val sourceLength = file.length() + if (sourceLength <= 0L) { + error("Downloaded document is empty: ${file.name}") + } + + val cacheDir = File(context.cacheDir, "downloaded_reader").apply { mkdirs() } + val prefix = "${source.id}-${chapter.chapter.id}-" + val target = File( + cacheDir, + "$prefix$sourceLength-${file.lastModified()}.$extension", + ) + + if (!target.isFile || target.length() != sourceLength) { + cacheDir.listFiles() + ?.filter { it.name.startsWith(prefix) } + ?.forEach { it.delete() } + + val temp = File(cacheDir, "${target.name}.part") + if (temp.exists()) temp.delete() + file.openInputStream().use { input -> + temp.outputStream().use { output -> input.copyTo(output) } + } + if (temp.length() != sourceLength) { + temp.delete() + error("Downloaded document cache copy is incomplete: ${file.name}") + } + if (!temp.renameTo(target)) { + temp.copyTo(target, overwrite = true) + temp.delete() + } + } + + return UniFile.fromFile(target) + ?: error("Unable to open cached downloaded document: ${target.absolutePath}") + } + + private suspend fun getFallbackPages(): List { + pageLoader?.recycle() + val fallback = documentFallback?.invoke() + ?: error("Downloaded document fallback is unavailable") + pageLoader = fallback + val pages = fallback.getPages() + isLocal = fallback.isLocal + return pages + } + override fun recycle() { super.recycle() pageLoader?.recycle()