5 Commits

Author SHA1 Message Date
BookOasis Reader 5de6a8f110 Reconcile release artifact history
Build & Test / Build & Test App (push) Waiting to run
Release / Extract tag name (push) Waiting to run
Release / Build (push) Blocked by required conditions
Release / Build (FOSS) (push) Blocked by required conditions
Release / Create GitHub Release (push) Blocked by required conditions
Update website / update_website (release) Waiting to run
2026-09-21 16:41:28 +09:00
BookOasis Reader d4789c2373 Update release APKs for 0.20.5 2026-09-21 16:41:02 +09:00
BookOasis Reader 8dc5a0fc0b Update release APKs for 0.20.5
Build & Test / Build & Test App (push) Has been cancelled
Release / Extract tag name (push) Has been cancelled
Release / Build (push) Has been cancelled
Release / Build (FOSS) (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
2026-09-21 16:38:01 +09:00
BookOasis Reader 105551f658 Bump version to 0.20.5
Release / Extract tag name (push) Has been cancelled
Release / Build (push) Has been cancelled
Release / Build (FOSS) (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
2026-09-21 16:29:22 +09:00
BookOasis Reader 6a57bc601f Fix reading downloaded EPUB and TXT books
Build & Test / Build & Test App (push) Has been cancelled
2026-09-21 16:23:42 +09:00
10 changed files with 161 additions and 8 deletions
+3
View File
@@ -22,3 +22,6 @@
*.woff binary
*.pyc binary
*.swp binary
# Android packages
*.apk binary
+2 -2
View File
@@ -33,8 +33,8 @@ android {
defaultConfig {
applicationId = "app.mihon"
versionCode = 30
versionName = "0.20.4"
versionCode = 31
versionName = "0.20.5"
buildConfigField("String", "COMMIT_COUNT", "\"${getLatestCommitCount()}\"")
buildConfigField("String", "COMMIT_SHA", "\"${getLatestCommitSha()}\"")
@@ -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.
*
@@ -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) {
@@ -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<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 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<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() {
super.recycle()
pageLoader?.recycle()
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.