Fix reading downloaded EPUB and TXT books
Build & Test / Build & Test App (push) Has been cancelled

This commit is contained in:
BookOasis Reader
2026-09-21 16:23:42 +09:00
parent fe73aff860
commit 6a57bc601f
3 changed files with 156 additions and 6 deletions
@@ -117,6 +117,37 @@ class DownloadProvider(
.firstOrNull() .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. * Returns a list of downloaded directories for the chapters that exist.
* *
@@ -130,6 +130,33 @@ class ChapterLoader(
) )
val bookOasisRef = dbChapter.url.toBookOasisChapterRef() val bookOasisRef = dbChapter.url.toBookOasisChapterRef()
val kavitaChapterId = dbChapter.url.toKavitaChapterId() 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 { return when {
isDownloaded -> DownloadPageLoader( isDownloaded -> DownloadPageLoader(
@@ -138,6 +165,7 @@ class ChapterLoader(
source, source,
downloadManager, downloadManager,
downloadProvider, downloadProvider,
documentFallback,
) )
source is LocalSource -> source.getFormat(chapter.chapter).let { format -> source is LocalSource -> source.getFormat(chapter.chapter).let { format ->
when (format) { when (format) {
@@ -10,12 +10,14 @@ import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import kotlinx.coroutines.CancellationException
import mihon.core.archive.archiveReader import mihon.core.archive.archiveReader
import mihon.core.archive.epubReader import mihon.core.archive.epubReader
import tachiyomi.core.common.storage.extension import tachiyomi.core.common.storage.extension
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.File
/** /**
* Loader used to load a chapter from the downloaded chapters. * Loader used to load a chapter from the downloaded chapters.
@@ -26,6 +28,7 @@ internal class DownloadPageLoader(
private val source: Source, private val source: Source,
private val downloadManager: DownloadManager, private val downloadManager: DownloadManager,
private val downloadProvider: DownloadProvider, private val downloadProvider: DownloadProvider,
private val documentFallback: (() -> PageLoader)? = null,
) : PageLoader() { ) : PageLoader() {
private val context: Context by injectLazy() private val context: Context by injectLazy()
@@ -35,9 +38,35 @@ internal class DownloadPageLoader(
override var isLocal: Boolean = true override var isLocal: Boolean = true
override val usesTextReaderStyle: Boolean override val usesTextReaderStyle: Boolean
get() = pageLoader?.usesTextReaderStyle == true get() = pageLoader?.usesTextReaderStyle == true || findDownloadedDocument() != null
override suspend fun getPages(): List<ReaderPage> { override suspend fun getPages(): List<ReaderPage> {
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<ReaderPage> {
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 dbChapter = chapter.chapter
val chapterPath = downloadProvider.findChapterDir( val chapterPath = downloadProvider.findChapterDir(
dbChapter.name, dbChapter.name,
@@ -46,17 +75,79 @@ internal class DownloadPageLoader(
manga.title, manga.title,
source, source,
) )
return if (chapterPath?.isFile == true) { return if (chapterPath?.isFile == true) {
when (chapterPath.extension?.lowercase()) { getPagesFromArchive(chapterPath)
"epub" -> getPagesFromEpub(chapterPath)
"txt" -> getPagesFromText(chapterPath)
else -> getPagesFromArchive(chapterPath)
}
} else { } else {
getPagesFromDirectory() 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<ReaderPage> {
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() { override fun recycle() {
super.recycle() super.recycle()
pageLoader?.recycle() pageLoader?.recycle()