From: Cameron Otsuka Date: Tue, 11 Aug 2026 23:11:13 +0000 (-0700) Subject: performance refactor X-Git-Url: https://git.otsuka.systems/?a=commitdiff_plain;h=25e6bf46a7e482eb8fdef7072490dd8430a02ebe;p=alexandria performance refactor --- diff --git a/README.md b/README.md index a71851c..4bce1a0 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,18 @@ The app requests no network or shared-storage permission. It imports each select The APK is written to `app/build/outputs/apk/debug/app-debug.apk`. +Run parser, renderer, repository, persistence, and model tests plus Android lint with: + +```powershell +.\gradlew.bat :app:testDebugUnitTest :app:lintDebug +``` + +With an Android virtual device running, execute the WebView lifecycle and main-thread I/O tests with: + +```powershell +.\gradlew.bat :app:connectedDebugAndroidTest +``` + ## End-to-end verification Start the supplied Android virtual device, then run: @@ -62,6 +74,14 @@ Run the focused reader tests separately: The fixed-layout test verifies automatic page fitting, image dithering, navigation, and position restoration. The search/history test verifies forward and backward searches from page two, result-jump history, an internal-link jump, and previous-location returns. Both write screenshots under `verification/`. +Measure cold import, cached opening, reader-cache reuse, and process memory with: + +```powershell +.\scripts\profile-reader.ps1 -Fixture .\tests\fixtures\odyssey.epub +``` + +The profile is written to `verification/reader-profile.json`. Supply a larger standards-compliant EPUB with `-Fixture` to test worst-case layout behavior. + To start a clean AVD before manual testing, use: ```powershell diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a50eee2..e7e7bec 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -10,12 +10,26 @@ android { targetSdk = 35 versionCode = 1 versionName = "1.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } + + sourceSets.getByName("test").resources.srcDir(rootProject.file("tests/fixtures")) + sourceSets.getByName("androidTest").assets.srcDir(rootProject.file("tests/fixtures")) + testOptions.unitTests.isIncludeAndroidResources = true } dependencies { implementation("androidx.activity:activity-ktx:1.10.1") implementation("org.jsoup:jsoup:1.18.3") + + testImplementation("junit:junit:4.13.2") + testImplementation("org.json:json:20240303") + testImplementation("androidx.test:core-ktx:1.7.0") + testImplementation("org.robolectric:robolectric:4.14.1") + + androidTestImplementation("androidx.test:core-ktx:1.7.0") + androidTestImplementation("androidx.test.ext:junit-ktx:1.3.0") + androidTestImplementation("androidx.test:runner:1.7.0") } kotlin { jvmToolchain(17) } diff --git a/app/src/androidTest/java/com/alexandria/reader/LibraryRepositoryInstrumentedTest.kt b/app/src/androidTest/java/com/alexandria/reader/LibraryRepositoryInstrumentedTest.kt new file mode 100644 index 0000000..c8a4755 --- /dev/null +++ b/app/src/androidTest/java/com/alexandria/reader/LibraryRepositoryInstrumentedTest.kt @@ -0,0 +1,62 @@ +package com.alexandria.reader + +import android.content.Context +import android.net.Uri +import android.os.StrictMode +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.util.Collections +import java.util.concurrent.Executors + +@RunWith(AndroidJUnit4::class) +class LibraryRepositoryInstrumentedTest { + @Test + fun readerMutationsDoNotWriteFilesOnTheMainThread() { + val context = ApplicationProvider.getApplicationContext() + File(context.filesDir, "library").deleteRecursively() + val source = File(context.cacheDir, "threading-test.epub") + InstrumentationRegistry.getInstrumentation().context.assets.open("navigation.epub").use { input -> + source.outputStream().use(input::copyTo) + } + val repository = LibraryRepository(context) + val publication = repository.import(Uri.fromFile(source)) + val violations = Collections.synchronizedList(mutableListOf()) + val listenerExecutor = Executors.newSingleThreadExecutor() + try { + InstrumentationRegistry.getInstrumentation().runOnMainSync { + val previous = StrictMode.getThreadPolicy() + StrictMode.setThreadPolicy( + StrictMode.ThreadPolicy.Builder(previous) + .detectDiskWrites() + .penaltyListener(listenerExecutor) { violation -> violations += violation.toString() } + .build(), + ) + try { + val id = publication.book.id + repository.markOpened(id) + repository.updateProgress(id, ReaderLocation(1, 4, 1, 20, .25f)) + repository.saveSettings(id, ReaderSettings(fontSize = 24)) + repository.saveBookmarks(id, listOf(Bookmark("Page", 1, 20, 1, .25f, 1L))) + repository.saveAnnotations(id, listOf(Annotation("note", 1, 0, 4, "Text", ""))) + repository.setState(id, LibraryBook.ReadingState.READING) + repository.rename(id, "Thread-safe title") + } finally { + StrictMode.setThreadPolicy(previous) + } + } + repository.awaitPendingWrites() + listenerExecutor.shutdown() + while (!listenerExecutor.isTerminated) Thread.sleep(10) + assertTrue("Main-thread disk writes: $violations", violations.isEmpty()) + } finally { + listenerExecutor.shutdownNow() + repository.close() + source.delete() + } + } +} diff --git a/app/src/androidTest/java/com/alexandria/reader/ReaderWebViewTest.kt b/app/src/androidTest/java/com/alexandria/reader/ReaderWebViewTest.kt new file mode 100644 index 0000000..52a465d --- /dev/null +++ b/app/src/androidTest/java/com/alexandria/reader/ReaderWebViewTest.kt @@ -0,0 +1,155 @@ +package com.alexandria.reader + +import android.content.Context +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference + +@RunWith(AndroidJUnit4::class) +class ReaderWebViewTest { + @Test + fun restoresFixedLayoutLocationAndReportsSubsequentNavigation() { + val prepared = prepareFixedPublication("restore") + val ready = CountDownLatch(1) + val layoutReady = CountDownLatch(1) + val restored = CountDownLatch(1) + val advanced = CountDownLatch(1) + val webView = AtomicReference() + val latest = AtomicReference(ReaderLocation()) + + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + val reader = ReaderWebView(activity) + webView.set(reader) + reader.listener = listener( + ready = { + ready.countDown() + reader.applySettings( + ReaderSettings(), + ReaderLocation(page = 2, totalPages = 4, chapter = 2, progress = 2f / 3f), + ) + }, + layoutReady = layoutReady::countDown, + location = { value -> + latest.set(value) + if (value.page == 2 && value.chapter == 2) restored.countDown() + if (value.page == 3 && value.chapter == 3) advanced.countDown() + }, + ) + activity.setContentView(reader) + reader.load(prepared.reader, prepared.publication.rootDirectory) + } + + assertTrue("Reader bridge did not become ready", ready.await(10, TimeUnit.SECONDS)) + assertTrue("Fixed layout did not report completion", layoutReady.await(10, TimeUnit.SECONDS)) + assertTrue("Saved fixed-layout location was not restored", restored.await(10, TimeUnit.SECONDS)) + scenario.onActivity { webView.get().nextPage() } + assertTrue("Page navigation was not reported", advanced.await(10, TimeUnit.SECONDS)) + assertEquals(3, latest.get().page) + } + } + + @Test + fun clampsPrimitiveBridgeValuesAndDropsCallbacksAfterDestroy() { + val prepared = prepareFixedPublication("bridge") + val ready = CountDownLatch(1) + val invalidValue = CountDownLatch(1) + val callbackCount = AtomicInteger() + val received = AtomicReference() + val webView = AtomicReference() + + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + val reader = ReaderWebView(activity) + webView.set(reader) + reader.listener = listener( + ready = ready::countDown, + location = { value -> + callbackCount.incrementAndGet() + if (value.totalPages == 1) { + received.set(value) + invalidValue.countDown() + } + }, + ) + activity.setContentView(reader) + reader.load(prepared.reader, prepared.publication.rootDirectory) + } + assertTrue("Reader bridge did not become ready", ready.await(10, TimeUnit.SECONDS)) + scenario.onActivity { + webView.get().evaluateJavascript("Android.onPageChanged(-5,0,-2,-9,2)", null) + } + assertTrue("Primitive bridge callback was not delivered", invalidValue.await(10, TimeUnit.SECONDS)) + assertEquals(ReaderLocation(progress = 1f), received.get()) + + val beforeDestroy = callbackCount.get() + scenario.onActivity { + val reader = webView.get() + reader.evaluateJavascript( + "setTimeout(function(){Android.onPageChanged(1,4,1,0,0.33);},250)", + null, + ) + reader.destroy() + } + Thread.sleep(500) + assertEquals("A destroyed WebView delivered a stale callback", beforeDestroy, callbackCount.get()) + } + } + + private data class Prepared(val publication: EpubPublication, val reader: File) + + private fun prepareFixedPublication(name: String): Prepared { + val context = ApplicationProvider.getApplicationContext() + val directory = File(context.cacheDir, "reader-webview-$name").apply { + deleteRecursively() + mkdirs() + } + val source = File(directory, "book.epub") + InstrumentationRegistry.getInstrumentation().context.assets.open("fixed-layout.epub").use { input -> + source.outputStream().use(input::copyTo) + } + val publication = EpubParser.parse( + source, + File(directory, "content"), + LibraryBook( + id = "0123456789abcdef01234567", + title = "WebView test", + author = "Alexandria", + fileName = "fixed-layout.epub", + ), + ) + return Prepared(publication, EpubHtmlBuilder.write(publication, File(directory, "reader.html"))) + } + + private fun listener( + ready: () -> Unit = {}, + layoutReady: () -> Unit = {}, + location: (ReaderLocation) -> Unit = {}, + ): ReaderWebView.Listener { + val onLocation = location + return object : ReaderWebView.Listener { + override fun onReady() = ready() + override fun onLayoutReady() = layoutReady() + override fun onLocationChanged(location: ReaderLocation) = onLocation(location) + override fun onTap(x: Float, y: Float) = Unit + override fun onSwipe(forward: Boolean) = Unit + override fun onExternalLink(url: String) = Unit + override fun onNavigationJump(origin: ReaderLocation) = Unit + override fun onSearchResults(count: Int, currentIndex: Int) = Unit + override fun onAnnotationTapped(id: String) = Unit + override fun onSelectionAction(action: ReaderWebView.SelectionAction, selection: JSONObject?) = Unit + override fun onRenderError(message: String) = Unit + } + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f010ca3..6bb7051 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,6 +12,7 @@ android:icon="@drawable/ic_launcher" android:roundIcon="@drawable/ic_launcher" android:allowBackup="false" + android:fullBackupContent="@xml/backup_rules" android:dataExtractionRules="@xml/data_extraction_rules" android:usesCleartextTraffic="false" android:hardwareAccelerated="true" diff --git a/app/src/main/java/com/alexandria/reader/EpubHtmlBuilder.kt b/app/src/main/java/com/alexandria/reader/EpubHtmlBuilder.kt new file mode 100644 index 0000000..5471126 --- /dev/null +++ b/app/src/main/java/com/alexandria/reader/EpubHtmlBuilder.kt @@ -0,0 +1,816 @@ +package com.alexandria.reader + +import org.json.JSONObject +import org.jsoup.Jsoup +import org.jsoup.nodes.Comment +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import org.jsoup.parser.Parser +import java.io.File +import java.util.Locale +import kotlin.math.roundToInt + +/** Builds one secure, paginated HTML document from the complete EPUB spine. */ +internal object EpubHtmlBuilder { + internal const val FORMAT_VERSION = "2" + + private data class ChapterDocument( + val index: Int, + val item: SpineItem, + val document: Document, + val body: Element, + val directory: String, + val viewportWidth: Int, + val viewportHeight: Int, + val idTargets: MutableMap = linkedMapOf(), + ) + + fun write(publication: EpubPublication, output: File): File { + WorkCancellation.check() + val chapters = publication.spine.mapIndexed { index, item -> + WorkCancellation.check() + val file = EpubParser.safeFile(publication.rootDirectory, item.href) + require(file.isFile) { "An EPUB spine document is missing." } + val document = file.inputStream().use { source -> + WorkCancellation.input(source).use { stream -> + Jsoup.parse(stream, null, file.toURI().toString(), Parser.xmlParser()) + } + } + // The spine is parsed as XML, but it is embedded in an HTML document. + // HTML serialization expands empty non-void tags such as to + // ; otherwise the browser treats the rest of a chapter as a link. + document.outputSettings().syntax(Document.OutputSettings.Syntax.html).prettyPrint(false) + val body = document.getAllElements().firstOrNull { it.tagName().substringAfter(':').equals("body", true) } + ?: document + val viewport = if (publication.fixedLayout) { + fixedViewport(document, body, publication.defaultPageWidth, publication.defaultPageHeight) + } else { + 0 to 0 + } + ChapterDocument( + index, item, document, body, item.href.substringBeforeLast('/', ""), viewport.first, viewport.second, + ) + }.let(::ArrayDeque) + + // All source documents share one browser document. Namespace their IDs + // and rewrite every same-document reference so repeated publisher and + // SVG IDs cannot resolve into another spine item. + val targetIds = linkedMapOf() + chapters.forEach { chapter -> + WorkCancellation.check() + val chapterTarget = "alex-chapter-${chapter.index}" + val seenIds = mutableSetOf() + var generatedIndex = 0 + targetIds[chapter.item.href] = chapterTarget + + fun register(element: Element, root: Boolean) { + val ids = sourceIds(element) + if (ids.isEmpty()) return + ids.forEach { id -> + require(seenIds.add(id)) { "An EPUB spine document contains duplicate IDs: $id" } + } + val generated = if (root) chapterTarget else "alex-${chapter.index}-${generatedIndex++}" + ids.forEach { id -> + chapter.idTargets[id] = generated + targetIds["${chapter.item.href}#$id"] = generated + } + if (!root) { + element.removeAttr("id") + element.removeAttr("xml:id") + element.attr("id", generated) + } + } + + if (chapter.body !== chapter.document) { + chapter.document.getAllElements().firstOrNull { elementName(it) == "html" } + ?.let { register(it, true) } + } + chapter.body.getAllElements().forEach { element -> + WorkCancellation.check() + register(element, element === chapter.body || elementName(element) in ROOT_ELEMENTS) + } + } + publication.toc.forEach { entry -> + require(entry.href in targetIds) { "The EPUB table of contents has a missing target: ${entry.href}" } + } + + // Preserve each source document's stylesheet order and isolate all + // selectors to the section that linked or declared them. + val publisherCss = buildString { + chapters.forEach { chapter -> + WorkCancellation.check() + chapter.document.getAllElements().forEach { element -> + WorkCancellation.check() + when (elementName(element)) { + "link" -> if (isStylesheet(element)) { + val href = element.attr("href") + if (href.isNotBlank() && !isExternal(href)) { + val path = EpubParser.normalizePath(chapter.directory, href.substringBefore('#')) + val css = loadStylesheet(path, publication.rootDirectory, mutableSetOf()) + if (css.isNotBlank()) { + append("\n/* ").append(path.replace("*/", "")).append(" · spine ") + .append(chapter.index).append(" */\n") + append(scopeCss(css, chapter.index, chapter.idTargets)) + } + } + } + "style" -> { + val css = element.data().ifBlank { element.wholeText() } + val expanded = expandCssImports( + css, chapter.directory, publication.rootDirectory, mutableSetOf(), + ) + append('\n').append( + scopeCss( + rewriteCssUrls(expanded, chapter.directory, publication.rootDirectory), + chapter.index, + chapter.idTargets, + ), + ) + } + } + } + } + } + + val references = JSONObject() + publication.toc.forEach { entry -> references.put(entry.href, targetIds.getValue(entry.href)) } + output.parentFile?.mkdirs() + writeHtml(output, publication, publisherCss, chapters, targetIds, references.toString()) + return output + } + + /** Writes one chapter at a time so large books do not need a second full-size HTML string. */ + private fun writeHtml( + output: File, + publication: EpubPublication, + publisherCss: String, + chapters: ArrayDeque, + targetIds: Map, + references: String, + ) { + val shell = buildHtml(publication, PUBLISHER_SLOT, CONTENT_SLOT, references) + val publisherIndex = shell.indexOf(PUBLISHER_SLOT) + val contentIndex = shell.indexOf(CONTENT_SLOT, publisherIndex + PUBLISHER_SLOT.length) + check(publisherIndex >= 0 && contentIndex > publisherIndex) { "Invalid reader HTML template." } + output.bufferedWriter().use { writer -> + WorkCancellation.check() + writer.append(shell, 0, publisherIndex) + writer.append(publisherCss) + writer.append(shell, publisherIndex + PUBLISHER_SLOT.length, contentIndex) + while (chapters.isNotEmpty()) { + WorkCancellation.check() + writer.append(renderChapter(chapters.removeFirst(), publication, targetIds)) + } + writer.append(shell, contentIndex + CONTENT_SLOT.length, shell.length) + } + } + + private fun renderChapter( + chapter: ChapterDocument, + publication: EpubPublication, + targetIds: Map, + ): String = buildString { + WorkCancellation.check() + chapter.body.getAllElements().filter { element -> + elementName(element) in BLOCKED_ELEMENTS || elementName(element) == "style" || isStylesheet(element) + }.forEach(Element::remove) + // XML permits , while HTML interprets it as an unclosed link. + // A comment forces Jsoup to emit an explicit closing tag without + // adding visible or measurable content. + chapter.body.getAllElements().filter { + it.tagName().substringAfter(':').equals("a", true) && it.childNodeSize() == 0 + }.forEach { it.appendChild(Comment("alex-empty")) } + chapter.body.getAllElements().forEach { element -> + WorkCancellation.check() + val name = elementName(element) + element.attributes().asList().toList().forEach { attribute -> + val key = attribute.key.lowercase(Locale.ROOT) + val value = attribute.value + val isHref = key == "href" || key.endsWith(":href") + when { + key.startsWith("on") -> element.removeAttr(attribute.key) + key == "style" -> { + val rewritten = rewriteLocalCssFragments( + rewriteCssUrls( + rewritePageBreaks(value), chapter.directory, publication.rootDirectory, + ), + chapter.idTargets, + ) + element.attr(attribute.key, rewritten) + element.attr("data-alex-publisher-style", rewritten) + } + key == "srcset" -> element.attr( + attribute.key, + rewriteSrcSet(value, chapter.directory, publication.rootDirectory), + ) + isHref && name in LINK_ELEMENTS -> rewriteLink(element, attribute.key, value, chapter, targetIds) + key.substringAfter(':') in ID_REFERENCE_ATTRIBUTES -> + element.attr(attribute.key, rewriteIdReferences(value, chapter.idTargets)) + key.substringAfter(':') in SMIL_TIMING_ATTRIBUTES -> + element.attr(attribute.key, rewriteSmilReferences(value, chapter.idTargets)) + key == "src" || key == "poster" || key == "data" || isHref -> { + val lower = value.trim().lowercase(Locale.ROOT) + val local = localFragmentTarget(value, chapter.idTargets) + when { + local != null -> element.attr(attribute.key, "#$local") + lower.startsWith("file:") || lower.startsWith("content:") || lower.startsWith("javascript:") -> + element.removeAttr(attribute.key) + value.isNotBlank() && !isExternal(value) && !value.startsWith('#') -> + resourceUrl(chapter.directory, value, publication.rootDirectory) + ?.let { element.attr(attribute.key, it) } ?: element.removeAttr(attribute.key) + } + } + "url(" in value.lowercase(Locale.ROOT) -> + element.attr(attribute.key, rewriteLocalCssFragments(value, chapter.idTargets)) + } + } + } + val html = chapter.document.getAllElements().firstOrNull { elementName(it) == "html" } + val classes = chapter.body.classNames().joinToString(" ") { escapeAttribute(it) } + val bodyStyle = chapter.body.attr("style").takeIf(String::isNotBlank).orEmpty() + val language = chapter.body.attr("lang").ifBlank { chapter.body.attr("xml:lang") } + .ifBlank { html?.attr("lang").orEmpty() }.ifBlank { html?.attr("xml:lang").orEmpty() } + val direction = chapter.body.attr("dir").ifBlank { html?.attr("dir").orEmpty() } + append("
') + if (!publication.fixedLayout) append("") + append(chapter.body.html()).append("
\n") + } + + private fun rewriteLink( + element: Element, + attributeName: String, + value: String, + chapter: ChapterDocument, + targetIds: Map, + ) { + if (value.isBlank()) return + if (isExternal(value)) { + element.attr("data-external-href", value) + element.attr(attributeName, "#") + return + } + val reference = when { + value == "#" -> chapter.item.href + value.startsWith('#') -> chapter.item.href + value + else -> EpubParser.resolveReference(chapter.directory, value) + } + val target = targetIds[reference] ?: error("An EPUB link points outside the reading order: $reference") + element.attr(attributeName, "#$target") + } + + private fun fixedViewport(document: Document, body: Element, defaultWidth: Int, defaultHeight: Int): Pair { + val viewport = document.getAllElements().firstOrNull { + it.tagName().substringAfter(':').equals("meta", true) && it.attr("name").equals("viewport", true) + }?.attr("content").orEmpty() + fun component(pattern: Regex): Int? = pattern.find(viewport)?.groupValues?.getOrNull(1) + ?.toDoubleOrNull()?.roundToInt()?.takeIf { it > 0 } + val viewportWidth = component(VIEWPORT_WIDTH_PATTERN) + val viewportHeight = component(VIEWPORT_HEIGHT_PATTERN) + if (viewportWidth != null && viewportHeight != null) return viewportWidth to viewportHeight + + val svg = document.getAllElements().firstOrNull { it.tagName().substringAfter(':').equals("svg", true) } + val viewBox = svg?.attributes()?.asList()?.firstOrNull { it.key.substringAfter(':').equals("viewBox", true) }?.value + ?.trim()?.split(VIEW_BOX_SEPARATOR)?.mapNotNull(String::toDoubleOrNull) + if (viewBox != null && viewBox.size >= 4 && viewBox[2] > 0 && viewBox[3] > 0) { + return viewBox[2].roundToInt() to viewBox[3].roundToInt() + } + fun dimension(value: String): Int? = DIMENSION_PATTERN.find(value)?.groupValues?.getOrNull(1) + ?.toDoubleOrNull()?.roundToInt()?.takeIf { it > 0 } + val svgWidth = dimension(svg?.attr("width").orEmpty()) + val svgHeight = dimension(svg?.attr("height").orEmpty()) + if (svgWidth != null && svgHeight != null) return svgWidth to svgHeight + + val style = body.attr("style") + val cssWidth = BODY_WIDTH_PATTERN.find(style)?.groupValues?.getOrNull(1)?.toDoubleOrNull()?.roundToInt() + val cssHeight = BODY_HEIGHT_PATTERN.find(style)?.groupValues?.getOrNull(1)?.toDoubleOrNull()?.roundToInt() + return (cssWidth?.takeIf { it > 0 } ?: defaultWidth) to (cssHeight?.takeIf { it > 0 } ?: defaultHeight) + } + + private fun buildHtml(publication: EpubPublication, publisherCss: String, content: String, references: String): String = + """ + + + + + +${escapeHtml(publication.book.title)} + + + + + +$content + + +""" + + + + + private fun loadStylesheet(path: String, root: File, importStack: MutableSet): String { + WorkCancellation.check() + val normalized = EpubParser.normalizePath("", path) + if (!importStack.add(normalized)) return "" + return try { + val file = runCatching { EpubParser.safeFile(root, normalized) }.getOrNull() + if (file?.isFile != true) return "" + val base = normalized.substringBeforeLast('/', "") + val css = file.inputStream().use { source -> + WorkCancellation.input(source).bufferedReader().use { it.readText() } + } + val expanded = expandCssImports(css, base, root, importStack) + rewriteCssUrls(expanded, base, root) + } finally { + importStack.remove(normalized) + } + } + + private fun expandCssImports(css: String, base: String, root: File, importStack: MutableSet): String { + return CSS_IMPORT_PATTERN.replace(css) { match -> + WorkCancellation.check() + val href = match.groupValues.drop(1).firstOrNull { it.isNotBlank() }.orEmpty() + if (href.isBlank() || isExternal(href)) "" + else loadStylesheet(EpubParser.normalizePath(base, href.substringBefore('#').substringBefore('?')), root, importStack) + } + } + + private fun rewriteCssUrls(css: String, base: String, root: File): String { + var rewritten = CSS_URL_PATTERN.replace(css) { match -> + val value = match.groupValues[2].trim() + val lower = value.lowercase(Locale.ROOT) + val target = resourceUrl(base, value, root) + when { + lower.startsWith("file:") || lower.startsWith("content:") || lower.startsWith("javascript:") -> "url('')" + target == null -> match.value + else -> "url('$target')" + } + } + rewritten = CSS_QUOTED_IMPORT_PATTERN.replace(rewritten) { match -> + val target = resourceUrl(base, match.groupValues[2], root) + if (target == null) match.value else "@import url('$target')" + } + return rewritten.replace(CSS_CHARSET_PATTERN, "") + } + + private fun rewriteSrcSet(value: String, base: String, root: File): String { + val candidates = mutableListOf() + var index = 0 + while (index < value.length) { + while (index < value.length && (value[index].isWhitespace() || value[index] == ',')) index++ + if (index >= value.length) break + + val urlStart = index + while (index < value.length && !value[index].isWhitespace()) index++ + var url = value.substring(urlStart, index) + var separatorInUrl = false + while (url.endsWith(',')) { + separatorInUrl = true + url = url.dropLast(1) + } + if (url.isBlank()) continue + + var descriptor = "" + if (!separatorInUrl) { + while (index < value.length && value[index].isWhitespace()) index++ + val descriptorStart = index + var parentheses = 0 + while (index < value.length) { + when (value[index]) { + '(' -> parentheses++ + ')' -> if (parentheses > 0) parentheses-- + ',' -> if (parentheses == 0) break + } + index++ + } + descriptor = value.substring(descriptorStart, index).trim() + if (index < value.length && value[index] == ',') index++ + } + + val lower = url.lowercase(Locale.ROOT) + val rewritten = when { + lower.startsWith("file:") || lower.startsWith("content:") || lower.startsWith("javascript:") -> null + isExternal(url) || url.startsWith('#') -> url + else -> resourceUrl(base, url, root) + } + if (rewritten != null) candidates += if (descriptor.isBlank()) rewritten else "$rewritten $descriptor" + } + return candidates.joinToString(", ") + } + + private fun resourceUrl(base: String, value: String, root: File): String? { + if (value.isBlank() || isExternal(value) || value.startsWith('#')) return null + val path = EpubParser.normalizePath(base, value.substringBefore('#').substringBefore('?')) + val fileUrl = runCatching { EpubParser.safeFile(root, path) }.getOrNull() + ?.takeIf(File::isFile)?.toURI()?.toASCIIString()?.replace("'", "%27") ?: return null + val fragment = value.substringAfter('#', "") + return if (fragment.isBlank()) fileUrl else "$fileUrl#${encodeUriComponent(EpubParser.decodeUriComponent(fragment))}" + } + + /** Re-targets every selector to the spine section that supplied the CSS. */ + private fun scopeCss(css: String, chapter: Int, idTargets: Map): String { + val scope = ".alex-chapter[data-spine=\"$chapter\"]" + + fun transformSelector(value: String): String = splitCssSelectors(value).joinToString(",") { source -> + var selector = rewriteSelectorIds(source, idTargets).trim() + .replace(HTML_BODY_CHILD_SELECTOR, scope) + .replace(HTML_BODY_SELECTOR, scope) + .replace(BODY_SELECTOR, scope) + .replace(HTML_SELECTOR, scope) + .replace(ROOT_SELECTOR, scope) + while ("$scope $scope" in selector || "$scope > $scope" in selector) { + selector = selector.replace("$scope > $scope", scope).replace("$scope $scope", scope) + } + val rootId = "#alex-chapter-$chapter" + when { + scope in selector || rootId in selector || selector.isBlank() -> selector + selector.startsWith('.') || selector.startsWith('[') || selector.startsWith(':') -> + "$scope$selector,$scope $selector" + else -> "$scope $selector" + } + } + + fun process(source: String): String { + val out = StringBuilder() + var index = 0 + while (index < source.length) { + WorkCancellation.check() + val open = findCssDelimiter(source, index, '{') + if (open < 0) { + out.append(source.substring(index)) + break + } + val prelude = source.substring(index, open) + val close = matchingBrace(source, open) + if (close < 0) { + out.append(source.substring(index)) + break + } + val body = source.substring(open + 1, close) + val trimmed = prelude.trimStart() + fun appendRule(rulePrelude: String, declarations: String) { + out.append(transformSelector(rulePrelude)).append('{') + .append(rewritePageBreaks(rewriteLocalCssFragments(declarations, idTargets))).append('}') + } + when { + trimmed.startsWith("@media", true) || trimmed.startsWith("@supports", true) || + trimmed.startsWith("@layer", true) || trimmed.startsWith("@container", true) -> + out.append(prelude).append('{').append(process(body)).append('}') + // Preserve semicolon at-rules such as @namespace, then scope + // the ordinary selector that follows them. + trimmed.startsWith('@') && prelude.lastIndexOf(';') >= 0 -> { + val split = prelude.lastIndexOf(';') + 1 + out.append(prelude.substring(0, split)) + appendRule(prelude.substring(split), body) + } + trimmed.startsWith('@') -> out.append(prelude).append('{') + .append(rewriteLocalCssFragments(body, idTargets)).append('}') + else -> appendRule(prelude, body) + } + index = close + 1 + } + return out.toString() + } + return process(css) + } + + private fun splitCssSelectors(value: String): List { + val result = mutableListOf() + var start = 0 + var parentheses = 0 + var brackets = 0 + var quote = '\u0000' + var comment = false + var index = 0 + while (index < value.length) { + val character = value[index] + when { + comment -> if (index + 1 < value.length && character == '*' && value[index + 1] == '/') { + comment = false + index++ + } + quote != '\u0000' -> if (character == '\\') index++ else if (character == quote) quote = '\u0000' + index + 1 < value.length && character == '/' && value[index + 1] == '*' -> { + comment = true + index++ + } + character == '\'' || character == '"' -> quote = character + character == '\\' && index + 1 < value.length -> index++ + character == '(' -> parentheses++ + character == ')' && parentheses > 0 -> parentheses-- + character == '[' -> brackets++ + character == ']' && brackets > 0 -> brackets-- + character == ',' && parentheses == 0 && brackets == 0 -> { + result += value.substring(start, index) + start = index + 1 + } + } + index++ + } + result += value.substring(start) + return result + } + + private fun rewriteSelectorIds(value: String, idTargets: Map): String { + val output = StringBuilder(value.length) + var quote = '\u0000' + var comment = false + var index = 0 + while (index < value.length) { + val character = value[index] + when { + comment -> { + output.append(character) + if (index + 1 < value.length && character == '*' && value[index + 1] == '/') { + output.append('/') + index++ + comment = false + } + } + quote != '\u0000' -> { + output.append(character) + if (character == '\\' && index + 1 < value.length) output.append(value[++index]) + else if (character == quote) quote = '\u0000' + } + index + 1 < value.length && character == '/' && value[index + 1] == '*' -> { + output.append("/*") + index++ + comment = true + } + character == '\'' || character == '"' -> { + output.append(character) + quote = character + } + character == '#' -> { + val identifier = readCssIdentifier(value, index + 1) + val target = idTargets[identifier.value] + output.append('#').append(target ?: value.substring(index + 1, identifier.end)) + index = identifier.end - 1 + } + else -> output.append(character) + } + index++ + } + return output.toString() + } + + private data class CssIdentifier(val value: String, val end: Int) + + private fun readCssIdentifier(value: String, start: Int): CssIdentifier { + val decoded = StringBuilder() + var index = start + while (index < value.length) { + val character = value[index] + when { + character == '\\' && index + 1 < value.length -> { + index++ + val hexStart = index + while (index < value.length && index - hexStart < 6 && value[index].digitToIntOrNull(16) != null) index++ + if (index > hexStart) { + val codePoint = value.substring(hexStart, index).toInt(16).coerceAtMost(0x10ffff) + decoded.appendCodePoint(if (codePoint == 0) 0xfffd else codePoint) + if (index < value.length && value[index].isWhitespace()) index++ + } else { + decoded.append(value[index]) + index++ + } + } + character == '-' || character == '_' || character.isLetterOrDigit() || character.code >= 0x80 -> { + decoded.append(character) + index++ + } + else -> break + } + } + return CssIdentifier(decoded.toString(), index) + } + + private fun rewriteLocalCssFragments(value: String, idTargets: Map): String = + CSS_URL_PATTERN.replace(value) { match -> + val reference = match.groupValues[2].trim() + val target = localFragmentTarget(reference, idTargets) + if (target == null) match.value else "url('#$target')" + } + + private fun localFragmentTarget(value: String, idTargets: Map): String? { + val trimmed = value.trim() + if (!trimmed.startsWith('#') || trimmed.length == 1) return null + return idTargets[EpubParser.decodeUriComponent(trimmed.drop(1))] + } + + private fun rewriteIdReferences(value: String, idTargets: Map): String = + value.split(WHITESPACE).joinToString(" ") { idTargets[it] ?: it } + + private fun rewriteSmilReferences(value: String, idTargets: Map): String = + SMIL_EVENT_REFERENCE_PATTERN.replace(value) { match -> + match.groupValues[1] + (idTargets[match.groupValues[2]] ?: match.groupValues[2]) + "." + } + + private fun rewritePageBreaks(css: String): String { + val rewritten = PAGE_BREAK_EDGE_PATTERN.replace(css) { match -> + val value = when (match.groupValues[2].lowercase(Locale.ROOT)) { + "always", "left", "right" -> "column" + "avoid" -> "avoid-column" + else -> match.groupValues[2].lowercase(Locale.ROOT) + } + "break-${match.groupValues[1].lowercase(Locale.ROOT)}:$value${match.groupValues[3]};" + } + return PAGE_BREAK_INSIDE_PATTERN.replace(rewritten) { match -> + val value = if (match.groupValues[1].equals("avoid", ignoreCase = true)) { + "avoid-column" + } else { + match.groupValues[1].lowercase(Locale.ROOT) + } + "break-inside:$value${match.groupValues[2]};" + } + } + + private fun findCssDelimiter(value: String, start: Int, delimiter: Char): Int { + var quote = '\u0000'; var comment = false; var index = start + while (index < value.length) { + if (comment) { if (index + 1 < value.length && value[index] == '*' && value[index + 1] == '/') { comment = false; index++ } } + else if (quote != '\u0000') { if (value[index] == '\\') index++ else if (value[index] == quote) quote = '\u0000' } + else if (index + 1 < value.length && value[index] == '/' && value[index + 1] == '*') { comment = true; index++ } + else if (value[index] == '\'' || value[index] == '"') quote = value[index] + else if (value[index] == '\\') index++ + else if (value[index] == delimiter) return index + index++ + } + return -1 + } + + private fun matchingBrace(value: String, open: Int): Int { + var depth = 1; var quote = '\u0000'; var comment = false; var index = open + 1 + while (index < value.length) { + if (comment) { if (index + 1 < value.length && value[index] == '*' && value[index + 1] == '/') { comment = false; index++ } } + else if (quote != '\u0000') { if (value[index] == '\\') index++ else if (value[index] == quote) quote = '\u0000' } + else if (index + 1 < value.length && value[index] == '/' && value[index + 1] == '*') { comment = true; index++ } + else if (value[index] == '\'' || value[index] == '"') quote = value[index] + else if (value[index] == '\\') index++ + else if (value[index] == '{') depth++ + else if (value[index] == '}' && --depth == 0) return index + index++ + } + return -1 + } + + private fun elementName(element: Element): String = element.tagName().substringAfter(':').lowercase(Locale.ROOT) + + private fun isStylesheet(element: Element): Boolean = elementName(element) == "link" && + "stylesheet" in element.attr("rel").lowercase(Locale.ROOT).split(WHITESPACE) + + private fun sourceIds(element: Element): Set = element.attributes().asList().mapNotNull { attribute -> + attribute.value.takeIf { + it.isNotBlank() && (attribute.key.equals("id", true) || attribute.key.equals("xml:id", true)) + } + }.toSet() + + private fun encodeUriComponent(value: String): String = buildString { + value.encodeToByteArray().forEach { byte -> + val unsigned = byte.toInt() and 0xff + val character = unsigned.toChar() + if (unsigned in 'a'.code..'z'.code || unsigned in 'A'.code..'Z'.code || unsigned in '0'.code..'9'.code || + character == '-' || character == '.' || character == '_' || character == '~' + ) { + append(character) + } else { + append('%').append(HEX[unsigned ushr 4]).append(HEX[unsigned and 0x0f]) + } + } + } + + private fun isExternal(value: String): Boolean { + val lower = value.trim().lowercase(Locale.ROOT) + return lower.startsWith("http:") || lower.startsWith("https:") || lower.startsWith("mailto:") || + lower.startsWith("tel:") || lower.startsWith("data:") || lower.startsWith("javascript:") || lower.startsWith("blob:") || + lower.startsWith("file:") || lower.startsWith("content:") || lower.startsWith("about:") + } + + private val WHITESPACE = Regex("\\s+") + private val VIEWPORT_WIDTH_PATTERN = Regex( + "(?:^|[,;\\s])width\\s*=\\s*([0-9]+(?:\\.[0-9]+)?)", + RegexOption.IGNORE_CASE, + ) + private val VIEWPORT_HEIGHT_PATTERN = Regex( + "(?:^|[,;\\s])height\\s*=\\s*([0-9]+(?:\\.[0-9]+)?)", + RegexOption.IGNORE_CASE, + ) + private val VIEW_BOX_SEPARATOR = Regex("[\\s,]+") + private val DIMENSION_PATTERN = Regex("([0-9]+(?:\\.[0-9]+)?)") + private val BODY_WIDTH_PATTERN = Regex( + "(?:^|;)\\s*width\\s*:\\s*([0-9]+(?:\\.[0-9]+)?)px", + RegexOption.IGNORE_CASE, + ) + private val BODY_HEIGHT_PATTERN = Regex( + "(?:^|;)\\s*height\\s*:\\s*([0-9]+(?:\\.[0-9]+)?)px", + RegexOption.IGNORE_CASE, + ) + private val CSS_IMPORT_PATTERN = Regex( + "@import\\s+(?:url\\(\\s*)?(?:\\\"([^\\\"]+)\\\"|'([^']+)'|([^\\s;)]+))\\s*\\)?[^;]*;", + setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL), + ) + private val CSS_URL_PATTERN = Regex( + "url\\(\\s*(['\"]?)(.*?)\\1\\s*\\)", + setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL), + ) + private val CSS_QUOTED_IMPORT_PATTERN = Regex("@import\\s+(['\"])(.*?)\\1", RegexOption.IGNORE_CASE) + private val CSS_CHARSET_PATTERN = Regex("@charset\\s+[^;]+;", RegexOption.IGNORE_CASE) + private val HTML_BODY_CHILD_SELECTOR = Regex( + "(?\\s*body(?=([.#:\\[]|\\s|>|\\+|~|$))", + RegexOption.IGNORE_CASE, + ) + private val HTML_BODY_SELECTOR = Regex( + "(?|\\+|~|$))", + RegexOption.IGNORE_CASE, + ) + private val BODY_SELECTOR = Regex("(?|\\+|~|$))", RegexOption.IGNORE_CASE) + private val HTML_SELECTOR = Regex("(?|\\+|~|$))", RegexOption.IGNORE_CASE) + private val ROOT_SELECTOR = Regex("(?", ">") + private fun escapeAttribute(value: String): String = escapeHtml(value).replace("\"", """) +} diff --git a/app/src/main/java/com/alexandria/reader/EpubParser.kt b/app/src/main/java/com/alexandria/reader/EpubParser.kt index 1208eac..cb8916d 100644 --- a/app/src/main/java/com/alexandria/reader/EpubParser.kt +++ b/app/src/main/java/com/alexandria/reader/EpubParser.kt @@ -1,31 +1,31 @@ package com.alexandria.reader -import android.net.Uri -import org.json.JSONObject import org.jsoup.Jsoup -import org.jsoup.nodes.Comment import org.jsoup.nodes.Document import org.jsoup.nodes.Element import org.jsoup.parser.Parser import java.io.File import java.io.FileOutputStream import java.util.Locale +import java.util.zip.ZipEntry import java.util.zip.ZipFile -import kotlin.math.roundToInt /** EPUB 2/3 package reader. It keeps the original resources intact for standards-based rendering. */ -object EpubParser { +internal object EpubParser { private data class ManifestItem(val href: String, val mediaType: String, val properties: Set) private data class ParsedSpineItem(val item: SpineItem, val linear: Boolean, val fixedLayout: Boolean) fun parse(source: File, output: File, book: LibraryBook): EpubPublication { + WorkCancellation.check() if (!File(output, ".complete").isFile) { output.deleteRecursively() output.mkdirs() extract(source, output) + WorkCancellation.check() File(output, ".complete").writeText("ok") } + WorkCancellation.check() val containerFile = File(output, "META-INF/container.xml") require(containerFile.isFile) { "This file has no EPUB container document." } val rootfile = parseXml(containerFile).allByLocalName("rootfile").firstOrNull() @@ -56,10 +56,7 @@ object EpubParser { if (!meta.attr("property").equals("rendition:viewport", ignoreCase = true)) { return@firstNotNullOfOrNull null } - val dimensions = Regex( - "(?:width\\s*=\\s*)?([0-9]+)\\s*(?:x|[,;]\\s*height\\s*=)\\s*([0-9]+)", - RegexOption.IGNORE_CASE, - ).find(meta.text()) ?: return@firstNotNullOfOrNull null + val dimensions = PACKAGE_VIEWPORT_PATTERN.find(meta.text()) ?: return@firstNotNullOfOrNull null val width = dimensions.groupValues[1].toIntOrNull() ?: return@firstNotNullOfOrNull null val height = dimensions.groupValues[2].toIntOrNull() ?: return@firstNotNullOfOrNull null (width to height).takeIf { width > 0 && height > 0 } @@ -71,32 +68,40 @@ object EpubParser { meta.text().trim().equals("pre-paginated", ignoreCase = true) } - val manifest = opf.allByLocalName("manifest").firstOrNull()?.children() + val manifest = linkedMapOf() + opf.allByLocalName("manifest").firstOrNull()?.children() ?.filter { it.localName() == "item" } - ?.associate { element -> + ?.forEach { element -> + WorkCancellation.check() val id = element.attr("id") val href = element.attr("href") require(id.isNotBlank() && href.isNotBlank()) { "Every EPUB manifest item must have an ID and an href." } - id to ManifestItem( - href = normalizePath(packageDirectory, href.substringBefore('#').substringBefore('?')), - mediaType = element.attr("media-type").lowercase(Locale.ROOT), - properties = element.attr("properties").lowercase(Locale.ROOT).split(Regex("\\s+")).filter(String::isNotBlank).toSet(), + val previous = manifest.put( + id, + ManifestItem( + href = normalizePath(packageDirectory, href.substringBefore('#').substringBefore('?')), + mediaType = element.attr("media-type").lowercase(Locale.ROOT), + properties = element.attr("properties").lowercase(Locale.ROOT) + .split(WHITESPACE).filter(String::isNotBlank).toSet(), + ), ) - }.orEmpty() + require(previous == null) { "Every EPUB manifest item must have a unique ID." } + } require(manifest.isNotEmpty()) { "The EPUB manifest is empty." } val spineElement = opf.allByLocalName("spine").firstOrNull() ?: error("The EPUB package document has no spine element.") val parsedSpine = spineElement.children().filter { it.localName() == "itemref" }.map { reference -> + WorkCancellation.check() val item = manifest[reference.attr("idref")] ?: error("The EPUB spine references an item that is not in the manifest.") require(isContentDocument(item.mediaType)) { "The EPUB spine contains a non-content document." } val chapterFile = safeFile(output, item.href) require(chapterFile.isFile) { "An EPUB spine document is missing." } val referenceProperties = reference.attr("properties").lowercase(Locale.ROOT) - .split(Regex("\\s+")).filter(String::isNotBlank).toSet() + .split(WHITESPACE).filter(String::isNotBlank).toSet() ParsedSpineItem( - item = SpineItem(item.href, chapterTitle(chapterFile)), + item = SpineItem(item.href, chapterFile.nameWithoutExtension), linear = !reference.attr("linear").equals("no", ignoreCase = true), fixedLayout = packageVersion == 3 && ("rendition:layout-pre-paginated" in item.properties || @@ -149,6 +154,9 @@ object EpubParser { } } + val titledSpine = spine.mapIndexed { index, item -> + item.copy(title = toc.firstOrNull { it.spineIndex == index }?.title ?: item.title) + } val direction = spineElement.attr("page-progression-direction").lowercase(Locale.ROOT) .takeIf { it == "rtl" || it == "ltr" } ?: "ltr" val linearSpine = parsedSpine.filter(ParsedSpineItem::linear) @@ -157,7 +165,7 @@ object EpubParser { return EpubPublication( book = book, rootDirectory = output, - spine = spine, + spine = titledSpine, toc = toc, readingDirection = direction, fixedLayout = fixedLayout, @@ -168,26 +176,40 @@ object EpubParser { private fun extract(source: File, output: File) { var expanded = 0L + val extractedPaths = mutableSetOf() ZipFile(source).use { zip -> + val first = zip.entries().takeIf { it.hasMoreElements() }?.nextElement() + ?: error("The EPUB archive is empty.") + require( + first.name == "mimetype" && first.method == ZipEntry.STORED && + first.size == EPUB_MEDIA_TYPE.length.toLong() + ) { + "The EPUB mimetype must be the first uncompressed archive entry." + } + val mediaType = zip.getInputStream(first).use { it.readBytes().decodeToString() } + require(mediaType == EPUB_MEDIA_TYPE) { "The EPUB archive has an invalid mimetype." } + val entries = zip.entries() while (entries.hasMoreElements()) { + WorkCancellation.check() val entry = entries.nextElement() if (entry.isDirectory) continue - val name = normalizePath("", entry.name) - require(name.isNotBlank() && !entry.name.replace('\\', '/').startsWith('/')) { - "The EPUB contains an unsafe resource path." - } + val name = archivePath(entry.name) + require(extractedPaths.add(name)) { "The EPUB contains duplicate resource paths." } val target = safeFile(output, name) target.parentFile?.mkdirs() - zip.getInputStream(entry).use { input -> - FileOutputStream(target).use { outputStream -> - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - while (true) { - val count = input.read(buffer) - if (count < 0) break - expanded += count - require(expanded <= MAX_EXPANDED_BYTES) { "The EPUB expands beyond the 2 GB safety limit." } - outputStream.write(buffer, 0, count) + zip.getInputStream(entry).use { source -> + WorkCancellation.input(source).use { input -> + FileOutputStream(target).use { outputStream -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + WorkCancellation.check() + val count = input.read(buffer) + if (count < 0) break + expanded += count + require(expanded <= MAX_EXPANDED_BYTES) { "The EPUB expands beyond the 2 GB safety limit." } + outputStream.write(buffer, 0, count) + } } } } @@ -227,24 +249,25 @@ object EpubParser { val nav = document.allByLocalName("nav").firstOrNull { element -> element.attributes().asList().any { attribute -> attribute.key.substringAfter(':').equals("type", true) && - "toc" in attribute.value.lowercase().split(Regex("\\s+")) + "toc" in attribute.value.lowercase().split(WHITESPACE) } } ?: return emptyList() val result = mutableListOf() val navDirectory = path.substringBeforeLast('/', "") fun walk(container: Element, depth: Int) { + WorkCancellation.check() container.children().filter { it.localName() == "li" }.forEach { item -> - val label = item.children().firstOrNull { it.localName() in setOf("a", "span") } + val label = item.children().firstOrNull { it.localName() in TOC_LABEL_ELEMENTS } val href = label?.attr("href").orEmpty() if (label != null && href.isNotBlank()) { val resolved = resolveReference(navDirectory, href) result += TocEntry(label.text().trim().ifBlank { "Untitled section" }, resolved, depth, spineIndex(spine, resolved)) } - item.children().filter { it.localName() in setOf("ol", "ul") }.forEach { walk(it, depth + 1) } + item.children().filter { it.localName() in TOC_LIST_ELEMENTS }.forEach { walk(it, depth + 1) } } } - nav.children().filter { it.localName() in setOf("ol", "ul") }.forEach { walk(it, 0) } + nav.children().filter { it.localName() in TOC_LIST_ELEMENTS }.forEach { walk(it, 0) } return result } @@ -258,6 +281,7 @@ object EpubParser { val directory = path.substringBeforeLast('/', "") fun walk(parent: Element, depth: Int) { + WorkCancellation.check() parent.children().filter { it.localName() == "navpoint" }.forEach { point -> val navLabel = point.children().firstOrNull { it.localName() == "navlabel" } ?: error("An EPUB NCX navigation point has no label.") @@ -284,23 +308,22 @@ object EpubParser { } } - private fun chapterTitle(file: File): String = runCatching { - val document = parseXml(file) - document.allByLocalName("h1").firstOrNull()?.text()?.trim() - ?: document.allByLocalName("h2").firstOrNull()?.text()?.trim() - ?: document.allByLocalName("title").firstOrNull()?.text()?.trim() - ?: file.nameWithoutExtension - }.getOrDefault(file.nameWithoutExtension) - internal fun normalizePath(base: String, value: String): String { - val decoded = Uri.decode(value.trim()).replace('\\', '/').substringBefore('?') + val decoded = decodeUriComponent(value.trim().substringBefore('?')) + require('\\' !in decoded && !decoded.startsWith('/')) { "The EPUB contains an unsafe resource path." } val source = if (base.isBlank()) decoded else "$base/$decoded" val parts = ArrayDeque() source.split('/').forEach { part -> when (part) { "", "." -> Unit - ".." -> if (parts.isNotEmpty()) parts.removeLast() - else -> parts.addLast(part) + ".." -> { + require(parts.isNotEmpty()) { "Resource path leaves the EPUB container." } + parts.removeLast() + } + else -> { + require('\u0000' !in part) { "The EPUB contains an unsafe resource path." } + parts.addLast(part) + } } } return parts.joinToString("/") @@ -310,7 +333,40 @@ object EpubParser { val fragment = href.substringAfter('#', "") val relative = href.substringBefore('#') val path = if (relative.isBlank()) normalizePath("", base) else normalizePath(base, relative) - return if (fragment.isBlank()) path else "$path#${Uri.decode(fragment)}" + return if (fragment.isBlank()) path else "$path#${decodeUriComponent(fragment)}" + } + + private fun archivePath(value: String): String { + require(value.isNotBlank() && !value.startsWith('/') && '\\' !in value && '\u0000' !in value) { + "The EPUB contains an unsafe resource path." + } + require(value.split('/').all { it.isNotBlank() && it != "." && it != ".." }) { + "The EPUB contains an unsafe resource path." + } + return value + } + + internal fun decodeUriComponent(value: String): String { + val decoded = StringBuilder(value.length) + var index = 0 + while (index < value.length) { + if (value[index] != '%') { + decoded.append(value[index++]) + continue + } + val bytes = ByteArray((value.length - index + 2) / 3) + var count = 0 + while (index < value.length && value[index] == '%') { + require(index + 2 < value.length) { "The EPUB contains an invalid encoded path." } + val high = value[index + 1].digitToIntOrNull(16) + val low = value[index + 2].digitToIntOrNull(16) + require(high != null && low != null) { "The EPUB contains an invalid encoded path." } + bytes[count++] = ((high shl 4) or low).toByte() + index += 3 + } + decoded.append(bytes.decodeToString(0, count, throwOnInvalidSequence = true)) + } + return decoded.toString() } internal fun safeFile(root: File, path: String): File { @@ -323,8 +379,10 @@ object EpubParser { return canonical } - private fun parseXml(file: File): Document = file.inputStream().use { stream -> - Jsoup.parse(stream, null, file.toURI().toString(), Parser.xmlParser()) + private fun parseXml(file: File): Document = file.inputStream().use { source -> + WorkCancellation.input(source).use { stream -> + Jsoup.parse(stream, null, file.toURI().toString(), Parser.xmlParser()) + } } private fun isContentDocument(mediaType: String): Boolean = @@ -335,734 +393,13 @@ object EpubParser { getAllElements().filter { it.localName() == name.lowercase(Locale.ROOT) } private fun Element.firstText(name: String): String? = allByLocalName(name).firstOrNull()?.text()?.trim() - private const val MAX_EXPANDED_BYTES = 2_147_483_648L -} - -/** Builds one secure, paginated HTML document from the complete EPUB spine. */ -object EpubHtmlBuilder { - private data class ChapterDocument( - val index: Int, - val item: SpineItem, - val document: Document, - val body: Element, - val directory: String, - val viewportWidth: Int, - val viewportHeight: Int, + private val WHITESPACE = Regex("\\s+") + private val PACKAGE_VIEWPORT_PATTERN = Regex( + "(?:width\\s*=\\s*)?([0-9]+)\\s*(?:x|[,;]\\s*height\\s*=)\\s*([0-9]+)", + RegexOption.IGNORE_CASE, ) - - fun write(publication: EpubPublication, output: File): File { - val chapters = publication.spine.mapIndexed { index, item -> - val file = EpubParser.safeFile(publication.rootDirectory, item.href) - require(file.isFile) { "An EPUB spine document is missing." } - val document = file.inputStream().use { Jsoup.parse(it, null, file.toURI().toString(), Parser.xmlParser()) } - // The spine is parsed as XML, but it is embedded in an HTML document. - // HTML serialization expands empty non-void tags such as
to - // ; otherwise the browser treats the rest of a chapter as a link. - document.outputSettings().syntax(Document.OutputSettings.Syntax.html).prettyPrint(false) - val body = document.getAllElements().firstOrNull { it.tagName().substringAfter(':').equals("body", true) } - ?: document - val viewport = if (publication.fixedLayout) { - fixedViewport(document, body, publication.defaultPageWidth, publication.defaultPageHeight) - } else { - 0 to 0 - } - ChapterDocument( - index, item, document, body, item.href.substringBeforeLast('/', ""), viewport.first, viewport.second, - ) - } - val targetIds = mutableMapOf() - chapters.forEach { chapter -> - targetIds[chapter.item.href] = "alex-chapter-${chapter.index}" - chapter.document.getAllElements().forEach { element -> - val oldId = element.id() - if (oldId.isNotBlank()) { - val generated = generatedId(chapter.index, oldId) - targetIds["${chapter.item.href}#$oldId"] = generated - element.attr("id", generated) - } - } - } - publication.toc.forEach { entry -> - require(entry.href in targetIds) { "The EPUB table of contents has a missing target: ${entry.href}" } - } - - val cssPaths = linkedSetOf() - val inlineStyles = mutableListOf>() - chapters.forEach { chapter -> - chapter.document.getAllElements().filter { it.tagName().substringAfter(':').equals("link", true) }.forEach { link -> - if ("stylesheet" in link.attr("rel").lowercase()) { - val href = link.attr("href") - if (href.isNotBlank() && !isExternal(href)) cssPaths += EpubParser.normalizePath(chapter.directory, href.substringBefore('#')) - } - } - chapter.document.getAllElements().filter { it.tagName().substringAfter(':').equals("style", true) }.forEach { style -> - inlineStyles += chapter.directory to style.data().ifBlank { style.html() } - } - } - val publisherCss = buildString { - val loadedStylesheets = mutableSetOf() - cssPaths.forEach { path -> - val css = loadStylesheet(path, publication.rootDirectory, loadedStylesheets) - if (css.isNotBlank()) { - append("\n/* ").append(path.replace("*/", "")).append(" */\n") - append(scopeCss(css)) - } - } - inlineStyles.forEach { (directory, css) -> - val expanded = expandCssImports(css, directory, publication.rootDirectory, loadedStylesheets) - append('\n').append(scopeCss(rewriteCssUrls(expanded, directory, publication.rootDirectory))) - } - } - - val chapterHtml = buildString { - chapters.forEach { chapter -> - chapter.body.select("script, iframe, frame, object, embed").remove() - // XML permits , while HTML interprets it as an unclosed link. - // A comment forces Jsoup to emit an explicit closing tag without - // adding visible or measurable content. - chapter.body.getAllElements().filter { - it.tagName().substringAfter(':').equals("a", true) && it.childNodeSize() == 0 - }.forEach { it.appendChild(Comment("alex-empty")) } - chapter.body.getAllElements().forEach { element -> - element.attributes().asList().toList().forEach { attribute -> - val key = attribute.key.lowercase(Locale.ROOT) - val value = attribute.value - when { - key.startsWith("on") -> element.removeAttr(attribute.key) - key == "style" -> element.attr( - attribute.key, - rewriteCssUrls(rewritePageBreaks(value), chapter.directory, publication.rootDirectory), - ) - key == "srcset" -> element.attr(attribute.key, rewriteSrcSet(value, chapter.directory, publication.rootDirectory)) - key in setOf("src", "poster", "data") || key.endsWith(":href") -> { - val lower = value.trim().lowercase(Locale.ROOT) - if (lower.startsWith("file:") || lower.startsWith("content:") || lower.startsWith("javascript:")) { - element.removeAttr(attribute.key) - } else if (value.isNotBlank() && !isExternal(value) && !value.startsWith('#')) { - resourceUrl(chapter.directory, value, publication.rootDirectory)?.let { element.attr(attribute.key, it) } - ?: element.removeAttr(attribute.key) - } - } - key == "href" -> { - if (value.isBlank()) Unit - else if (isExternal(value)) { - element.attr("data-external-href", value) - element.attr("href", "#") - } else { - val reference = when { - value == "#" -> chapter.item.href - value.startsWith('#') -> chapter.item.href + value - else -> EpubParser.resolveReference(chapter.directory, value) - } - val target = targetIds[reference] - ?: error("An EPUB link points outside the reading order: $reference") - element.attr("href", "#$target") - element.attr("data-alex-ref", reference) - } - } - } - } - } - val classes = chapter.body.classNames().joinToString(" ") { escapeAttribute(it) } - val bodyStyle = chapter.body.attr("style").takeIf(String::isNotBlank).orEmpty() - val language = chapter.body.attr("lang").ifBlank { chapter.document.attr("lang") } - val direction = chapter.body.attr("dir") - append("
') - if (!publication.fixedLayout) append("") - append(chapter.body.html()).append("
\n") - } - } - - val references = JSONObject() - targetIds.forEach { (key, value) -> references.put(key, value) } - output.parentFile?.mkdirs() - output.writeText(buildHtml(publication, publisherCss, chapterHtml, references.toString())) - return output - } - - private fun fixedViewport(document: Document, body: Element, defaultWidth: Int, defaultHeight: Int): Pair { - val viewport = document.getAllElements().firstOrNull { - it.tagName().substringAfter(':').equals("meta", true) && it.attr("name").equals("viewport", true) - }?.attr("content").orEmpty() - fun component(name: String): Int? = Regex("(?:^|[,;\\s])$name\\s*=\\s*([0-9]+(?:\\.[0-9]+)?)", RegexOption.IGNORE_CASE) - .find(viewport)?.groupValues?.getOrNull(1)?.toDoubleOrNull()?.roundToInt()?.takeIf { it > 0 } - val viewportWidth = component("width") - val viewportHeight = component("height") - if (viewportWidth != null && viewportHeight != null) return viewportWidth to viewportHeight - - val svg = document.getAllElements().firstOrNull { it.tagName().substringAfter(':').equals("svg", true) } - val viewBox = svg?.attributes()?.asList()?.firstOrNull { it.key.substringAfter(':').equals("viewBox", true) }?.value - ?.trim()?.split(Regex("[\\s,]+"))?.mapNotNull(String::toDoubleOrNull) - if (viewBox != null && viewBox.size >= 4 && viewBox[2] > 0 && viewBox[3] > 0) { - return viewBox[2].roundToInt() to viewBox[3].roundToInt() - } - fun dimension(value: String): Int? = Regex("([0-9]+(?:\\.[0-9]+)?)").find(value) - ?.groupValues?.getOrNull(1)?.toDoubleOrNull()?.roundToInt()?.takeIf { it > 0 } - val svgWidth = dimension(svg?.attr("width").orEmpty()) - val svgHeight = dimension(svg?.attr("height").orEmpty()) - if (svgWidth != null && svgHeight != null) return svgWidth to svgHeight - - val style = body.attr("style") - val cssWidth = Regex("(?:^|;)\\s*width\\s*:\\s*([0-9]+(?:\\.[0-9]+)?)px", RegexOption.IGNORE_CASE) - .find(style)?.groupValues?.getOrNull(1)?.toDoubleOrNull()?.roundToInt() - val cssHeight = Regex("(?:^|;)\\s*height\\s*:\\s*([0-9]+(?:\\.[0-9]+)?)px", RegexOption.IGNORE_CASE) - .find(style)?.groupValues?.getOrNull(1)?.toDoubleOrNull()?.roundToInt() - return (cssWidth?.takeIf { it > 0 } ?: defaultWidth) to (cssHeight?.takeIf { it > 0 } ?: defaultHeight) - } - - private fun buildHtml(publication: EpubPublication, publisherCss: String, content: String, references: String): String = - """ - - - - - -${escapeHtml(publication.book.title)} - - - - - -$content - - -""" - - private const val IMAGE_DITHER_SCRIPT = """ -function applyImageDithering(root) { - var filter=document.getElementById('alex-image-dither'); - if(!filter)return; - if(!filter.firstChild){ - function node(name,attributes){var value=document.createElementNS('http://www.w3.org/2000/svg',name);Object.keys(attributes||{}).forEach(function(key){value.setAttribute(key,String(attributes[key]));});return value;} - filter.appendChild(node('feColorMatrix',{type:'matrix',result:'alex-gray',values:'0.2126 0.7152 0.0722 0 0 0.2126 0.7152 0.0722 0 0 0.2126 0.7152 0.0722 0 0 0 0 0 1 0'})); - filter.appendChild(node('feTurbulence',{type:'fractalNoise',baseFrequency:'.47',numOctaves:'1',seed:'37',stitchTiles:'stitch',result:'alex-noise'})); - filter.appendChild(node('feColorMatrix',{in:'alex-noise',type:'matrix',result:'alex-drift',values:'.065 0 0 0 -.0325 .065 0 0 0 -.0325 .065 0 0 0 -.0325 0 0 0 1 0'})); - filter.appendChild(node('feComposite',{in:'alex-gray',in2:'alex-drift',operator:'arithmetic',k1:'0',k2:'1',k3:'1',k4:'0',result:'alex-noisy'})); - var levels=[];for(var level=0;level<16;level++)levels.push((level/15).toFixed(4)); - var quantized=node('feComponentTransfer',{in:'alex-noisy',result:'alex-quantized'}); - ['R','G','B'].forEach(function(channel){quantized.appendChild(node('feFunc'+channel,{type:'discrete',tableValues:levels.join(' ')}));}); - quantized.appendChild(node('feFuncA',{type:'table',tableValues:'0 1'}));filter.appendChild(quantized); - filter.appendChild(node('feComposite',{in:'alex-quantized',in2:'SourceGraphic',operator:'in'})); - } - Array.prototype.forEach.call(root.querySelectorAll('img,canvas,video,.alex-chapter svg'),function(image){image.style.filter='url(#alex-image-dither)';}); -} -""" - - private const val REFLOWABLE_READER_SCRIPT = """ -(function () { - 'use strict'; - var state = { page:0, total:1, mode:'paged', chapterStarts:[], searchIndex:-1, searchToken:0 }; - var scrolling = function () { return document.scrollingElement; }; - var pageExtent = function () { return state.mode === 'paged' ? window.innerWidth : window.innerHeight; }; - var scrollPosition = function () { return state.mode === 'paged' ? scrolling().scrollLeft : scrolling().scrollTop; }; - function documentExtent() { - var root = scrolling(); - return state.mode === 'paged' ? Math.max(root.scrollWidth, document.body.scrollWidth) : Math.max(root.scrollHeight, document.body.scrollHeight); - } - function pageForElement(element) { - if (!element) return 0; - var rects = element.getClientRects(); - var rect = rects.length ? rects[0] : element.getBoundingClientRect(); - var absolute = state.mode === 'paged' ? rect.left + scrolling().scrollLeft : rect.top + scrolling().scrollTop; - return Math.max(0, Math.floor((absolute + 2) / Math.max(1, pageExtent()))); - } - function recalculate() { - var extent = documentExtent(); - state.total = Math.max(1, Math.ceil((extent - 1) / Math.max(1, pageExtent()))); - state.page = Math.max(0, Math.min(state.total - 1, Math.round(scrollPosition() / Math.max(1, pageExtent())))); - state.chapterStarts = Array.prototype.map.call(document.querySelectorAll('.alex-chapter-start'), function (element) { return pageForElement(element); }); - notifyPage(); - } - function chapterAt(page) { - var result = 0; - state.chapterStarts.forEach(function (start, index) { if (start <= page) result = index; }); - return result; - } - function textOffsetAtPoint(chapter) { - var range = null; - var x = Math.max(4, Math.min(window.innerWidth - 4, parseInt(getComputedStyle(document.documentElement).getPropertyValue('--alex-side')) + 5 || 20)); - for (var y = 8; y < window.innerHeight - 8 && !range; y += 24) { - range = document.caretRangeFromPoint(x, y); - } - if (!range || !chapter.contains(range.startContainer)) return 0; - var before = document.createRange(); before.selectNodeContents(chapter); before.setEnd(range.startContainer, range.startOffset); - return before.toString().length; - } - function locator() { - var chapterIndex = chapterAt(state.page); - var chapter = document.querySelector('.alex-chapter[data-spine="' + chapterIndex + '"]') || document.querySelectorAll('.alex-chapter')[chapterIndex]; - return { page:state.page, totalPages:state.total, chapter:chapterIndex, offset:chapter ? textOffsetAtPoint(chapter) : 0, progress:state.total <= 1 ? 0 : state.page / (state.total - 1) }; - } - function notifyPage() { - if (!window.Android || !Android.onPageChanged) return; - Android.onPageChanged(JSON.stringify(locator())); - } - function scrollToPage(page, report) { - state.page = Math.max(0, Math.min(state.total - 1, Math.round(Number(page) || 0))); - var position = state.page * pageExtent(); - if (state.mode === 'paged') scrolling().scrollTo(position, 0); else scrolling().scrollTo(0, position); - if (report !== false) notifyPage(); - } - function nodeAtOffset(chapter, offset) { - var walker = document.createTreeWalker(chapter, NodeFilter.SHOW_TEXT, { acceptNode:function (node) { - return /^(SCRIPT|STYLE)$/.test(node.parentElement ? node.parentElement.tagName : '') ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT; - }}); - var node, count = 0, last = null; - while ((node = walker.nextNode())) { last = node; if (count + node.data.length >= offset) return {node:node, offset:Math.max(0, offset-count)}; count += node.data.length; } - return last ? {node:last, offset:last.data.length} : null; - } - function goToLocator(value) { - if (!value) { scrollToPage(0, true); return; } - var chapter = document.querySelector('.alex-chapter[data-spine="' + (value.chapter || 0) + '"]'); - var point = chapter ? nodeAtOffset(chapter, Math.max(0, value.offset || 0)) : null; - if (point) { - var range = document.createRange(); range.setStart(point.node, Math.min(point.offset, point.node.length)); range.collapse(true); - var rects = range.getClientRects(), rect = rects.length ? rects[0] : null; - if (rect && isFinite(rect.left) && isFinite(rect.top) && (rect.width > 0 || rect.height > 0)) { - var absolute = state.mode === 'paged' ? rect.left + scrolling().scrollLeft : rect.top + scrolling().scrollTop; - scrollToPage(Math.floor((absolute + 2) / pageExtent()), true); return; - } - } - if (Number(value.totalPages) === state.total && typeof value.page === 'number') { scrollToPage(value.page, true); return; } - if (typeof value.progress === 'number') { scrollToPage(Math.round(value.progress * Math.max(0, state.total - 1)), true); return; } - scrollToPage(value.page || 0, true); - } - function applySettings(settings, keep) { - var root = document.documentElement, body = document.body; - var family = settings.fontFamily || 'Atkinson Hyperlegible Next'; - root.style.setProperty('--alex-font', "'" + family.replace(/'/g, '') + "'"); - root.style.setProperty('--alex-font-size', Math.max(12, Math.min(38, settings.fontSize || 20)) + 'px'); - root.style.setProperty('--alex-line-height', Math.max(1, Math.min(2, settings.lineHeight || 1.32))); - root.style.setProperty('--alex-side', Math.max(0, Math.min(96, settings.margin == null ? 32 : settings.margin)) + 'px'); - state.mode = settings.mode === 'continuous' ? 'continuous' : 'paged'; - body.classList.toggle('alex-paged', state.mode === 'paged'); body.classList.toggle('alex-continuous', state.mode !== 'paged'); - root.classList.toggle('alex-continuous', state.mode !== 'paged'); - body.classList.toggle('alex-hyphenate', settings.hyphenation !== false); body.classList.toggle('alex-no-hyphenate', settings.hyphenation === false); - body.classList.toggle('alex-font-override', family !== 'Publisher'); - ['left','right','center','justify'].forEach(function (name) { body.classList.toggle('alex-align-' + name, settings.textAlign === name); }); - document.getElementById('alex-publisher-styles').disabled = settings.publisherStyles === false; - applyImageDithering(body); - setTimeout(function () { recalculate(); if (keep) goToLocator(keep); }, 80); - } - function recordNavigation(targetPage, origin) { - var from = origin || locator(); - var target = Math.max(0, Math.min(state.total - 1, Math.round(Number(targetPage) || 0))); - var fromPage = Math.max(0, Math.min(state.total - 1, Math.round(Number(from.page) || 0))); - if (target === fromPage || !window.Android || !Android.onNavigationJump) return; - Android.onNavigationJump(JSON.stringify(from)); - } - function goToReference(reference, record) { - var id = window.ALEX_REFERENCES[reference]; - if (!id) return; - var target = document.getElementById(id); - if (!target) return; - var page = pageForElement(target); if (record !== false) recordNavigation(page); scrollToPage(page, true); - } - function clearSearch() { - state.searchToken++; - Array.prototype.forEach.call(document.querySelectorAll('mark.alex-search'), function (mark) { mark.replaceWith(document.createTextNode(mark.textContent)); }); - document.body.normalize(); state.searchIndex = -1; recalculate(); - } - function search(query, direction) { - var origin = locator(); clearSearch(); var token = state.searchToken; - query = String(query || '').trim(); direction = direction === 'backward' ? 'backward' : 'forward'; - if (!query) { if (window.Android) Android.onSearchResults('[]'); return; } - var needle = query.toLocaleLowerCase(), counts = {}, walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {acceptNode:function(node){ - var parent = node.parentElement, chapter = parent ? parent.closest('.alex-chapter') : null; - return !chapter || /^(SCRIPT|STYLE|TEXTAREA)$/.test(parent.tagName) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT; - }}), nodes = [], node; - while ((node = walker.nextNode())) { - var chapter = node.parentElement.closest('.alex-chapter'), chapterIndex = parseInt(chapter.getAttribute('data-spine') || '0'); - var base = counts[chapterIndex] || 0; nodes.push({node:node,chapter:chapterIndex,offset:base}); counts[chapterIndex] = base + node.data.length; - } - var marks = []; - nodes.forEach(function (part) { - var textNode = part.node, lower = textNode.data.toLocaleLowerCase(), positions = [], from = 0, found; - while ((found = lower.indexOf(needle, from)) >= 0) { positions.push(found); from = found + Math.max(1, needle.length); } - for (var i = positions.length - 1; i >= 0; i--) { - var after = textNode.splitText(positions[i]); after.splitText(query.length); - var mark = document.createElement('mark'); mark.className = 'alex-search'; mark.dataset.chapter = String(part.chapter); - mark.dataset.offset = String(part.offset + positions[i]); after.parentNode.replaceChild(mark, after); mark.appendChild(after); marks.push(mark); - } - }); - marks = Array.prototype.slice.call(document.querySelectorAll('mark.alex-search')); - setTimeout(function () { - if (token !== state.searchToken) return; - recalculate(); var results = marks.map(function(mark){ - return {page:pageForElement(mark),chapter:parseInt(mark.dataset.chapter || '0'),offset:parseInt(mark.dataset.offset || '0')}; - }); - var selected = -1; - if (direction === 'backward') { - for (var i = results.length - 1; i >= 0; i--) if (results[i].chapter < origin.chapter || (results[i].chapter === origin.chapter && results[i].offset <= origin.offset)) { selected = i; break; } - if (selected < 0 && results.length) selected = results.length - 1; - } else { - for (var j = 0; j < results.length; j++) if (results[j].chapter > origin.chapter || (results[j].chapter === origin.chapter && results[j].offset >= origin.offset)) { selected = j; break; } - if (selected < 0 && results.length) selected = 0; - } - results.forEach(function(result,index){result.current = index === selected;}); - state.searchIndex = selected; - if (selected >= 0) { marks[selected].classList.add('alex-search-current'); recordNavigation(results[selected].page, origin); scrollToPage(results[selected].page, true); } - if (window.Android) Android.onSearchResults(JSON.stringify(results)); - }, 40); - } - function moveSearch(delta) { - var marks = document.querySelectorAll('mark.alex-search'); if (!marks.length) return; - if (state.searchIndex >= 0 && marks[state.searchIndex]) marks[state.searchIndex].classList.remove('alex-search-current'); - state.searchIndex = (state.searchIndex + delta + marks.length) % marks.length; - var page = pageForElement(marks[state.searchIndex]); recordNavigation(page); marks[state.searchIndex].classList.add('alex-search-current'); scrollToPage(page, true); - } - function captureSelection() { - var selection = window.getSelection(); if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return null; - var range = selection.getRangeAt(0), chapter = range.commonAncestorContainer.nodeType === 1 ? range.commonAncestorContainer : range.commonAncestorContainer.parentElement; - chapter = chapter ? chapter.closest('.alex-chapter') : null; if (!chapter) return null; - var before = document.createRange(); before.selectNodeContents(chapter); before.setEnd(range.startContainer, range.startOffset); - var start = before.toString().length, quote = range.toString(); - return { chapter:parseInt(chapter.getAttribute('data-spine') || '0'), start:start, end:start+quote.length, quote:quote.substring(0,10000) }; - } - function wrapAnnotation(annotation) { - var chapter = document.querySelector('.alex-chapter[data-spine="' + annotation.chapter + '"]'); if (!chapter || annotation.end <= annotation.start) return; - var walker = document.createTreeWalker(chapter, NodeFilter.SHOW_TEXT, {acceptNode:function(node){ return node.parentElement && node.parentElement.closest('mark.alex-annotation') ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT; }}); - var nodes=[], node, count=0; while ((node=walker.nextNode())) { nodes.push({node:node,start:count,end:count+node.data.length}); count += node.data.length; } - nodes.reverse().forEach(function(part){ - var from=Math.max(annotation.start,part.start)-part.start, to=Math.min(annotation.end,part.end)-part.start; - if (to>from) { var selected=part.node; if (to0) selected=selected.splitText(from); var mark=document.createElement('mark'); mark.className='alex-annotation'; mark.dataset.annotation=annotation.id; selected.parentNode.replaceChild(mark,selected); mark.appendChild(selected); } - }); - } - function applyAnnotations(values) { (values || []).slice().sort(function(a,b){return b.start-a.start;}).forEach(wrapAnnotation); setTimeout(recalculate,20); } - function removeAnnotation(id) { Array.prototype.forEach.call(document.querySelectorAll('mark[data-annotation="' + id + '"]'),function(mark){mark.replaceWith(document.createTextNode(mark.textContent));}); document.body.normalize(); recalculate(); } - document.addEventListener('click', function (event) { - var mark=event.target.closest('mark.alex-annotation'); if(mark && window.Android){event.preventDefault();Android.onAnnotationTapped(mark.dataset.annotation);return;} - var link=event.target.closest('a'); if(!link)return; - var external=link.getAttribute('data-external-href'); if(external){event.preventDefault();if(window.Android)Android.onExternalLink(external);return;} - var reference=link.getAttribute('data-alex-ref'); if(reference){event.preventDefault();goToReference(reference,true);} - }, true); - var scrollTimer=0; - function handleScroll(){ - clearTimeout(scrollTimer); scrollTimer=setTimeout(function(){ - var next=Math.max(0,Math.min(state.total-1,Math.round(scrollPosition()/Math.max(1,pageExtent())))); - if(next!==state.page){state.page=next;notifyPage();} - },80); - } - scrolling().addEventListener('scroll',handleScroll,{passive:true}); - window.addEventListener('scroll',handleScroll,{passive:true}); - var resizeTimer=0; window.addEventListener('resize',function(){clearTimeout(resizeTimer);resizeTimer=setTimeout(function(){var keep=locator();recalculate();goToLocator(keep);},120);}); - window.Alex = { setPage:scrollToPage, next:function(){scrollToPage(state.page+1,true);}, previous:function(){scrollToPage(state.page-1,true);}, - locator:locator, goToLocator:goToLocator, applySettings:applySettings, goToReference:goToReference, search:search, clearSearch:clearSearch, - moveSearch:moveSearch, captureSelection:captureSelection, applyAnnotations:applyAnnotations, wrapAnnotation:wrapAnnotation, removeAnnotation:removeAnnotation, - chapterPage:function(index){return state.chapterStarts[Math.max(0,Math.min(state.chapterStarts.length-1,index))]||0;} }; - function ready() { recalculate(); if(window.Android&&Android.onReaderReady)Android.onReaderReady(); } - document.fonts.ready.then(function(){setTimeout(ready,30);}); -})(); -""" - - private const val FIXED_READER_SCRIPT = """ -(function () { - 'use strict'; - var pages = Array.prototype.slice.call(document.querySelectorAll('.alex-chapter')); - var state = {page:0,total:pages.length,searchIndex:-1}; - function number(value,fallback){value=Number(value);return isFinite(value)?value:fallback;} - function clamp(value,min,max){return Math.max(min,Math.min(max,value));} - function pageElement(){return pages[clamp(state.page,0,pages.length-1)];} - function pageSize(page){return{width:Math.max(1,number(page.getAttribute('data-page-width'),1200)),height:Math.max(1,number(page.getAttribute('data-page-height'),1600))};} - function layoutPage(){ - state.page=clamp(Math.round(number(state.page,0)),0,pages.length-1); - pages.forEach(function(page,index){page.classList.toggle('alex-current-page',index===state.page);}); - var page=pageElement(),size=pageSize(page),availableWidth=Math.max(1,window.innerWidth),availableHeight=Math.max(1,window.innerHeight); - var scale=Math.max(0.01,Math.min(availableWidth/size.width,availableHeight/size.height)); - var x=(availableWidth-size.width*scale)/2,y=(availableHeight-size.height*scale)/2; - page.style.width=size.width+'px';page.style.height=size.height+'px';page.style.transform='translate('+x+'px,'+y+'px) scale('+scale+')'; - applyImageDithering(page); - } - function locator(){return{page:state.page,totalPages:state.total,chapter:state.page,offset:0,progress:state.total<=1?0:state.page/(state.total-1)};} - function notifyPage(){if(window.Android&&Android.onPageChanged)Android.onPageChanged(JSON.stringify(locator()));} - function recalculate(){state.total=pages.length;layoutPage();notifyPage();} - function setPage(page,report){state.page=clamp(Math.round(number(page,0)),0,state.total-1);layoutPage();if(report!==false)notifyPage();} - function pageForElement(element){var chapter=element?element.closest('.alex-chapter'):null;return chapter?clamp(number(chapter.getAttribute('data-spine'),0),0,state.total-1):0;} - function goToLocator(value){if(!value){setPage(0,true);return;}setPage(number(value.chapter,value.page||0),true);} - function applySettings(settings,keep){document.getElementById('alex-publisher-styles').disabled=false;if(keep)state.page=clamp(Math.round(number(keep.chapter,keep.page||0)),0,state.total-1);layoutPage();notifyPage();} - function recordNavigation(targetPage,origin) { - var from=origin||locator(),target=clamp(Math.round(number(targetPage,0)),0,state.total-1),fromPage=clamp(Math.round(number(from.page,0)),0,state.total-1); - if(target===fromPage||!window.Android||!Android.onNavigationJump)return;Android.onNavigationJump(JSON.stringify(from)); - } - function goToReference(reference,record) { - var id=window.ALEX_REFERENCES[reference];if(!id)return; - var target=document.getElementById(id);if(!target)return; - var page=pageForElement(target);if(record!==false)recordNavigation(page);setPage(page,true); - } - function clearSearch() { - Array.prototype.forEach.call(document.querySelectorAll('mark.alex-search'),function(mark){mark.replaceWith(document.createTextNode(mark.textContent));}); - document.body.normalize();state.searchIndex=-1; - } - function search(query,direction) { - var origin=locator();clearSearch();query=String(query||'').trim();direction=direction==='backward'?'backward':'forward'; - if(!query){if(window.Android)Android.onSearchResults('[]');return;} - var needle=query.toLocaleLowerCase(),walker=document.createTreeWalker(document.body,NodeFilter.SHOW_TEXT,{acceptNode:function(node){ - var parent=node.parentElement,chapter=parent?parent.closest('.alex-chapter'):null; - return !chapter||/^(SCRIPT|STYLE|TEXTAREA)$/.test(parent.tagName)?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_ACCEPT; - }}),nodes=[],node;while((node=walker.nextNode()))nodes.push(node);var marks=[]; - nodes.forEach(function(textNode){var lower=textNode.data.toLocaleLowerCase(),positions=[],from=0,found; - while((found=lower.indexOf(needle,from))>=0){positions.push(found);from=found+Math.max(1,needle.length);} - for(var i=positions.length-1;i>=0;i--){var after=textNode.splitText(positions[i]);after.splitText(query.length);var mark=document.createElement('mark');mark.className='alex-search';after.parentNode.replaceChild(mark,after);mark.appendChild(after);marks.push(mark);} - });marks=Array.prototype.slice.call(document.querySelectorAll('mark.alex-search')); - var results=marks.map(function(mark){return{page:pageForElement(mark)};}); - var selected=-1;if(direction==='backward'){for(var back=results.length-1;back>=0;back--)if(results[back].page<=origin.page){selected=back;break;}if(selected<0&&results.length)selected=results.length-1;} - else{for(var forward=0;forward=origin.page){selected=forward;break;}if(selected<0&&results.length)selected=0;} - results.forEach(function(result,index){result.current=index===selected;});state.searchIndex=selected; - if(selected>=0){marks[selected].classList.add('alex-search-current');recordNavigation(results[selected].page,origin);setPage(results[selected].page,true);} - if(window.Android)Android.onSearchResults(JSON.stringify(results)); - } - function moveSearch(delta) { - var marks=document.querySelectorAll('mark.alex-search');if(!marks.length)return; - if(state.searchIndex>=0&&marks[state.searchIndex])marks[state.searchIndex].classList.remove('alex-search-current'); - state.searchIndex=(state.searchIndex+delta+marks.length)%marks.length;var page=pageForElement(marks[state.searchIndex]);recordNavigation(page);marks[state.searchIndex].classList.add('alex-search-current');setPage(page,true); - } - function captureSelection() { - var selection=window.getSelection();if(!selection||selection.rangeCount===0||selection.isCollapsed)return null; - var range=selection.getRangeAt(0),chapter=range.commonAncestorContainer.nodeType===1?range.commonAncestorContainer:range.commonAncestorContainer.parentElement; - chapter=chapter?chapter.closest('.alex-chapter'):null;if(!chapter)return null; - var before=document.createRange();before.selectNodeContents(chapter);before.setEnd(range.startContainer,range.startOffset);var start=before.toString().length,quote=range.toString(); - return{chapter:parseInt(chapter.getAttribute('data-spine')||'0'),start:start,end:start+quote.length,quote:quote.substring(0,10000)}; - } - function wrapAnnotation(annotation) { - var chapter=document.querySelector('.alex-chapter[data-spine="'+annotation.chapter+'"]');if(!chapter||annotation.end<=annotation.start)return; - var walker=document.createTreeWalker(chapter,NodeFilter.SHOW_TEXT,{acceptNode:function(node){return node.parentElement&&node.parentElement.closest('mark.alex-annotation')?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_ACCEPT;}}),nodes=[],node,count=0; - while((node=walker.nextNode())){nodes.push({node:node,start:count,end:count+node.data.length});count+=node.data.length;}nodes.reverse().forEach(function(part){var from=Math.max(annotation.start,part.start)-part.start,to=Math.min(annotation.end,part.end)-part.start;if(to>from){var selected=part.node;if(to0)selected=selected.splitText(from);var mark=document.createElement('mark');mark.className='alex-annotation';mark.dataset.annotation=annotation.id;selected.parentNode.replaceChild(mark,selected);mark.appendChild(selected);}}); - } - function applyAnnotations(values){(values||[]).slice().sort(function(a,b){return b.start-a.start;}).forEach(wrapAnnotation);} - function removeAnnotation(id){Array.prototype.forEach.call(document.querySelectorAll('mark[data-annotation="'+id+'"]'),function(mark){mark.replaceWith(document.createTextNode(mark.textContent));});document.body.normalize();} - document.addEventListener('click',function(event){ - var mark=event.target.closest('mark.alex-annotation');if(mark&&window.Android){event.preventDefault();Android.onAnnotationTapped(mark.dataset.annotation);return;} - var link=event.target.closest('a');if(!link)return;var external=link.getAttribute('data-external-href');if(external){event.preventDefault();if(window.Android)Android.onExternalLink(external);return;} - var reference=link.getAttribute('data-alex-ref');if(reference){event.preventDefault();goToReference(reference,true);} - },true); - var resizeTimer=0;window.addEventListener('resize',function(){clearTimeout(resizeTimer);resizeTimer=setTimeout(function(){layoutPage();notifyPage();},120);}); - window.Alex={setPage:setPage,next:function(){setPage(state.page+1,true);},previous:function(){setPage(state.page-1,true);},locator:locator,goToLocator:goToLocator, - applySettings:applySettings,goToReference:goToReference,search:search,clearSearch:clearSearch,moveSearch:moveSearch,captureSelection:captureSelection, - applyAnnotations:applyAnnotations,wrapAnnotation:wrapAnnotation,removeAnnotation:removeAnnotation,chapterPage:function(index){return clamp(index,0,state.total-1);}}; - function ready(){recalculate();if(window.Android&&Android.onReaderReady)Android.onReaderReady();} - document.fonts.ready.then(function(){setTimeout(ready,30);}); -})(); -""" - - private fun loadStylesheet(path: String, root: File, loaded: MutableSet): String { - val normalized = EpubParser.normalizePath("", path) - if (!loaded.add(normalized)) return "" - val file = runCatching { EpubParser.safeFile(root, normalized) }.getOrNull() - if (file?.isFile != true) return "" - val base = normalized.substringBeforeLast('/', "") - val expanded = expandCssImports(file.readText(), base, root, loaded) - return rewriteCssUrls(expanded, base, root) - } - - private fun expandCssImports(css: String, base: String, root: File, loaded: MutableSet): String { - val importPattern = Regex( - "@import\\s+(?:url\\(\\s*)?(?:\\\"([^\\\"]+)\\\"|'([^']+)'|([^\\s;)]+))\\s*\\)?[^;]*;", - setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL), - ) - return importPattern.replace(css) { match -> - val href = match.groupValues.drop(1).firstOrNull { it.isNotBlank() }.orEmpty() - if (href.isBlank() || isExternal(href)) "" - else loadStylesheet(EpubParser.normalizePath(base, href.substringBefore('#').substringBefore('?')), root, loaded) - } - } - - private fun rewriteCssUrls(css: String, base: String, root: File): String { - var rewritten = Regex("url\\(\\s*(['\"]?)(.*?)\\1\\s*\\)", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)).replace(css) { match -> - val value = match.groupValues[2].trim() - val lower = value.lowercase(Locale.ROOT) - val target = resourceUrl(base, value, root) - when { - lower.startsWith("file:") || lower.startsWith("content:") || lower.startsWith("javascript:") -> "url('')" - target == null -> match.value - else -> "url('$target')" - } - } - rewritten = Regex("@import\\s+(['\"])(.*?)\\1", RegexOption.IGNORE_CASE).replace(rewritten) { match -> - val target = resourceUrl(base, match.groupValues[2], root) - if (target == null) match.value else "@import url('$target')" - } - return rewritten.replace(Regex("@charset\\s+[^;]+;", RegexOption.IGNORE_CASE), "") - } - - private fun rewriteSrcSet(value: String, base: String, root: File): String = value.split(',').joinToString(", ") { candidate -> - val bits = candidate.trim().split(Regex("\\s+"), limit = 2) - val url = resourceUrl(base, bits.firstOrNull().orEmpty(), root) ?: bits.firstOrNull().orEmpty() - if (bits.size == 2) "$url ${bits[1]}" else url - } - - private fun resourceUrl(base: String, value: String, root: File): String? { - if (value.isBlank() || isExternal(value) || value.startsWith('#')) return null - val path = EpubParser.normalizePath(base, value.substringBefore('#').substringBefore('?')) - return runCatching { EpubParser.safeFile(root, path) }.getOrNull()?.takeIf(File::isFile)?.toURI()?.toASCIIString() - } - - /** Re-targets root/body selectors to each embedded spine section. */ - private fun scopeCss(css: String): String { - fun transformSelector(value: String): String = value.split(',').joinToString(",") { selector -> - selector - .replace(Regex("(?|\\+|~|$))", RegexOption.IGNORE_CASE), ".alex-chapter") - .replace(Regex("(?|\\+|~|$))", RegexOption.IGNORE_CASE), ".alex-chapter") - .replace(Regex("(?|\\+|~|$))", RegexOption.IGNORE_CASE), "#alex-book") - .replace(Regex("(? - out.append(prelude).append('{').append(process(body)).append('}') - // Preserve semicolon at-rules such as @namespace, then scope - // the ordinary selector that follows them. - trimmed.startsWith('@') && prelude.lastIndexOf(';') >= 0 -> { - val split = prelude.lastIndexOf(';') + 1 - out.append(prelude.substring(0, split)) - appendRule(prelude.substring(split), body) - } - trimmed.startsWith('@') -> out.append(prelude).append('{').append(body).append('}') - else -> appendRule(prelude, body) - } - index = close + 1 - } - return out.toString() - } - return process(css) - } - - private fun rewritePageBreaks(css: String): String { - val edgeRule = Regex( - "page-break-(before|after)\\s*:\\s*(always|avoid|auto|left|right|inherit|initial|unset)(\\s*!important)?\\s*;?", - RegexOption.IGNORE_CASE, - ) - val rewritten = edgeRule.replace(css) { match -> - val value = when (match.groupValues[2].lowercase(Locale.ROOT)) { - "always", "left", "right" -> "column" - "avoid" -> "avoid-column" - else -> match.groupValues[2].lowercase(Locale.ROOT) - } - "break-${match.groupValues[1].lowercase(Locale.ROOT)}:$value${match.groupValues[3]};" - } - val insideRule = Regex( - "page-break-inside\\s*:\\s*(avoid|auto|inherit|initial|unset)(\\s*!important)?\\s*;?", - RegexOption.IGNORE_CASE, - ) - return insideRule.replace(rewritten) { match -> - val value = if (match.groupValues[1].equals("avoid", ignoreCase = true)) { - "avoid-column" - } else { - match.groupValues[1].lowercase(Locale.ROOT) - } - "break-inside:$value${match.groupValues[2]};" - } - } - - private fun findCssDelimiter(value: String, start: Int, delimiter: Char): Int { - var quote = '\u0000'; var comment = false; var index = start - while (index < value.length) { - if (comment) { if (index + 1 < value.length && value[index] == '*' && value[index + 1] == '/') { comment = false; index++ } } - else if (quote != '\u0000') { if (value[index] == '\\') index++ else if (value[index] == quote) quote = '\u0000' } - else if (index + 1 < value.length && value[index] == '/' && value[index + 1] == '*') { comment = true; index++ } - else if (value[index] == '\'' || value[index] == '"') quote = value[index] - else if (value[index] == delimiter) return index - index++ - } - return -1 - } - - private fun matchingBrace(value: String, open: Int): Int { - var depth = 1; var quote = '\u0000'; var comment = false; var index = open + 1 - while (index < value.length) { - if (comment) { if (index + 1 < value.length && value[index] == '*' && value[index + 1] == '/') { comment = false; index++ } } - else if (quote != '\u0000') { if (value[index] == '\\') index++ else if (value[index] == quote) quote = '\u0000' } - else if (index + 1 < value.length && value[index] == '/' && value[index + 1] == '*') { comment = true; index++ } - else if (value[index] == '\'' || value[index] == '"') quote = value[index] - else if (value[index] == '{') depth++ - else if (value[index] == '}' && --depth == 0) return index - index++ - } - return -1 - } - - private fun generatedId(chapter: Int, original: String): String = - "alex-$chapter-${original.replace(Regex("[^A-Za-z0-9_-]"), "-")}-${original.hashCode().toUInt().toString(16)}" - - private fun isExternal(value: String): Boolean { - val lower = value.trim().lowercase(Locale.ROOT) - return lower.startsWith("http:") || lower.startsWith("https:") || lower.startsWith("mailto:") || - lower.startsWith("tel:") || lower.startsWith("data:") || lower.startsWith("javascript:") || lower.startsWith("blob:") || - lower.startsWith("file:") || lower.startsWith("content:") || lower.startsWith("about:") - } - - private fun escapeHtml(value: String): String = value.replace("&", "&").replace("<", "<").replace(">", ">") - private fun escapeAttribute(value: String): String = escapeHtml(value).replace("\"", """) + private val TOC_LABEL_ELEMENTS = setOf("a", "span") + private val TOC_LIST_ELEMENTS = setOf("ol", "ul") + private const val EPUB_MEDIA_TYPE = "application/epub+zip" + private const val MAX_EXPANDED_BYTES = 2_147_483_648L } diff --git a/app/src/main/java/com/alexandria/reader/LibraryRepository.kt b/app/src/main/java/com/alexandria/reader/LibraryRepository.kt index 7f59974..e836dcc 100644 --- a/app/src/main/java/com/alexandria/reader/LibraryRepository.kt +++ b/app/src/main/java/com/alexandria/reader/LibraryRepository.kt @@ -11,12 +11,21 @@ import java.io.FileInputStream import java.io.FileOutputStream import java.security.MessageDigest import java.util.UUID +import kotlin.math.roundToInt /** Owns imported files and all reading state. No broad storage permission is required. */ -class LibraryRepository(private val context: Context) { +internal class LibraryRepository(private val context: Context) : AutoCloseable { + data class ReaderState( + val location: ReaderLocation, + val settings: ReaderSettings, + val bookmarks: MutableList, + val annotations: MutableList, + ) + private val libraryDirectory = File(context.filesDir, "library").apply { mkdirs() } private val indexFile = File(libraryDirectory, "index.json") private val preferences = context.getSharedPreferences("alexandria-reader", Context.MODE_PRIVATE) + private val stateWriter = CoalescingFileWriter() private val lock = Any() private var books = loadIndex().toMutableList() @@ -35,13 +44,14 @@ class LibraryRepository(private val context: Context) { } selected.first.lowercase().contains(selected.second) } - when (order) { + val sorted = when (order) { SortOrder.RECENT -> filtered.sortedWith(compareByDescending { it.lastOpenedAt }.thenBy { it.title.lowercase() }) SortOrder.ADDED -> filtered.sortedByDescending { it.addedAt } SortOrder.TITLE -> filtered.sortedBy { it.title.lowercase() } SortOrder.AUTHOR -> filtered.sortedWith(compareBy { it.author.lowercase() }.thenBy { it.title.lowercase() }) SortOrder.PROGRESS -> filtered.sortedByDescending { it.progress } } + sorted.map(LibraryBook::copy) } fun sortOrder(): SortOrder = runCatching { @@ -54,90 +64,135 @@ class LibraryRepository(private val context: Context) { /** Copies a SAF document into private storage, verifies it, and extracts its EPUB package. */ fun import(uri: Uri): EpubPublication { + WorkCancellation.check() val displayName = displayName(context.contentResolver, uri) ?: "book.epub" val temporary = File(context.cacheDir, "import-${UUID.randomUUID()}.epub") val digest = MessageDigest.getInstance("SHA-256") try { - context.contentResolver.openInputStream(uri)?.use { input -> - FileOutputStream(temporary).use { output -> - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - var total = 0L - while (true) { - val count = input.read(buffer) - if (count < 0) break - total += count - require(total <= MAX_IMPORT_BYTES) { "This book is larger than the 1 GB import limit." } - digest.update(buffer, 0, count) - output.write(buffer, 0, count) + context.contentResolver.openInputStream(uri)?.use { source -> + WorkCancellation.input(source).use { input -> + FileOutputStream(temporary).use { output -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var total = 0L + while (true) { + WorkCancellation.check() + val count = input.read(buffer) + if (count < 0) break + total += count + require(total <= MAX_IMPORT_BYTES) { "This book is larger than the 1 GB import limit." } + digest.update(buffer, 0, count) + output.write(buffer, 0, count) + } } } } ?: error("The selected document could not be read.") + WorkCancellation.check() require(temporary.length() > 0) { "The selected document is empty." } - val id = digest.digest().joinToString("") { "%02x".format(it) }.take(24) - synchronized(lock) { - val existing = books.firstOrNull { it.id == id } - if (existing != null) return open(existing) - } + val id = digest.digest().toHex().take(24) + val existing = synchronized(lock) { books.firstOrNull { it.id == id }?.copy() } + if (existing != null) return open(existing) val bookDirectory = directoryFor(id).apply { mkdirs() } - val source = File(bookDirectory, "source.epub") - if (!temporary.renameTo(source)) { - FileInputStream(temporary).use { input -> FileOutputStream(source).use(input::copyTo) } - } - val safeName = displayName.replace(Regex("[\\/\\u0000-\\u001f]"), "_").take(160) - val book = LibraryBook( - id = id, - title = safeName.substringBeforeLast('.').ifBlank { "Untitled" }, - author = "Unknown author", - fileName = safeName, - ) - val publication = try { - EpubParser.parse(source, File(bookDirectory, "content"), book) - } catch (error: Throwable) { + try { + val source = File(bookDirectory, "source.epub") + if (!temporary.renameTo(source)) copyFile(temporary, source) + WorkCancellation.check() + val safeName = displayName.replace(INVALID_FILE_NAME_CHARACTER, "_").take(160) + val book = LibraryBook( + id = id, + title = safeName.substringBeforeLast('.').ifBlank { "Untitled" }, + author = "Unknown author", + fileName = safeName, + ) + val publication = EpubParser.parse(source, File(bookDirectory, "content"), book) + synchronized(lock) { + books.removeAll { it.id == id } + books.add(publication.book) + enqueueIndexLocked() + } + stateWriter.await() + WorkCancellation.check() + return publication + } catch (error: Exception) { + synchronized(lock) { + if (books.removeAll { it.id == id }) enqueueIndexLocked() + } bookDirectory.deleteRecursively() throw error } - synchronized(lock) { - books.removeAll { it.id == id } - books.add(publication.book) - saveIndex() - } - return publication } finally { temporary.delete() } } fun open(book: LibraryBook): EpubPublication { - val canonical = synchronized(lock) { books.first { it.id == book.id } } - val directory = directoryFor(canonical.id) + WorkCancellation.check() + val stored = synchronized(lock) { books.first { it.id == book.id }.copy() } + val directory = directoryFor(stored.id) val source = File(directory, "source.epub") require(source.isFile) { "The imported EPUB is missing." } - return EpubParser.parse(source, File(directory, "content"), canonical) + val parsed = EpubParser.parse(source, File(directory, "content"), stored.copy()) + WorkCancellation.check() + return parsed.copy(book = stored) + } + + /** Returns a cached reader document, rebuilding it only when its inputs change. */ + fun readerFile(publication: EpubPublication): File { + WorkCancellation.check() + val directory = directoryFor(publication.book.id) + val reader = File(directory, "reader.html") + val version = File(directory, "reader.version") + val expectedVersion = readerCacheVersion(publication) + if (reader.isFile && reader.length() > 0L && version.readTextOrNull() == expectedVersion) return reader + + val temporary = File.createTempFile("reader-", ".html", directory) + try { + EpubHtmlBuilder.write(publication, temporary) + WorkCancellation.check() + AtomicFiles.replace(temporary, reader) + AtomicFiles.write(version, expectedVersion) + } finally { + temporary.delete() + } + return reader } - fun readerFile(publication: EpubPublication): File = - EpubHtmlBuilder.write(publication, File(directoryFor(publication.book.id), "reader.html")) + /** Reads a consistent snapshot after all state queued by the previous reader has reached disk. */ + fun readerState(bookId: String): ReaderState { + stateWriter.await() + WorkCancellation.check() + return ReaderState( + location = readLocation(bookId), + settings = readSettings(bookId), + bookmarks = readArray(bookId, "bookmarks.json", Bookmark::fromJson), + annotations = readArray(bookId, "annotations.json", Annotation::fromJson), + ) + } fun updateProgress(bookId: String, location: ReaderLocation) = synchronized(lock) { books.firstOrNull { it.id == bookId }?.let { book -> - book.progress = location.progress.coerceIn(0f, 1f) - book.lastOpenedAt = System.currentTimeMillis() - book.state = when { - book.progress >= .985f -> LibraryBook.ReadingState.FINISHED - book.progress > 0f -> LibraryBook.ReadingState.READING + val progress = location.progress.coerceIn(0f, 1f) + val state = when { + progress >= .985f -> LibraryBook.ReadingState.FINISHED + progress > 0f -> LibraryBook.ReadingState.READING else -> book.state } - saveIndex() + // The library shows whole percentages. Keep exact progress in memory and + // avoid rewriting the full index when its visible value is unchanged. + val displayedProgressChanged = (book.progress * 100).roundToInt() != (progress * 100).roundToInt() + val stateChanged = book.state != state + book.progress = progress + book.state = state + if (displayedProgressChanged || stateChanged) enqueueIndexLocked() + stateWriter.enqueue(File(directoryFor(bookId), "location.json")) { location.toJson().toString() } } - saveLocation(bookId, location) } fun markOpened(bookId: String) = synchronized(lock) { books.firstOrNull { it.id == bookId }?.let { it.lastOpenedAt = System.currentTimeMillis() if (it.state == LibraryBook.ReadingState.NEW) it.state = LibraryBook.ReadingState.READING - saveIndex() + enqueueIndexLocked() } } @@ -145,62 +200,84 @@ class LibraryRepository(private val context: Context) { books.firstOrNull { it.id == bookId }?.let { it.state = state if (state == LibraryBook.ReadingState.FINISHED) it.progress = 1f - saveIndex() + enqueueIndexLocked() } } fun rename(bookId: String, title: String) = synchronized(lock) { books.firstOrNull { it.id == bookId }?.let { - it.title = title.trim().ifBlank { it.title } - saveIndex() + val updated = title.trim().ifBlank { it.title } + if (updated != it.title) { + it.title = updated + enqueueIndexLocked() + } } } - fun remove(bookId: String) = synchronized(lock) { - books.removeAll { it.id == bookId } - saveIndex() + fun remove(bookId: String) { + stateWriter.await() + WorkCancellation.check() + val removed = synchronized(lock) { + val index = books.indexOfFirst { it.id == bookId } + if (index < 0) { + null + } else { + val book = books.removeAt(index) + enqueueIndexLocked() + index to book + } + } ?: return + try { + stateWriter.await() + WorkCancellation.check() + } catch (error: Exception) { + synchronized(lock) { + books.add(removed.first.coerceAtMost(books.size), removed.second) + enqueueIndexLocked() + } + throw error + } directoryFor(bookId).deleteRecursively() } - fun location(bookId: String): ReaderLocation { - val file = File(directoryFor(bookId), "location.json") - return runCatching { ReaderLocation.fromJson(JSONObject(file.readText())) }.getOrDefault(ReaderLocation()) + fun saveSettings(bookId: String, settings: ReaderSettings, makeDefault: Boolean = false) { + val json = settings.toJson().toString() + stateWriter.enqueue(File(directoryFor(bookId), "settings.json")) { json } + if (makeDefault) preferences.edit().putString("default-settings", json).apply() + } + + fun saveBookmarks(bookId: String, values: List) = + enqueueArray(bookId, "bookmarks.json", values.toList(), Bookmark::toJson) + + fun saveAnnotations(bookId: String, values: List) = + enqueueArray(bookId, "annotations.json", values.map(Annotation::copy), Annotation::toJson) + + internal fun awaitPendingWrites() = stateWriter.await() + + override fun close() { + stateWriter.close() } - private fun saveLocation(bookId: String, location: ReaderLocation) { - writeAtomically(File(directoryFor(bookId), "location.json"), location.toJson().toString()) + private fun readLocation(bookId: String): ReaderLocation { + val file = File(directoryFor(bookId), "location.json") + return runCatching { ReaderLocation.fromJson(JSONObject(file.readText())) }.getOrDefault(ReaderLocation()) } - fun settings(bookId: String): ReaderSettings { + private fun readSettings(bookId: String): ReaderSettings { val perBook = File(directoryFor(bookId), "settings.json") val value = runCatching { JSONObject(perBook.readText()) }.getOrNull() ?: preferences.getString("default-settings", null)?.let { runCatching { JSONObject(it) }.getOrNull() } return value?.let(ReaderSettings::fromJson) ?: ReaderSettings() } - fun saveSettings(bookId: String, settings: ReaderSettings, makeDefault: Boolean = false) { - val json = settings.toJson().toString() - writeAtomically(File(directoryFor(bookId), "settings.json"), json) - if (makeDefault) preferences.edit().putString("default-settings", json).apply() - } - - fun bookmarks(bookId: String): MutableList = readArray(bookId, "bookmarks.json", Bookmark::fromJson) - fun annotations(bookId: String): MutableList = readArray(bookId, "annotations.json", Annotation::fromJson) - - fun saveBookmarks(bookId: String, values: List) = - writeArray(bookId, "bookmarks.json", values.map(Bookmark::toJson)) - - fun saveAnnotations(bookId: String, values: List) = - writeArray(bookId, "annotations.json", values.map(Annotation::toJson)) - private fun readArray(bookId: String, name: String, convert: (JSONObject) -> T): MutableList = runCatching { JSONArray(File(directoryFor(bookId), name).readText()).mapObjects(convert).toMutableList() } .getOrDefault(mutableListOf()) - private fun writeArray(bookId: String, name: String, values: List) { - val array = JSONArray() - values.forEach(array::put) - writeAtomically(File(directoryFor(bookId), name), array.toString()) + private fun enqueueArray(bookId: String, name: String, values: List, convert: (T) -> JSONObject) { + stateWriter.enqueue(File(directoryFor(bookId), name)) { + JSONArray().apply { values.forEach { put(convert(it)) } }.toString() + } } private fun directoryFor(id: String): File = File(libraryDirectory, id) @@ -210,30 +287,51 @@ class LibraryRepository(private val context: Context) { .filter { File(libraryDirectory, "${it.id}/source.epub").isFile } }.getOrDefault(emptyList()) - private fun saveIndex() { - val array = JSONArray() - books.forEach { array.put(it.toJson()) } - writeAtomically(indexFile, array.toString()) + private fun enqueueIndexLocked() { + val snapshot = books.map(LibraryBook::copy) + stateWriter.enqueue(indexFile) { + JSONArray().apply { snapshot.forEach { put(it.toJson()) } }.toString() + } } - private fun writeAtomically(target: File, value: String) { - target.parentFile?.mkdirs() - val temporary = File(target.parentFile, ".${target.name}.tmp") - temporary.writeText(value) - if (target.exists()) target.delete() - if (!temporary.renameTo(target)) { - target.writeText(value) - temporary.delete() + private fun copyFile(source: File, target: File) { + FileInputStream(source).use { fileInput -> + WorkCancellation.input(fileInput).use { input -> + FileOutputStream(target).use { output -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + WorkCancellation.check() + val count = input.read(buffer) + if (count < 0) break + output.write(buffer, 0, count) + } + } + } } } + private fun File.readTextOrNull(): String? = runCatching { takeIf(File::isFile)?.readText() }.getOrNull() + companion object { + private val INVALID_FILE_NAME_CHARACTER = Regex("[\\/\\u0000-\\u001f]") private const val MAX_IMPORT_BYTES = 1_073_741_824L + internal fun readerCacheVersion(publication: EpubPublication): String { + val inputs = "${publication.book.title}\u0000${publication.book.language}" + return "${EpubHtmlBuilder.FORMAT_VERSION}:${MessageDigest.getInstance("SHA-256").digest(inputs.encodeToByteArray()).toHex()}" + } + + private fun ByteArray.toHex(): String = joinToString("") { byte -> + val value = byte.toInt() and 0xff + "${HEX[value ushr 4]}${HEX[value and 0x0f]}" + } + private fun displayName(resolver: ContentResolver, uri: Uri): String? = runCatching { resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null } }.getOrNull() + + private const val HEX = "0123456789abcdef" } } diff --git a/app/src/main/java/com/alexandria/reader/LibraryScreen.kt b/app/src/main/java/com/alexandria/reader/LibraryScreen.kt index 29876fd..d8f4c89 100644 --- a/app/src/main/java/com/alexandria/reader/LibraryScreen.kt +++ b/app/src/main/java/com/alexandria/reader/LibraryScreen.kt @@ -1,14 +1,18 @@ package com.alexandria.reader +import android.annotation.SuppressLint import android.app.Activity import android.app.AlertDialog -import android.graphics.BitmapFactory +import android.graphics.Bitmap +import android.graphics.ImageDecoder +import android.graphics.Rect import android.graphics.Typeface import android.graphics.drawable.ColorDrawable import android.text.Editable import android.text.TextWatcher import android.view.Gravity import android.view.View +import android.util.LruCache import android.view.ViewGroup import android.widget.BaseAdapter import android.widget.EditText @@ -18,21 +22,26 @@ import android.widget.LinearLayout import android.widget.ListView import android.widget.PopupMenu import android.widget.TextView -import android.widget.Toast +import java.io.File import java.text.DateFormat import java.util.Date import kotlin.math.roundToInt /** Plato-style searchable library shelf backed by Alexandria's private imports. */ -class LibraryScreen( +@SuppressLint("ViewConstructor", "SetTextI18n") +internal class LibraryScreen( private val activity: Activity, private val repository: LibraryRepository, private val openBook: (LibraryBook) -> Unit, private val importBooks: () -> Unit, + private val removeBook: (LibraryBook) -> Unit, ) : FrameLayout(activity) { private val list = ListView(activity) private val adapter = BookAdapter() private val toolbar = LinearLayout(activity) + private val coverCache = object : LruCache(COVER_CACHE_KB) { + override fun sizeOf(key: String, value: Bitmap): Int = (value.allocationByteCount / 1024).coerceAtLeast(1) + } private var searchField: EditText? = null private var query = "" private var sort = repository.sortOrder() @@ -51,14 +60,7 @@ class LibraryScreen( toolbar.setBackgroundColor(EInkPalette.PAPER) addView(toolbar, LayoutParams(LayoutParams.MATCH_PARENT, toolbarHeight, Gravity.TOP)) - toolbar.addView(TextView(activity).apply { - text = "ALEXANDRIA" - textSize = 19f - typeface = Typeface.create("sans-serif", Typeface.BOLD) - letterSpacing = .11f - setTextColor(EInkPalette.INK) - contentDescription = "Alexandria library" - }, LinearLayout.LayoutParams(0, LayoutParams.MATCH_PARENT, 1f).apply { gravity = Gravity.CENTER_VERTICAL }) + toolbar.addView(titleView(), LinearLayout.LayoutParams(0, LayoutParams.MATCH_PARENT, 1f)) toolbar.addView(button("⌕", "Search library") { _ -> toggleSearch() }) toolbar.addView(button("⇅", "Sort library") { view -> showSortMenu(view) }) toolbar.addView(button("+", "Import EPUB") { _ -> importBooks() }) @@ -147,13 +149,20 @@ class LibraryScreen( searchField = input toolbar.addView(input, 0, LinearLayout.LayoutParams(0, LayoutParams.MATCH_PARENT, 1f)) input.requestFocus() - (activity.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager) - .showSoftInput(input, android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT) + input.post { + (activity.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager) + .showSoftInput(input, android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT) + } } private fun titleView(): TextView = TextView(activity).apply { - text = "ALEXANDRIA"; textSize = 19f; typeface = Typeface.DEFAULT_BOLD; letterSpacing = .11f - gravity = Gravity.CENTER_VERTICAL; setTextColor(EInkPalette.INK) + text = "ALEXANDRIA" + textSize = 19f + typeface = Typeface.create("sans-serif", Typeface.BOLD) + letterSpacing = .11f + gravity = Gravity.CENTER_VERTICAL + setTextColor(EInkPalette.INK) + contentDescription = "Alexandria library" } private fun showSortMenu(anchor: View) { @@ -211,11 +220,11 @@ class LibraryScreen( private fun confirmRemove(book: LibraryBook) { AlertDialog.Builder(activity).setTitle("Remove book?") .setMessage("Alexandria's private copy of “${book.title}” and its reading state will be deleted. The original file will not change.") - .setPositiveButton("Remove") { _, _ -> repository.remove(book.id); refresh() } + .setPositiveButton("Remove") { _, _ -> removeBook(book) } .setNegativeButton("Cancel", null).show() } - fun refresh() { + private fun refresh() { adapter.replace(repository.books(sort, query)) } @@ -275,16 +284,42 @@ class LibraryScreen( } progress.text = if (book.state == LibraryBook.ReadingState.NEW) "NEW" else "${(book.progress * 100).roundToInt()}%" progress.typeface = if (book.state == LibraryBook.ReadingState.NEW) Typeface.DEFAULT_BOLD else Typeface.DEFAULT - val bitmap = book.coverPath?.let { path -> - runCatching { - val options = BitmapFactory.Options().apply { inSampleSize = 4 } - BitmapFactory.decodeFile(path, options) - }.getOrNull() - } + val bitmap = book.coverPath?.let(::coverBitmap) if (bitmap != null) cover.setImageBitmap(bitmap) else cover.setImageDrawable(null) contentDescription = "${book.title}, ${book.author}, ${progress.text}" } } + private fun coverBitmap(path: String): Bitmap? { + coverCache.get(path)?.let { return it } + val targetWidth = dp(62) + val targetHeight = dp(88) + val bitmap = runCatching { + ImageDecoder.decodeBitmap(ImageDecoder.createSource(File(path))) { decoder, info, _ -> + val width = info.size.width + val height = info.size.height + require(width > 0 && height > 0) { "Invalid cover image dimensions." } + val crop = if (width.toLong() * targetHeight > height.toLong() * targetWidth) { + val cropWidth = (height.toLong() * targetWidth / targetHeight).toInt().coerceAtLeast(1) + val left = (width - cropWidth) / 2 + Rect(left, 0, left + cropWidth, height) + } else { + val cropHeight = (width.toLong() * targetHeight / targetWidth).toInt().coerceAtLeast(1) + val top = (height - cropHeight) / 2 + Rect(0, top, width, top + cropHeight) + } + decoder.crop = crop + decoder.setTargetSize(targetWidth, targetHeight) + decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE + } + }.getOrNull() ?: return null + coverCache.put(path, bitmap) + return bitmap + } + private fun dp(value: Int): Int = (value * resources.displayMetrics.density).roundToInt() + + companion object { + private const val COVER_CACHE_KB = 8 * 1024 + } } diff --git a/app/src/main/java/com/alexandria/reader/MainActivity.kt b/app/src/main/java/com/alexandria/reader/MainActivity.kt index 096f3e4..e67619a 100644 --- a/app/src/main/java/com/alexandria/reader/MainActivity.kt +++ b/app/src/main/java/com/alexandria/reader/MainActivity.kt @@ -12,20 +12,32 @@ import androidx.activity.ComponentActivity import androidx.activity.addCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.IntentCompat +import java.io.File +import java.util.concurrent.CancellationException +import java.util.concurrent.Executors +import java.util.concurrent.Future import java.util.concurrent.atomic.AtomicInteger class MainActivity : ComponentActivity() { + private data class PreparedBook( + val publication: EpubPublication, + val readerFile: File, + val state: LibraryRepository.ReaderState, + ) + private lateinit var repository: LibraryRepository private var libraryScreen: LibraryScreen? = null private var readerScreen: ReaderScreen? = null private val operation = AtomicInteger() + private val ioExecutor = Executors.newSingleThreadExecutor() + private var activeWork: Future<*>? = null private val openDocuments = registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris -> if (uris.isNotEmpty()) importUris(uris) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - repository = LibraryRepository(this) + repository = LibraryRepository(applicationContext) onBackPressedDispatcher.addCallback(this) { navigateBack() } if (!handleIntent(intent)) showLibrary() } @@ -53,10 +65,10 @@ class MainActivity : ComponentActivity() { } private fun showLibrary() { - operation.incrementAndGet() + beginOperation() readerScreen?.dispose() readerScreen = null - val screen = LibraryScreen(this, repository, ::openBook, ::openPicker) + val screen = LibraryScreen(this, repository, ::openBook, ::openPicker, ::removeBook) libraryScreen = screen setContentView(screen) title = "Alexandria Library" @@ -67,46 +79,88 @@ class MainActivity : ComponentActivity() { } private fun importUris(uris: List) { - val token = operation.incrementAndGet() + val token = beginOperation() showLoading(if (uris.size == 1) "Importing EPUB…" else "Importing ${uris.size} EPUBs…") - Thread { + submitOperation { var last: EpubPublication? = null - var failure: Throwable? = null - uris.forEach { uri -> + var failure: Exception? = null + for (uri in uris) { + if (token != operation.get()) return@submitOperation try { last = repository.import(uri) - } catch (error: Throwable) { + } catch (error: Exception) { + WorkCancellation.check() failure = error } } + if (token != operation.get()) return@submitOperation + val prepared = try { + last?.let(::prepare) + } catch (error: Exception) { + failure = error + null + } + runOnUiThread { + if (token != operation.get()) return@runOnUiThread + if (prepared != null) showReader(prepared) else showImportError(requireNotNull(failure)) + if (prepared != null && failure != null) Toast.makeText(this, "Some files could not be imported", Toast.LENGTH_LONG).show() + } + } + } + + private fun removeBook(book: LibraryBook) { + val token = beginOperation() + showLoading("Removing ${book.title}…") + submitOperation { + val failure = try { + repository.remove(book.id) + null + } catch (error: Exception) { + WorkCancellation.check() + error + } runOnUiThread { if (token != operation.get()) return@runOnUiThread - val publication = last - if (publication != null) showReader(publication) else showImportError(requireNotNull(failure)) - if (publication != null && failure != null) Toast.makeText(this, "Some files could not be imported", Toast.LENGTH_LONG).show() + showLibrary() + if (failure != null) Toast.makeText(this, "The book could not be removed", Toast.LENGTH_LONG).show() } - }.start() + } } private fun openBook(book: LibraryBook) { - val token = operation.incrementAndGet() + val token = beginOperation() showLoading("Opening ${book.title}…") - Thread { - val result = runCatching { repository.open(book) } + submitOperation { + val result = try { + val publication = repository.open(book) + if (token != operation.get()) return@submitOperation + Result.success(prepare(publication)) + } catch (error: Exception) { + WorkCancellation.check() + Result.failure(error) + } runOnUiThread { if (token != operation.get()) return@runOnUiThread result.onSuccess(::showReader).onFailure(::showImportError) } - }.start() + } } - private fun showReader(publication: EpubPublication) { + private fun prepare(publication: EpubPublication): PreparedBook = PreparedBook( + publication = publication, + readerFile = repository.readerFile(publication), + state = repository.readerState(publication.book.id), + ) + + private fun showReader(prepared: PreparedBook) { readerScreen?.dispose() libraryScreen = null - val screen = ReaderScreen(this, publication, repository, ::showLibrary) + val screen = ReaderScreen( + this, prepared.publication, prepared.readerFile, prepared.state, repository, ::showLibrary, + ) readerScreen = screen setContentView(screen) - title = publication.book.title + title = prepared.publication.book.title } private fun showLoading(message: String) { @@ -143,6 +197,7 @@ class MainActivity : ComponentActivity() { } else if (libraryScreen?.searchFocusActive() == true) { libraryScreen?.closeSearch() } else { + beginOperation() finish() } } @@ -157,10 +212,32 @@ class MainActivity : ComponentActivity() { readerScreen?.resume() } + private fun beginOperation(): Int { + activeWork?.cancel(true) + activeWork = null + return operation.incrementAndGet() + } + + private fun submitOperation(action: () -> Unit) { + activeWork = ioExecutor.submit { + try { + action() + } catch (_: CancellationException) { + // A newer navigation request owns the screen. + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } + } + } + override fun onDestroy() { - operation.incrementAndGet() + beginOperation() readerScreen?.dispose() readerScreen = null + // Close the repository after the cancelled task has unwound. This keeps + // its state writer available for import cleanup and the reader's final save. + ioExecutor.execute(repository::close) + ioExecutor.shutdown() super.onDestroy() } } diff --git a/app/src/main/java/com/alexandria/reader/Models.kt b/app/src/main/java/com/alexandria/reader/Models.kt index da26794..f907d93 100644 --- a/app/src/main/java/com/alexandria/reader/Models.kt +++ b/app/src/main/java/com/alexandria/reader/Models.kt @@ -5,7 +5,7 @@ import org.json.JSONObject import java.io.File /** Metadata kept for every document that Alexandria imports into its private library. */ -data class LibraryBook( +internal data class LibraryBook( val id: String, var title: String, var author: String, @@ -37,37 +37,43 @@ data class LibraryBook( } companion object { - fun fromJson(value: JSONObject): LibraryBook = LibraryBook( - id = value.getString("id"), - title = value.optString("title", "Untitled"), - author = value.optString("author", "Unknown author"), - language = value.optString("language"), - publisher = value.optString("publisher"), - description = value.optString("description"), - fileName = value.optString("fileName", "book.epub"), - coverPath = value.optString("coverPath").takeIf { it.isNotBlank() && it != "null" }, - addedAt = value.optLong("addedAt", System.currentTimeMillis()), - lastOpenedAt = value.optLong("lastOpenedAt"), - progress = value.optDouble("progress", 0.0).toFloat().coerceIn(0f, 1f), - state = runCatching { ReadingState.valueOf(value.optString("state", "NEW")) } - .getOrDefault(ReadingState.NEW), - ) + private val ID_PATTERN = Regex("[0-9a-f]{24}") + + fun fromJson(value: JSONObject): LibraryBook { + val id = value.getString("id") + require(ID_PATTERN.matches(id)) { "Invalid library book ID." } + return LibraryBook( + id = id, + title = value.optString("title", "Untitled"), + author = value.optString("author", "Unknown author"), + language = value.optString("language"), + publisher = value.optString("publisher"), + description = value.optString("description"), + fileName = value.optString("fileName", "book.epub"), + coverPath = value.optString("coverPath").takeIf { it.isNotBlank() && it != "null" }, + addedAt = value.optLong("addedAt", System.currentTimeMillis()).coerceAtLeast(0L), + lastOpenedAt = value.optLong("lastOpenedAt").coerceAtLeast(0L), + progress = value.finiteFloat("progress", 0f).coerceIn(0f, 1f), + state = runCatching { ReadingState.valueOf(value.optString("state", "NEW")) } + .getOrDefault(ReadingState.NEW), + ) + } } } -data class SpineItem( +internal data class SpineItem( val href: String, val title: String, ) -data class TocEntry( +internal data class TocEntry( val title: String, val href: String, val depth: Int, val spineIndex: Int, ) -data class EpubPublication( +internal data class EpubPublication( val book: LibraryBook, val rootDirectory: File, val spine: List, @@ -78,7 +84,7 @@ data class EpubPublication( val defaultPageHeight: Int, ) -data class ReaderLocation( +internal data class ReaderLocation( val page: Int = 0, val totalPages: Int = 1, val chapter: Int = 0, @@ -94,17 +100,20 @@ data class ReaderLocation( } companion object { - fun fromJson(value: JSONObject): ReaderLocation = ReaderLocation( - page = value.optInt("page").coerceAtLeast(0), - totalPages = value.optInt("totalPages", 1).coerceAtLeast(1), - chapter = value.optInt("chapter").coerceAtLeast(0), - offset = value.optInt("offset").coerceAtLeast(0), - progress = value.optDouble("progress", 0.0).toFloat().coerceIn(0f, 1f), - ) + fun fromJson(value: JSONObject): ReaderLocation { + val totalPages = value.optInt("totalPages", 1).coerceAtLeast(1) + return ReaderLocation( + page = value.optInt("page").coerceIn(0, totalPages - 1), + totalPages = totalPages, + chapter = value.optInt("chapter").coerceAtLeast(0), + offset = value.optInt("offset").coerceAtLeast(0), + progress = value.finiteFloat("progress", 0f).coerceIn(0f, 1f), + ) + } } } -data class ReaderSettings( +internal data class ReaderSettings( val fontFamily: String = ATKINSON_HYPERLEGIBLE_NEXT, val fontSize: Int = 20, val lineHeight: Float = 1.32f, @@ -131,6 +140,8 @@ data class ReaderSettings( const val PUBLISHER = "Publisher" const val ATKINSON_HYPERLEGIBLE_NEXT = "Atkinson Hyperlegible Next" val FONT_FAMILIES = listOf(PUBLISHER, ATKINSON_HYPERLEGIBLE_NEXT) + private val TEXT_ALIGNMENTS = setOf("publisher", "left", "right", "center", "justify") + private val READING_MODES = setOf("paged", "continuous") fun fromJson(value: JSONObject): ReaderSettings = ReaderSettings( fontFamily = when (value.optString("fontFamily", ATKINSON_HYPERLEGIBLE_NEXT)) { @@ -138,19 +149,18 @@ data class ReaderSettings( else -> ATKINSON_HYPERLEGIBLE_NEXT }, fontSize = value.optInt("fontSize", 20).coerceIn(12, 38), - lineHeight = value.optDouble("lineHeight", 1.32).toFloat().coerceIn(1f, 2f), + lineHeight = value.finiteFloat("lineHeight", 1.32f).coerceIn(1f, 2f), margin = value.optInt("margin", 32).coerceIn(0, 96), - textAlign = value.optString("textAlign", "publisher") - .takeIf { it in setOf("publisher", "left", "right", "center", "justify") } ?: "publisher", + textAlign = value.optString("textAlign", "publisher").takeIf { it in TEXT_ALIGNMENTS } ?: "publisher", publisherStyles = value.optBoolean("publisherStyles", true), hyphenation = value.optBoolean("hyphenation", true), - mode = value.optString("mode", "paged").takeIf { it in setOf("paged", "continuous") } ?: "paged", + mode = value.optString("mode", "paged").takeIf { it in READING_MODES } ?: "paged", brightness = value.optInt("brightness", -1).coerceIn(-1, 100), ) } } -data class Bookmark( +internal data class Bookmark( val label: String, val chapter: Int, val offset: Int, @@ -165,14 +175,14 @@ data class Bookmark( companion object { fun fromJson(value: JSONObject) = Bookmark( - value.optString("label", "Bookmark"), value.optInt("chapter"), - value.optInt("offset"), value.optInt("page"), value.optDouble("progress").toFloat(), - value.optLong("createdAt"), + value.optString("label", "Bookmark"), value.optInt("chapter").coerceAtLeast(0), + value.optInt("offset").coerceAtLeast(0), value.optInt("page").coerceAtLeast(0), + value.finiteFloat("progress", 0f).coerceIn(0f, 1f), value.optLong("createdAt").coerceAtLeast(0L), ) } } -data class Annotation( +internal data class Annotation( val id: String, val chapter: Int, val start: Int, @@ -186,12 +196,22 @@ data class Annotation( } companion object { - fun fromJson(value: JSONObject) = Annotation( - value.getString("id"), value.optInt("chapter"), value.optInt("start"), - value.optInt("end"), value.optString("quote"), value.optString("note"), - ) + fun fromJson(value: JSONObject): Annotation { + val id = value.getString("id") + require(id.isNotBlank()) { "Invalid annotation ID." } + val start = value.optInt("start").coerceAtLeast(0) + return Annotation( + id, value.optInt("chapter").coerceAtLeast(0), start, + value.optInt("end").coerceAtLeast(start), value.optString("quote"), value.optString("note"), + ) + } } } internal fun JSONArray.mapObjects(block: (JSONObject) -> T): List = - (0 until length()).mapNotNull { index -> optJSONObject(index)?.let(block) } + (0 until length()).mapNotNull { index -> + optJSONObject(index)?.let { value -> runCatching { block(value) }.getOrNull() } + } + +private fun JSONObject.finiteFloat(name: String, fallback: Float): Float = + optDouble(name, fallback.toDouble()).takeIf(Double::isFinite)?.toFloat() ?: fallback diff --git a/app/src/main/java/com/alexandria/reader/Persistence.kt b/app/src/main/java/com/alexandria/reader/Persistence.kt new file mode 100644 index 0000000..1f8c9e0 --- /dev/null +++ b/app/src/main/java/com/alexandria/reader/Persistence.kt @@ -0,0 +1,116 @@ +package com.alexandria.reader + +import android.util.Log +import java.io.File +import java.io.FileOutputStream +import java.io.OutputStreamWriter +import java.nio.charset.StandardCharsets +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.util.LinkedHashMap +import java.util.concurrent.CountDownLatch +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicReference + +/** Durable replacement for small private-state files. */ +internal object AtomicFiles { + fun write(target: File, value: String) { + val parent = requireNotNull(target.parentFile) + require(parent.mkdirs() || parent.isDirectory) { "Could not create ${parent.path}." } + val temporary = File.createTempFile(".${target.name}-", ".tmp", parent) + try { + FileOutputStream(temporary).use { stream -> + val writer = OutputStreamWriter(stream, StandardCharsets.UTF_8) + writer.write(value) + writer.flush() + stream.fd.sync() + } + replace(temporary, target) + } finally { + temporary.delete() + } + } + + fun replace(source: File, target: File) { + try { + Files.move( + source.toPath(), target.toPath(), + StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING, + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(source.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + } +} + +/** + * Serializes private-state writes and retains only the latest queued value for + * each file. Value creation also runs off the caller thread. + */ +internal class CoalescingFileWriter( + private val executor: ExecutorService = Executors.newSingleThreadExecutor { task -> + Thread(task, "alexandria-state-writer") + }, + private val write: (File, String) -> Unit = AtomicFiles::write, +) : AutoCloseable { + private data class PendingWrite(val target: File, val value: () -> String) + + private val lock = Any() + private val pending = LinkedHashMap() + private val failure = AtomicReference() + private var drainScheduled = false + private var closed = false + + fun enqueue(target: File, value: () -> String) = synchronized(lock) { + check(!closed) { "The state writer is closed." } + val normalized = target.absoluteFile.normalize() + pending[normalized.path] = PendingWrite(normalized, value) + scheduleDrainLocked() + } + + /** Waits for writes already queued when this method starts. Never call it from the UI thread. */ + fun await() { + val complete = CountDownLatch(1) + synchronized(lock) { + check(!closed) { "The state writer is closed." } + executor.execute(complete::countDown) + } + complete.await() + failure.getAndSet(null)?.let { throw IllegalStateException("Could not save reading state.", it) } + } + + override fun close() = synchronized(lock) { + if (closed) return@synchronized + closed = true + if (pending.isNotEmpty()) scheduleDrainLocked() + executor.shutdown() + } + + private fun scheduleDrainLocked() { + if (drainScheduled) return + drainScheduled = true + executor.execute(::drain) + } + + private fun drain() { + while (true) { + val next = synchronized(lock) { + val entry = pending.entries.firstOrNull() + if (entry == null) { + drainScheduled = false + return + } + pending.remove(entry.key) + entry.value + } + try { + write(next.target, next.value()) + } catch (error: Exception) { + failure.compareAndSet(null, error) + Log.e("AlexandriaState", "Could not write ${next.target.path}", error) + } + } + } +} diff --git a/app/src/main/java/com/alexandria/reader/ReaderScreen.kt b/app/src/main/java/com/alexandria/reader/ReaderScreen.kt index d64bb23..c93c44f 100644 --- a/app/src/main/java/com/alexandria/reader/ReaderScreen.kt +++ b/app/src/main/java/com/alexandria/reader/ReaderScreen.kt @@ -1,5 +1,6 @@ package com.alexandria.reader +import android.annotation.SuppressLint import android.app.Activity import android.app.AlertDialog import android.content.ClipData @@ -7,16 +8,13 @@ import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.content.pm.ActivityInfo -import android.graphics.Typeface import android.net.Uri import android.os.Handler import android.os.Looper -import android.provider.Settings import android.text.InputType import android.view.Gravity import android.view.KeyEvent import android.view.View -import android.view.ViewGroup import android.view.WindowManager import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputMethodManager @@ -31,17 +29,20 @@ import android.widget.SeekBar import android.widget.Spinner import android.widget.TextView import android.widget.Toast -import org.json.JSONArray import org.json.JSONObject +import java.io.File import java.text.DateFormat import java.util.Date import java.util.UUID import kotlin.math.roundToInt /** Native reader controls around the standards-based, local-only EPUB surface. */ -class ReaderScreen( +@SuppressLint("ViewConstructor", "SetTextI18n") +internal class ReaderScreen( private val activity: Activity, private val publication: EpubPublication, + private val readerFile: File, + initialState: LibraryRepository.ReaderState, private val repository: LibraryRepository, private val closeReader: () -> Unit, ) : FrameLayout(activity), ReaderWebView.Listener { @@ -58,23 +59,26 @@ class ReaderScreen( private var controlsVisible = true private var initialized = false private var restoring = true - private val initialLocation = repository.location(publication.book.id) + private val initialLocation = initialState.location private var current = initialLocation - private var settings = repository.settings(publication.book.id) - private val bookmarks = repository.bookmarks(publication.book.id) - private val annotations = repository.annotations(publication.book.id) + private var lastSavedLocation = initialLocation + private var settings = initialState.settings + private val bookmarks = initialState.bookmarks + private val annotations = initialState.annotations private val locationHistory = ArrayDeque() private var pendingSave: Runnable? = null private var searchCount = 0 private var searchIndex = -1 private var searchDirection = ReaderWebView.SearchDirection.FORWARD + private var settingsRevision = 0 + private var disposed = false init { setBackgroundColor(EInkPalette.PAPER) isFocusableInTouchMode = true buildReader() webView.listener = this - webView.load(repository.readerFile(publication)) + webView.load(readerFile, publication.rootDirectory) repository.markOpened(publication.book.id) applyBrightness() } @@ -91,7 +95,7 @@ class ReaderScreen( topBar.setPadding(dp(4), 0, dp(4), 0) addView(topBar, LayoutParams(LayoutParams.MATCH_PARENT, barHeight, Gravity.TOP)) - topBar.addView(actionButton("‹", "Back to library") { _ -> flush(); closeReader() }, lp(dp(52))) + topBar.addView(actionButton("‹", "Back to library") { _ -> closeReader() }, lp(dp(52))) titleLabel.apply { text = publication.book.title textSize = 15f @@ -167,23 +171,27 @@ class ReaderScreen( } override fun onReady() { + if (disposed) return initialized = true webView.applyAnnotations(annotations) webView.applySettings(settings, initialLocation) - handler.postDelayed({ - restoring = false - loading.visibility = View.GONE - webView.captureLocation { onLocationChanged(it) } - }, 350) + } + + override fun onLayoutReady() { + if (disposed || !restoring) return + restoring = false + loading.visibility = View.GONE + webView.captureLocation(::onLocationChanged) } override fun onLocationChanged(location: ReaderLocation) { + if (disposed) return // The document reports its temporary page-zero layout immediately before // onReady. Do not let that transient callback replace the saved locator. if (!initialized) return current = location val percent = (location.progress * 100).roundToInt().coerceIn(0, 100) - progressLabel.text = if (settings.mode == "continuous") { + progressLabel.text = if (!publication.fixedLayout && settings.mode == "continuous") { "${percent}% · ${location.page + 1} / ${location.totalPages}" } else { "Page ${location.page + 1} of ${location.totalPages} · ${percent}%" @@ -198,10 +206,20 @@ class ReaderScreen( } private fun scheduleSave(location: ReaderLocation) { + if (location == lastSavedLocation) return pendingSave?.let(handler::removeCallbacks) - val task = Runnable { repository.updateProgress(publication.book.id, location) } + val task = Runnable { + pendingSave = null + persist(location) + } pendingSave = task - handler.postDelayed(task, 250) + handler.postDelayed(task, SAVE_DELAY_MS) + } + + private fun persist(location: ReaderLocation) { + if (location == lastSavedLocation) return + repository.updateProgress(publication.book.id, location) + lastSavedLocation = location } override fun onTap(x: Float, y: Float) { @@ -267,7 +285,7 @@ class ReaderScreen( menu.add("Go to page").setOnMenuItemClickListener { showGoToPage(); true } menu.add("Rotate screen").setOnMenuItemClickListener { rotateScreen(); true } menu.add("Book information").setOnMenuItemClickListener { showBookInformation(); true } - menu.add("Close book").setOnMenuItemClickListener { flush(); closeReader(); true } + menu.add("Close book").setOnMenuItemClickListener { closeReader(); true } show() } } @@ -311,11 +329,13 @@ class ReaderScreen( .setPositiveButton("Add this page") { _, _ -> toggleBookmark() }.setNegativeButton("Close", null).show() return } - val labels = bookmarks.sortedBy { it.progress }.map { - "${it.label}\n${(it.progress * 100).roundToInt()}% · ${DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(it.createdAt))}" + val sorted = bookmarks.sortedBy { it.progress } + val dateFormat = DateFormat.getDateInstance(DateFormat.MEDIUM) + val labels = sorted.map { + "${it.label}\n${(it.progress * 100).roundToInt()}% · ${dateFormat.format(Date(it.createdAt))}" }.toTypedArray() AlertDialog.Builder(activity).setTitle("Bookmarks").setItems(labels) { _, index -> - val bookmark = bookmarks.sortedBy { it.progress }[index] + val bookmark = sorted[index] rememberLocation() webView.goToLocation(ReaderLocation(bookmark.page, current.totalPages, bookmark.chapter, bookmark.offset, bookmark.progress)) }.setNeutralButton("Remove all") { _, _ -> @@ -332,7 +352,7 @@ class ReaderScreen( } val sorted = annotations.sortedWith(compareBy { it.chapter }.thenBy { it.start }) val labels = sorted.map { annotation -> - val quote = annotation.quote.replace(Regex("\\s+"), " ").take(90) + val quote = annotation.quote.replace(WHITESPACE, " ").take(90) if (annotation.note.isBlank()) "“$quote”" else "${annotation.note}\n“$quote”" }.toTypedArray() AlertDialog.Builder(activity).setTitle("Annotations and highlights").setItems(labels) { _, index -> @@ -375,7 +395,7 @@ class ReaderScreen( private fun showAnnotationEditor(annotation: Annotation) { val message = buildString { - append('“').append(annotation.quote.replace(Regex("\\s+"), " ").take(600)).append('”') + append('“').append(annotation.quote.replace(WHITESPACE, " ").take(600)).append('”') if (annotation.note.isNotBlank()) append("\n\n").append(annotation.note) } AlertDialog.Builder(activity).setTitle(if (annotation.note.isBlank()) "Highlight" else "Annotation") @@ -487,7 +507,7 @@ class ReaderScreen( if (submitted) { val query = text.toString() hideKeyboard(this) - handler.postDelayed({ runSearch(query) }, 180) + runSearch(query) true } else false } @@ -526,10 +546,9 @@ class ReaderScreen( hideKeyboard(webView) } - override fun onSearchResults(results: JSONArray) { - searchCount = results.length() - searchIndex = (0 until searchCount).firstOrNull { results.optJSONObject(it)?.optBoolean("current") == true } - ?: if (searchCount > 0) 0 else -1 + override fun onSearchResults(count: Int, currentIndex: Int) { + searchCount = count + searchIndex = if (searchCount > 0) currentIndex.coerceIn(0, searchCount - 1) else -1 updateSearchStatus() if (searchCount == 0) Toast.makeText(activity, "No matches", Toast.LENGTH_SHORT).show() } @@ -630,17 +649,14 @@ class ReaderScreen( private fun updateSettings(value: ReaderSettings, reflow: Boolean = true) { if (value == settings) return + settings = value + repository.saveSettings(publication.book.id, settings) + applyBrightness() if (reflow && initialized) { + val revision = ++settingsRevision webView.captureLocation { location -> - settings = value - repository.saveSettings(publication.book.id, settings) - applyBrightness() - webView.applySettings(settings, location) + if (!disposed && revision == settingsRevision) webView.applySettings(settings, location) } - } else { - settings = value - repository.saveSettings(publication.book.id, settings) - applyBrightness() } } @@ -679,7 +695,7 @@ class ReaderScreen( override fun onExternalLink(url: String) { val scheme = runCatching { Uri.parse(url).scheme?.lowercase() }.getOrNull() - if (scheme !in setOf("http", "https", "mailto", "tel")) return + if (scheme !in EXTERNAL_LINK_SCHEMES) return AlertDialog.Builder(activity).setTitle("Open external link?").setMessage(url) .setPositiveButton("Open") { _, _ -> runCatching { activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) } @@ -715,20 +731,29 @@ class ReaderScreen( webView.resumeTimers() } - fun flush() { + private fun flush() { pendingSave?.let(handler::removeCallbacks) - repository.updateProgress(publication.book.id, current) + pendingSave = null + if (!restoring) persist(current) } fun dispose() { + if (disposed) return flush() + disposed = true handler.removeCallbacksAndMessages(null) removeView(webView) webView.destroy() + activity.window.attributes = activity.window.attributes.apply { + screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE + } } private fun showKeyboard(view: View) { - handler.postDelayed({ (activity.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).showSoftInput(view, InputMethodManager.SHOW_IMPLICIT) }, 120) + view.post { + (activity.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager) + .showSoftInput(view, InputMethodManager.SHOW_IMPLICIT) + } } private fun hideKeyboard(view: View) { @@ -738,4 +763,9 @@ class ReaderScreen( private fun lp(width: Int): LinearLayout.LayoutParams = LinearLayout.LayoutParams(width, LayoutParams.MATCH_PARENT) private fun dp(value: Int): Int = (value * resources.displayMetrics.density).roundToInt() + companion object { + private const val SAVE_DELAY_MS = 250L + private val EXTERNAL_LINK_SCHEMES = setOf("http", "https", "mailto", "tel") + private val WHITESPACE = Regex("\\s+") + } } diff --git a/app/src/main/java/com/alexandria/reader/ReaderScripts.kt b/app/src/main/java/com/alexandria/reader/ReaderScripts.kt new file mode 100644 index 0000000..98ca388 --- /dev/null +++ b/app/src/main/java/com/alexandria/reader/ReaderScripts.kt @@ -0,0 +1,276 @@ +package com.alexandria.reader + +internal const val IMAGE_DITHER_SCRIPT = """ +function applyPublisherStyles(enabled) { + document.getElementById('alex-publisher-styles').disabled=!enabled; + Array.prototype.forEach.call(document.querySelectorAll('[data-alex-publisher-style]'),function(element){ + if(enabled)element.setAttribute('style',element.getAttribute('data-alex-publisher-style'));else element.removeAttribute('style'); + }); +} +function applyImageDithering(root) { + var filter=document.getElementById('alex-image-dither'); + if(!filter)return; + if(!filter.firstChild){ + function node(name,attributes){var value=document.createElementNS('http://www.w3.org/2000/svg',name);Object.keys(attributes||{}).forEach(function(key){value.setAttribute(key,String(attributes[key]));});return value;} + filter.appendChild(node('feColorMatrix',{type:'matrix',result:'alex-gray',values:'0.2126 0.7152 0.0722 0 0 0.2126 0.7152 0.0722 0 0 0.2126 0.7152 0.0722 0 0 0 0 0 1 0'})); + filter.appendChild(node('feTurbulence',{type:'fractalNoise',baseFrequency:'.47',numOctaves:'1',seed:'37',stitchTiles:'stitch',result:'alex-noise'})); + filter.appendChild(node('feColorMatrix',{in:'alex-noise',type:'matrix',result:'alex-drift',values:'.065 0 0 0 -.0325 .065 0 0 0 -.0325 .065 0 0 0 -.0325 0 0 0 1 0'})); + filter.appendChild(node('feComposite',{in:'alex-gray',in2:'alex-drift',operator:'arithmetic',k1:'0',k2:'1',k3:'1',k4:'0',result:'alex-noisy'})); + var levels=[];for(var level=0;level<16;level++)levels.push((level/15).toFixed(4)); + var quantized=node('feComponentTransfer',{in:'alex-noisy',result:'alex-quantized'}); + ['R','G','B'].forEach(function(channel){quantized.appendChild(node('feFunc'+channel,{type:'discrete',tableValues:levels.join(' ')}));}); + quantized.appendChild(node('feFuncA',{type:'table',tableValues:'0 1'}));filter.appendChild(quantized); + filter.appendChild(node('feComposite',{in:'alex-quantized',in2:'SourceGraphic',operator:'in'})); + } + Array.prototype.forEach.call(root.querySelectorAll('img,canvas,video,.alex-chapter svg'),function(image){image.style.filter='url(#alex-image-dither)';}); +} +""" + +internal const val REFLOWABLE_READER_SCRIPT = """ +(function () { + 'use strict'; + var chapters = Array.prototype.slice.call(document.querySelectorAll('.alex-chapter')); + var state = { page:0, total:1, mode:'paged', chapterStarts:[], searchIndex:-1, searchToken:0, layoutToken:0, layoutReady:false }; + var visibleChapter = null; + var scrolling = function () { return document.scrollingElement; }; + var pageExtent = function () { return state.mode === 'paged' ? window.innerWidth : window.innerHeight; }; + var scrollPosition = function () { return scrolling().scrollTop; }; + function clamp(value, minimum, maximum) { return Math.max(minimum, Math.min(maximum, value)); } + function documentExtent() { var root=scrolling(); return Math.max(root.scrollHeight, document.body.scrollHeight); } + function chapterIndex(element) { + var chapter=element ? element.closest('.alex-chapter') : null; + return chapter ? clamp(parseInt(chapter.getAttribute('data-spine') || '0'),0,chapters.length-1) : 0; + } + function chapterAt(page) { + var low=0,high=Math.max(0,state.chapterStarts.length-1),result=0; + while(low<=high){var middle=(low+high)>>1;if((state.chapterStarts[middle]||0)<=page){result=middle;low=middle+1;}else high=middle-1;} + return result; + } + function setVisibleChapter(index) { + var next=chapters[clamp(index,0,chapters.length-1)]; + if(visibleChapter!==next){if(visibleChapter)visibleChapter.classList.remove('alex-current-page');next.classList.add('alex-current-page');visibleChapter=next;} + return next; + } + function showPaged(page, report) { + state.page=clamp(Math.round(Number(page)||0),0,state.total-1); + var chapter=chapterAt(state.page),local=state.page-(state.chapterStarts[chapter]||0),element=setVisibleChapter(chapter); + element.style.transform='translateX('+(-local*Math.max(1,window.innerWidth))+'px)'; + if(report!==false)notifyPage(); + } + function scrollToPage(page, report) { + if(state.mode==='paged'){showPaged(page,report);return;} + state.page=clamp(Math.round(Number(page)||0),0,state.total-1); + scrolling().scrollTo(0,state.page*Math.max(1,pageExtent())); + if(report!==false)notifyPage(); + } + function withMeasuredChapter(index, action) { + var chapter=chapters[clamp(index,0,chapters.length-1)],wasVisible=chapter===visibleChapter,transform=chapter.style.transform; + if(!wasVisible)chapter.classList.add('alex-measuring'); + chapter.style.transform='none'; + var result=action(chapter); + chapter.style.transform=transform; + if(!wasVisible)chapter.classList.remove('alex-measuring'); + return result; + } + function localPageForElement(element) { + if(!element)return 0; + var index=chapterIndex(element); + return withMeasuredChapter(index,function(chapter){ + var rects=element.getClientRects(),rect=rects.length?rects[0]:element.getBoundingClientRect(),origin=chapter.getBoundingClientRect().left; + return Math.max(0,Math.floor((rect.left-origin+2)/Math.max(1,window.innerWidth))); + }); + } + function pageForElement(element) { + if(!element)return 0; + if(state.mode==='paged'){var index=chapterIndex(element);return(state.chapterStarts[index]||0)+localPageForElement(element);} + var rects=element.getClientRects(),rect=rects.length?rects[0]:element.getBoundingClientRect(); + return Math.max(0,Math.floor((rect.top+scrolling().scrollTop+2)/Math.max(1,pageExtent()))); + } + function measurePaged(keep, complete) { + var token=++state.layoutToken,index=0,total=0,starts=[];state.layoutReady=false; + function batch(){ + if(token!==state.layoutToken)return; + var limit=Math.min(chapters.length,index+8); + for(;index=offset)return{node:node,offset:Math.max(0,offset-count)};count+=node.data.length;}return last?{node:last,offset:last.data.length}:null; + } + function pageForTextPoint(chapterIndex, point) { + return withMeasuredChapter(chapterIndex,function(chapter){ + var range=document.createRange();range.setStart(point.node,Math.min(point.offset,point.node.length));range.collapse(true); + var rects=range.getClientRects(),rect=rects.length?rects[0]:null;if(!rect||!isFinite(rect.left)||!isFinite(rect.top))return-1; + if(state.mode==='paged')return(state.chapterStarts[chapterIndex]||0)+Math.max(0,Math.floor((rect.left-chapter.getBoundingClientRect().left+2)/Math.max(1,window.innerWidth))); + return Math.max(0,Math.floor((rect.top+scrolling().scrollTop+2)/Math.max(1,pageExtent()))); + }); + } + function goToLocator(value, report) { + if(!value){scrollToPage(0,report);return;} + var index=clamp(Math.round(Number(value.chapter)||0),0,chapters.length-1),chapter=chapters[index],point=chapter?nodeAtOffset(chapter,Math.max(0,value.offset||0)):null; + if(point){var textPage=pageForTextPoint(index,point);if(textPage>=0){scrollToPage(textPage,report);return;}} + if(Number(value.totalPages)===state.total&&typeof value.page==='number'){scrollToPage(value.page,report);return;} + if(typeof value.progress==='number'){scrollToPage(Math.round(value.progress*Math.max(0,state.total-1)),report);return;} + scrollToPage((state.chapterStarts[index]||0),report); + } + function applySettings(settings,keep) { + var root=document.documentElement,body=document.body,family=settings.fontFamily||'Atkinson Hyperlegible Next'; + root.style.setProperty('--alex-font',"'"+family.replace(/'/g,'')+"'");root.style.setProperty('--alex-font-size',Math.max(12,Math.min(38,settings.fontSize||20))+'px'); + root.style.setProperty('--alex-line-height',Math.max(1,Math.min(2,settings.lineHeight||1.32)));root.style.setProperty('--alex-side',Math.max(0,Math.min(96,settings.margin==null?32:settings.margin))+'px'); + state.mode=settings.mode==='continuous'?'continuous':'paged';body.classList.toggle('alex-paged',state.mode==='paged');body.classList.toggle('alex-continuous',state.mode!=='paged');root.classList.toggle('alex-continuous',state.mode!=='paged'); + body.classList.toggle('alex-hyphenate',settings.hyphenation!==false);body.classList.toggle('alex-no-hyphenate',settings.hyphenation===false);body.classList.toggle('alex-font-override',family!=='Publisher'); + ['left','right','center','justify'].forEach(function(name){body.classList.toggle('alex-align-'+name,settings.textAlign===name);});applyPublisherStyles(settings.publisherStyles!==false);applyImageDithering(body); + refreshLayout(keep,function(){if(window.Android&&Android.onLayoutReady)Android.onLayoutReady();}); + } + function recordNavigation(targetPage,origin) {var from=origin||locator(),target=clamp(Math.round(Number(targetPage)||0),0,state.total-1),fromPage=clamp(Math.round(Number(from.page)||0),0,state.total-1);if(target===fromPage||!window.Android||!Android.onNavigationJump)return;Android.onNavigationJump(from.page,from.totalPages,from.chapter,from.offset,from.progress);} + function goToTarget(target,record) {if(!target)return;var page=pageForElement(target);if(record!==false)recordNavigation(page);scrollToPage(page,true);} + function goToReference(reference,record) {var id=window.ALEX_REFERENCES[reference];if(id)goToTarget(document.getElementById(id),record);} + function clearSearch() {state.searchToken++;Array.prototype.forEach.call(document.querySelectorAll('mark.alex-search'),function(mark){mark.replaceWith(document.createTextNode(mark.textContent));});document.body.normalize();state.searchIndex=-1;} + function search(query,direction) { + var origin=locator();clearSearch();var token=state.searchToken;query=String(query||'').trim();direction=direction==='backward'?'backward':'forward';if(!query){if(window.Android)Android.onSearchResults(0,-1);return;} + var needle=query.toLocaleLowerCase(),counts={},walker=document.createTreeWalker(document.body,NodeFilter.SHOW_TEXT,{acceptNode:function(node){var parent=node.parentElement,chapter=parent?parent.closest('.alex-chapter'):null;return!chapter||/^(SCRIPT|STYLE|TEXTAREA)$/.test(parent.tagName)?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_ACCEPT;}}),nodes=[],node; + while((node=walker.nextNode())){var chapter=node.parentElement.closest('.alex-chapter'),chapterNumber=parseInt(chapter.getAttribute('data-spine')||'0'),base=counts[chapterNumber]||0;nodes.push({node:node,chapter:chapterNumber,offset:base});counts[chapterNumber]=base+node.data.length;} + var marks=[];nodes.forEach(function(part){var textNode=part.node,lower=textNode.data.toLocaleLowerCase(),positions=[],from=0,found;while((found=lower.indexOf(needle,from))>=0){positions.push(found);from=found+Math.max(1,needle.length);}for(var i=positions.length-1;i>=0;i--){var after=textNode.splitText(positions[i]);after.splitText(query.length);var mark=document.createElement('mark');mark.className='alex-search';mark.dataset.chapter=String(part.chapter);mark.dataset.offset=String(part.offset+positions[i]);after.parentNode.replaceChild(mark,after);mark.appendChild(after);marks.push(mark);}}); + marks=Array.prototype.slice.call(document.querySelectorAll('mark.alex-search'));setTimeout(function(){if(token!==state.searchToken)return;var selected=-1; + if(direction==='backward'){for(var i=marks.length-1;i>=0;i--){var backChapter=parseInt(marks[i].dataset.chapter||'0'),backOffset=parseInt(marks[i].dataset.offset||'0');if(backChapterorigin.chapter||(forwardChapter===origin.chapter&&forwardOffset>=origin.offset)){selected=j;break;}}if(selected<0&&marks.length)selected=0;} + state.searchIndex=selected;if(selected>=0){marks[selected].classList.add('alex-search-current');var page=pageForElement(marks[selected]);recordNavigation(page,origin);scrollToPage(page,true);}if(window.Android)Android.onSearchResults(marks.length,selected); + },20); + } + function moveSearch(delta){var marks=document.querySelectorAll('mark.alex-search');if(!marks.length)return;if(state.searchIndex>=0&&marks[state.searchIndex])marks[state.searchIndex].classList.remove('alex-search-current');state.searchIndex=(state.searchIndex+delta+marks.length)%marks.length;var page=pageForElement(marks[state.searchIndex]);recordNavigation(page);marks[state.searchIndex].classList.add('alex-search-current');scrollToPage(page,true);} + function captureSelection(){var selection=window.getSelection();if(!selection||selection.rangeCount===0||selection.isCollapsed)return null;var range=selection.getRangeAt(0),chapter=range.commonAncestorContainer.nodeType===1?range.commonAncestorContainer:range.commonAncestorContainer.parentElement;chapter=chapter?chapter.closest('.alex-chapter'):null;if(!chapter)return null;var before=document.createRange();before.selectNodeContents(chapter);before.setEnd(range.startContainer,range.startOffset);var start=before.toString().length,quote=range.toString();return{chapter:parseInt(chapter.getAttribute('data-spine')||'0'),start:start,end:start+quote.length,quote:quote.substring(0,10000)};} + function wrapAnnotation(annotation){var chapter=chapters[annotation.chapter];if(!chapter||annotation.end<=annotation.start)return;var walker=document.createTreeWalker(chapter,NodeFilter.SHOW_TEXT,{acceptNode:function(node){return node.parentElement&&node.parentElement.closest('mark.alex-annotation')?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_ACCEPT;}}),nodes=[],node,count=0;while((node=walker.nextNode())){nodes.push({node:node,start:count,end:count+node.data.length});count+=node.data.length;}nodes.reverse().forEach(function(part){var from=Math.max(annotation.start,part.start)-part.start,to=Math.min(annotation.end,part.end)-part.start;if(to>from){var selected=part.node;if(to0)selected=selected.splitText(from);var mark=document.createElement('mark');mark.className='alex-annotation';mark.dataset.annotation=annotation.id;selected.parentNode.replaceChild(mark,selected);mark.appendChild(selected);}});} + function applyAnnotations(values){(values||[]).slice().sort(function(a,b){return b.start-a.start;}).forEach(wrapAnnotation);if(state.layoutReady)refreshLayout(locator());} + function removeAnnotation(id){var keep=locator();Array.prototype.forEach.call(document.querySelectorAll('mark[data-annotation="'+id+'"]'),function(mark){mark.replaceWith(document.createTextNode(mark.textContent));});document.body.normalize();if(state.layoutReady)refreshLayout(keep);} + document.addEventListener('click',function(event){var mark=event.target.closest('mark.alex-annotation');if(mark&&window.Android){event.preventDefault();Android.onAnnotationTapped(mark.dataset.annotation);return;}var link=event.target.closest('a');if(!link)return;var external=link.getAttribute('data-external-href');if(external){event.preventDefault();if(window.Android)Android.onExternalLink(external);return;}var href=link.getAttribute('href')||'';if(href.charAt(0)==='#'){event.preventDefault();goToTarget(document.getElementById(href.substring(1)),true);}},true); + var scrollTimer=0;function handleScroll(){if(state.mode!=='continuous')return;clearTimeout(scrollTimer);scrollTimer=setTimeout(function(){var next=clamp(Math.round(scrollPosition()/Math.max(1,pageExtent())),0,state.total-1);if(next!==state.page){state.page=next;notifyPage();}},80);}scrolling().addEventListener('scroll',handleScroll,{passive:true});window.addEventListener('scroll',handleScroll,{passive:true}); + var resizeTimer=0;window.addEventListener('resize',function(){clearTimeout(resizeTimer);resizeTimer=setTimeout(function(){var keep=locator();refreshLayout(keep);},120);}); + window.Alex={setPage:scrollToPage,next:function(){scrollToPage(state.page+1,true);},previous:function(){scrollToPage(state.page-1,true);},locator:locator,goToLocator:goToLocator,applySettings:applySettings,goToReference:goToReference,search:search,clearSearch:clearSearch,moveSearch:moveSearch,captureSelection:captureSelection,applyAnnotations:applyAnnotations,wrapAnnotation:wrapAnnotation,removeAnnotation:removeAnnotation,chapterPage:function(index){return state.chapterStarts[clamp(index,0,state.chapterStarts.length-1)]||0;}}; + var loaded=document.readyState==='complete'?Promise.resolve():new Promise(function(resolve){window.addEventListener('load',resolve,{once:true});}); + Promise.all([document.fonts.ready,loaded]).then(function(){setTimeout(function(){if(window.Android&&Android.onReaderReady)Android.onReaderReady();},20);}); +})(); +""" + +internal const val FIXED_READER_SCRIPT = """ +(function () { + 'use strict'; + var pages = Array.prototype.slice.call(document.querySelectorAll('.alex-chapter')); + var state = {page:0,total:pages.length,searchIndex:-1},visiblePage=null; + function number(value,fallback){value=Number(value);return isFinite(value)?value:fallback;} + function clamp(value,min,max){return Math.max(min,Math.min(max,value));} + function pageElement(){return pages[clamp(state.page,0,pages.length-1)];} + function pageSize(page){return{width:Math.max(1,number(page.getAttribute('data-page-width'),1200)),height:Math.max(1,number(page.getAttribute('data-page-height'),1600))};} + function layoutPage(){ + state.page=clamp(Math.round(number(state.page,0)),0,pages.length-1); + var page=pageElement();if(visiblePage!==page){if(visiblePage)visiblePage.classList.remove('alex-current-page');page.classList.add('alex-current-page');visiblePage=page;} + var size=pageSize(page),availableWidth=Math.max(1,window.innerWidth),availableHeight=Math.max(1,window.innerHeight); + var scale=Math.max(0.01,Math.min(availableWidth/size.width,availableHeight/size.height)); + var x=(availableWidth-size.width*scale)/2,y=(availableHeight-size.height*scale)/2; + page.style.width=size.width+'px';page.style.height=size.height+'px';page.style.transform='translate('+x+'px,'+y+'px) scale('+scale+')'; + applyImageDithering(page); + } + function locator(){return{page:state.page,totalPages:state.total,chapter:state.page,offset:0,progress:state.total<=1?0:state.page/(state.total-1)};} + function notifyPage(){if(window.Android&&Android.onPageChanged){var value=locator();Android.onPageChanged(value.page,value.totalPages,value.chapter,value.offset,value.progress);}} + function recalculate(){state.total=pages.length;layoutPage();notifyPage();} + function setPage(page,report){state.page=clamp(Math.round(number(page,0)),0,state.total-1);layoutPage();if(report!==false)notifyPage();} + function pageForElement(element){var chapter=element?element.closest('.alex-chapter'):null;return chapter?clamp(number(chapter.getAttribute('data-spine'),0),0,state.total-1):0;} + function goToLocator(value){if(!value){setPage(0,true);return;}setPage(number(value.chapter,value.page||0),true);} + function applySettings(settings,keep){applyPublisherStyles(true);if(keep)state.page=clamp(Math.round(number(keep.chapter,keep.page||0)),0,state.total-1);layoutPage();notifyPage();if(window.Android&&Android.onLayoutReady)Android.onLayoutReady();} + function recordNavigation(targetPage,origin) { + var from=origin||locator(),target=clamp(Math.round(number(targetPage,0)),0,state.total-1),fromPage=clamp(Math.round(number(from.page,0)),0,state.total-1); + if(target===fromPage||!window.Android||!Android.onNavigationJump)return;Android.onNavigationJump(from.page,from.totalPages,from.chapter,from.offset,from.progress); + } + function goToReference(reference,record) { + var id=window.ALEX_REFERENCES[reference];if(!id)return; + var target=document.getElementById(id);if(!target)return; + var page=pageForElement(target);if(record!==false)recordNavigation(page);setPage(page,true); + } + function clearSearch() { + Array.prototype.forEach.call(document.querySelectorAll('mark.alex-search'),function(mark){mark.replaceWith(document.createTextNode(mark.textContent));}); + document.body.normalize();state.searchIndex=-1; + } + function search(query,direction) { + var origin=locator();clearSearch();query=String(query||'').trim();direction=direction==='backward'?'backward':'forward'; + if(!query){if(window.Android)Android.onSearchResults(0,-1);return;} + var needle=query.toLocaleLowerCase(),walker=document.createTreeWalker(document.body,NodeFilter.SHOW_TEXT,{acceptNode:function(node){ + var parent=node.parentElement,chapter=parent?parent.closest('.alex-chapter'):null; + return !chapter||/^(SCRIPT|STYLE|TEXTAREA)$/.test(parent.tagName)?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_ACCEPT; + }}),nodes=[],node;while((node=walker.nextNode()))nodes.push(node);var marks=[]; + nodes.forEach(function(textNode){var lower=textNode.data.toLocaleLowerCase(),positions=[],from=0,found; + while((found=lower.indexOf(needle,from))>=0){positions.push(found);from=found+Math.max(1,needle.length);} + for(var i=positions.length-1;i>=0;i--){var after=textNode.splitText(positions[i]);after.splitText(query.length);var mark=document.createElement('mark');mark.className='alex-search';after.parentNode.replaceChild(mark,after);mark.appendChild(after);marks.push(mark);} + });marks=Array.prototype.slice.call(document.querySelectorAll('mark.alex-search')); + var results=marks.map(function(mark){return{page:pageForElement(mark)};}); + var selected=-1;if(direction==='backward'){for(var back=results.length-1;back>=0;back--)if(results[back].page<=origin.page){selected=back;break;}if(selected<0&&results.length)selected=results.length-1;} + else{for(var forward=0;forward=origin.page){selected=forward;break;}if(selected<0&&results.length)selected=0;} + state.searchIndex=selected; + if(selected>=0){marks[selected].classList.add('alex-search-current');recordNavigation(results[selected].page,origin);setPage(results[selected].page,true);} + if(window.Android)Android.onSearchResults(results.length,selected); + } + function moveSearch(delta) { + var marks=document.querySelectorAll('mark.alex-search');if(!marks.length)return; + if(state.searchIndex>=0&&marks[state.searchIndex])marks[state.searchIndex].classList.remove('alex-search-current'); + state.searchIndex=(state.searchIndex+delta+marks.length)%marks.length;var page=pageForElement(marks[state.searchIndex]);recordNavigation(page);marks[state.searchIndex].classList.add('alex-search-current');setPage(page,true); + } + function captureSelection() { + var selection=window.getSelection();if(!selection||selection.rangeCount===0||selection.isCollapsed)return null; + var range=selection.getRangeAt(0),chapter=range.commonAncestorContainer.nodeType===1?range.commonAncestorContainer:range.commonAncestorContainer.parentElement; + chapter=chapter?chapter.closest('.alex-chapter'):null;if(!chapter)return null; + var before=document.createRange();before.selectNodeContents(chapter);before.setEnd(range.startContainer,range.startOffset);var start=before.toString().length,quote=range.toString(); + return{chapter:parseInt(chapter.getAttribute('data-spine')||'0'),start:start,end:start+quote.length,quote:quote.substring(0,10000)}; + } + function wrapAnnotation(annotation) { + var chapter=document.querySelector('.alex-chapter[data-spine="'+annotation.chapter+'"]');if(!chapter||annotation.end<=annotation.start)return; + var walker=document.createTreeWalker(chapter,NodeFilter.SHOW_TEXT,{acceptNode:function(node){return node.parentElement&&node.parentElement.closest('mark.alex-annotation')?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_ACCEPT;}}),nodes=[],node,count=0; + while((node=walker.nextNode())){nodes.push({node:node,start:count,end:count+node.data.length});count+=node.data.length;}nodes.reverse().forEach(function(part){var from=Math.max(annotation.start,part.start)-part.start,to=Math.min(annotation.end,part.end)-part.start;if(to>from){var selected=part.node;if(to0)selected=selected.splitText(from);var mark=document.createElement('mark');mark.className='alex-annotation';mark.dataset.annotation=annotation.id;selected.parentNode.replaceChild(mark,selected);mark.appendChild(selected);}}); + } + function applyAnnotations(values){(values||[]).slice().sort(function(a,b){return b.start-a.start;}).forEach(wrapAnnotation);} + function removeAnnotation(id){Array.prototype.forEach.call(document.querySelectorAll('mark[data-annotation="'+id+'"]'),function(mark){mark.replaceWith(document.createTextNode(mark.textContent));});document.body.normalize();} + document.addEventListener('click',function(event){ + var mark=event.target.closest('mark.alex-annotation');if(mark&&window.Android){event.preventDefault();Android.onAnnotationTapped(mark.dataset.annotation);return;} + var link=event.target.closest('a');if(!link)return;var external=link.getAttribute('data-external-href');if(external){event.preventDefault();if(window.Android)Android.onExternalLink(external);return;} + var href=link.getAttribute('href')||'';if(href.charAt(0)==='#'){event.preventDefault();var target=document.getElementById(href.substring(1));if(target){var page=pageForElement(target);recordNavigation(page);setPage(page,true);}} + },true); + var resizeTimer=0;window.addEventListener('resize',function(){clearTimeout(resizeTimer);resizeTimer=setTimeout(function(){layoutPage();notifyPage();},120);}); + window.Alex={setPage:setPage,next:function(){setPage(state.page+1,true);},previous:function(){setPage(state.page-1,true);},locator:locator,goToLocator:goToLocator, + applySettings:applySettings,goToReference:goToReference,search:search,clearSearch:clearSearch,moveSearch:moveSearch,captureSelection:captureSelection, + applyAnnotations:applyAnnotations,wrapAnnotation:wrapAnnotation,removeAnnotation:removeAnnotation,chapterPage:function(index){return clamp(index,0,state.total-1);}}; + function ready(){recalculate();if(window.Android&&Android.onReaderReady)Android.onReaderReady();} + document.fonts.ready.then(function(){setTimeout(ready,30);}); +})(); +""" diff --git a/app/src/main/java/com/alexandria/reader/ReaderWebView.kt b/app/src/main/java/com/alexandria/reader/ReaderWebView.kt index 4542eec..1300fa8 100644 --- a/app/src/main/java/com/alexandria/reader/ReaderWebView.kt +++ b/app/src/main/java/com/alexandria/reader/ReaderWebView.kt @@ -12,7 +12,6 @@ import android.view.MenuItem import android.view.MotionEvent import android.webkit.ConsoleMessage import android.webkit.JavascriptInterface -import android.webkit.JsResult import android.webkit.ValueCallback import android.webkit.WebChromeClient import android.webkit.WebResourceError @@ -27,16 +26,17 @@ import java.io.ByteArrayInputStream import java.io.File import kotlin.math.abs -@SuppressLint("SetJavaScriptEnabled") -class ReaderWebView(context: Context) : WebView(context) { +@SuppressLint("SetJavaScriptEnabled", "ClickableViewAccessibility") +internal class ReaderWebView(context: Context) : WebView(context) { interface Listener { fun onReady() + fun onLayoutReady() fun onLocationChanged(location: ReaderLocation) fun onTap(x: Float, y: Float) fun onSwipe(forward: Boolean) fun onExternalLink(url: String) fun onNavigationJump(origin: ReaderLocation) - fun onSearchResults(results: JSONArray) + fun onSearchResults(count: Int, currentIndex: Int) fun onAnnotationTapped(id: String) fun onSelectionAction(action: SelectionAction, selection: JSONObject?) fun onRenderError(message: String) @@ -51,6 +51,8 @@ class ReaderWebView(context: Context) : WebView(context) { var listener: Listener? = null private val mainHandler = Handler(Looper.getMainLooper()) private var pageLoaded = false + private var loadedReader: File? = null + private var resourceRoot: File? = null private var suppressTapUntil = 0L private val gestures = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() { @@ -59,13 +61,7 @@ class ReaderWebView(context: Context) : WebView(context) { override fun onSingleTapConfirmed(event: MotionEvent): Boolean { if (android.os.SystemClock.uptimeMillis() < suppressTapUntil) return false val hit = hitTestResult - val isLink = hit.type in setOf( - HitTestResult.SRC_ANCHOR_TYPE, - HitTestResult.SRC_IMAGE_ANCHOR_TYPE, - HitTestResult.EMAIL_TYPE, - HitTestResult.PHONE_TYPE, - HitTestResult.GEO_TYPE, - ) + val isLink = hit.type in LINK_HIT_TYPES if (!isLink) { performClick() listener?.onTap(event.x, event.y) @@ -97,7 +93,6 @@ class ReaderWebView(context: Context) : WebView(context) { allowFileAccess = true allowContentAccess = false blockNetworkLoads = true - cacheMode = WebSettings.LOAD_NO_CACHE builtInZoomControls = false displayZoomControls = false setSupportZoom(false) @@ -108,11 +103,6 @@ class ReaderWebView(context: Context) : WebView(context) { } addJavascriptInterface(Bridge(), "Android") webChromeClient = object : WebChromeClient() { - override fun onJsAlert(view: WebView?, url: String?, message: String?, result: JsResult?): Boolean { - result?.cancel() - return true - } - override fun onConsoleMessage(message: ConsoleMessage): Boolean { when (message.messageLevel()) { ConsoleMessage.MessageLevel.ERROR -> Log.e("AlexandriaRenderer", "${message.message()} (${message.sourceId()}:${message.lineNumber()})") @@ -125,17 +115,19 @@ class ReaderWebView(context: Context) : WebView(context) { webViewClient = object : WebViewClient() { override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { val uri = request?.url ?: return true - if (uri.scheme == "file" && uri.path?.endsWith("reader.html") == true) return false + if (request.isForMainFrame && uri.toString() == loadedReader?.toURI()?.toASCIIString()) return false listener?.onExternalLink(uri.toString()) return true } override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? { - val scheme = request?.url?.scheme?.lowercase() - if (scheme != null && scheme !in setOf("file", "data", "blob")) { - return WebResourceResponse("text/plain", "utf-8", ByteArrayInputStream(ByteArray(0))) + val uri = request?.url ?: return emptyResponse() + val allowed = when (uri.scheme?.lowercase()) { + "data", "blob" -> true + "file" -> isAllowedFile(uri.path) + else -> false } - return super.shouldInterceptRequest(view, request) + return if (allowed) super.shouldInterceptRequest(view, request) else emptyResponse() } override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) { @@ -146,11 +138,25 @@ class ReaderWebView(context: Context) : WebView(context) { } } - fun load(file: File) { + fun load(file: File, resources: File) { pageLoaded = false - loadUrl(file.toURI().toASCIIString()) + loadedReader = file.canonicalFile + resourceRoot = resources.canonicalFile + loadUrl(requireNotNull(loadedReader).toURI().toASCIIString()) } + private fun isAllowedFile(path: String?): Boolean { + if (path == null) return false + if (path.startsWith("/android_res/")) return true + val candidate = runCatching { File(path).canonicalFile }.getOrNull() ?: return false + if (candidate == loadedReader) return true + val root = resourceRoot ?: return false + return candidate.path == root.path || candidate.path.startsWith(root.path + File.separator) + } + + private fun emptyResponse(): WebResourceResponse = + WebResourceResponse("text/plain", "utf-8", ByteArrayInputStream(ByteArray(0))) + fun applySettings(settings: ReaderSettings, location: ReaderLocation? = null) { val keep = location?.toJson()?.toString() ?: "Alex.locator()" javascript("Alex.applySettings(${settings.toJson()}, $keep)") @@ -256,16 +262,21 @@ class ReaderWebView(context: Context) : WebView(context) { listener?.onReady() } - @JavascriptInterface fun onPageChanged(json: String) = mainHandler.post { - runCatching { ReaderLocation.fromJson(JSONObject(json)) }.getOrNull()?.let { listener?.onLocationChanged(it) } + @JavascriptInterface fun onLayoutReady() = mainHandler.post { listener?.onLayoutReady() } + + @JavascriptInterface + fun onPageChanged(page: Int, totalPages: Int, chapter: Int, offset: Int, progress: Double) = mainHandler.post { + listener?.onLocationChanged(location(page, totalPages, chapter, offset, progress)) } @JavascriptInterface fun onExternalLink(url: String) = mainHandler.post { listener?.onExternalLink(url) } - @JavascriptInterface fun onNavigationJump(json: String) = mainHandler.post { - runCatching { ReaderLocation.fromJson(JSONObject(json)) }.getOrNull()?.let { listener?.onNavigationJump(it) } + + @JavascriptInterface + fun onNavigationJump(page: Int, totalPages: Int, chapter: Int, offset: Int, progress: Double) = mainHandler.post { + listener?.onNavigationJump(location(page, totalPages, chapter, offset, progress)) } - @JavascriptInterface fun onSearchResults(json: String) = mainHandler.post { - runCatching { JSONArray(json) }.getOrNull()?.let { listener?.onSearchResults(it) } + @JavascriptInterface fun onSearchResults(count: Int, currentIndex: Int) = mainHandler.post { + listener?.onSearchResults(count.coerceAtLeast(0), currentIndex) } @JavascriptInterface fun onAnnotationTapped(id: String) = mainHandler.post { suppressTapUntil = android.os.SystemClock.uptimeMillis() + 700L @@ -275,17 +286,38 @@ class ReaderWebView(context: Context) : WebView(context) { override fun destroy() { listener = null + pageLoaded = false + loadedReader = null + resourceRoot = null + mainHandler.removeCallbacksAndMessages(null) removeJavascriptInterface("Android") stopLoading() - loadUrl("about:blank") super.destroy() } + private fun location(page: Int, totalPages: Int, chapter: Int, offset: Int, progress: Double): ReaderLocation { + val total = totalPages.coerceAtLeast(1) + return ReaderLocation( + page = page.coerceIn(0, total - 1), + totalPages = total, + chapter = chapter.coerceAtLeast(0), + offset = offset.coerceAtLeast(0), + progress = progress.takeIf(Double::isFinite)?.toFloat()?.coerceIn(0f, 1f) ?: 0f, + ) + } + companion object { private const val ACTION_HIGHLIGHT = 0xA110 private const val ACTION_NOTE = 0xA111 private const val ACTION_DEFINE = 0xA112 private const val ACTION_SEARCH = 0xA113 + private val LINK_HIT_TYPES = setOf( + HitTestResult.SRC_ANCHOR_TYPE, + HitTestResult.SRC_IMAGE_ANCHOR_TYPE, + HitTestResult.EMAIL_TYPE, + HitTestResult.PHONE_TYPE, + HitTestResult.GEO_TYPE, + ) private fun decodeJavascriptString(value: String?): String? { if (value == null || value == "null") return null diff --git a/app/src/main/java/com/alexandria/reader/WorkCancellation.kt b/app/src/main/java/com/alexandria/reader/WorkCancellation.kt new file mode 100644 index 0000000..4b64674 --- /dev/null +++ b/app/src/main/java/com/alexandria/reader/WorkCancellation.kt @@ -0,0 +1,29 @@ +package com.alexandria.reader + +import java.io.FilterInputStream +import java.io.InputStream +import java.util.concurrent.CancellationException + +/** Cooperative cancellation for import, extraction, parsing, and rendering work. */ +internal object WorkCancellation { + fun check() { + if (Thread.currentThread().isInterrupted) throw CancellationException("Operation cancelled.") + } + + fun input(stream: InputStream): InputStream = object : FilterInputStream(stream) { + override fun read(): Int { + WorkCancellation.check() + return super.read() + } + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + WorkCancellation.check() + return super.read(buffer, offset, length) + } + + override fun skip(count: Long): Long { + WorkCancellation.check() + return super.skip(count) + } + } +} diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..3e88e57 --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/app/src/test/java/com/alexandria/reader/EpubParserTest.kt b/app/src/test/java/com/alexandria/reader/EpubParserTest.kt new file mode 100644 index 0000000..f5fca05 --- /dev/null +++ b/app/src/test/java/com/alexandria/reader/EpubParserTest.kt @@ -0,0 +1,215 @@ +package com.alexandria.reader + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.jsoup.Jsoup +import java.io.File +import java.util.zip.CRC32 +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +class EpubParserTest { + @get:Rule + val temporary = TemporaryFolder() + + @Test + fun parsesEpub2MetadataCoverAndNestedNcx() { + val book = testBook() + val publication = EpubParser.parse(fixture("epub2.epub"), temporary.newFolder("epub2"), book) + + assertEquals("EPUB 2 Verification", publication.book.title) + assertEquals("Alexandria Tests", publication.book.author) + assertEquals(2, publication.spine.size) + assertEquals("EPUB 2 Start", publication.spine.first().title) + assertTrue(publication.toc.any { it.depth > 0 }) + assertTrue(publication.book.coverPath?.let(::File)?.isFile == true) + assertFalse(publication.fixedLayout) + } + + @Test + fun parsesStandardFixedLayoutMetadata() { + val publication = EpubParser.parse( + fixture("fixed-layout.epub"), + temporary.newFolder("fixed"), + testBook(), + ) + + assertTrue(publication.fixedLayout) + assertEquals(4, publication.spine.size) + assertEquals(1200, publication.defaultPageWidth) + assertEquals(1600, publication.defaultPageHeight) + } + + @Test + fun resolvesEncodedRelativePathsWithoutLeavingTheContainer() { + assertEquals("OPS/Images/cover art.png", EpubParser.normalizePath("OPS/Text", "../Images/cover%20art.png")) + assertEquals("OPS/Text/chapter.xhtml#part one", EpubParser.resolveReference("OPS/Text", "chapter.xhtml#part%20one")) + + assertThrows(IllegalArgumentException::class.java) { + EpubParser.normalizePath("OPS", "../../outside.xhtml") + } + assertThrows(IllegalArgumentException::class.java) { + EpubParser.normalizePath("OPS", "Text\\chapter.xhtml") + } + assertThrows(IllegalArgumentException::class.java) { + EpubParser.normalizePath("", "/chapter.xhtml") + } + } + + @Test + fun rejectsUnsafeArchiveEntryBeforeWritingIt() { + val source = temporary.newFile("unsafe.epub") + ZipOutputStream(source.outputStream()).use { zip -> + val mimetype = "application/epub+zip".encodeToByteArray() + zip.putNextEntry(ZipEntry("mimetype").apply { + method = ZipEntry.STORED + size = mimetype.size.toLong() + compressedSize = size + crc = CRC32().apply { update(mimetype) }.value + }) + zip.write(mimetype) + zip.closeEntry() + zip.putNextEntry(ZipEntry("../outside.txt")) + zip.write("unsafe".encodeToByteArray()) + zip.closeEntry() + } + val output = temporary.newFolder("unsafe-output") + + assertThrows(IllegalArgumentException::class.java) { + EpubParser.parse(source, output, testBook()) + } + assertFalse(File(output.parentFile, "outside.txt").exists()) + } + + @Test + fun preservesCssFunctionCommasSvgResourceFragmentsAndRootTargets() { + val root = temporary.newFolder("publication") + val text = File(root, "Text").apply(File::mkdirs) + val images = File(root, "Images").apply(File::mkdirs) + File(images, "sprite.svg").writeText( + """""", + ) + File(text, "chapter.xhtml").writeText( + """ + + +

Text

+""", + ) + val publication = EpubPublication( + book = testBook(), + rootDirectory = root, + spine = listOf(SpineItem("Text/chapter.xhtml", "Chapter")), + toc = listOf( + TocEntry("Chapter", "Text/chapter.xhtml#chapter-body", 0, 0), + TocEntry("Target", "Text/chapter.xhtml#target", 1, 0), + ), + readingDirection = "ltr", + fixedLayout = false, + defaultPageWidth = 1200, + defaultPageHeight = 1600, + ) + + val html = EpubHtmlBuilder.write(publication, temporary.newFile("reader.html")).readText() + + assertTrue(html.contains(".pick:is(.one,.two)")) + assertTrue(html.contains("sprite.svg#shape")) + assertTrue(html.contains("\"Text/chapter.xhtml#chapter-body\":\"alex-chapter-0\"")) + assertTrue(html.contains("lang=\"fr\"")) + val target = requireNotNull(Jsoup.parse(html).selectFirst("p")) + assertTrue(target.id().startsWith("alex-0-")) + assertEquals("color:red", target.attr("data-alex-publisher-style")) + } + + @Test + fun isolatesChapterCssAndNamespacesRepeatedIdsAndSvgReferences() { + val root = temporary.newFolder("isolated-publication") + val text = File(root, "Text").apply(File::mkdirs) + val styles = File(root, "Styles").apply(File::mkdirs) + File(styles, "shared.css").writeText(".shared:is(.one,.two) { font-weight:bold; } #paint stop { stop-color:#000; }") + repeat(2) { index -> + File(text, "chapter${index + 1}.xhtml").writeText( + """ + + + + + +Image +

Target

""", + ) + } + val publication = EpubPublication( + book = testBook(), + rootDirectory = root, + spine = listOf( + SpineItem("Text/chapter1.xhtml", "One"), + SpineItem("Text/chapter2.xhtml", "Two"), + ), + toc = listOf( + TocEntry("One", "Text/chapter1.xhtml#same-body", 0, 0), + TocEntry("Two", "Text/chapter2.xhtml#same-body", 0, 1), + ), + readingDirection = "ltr", + fixedLayout = false, + defaultPageWidth = 1200, + defaultPageHeight = 1600, + ) + + val document = Jsoup.parse(EpubHtmlBuilder.write(publication, temporary.newFile("isolated.html")).readText()) + val sections = document.select("section.alex-chapter") + assertEquals(2, sections.size) + val paintIds = sections.map { requireNotNull(it.selectFirst("linearGradient")).id() } + val boxIds = sections.map { requireNotNull(it.selectFirst("rect")).id() } + val labelIds = sections.map { requireNotNull(it.selectFirst("title")).id() } + assertNotEquals(paintIds[0], paintIds[1]) + assertNotEquals(boxIds[0], boxIds[1]) + sections.forEachIndexed { index, section -> + assertEquals("url('#${paintIds[index]}')", requireNotNull(section.selectFirst("rect")).attr("fill")) + assertEquals(labelIds[index], requireNotNull(section.selectFirst("rect")).attr("aria-labelledby")) + assertEquals("#${boxIds[index]}", requireNotNull(section.selectFirst("use")).attr("href")) + assertEquals("#${boxIds[index]}", requireNotNull(section.selectFirst("a")).attr("href")) + assertTrue(section.select("style, link[rel=stylesheet]").isEmpty()) + } + val css = requireNotNull(document.getElementById("alex-publisher-styles")).data() + assertTrue(css.contains(".alex-chapter[data-spine=\"0\"] .chapter-only")) + assertTrue(css.contains(".alex-chapter[data-spine=\"1\"] .chapter-only")) + assertTrue(css.contains(".alex-chapter[data-spine=\"0\"] .shared:is(.one,.two)")) + assertTrue(css.contains(".alex-chapter[data-spine=\"1\"] .shared:is(.one,.two)")) + assertTrue(css, css.contains(".alex-chapter[data-spine=\"0\"].body-class > svg")) + assertFalse(css.contains("#paint")) + assertFalse(css.contains("#box")) + } + + @Test + fun rejectsDuplicateIdsInsideOneSpineDocument() { + val root = temporary.newFolder("duplicate-id-publication") + File(root, "chapter.xhtml").writeText( + """

One

Two

""", + ) + val publication = EpubPublication( + testBook(), root, listOf(SpineItem("chapter.xhtml", "Chapter")), + listOf(TocEntry("Chapter", "chapter.xhtml", 0, 0)), "ltr", false, 1200, 1600, + ) + + assertThrows(IllegalArgumentException::class.java) { + EpubHtmlBuilder.write(publication, temporary.newFile("duplicate.html")) + } + } + + private fun fixture(name: String): File = + File(requireNotNull(javaClass.classLoader?.getResource(name)) { "Missing fixture: $name" }.toURI()) + + private fun testBook() = LibraryBook( + id = "0123456789abcdef01234567", + title = "Test book", + author = "Test author", + fileName = "test.epub", + ) +} diff --git a/app/src/test/java/com/alexandria/reader/LibraryRepositoryTest.kt b/app/src/test/java/com/alexandria/reader/LibraryRepositoryTest.kt new file mode 100644 index 0000000..865612f --- /dev/null +++ b/app/src/test/java/com/alexandria/reader/LibraryRepositoryTest.kt @@ -0,0 +1,133 @@ +package com.alexandria.reader + +import android.content.Context +import android.net.Uri +import androidx.test.core.app.ApplicationProvider +import org.json.JSONArray +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertThrows +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.annotation.Config +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class LibraryRepositoryTest { + private lateinit var context: Context + private var repository: LibraryRepository? = null + + @Before + fun resetPrivateStorage() { + context = ApplicationProvider.getApplicationContext() + File(context.filesDir, "library").deleteRecursively() + context.cacheDir.listFiles().orEmpty().filter { it.name.startsWith("import-") }.forEach(File::deleteRecursively) + context.getSharedPreferences("alexandria-reader", Context.MODE_PRIVATE).edit().clear().commit() + } + + @After + fun closeRepository() { + repository?.close() + } + + @Test + fun persistsLatestReaderStateAndSkipsMalformedRecords() { + val repository = newRepository() + val publication = repository.import(Uri.fromFile(fixture("navigation.epub"))) + val id = publication.book.id + repository.updateProgress(id, ReaderLocation(1, 4, 1, 20, .25f)) + repository.updateProgress(id, ReaderLocation(3, 4, 3, 40, 1f)) + repository.saveSettings(id, ReaderSettings(fontSize = 28)) + repository.saveBookmarks( + id, + listOf(Bookmark("End", 3, 40, 3, 1f, 100L)), + ) + repository.saveAnnotations(id, listOf(Annotation("valid", 3, 0, 4, "Text", "Note"))) + repository.awaitPendingWrites() + + val bookDirectory = File(context.filesDir, "library/$id") + val bookmarks = JSONArray(File(bookDirectory, "bookmarks.json").readText()) + .put(JSONObject().put("id", "not-a-bookmark")) + .put("invalid") + AtomicFiles.write(File(bookDirectory, "bookmarks.json"), bookmarks.toString()) + val annotationsFile = File(bookDirectory, "annotations.json") + val annotations = JSONArray(annotationsFile.readText()).put(JSONObject()).put("invalid") + AtomicFiles.write(annotationsFile, annotations.toString()) + val state = repository.readerState(id) + + assertEquals(3, state.location.page) + assertEquals(1f, state.location.progress) + assertEquals(28, state.settings.fontSize) + assertEquals(listOf("End", "Bookmark"), state.bookmarks.map(Bookmark::label)) + assertEquals(listOf("valid"), state.annotations.map(Annotation::id)) + assertEquals(LibraryBook.ReadingState.FINISHED, repository.books().single().state) + assertFalse(bookDirectory.walk().any { it.name.endsWith(".tmp") }) + } + + @Test + fun loadsValidIndexRecordsWhenOtherRecordsAreMalformed() { + val library = File(context.filesDir, "library").apply(File::mkdirs) + val valid = LibraryBook( + id = "0123456789abcdef01234567", + title = "Valid", + author = "Author", + fileName = "valid.epub", + ) + File(library, valid.id).apply(File::mkdirs).resolve("source.epub").writeText("source") + AtomicFiles.write( + File(library, "index.json"), + JSONArray().put(valid.toJson()).put(JSONObject().put("id", "../../outside")).put("invalid").toString(), + ) + + val repository = newRepository() + + assertEquals(listOf("Valid"), repository.books().map(LibraryBook::title)) + } + + @Test + fun invalidatesReaderCacheWhenRenderedMetadataChanges() { + val repository = newRepository() + val publication = repository.import(Uri.fromFile(fixture("navigation.epub"))) + val reader = repository.readerFile(publication) + val firstVersion = File(reader.parentFile, "reader.version").readText() + val firstContent = reader.readText() + + assertEquals(reader, repository.readerFile(publication)) + assertEquals(firstContent, reader.readText()) + repository.rename(publication.book.id, "Renamed title") + val renamedBook = repository.books().single() + val renamedPublication = repository.open(renamedBook) + repository.readerFile(renamedPublication) + val secondVersion = File(reader.parentFile, "reader.version").readText() + + assertNotEquals(firstVersion, secondVersion) + assertTrue(reader.readText().contains("Renamed title")) + } + + @Test + fun failedImportRemovesStagingFilesAndLibraryDirectory() { + val repository = newRepository() + val invalid = File(context.cacheDir, "invalid.epub").apply { writeText("not an EPUB") } + + assertThrows(Exception::class.java) { + repository.import(Uri.fromFile(invalid)) + } + + assertTrue(repository.books().isEmpty()) + val library = File(context.filesDir, "library") + assertFalse(library.listFiles().orEmpty().any { it.isDirectory }) + assertFalse(context.cacheDir.listFiles().orEmpty().any { it.name.startsWith("import-") }) + } + + private fun newRepository(): LibraryRepository = LibraryRepository(context).also { repository = it } + + private fun fixture(name: String): File = + File(requireNotNull(javaClass.classLoader?.getResource(name)) { "Missing fixture: $name" }.toURI()) +} diff --git a/app/src/test/java/com/alexandria/reader/ModelsTest.kt b/app/src/test/java/com/alexandria/reader/ModelsTest.kt new file mode 100644 index 0000000..1b4eb44 --- /dev/null +++ b/app/src/test/java/com/alexandria/reader/ModelsTest.kt @@ -0,0 +1,57 @@ +package com.alexandria.reader + +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ModelsTest { + @Test + fun clampsMalformedLocationsToValidFiniteValues() { + val location = ReaderLocation.fromJson( + JSONObject("""{"page":99,"totalPages":3,"chapter":-2,"offset":-4,"progress":"NaN"}"""), + ) + + assertEquals(2, location.page) + assertEquals(3, location.totalPages) + assertEquals(0, location.chapter) + assertEquals(0, location.offset) + assertEquals(0f, location.progress) + assertTrue(location.progress.isFinite()) + } + + @Test + fun keepsValidRecordsWhenOneStoredRecordIsInvalid() { + val valid = LibraryBook( + id = "0123456789abcdef01234567", + title = "Valid", + author = "Author", + fileName = "valid.epub", + ).toJson() + val records = JSONArray().put(valid).put(JSONObject().put("id", "../../outside")) + + val books = records.mapObjects(LibraryBook::fromJson) + + assertEquals(listOf("Valid"), books.map(LibraryBook::title)) + } + + @Test + fun normalizesStoredSettingsAndAnnotations() { + val settings = ReaderSettings.fromJson( + JSONObject("""{"fontSize":400,"lineHeight":"NaN","margin":-1,"textAlign":"invalid","mode":"invalid"}"""), + ) + val annotation = Annotation.fromJson( + JSONObject("""{"id":"note","chapter":-1,"start":8,"end":3,"quote":"text"}"""), + ) + + assertEquals(38, settings.fontSize) + assertEquals(1.32f, settings.lineHeight) + assertEquals(0, settings.margin) + assertEquals("publisher", settings.textAlign) + assertEquals("paged", settings.mode) + assertEquals(0, annotation.chapter) + assertEquals(8, annotation.start) + assertEquals(8, annotation.end) + } +} diff --git a/app/src/test/java/com/alexandria/reader/PersistenceTest.kt b/app/src/test/java/com/alexandria/reader/PersistenceTest.kt new file mode 100644 index 0000000..ecaf6c8 --- /dev/null +++ b/app/src/test/java/com/alexandria/reader/PersistenceTest.kt @@ -0,0 +1,73 @@ +package com.alexandria.reader + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.ByteArrayInputStream +import java.io.File +import java.util.Collections +import java.util.concurrent.CancellationException +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +class PersistenceTest { + @get:Rule + val temporary = TemporaryFolder() + + @Test + fun atomicWritesReplaceCompleteFilesAndRemoveTemporaryFiles() { + val directory = temporary.newFolder("atomic") + val target = File(directory, "state.json") + AtomicFiles.write(target, "{\"value\":1}") + AtomicFiles.write(target, "{\"value\":2}") + + assertEquals("{\"value\":2}", target.readText()) + assertFalse(directory.listFiles().orEmpty().any { it.name.endsWith(".tmp") }) + } + + @Test + fun interruptibleInputStopsCancelledWork() { + Thread.currentThread().interrupt() + try { + val error = org.junit.Assert.assertThrows(CancellationException::class.java) { + WorkCancellation.input(ByteArrayInputStream(byteArrayOf(1))).read() + } + assertTrue(error.message?.contains("cancelled") == true) + } finally { + Thread.interrupted() + } + } + + @Test + fun coalescesQueuedValuesForTheSameStateFile() { + val target = temporary.newFile("coalesced.json") + val firstStarted = CountDownLatch(1) + val releaseFirst = CountDownLatch(1) + val writes = Collections.synchronizedList(mutableListOf()) + val writer = CoalescingFileWriter(write = { file, value -> + writes += value + if (value == "first") { + firstStarted.countDown() + check(releaseFirst.await(5, TimeUnit.SECONDS)) + } + file.writeText(value) + }) + try { + writer.enqueue(target) { "first" } + check(firstStarted.await(5, TimeUnit.SECONDS)) + writer.enqueue(target) { "second" } + writer.enqueue(target) { "latest" } + releaseFirst.countDown() + writer.await() + + assertEquals(listOf("first", "latest"), writes) + assertEquals("latest", target.readText()) + } finally { + releaseFirst.countDown() + writer.close() + } + } +} diff --git a/scripts/profile-reader.ps1 b/scripts/profile-reader.ps1 new file mode 100644 index 0000000..2432398 --- /dev/null +++ b/scripts/profile-reader.ps1 @@ -0,0 +1,118 @@ +[CmdletBinding()] +param( + [string]$Fixture, + [string]$OutputFile +) + +$ErrorActionPreference = "Stop" +if (-not $Fixture) { $Fixture = Join-Path $PSScriptRoot "..\tests\fixtures\odyssey.epub" } +if (-not $OutputFile) { $OutputFile = Join-Path $PSScriptRoot "..\verification\reader-profile.json" } +$sdk = if ($env:ANDROID_SDK_ROOT) { $env:ANDROID_SDK_ROOT } elseif ($env:ANDROID_HOME) { $env:ANDROID_HOME } else { Join-Path $env:LOCALAPPDATA "Android\Sdk" } +$adb = Join-Path $sdk "platform-tools\adb.exe" +if (-not (Test-Path $adb)) { throw "adb was not found under $sdk" } +if (-not (Test-Path $Fixture)) { throw "Fixture not found: $Fixture" } +$device = (& $adb devices | Select-String "^emulator-[0-9]+\s+device$" | Select-Object -First 1) +if (-not $device) { throw "Start the Android virtual device before profiling." } +$serial = $device.ToString().Split("`t")[0] +$outputDirectory = Split-Path -Parent $OutputFile +New-Item -ItemType Directory -Force $outputDirectory | Out-Null + +function Invoke-Adb([Parameter(ValueFromRemainingArguments=$true)][string[]]$Arguments) { + $preference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + & $adb -s $serial @Arguments + $exitCode = $LASTEXITCODE + $ErrorActionPreference = $preference + if ($exitCode -ne 0) { throw "adb failed: $($Arguments -join ' ')" } +} + +function Wait-Reader([Diagnostics.Stopwatch]$Stopwatch) { + $localDump = Join-Path $outputDirectory "profile-window.xml" + for ($attempt = 0; $attempt -lt 60; $attempt++) { + Start-Sleep -Milliseconds 250 + $preference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + & $adb -s $serial shell timeout 2 uiautomator dump /sdcard/alexandria-profile.xml 2>$null | Out-Null + $dumpExitCode = $LASTEXITCODE + if ($dumpExitCode -eq 0) { + & $adb -s $serial pull /sdcard/alexandria-profile.xml $localDump 2>$null | Out-Null + $pullExitCode = $LASTEXITCODE + } else { + $pullExitCode = 1 + } + $ErrorActionPreference = $preference + if ($pullExitCode -eq 0 -and (Get-Content $localDump -Raw) -match "Page 1 of ([2-9]|[1-9][0-9]+)") { + $Stopwatch.Stop() + Remove-Item -ErrorAction SilentlyContinue $localDump + return $Stopwatch.ElapsedMilliseconds + } + } + throw "The reader did not finish layout within the profiling timeout." +} + +function Read-Memory { + $memory = (& $adb -s $serial shell dumpsys meminfo com.alexandria.reader) -join "`n" + $totalPss = if ($memory -match "TOTAL PSS:\s+([0-9]+)") { [int]$Matches[1] } else { $null } + $javaHeap = if ($memory -match "Java Heap:\s+([0-9]+)") { [int]$Matches[1] } else { $null } + $rendererRss = 0 + (& $adb -s $serial shell ps -A -o NAME,RSS) | ForEach-Object { + if ($_ -match "webview:sandboxed_process.*\s+([0-9]+)$") { $rendererRss += [int]$Matches[1] } + } + return @{ totalPssKb = $totalPss; javaHeapKb = $javaHeap; rendererRssKb = $rendererRss } +} + +Push-Location (Join-Path $PSScriptRoot "..") +try { + & .\gradlew.bat :app:assembleDebug + if ($LASTEXITCODE -ne 0) { throw "Debug build failed." } + Invoke-Adb install '-r' '.\app\build\outputs\apk\debug\app-debug.apk' | Out-Null + Invoke-Adb shell pm clear com.alexandria.reader | Out-Null + Invoke-Adb shell am start '-W' '-n' com.alexandria.reader/.MainActivity '-a' android.intent.action.MAIN '-c' android.intent.category.LAUNCHER | Out-Null + Start-Sleep -Seconds 1 + Invoke-Adb root | Out-Null + Invoke-Adb wait-for-device + $remote = "/data/user/0/com.alexandria.reader/files/profile.epub" + $staged = "/data/local/tmp/alexandria-profile.epub" + Invoke-Adb push $Fixture $staged | Out-Null + Invoke-Adb shell run-as com.alexandria.reader cp $staged files/profile.epub | Out-Null + Invoke-Adb shell rm '-f' $staged | Out-Null + Invoke-Adb shell am force-stop com.alexandria.reader + $coldWatch = [Diagnostics.Stopwatch]::StartNew() + Invoke-Adb shell am start '-W' '-a' android.intent.action.VIEW '-d' "file://$remote" '-t' application/epub+zip com.alexandria.reader/.MainActivity | Out-Null + $coldMs = Wait-Reader $coldWatch + Start-Sleep -Milliseconds 500 + $coldMemory = Read-Memory + $readerStatBefore = ((& $adb -s $serial shell "stat -c '%i:%s:%Y' /data/user/0/com.alexandria.reader/files/library/*/reader.html") -join "").Trim() + + Invoke-Adb shell am force-stop com.alexandria.reader + Invoke-Adb shell am start '-W' '-n' com.alexandria.reader/.MainActivity '-a' android.intent.action.MAIN '-c' android.intent.category.LAUNCHER | Out-Null + Start-Sleep -Milliseconds 750 + $warmWatch = [Diagnostics.Stopwatch]::StartNew() + Invoke-Adb shell input tap 500 210 | Out-Null + $warmMs = Wait-Reader $warmWatch + Start-Sleep -Milliseconds 500 + $warmMemory = Read-Memory + $readerStatAfter = ((& $adb -s $serial shell "stat -c '%i:%s:%Y' /data/user/0/com.alexandria.reader/files/library/*/reader.html") -join "").Trim() + + $result = [ordered]@{ + fixture = (Resolve-Path $Fixture).Path + fixtureBytes = (Get-Item $Fixture).Length + coldImportAndLayoutMs = $coldMs + cachedOpenAndLayoutMs = $warmMs + coldTotalPssKb = $coldMemory.totalPssKb + coldJavaHeapKb = $coldMemory.javaHeapKb + coldWebViewRendererRssKb = $coldMemory.rendererRssKb + cachedTotalPssKb = $warmMemory.totalPssKb + cachedJavaHeapKb = $warmMemory.javaHeapKb + cachedWebViewRendererRssKb = $warmMemory.rendererRssKb + readerFile = $readerStatAfter + cacheFileUnchanged = $readerStatBefore -eq $readerStatAfter + measuredAtUtc = [DateTime]::UtcNow.ToString("o") + } + $result | ConvertTo-Json | Set-Content -Encoding utf8 $OutputFile + Write-Host "Reader profile written to $OutputFile" + $result | Format-List +} +finally { + Pop-Location +} diff --git a/scripts/verify-fixed-layout.ps1 b/scripts/verify-fixed-layout.ps1 index ea81925..9fd12d7 100644 --- a/scripts/verify-fixed-layout.ps1 +++ b/scripts/verify-fixed-layout.ps1 @@ -68,8 +68,10 @@ try { Start-Sleep -Seconds 1 Invoke-Adb root | Out-Null Invoke-Adb wait-for-device - Invoke-Adb push $Fixture /data/user/0/com.alexandria.reader/files/fixed-verification.epub | Out-Null - Invoke-Adb shell chmod 666 /data/user/0/com.alexandria.reader/files/fixed-verification.epub + $staged = "/data/local/tmp/alexandria-fixed-verification.epub" + Invoke-Adb push $Fixture $staged | Out-Null + Invoke-Adb shell run-as com.alexandria.reader cp $staged files/fixed-verification.epub | Out-Null + Invoke-Adb shell rm '-f' $staged | Out-Null Invoke-Adb logcat '-c' Invoke-Adb shell am force-stop com.alexandria.reader Invoke-Adb shell am start '-W' '-a' android.intent.action.VIEW '-d' file:///data/user/0/com.alexandria.reader/files/fixed-verification.epub '-t' application/epub+zip com.alexandria.reader/.MainActivity | Out-Null diff --git a/scripts/verify-running.ps1 b/scripts/verify-running.ps1 index 9da7bac..c1a2665 100644 --- a/scripts/verify-running.ps1 +++ b/scripts/verify-running.ps1 @@ -31,8 +31,11 @@ try { & $adb -s $serial root | Out-Null & $adb -s $serial wait-for-device $remote = "/data/user/0/com.alexandria.reader/files/verification.epub" - & $adb -s $serial push $Fixture $remote | Out-Null - & $adb -s $serial shell chmod 666 $remote + $staged = "/data/local/tmp/alexandria-verification.epub" + & $adb -s $serial push $Fixture $staged | Out-Null + & $adb -s $serial shell run-as com.alexandria.reader cp $staged files/verification.epub + if ($LASTEXITCODE -ne 0) { throw "Could not stage the fixture in private app storage." } + & $adb -s $serial shell rm -f $staged | Out-Null & $adb -s $serial logcat -c & $adb -s $serial shell am force-stop com.alexandria.reader & $adb -s $serial shell am start -W -a android.intent.action.VIEW -d "file://$remote" -t application/epub+zip com.alexandria.reader/.MainActivity | Out-Null diff --git a/scripts/verify-search-history.ps1 b/scripts/verify-search-history.ps1 index 4cfb4a6..fb709ae 100644 --- a/scripts/verify-search-history.ps1 +++ b/scripts/verify-search-history.ps1 @@ -58,8 +58,10 @@ try { Invoke-Adb root | Out-Null Invoke-Adb wait-for-device $remote = "/data/user/0/com.alexandria.reader/files/navigation-verification.epub" - Invoke-Adb push $Fixture $remote | Out-Null - Invoke-Adb shell chmod 666 $remote + $staged = "/data/local/tmp/alexandria-navigation-verification.epub" + Invoke-Adb push $Fixture $staged | Out-Null + Invoke-Adb shell run-as com.alexandria.reader cp $staged files/navigation-verification.epub | Out-Null + Invoke-Adb shell rm '-f' $staged | Out-Null Invoke-Adb logcat '-c' Invoke-Adb shell am force-stop com.alexandria.reader Invoke-Adb shell am start '-W' '-a' android.intent.action.VIEW '-d' "file://$remote" '-t' application/epub+zip com.alexandria.reader/.MainActivity | Out-Null