9 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
javara999 fe73aff860 Fix EPUB and TXT document downloads
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
Build & Test / Build & Test App (push) Has been cancelled
2026-09-21 13:29:23 +09:00
javara999 a347cd10b8 Add release APK artifacts
Update website / update_website (release) Waiting to run
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 11:11:15 +09:00
javara999 849148691a Support EPUB and TXT downloads from Kavita and BookOasis
Build & Test / Build & Test App (push) Has been cancelled
2026-09-21 11:02:48 +09:00
javara999 c0e0ef1575 Improve live text reader repagination
Build & Test / Build & Test App (push) Has been cancelled
2026-09-19 20:12:09 +09:00
23 changed files with 547 additions and 122 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()}\"")
@@ -53,7 +53,7 @@ internal fun ColumnScope.GeneralPage(viewModel: ReaderSettingsViewModel) {
selected = readerTheme == value,
onClick = {
viewModel.preferences.readerTheme.set(value)
viewModel.requestTextSettingsReload()
viewModel.requestTextAppearanceReload()
},
label = { Text(stringResource(labelRes)) },
)
@@ -374,10 +374,13 @@ class DownloadCache(
when {
// Ignore incomplete downloads
it.name?.endsWith(Downloader.TMP_DIR_SUFFIX) == true -> null
it.name?.contains(".download.", ignoreCase = true) == true -> null
// Folder of images
it.isDirectory -> it.name
// CBZ files
it.isFile && it.extension == "cbz" -> it.nameWithoutExtension
// Archived or original document files
it.isFile && it.extension?.lowercase()?.let { extension ->
extension == "cbz" || extension in DOCUMENT_DOWNLOAD_EXTENSIONS
} == true -> it.nameWithoutExtension
// Anything else is irrelevant
else -> null
}
@@ -405,8 +405,12 @@ class DownloadManager(
.firstOrNull() ?: return
var newName = provider.getChapterDirName(newChapter.name, newChapter.scanlator, newChapter.url)
if (oldDownload.isFile && oldDownload.extension == "cbz") {
newName += ".cbz"
if (oldDownload.isFile) {
oldDownload.extension?.lowercase()?.let { extension ->
if (extension == "cbz" || extension in DOCUMENT_DOWNLOAD_EXTENSIONS) {
newName += ".$extension"
}
}
}
if (oldDownload.name == newName) return
@@ -19,6 +19,8 @@ import tachiyomi.domain.storage.service.StorageManager
import tachiyomi.i18n.MR
import java.io.IOException
internal val DOCUMENT_DOWNLOAD_EXTENSIONS = setOf("epub", "txt")
/**
* This class is used to provide the directories where the downloads should be saved.
* It uses the following path scheme: /<root downloads dir>/<source name>/<manga>/<chapter>
@@ -115,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.
*
@@ -250,11 +283,17 @@ class DownloadProvider(
add(chapterDirName)
// Archived chapters
add("$chapterDirName.cbz")
DOCUMENT_DOWNLOAD_EXTENSIONS.forEach { extension ->
add("$chapterDirName.$extension")
}
// any legacy names
legacyChapterDirNames.forEach {
add(it)
add("$it.cbz")
DOCUMENT_DOWNLOAD_EXTENSIONS.forEach { extension ->
add("$it.$extension")
}
}
}
}
@@ -12,8 +12,16 @@ import eu.kanade.tachiyomi.data.download.model.Download
import eu.kanade.tachiyomi.data.library.LibraryUpdateNotifier
import eu.kanade.tachiyomi.data.notification.NotificationHandler
import eu.kanade.tachiyomi.network.HttpException
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.source.UnmeteredSource
import eu.kanade.tachiyomi.source.bookoasis.bookOasisDocumentDownloadRequest
import eu.kanade.tachiyomi.source.bookoasis.isBookOasisSource
import eu.kanade.tachiyomi.source.bookoasis.toBookOasisChapterRef
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.kavita.detectKavitaDocumentFormat
import eu.kanade.tachiyomi.source.kavita.isKavitaSource
import eu.kanade.tachiyomi.source.kavita.kavitaDocumentDownloadRequest
import eu.kanade.tachiyomi.source.kavita.toKavitaChapterId
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.storage.DiskUtil.NOMEDIA_FILE
@@ -44,6 +52,7 @@ import kotlinx.coroutines.supervisorScope
import logcat.LogPriority
import mihon.core.archive.ZipWriter
import nl.adaptivity.xmlutil.serialization.XML
import okhttp3.Request
import okhttp3.Response
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.core.common.util.lang.launchIO
@@ -339,9 +348,15 @@ class Downloader(
download.chapter.scanlator,
download.chapter.url,
)
try {
val remoteDocument = findRemoteDocument(download)
if (remoteDocument != null) {
downloadDocument(download, mangaDir, chapterDirname, remoteDocument)
return
}
val tmpDir = mangaDir.createDirectory(chapterDirname + TMP_DIR_SUFFIX)!!
try {
// If the page list already exists, start from the file
val pageList = download.pages ?: run {
// Otherwise, pull page list from network and add them to download object
@@ -415,6 +430,85 @@ class Downloader(
}
}
private suspend fun findRemoteDocument(download: Download): RemoteDocument? {
val source = download.source
return when {
source.isBookOasisSource() -> download.chapter.url
.toBookOasisChapterRef()
?.takeIf { it.format in DOCUMENT_DOWNLOAD_EXTENSIONS }
?.let { ref -> RemoteDocument(ref.format, source.bookOasisDocumentDownloadRequest(ref)) }
source.isKavitaSource() -> download.chapter.url
.toKavitaChapterId()
?.let { chapterId ->
source.detectKavitaDocumentFormat(chapterId)?.let { format ->
RemoteDocument(format, source.kavitaDocumentDownloadRequest(chapterId))
}
}
else -> null
}
}
private suspend fun downloadDocument(
download: Download,
mangaDir: UniFile,
chapterDirname: String,
document: RemoteDocument,
) {
val targetName = "$chapterDirname.${document.extension}"
val tempName = "$chapterDirname.download.${document.extension}"
mangaDir.findFile(tempName)?.delete()
val temp = mangaDir.createFile(tempName)
?: error("Failed to create temporary document file")
val page = Page(0).apply {
status = Page.State.DownloadImage
}
download.pages = listOf(page)
download.status = Download.State.DOWNLOADING
notifier.onProgressChange(download)
try {
download.source.client.newCall(document.request).await().use { response ->
if (!response.isSuccessful) {
throw IllegalStateException(
"Document download failed: HTTP ${response.code}",
)
}
temp.openOutputStream().use { output ->
response.body.byteStream().use { input -> input.copyTo(output) }
}
}
if (temp.length() <= 0L) {
throw IllegalStateException("Document download returned an empty file")
}
mangaDir.findFile(targetName)?.delete()
if (!temp.renameTo(targetName)) {
val target = mangaDir.createFile(targetName)
?: error("Failed to create downloaded document file")
temp.openInputStream().use { input ->
target.openOutputStream().use { output ->
input.copyTo(output)
}
}
temp.delete()
}
} catch (error: Throwable) {
temp.delete()
throw error
}
val target = mangaDir.findFile(targetName)
?: error("Downloaded document file is missing")
page.uri = target.uri
page.status = Page.State.Ready
page.progress = 100
notifier.onProgressChange(download)
cache.addChapter(chapterDirname, mangaDir, download.manga)
DiskUtil.createNoMediaFile(mangaDir, context)
download.status = Download.State.DOWNLOADED
}
/**
* Gets the image from the filesystem if it exists or downloads it otherwise.
*
@@ -740,5 +834,10 @@ class Downloader(
}
}
private data class RemoteDocument(
val extension: String,
val request: Request,
)
// Arbitrary minimum required space to start a download: 200 MB
private const val MIN_DISK_SPACE = 200L * 1024 * 1024
@@ -21,13 +21,40 @@ internal data class BookOasisChapterRef(
val format: String,
)
private fun HttpSource.bookOasisServerBase(): String {
val normalized = baseUrl.trimEnd('/')
return when {
normalized.endsWith("/app-opds-adult", ignoreCase = true) ->
normalized.dropLast("/app-opds-adult".length)
normalized.endsWith("/app-opds", ignoreCase = true) ->
normalized.dropLast("/app-opds".length)
else -> normalized
}
}
internal fun HttpSource.bookOasisDocumentDownloadRequest(ref: BookOasisChapterRef): Request {
val downloadUrl = buildString {
append(bookOasisServerBase())
append("/app-opds/download/")
append(ref.dbType)
append('/')
append(ref.bookId)
}
return Request.Builder()
.url(downloadUrl)
.headers(headers)
.get()
.build()
}
internal fun Source.isBookOasisSource(): Boolean {
return this::class.java.name.startsWith(BOOKOASIS_PACKAGE_PREFIX)
}
internal fun String.toBookOasisChapterRef(): BookOasisChapterRef? {
val path = substringBefore('?')
val match = Regex("""^/api/media/books/(\d+)/info$""").matchEntire(path) ?: return null
val match = Regex("""^/(?:app-opds(?:-adult)?/)?api/media/books/(\d+)/info$""").matchEntire(path) ?: return null
val params = parseQueryParameters(this)
val format = params["bo_format"]?.lowercase() ?: return null
if (format !in DOCUMENT_FORMATS) return null
@@ -1,12 +1,61 @@
package eu.kanade.tachiyomi.source.kavita
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.online.HttpSource
import okhttp3.Request
import org.json.JSONObject
import java.util.Locale
private const val KAVITA_PACKAGE_PREFIX = "eu.kanade.tachiyomi.extension.all.kavita."
internal fun Source.isKavitaSource(): Boolean =
this::class.java.name.startsWith(KAVITA_PACKAGE_PREFIX)
private fun HttpSource.kavitaApiBase(): String {
val normalized = baseUrl.trimEnd('/')
return if (normalized.endsWith("/api", ignoreCase = true)) normalized else "$normalized/api"
}
internal suspend fun HttpSource.detectKavitaDocumentFormat(chapterId: Int): String? {
val apiBase = kavitaApiBase()
val request = Request.Builder()
.url("$apiBase/Chapter?chapterId=$chapterId")
.headers(headers)
.get()
.build()
client.newCall(request).await().use { response ->
if (!response.isSuccessful) return null
val body = response.body.string()
if (body.isBlank()) return null
val files = JSONObject(body).optJSONArray("files") ?: return null
for (i in 0 until files.length()) {
val file = files.optJSONObject(i) ?: continue
val extension = file.optString("extension")
.ifBlank { file.optString("filePath") }
.substringAfterLast('.')
.trim()
.trimStart('.')
.lowercase(Locale.ROOT)
if (extension == "epub" || extension == "txt") return extension
if (file.optInt("format", -1) == 3) return "epub"
}
}
return null
}
internal fun HttpSource.kavitaDocumentDownloadRequest(chapterId: Int): Request {
val apiBase = kavitaApiBase()
return Request.Builder()
.url("$apiBase/Download/chapter?chapterId=$chapterId")
.headers(headers)
.get()
.build()
}
/**
* Kavita extension chapter URLs are normally /Chapter/{id}. Keep compatibility
* with older/alternate forms used by reading lists and previous releases.
@@ -57,6 +57,7 @@ import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.storage.cacheImageDir
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
@@ -186,6 +187,12 @@ class ReaderViewModel(
*/
private var loader: ChapterLoader? = null
/**
* Only the newest text repagination request is allowed to finish. Slider
* changes can arrive faster than a large TXT/EPUB chapter can be rebuilt.
*/
private var textRepaginationJob: Job? = null
/**
* The time the chapter was started reading
*/
@@ -841,17 +848,26 @@ class ReaderViewModel(
if (chapter.pageLoader?.usesTextReaderStyle != true) return
if (chapter.pages?.any { it is TextReaderPage } != true) return
val chapterLoader = loader ?: return
val requestedPage = (state.value.currentPage - 1).coerceAtLeast(0)
val oldLastIndex = (chapter.pages?.lastIndex ?: 0).coerceAtLeast(0)
val readingProgress = if (oldLastIndex == 0) 0f else requestedPage.toFloat() / oldLastIndex
viewModelScope.launchIO {
val oldPageCount = chapter.pages?.size?.coerceAtLeast(1) ?: 1
val requestedPage = chapter.requestedPage.coerceIn(0, oldPageCount - 1)
// Anchor at the middle of the visible page so position remains stable
// when font size/spacing changes the total page count.
val readingProgress = ((requestedPage + 0.5f) / oldPageCount)
.coerceIn(0f, 0.999999f)
textRepaginationJob?.cancel()
textRepaginationJob = viewModelScope.launchIO {
try {
val oldLoader = chapterLoader.reloadTextChapter(chapter, readingProgress) ?: return@launchIO
eventChannel.send(Event.ReloadTextViewerChapters)
// Give old visible holders time to detach before closing resources such as EpubReader.
delay(2000)
// Resource cleanup must survive cancellation by the next slider value.
viewModelScope.launchNonCancellable {
delay(750)
oldLoader.recycle()
}
eventChannel.trySend(Event.ReloadTextViewerChapters)
} catch (e: Throwable) {
if (e is CancellationException) throw e
logcat(LogPriority.ERROR, e) { "Failed to repaginate text reader style" }
@@ -4,11 +4,11 @@ import android.content.Context
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.source.bookoasis.BookOasisChapterRef
import eu.kanade.tachiyomi.source.bookoasis.bookOasisDocumentDownloadRequest
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle
import mihon.core.archive.epubReader
import okhttp3.Request
import java.io.File
import java.io.IOException
@@ -68,19 +68,7 @@ internal class BookOasisFilePageLoader(
val temp = File(cacheDir, "${target.name}.part")
if (temp.exists()) temp.delete()
val downloadUrl = buildString {
append(source.baseUrl.trimEnd('/'))
append("/api/media/books/")
append(ref.bookId)
append("/download?type=")
append(ref.dbType)
}
val request = Request.Builder()
.url(downloadUrl)
.headers(source.headers)
.get()
.build()
val request = source.bookOasisDocumentDownloadRequest(ref)
source.client.newCall(request).await().use { response ->
if (!response.isSuccessful) {
@@ -22,7 +22,8 @@ import tachiyomi.domain.source.model.StubSource
import tachiyomi.i18n.MR
import tachiyomi.source.local.LocalSource
import tachiyomi.source.local.io.Format
import kotlin.math.roundToInt
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
/**
* Loader used to retrieve the [PageLoader] for a given chapter.
@@ -90,10 +91,14 @@ class ChapterLoader(
throw Exception(context.stringResource(MR.strings.page_list_empty_error))
}
// If a newer slider value cancelled this rebuild while getPages()
// was working, do not publish stale pages into the active chapter.
currentCoroutineContext().ensureActive()
val oldLoader = chapter.pageLoader
chapter.pageLoader = newLoader
chapter.requestedPage = (readingProgress * pages.lastIndex)
.roundToInt()
chapter.requestedPage = (readingProgress.coerceIn(0f, 0.999999f) * pages.size)
.toInt()
.coerceIn(0, pages.lastIndex)
chapter.state = ReaderChapter.State.Loaded(pages)
oldLoader
@@ -125,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(
@@ -133,6 +165,7 @@ class ChapterLoader(
source,
downloadManager,
downloadProvider,
documentFallback,
)
source is LocalSource -> source.getFormat(chapter.chapter).let { format ->
when (format) {
@@ -10,9 +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.
@@ -23,15 +28,45 @@ 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()
private var archivePageLoader: ArchivePageLoader? = null
private var pageLoader: PageLoader? = null
private val readerPreferences: ReaderPreferences by injectLazy()
override var isLocal: Boolean = true
override val usesTextReaderStyle: Boolean
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,
@@ -40,6 +75,7 @@ internal class DownloadPageLoader(
manga.title,
source,
)
return if (chapterPath?.isFile == true) {
getPagesFromArchive(chapterPath)
} else {
@@ -47,13 +83,97 @@ internal class DownloadPageLoader(
}
}
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()
archivePageLoader?.recycle()
pageLoader?.recycle()
pageLoader = null
}
private suspend fun getPagesFromArchive(file: UniFile): List<ReaderPage> {
val loader = ArchivePageLoader(file.archiveReader(context)).also { archivePageLoader = it }
val loader = ArchivePageLoader(file.archiveReader(context)).also { pageLoader = it }
return loader.getPages()
}
private suspend fun getPagesFromEpub(file: UniFile): List<ReaderPage> {
val loader = EpubPageLoader(
file.epubReader(context),
readerPreferences.textReaderStyle(context),
context.cacheDir,
).also { pageLoader = it }
return loader.getPages()
}
private suspend fun getPagesFromText(file: UniFile): List<ReaderPage> {
val loader = TextPageLoader(
file,
readerPreferences.textReaderStyle(context),
context.cacheDir,
).also { pageLoader = it }
return loader.getPages()
}
@@ -69,6 +189,6 @@ internal class DownloadPageLoader(
}
override suspend fun loadPage(page: ReaderPage) {
archivePageLoader?.loadPage(page)
pageLoader?.loadPage(page)
}
}
@@ -4,16 +4,15 @@ import android.content.Context
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.data.cache.ChapterCache
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.source.kavita.detectKavitaDocumentFormat
import eu.kanade.tachiyomi.source.kavita.kavitaDocumentDownloadRequest
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.reader.model.ReaderChapter
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle
import mihon.core.archive.epubReader
import okhttp3.Request
import org.json.JSONObject
import java.io.File
import java.io.IOException
import java.util.Locale
/**
* Compatibility loader for Kavita document chapters.
@@ -42,7 +41,7 @@ internal class KavitaCompatPageLoader(
override suspend fun getPages(): List<ReaderPage> {
check(!isRecycled)
val format = detectDocumentFormat()
val format = source.detectKavitaDocumentFormat(chapterId)
val loader = when (format) {
"epub", "txt" -> createDocumentLoader(format)
else -> HttpPageLoader(chapter, source, chapterCache)
@@ -67,51 +66,6 @@ internal class KavitaCompatPageLoader(
super.recycle()
}
private suspend fun detectDocumentFormat(): String? {
val apiBase = source.baseUrl.trimEnd('/') + "/api"
val request = Request.Builder()
.url("$apiBase/Chapter?chapterId=$chapterId")
.headers(source.headers)
.get()
.build()
source.client.newCall(request).await().use { response ->
if (!response.isSuccessful) {
// Preserve the extension's normal behavior when chapter metadata
// cannot be queried. It may still be readable as image content.
return null
}
val body = response.body.string()
if (body.isBlank()) return null
val chapterJson = JSONObject(body)
val files = chapterJson.optJSONArray("files") ?: return null
for (i in 0 until files.length()) {
val file = files.optJSONObject(i) ?: continue
val extension = normalizeExtension(
file.optString("extension").ifBlank { file.optString("filePath") },
)
if (extension == "epub" || extension == "txt") {
return extension
}
// Current Kavita format enum: EPUB == 3. TXT has no dedicated
// enum value in upstream Kavita, so it is detected by extension.
if (file.optInt("format", -1) == 3) {
return "epub"
}
}
}
return null
}
private fun normalizeExtension(value: String): String =
value.substringAfterLast('.', value)
.trim()
.trimStart('.')
.lowercase(Locale.ROOT)
private suspend fun createDocumentLoader(format: String): PageLoader {
val file = getOrDownloadDocument(format)
val uniFile = UniFile.fromFile(file)
@@ -132,12 +86,7 @@ internal class KavitaCompatPageLoader(
val temp = File(cacheDir, "${target.name}.part")
if (temp.exists()) temp.delete()
val apiBase = source.baseUrl.trimEnd('/') + "/api"
val request = Request.Builder()
.url("$apiBase/Download/chapter?chapterId=$chapterId")
.headers(source.headers)
.get()
.build()
val request = source.kavitaDocumentDownloadRequest(chapterId)
source.client.newCall(request).await().use { response ->
if (!response.isSuccessful) {
@@ -16,13 +16,10 @@ import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.io.Reader
import kotlin.math.roundToInt
internal object TextPageRenderer {
private const val PAGE_WIDTH = 1440
private const val PAGE_HEIGHT = 2160
private const val MARGIN = 96
private const val CONTENT_WIDTH = PAGE_WIDTH - (MARGIN * 2)
private const val CONTENT_HEIGHT = PAGE_HEIGHT - (MARGIN * 2)
private const val MARGIN_DP = 32f
private const val STREAM_BUFFER_CHARS = 64 * 1024
private const val STREAM_READ_CHARS = 8 * 1024
@@ -101,7 +98,8 @@ internal object TextPageRenderer {
private fun calculatePageSlices(text: String, style: TextReaderStyle): List<PageSlice> {
if (text.isEmpty()) return emptyList()
val layout = createLayout(text, style)
val metrics = resolveLayoutMetrics(style)
val layout = createLayout(text, style, metrics.contentWidth)
if (layout.lineCount == 0) return listOf(PageSlice(0, text.length))
val pages = mutableListOf<PageSlice>()
@@ -111,7 +109,7 @@ internal object TextPageRenderer {
var endLine = startLine
while (
endLine + 1 < layout.lineCount &&
layout.getLineBottom(endLine + 1) - startTop <= CONTENT_HEIGHT
layout.getLineBottom(endLine + 1) - startTop <= metrics.contentHeight
) {
endLine++
}
@@ -125,13 +123,14 @@ internal object TextPageRenderer {
}
fun render(text: String, style: TextReaderStyle): InputStream {
val bitmap = Bitmap.createBitmap(PAGE_WIDTH, PAGE_HEIGHT, Bitmap.Config.ARGB_8888)
val metrics = resolveLayoutMetrics(style)
val bitmap = Bitmap.createBitmap(metrics.pageWidth, metrics.pageHeight, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
val (backgroundColor, _) = resolveColors(style)
canvas.drawColor(backgroundColor)
canvas.save()
canvas.translate(MARGIN.toFloat(), MARGIN.toFloat())
createLayout(text, style).draw(canvas)
canvas.translate(metrics.horizontalMargin.toFloat(), metrics.verticalMargin.toFloat())
createLayout(text, style, metrics.contentWidth).draw(canvas)
canvas.restore()
val output = ByteArrayOutputStream()
@@ -140,25 +139,46 @@ internal object TextPageRenderer {
return ByteArrayInputStream(output.toByteArray())
}
private fun createLayout(text: String, style: TextReaderStyle): StaticLayout {
private fun createLayout(
text: String,
style: TextReaderStyle,
contentWidth: Int,
): StaticLayout {
val (_, textColor) = resolveColors(style)
val paint = TextPaint().apply {
isAntiAlias = true
color = textColor
textSize = style.fontSize * 3f
textSize = style.fontSizePx.coerceAtLeast(1f)
typeface = style.fontFamily.androidFamilyName
?.let { Typeface.create(it, Typeface.NORMAL) }
?: Typeface.DEFAULT
}
val styledText = addParagraphSpacing(text, style.paragraphSpacing * 3)
val styledText = addParagraphSpacing(text, style.paragraphSpacingPx)
return StaticLayout.Builder
.obtain(styledText, 0, styledText.length, paint, CONTENT_WIDTH)
.obtain(styledText, 0, styledText.length, paint, contentWidth)
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
.setIncludePad(true)
.setLineSpacing(0f, style.lineSpacingPercent / 100f)
.build()
}
private fun resolveLayoutMetrics(style: TextReaderStyle): LayoutMetrics {
val pageWidth = style.pageWidthPx.coerceAtLeast(1)
val pageHeight = style.pageHeightPx.coerceAtLeast(1)
val requestedMargin = (MARGIN_DP * style.density).roundToInt().coerceAtLeast(1)
val horizontalMargin = requestedMargin.coerceAtMost(((pageWidth - 1) / 2).coerceAtLeast(0))
val verticalMargin = requestedMargin.coerceAtMost(((pageHeight - 1) / 2).coerceAtLeast(0))
return LayoutMetrics(
pageWidth = pageWidth,
pageHeight = pageHeight,
horizontalMargin = horizontalMargin,
verticalMargin = verticalMargin,
contentWidth = (pageWidth - horizontalMargin * 2).coerceAtLeast(1),
contentHeight = (pageHeight - verticalMargin * 2).coerceAtLeast(1),
)
}
private fun resolveColors(style: TextReaderStyle): Pair<Int, Int> {
val useDark = when (style.readerTheme) {
0 -> false
@@ -205,6 +225,15 @@ internal object TextPageRenderer {
}
}
private data class LayoutMetrics(
val pageWidth: Int,
val pageHeight: Int,
val horizontalMargin: Int,
val verticalMargin: Int,
val contentWidth: Int,
val contentHeight: Int,
)
private data class PageSlice(
val start: Int,
val endExclusive: Int,
@@ -5,9 +5,8 @@ import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.setting.TextReaderStyle
/**
* A rendered text page whose style can be changed without rebuilding the chapter.
* Page boundaries remain stable during the live preview; a full repagination is done
* once when the settings dialog is dismissed.
* A rendered text page whose visual style can be refreshed cheaply while layout-affecting
* changes trigger a debounced chapter repagination to rebuild page boundaries.
*/
internal class TextReaderPage(
index: Int,
@@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.ui.reader.setting
import android.content.Context
import android.content.res.Configuration
import android.os.Build
import android.util.TypedValue
import androidx.compose.ui.graphics.BlendMode
import dev.icerock.moko.resources.StringResource
import dev.zacsweers.metro.AppScope
@@ -137,7 +138,20 @@ class ReaderPreferences(
}
}
fun textReaderStyle(context: Context) = TextReaderStyle(
fun textReaderStyle(context: Context): TextReaderStyle {
val displayMetrics = context.resources.displayMetrics
val rawWidth = displayMetrics.widthPixels.coerceAtLeast(1)
val rawHeight = displayMetrics.heightPixels.coerceAtLeast(1)
// Keep the same physical page proportions as the device while capping the
// raster size so high-resolution phones do not create oversized bitmaps.
val renderScale = minOf(
1f,
TEXT_READER_MAX_RENDER_WIDTH / rawWidth.toFloat(),
TEXT_READER_MAX_RENDER_HEIGHT / rawHeight.toFloat(),
)
return TextReaderStyle(
fontSize = textFontSize.get(),
lineSpacingPercent = textLineSpacing.get(),
paragraphSpacing = textParagraphSpacing.get(),
@@ -145,7 +159,23 @@ class ReaderPreferences(
readerTheme = readerTheme.get(),
automaticDark = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) ==
Configuration.UI_MODE_NIGHT_YES,
pageWidthPx = (rawWidth * renderScale).roundToInt().coerceAtLeast(1),
pageHeightPx = (rawHeight * renderScale).roundToInt().coerceAtLeast(1),
density = displayMetrics.density * renderScale,
fontSizePx = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_SP,
textFontSize.get().toFloat(),
displayMetrics,
) * renderScale,
paragraphSpacingPx = (
TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
textParagraphSpacing.get().toFloat(),
displayMetrics,
) * renderScale
).roundToInt(),
)
}
// endregion
@@ -336,6 +366,9 @@ class ReaderPreferences(
const val WEBTOON_PADDING_MIN = 0
const val WEBTOON_PADDING_MAX = 25
private const val TEXT_READER_MAX_RENDER_WIDTH = 1440
private const val TEXT_READER_MAX_RENDER_HEIGHT = 2400
const val MILLI_CONVERSION = 100
val TapZones = listOf(
@@ -399,4 +432,9 @@ data class TextReaderStyle(
val fontFamily: ReaderPreferences.TextFontFamily,
val readerTheme: Int,
val automaticDark: Boolean,
val pageWidthPx: Int,
val pageHeightPx: Int,
val density: Float,
val fontSizePx: Float,
val paragraphSpacingPx: Int,
)
@@ -32,20 +32,49 @@ class ReaderSettingsViewModel(
.stateIn(viewModelScope, SharingStarted.Lazily, null)
private var textSettingsReloadJob: Job? = null
private var textAppearanceReloadJob: Job? = null
private var textSettingsDirty = false
/**
* Layout-affecting text settings are previewed quickly, then the chapter is
* repaginated after a short debounce. This keeps the slider responsive while
* ensuring the number of characters/lines on a page follows the new style.
*/
fun requestTextSettingsReload() {
textSettingsDirty = true
textSettingsReloadJob?.cancel()
textSettingsReloadJob = viewModelScope.launch {
delay(200)
delay(250)
onTextSettingsPreview()
textSettingsDirty = false
onTextSettingsCommit()
textSettingsReloadJob = null
}
}
/**
* Theme-only changes do not affect page boundaries, so avoid an unnecessary
* full repagination.
*/
fun requestTextAppearanceReload() {
textAppearanceReloadJob?.cancel()
textAppearanceReloadJob = viewModelScope.launch {
delay(50)
onTextSettingsPreview()
textAppearanceReloadJob = null
}
}
fun flushTextSettingsReload() {
if (!textSettingsDirty) return
val hadPendingAppearanceReload = textAppearanceReloadJob != null
textAppearanceReloadJob?.cancel()
textAppearanceReloadJob = null
if (!textSettingsDirty) {
if (hadPendingAppearanceReload) {
onTextSettingsPreview()
}
return
}
textSettingsReloadJob?.cancel()
textSettingsReloadJob = null
onTextSettingsPreview()
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.