From f6709f8a9f4f7b19b2e13a3976dce435ead2b615 Mon Sep 17 00:00:00 2001 From: Andriy Chortkivskyy Date: Fri, 9 Oct 2026 11:39:51 +0200 Subject: [PATCH 01/13] docs: plan foldable two-page spread reading Co-Authored-By: Claude Opus 5.5 --- docs/fold-dual-page-plan.md | 164 ++++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 docs/fold-dual-page-plan.md diff --git a/docs/fold-dual-page-plan.md b/docs/fold-dual-page-plan.md new file mode 100644 index 00000000..f9e4ed00 --- /dev/null +++ b/docs/fold-dual-page-plan.md @@ -0,0 +1,164 @@ +# Foldable two-page spread — development plan + +Branch: `feature/fold-dual-page` (from upstream `main` @ `566ba0d`, v1.6.0 / versionCode 26) + +Goal: when a Galaxy Z Fold8 is unfolded and the window is horizontal (wider than tall), readers show two pages side by side like an open book. Folded (cover screen), portrait, or any phone keeps today's single-page behavior. + +## 1. Research summary + +### Foldable SDKs + +| Option | Status | Use here | +|---|---|---| +| **Jetpack WindowManager** `androidx.window:window` | Stable 1.5.1. Gives `WindowInfoTracker.windowLayoutInfo()` → `FoldingFeature` (state FLAT/HALF_OPENED, orientation, `isSeparating`, `occlusionType`, bounds) and `WindowMetricsCalculator` | **Yes** — read posture and hinge bounds; place the spread gutter on the hinge | +| `androidx.window:window-testing` | Stable, same version | **Yes** — `TestWindowLayoutInfo` / `FoldingFeature(...)` fakes for instrumented tests | +| Compose `material3-adaptive` (`currentWindowAdaptiveInfo`) | Stable 1.3.0, but needs Kotlin 2.0+ | **No** — the project is on Kotlin 1.9 / Compose compiler 1.5.14. Upgrading that is out of scope | +| Samsung-specific fold SDK | None current. Samsung's Flex-mode docs and codelab point to Jetpack WindowManager | **No** SDK. Use Samsung **Remote Test Lab** (has Z Fold8, SM-F971) and the Galaxy emulator skins for testing | +| Readium kotlin-toolkit (current 3.0.2, latest 3.4.0) | Reflowable EPUB: `EpubPreferences.columnCount` (ONE/TWO/AUTO). Fixed-layout EPUB: `spread` (AUTO is treated as *single page* — a FIXME in Readium, so set `ALWAYS`/`NEVER` explicitly). PDFium: **no spreads**. Image/comic navigator: **no spreads** | EPUB stays on 3.0.2 (everything needed is there). Comics need a custom spread view | + +Sources: developer.android.com/jetpack/androidx/releases/window, developer.android.com/develop/ui/compose/layouts/adaptive/foldables/make-your-app-fold-aware, developer.samsung.com/codelab/galaxy-z/flex-mode.html, developer.samsung.com/remote-test-lab, readium.org/kotlin-toolkit/latest/guides/navigator/preferences/. + +### Device facts that shape the design + +- **Z Fold8** (the new "wide" model): inner display 7.6", 4:3, ~2448×1848. **Unfolded with the hinge vertical, it is landscape by default.** Each half is about 1224×1848, roughly a portrait page, so it is a natural book spread with the gutter on the hinge. +- **Z Fold8 Ultra** (classic form): inner display about 2256×2504, near-square and portrait by default. The user rotates it to get a horizontal window. +- Both cover screens are under 600dp on their shortest side. Both inner screens are well over 600dp. +- The dp sizes above are unverified. Measure at runtime and don't hard-code device values. +- When flat, the hinge *should* report `FoldingFeature(FLAT, VERTICAL, isSeparating=false)`. **Don't rely on it being present.** Decide spread vs single page from window geometry, and use the fold only to place the gutter. + +### What the codebase already gives us + +- Reader activities (EPUB/PDF/comic) do **not** declare `configChanges`. Folding or unfolding the phone recreates the activity and reopens the book at the saved locator; this is the issue #47 flow, already confirmed on a device. So a spread decision taken in `onCreate` is always fresh after fold/unfold or rotation. +- EPUB hard-codes `columnCount = ColumnCount.ONE` in `readiumPreferences()` (`ReadiumEpubReaderActivity.kt:196`). It never sets `spread` for fixed-layout EPUBs. +- Paginated comics use Readium `ImageNavigatorFragment` (a `ViewPager` with one page per item). Continuous comics already have a custom Compose surface with a bounded LRU bitmap cache, a decode-at-width loader, tap zones and long-press export (`ContinuousComicReader.kt`, `ReadiumComicReaderActivity.loadContinuousComicPage`). The spread view can reuse all of that. +- PDF: upstream **deliberately removed** a custom spread renderer in PR #269 (2026-10-08, closes #193). Their stated scope: "No custom two-page spread pairing". Any PDF spread work diverges from upstream direction. +- Reader preferences are per library, stored in a versioned JSON envelope (`LibraryReaderPreferences`, `AppPreferencesStore`). The options UI is in `BookOrbitHomeScreen.kt` (~L4260–4680). +- The global orientation lock (`requestedOrientationForLock`) is honored by every reader. + +## 2. Spread trigger policy + +A pure, unit-tested function in a new file `ReaderSpreadPolicy.kt`: + +```kotlin +internal enum class ReaderSpreadMode { AUTO, OFF } // per-library preference, default AUTO + +internal data class ReaderWindowState( + val widthDp: Int, + val heightDp: Int, + val hinge: ReaderHinge? // from FoldingFeature, null when unknown +) + +internal fun shouldUseTwoPageSpread( + mode: ReaderSpreadMode, + window: ReaderWindowState, + layoutMode: ReaderLayoutMode +): Boolean = + mode == ReaderSpreadMode.AUTO && + layoutMode == ReaderLayoutMode.PAGINATED && // continuous scroll never spreads + minOf(window.widthDp, window.heightDp) >= 600 && // unfolded inner screen, not the cover screen + window.widthDp > window.heightDp && // horizontal + window.hinge?.isHorizontalSeparating != true // tabletop half-open: keep single page +``` + +- **Inputs:** + - `Configuration.screenWidthDp`/`screenHeightDp` in `onCreate`, which is fresh on every recreation. + - `WindowInfoTracker.windowLayoutInfo(activity)` collected under `repeatOnLifecycle(STARTED)`. Posture changes (FLAT ↔ HALF_OPENED) do not recreate the activity, so this flow reapplies live. +- **Multi-window/split screen:** the dp check uses the *window* size, not the display size, so a half-screen split falls back to a single page automatically. +- **Orientation lock:** if the user has locked portrait, the window is never horizontal, so there's no spread. No special case is needed. + +## 3. Phased work + +### Phase 0 — Setup (½ day) + +- [ ] Create a GitHub fork (`chortya/lagrange-reader`), push the branch, and add `upstream` as a remote. +- [ ] Run the baseline gates green: `./gradlew :app:testDebugUnitTest :app:lintDebug :app:assembleDebug`. +- [ ] Set up AVDs: "7.6\" Fold-in with outer display", Pixel 9 Pro Fold, and "Resizable (Experimental)". Install the Samsung Galaxy emulator skin for the Fold8 aspect ratio. +- [ ] Book a Remote Test Lab slot on Z Fold8 (SM-F971) for later phases if no physical device is available. + +### Phase 1 — Foundation: policy, preference, window tracking (1–2 days) + +- [ ] Add `androidx.window:window:1.5.1` and `androidx.window:window-testing` (androidTest). If AAR metadata demands compileSdk 36, either bump compileSdk (targetSdk stays 35) or pin 1.4.x. Decide at build time. +- [ ] `ReaderSpreadPolicy.kt`: `ReaderSpreadMode`, `ReaderWindowState`, `ReaderHinge` (orientation, separating, bounds in px), `shouldUseTwoPageSpread`, and `spreadGutterPx()` (hinge centre if a vertical fold exists, else window centre). +- [ ] `ReaderWindowTracker`: a small helper that each reader activity owns. It exposes `StateFlow` from Configuration plus WindowInfoTracker, so the code is written once and not three times. +- [ ] `LibraryReaderPreferences`: add `twoPageSpread: ReaderSpreadMode = AUTO` and `comicSpreadCoverAlone: Boolean = true`. Persist them in the JSON envelope with defaults when the key is absent. Legacy and malformed profiles must still load, per the architecture rule. +- [ ] Options UI (`BookOrbitHomeScreen.kt`): add a "Two-page spread on unfolded screen" toggle (EPUB + comic sections), plus a "Show cover alone" toggle for comics. +- [ ] Tests: + - `ReaderSpreadPolicyTest`: cover screen (landscape) → single; inner portrait → single; inner landscape → spread; split-screen half → single; tabletop half-open → single; OFF → single; continuous → single. + - `LibraryReaderPreferencesTest`: round-trip and legacy decode. + +### Phase 2 — EPUB (2–3 days) — highest value, lowest risk + +- [ ] Reflowable: `readiumPreferences(..., twoPageSpread: Boolean)` → `columnCount = if (spread) ColumnCount.TWO else ColumnCount.ONE`. Readium turns pages by full viewport width, so one "page turn" moves two columns. +- [ ] Fixed-layout: set `spread = if (spread) Spread.ALWAYS else Spread.NEVER`. Readium 3.x treats AUTO as single, so it must be explicit. +- [ ] Live switching: on a posture or spread change without recreation, call `navigator.submitPreferences(...)` (the existing `applyReaderPreferences` path). The locator survives. +- [ ] Gutter: + - Readium's column gap sits at the viewport centre. Keep left and right padding symmetric while in spread so the gap lands on the hinge. + - Check the Fold8 hinge is at the exact centre using `spreadGutterPx()`. + - Optionally add an inner-gutter padding preference later. +- [ ] Verify these still behave correctly in two-column mode: + - **Progress footer:** `PaginationListener` `pageIndex`/`totalPages` now count screens. Check the "page x of y" wording. + - **Resume:** positions/percentage resume and saved-instance locator restore. + - **Text to speech and read-along:** sentence highlighting and auto page-turn across columns, including TTS running across a fold/unfold. + - **Annotations:** highlight selection and action mode when a selection crosses the column boundary. + - **Image gestures:** the EPUB image gesture bridge (`epub-image-gesture.js`) for image-only pages. + - **Navigation:** tap zones and the tutorial overlay (screen-level, so unchanged), plus volume-button paging. +- [ ] Tests: + - JVM: `readiumPreferences` column/spread mapping. + - Instrumented: extend `ReaderOrientationConfigurationInstrumentedTest` and `EpubReaderOptionsOverlayInstrumentedTest`, using `window-testing` fakes and a forced landscape ≥600dp configuration. + +### Phase 3 — Comics, paginated (5–7 days) + +Readium's image navigator can't do spreads, so add a custom Compose spread pager. It is used only when the spread is active; otherwise the existing `ImageNavigatorFragment` path stays untouched. + +- [ ] **Pairing algorithm** (pure, `ComicSpreadPairing.kt`): + - `comicSpreads(pageCount, coverAlone, pageAspect: (Int) -> Float?, offset: Int): List`. + - Landscape or double-width pages (aspect > 1) stand alone. + - The cover is shown alone when enabled. + - Pages in the spread are ordered left/right by `LibraryReadingDirection`: RTL puts page *n* on the right. + - An "offset by one page" toggle realigns two-page art in manga whose pairing is off by one, as other readers offer. +- [ ] **Aspect ratios** come from the existing `continuousComicPageAspectRatios` cache, or from cheap bounds-only decodes. Until a ratio is known, a page is treated as portrait, and pairs are recomputed when a wide page is found. The current spread must stay anchored while that happens. +- [ ] **`ComicSpreadReader` composable:** + - `HorizontalPager` of spreads. + - Each half is decoded at half the window width, reusing `loadContinuousComicPage` and the LRU cache, so memory use is about the same as the single-page view. + - Gutter at the hinge. + - Bounded pinch-zoom/pan on the whole spread, reusing the `ComicPageImageViewer` limits. + - Long-press on either half exports that page through the existing path. +- [ ] **Navigation:** tap zones (`readerTapZoneAction`), volume buttons and the menu toggle move by spread. The tutorial overlay is reused. +- [ ] **Progress and resume:** + - The locator saved is the *first* page of the spread, so the spread realigns after recreation. + - Reported progress uses the *last* visible page, so the final spread reaches 100%. + - `ReadingSessionTracker` page activity is fired for both pages. +- [ ] **Fold/unfold mid-book:** recreation reopens at the saved page index. That page is in the new spread (or shown alone when folded), so no position is lost. +- [ ] Tests: + - `ComicSpreadPairingTest`: cover alone, odd/even counts, wide pages, RTL, offset, single-page book. + - Progress-mapping unit test. + - Instrumented open/rotate test, extending `ReadiumComicOpenInstrumentedTest`. + +### Phase 4 — PDF — OUT OF SCOPE (decided 2026-10-09) + +PDF keeps the native single-page PDFium navigator on every screen, consistent with upstream PR #269. The spread policy must not touch `ReadiumPdfReaderActivity`. + +If this is revisited, the path would be to reuse the Phase 3 spread pager through a `PageBitmapSource` abstraction and render pages with PdfiumCore. That would mean rebuilding zoom, snapping, `PdfHyperlinkTapHandler` hit-testing and `pdfColorMatrix` inversion, and it would be a long-lived divergence from upstream. + +### Phase 5 — Validation, docs, release (2 days) + +- [ ] Automated gates from `CONTRIBUTING.md`: unit tests, `compileDebugAndroidTestKotlin`, lint, `assembleDebug`, `git diff --check`. +- [ ] Device matrix (Fold8 first): Fold8 inner landscape = natural posture, and inner after rotating to portrait; Fold8 Ultra inner rotated to landscape; cover screen both orientations; split-screen; half-open book and tabletop postures; fold/unfold mid-chapter with TTS playing; RTL manga; orientation lock on. +- [ ] Update `docs/architecture.md` (reader section), `docs/ui-ux.md` (reader contracts), `docs/testing.md` (spread manual procedure) and `CHECKLIST.md`. +- [ ] Optional: open an upstream issue/PR for the EPUB part. It's small, self-contained and likely acceptable. Keep the comic spread pager on the fork unless upstream wants it. + +**Estimate:** about 2–2.5 weeks (Phases 0–3 plus Phase 5). + +## 4. Risks and notes + +- **Android 16 large-screen rules.** Once an app targets API 36, on sw≥600dp displays (the Fold inner screen) Android ignores `setRequestedOrientation`, orientation/aspect manifest attributes and `resizableActivity`. Upstream targets 35 today. Play now requires 36 for updates, so this will land soon, and the reader orientation lock will then stop working on the unfolded screen. The spread policy is purely geometry-based, so it keeps working. The orientation-lock UI should say it doesn't apply on large screens. +- **compileSdk bump.** The latest `androidx.window` may require compileSdk 36. That is low risk because targetSdk stays 35. +- **Near-square Ultra.** On the Ultra in landscape, each spread page is narrow (~0.55 aspect). Comics will letterbox top and bottom. EPUB columns of roughly 450–600dp are comfortable. The per-library OFF switch covers users who prefer a single page. +- **Readium upgrade not required.** 3.0.2 already has `columnCount` and `spread`. Upgrading to 3.4.0 is a separate decision; it raises compile/target SDK. +- **Upstream sync.** Keep spread code in new files (`ReaderSpreadPolicy.kt`, `ComicSpreadPairing.kt`, `ComicSpreadReader.kt`, `ReaderWindowTracker.kt`) with thin hooks into the existing activities, so rebasing on upstream stays cheap. + +## 5. Decisions (2026-10-09) + +1. **Target device: Z Fold8 (wide).** The primary posture is the natural unfolded landscape, with the hinge vertical and one page on each side of it. Test first on Fold8 (physical device or Remote Test Lab SM-F971). The Ultra and other foldables are covered by the same geometry policy but are secondary in the test matrix. +2. **PDF is out of scope.** Phase 4 is dropped, PDF keeps native single-page PDFium behavior, and there's no divergence from upstream PR #269. +3. **Work happens on a GitHub fork** (`chortya/lagrange-reader`) with `upstream` → `van-geaux/lagrange-reader`. Upstreaming the EPUB part stays optional. From 84f90aa38a8b8c7b7e89d03f8d504385f2527e13 Mon Sep 17 00:00:00 2001 From: Andriy Chortkivskyy Date: Fri, 9 Oct 2026 12:25:34 +0200 Subject: [PATCH 02/13] feat: two-page EPUB spreads on unfolded horizontal windows Add a geometry-based spread policy (sw>=600dp, horizontal, paginated, not tabletop) backed by Jetpack WindowManager posture, per-library spread preferences with options UI, and Readium two-column/fixed-layout spread configuration that bypasses ReadiumCSS's 60em media query. Co-Authored-By: Claude Opus 5.5 --- app/build.gradle.kts | 2 + .../ReadiumEpubOpenInstrumentedTest.kt | 59 ++++++ .../vangeaux/lagrange/BookOrbitHomeScreen.kt | 87 +++++++- .../lagrange/LibraryReaderPreferences.kt | 13 +- .../vangeaux/lagrange/ReaderSpreadPolicy.kt | 97 +++++++++ .../vangeaux/lagrange/ReaderWindowTracker.kt | 92 +++++++++ .../lagrange/ReadiumEpubReaderActivity.kt | 94 ++++++++- .../lagrange/EpubSpreadPreferencesTest.kt | 48 +++++ .../lagrange/LibraryReaderPreferencesTest.kt | 40 ++++ .../lagrange/ReaderSpreadPolicyTest.kt | 186 ++++++++++++++++++ 10 files changed, 702 insertions(+), 16 deletions(-) create mode 100644 app/src/main/java/com/vangeaux/lagrange/ReaderSpreadPolicy.kt create mode 100644 app/src/main/java/com/vangeaux/lagrange/ReaderWindowTracker.kt create mode 100644 app/src/test/java/com/vangeaux/lagrange/EpubSpreadPreferencesTest.kt create mode 100644 app/src/test/java/com/vangeaux/lagrange/ReaderSpreadPolicyTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7cb5f30f..17a95f73 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -143,6 +143,7 @@ dependencies { implementation("androidx.room:room-ktx:2.6.1") implementation("androidx.webkit:webkit:1.11.0") implementation("androidx.work:work-runtime-ktx:2.9.1") + implementation("androidx.window:window:1.5.1") implementation("androidx.media3:media3-exoplayer:1.4.1") implementation("androidx.media3:media3-ui:1.4.1") implementation("androidx.media3:media3-session:1.4.1") @@ -167,5 +168,6 @@ dependencies { androidTestImplementation("androidx.compose.ui:ui-test-junit4") androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") androidTestImplementation("androidx.test.ext:junit:1.2.1") + androidTestImplementation("androidx.window:window-testing:1.5.1") androidTestImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") } diff --git a/app/src/androidTest/java/com/vangeaux/lagrange/ReadiumEpubOpenInstrumentedTest.kt b/app/src/androidTest/java/com/vangeaux/lagrange/ReadiumEpubOpenInstrumentedTest.kt index df282a1a..a6982042 100644 --- a/app/src/androidTest/java/com/vangeaux/lagrange/ReadiumEpubOpenInstrumentedTest.kt +++ b/app/src/androidTest/java/com/vangeaux/lagrange/ReadiumEpubOpenInstrumentedTest.kt @@ -38,6 +38,7 @@ import org.readium.navigator.media.tts.AndroidTtsNavigatorFactory import org.readium.navigator.media.tts.TtsNavigator import org.readium.r2.navigator.epub.EpubNavigatorFragment import org.readium.r2.navigator.preferences.ColumnCount +import org.readium.r2.navigator.preferences.Spread import org.readium.r2.navigator.preferences.FontFamily import org.readium.r2.navigator.preferences.Theme import org.readium.r2.shared.ExperimentalReadiumApi @@ -453,6 +454,64 @@ class ReadiumEpubOpenInstrumentedTest { ) } + @OptIn(ExperimentalReadiumApi::class) + @Test + fun singlePagePreferencesKeepOneColumnWithoutPageMargins() { + val preferences = readiumPreferences(EpubReaderTheme.Sepia, fontScale = 1f) + + assertEquals(ColumnCount.ONE, preferences.columnCount) + assertEquals(0.0, requireNotNull(preferences.pageMargins), 0.0) + assertEquals(Spread.NEVER, preferences.spread) + assertEquals(false, preferences.scroll) + } + + @OptIn(ExperimentalReadiumApi::class) + @Test + fun paginatedSpreadRequestsTwoColumnsFixedLayoutSpreadsAndGutters() { + val preferences = readiumPreferences( + EpubReaderTheme.OledBlack, + fontScale = 1.2f, + layoutMode = ReaderLayoutMode.PAGINATED, + twoPageSpread = true + ) + + assertEquals(ColumnCount.TWO, preferences.columnCount) + assertEquals(1.0, requireNotNull(preferences.pageMargins), 0.0) + assertEquals(Spread.ALWAYS, preferences.spread) + assertEquals(false, preferences.scroll) + } + + @OptIn(ExperimentalReadiumApi::class) + @Test + fun continuousLayoutIgnoresSpreadRequest() { + val preferences = readiumPreferences( + EpubReaderTheme.Dark, + fontScale = 1f, + layoutMode = ReaderLayoutMode.CONTINUOUS, + twoPageSpread = true + ) + + assertEquals(ColumnCount.ONE, preferences.columnCount) + assertEquals(0.0, requireNotNull(preferences.pageMargins), 0.0) + assertEquals(Spread.NEVER, preferences.spread) + assertEquals(true, preferences.scroll) + } + + @OptIn(ExperimentalReadiumApi::class) + @Test + fun spreadLeavesTypographyPreferencesUnchanged() { + fun preferences(spread: Boolean) = readiumPreferences( + EpubReaderTheme.Light, + fontScale = 1.4f, + lineSpacing = 1.5f, + fontFamily = EpubReaderFontFamily.SYSTEM_SERIF, + wordSpacing = 0.3f, + twoPageSpread = spread + ).copy(columnCount = null, pageMargins = null, spread = null) + + assertEquals(preferences(false), preferences(true)) + } + @Test fun injectsCustomFontIntoAReaderCopyWithoutChangingTheSource() { val context = ApplicationProvider.getApplicationContext() diff --git a/app/src/main/java/com/vangeaux/lagrange/BookOrbitHomeScreen.kt b/app/src/main/java/com/vangeaux/lagrange/BookOrbitHomeScreen.kt index a21fc8d0..8a70be92 100644 --- a/app/src/main/java/com/vangeaux/lagrange/BookOrbitHomeScreen.kt +++ b/app/src/main/java/com/vangeaux/lagrange/BookOrbitHomeScreen.kt @@ -4282,6 +4282,15 @@ private fun LibraryReaderConfiguration( ) } ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Text("Unfolded screens", style = MaterialTheme.typography.titleMedium) + ReaderSpreadSettings( + value = value, + layoutMode = ReaderLayoutMode.PAGINATED, + testTagPrefix = "options-reading", + showComicOptions = true, + onPreferencesChange = { next -> onPreferencesChange(selectedLibrary.id, next) } + ) } HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) } @@ -4630,6 +4639,13 @@ internal fun ReaderConfigurationControls( testTagPrefix = "$testTagPrefix-epub", onLayoutModeChange = { onPreferencesChange(value.copy(epubLayoutMode = it)) } ) + ReaderSpreadSettings( + value = value, + layoutMode = value.epubLayoutMode, + testTagPrefix = "$testTagPrefix-epub", + showComicOptions = false, + onPreferencesChange = onPreferencesChange + ) Text("Page margins", style = MaterialTheme.typography.titleMedium) listOf( "Top" to value.padding.top, @@ -4669,13 +4685,70 @@ internal fun ReaderConfigurationControls( invertColors = value.invertPdfColors, onInvertColorsChange = { onPreferencesChange(value.copy(invertPdfColors = it)) } ) - ReaderConfigurationFormat.COMIC -> ReaderFormatLayoutSettings( - formatLabel = "CBR/CBZ", - layoutMode = value.comicLayoutMode, - pageGapDp = value.comicPageGapDp, - testTagPrefix = "$testTagPrefix-comic", - onLayoutModeChange = { onPreferencesChange(value.copy(comicLayoutMode = it)) }, - onPageGapChange = { onPreferencesChange(value.copy(comicPageGapDp = it)) } + ReaderConfigurationFormat.COMIC -> { + ReaderFormatLayoutSettings( + formatLabel = "CBR/CBZ", + layoutMode = value.comicLayoutMode, + pageGapDp = value.comicPageGapDp, + testTagPrefix = "$testTagPrefix-comic", + onLayoutModeChange = { onPreferencesChange(value.copy(comicLayoutMode = it)) }, + onPageGapChange = { onPreferencesChange(value.copy(comicPageGapDp = it)) } + ) + ReaderSpreadSettings( + value = value, + layoutMode = value.comicLayoutMode, + testTagPrefix = "$testTagPrefix-comic", + showComicOptions = true, + onPreferencesChange = onPreferencesChange + ) + } + } +} + +@Composable +private fun ReaderSpreadSettings( + value: LibraryReaderPreferences, + layoutMode: ReaderLayoutMode, + testTagPrefix: String, + showComicOptions: Boolean, + onPreferencesChange: (LibraryReaderPreferences) -> Unit +) { + val spreadEnabled = value.twoPageSpread == ReaderSpreadMode.AUTO + AppPreferenceSwitchRow( + title = "Two-page spread when unfolded", + summary = if (layoutMode == ReaderLayoutMode.PAGINATED) { + "On a large screen held horizontally, show two pages side by side" + } else { + "Applies to paginated layout on a large screen held horizontally" + }, + checked = spreadEnabled, + testTag = "$testTagPrefix-two-page-spread", + onCheckedChange = { enabled -> + onPreferencesChange( + value.copy(twoPageSpread = if (enabled) ReaderSpreadMode.AUTO else ReaderSpreadMode.OFF) + ) + } + ) + if (showComicOptions) { + AppPreferenceSwitchRow( + title = "Show cover alone", + summary = "Keep the first page by itself so later pages pair like a printed book", + checked = value.comicSpreadCoverAlone, + enabled = spreadEnabled, + testTag = "$testTagPrefix-spread-cover-alone", + onCheckedChange = { enabled -> + onPreferencesChange(value.copy(comicSpreadCoverAlone = enabled)) + } + ) + AppPreferenceSwitchRow( + title = "Shift page pairs", + summary = "Move pairs by one page when two-page artwork is split across spreads", + checked = value.comicSpreadShift, + enabled = spreadEnabled, + testTag = "$testTagPrefix-spread-shift", + onCheckedChange = { enabled -> + onPreferencesChange(value.copy(comicSpreadShift = enabled)) + } ) } } diff --git a/app/src/main/java/com/vangeaux/lagrange/LibraryReaderPreferences.kt b/app/src/main/java/com/vangeaux/lagrange/LibraryReaderPreferences.kt index c08b679a..13f7428d 100644 --- a/app/src/main/java/com/vangeaux/lagrange/LibraryReaderPreferences.kt +++ b/app/src/main/java/com/vangeaux/lagrange/LibraryReaderPreferences.kt @@ -65,7 +65,10 @@ data class LibraryReaderPreferences( val joinPdfFacingPages: Boolean = false, val invertPdfColors: Boolean = false, val comicLayoutMode: ReaderLayoutMode = ReaderLayoutMode.PAGINATED, - val comicPageGapDp: Float = DEFAULT_READER_PAGE_GAP_DP + val comicPageGapDp: Float = DEFAULT_READER_PAGE_GAP_DP, + val twoPageSpread: ReaderSpreadMode = ReaderSpreadMode.AUTO, + val comicSpreadCoverAlone: Boolean = true, + val comicSpreadShift: Boolean = false ) { fun normalized(): LibraryReaderPreferences = copy( fontScale = fontScale.coerceIn(MIN_EPUB_FONT_SCALE, MAX_EPUB_FONT_SCALE), @@ -180,6 +183,9 @@ private fun libraryReaderPreferenceStorageValue(value: LibraryReaderPreferences) put("invertPdfColors", normalized.invertPdfColors) put("comicLayout", readerLayoutModeStorageValue(normalized.comicLayoutMode)) put("comicPageGapDp", normalized.comicPageGapDp.toDouble()) + put("twoPageSpread", readerSpreadModeStorageValue(normalized.twoPageSpread)) + put("comicSpreadCoverAlone", normalized.comicSpreadCoverAlone) + put("comicSpreadShift", normalized.comicSpreadShift) } } @@ -238,7 +244,10 @@ internal fun libraryReaderPreferencesFromStorage(value: String?): Map put(libraryId, decoded) } } diff --git a/app/src/main/java/com/vangeaux/lagrange/ReaderSpreadPolicy.kt b/app/src/main/java/com/vangeaux/lagrange/ReaderSpreadPolicy.kt new file mode 100644 index 00000000..3af4a9bd --- /dev/null +++ b/app/src/main/java/com/vangeaux/lagrange/ReaderSpreadPolicy.kt @@ -0,0 +1,97 @@ +package com.vangeaux.lagrange + +import android.content.res.Configuration + +enum class ReaderSpreadMode(val displayName: String) { + AUTO("Automatic"), + OFF("Off") +} + +/** Smallest window side, in dp, that identifies an unfolded inner display rather than a cover screen. */ +internal const val READER_SPREAD_MIN_SHORT_SIDE_DP = 600 + +enum class ReaderHingeOrientation { VERTICAL, HORIZONTAL } + +/** Provider-neutral projection of a Jetpack WindowManager `FoldingFeature`, in window pixels. */ +internal data class ReaderHinge( + val orientation: ReaderHingeOrientation, + val isHalfOpened: Boolean, + val isSeparating: Boolean, + val leftPx: Int, + val rightPx: Int +) + +internal data class ReaderWindowState( + val widthDp: Int, + val heightDp: Int, + val hinge: ReaderHinge? = null +) { + val isHorizontal: Boolean get() = widthDp > heightDp + val shortSideDp: Int get() = minOf(widthDp, heightDp) + + companion object { + fun fromConfiguration(configuration: Configuration, hinge: ReaderHinge? = null) = + ReaderWindowState( + widthDp = configuration.screenWidthDp, + heightDp = configuration.screenHeightDp, + hinge = hinge + ) + } +} + +/** + * Two-page spreads are shown only on a large (unfolded) window held horizontally, in paginated + * layout. Window size, not display size, is used so split-screen falls back to one page. A + * half-opened horizontal hinge (tabletop posture) keeps one page because the spread would straddle + * the fold. + */ +internal fun shouldUseTwoPageSpread( + mode: ReaderSpreadMode, + window: ReaderWindowState, + layoutMode: ReaderLayoutMode +): Boolean { + if (mode == ReaderSpreadMode.OFF) return false + if (layoutMode != ReaderLayoutMode.PAGINATED) return false + if (window.widthDp <= 0 || window.heightDp <= 0) return false + if (window.shortSideDp < READER_SPREAD_MIN_SHORT_SIDE_DP) return false + if (!window.isHorizontal) return false + val hinge = window.hinge + if (hinge != null && hinge.orientation == ReaderHingeOrientation.HORIZONTAL && hinge.isHalfOpened) { + return false + } + return true +} + +/** + * Horizontal pixel span reserved between the two pages of a spread. A separating vertical hinge + * (half-opened book posture or a dual-screen device) is avoided exactly; otherwise the gutter is + * centred on the window with [minimumGapPx] width. + */ +internal fun readerSpreadGutterPx( + windowWidthPx: Int, + hinge: ReaderHinge?, + minimumGapPx: Int +): IntRange { + val width = windowWidthPx.coerceAtLeast(0) + val gap = minimumGapPx.coerceIn(0, width) + if ( + hinge != null && + hinge.orientation == ReaderHingeOrientation.VERTICAL && + hinge.isSeparating && + hinge.rightPx > hinge.leftPx && + hinge.leftPx > 0 && + hinge.rightPx < width + ) { + val hingeWidth = hinge.rightPx - hinge.leftPx + if (hingeWidth >= gap) return hinge.leftPx until hinge.rightPx + val centre = (hinge.leftPx + hinge.rightPx) / 2 + return (centre - gap / 2) until (centre - gap / 2 + gap) + } + val start = (width - gap) / 2 + return start until start + gap +} + +internal fun readerSpreadModeStorageValue(value: ReaderSpreadMode): String = value.name.lowercase() + +internal fun readerSpreadModeFromStorage(value: String?): ReaderSpreadMode = + if (value?.trim()?.lowercase() == "off") ReaderSpreadMode.OFF else ReaderSpreadMode.AUTO diff --git a/app/src/main/java/com/vangeaux/lagrange/ReaderWindowTracker.kt b/app/src/main/java/com/vangeaux/lagrange/ReaderWindowTracker.kt new file mode 100644 index 00000000..28222a34 --- /dev/null +++ b/app/src/main/java/com/vangeaux/lagrange/ReaderWindowTracker.kt @@ -0,0 +1,92 @@ +package com.vangeaux.lagrange + +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.window.layout.FoldingFeature +import androidx.window.layout.WindowInfoTracker +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +internal const val READER_SPREAD_POSTURE_DEBOUNCE_MILLIS = 400L + +internal fun readerHingeFrom( + isVertical: Boolean, + isHalfOpened: Boolean, + isSeparating: Boolean, + leftPx: Int, + rightPx: Int +): ReaderHinge = ReaderHinge( + orientation = if (isVertical) ReaderHingeOrientation.VERTICAL else ReaderHingeOrientation.HORIZONTAL, + isHalfOpened = isHalfOpened, + isSeparating = isSeparating, + leftPx = leftPx, + rightPx = rightPx +) + +internal fun FoldingFeature.toReaderHinge(): ReaderHinge = readerHingeFrom( + isVertical = orientation == FoldingFeature.Orientation.VERTICAL, + isHalfOpened = state == FoldingFeature.State.HALF_OPENED, + isSeparating = isSeparating, + leftPx = bounds.left, + rightPx = bounds.right +) + +/** + * Activity-scoped window geometry for reader spread decisions. Size changes recreate reader + * activities, so the initial configuration snapshot is authoritative for size; the WindowManager + * flow adds hinge posture, which can change without recreation. + */ +internal class ReaderWindowTracker(private val activity: FragmentActivity) { + // Seeded with the last hinge seen in this process so a recreated reader starts from the + // current posture instead of "no hinge"; otherwise a posture-driven recreation could loop. + private val mutableState = MutableStateFlow( + ReaderWindowState.fromConfiguration(activity.resources.configuration, lastKnownHinge) + ) + val state: StateFlow = mutableState.asStateFlow() + + fun start() { + activity.lifecycleScope.launch { + activity.repeatOnLifecycle(Lifecycle.State.STARTED) { + WindowInfoTracker.getOrCreate(activity) + .windowLayoutInfo(activity) + .collect { info -> + val hinge = info.displayFeatures + .filterIsInstance() + .firstOrNull() + ?.toReaderHinge() + lastKnownHinge = hinge + mutableState.value = ReaderWindowState.fromConfiguration( + activity.resources.configuration, + hinge + ) + } + } + } + } + + /** + * Emits only when [decide] flips after the initial state, debounced so a hinge moving through + * intermediate postures does not recreate the reader repeatedly. + */ + @OptIn(FlowPreview::class) + fun spreadChanges(decide: () -> Boolean): Flow = state + .map { decide() } + .distinctUntilChanged() + .drop(1) + .debounce(READER_SPREAD_POSTURE_DEBOUNCE_MILLIS) + + companion object { + @Volatile + private var lastKnownHinge: ReaderHinge? = null + } +} diff --git a/app/src/main/java/com/vangeaux/lagrange/ReadiumEpubReaderActivity.kt b/app/src/main/java/com/vangeaux/lagrange/ReadiumEpubReaderActivity.kt index 9d205e7d..87fec753 100644 --- a/app/src/main/java/com/vangeaux/lagrange/ReadiumEpubReaderActivity.kt +++ b/app/src/main/java/com/vangeaux/lagrange/ReadiumEpubReaderActivity.kt @@ -86,7 +86,11 @@ import org.readium.r2.navigator.epub.EpubPreferences import org.readium.r2.navigator.input.InputListener import org.readium.r2.navigator.input.TapEvent import org.readium.r2.navigator.preferences.Color as ReadiumColor +import org.readium.r2.navigator.epub.css.ColCount as ReadiumCssColCount +import org.readium.r2.navigator.epub.css.Length as ReadiumCssLength +import org.readium.r2.navigator.epub.css.RsProperties import org.readium.r2.navigator.preferences.ColumnCount +import org.readium.r2.navigator.preferences.Spread import org.readium.r2.navigator.preferences.FontFamily as ReadiumFontFamily import org.readium.r2.navigator.preferences.Theme as ReadiumTheme import org.readium.r2.navigator.preferences.ReadingProgression as ReadiumReadingProgression @@ -162,6 +166,42 @@ internal fun readiumFontFamily(fontFamily: EpubReaderFontFamily): ReadiumFontFam EpubReaderFontFamily.CUSTOM -> null } +/** CSS px of Readium page gutter on each side of a spread column; the pages are 2x this apart. */ +internal const val EPUB_SPREAD_PAGE_GUTTER_CSS_PX = 24.0 + +internal fun epubSpreadActive(twoPageSpread: Boolean, layoutMode: ReaderLayoutMode): Boolean = + twoPageSpread && layoutMode == ReaderLayoutMode.PAGINATED + +/** + * ReadiumCSS only honours a two-column request above a 60em viewport. Inline Readium CSS + * properties take precedence over that media query, so spreads also work on unfolded phones whose + * inner display is narrower than 960 CSS px. Column gap stays zero to keep Readium's + * viewport-width pagination exact; the gap between pages comes from the per-column page gutter. + */ +@OptIn(ExperimentalReadiumApi::class) +internal fun epubSpreadRsProperties(twoPageSpread: Boolean): RsProperties = if (twoPageSpread) { + RsProperties( + colCount = ReadiumCssColCount.TWO, + pageGutter = ReadiumCssLength.Px(EPUB_SPREAD_PAGE_GUTTER_CSS_PX), + overrides = mapOf("--RS__colWidth" to "auto") + ) +} else { + RsProperties() +} + +/** Left/right reader padding percentages; a spread uses their mean so the gutter stays centred. */ +internal fun epubHorizontalPaddingPercents( + left: Float, + right: Float, + twoPageSpread: Boolean +): Pair { + val safeLeft = left.coerceIn(0f, 100f) + val safeRight = right.coerceIn(0f, 100f) + if (!twoPageSpread) return safeLeft to safeRight + val mean = (safeLeft + safeRight) / 2f + return mean to mean +} + internal fun readiumEpubLineHeight(lineSpacing: Float): Double = lineSpacing .coerceIn(DEFAULT_EPUB_LINE_SPACING, MAX_EPUB_LINE_SPACING) .toDouble() @@ -178,7 +218,8 @@ internal fun readiumPreferences( readingDirection: LibraryReadingDirection = LibraryReadingDirection.LEFT_TO_RIGHT, layoutMode: ReaderLayoutMode = ReaderLayoutMode.PAGINATED, fontFamily: EpubReaderFontFamily = EpubReaderFontFamily.PUBLISHER_DEFAULT, - wordSpacing: Float = DEFAULT_EPUB_WORD_SPACING + wordSpacing: Float = DEFAULT_EPUB_WORD_SPACING, + twoPageSpread: Boolean = false ): EpubPreferences = EpubPreferences( backgroundColor = ReadiumColor(theme.backgroundColor), textColor = ReadiumColor(cssHexColorInt(theme.foregroundCss)), @@ -192,8 +233,11 @@ internal fun readiumPreferences( wordSpacing = readiumEpubWordSpacing(wordSpacing), fontFamily = readiumFontFamily(fontFamily), readingProgression = readiumEpubReadingProgression(readingDirection), - pageMargins = 0.0, - columnCount = ColumnCount.ONE, + // Spreads give each column its own Readium page gutter so the two pages never touch. + pageMargins = if (epubSpreadActive(twoPageSpread, layoutMode)) 1.0 else 0.0, + columnCount = if (epubSpreadActive(twoPageSpread, layoutMode)) ColumnCount.TWO else ColumnCount.ONE, + // Fixed-layout only. Readium 3.x treats Spread.AUTO as single page, so it must be explicit. + spread = if (epubSpreadActive(twoPageSpread, layoutMode)) Spread.ALWAYS else Spread.NEVER, scroll = layoutMode == ReaderLayoutMode.CONTINUOUS, // Readium only applies lineHeight and the other user CSS overrides when // publisher styles are disabled. @@ -586,6 +630,9 @@ class ReadiumEpubReaderActivity : FragmentActivity() { private var readingDirection by mutableStateOf(LibraryReadingDirection.LEFT_TO_RIGHT) private var epubLayoutMode by mutableStateOf(ReaderLayoutMode.PAGINATED) private var readerPreferences by mutableStateOf(LibraryReaderPreferences()) + private val windowTracker by lazy { ReaderWindowTracker(this) } + // Fixed for the navigator's lifetime: Readium CSS properties are set at fragment creation. + private var twoPageSpreadActive = false private var showTapZoneTutorialAfterChanges by mutableStateOf(true) private var chapterTitles by mutableStateOf(emptyList()) private var currentChapter by mutableStateOf(0) @@ -737,6 +784,12 @@ class ReadiumEpubReaderActivity : FragmentActivity() { appPreferencesStore.save( appPreferences.withReaderPreferences(libraryId, readerPreferences) ) + twoPageSpreadActive = computeTwoPageSpread() + windowTracker.start() + lifecycleScope.launch { + windowTracker.spreadChanges { computeTwoPageSpread() } + .collect { recreateIfSpreadChanged() } + } configureSystemBars() createReaderViews() bindTtsService() @@ -1154,10 +1207,12 @@ class ReadiumEpubReaderActivity : FragmentActivity() { readingDirection, epubLayoutMode, selectedFontFamily, - readerPreferences.wordSpacing + readerPreferences.wordSpacing, + twoPageSpreadActive ), paginationListener = paginationListener, configuration = EpubNavigatorFragment.Configuration( + readiumCssRsProperties = epubSpreadRsProperties(twoPageSpreadActive), shouldApplyInsetsPadding = false, selectionActionModeCallback = if ( epubAnnotationFeaturesEnabled(intent) || @@ -2627,7 +2682,8 @@ class ReadiumEpubReaderActivity : FragmentActivity() { normalized.readingDirection, normalized.epubLayoutMode, normalized.fontFamily, - normalized.wordSpacing + normalized.wordSpacing, + twoPageSpreadActive ) ) val current = appPreferencesStore.read() @@ -2635,9 +2691,28 @@ class ReadiumEpubReaderActivity : FragmentActivity() { current.withReaderPreferences(libraryId, normalized) ) configureSystemBars() + if (recreateIfSpreadChanged()) return if (tapZoneChanged && showTapZoneTutorialAfterChanges) showTapZoneTutorial() } + private fun computeTwoPageSpread(): Boolean = shouldUseTwoPageSpread( + mode = readerPreferences.twoPageSpread, + window = windowTracker.state.value, + layoutMode = epubLayoutMode + ) + + /** + * Window size changes already recreate this activity. Posture or preference changes that flip + * the spread decision recreate it too, reopening at the saved locator, because Readium CSS + * column properties cannot be changed on a live navigator. + */ + private fun recreateIfSpreadChanged(): Boolean { + if (isFinishing || isDestroyed) return false + if (computeTwoPageSpread() == twoPageSpreadActive) return false + recreate() + return true + } + private fun updateShowTapZoneTutorialAfterChanges(enabled: Boolean) { showTapZoneTutorialAfterChanges = enabled appPreferencesStore.save( @@ -2658,10 +2733,15 @@ class ReadiumEpubReaderActivity : FragmentActivity() { val topMargin = (viewOnlyBannerView.layoutParams as? FrameLayout.LayoutParams)?.topMargin ?: 0 occupiedPreviewBannerBottom(topMargin, viewOnlyBannerView.height) } else 0 + val (leftPercent, rightPercent) = epubHorizontalPaddingPercents( + padding.left, + padding.right, + epubSpreadActive(twoPageSpreadActive, epubLayoutMode) + ) readerContainer.setPadding( - horizontal(padding.left), + horizontal(leftPercent), effectiveReaderTopSpace(normalTopPadding, occupiedBannerBottom, isPreview), - horizontal(padding.right), + horizontal(rightPercent), vertical(padding.bottom) + dpToPx(EPUB_READER_PROGRESS_FOOTER_HEIGHT_DP) ) } diff --git a/app/src/test/java/com/vangeaux/lagrange/EpubSpreadPreferencesTest.kt b/app/src/test/java/com/vangeaux/lagrange/EpubSpreadPreferencesTest.kt new file mode 100644 index 00000000..39a65f56 --- /dev/null +++ b/app/src/test/java/com/vangeaux/lagrange/EpubSpreadPreferencesTest.kt @@ -0,0 +1,48 @@ +package com.vangeaux.lagrange + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.readium.r2.shared.ExperimentalReadiumApi + +@OptIn(ExperimentalReadiumApi::class) +class EpubSpreadPreferencesTest { + @Test + fun `spread css properties force two columns independent of the 60em media query`() { + val css = epubSpreadRsProperties(twoPageSpread = true).toCssProperties() + .filterValues { it != null } + + assertEquals("2", css["--RS__colCount"]) + assertEquals("auto", css["--RS__colWidth"]) + assertEquals("24px", css["--RS__pageGutter"]) + // A column gap would break Readium's viewport-width pagination. + assertFalse(css.containsKey("--RS__colGap")) + assertEquals(setOf("--RS__colCount", "--RS__colWidth", "--RS__pageGutter"), css.keys) + } + + @Test + fun `single page css properties are Readium defaults`() { + assertTrue( + epubSpreadRsProperties(twoPageSpread = false).toCssProperties() + .filterValues { it != null } + .isEmpty() + ) + } + + @Test + fun `spread is active only for paginated layout`() { + assertTrue(epubSpreadActive(true, ReaderLayoutMode.PAGINATED)) + assertFalse(epubSpreadActive(true, ReaderLayoutMode.CONTINUOUS)) + assertFalse(epubSpreadActive(false, ReaderLayoutMode.PAGINATED)) + assertFalse(epubSpreadActive(false, ReaderLayoutMode.CONTINUOUS)) + } + + @Test + fun `spread padding uses the mean of left and right so the gutter stays centred`() { + assertEquals(10f to 30f, epubHorizontalPaddingPercents(10f, 30f, twoPageSpread = false)) + assertEquals(20f to 20f, epubHorizontalPaddingPercents(10f, 30f, twoPageSpread = true)) + assertEquals(0f to 100f, epubHorizontalPaddingPercents(-5f, 150f, twoPageSpread = false)) + assertEquals(50f to 50f, epubHorizontalPaddingPercents(-5f, 150f, twoPageSpread = true)) + } +} diff --git a/app/src/test/java/com/vangeaux/lagrange/LibraryReaderPreferencesTest.kt b/app/src/test/java/com/vangeaux/lagrange/LibraryReaderPreferencesTest.kt index 68d19b49..8bc94e4f 100644 --- a/app/src/test/java/com/vangeaux/lagrange/LibraryReaderPreferencesTest.kt +++ b/app/src/test/java/com/vangeaux/lagrange/LibraryReaderPreferencesTest.kt @@ -134,6 +134,46 @@ class LibraryReaderPreferencesTest { assertFalse(decoded.volumeButtonPageNavigation) } + @Test + fun `spread preferences round trip through storage`() { + val profile = LibraryReaderPreferences( + twoPageSpread = ReaderSpreadMode.OFF, + comicSpreadCoverAlone = false, + comicSpreadShift = true + ) + + val decoded = libraryReaderPreferencesFromStorage( + libraryReaderPreferencesStorageValue(mapOf("manga" to profile)) + ).getValue("manga") + + assertEquals(profile, decoded) + assertEquals(ReaderSpreadMode.OFF, decoded.twoPageSpread) + assertFalse(decoded.comicSpreadCoverAlone) + assertTrue(decoded.comicSpreadShift) + } + + @Test + fun `legacy profiles without spread keys default to automatic spreads with cover alone`() { + val decoded = libraryReaderPreferencesFromStorage( + """{"version":2,"profiles":{"library":{"theme":"oled_black"}}}""" + ).getValue("library") + + assertEquals(ReaderSpreadMode.AUTO, decoded.twoPageSpread) + assertTrue(decoded.comicSpreadCoverAlone) + assertFalse(decoded.comicSpreadShift) + assertEquals(EpubReaderTheme.OledBlack, decoded.theme) + } + + @Test + fun `unknown spread mode value falls back to automatic without dropping the profile`() { + val decoded = libraryReaderPreferencesFromStorage( + """{"version":2,"profiles":{"library":{"twoPageSpread":"sideways","comicSpreadShift":true}}}""" + ).getValue("library") + + assertEquals(ReaderSpreadMode.AUTO, decoded.twoPageSpread) + assertTrue(decoded.comicSpreadShift) + } + @Test fun `global navigation bar policy applies uniformly to supported readers`() { assertTrue(AppPreferences().hideNavigationBarWhileReading) diff --git a/app/src/test/java/com/vangeaux/lagrange/ReaderSpreadPolicyTest.kt b/app/src/test/java/com/vangeaux/lagrange/ReaderSpreadPolicyTest.kt new file mode 100644 index 00000000..783d1529 --- /dev/null +++ b/app/src/test/java/com/vangeaux/lagrange/ReaderSpreadPolicyTest.kt @@ -0,0 +1,186 @@ +package com.vangeaux.lagrange + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ReaderSpreadPolicyTest { + // Representative window sizes (dp). Fold8 inner display is 4:3 and landscape when unfolded. + private val fold8InnerLandscape = ReaderWindowState(widthDp = 933, heightDp = 680) + private val fold8InnerLandscapeLowDensity = ReaderWindowState(widthDp = 1224, heightDp = 900) + private val fold8InnerPortrait = ReaderWindowState(widthDp = 704, heightDp = 909) + private val fold8CoverPortrait = ReaderWindowState(widthDp = 384, heightDp = 640) + private val fold8CoverLandscape = ReaderWindowState(widthDp = 712, heightDp = 360) + private val fold8InnerSplitHalf = ReaderWindowState(widthDp = 462, heightDp = 680) + private val fold8UltraLandscape = ReaderWindowState(widthDp = 960, heightDp = 840) + private val phoneLandscape = ReaderWindowState(widthDp = 915, heightDp = 388) + private val tabletLandscape = ReaderWindowState(widthDp = 1280, heightDp = 776) + + private fun spread( + window: ReaderWindowState, + mode: ReaderSpreadMode = ReaderSpreadMode.AUTO, + layout: ReaderLayoutMode = ReaderLayoutMode.PAGINATED + ) = shouldUseTwoPageSpread(mode, window, layout) + + @Test + fun `unfolded Fold8 in its natural landscape posture shows a spread`() { + assertTrue(spread(fold8InnerLandscape)) + assertTrue(spread(fold8InnerLandscapeLowDensity)) + } + + @Test + fun `unfolded Fold8 rotated to portrait keeps a single page`() { + assertFalse(spread(fold8InnerPortrait)) + } + + @Test + fun `cover screen never shows a spread in either orientation`() { + assertFalse(spread(fold8CoverPortrait)) + assertFalse(spread(fold8CoverLandscape)) + } + + @Test + fun `phone landscape is wide but too short for a spread`() { + assertFalse(spread(phoneLandscape)) + } + + @Test + fun `split screen half of the inner display keeps a single page`() { + assertFalse(spread(fold8InnerSplitHalf)) + } + + @Test + fun `other large horizontal windows also spread`() { + assertTrue(spread(fold8UltraLandscape)) + assertTrue(spread(tabletLandscape)) + } + + @Test + fun `square window does not spread`() { + assertFalse(spread(ReaderWindowState(widthDp = 800, heightDp = 800))) + } + + @Test + fun `short side boundary is inclusive at 600dp`() { + assertTrue(spread(ReaderWindowState(widthDp = 900, heightDp = READER_SPREAD_MIN_SHORT_SIDE_DP))) + assertFalse(spread(ReaderWindowState(widthDp = 900, heightDp = READER_SPREAD_MIN_SHORT_SIDE_DP - 1))) + } + + @Test + fun `off preference always keeps a single page`() { + assertFalse(spread(fold8InnerLandscape, mode = ReaderSpreadMode.OFF)) + assertFalse(spread(tabletLandscape, mode = ReaderSpreadMode.OFF)) + } + + @Test + fun `continuous layout never spreads`() { + assertFalse(spread(fold8InnerLandscape, layout = ReaderLayoutMode.CONTINUOUS)) + } + + @Test + fun `unknown or zero window size never spreads`() { + assertFalse(spread(ReaderWindowState(widthDp = 0, heightDp = 0))) + assertFalse(spread(ReaderWindowState(widthDp = 1000, heightDp = 0))) + } + + @Test + fun `flat vertical hinge keeps the spread`() { + val window = fold8InnerLandscape.copy( + hinge = readerHingeFrom( + isVertical = true, + isHalfOpened = false, + isSeparating = false, + leftPx = 1224, + rightPx = 1224 + ) + ) + assertTrue(spread(window)) + } + + @Test + fun `half opened book posture keeps the spread`() { + val window = fold8InnerLandscape.copy( + hinge = readerHingeFrom( + isVertical = true, + isHalfOpened = true, + isSeparating = true, + leftPx = 1224, + rightPx = 1224 + ) + ) + assertTrue(spread(window)) + } + + @Test + fun `half opened tabletop posture keeps a single page`() { + val window = tabletLandscape.copy( + hinge = readerHingeFrom( + isVertical = false, + isHalfOpened = true, + isSeparating = true, + leftPx = 0, + rightPx = 2560 + ) + ) + assertFalse(spread(window)) + } + + @Test + fun `flat horizontal hinge does not block a spread`() { + val window = tabletLandscape.copy( + hinge = readerHingeFrom( + isVertical = false, + isHalfOpened = false, + isSeparating = false, + leftPx = 0, + rightPx = 2560 + ) + ) + assertTrue(spread(window)) + } + + @Test + fun `gutter is centred when there is no separating hinge`() { + assertEquals(1214 until 1234, readerSpreadGutterPx(2448, hinge = null, minimumGapPx = 20)) + val flatHinge = readerHingeFrom(true, false, false, 1224, 1224) + assertEquals(1214 until 1234, readerSpreadGutterPx(2448, flatHinge, minimumGapPx = 20)) + } + + @Test + fun `gutter covers a wide separating vertical hinge exactly`() { + val hinge = readerHingeFrom(true, true, true, 1300, 1380) + assertEquals(1300 until 1380, readerSpreadGutterPx(2700, hinge, minimumGapPx = 20)) + } + + @Test + fun `gutter is centred on a narrow separating hinge with the minimum gap`() { + val hinge = readerHingeFrom(true, true, true, 1220, 1228) + assertEquals(1214 until 1234, readerSpreadGutterPx(2448, hinge, minimumGapPx = 20)) + } + + @Test + fun `horizontal or out of bounds hinges are ignored for the gutter`() { + val horizontal = readerHingeFrom(false, true, true, 0, 2448) + assertEquals(1214 until 1234, readerSpreadGutterPx(2448, horizontal, minimumGapPx = 20)) + val outside = readerHingeFrom(true, true, true, 2500, 2600) + assertEquals(1214 until 1234, readerSpreadGutterPx(2448, outside, minimumGapPx = 20)) + } + + @Test + fun `zero gap gutter is empty and gap never exceeds window`() { + assertTrue(readerSpreadGutterPx(2448, null, minimumGapPx = 0).isEmpty()) + assertEquals(0 until 10, readerSpreadGutterPx(10, null, minimumGapPx = 50)) + assertTrue(readerSpreadGutterPx(-5, null, minimumGapPx = 50).isEmpty()) + } + + @Test + fun `spread mode storage round trips and defaults to automatic`() { + ReaderSpreadMode.entries.forEach { mode -> + assertEquals(mode, readerSpreadModeFromStorage(readerSpreadModeStorageValue(mode))) + } + assertEquals(ReaderSpreadMode.AUTO, readerSpreadModeFromStorage(null)) + assertEquals(ReaderSpreadMode.AUTO, readerSpreadModeFromStorage("garbage")) + assertEquals(ReaderSpreadMode.OFF, readerSpreadModeFromStorage(" OFF ")) + } +} From d49ddf1c16b42a5d66721c02c7bbdc8fd90a621d Mon Sep 17 00:00:00 2001 From: Andriy Chortkivskyy Date: Fri, 9 Oct 2026 12:31:08 +0200 Subject: [PATCH 03/13] feat: true OLED white-on-black EPUB reader theme OLED black now renders pure #FFFFFF text and links on #000000 across the page, reader chrome, options sheet, overlays, dialogs, window background and status-bar strip. Selection inverts to white; ReadiumCSS night-mode blue links and pale-blue selection are overridden in-page. Images keep their original colours. Other reader themes and comics are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../EpubFoldOledWebViewInstrumentedTest.kt | 224 ++++++++++++++++++ .../com/vangeaux/lagrange/BookOrbitApp.kt | 46 ++-- .../lagrange/EpubMediaOverlayPlayback.kt | 5 +- .../com/vangeaux/lagrange/EpubOledTheme.kt | 154 ++++++++++++ .../com/vangeaux/lagrange/EpubTtsControls.kt | 5 +- .../lagrange/ReadiumEpubReaderActivity.kt | 51 +++- .../res/drawable/oled_dialog_background.xml | 15 ++ app/src/main/res/values/colors.xml | 5 + app/src/main/res/values/themes.xml | 45 ++++ .../vangeaux/lagrange/EpubOledThemeTest.kt | 201 ++++++++++++++++ 10 files changed, 722 insertions(+), 29 deletions(-) create mode 100644 app/src/androidTest/java/com/vangeaux/lagrange/EpubFoldOledWebViewInstrumentedTest.kt create mode 100644 app/src/main/java/com/vangeaux/lagrange/EpubOledTheme.kt create mode 100644 app/src/main/res/drawable/oled_dialog_background.xml create mode 100644 app/src/test/java/com/vangeaux/lagrange/EpubOledThemeTest.kt diff --git a/app/src/androidTest/java/com/vangeaux/lagrange/EpubFoldOledWebViewInstrumentedTest.kt b/app/src/androidTest/java/com/vangeaux/lagrange/EpubFoldOledWebViewInstrumentedTest.kt new file mode 100644 index 00000000..e59520a2 --- /dev/null +++ b/app/src/androidTest/java/com/vangeaux/lagrange/EpubFoldOledWebViewInstrumentedTest.kt @@ -0,0 +1,224 @@ +package com.vangeaux.lagrange + +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.readium.r2.shared.ExperimentalReadiumApi + +/** + * Exercises the fold spread and OLED page styling against the real ReadiumCSS stylesheet shipped + * in the app's Readium assets, in a WebView narrower than ReadiumCSS's 60em two-column breakpoint + * (an unfolded Fold8 inner display). + */ +@RunWith(AndroidJUnit4::class) +@OptIn(ExperimentalReadiumApi::class) +class EpubFoldOledWebViewInstrumentedTest { + @get:Rule + val composeRule = createComposeRule() + + private val readiumAfterCss: String by lazy { + InstrumentationRegistry.getInstrumentation().targetContext.assets + .open("readium/readium-css/ReadiumCSS-after.css") + .bufferedReader() + .use { it.readText() } + } + + private val longChapter: String = + (1..80).joinToString("") { index -> + "

Paragraph $index. " + + "The quick brown fox jumps over the lazy dog. ".repeat(4) + "

" + } + + @Test + fun spreadPropertiesForceTwoColumnsWithGuttersBelowTheSixtyEmBreakpoint() { + val spreadStyle = epubSpreadRsProperties(twoPageSpread = true).toCssProperties() + .filterValues { it != null } + .entries + .joinToString("; ") { (key, value) -> "$key: $value" } + + "; --USER__colCount: 2; --USER__pageMargins: 1" + val webView = loadPage(rootStyle = spreadStyle, body = longChapter, widthDp = 880, heightDp = 640) + + val layout = JSONObject( + decodeJavascriptString( + evaluateJavascript( + webView, + """ + JSON.stringify((function() { + const width = window.innerWidth; + const lefts = Array.from(document.querySelectorAll('p')) + .map(p => p.getBoundingClientRect()) + .filter(r => r.left >= 0 && r.left < width) + .map(r => r.left); + const rights = Array.from(document.querySelectorAll('p')) + .map(p => p.getBoundingClientRect()) + .filter(r => r.left >= 0 && r.right <= width / 2 + 1) + .map(r => r.right); + return { + width: width, + belowBreakpoint: !window.matchMedia('(min-width: 60em)').matches, + columnCount: getComputedStyle(document.documentElement).columnCount, + bodyPaddingLeft: getComputedStyle(document.body).paddingLeft, + firstColumnRight: Math.max.apply(null, rights), + secondColumnLeft: Math.max.apply(null, lefts) + }; + })()) + """.trimIndent() + ) + ) + ) + + assertTrue("fixture must be narrower than 60em", layout.getBoolean("belowBreakpoint")) + assertEquals("2", layout.getString("columnCount")) + assertEquals("24px", layout.getString("bodyPaddingLeft")) + val half = layout.getDouble("width") / 2.0 + assertTrue( + "first column must end before the gutter: $layout", + layout.getDouble("firstColumnRight") <= half - 20.0 + ) + assertTrue( + "second column must start after the gutter on the same screen: $layout", + layout.getDouble("secondColumnLeft") >= half + 20.0 + ) + } + + @Test + fun withoutSpreadPropertiesTheSameNarrowWindowStaysSingleColumn() { + val webView = loadPage( + rootStyle = "--USER__colCount: 1; --USER__pageMargins: 0", + body = longChapter, + widthDp = 880, + heightDp = 640 + ) + + val narrowPortraitDevice = evaluateJavascript( + webView, + "!window.matchMedia('(min-width: 60em), (min-device-width: 36em) and (max-device-width: 47em) and (orientation: landscape)').matches" + ) + if (narrowPortraitDevice == "true") { + assertEquals( + "\"1\"", + evaluateJavascript(webView, "getComputedStyle(document.documentElement).columnCount") + ) + } + } + + @Test + fun oledScriptTurnsReadiumNightModeLinksAndSelectionWhiteOnBlack() { + val nightStyle = "--USER__appearance: readium-night-on; " + + "--USER__backgroundColor: ${EpubReaderTheme.OledBlack.backgroundCss}; " + + "--USER__textColor: ${EpubReaderTheme.OledBlack.foregroundCss}" + val webView = loadPage( + rootStyle = nightStyle, + body = """ +

Chapter

+

Body text with a link.

+
+ """.trimIndent(), + widthDp = 600, + heightDp = 600 + ) + + fun styles(): List = javascriptStringArray( + webView, + """[ + getComputedStyle(document.documentElement).backgroundColor, + getComputedStyle(document.getElementById('text')).color, + getComputedStyle(document.getElementById('heading')).color, + getComputedStyle(document.getElementById('link')).color, + getComputedStyle(document.getElementById('text'), '::selection').backgroundColor + ]""" + ) + + val before = styles() + assertEquals("rgb(0, 0, 0)", before[0]) + assertEquals("rgb(255, 255, 255)", before[1]) + assertEquals("rgb(255, 255, 255)", before[2]) + // ReadiumCSS night mode forces its own blue link colour. + assertEquals("rgb(99, 202, 255)", before[3]) + + evaluateJavascript(webView, readiumEpubOledTextStyleScript()) + evaluateJavascript(webView, readiumEpubOledTextStyleScript()) + val after = styles() + + assertEquals("rgb(0, 0, 0)", after[0]) + assertEquals("rgb(255, 255, 255)", after[1]) + assertEquals("rgb(255, 255, 255)", after[2]) + assertEquals("rgb(255, 255, 255)", after[3]) + assertEquals("rgb(255, 255, 255)", after[4]) + assertEquals( + "1", + evaluateJavascript(webView, "document.querySelectorAll('#lagrange-readium-epub-oled-text').length") + ) + } + + private fun loadPage(rootStyle: String, body: String, widthDp: Int, heightDp: Int): WebView { + val loaded = CountDownLatch(1) + lateinit var webView: WebView + val css = readiumAfterCss + composeRule.setContent { + AndroidView( + modifier = Modifier.size(widthDp.dp, heightDp.dp), + factory = { context -> + WebView(context).apply { + settings.javaScriptEnabled = true + webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView, url: String?) { + super.onPageFinished(view, url) + loaded.countDown() + } + } + loadDataWithBaseURL( + "https://reader.test/", + "" + + "" + + "$body", + "text/html", + Charsets.UTF_8.name(), + null + ) + }.also { webView = it } + } + ) + } + assertTrue("ReadiumCSS fixture did not finish loading", loaded.await(10, TimeUnit.SECONDS)) + repeat(20) { + if (evaluateJavascript(webView, "window.innerWidth > 0") == "true") return webView + Thread.sleep(100) + } + return webView + } + + private fun javascriptStringArray(webView: WebView, expression: String): List = + JSONArray(evaluateJavascript(webView, expression)) + .let { values -> (0 until values.length()).map(values::getString) } + + private fun evaluateJavascript(webView: WebView, script: String): String { + val completed = CountDownLatch(1) + var result = "null" + composeRule.runOnIdle { + webView.evaluateJavascript(script) { value -> + result = value ?: "null" + completed.countDown() + } + } + assertTrue("JavaScript evaluation timed out", completed.await(5, TimeUnit.SECONDS)) + return result + } + + private fun decodeJavascriptString(value: String): String = JSONArray("[$value]").getString(0) +} diff --git a/app/src/main/java/com/vangeaux/lagrange/BookOrbitApp.kt b/app/src/main/java/com/vangeaux/lagrange/BookOrbitApp.kt index a118efdc..1ad3d669 100644 --- a/app/src/main/java/com/vangeaux/lagrange/BookOrbitApp.kt +++ b/app/src/main/java/com/vangeaux/lagrange/BookOrbitApp.kt @@ -19,6 +19,7 @@ import android.webkit.WebChromeClient import android.webkit.WebView import android.webkit.WebViewClient import androidx.activity.compose.BackHandler +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.Image import androidx.compose.foundation.clickable @@ -1386,7 +1387,9 @@ internal fun ReaderLightweightChrome( val parentTypography = MaterialTheme.typography val parentShapes = MaterialTheme.shapes val colors = remember(theme) { - if (theme == EpubReaderTheme.Dark) { + if (theme.isOled()) { + oledReaderColorScheme() + } else if (theme == EpubReaderTheme.Dark) { darkColorScheme( primary = Color(palette.accent), onPrimary = Color(palette.onAccent), @@ -1417,6 +1420,8 @@ internal fun ReaderLightweightChrome( } } + // OLED chrome is opaque so page text never bleeds through a black bar. + val chromeSurfaceAlpha = if (theme.isOled()) 1f else 0.96f MaterialTheme(colorScheme = colors, typography = parentTypography, shapes = parentShapes) { BoxWithConstraints(modifier = modifier.fillMaxSize()) { val positionControlHeight = (maxHeight * READER_POSITION_CONTROL_HEIGHT_FRACTION) @@ -1433,7 +1438,7 @@ internal fun ReaderLightweightChrome( .fillMaxWidth() .windowInsetsPadding(WindowInsets.statusBars.only(WindowInsetsSides.Top)) .testTag("reader-lightweight-top-bar"), - color = MaterialTheme.colorScheme.surface.copy(alpha = 0.96f), + color = MaterialTheme.colorScheme.surface.copy(alpha = chromeSurfaceAlpha), contentColor = MaterialTheme.colorScheme.onSurface, shadowElevation = 5.dp ) { @@ -1466,7 +1471,7 @@ internal fun ReaderLightweightChrome( .height(positionControlHeight) .testTag("reader-lightweight-position-control"), shape = RoundedCornerShape(topStart = 22.dp, bottomStart = 22.dp), - color = MaterialTheme.colorScheme.surface.copy(alpha = 0.96f), + color = MaterialTheme.colorScheme.surface.copy(alpha = chromeSurfaceAlpha), contentColor = MaterialTheme.colorScheme.onSurface, shadowElevation = 5.dp ) { @@ -1591,7 +1596,7 @@ internal fun ReaderLightweightChrome( .fillMaxWidth() .padding(bottom = EPUB_READER_PROGRESS_FOOTER_HEIGHT) .testTag("reader-lightweight-bottom-bar"), - color = MaterialTheme.colorScheme.surface.copy(alpha = 0.96f), + color = MaterialTheme.colorScheme.surface.copy(alpha = chromeSurfaceAlpha), contentColor = MaterialTheme.colorScheme.onSurface, shadowElevation = 5.dp ) { @@ -1653,12 +1658,16 @@ private fun ReaderPositionListDialog( onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false) ) { + // On a pure black (OLED) scheme shadows and container fills are invisible, so edges and + // the current entry are drawn with hairline outlines instead. + val outlineOnly = MaterialTheme.colorScheme.primaryContainer == MaterialTheme.colorScheme.surface Surface( modifier = Modifier.fillMaxWidth(0.9f).heightIn(max = 560.dp), shape = RoundedCornerShape(24.dp), color = MaterialTheme.colorScheme.surface, contentColor = MaterialTheme.colorScheme.onSurface, - shadowElevation = 12.dp + shadowElevation = 12.dp, + border = if (outlineOnly) BorderStroke(1.dp, MaterialTheme.colorScheme.outline) else null ) { Column(modifier = Modifier.padding(vertical = 12.dp)) { Text( @@ -1683,7 +1692,10 @@ private fun ReaderPositionListDialog( } else { Color.Transparent }, - shape = RoundedCornerShape(12.dp) + shape = RoundedCornerShape(12.dp), + border = if (selected && outlineOnly) { + BorderStroke(1.dp, MaterialTheme.colorScheme.primary) + } else null ) { Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) { Text( @@ -1746,13 +1758,13 @@ internal fun EpubReaderTheme.readerOptionsPalette(): EpubReaderOptionsPalette = outline = 0xFF8B8175.toInt() ) EpubReaderTheme.OledBlack -> EpubReaderOptionsPalette( - container = 0xFF000000.toInt(), - content = 0xFFF5F5F6.toInt(), - mutedContent = 0xFFCECDD2.toInt(), - accent = 0xFFA8CDE0.toInt(), - onAccent = 0xFF073447.toInt(), - surfaceVariant = 0xFF1C1C1E.toInt(), - outline = 0xFF96959B.toInt() + container = OLED_BLACK_ARGB, + content = OLED_WHITE_ARGB, + mutedContent = OLED_MUTED_ARGB, + accent = OLED_WHITE_ARGB, + onAccent = OLED_BLACK_ARGB, + surfaceVariant = OLED_HAIRLINE_ARGB, + outline = OLED_OUTLINE_ARGB ) } @@ -1774,7 +1786,9 @@ internal fun EpubReaderOptionsBottomSheet( val parentShapes = MaterialTheme.shapes val colors = remember(preferences.theme) { val palette = preferences.theme.readerOptionsPalette() - if (preferences.theme == EpubReaderTheme.Dark || preferences.theme == EpubReaderTheme.OledBlack) { + if (preferences.theme.isOled()) { + oledReaderColorScheme() + } else if (preferences.theme == EpubReaderTheme.Dark) { darkColorScheme( primary = Color(palette.accent), onPrimary = Color(palette.onAccent), @@ -2658,7 +2672,7 @@ enum class EpubReaderTheme( label = "OLED black", backgroundColor = 0xFF000000.toInt(), backgroundCss = "#000000", - foregroundCss = "#F5F5F6", - linkCss = "#A8CDE0" + foregroundCss = "#FFFFFF", + linkCss = "#FFFFFF" ) } diff --git a/app/src/main/java/com/vangeaux/lagrange/EpubMediaOverlayPlayback.kt b/app/src/main/java/com/vangeaux/lagrange/EpubMediaOverlayPlayback.kt index 51ede771..fe8a6bb2 100644 --- a/app/src/main/java/com/vangeaux/lagrange/EpubMediaOverlayPlayback.kt +++ b/app/src/main/java/com/vangeaux/lagrange/EpubMediaOverlayPlayback.kt @@ -301,7 +301,10 @@ internal fun EpubMediaOverlayControls( ) { var settingsVisible by remember { mutableStateOf(false) } Box(modifier = modifier.fillMaxWidth()) { - Card(modifier = Modifier.fillMaxWidth().navigationBarsPadding()) { + Card( + modifier = Modifier.fillMaxWidth().navigationBarsPadding(), + border = epubReaderOledPanelBorder() + ) { Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween, diff --git a/app/src/main/java/com/vangeaux/lagrange/EpubOledTheme.kt b/app/src/main/java/com/vangeaux/lagrange/EpubOledTheme.kt new file mode 100644 index 00000000..63c65f5c --- /dev/null +++ b/app/src/main/java/com/vangeaux/lagrange/EpubOledTheme.kt @@ -0,0 +1,154 @@ +package com.vangeaux.lagrange + +import androidx.compose.foundation.BorderStroke +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/* + * True OLED design for the EPUB reader: every surface is #000000 so OLED pixels stay off, and + * primary text and icons are #FFFFFF. Secondary text and hairline outlines use neutral greys on + * black; selection states invert to white-on-black. Comics and PDFs are intentionally unaffected. + */ + +internal const val OLED_BLACK_ARGB = 0xFF000000.toInt() +internal const val OLED_WHITE_ARGB = 0xFFFFFFFF.toInt() +internal const val OLED_MUTED_ARGB = 0xFFBDBDBD.toInt() +internal const val OLED_OUTLINE_ARGB = 0xFF8A8A8A.toInt() +/** Hairlines and slider tracks only; never used as a surface fill. */ +internal const val OLED_HAIRLINE_ARGB = 0xFF333333.toInt() +internal const val OLED_ERROR_ARGB = 0xFFFF8A80.toInt() + +internal fun EpubReaderTheme.isOled(): Boolean = this == EpubReaderTheme.OledBlack + +internal fun oledReaderColorScheme(): ColorScheme { + val black = Color(OLED_BLACK_ARGB) + val white = Color(OLED_WHITE_ARGB) + val muted = Color(OLED_MUTED_ARGB) + val outline = Color(OLED_OUTLINE_ARGB) + val hairline = Color(OLED_HAIRLINE_ARGB) + val error = Color(OLED_ERROR_ARGB) + return darkColorScheme( + primary = white, + onPrimary = black, + primaryContainer = black, + onPrimaryContainer = white, + inversePrimary = black, + secondary = white, + onSecondary = black, + secondaryContainer = white, + onSecondaryContainer = black, + tertiary = white, + onTertiary = black, + tertiaryContainer = black, + onTertiaryContainer = white, + background = black, + onBackground = white, + surface = black, + onSurface = white, + surfaceVariant = hairline, + onSurfaceVariant = muted, + // No tonal elevation tint: elevated surfaces must stay pure black. + surfaceTint = black, + inverseSurface = white, + inverseOnSurface = black, + error = error, + onError = black, + errorContainer = black, + onErrorContainer = error, + outline = outline, + outlineVariant = hairline, + scrim = black, + surfaceBright = black, + surfaceContainer = black, + surfaceContainerHigh = black, + surfaceContainerHighest = black, + surfaceContainerLow = black, + surfaceContainerLowest = black, + surfaceDim = black + ) +} + +/** + * Material theme for EPUB reader overlays. OLED replaces the app colour scheme with the pure + * black/white scheme; every other reader theme keeps the existing app theme unchanged. + */ +@Composable +internal fun EpubReaderMaterialTheme( + theme: EpubReaderTheme, + content: @Composable () -> Unit +) { + BookOrbitTheme { + if (theme.isOled()) { + MaterialTheme( + colorScheme = oledReaderColorScheme(), + typography = MaterialTheme.typography, + shapes = MaterialTheme.shapes + ) { + CompositionLocalProvider(LocalEpubReaderOledOutlines provides true, content = content) + } + } else { + content() + } + } +} + +/** True inside OLED reader overlays, where shadows and tonal fills are invisible on black. */ +internal val LocalEpubReaderOledOutlines = staticCompositionLocalOf { false } + +/** Hairline edge for floating reader panels on OLED; null keeps the existing look elsewhere. */ +@Composable +internal fun epubReaderOledPanelBorder(): BorderStroke? = + if (LocalEpubReaderOledOutlines.current) { + BorderStroke(1.dp, MaterialTheme.colorScheme.outline) + } else { + null + } + +/** Android view theme for the EPUB reader activity; applied before the window is created. */ +internal fun epubReaderActivityThemeRes(theme: EpubReaderTheme): Int = + if (theme.isOled()) R.style.Theme_BookOrbit_Reader_Oled else R.style.Theme_BookOrbit_Reader + +/** Switching into or out of OLED changes the activity theme, which requires recreation. */ +internal fun epubReaderThemeChangeRequiresRecreate( + previous: EpubReaderTheme, + next: EpubReaderTheme +): Boolean = previous.isOled() != next.isOled() + +/** + * In-page OLED overrides. ReadiumCSS night mode forces blue links and a pale-blue text selection; + * these rules are more specific than ReadiumCSS's `:root[style*=readium-night-on] a:link` and keep + * the page strictly white on black. Images are left untouched (see the image colour preservation + * script), so illustrations keep their original colours. + */ +internal fun readiumEpubOledTextStyleScript(): String = """ + (function() { + const styleId = 'lagrange-readium-epub-oled-text'; + if (document.getElementById(styleId)) return 'already-installed'; + const style = document.createElement('style'); + style.id = styleId; + style.textContent = ` + html:root[style] a:link, + html:root[style] a:link *, + html:root[style] a:visited, + html:root[style] a:visited * { + color: #FFFFFF !important; + text-decoration-line: underline !important; + } + html:root[style] ::selection { + background-color: #FFFFFF !important; + color: #000000 !important; + } + html:root[style] hr { + border-color: #FFFFFF !important; + } + `; + (document.head || document.documentElement).appendChild(style); + return 'installed'; + })(); +""".trimIndent() diff --git a/app/src/main/java/com/vangeaux/lagrange/EpubTtsControls.kt b/app/src/main/java/com/vangeaux/lagrange/EpubTtsControls.kt index 78f381bd..eed49dd6 100644 --- a/app/src/main/java/com/vangeaux/lagrange/EpubTtsControls.kt +++ b/app/src/main/java/com/vangeaux/lagrange/EpubTtsControls.kt @@ -59,7 +59,10 @@ internal fun EpubTtsControls( ) { var settingsVisible by remember { mutableStateOf(false) } Box(modifier = modifier.fillMaxWidth()) { - Card(modifier = Modifier.fillMaxWidth().navigationBarsPadding()) { + Card( + modifier = Modifier.fillMaxWidth().navigationBarsPadding(), + border = epubReaderOledPanelBorder() + ) { Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween, diff --git a/app/src/main/java/com/vangeaux/lagrange/ReadiumEpubReaderActivity.kt b/app/src/main/java/com/vangeaux/lagrange/ReadiumEpubReaderActivity.kt index 87fec753..64dac97d 100644 --- a/app/src/main/java/com/vangeaux/lagrange/ReadiumEpubReaderActivity.kt +++ b/app/src/main/java/com/vangeaux/lagrange/ReadiumEpubReaderActivity.kt @@ -324,8 +324,18 @@ private const val EPUB_IMAGE_GESTURE_ASSET = "epub-image-gesture.js" private const val EPUB_IMAGE_GESTURE_PROBE_SCRIPT = "(function(){const k='__bookorbitImageGesture';const v=window[k]||null;window[k]=null;return v;})()" -internal fun epubCurrentDocumentInstallScript(gestureScript: String): String = - readiumEpubImageColorOverrideScript() + "\n" + gestureScript +internal fun epubCurrentDocumentInstallScript( + gestureScript: String, + oledText: Boolean = false +): String = buildString { + append(readiumEpubImageColorOverrideScript()) + if (oledText) { + append("\n") + append(readiumEpubOledTextStyleScript()) + } + append("\n") + append(gestureScript) +} internal suspend fun runEpubCurrentDocumentBridgeLoop( installScript: String, @@ -735,6 +745,7 @@ class ReadiumEpubReaderActivity : FragmentActivity() { !isTapZoneTutorialVisible() override fun onCreate(savedInstanceState: Bundle?) { + setTheme(epubReaderActivityThemeRes(initialReaderTheme())) requestWindowFeature(Window.FEATURE_NO_TITLE) if (savedInstanceState != null) { supportFragmentManager.fragmentFactory = EpubNavigatorFragment.createDummyFactory() @@ -888,7 +899,7 @@ class ReadiumEpubReaderActivity : FragmentActivity() { ) viewOnlyBannerView = ComposeView(this).apply { visibility = if (isPreview) View.VISIBLE else View.GONE - setContent { BookOrbitTheme { ViewOnlyModeBanner(onConfirmReadMode = ::enableReadMode) } } + setContent { EpubReaderMaterialTheme(selectedTheme) { ViewOnlyModeBanner(onConfirmReadMode = ::enableReadMode) } } addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> applyReaderPadding() } ViewCompat.setOnApplyWindowInsetsListener(this) { view, insets -> val statusBarTop = insets.getInsets(WindowInsetsCompat.Type.statusBars()).top @@ -919,7 +930,7 @@ class ReadiumEpubReaderActivity : FragmentActivity() { } footerView = ComposeView(this).apply { setContent { - BookOrbitTheme { + EpubReaderMaterialTheme(selectedTheme) { EpubReaderProgressFooter( status = currentProgressStatus(), theme = selectedTheme, @@ -939,7 +950,7 @@ class ReadiumEpubReaderActivity : FragmentActivity() { chromeView = ComposeView(this).apply { visibility = View.GONE setContent { - BookOrbitTheme { + EpubReaderMaterialTheme(selectedTheme) { ReaderLightweightChrome( title = if (isPreview) "Preview · $displayTitle" else displayTitle, theme = selectedTheme, @@ -987,7 +998,7 @@ class ReadiumEpubReaderActivity : FragmentActivity() { optionsView = ComposeView(this).apply { visibility = View.GONE setContent { - BookOrbitTheme { + EpubReaderMaterialTheme(selectedTheme) { Box(modifier = Modifier.fillMaxSize()) { EpubReaderDismissScrim(onDismiss = ::hideOptions) EpubReaderOptionsBottomSheet( @@ -1019,7 +1030,7 @@ class ReadiumEpubReaderActivity : FragmentActivity() { visibility = View.GONE importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES setContent { - BookOrbitTheme { + EpubReaderMaterialTheme(selectedTheme) { ReaderTapZoneTutorial( onDismiss = ::hideTapZoneTutorial, readingDirection = readingDirection, @@ -1039,7 +1050,7 @@ class ReadiumEpubReaderActivity : FragmentActivity() { ) val epubImageViewerView = ComposeView(this).apply { setContent { - BookOrbitTheme { + EpubReaderMaterialTheme(selectedTheme) { epubImageViewer?.let { (_, bitmap) -> ComicPageImageViewer( title = displayTitle, @@ -1067,7 +1078,7 @@ class ReadiumEpubReaderActivity : FragmentActivity() { mediaOverlayView = ComposeView(this).apply { visibility = if (mediaOverlayPlayback.player == null) View.GONE else View.VISIBLE setContent { - BookOrbitTheme { + EpubReaderMaterialTheme(selectedTheme) { val clip = mediaOverlayPlayback.activeClip if (clip != null) { EpubMediaOverlayControls( @@ -1098,7 +1109,7 @@ class ReadiumEpubReaderActivity : FragmentActivity() { ttsView = ComposeView(this).apply { visibility = View.GONE setContent { - BookOrbitTheme { + EpubReaderMaterialTheme(selectedTheme) { EpubTtsControls( settings = ttsSettings, isPlaying = ttsIsPlaying, @@ -2002,7 +2013,11 @@ class ReadiumEpubReaderActivity : FragmentActivity() { val gestureScript = runCatching { assets.open(EPUB_IMAGE_GESTURE_ASSET).bufferedReader().use { it.readText() } }.getOrNull() ?: return - val installScript = epubCurrentDocumentInstallScript(gestureScript) + // OLED in-page styling is fixed per navigator: switching into or out of OLED recreates. + val installScript = epubCurrentDocumentInstallScript( + gestureScript, + oledText = selectedTheme.isOled() + ) epubImageGestureJob = lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { runEpubCurrentDocumentBridgeLoop( @@ -2661,6 +2676,7 @@ class ReadiumEpubReaderActivity : FragmentActivity() { private fun applyReaderPreferences(next: LibraryReaderPreferences) { val normalized = next.normalized() + val previousTheme = readerPreferences.theme val tapZoneChanged = readerTapZonePreferencesChanged(readerPreferences, normalized) readerPreferences = normalized selectedTheme = normalized.theme @@ -2691,10 +2707,21 @@ class ReadiumEpubReaderActivity : FragmentActivity() { current.withReaderPreferences(libraryId, normalized) ) configureSystemBars() + if (epubReaderThemeChangeRequiresRecreate(previousTheme, normalized.theme)) { + recreate() + return + } if (recreateIfSpreadChanged()) return if (tapZoneChanged && showTapZoneTutorialAfterChanges) showTapZoneTutorial() } + /** Mirrors the reader-preference resolution in [onCreate] so the window theme matches it. */ + private fun initialReaderTheme(): EpubReaderTheme { + val library = intent.getStringExtra(EXTRA_LIBRARY_ID).orEmpty() + return appPreferencesStore.read().libraryReaderPreferences[library]?.theme + ?: themeStore.read() + } + private fun computeTwoPageSpread(): Boolean = shouldUseTwoPageSpread( mode = readerPreferences.twoPageSpread, window = windowTracker.state.value, @@ -2892,6 +2919,8 @@ class ReadiumEpubReaderActivity : FragmentActivity() { } window.statusBarColor = selectedTheme.backgroundColor window.navigationBarColor = Color.BLACK + // Android 15 draws the status-bar strip from the window background, not statusBarColor. + window.setBackgroundDrawable(ColorDrawable(selectedTheme.backgroundColor)) } private fun chapterTitle(tableOfContents: List, resource: Link): String? { diff --git a/app/src/main/res/drawable/oled_dialog_background.xml b/app/src/main/res/drawable/oled_dialog_background.xml new file mode 100644 index 00000000..be6a8363 --- /dev/null +++ b/app/src/main/res/drawable/oled_dialog_background.xml @@ -0,0 +1,15 @@ + + + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 85f136fb..b59818a3 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -3,4 +3,9 @@ #102A43 #F0B429 #F8FBFF + #FF000000 + #FFFFFFFF + #FFBDBDBD + #FF8A8A8A + #66FFFFFF diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 6e9eb35d..79f9f386 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -13,4 +13,49 @@ true @android:color/black + + + diff --git a/app/src/test/java/com/vangeaux/lagrange/EpubOledThemeTest.kt b/app/src/test/java/com/vangeaux/lagrange/EpubOledThemeTest.kt new file mode 100644 index 00000000..35ffd2de --- /dev/null +++ b/app/src/test/java/com/vangeaux/lagrange/EpubOledThemeTest.kt @@ -0,0 +1,201 @@ +package com.vangeaux.lagrange + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class EpubOledThemeTest { + private val black = Color(0xFF000000) + private val white = Color(0xFFFFFFFF) + + @Test + fun `oled page is pure white text and links on pure black`() { + val theme = EpubReaderTheme.OledBlack + + assertEquals(0xFF000000.toInt(), theme.backgroundColor) + assertEquals("#000000", theme.backgroundCss) + assertEquals("#FFFFFF", theme.foregroundCss) + assertEquals("#FFFFFF", theme.linkCss) + assertEquals(0xFFFFFFFF.toInt(), cssHexColorInt(theme.foregroundCss)) + } + + @Test + fun `other reader themes are unchanged`() { + assertEquals("#1F1C17", EpubReaderTheme.Light.foregroundCss) + assertEquals("#3B2E1E", EpubReaderTheme.Sepia.foregroundCss) + assertEquals("#ECE4D8", EpubReaderTheme.Dark.foregroundCss) + assertEquals(0xFF181512.toInt(), EpubReaderTheme.Dark.backgroundColor) + } + + @Test + fun `oled options palette is black and white with an inverted primary action`() { + val palette = EpubReaderTheme.OledBlack.readerOptionsPalette() + + assertEquals(OLED_BLACK_ARGB, palette.container) + assertEquals(OLED_WHITE_ARGB, palette.content) + assertEquals(OLED_WHITE_ARGB, palette.accent) + assertEquals(OLED_BLACK_ARGB, palette.onAccent) + } + + @Test + fun `every oled surface role is pure black`() { + val scheme = oledReaderColorScheme() + val surfaces = mapOf( + "background" to scheme.background, + "surface" to scheme.surface, + "surfaceTint" to scheme.surfaceTint, + "surfaceBright" to scheme.surfaceBright, + "surfaceDim" to scheme.surfaceDim, + "surfaceContainer" to scheme.surfaceContainer, + "surfaceContainerLow" to scheme.surfaceContainerLow, + "surfaceContainerLowest" to scheme.surfaceContainerLowest, + "surfaceContainerHigh" to scheme.surfaceContainerHigh, + "surfaceContainerHighest" to scheme.surfaceContainerHighest, + "primaryContainer" to scheme.primaryContainer, + "tertiaryContainer" to scheme.tertiaryContainer, + "errorContainer" to scheme.errorContainer, + "scrim" to scheme.scrim + ) + surfaces.forEach { (role, color) -> assertEquals("$role must be black", black, color) } + } + + @Test + fun `oled primary content is pure white`() { + val scheme = oledReaderColorScheme() + listOf( + "onBackground" to scheme.onBackground, + "onSurface" to scheme.onSurface, + "primary" to scheme.primary, + "onPrimaryContainer" to scheme.onPrimaryContainer, + "secondary" to scheme.secondary, + "tertiary" to scheme.tertiary + ).forEach { (role, color) -> assertEquals("$role must be white", white, color) } + } + + @Test + fun `oled selected states invert to black on white`() { + val scheme = oledReaderColorScheme() + + assertEquals(black, scheme.onPrimary) + assertEquals(white, scheme.secondaryContainer) + assertEquals(black, scheme.onSecondaryContainer) + assertEquals(white, scheme.inverseSurface) + assertEquals(black, scheme.inverseOnSurface) + } + + @Test + fun `oled secondary text and outlines stay readable on black`() { + val scheme = oledReaderColorScheme() + + assertTrue(contrastRatio(scheme.onSurfaceVariant, black) >= 4.5) + assertTrue(contrastRatio(scheme.error, black) >= 4.5) + // Non-text UI boundaries (WCAG 1.4.11). + assertTrue(contrastRatio(scheme.outline, black) >= 3.0) + } + + @Test + fun `oled hairlines are neutral grey and dimmer than outlines`() { + val scheme = oledReaderColorScheme() + + listOf(scheme.outline, scheme.outlineVariant, scheme.surfaceVariant, scheme.onSurfaceVariant) + .forEach { color -> assertTrue("$color is not neutral", isNeutralGrey(color)) } + assertTrue(luminance(scheme.outlineVariant) < luminance(scheme.outline)) + } + + @Test + fun `only oled changes the activity theme and needs recreation`() { + assertTrue(EpubReaderTheme.OledBlack.isOled()) + EpubReaderTheme.entries.filterNot { it == EpubReaderTheme.OledBlack }.forEach { theme -> + assertFalse(theme.isOled()) + assertEquals(R.style.Theme_BookOrbit_Reader, epubReaderActivityThemeRes(theme)) + assertTrue(epubReaderThemeChangeRequiresRecreate(theme, EpubReaderTheme.OledBlack)) + assertTrue(epubReaderThemeChangeRequiresRecreate(EpubReaderTheme.OledBlack, theme)) + } + assertEquals( + R.style.Theme_BookOrbit_Reader_Oled, + epubReaderActivityThemeRes(EpubReaderTheme.OledBlack) + ) + assertNotEquals(R.style.Theme_BookOrbit_Reader, R.style.Theme_BookOrbit_Reader_Oled) + assertFalse(epubReaderThemeChangeRequiresRecreate(EpubReaderTheme.OledBlack, EpubReaderTheme.OledBlack)) + assertFalse(epubReaderThemeChangeRequiresRecreate(EpubReaderTheme.Light, EpubReaderTheme.Dark)) + assertFalse(epubReaderThemeChangeRequiresRecreate(EpubReaderTheme.Sepia, EpubReaderTheme.Light)) + } + + @Test + fun `oled uses light status bar icons`() { + assertFalse(EpubReaderTheme.OledBlack.usesDarkStatusBarIcons()) + } + + @Test + fun `oled page script forces white links and white selection without touching images`() { + val script = readiumEpubOledTextStyleScript() + + assertTrue(script.contains("lagrange-readium-epub-oled-text")) + assertTrue(script.contains("a:link")) + assertTrue(script.contains("a:visited")) + assertTrue(script.contains("::selection")) + assertTrue(script.contains("color: #FFFFFF !important")) + assertTrue(script.contains("background-color: #FFFFFF !important")) + assertFalse(script.contains("img")) + assertFalse(script.contains("filter")) + assertFalse(script.contains("#63caff", ignoreCase = true)) + // Idempotent: the bridge loop re-runs it every poll. + assertTrue(script.contains("already-installed")) + } + + @Test + fun `oled page script is installed only for oled and before the gesture bridge`() { + val oled = epubCurrentDocumentInstallScript("gesture-installer", oledText = true) + val normal = epubCurrentDocumentInstallScript("gesture-installer", oledText = false) + val default = epubCurrentDocumentInstallScript("gesture-installer") + + assertTrue(oled.contains("lagrange-readium-epub-oled-text")) + assertTrue(oled.contains("bookorbit-readium-epub-image-color-preservation")) + assertTrue(oled.endsWith("gesture-installer")) + assertFalse(normal.contains("lagrange-readium-epub-oled-text")) + assertTrue(normal.endsWith("gesture-installer")) + assertEquals(normal, default) + assertEquals( + readiumEpubImageColorOverrideScript() + "\n" + "gesture-installer", + normal + ) + } + + @Test + fun `image color preservation script stays free of theme styling`() { + val script = readiumEpubImageColorOverrideScript() + + assertFalse(script.contains("#FFFFFF")) + assertFalse(script.contains("::selection")) + } + + private fun isNeutralGrey(color: Color): Boolean { + val argb = color.toArgb() + val red = (argb shr 16) and 0xFF + val green = (argb shr 8) and 0xFF + val blue = argb and 0xFF + return red == green && green == blue + } + + private fun contrastRatio(first: Color, second: Color): Double { + val lighter = maxOf(luminance(first), luminance(second)) + val darker = minOf(luminance(first), luminance(second)) + return (lighter + 0.05) / (darker + 0.05) + } + + private fun luminance(color: Color): Double { + val argb = color.toArgb() + fun channel(value: Int): Double { + val normalized = value / 255.0 + return if (normalized <= 0.03928) normalized / 12.92 + else Math.pow((normalized + 0.055) / 1.055, 2.4) + } + return 0.2126 * channel((argb shr 16) and 0xFF) + + 0.7152 * channel((argb shr 8) and 0xFF) + + 0.0722 * channel(argb and 0xFF) + } +} From a16477fa87f1eb9858fb2d6ab9dc6e8fa63d9710 Mon Sep 17 00:00:00 2001 From: Andriy Chortkivskyy Date: Fri, 9 Oct 2026 12:53:20 +0200 Subject: [PATCH 04/13] feat: two-page comic spreads on unfolded horizontal windows Add a Compose spread pager for paginated CBZ/CBR when the spread policy applies: cover-alone and shift pairing, wide pages shown alone with live re-pairing, RTL page order, hinge-aware gutter, bounded pinch zoom, tap-zone/volume/page-list navigation by spread, per-page long-press export, first-page resume and last-visible-page progress. Includes unit, Compose UI and device-level spread/OLED instrumented tests. Co-Authored-By: Claude Opus 5.5 --- .../ComicSpreadReaderInstrumentedTest.kt | 195 +++++++++ .../EpubFoldOledWebViewInstrumentedTest.kt | 18 +- .../FoldSpreadOledActivityInstrumentedTest.kt | 391 +++++++++++++++++ .../com/vangeaux/lagrange/BookOrbitApp.kt | 11 +- .../vangeaux/lagrange/ComicSpreadPairing.kt | 131 ++++++ .../vangeaux/lagrange/ComicSpreadReader.kt | 400 ++++++++++++++++++ .../lagrange/ReadiumComicReaderActivity.kt | 135 +++++- .../lagrange/ReadiumEpubReaderActivity.kt | 5 + .../lagrange/ComicSpreadLayoutTest.kt | 65 +++ .../lagrange/ComicSpreadPairingTest.kt | 216 ++++++++++ 10 files changed, 1545 insertions(+), 22 deletions(-) create mode 100644 app/src/androidTest/java/com/vangeaux/lagrange/ComicSpreadReaderInstrumentedTest.kt create mode 100644 app/src/androidTest/java/com/vangeaux/lagrange/FoldSpreadOledActivityInstrumentedTest.kt create mode 100644 app/src/main/java/com/vangeaux/lagrange/ComicSpreadPairing.kt create mode 100644 app/src/main/java/com/vangeaux/lagrange/ComicSpreadReader.kt create mode 100644 app/src/test/java/com/vangeaux/lagrange/ComicSpreadLayoutTest.kt create mode 100644 app/src/test/java/com/vangeaux/lagrange/ComicSpreadPairingTest.kt diff --git a/app/src/androidTest/java/com/vangeaux/lagrange/ComicSpreadReaderInstrumentedTest.kt b/app/src/androidTest/java/com/vangeaux/lagrange/ComicSpreadReaderInstrumentedTest.kt new file mode 100644 index 00000000..7c1b103d --- /dev/null +++ b/app/src/androidTest/java/com/vangeaux/lagrange/ComicSpreadReaderInstrumentedTest.kt @@ -0,0 +1,195 @@ +package com.vangeaux.lagrange + +import android.graphics.Bitmap +import android.graphics.Color +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.click +import androidx.compose.ui.test.longClick +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ComicSpreadReaderInstrumentedTest { + @get:Rule + val composeRule = createComposeRule() + + private val portrait = Bitmap.createBitmap(66, 100, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.RED) } + private val wide = Bitmap.createBitmap(140, 100, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.BLUE) } + + private class Recorder { + val spreads = mutableListOf() + val taps = mutableListOf() + var longPressedPage: Int? = null + var navigator: ComicSpreadNavigator? = null + val loadRequests = mutableListOf>() + } + + private fun setReader( + recorder: Recorder, + pageCount: Int = 9, + initialPage: Int = 0, + readingDirection: LibraryReadingDirection = LibraryReadingDirection.LEFT_TO_RIGHT, + widePages: () -> Set = { emptySet() }, + bitmapFor: (Int) -> Bitmap = { portrait } + ) { + composeRule.setContent { + Box(modifier = Modifier.size(width = 900.dp, height = 600.dp)) { + ComicSpreadReader( + pageCount = pageCount, + initialPage = initialPage, + coverAlone = true, + shift = false, + widePages = widePages(), + readingDirection = readingDirection, + tapZoneLayout = ReaderTapZoneLayout.CURRENT_EDGES, + tapZoneInvertMode = ReaderTapZoneInvertMode.NONE, + gutterForWidth = { width -> readerSpreadGutterPx(width, null, 0) }, + animateNavigation = { false }, + cachedPage = { _, _ -> null }, + loadPage = { page, width -> + synchronized(recorder.loadRequests) { recorder.loadRequests += page to width } + bitmapFor(page) + }, + onSpreadChanged = { recorder.spreads += it }, + onTap = { recorder.taps += it }, + onLongPress = { page, _ -> recorder.longPressedPage = page }, + onNavigatorAvailable = { recorder.navigator = it } + ) + } + } + composeRule.waitForIdle() + } + + @Test + fun opensOnTheSpreadContainingTheInitialPageAndReportsIt() { + val recorder = Recorder() + setReader(recorder, initialPage = 4) + + composeRule.onNodeWithContentDescription("Pages 4 and 5").assertIsDisplayed() + assertEquals(ComicSpread(listOf(3, 4)), recorder.spreads.last()) + } + + @Test + fun coverIsShownAloneThenPagesPair() { + val recorder = Recorder() + setReader(recorder, initialPage = 0) + + composeRule.onNodeWithContentDescription("Page 1").assertIsDisplayed() + composeRule.runOnIdle { requireNotNull(recorder.navigator).step(1) } + composeRule.waitForIdle() + + composeRule.onNodeWithContentDescription("Pages 2 and 3").assertIsDisplayed() + assertEquals(ComicSpread(listOf(1, 2)), recorder.spreads.last()) + } + + @Test + fun navigatorGoToPageAndStepStayWithinTheBook() { + val recorder = Recorder() + setReader(recorder, pageCount = 9) + val navigator = requireNotNull(recorder.navigator) + + composeRule.runOnIdle { navigator.goToPage(8) } + composeRule.waitForIdle() + assertEquals(ComicSpread(listOf(7, 8)), recorder.spreads.last()) + + composeRule.runOnIdle { navigator.step(5) } + composeRule.waitForIdle() + assertEquals(ComicSpread(listOf(7, 8)), recorder.spreads.last()) + + composeRule.runOnIdle { navigator.step(-1) } + composeRule.waitForIdle() + assertEquals(ComicSpread(listOf(5, 6)), recorder.spreads.last()) + + composeRule.runOnIdle { navigator.goToPage(-3) } + composeRule.waitForIdle() + assertEquals(ComicSpread(listOf(0)), recorder.spreads.last()) + } + + @Test + fun edgeTapsMapToTapZoneActionsAcrossTheWholeSpread() { + val recorder = Recorder() + setReader(recorder, initialPage = 2) + + val reader = composeRule.onNodeWithContentDescription("Pages 2 and 3") + reader.performTouchInput { click(centerRight.copy(x = width * 0.95f)) } + composeRule.waitForIdle() + reader.performTouchInput { click(centerLeft.copy(x = width * 0.05f)) } + composeRule.waitForIdle() + reader.performTouchInput { click(center) } + composeRule.waitForIdle() + + assertEquals( + listOf( + ContinuousComicTapAction.NEXT, + ContinuousComicTapAction.PREVIOUS, + ContinuousComicTapAction.MENU + ), + recorder.taps + ) + } + + @Test + fun longPressExportsThePageUnderTheFingerInRightToLeftBooks() { + val recorder = Recorder() + setReader(recorder, initialPage = 2, readingDirection = LibraryReadingDirection.RIGHT_TO_LEFT) + + val reader = composeRule.onNodeWithContentDescription("Pages 2 and 3") + // Right-to-left: the earlier page (index 1) sits on the right half. + reader.performTouchInput { longClick(centerRight.copy(x = width * 0.75f)) } + composeRule.waitForIdle() + assertEquals(1, recorder.longPressedPage) + + reader.performTouchInput { longClick(centerLeft.copy(x = width * 0.25f)) } + composeRule.waitForIdle() + assertEquals(2, recorder.longPressedPage) + } + + @Test + fun pairedPagesDecodeAtHalfWidthAndLonePagesAtFullWidth() { + val recorder = Recorder() + setReader(recorder, initialPage = 0) + composeRule.waitUntil(5_000) { + synchronized(recorder.loadRequests) { + recorder.loadRequests.any { it.first == 1 } && recorder.loadRequests.any { it.first == 0 } + } + } + + val requests = synchronized(recorder.loadRequests) { recorder.loadRequests.toList() } + val coverWidth = requests.first { it.first == 0 }.second + val pairedWidth = requests.first { it.first == 1 }.second + assertTrue("cover $coverWidth vs paired $pairedWidth", coverWidth >= pairedWidth * 2 - 1) + } + + @Test + fun discoveringAWidePageRepairsWithoutLosingTheCurrentPage() { + val recorder = Recorder() + var widePages by mutableStateOf(emptySet()) + setReader(recorder, initialPage = 5, widePages = { widePages }, bitmapFor = { page -> + if (page == 4) wide else portrait + }) + assertEquals(ComicSpread(listOf(5, 6)), recorder.spreads.last()) + + composeRule.runOnIdle { widePages = setOf(4) } + composeRule.waitForIdle() + + val current = recorder.spreads.last() + assertTrue("page 5 must stay visible, was $current", 5 in current) + assertNotNull(composeRule.onNodeWithTag(COMIC_SPREAD_READER_TEST_TAG).assertIsDisplayed()) + } +} diff --git a/app/src/androidTest/java/com/vangeaux/lagrange/EpubFoldOledWebViewInstrumentedTest.kt b/app/src/androidTest/java/com/vangeaux/lagrange/EpubFoldOledWebViewInstrumentedTest.kt index e59520a2..f52e06c3 100644 --- a/app/src/androidTest/java/com/vangeaux/lagrange/EpubFoldOledWebViewInstrumentedTest.kt +++ b/app/src/androidTest/java/com/vangeaux/lagrange/EpubFoldOledWebViewInstrumentedTest.kt @@ -60,21 +60,19 @@ class EpubFoldOledWebViewInstrumentedTest { """ JSON.stringify((function() { const width = window.innerWidth; - const lefts = Array.from(document.querySelectorAll('p')) - .map(p => p.getBoundingClientRect()) - .filter(r => r.left >= 0 && r.left < width) - .map(r => r.left); - const rights = Array.from(document.querySelectorAll('p')) - .map(p => p.getBoundingClientRect()) - .filter(r => r.left >= 0 && r.right <= width / 2 + 1) - .map(r => r.right); + const half = width / 2; + const rects = Array.from(document.querySelectorAll('p')) + .flatMap(p => Array.from(p.getClientRects())) + .filter(r => r.width > 0 && r.left >= 0 && r.left < width); + const first = rects.filter(r => r.left < half); + const second = rects.filter(r => r.left >= half); return { width: width, belowBreakpoint: !window.matchMedia('(min-width: 60em)').matches, columnCount: getComputedStyle(document.documentElement).columnCount, bodyPaddingLeft: getComputedStyle(document.body).paddingLeft, - firstColumnRight: Math.max.apply(null, rights), - secondColumnLeft: Math.max.apply(null, lefts) + firstColumnRight: first.length ? Math.max.apply(null, first.map(r => r.right)) : -1, + secondColumnLeft: second.length ? Math.min.apply(null, second.map(r => r.left)) : -1 }; })()) """.trimIndent() diff --git a/app/src/androidTest/java/com/vangeaux/lagrange/FoldSpreadOledActivityInstrumentedTest.kt b/app/src/androidTest/java/com/vangeaux/lagrange/FoldSpreadOledActivityInstrumentedTest.kt new file mode 100644 index 00000000..5d3a9eb7 --- /dev/null +++ b/app/src/androidTest/java/com/vangeaux/lagrange/FoldSpreadOledActivityInstrumentedTest.kt @@ -0,0 +1,391 @@ +package com.vangeaux.lagrange + +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.SystemClock +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import java.io.ByteArrayOutputStream +import java.io.File +import java.util.zip.CRC32 +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * End-to-end reader checks for foldable spreads and the OLED EPUB theme. Readers are launched in a + * locked-landscape window. Spread assertions require a large (unfolded/tablet) window and are + * skipped with an assumption on phone-sized devices; single-page and OLED assertions always run. + */ +@RunWith(AndroidJUnit4::class) +class FoldSpreadOledActivityInstrumentedTest { + private val context = ApplicationProvider.getApplicationContext() + private val store = AppPreferencesStore(context) + private lateinit var original: AppPreferences + private val libraryId = "instrumented-fold-${System.nanoTime()}" + + @Before + fun saveOriginalPreferences() { + original = store.read() + } + + @After + fun restoreOriginalPreferences() { + store.save(original) + } + + private fun useProfile(profile: LibraryReaderPreferences) { + store.save( + original.copy( + lockOrientation = true, + lockedOrientation = LockedOrientation.LANDSCAPE + ).withReaderPreferences(libraryId, profile) + ) + } + + private fun awaitCondition( + scenario: ActivityScenario, + message: String, + condition: (A) -> Boolean + ) { + repeat(60) { + var satisfied = false + scenario.onActivity { activity -> satisfied = condition(activity) } + if (satisfied) return + SystemClock.sleep(250) + } + throw AssertionError(message) + } + + private fun windowQualifiesForSpread(activity: android.app.Activity): Boolean = + shouldUseTwoPageSpread( + ReaderSpreadMode.AUTO, + ReaderWindowState.fromConfiguration(activity.resources.configuration), + ReaderLayoutMode.PAGINATED + ) + + // ---- Comics ---- + + @Test + fun comicOpensAsTwoPageSpreadOnLargeHorizontalWindowAndResumesAfterRecreation() { + val cbz = File(context.cacheDir, "fold-spread-${System.nanoTime()}.cbz") + val readerKey = "fold-spread-comic-${System.nanoTime()}" + writeCbz(cbz, 7) + useProfile(LibraryReaderPreferences(twoPageSpread = ReaderSpreadMode.AUTO)) + try { + ActivityScenario.launch( + ReadiumComicReaderActivity.createIntent( + context = context, + file = cbz, + title = "Fold spread", + readerKey = readerKey, + libraryId = libraryId, + launchMode = ReaderLaunchMode.NORMAL, + initialPage = 4 + ) + ).use { scenario -> + var qualifies = false + awaitCondition(scenario, "comic reader did not settle") { activity -> + qualifies = windowQualifiesForSpread(activity) + activity.isComicSpreadReaderVisible() == qualifies && + ReadiumComicLocatorStore(context).read(readerKey) != null + } + assumeTrue("Requires a large horizontal window (unfolded foldable or tablet)", qualifies) + + // Page 5 (index 4) belongs to the spread 4–5 (indexes 3, 4): resume is the first page. + val stored = requireNotNull(ReadiumComicLocatorStore(context).read(readerKey)) + assertTrue(stored.href.toString().endsWith("0003.jpg")) + + scenario.recreate() + awaitCondition(scenario, "spread did not survive recreation") { activity -> + activity.isComicSpreadReaderVisible() + } + val restored = requireNotNull(ReadiumComicLocatorStore(context).read(readerKey)) + assertEquals(stored.href, restored.href) + scenario.onActivity { activity -> + assertEquals( + "the Readium image navigator must not be installed in spread mode", + 0, + activity.supportFragmentManager.fragments.count { it.tag == "readium_comic_navigator" } + ) + } + } + } finally { + cbz.delete() + } + } + + @Test + fun comicSpreadPreferenceOffKeepsTheSinglePageNavigator() { + val cbz = File(context.cacheDir, "fold-single-${System.nanoTime()}.cbz") + val readerKey = "fold-single-comic-${System.nanoTime()}" + writeCbz(cbz, 4) + useProfile(LibraryReaderPreferences(twoPageSpread = ReaderSpreadMode.OFF)) + try { + ActivityScenario.launch( + ReadiumComicReaderActivity.createIntent( + context = context, + file = cbz, + title = "Fold single", + readerKey = readerKey, + libraryId = libraryId, + launchMode = ReaderLaunchMode.NORMAL, + initialPage = 0 + ) + ).use { scenario -> + awaitCondition(scenario, "single-page comic navigator did not open") { activity -> + activity.supportFragmentManager.fragments.any { it.tag == "readium_comic_navigator" } + } + scenario.onActivity { activity -> assertFalse(activity.isComicSpreadReaderVisible()) } + } + } finally { + cbz.delete() + } + } + + // ---- EPUB ---- + + @Test + fun epubUsesTwoColumnsOnLargeHorizontalWindowAndOneOtherwise(): Unit = runBlocking { + val epub = File(context.cacheDir, "fold-spread-${System.nanoTime()}.epub") + val readerKey = "fold-spread-epub-${System.nanoTime()}" + writeEpub(epub) + useProfile(LibraryReaderPreferences(twoPageSpread = ReaderSpreadMode.AUTO)) + try { + launchEpub(epub, readerKey).use { scenario -> + awaitEpubReady(scenario, readerKey) + var qualifies = false + var active = false + scenario.onActivity { activity -> + qualifies = windowQualifiesForSpread(activity) + active = activity.isTwoPageSpreadActive() + } + assertEquals(qualifies, active) + val columns = awaitJavascript(scenario, "getComputedStyle(document.documentElement).columnCount") + assertEquals(if (qualifies) "\"2\"" else "\"1\"", columns) + } + } finally { + epub.delete() + } + } + + @Test + fun epubSpreadPreferenceOffKeepsOneColumn(): Unit = runBlocking { + val epub = File(context.cacheDir, "fold-off-${System.nanoTime()}.epub") + val readerKey = "fold-off-epub-${System.nanoTime()}" + writeEpub(epub) + useProfile(LibraryReaderPreferences(twoPageSpread = ReaderSpreadMode.OFF)) + try { + launchEpub(epub, readerKey).use { scenario -> + awaitEpubReady(scenario, readerKey) + scenario.onActivity { activity -> assertFalse(activity.isTwoPageSpreadActive()) } + } + } finally { + epub.delete() + } + } + + @Test + fun oledEpubRendersPureWhiteOnPureBlackInPageAndWindow(): Unit = runBlocking { + val epub = File(context.cacheDir, "oled-${System.nanoTime()}.epub") + val readerKey = "oled-epub-${System.nanoTime()}" + writeEpub(epub) + useProfile(LibraryReaderPreferences(theme = EpubReaderTheme.OledBlack)) + try { + launchEpub(epub, readerKey).use { scenario -> + awaitEpubReady(scenario, readerKey) + scenario.onActivity { activity -> + val background = activity.window.decorView.background + assertTrue(background is ColorDrawable) + assertEquals(Color.BLACK, (background as ColorDrawable).color) + } + // The bridge loop installs the OLED page style shortly after the document loads. + assertEquals( + "true", + awaitJavascript(scenario, "document.getElementById('lagrange-readium-epub-oled-text') !== null") { + it == "true" + } + ) + assertEquals( + "\"rgb(255, 255, 255)\"", + awaitJavascript(scenario, "getComputedStyle(document.getElementById('link')).color") + ) + assertEquals( + "\"rgb(255, 255, 255)\"", + awaitJavascript(scenario, "getComputedStyle(document.querySelector('p')).color") + ) + assertEquals( + "\"rgb(0, 0, 0)\"", + awaitJavascript(scenario, "getComputedStyle(document.documentElement).backgroundColor") + ) + } + } finally { + epub.delete() + } + } + + @Test + fun nonOledEpubDoesNotInstallOledPageStyle(): Unit = runBlocking { + val epub = File(context.cacheDir, "dark-${System.nanoTime()}.epub") + val readerKey = "dark-epub-${System.nanoTime()}" + writeEpub(epub) + useProfile(LibraryReaderPreferences(theme = EpubReaderTheme.Dark)) + try { + launchEpub(epub, readerKey).use { scenario -> + awaitEpubReady(scenario, readerKey) + // Wait for the bridge loop to install its own image style, then check OLED is absent. + awaitJavascript( + scenario, + "document.getElementById('bookorbit-readium-epub-image-color-preservation') !== null" + ) { it == "true" } + assertEquals( + "false", + awaitJavascript(scenario, "document.getElementById('lagrange-readium-epub-oled-text') !== null") + ) + } + } finally { + epub.delete() + } + } + + private fun launchEpub(epub: File, readerKey: String): ActivityScenario = + ActivityScenario.launch( + ReadiumEpubReaderActivity.createIntent( + context = context, + file = epub, + title = "Fold OLED", + readerKey = readerKey, + libraryId = libraryId, + launchMode = ReaderLaunchMode.NORMAL, + initialChapter = 0, + initialPage = 0, + initialPageCount = 1, + initialPercent = null + ) + ) + + private fun awaitEpubReady(scenario: ActivityScenario, readerKey: String) { + repeat(60) { + var ready = false + scenario.onActivity { activity -> + ready = activity.supportFragmentManager + .findFragmentByTag("readium_epub_navigator") != null + } + if (ready && ReadiumEpubLocatorStore(context).read(readerKey) != null) return + SystemClock.sleep(250) + } + throw AssertionError("EPUB reader did not become ready") + } + + private suspend fun awaitJavascript( + scenario: ActivityScenario, + script: String, + accept: (String?) -> Boolean = { it != null && it != "null" } + ): String? { + var last: String? = null + repeat(40) { + lateinit var activity: ReadiumEpubReaderActivity + scenario.onActivity { activity = it } + last = withContext(Dispatchers.Main) { activity.evaluateReaderJavascript(script) } + if (accept(last)) return last + SystemClock.sleep(250) + } + return last + } + + private fun pageBytes(color: Int): ByteArray = ByteArrayOutputStream().use { output -> + Bitmap.createBitmap(66, 100, Bitmap.Config.ARGB_8888).apply { + eraseColor(color) + compress(Bitmap.CompressFormat.JPEG, 90, output) + recycle() + } + output.toByteArray() + } + + private fun writeCbz(target: File, pageCount: Int) { + val colors = listOf(Color.RED, Color.GREEN, Color.BLUE, Color.YELLOW, Color.CYAN, Color.MAGENTA, Color.GRAY) + ZipOutputStream(target.outputStream().buffered()).use { zip -> + repeat(pageCount) { index -> + zip.putNextEntry(ZipEntry(index.toString().padStart(4, '0') + ".jpg")) + zip.write(pageBytes(colors[index % colors.size])) + zip.closeEntry() + } + } + } + + private fun writeEpub(target: File) { + ZipOutputStream(target.outputStream().buffered()).use { zip -> + val mimetype = "application/epub+zip".toByteArray() + zip.putNextEntry( + ZipEntry("mimetype").apply { + method = ZipEntry.STORED + size = mimetype.size.toLong() + compressedSize = mimetype.size.toLong() + crc = CRC32().apply { update(mimetype) }.value + } + ) + zip.write(mimetype) + zip.closeEntry() + fun deflated(name: String, text: String) { + zip.putNextEntry(ZipEntry(name)) + zip.write(text.trimIndent().toByteArray()) + zip.closeEntry() + } + deflated( + "META-INF/container.xml", + """ + + + + + + + """ + ) + deflated( + "OEBPS/content.opf", + """ + + + + fold-oled-fixture + Fold OLED Fixture + en + 2026-10-09T00:00:00Z + + + + + + + """ + ) + val text = "Fold and OLED reading content. ".repeat(400) + deflated( + "OEBPS/Text/chapter-1.xhtml", + """ + + Chapter 1 + +

Chapter 1

+

Intro with a link.

+

$text

+ + + """ + ) + } + } +} diff --git a/app/src/main/java/com/vangeaux/lagrange/BookOrbitApp.kt b/app/src/main/java/com/vangeaux/lagrange/BookOrbitApp.kt index 1ad3d669..f3872a23 100644 --- a/app/src/main/java/com/vangeaux/lagrange/BookOrbitApp.kt +++ b/app/src/main/java/com/vangeaux/lagrange/BookOrbitApp.kt @@ -1986,7 +1986,8 @@ private class EpubPageMeasurementBridge( internal fun ComicReaderProgressFooter( currentPage: Int, pageCount: Int, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + spreadFirstPage: Int? = null ) { Surface( modifier = modifier.height(EPUB_READER_PROGRESS_FOOTER_HEIGHT), @@ -1998,9 +1999,13 @@ internal fun ComicReaderProgressFooter( contentAlignment = Alignment.Center ) { Text( - text = "Page ${currentPage + 1} of $pageCount", + text = comicReaderFooterLabel(currentPage, pageCount, spreadFirstPage), modifier = Modifier.semantics { - contentDescription = "Comic page ${currentPage + 1} of $pageCount" + contentDescription = comicReaderFooterAccessibilityLabel( + currentPage, + pageCount, + spreadFirstPage + ) }, maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/app/src/main/java/com/vangeaux/lagrange/ComicSpreadPairing.kt b/app/src/main/java/com/vangeaux/lagrange/ComicSpreadPairing.kt new file mode 100644 index 00000000..509f637e --- /dev/null +++ b/app/src/main/java/com/vangeaux/lagrange/ComicSpreadPairing.kt @@ -0,0 +1,131 @@ +package com.vangeaux.lagrange + +/** Pages at or above this width/height ratio are double-page art and are always shown alone. */ +internal const val COMIC_WIDE_PAGE_MIN_ASPECT = 1.0f + +/** + * One screen of a two-page comic spread. [pages] are reading-order page indexes in ascending + * order: a single page, or two consecutive pages. + */ +internal data class ComicSpread(val pages: List) { + init { + require(pages.size in 1..2) { "A spread holds one or two pages" } + require(pages.size == 1 || pages[1] == pages[0] + 1) { "Spread pages must be consecutive" } + } + + val firstPage: Int get() = pages.first() + val lastPage: Int get() = pages.last() + val isPair: Boolean get() = pages.size == 2 + + operator fun contains(page: Int): Boolean = page in pages +} + +internal fun isWideComicPage(aspectRatio: Float?): Boolean = + aspectRatio != null && aspectRatio.isFinite() && aspectRatio >= COMIC_WIDE_PAGE_MIN_ASPECT + +/** + * Pairs comic pages like a printed book. + * + * - [coverAlone] keeps page 1 by itself so pages 2–3, 4–5… face each other. + * - [shift] moves every following pair by one page, for books whose double-page art is split. + * - Wide (double-page) pages are always shown alone, and pairing restarts after them so the + * following pages keep facing each other. + * - Unknown aspect ratios are treated as portrait. + */ +internal fun comicSpreads( + pageCount: Int, + coverAlone: Boolean, + shift: Boolean, + isWidePage: (Int) -> Boolean = { false } +): List { + if (pageCount <= 0) return emptyList() + val spreads = ArrayList(pageCount / 2 + 2) + var page = 0 + var leadingSingles = (if (coverAlone) 1 else 0) + (if (shift) 1 else 0) + while (page < pageCount) { + val wide = isWidePage(page) + val canPair = !wide && + leadingSingles == 0 && + page + 1 < pageCount && + !isWidePage(page + 1) + if (canPair) { + spreads += ComicSpread(listOf(page, page + 1)) + page += 2 + } else { + spreads += ComicSpread(listOf(page)) + if (leadingSingles > 0) leadingSingles-- + page += 1 + } + } + return spreads +} + +/** Index of the spread that shows [page], clamped into the book. */ +internal fun comicSpreadIndexForPage(spreads: List, page: Int): Int { + if (spreads.isEmpty()) return 0 + val clamped = page.coerceIn(0, spreads.last().lastPage) + val index = spreads.binarySearch { spread -> + when { + spread.lastPage < clamped -> -1 + spread.firstPage > clamped -> 1 + else -> 0 + } + } + return if (index >= 0) index else 0 +} + +/** Left-to-right screen order of a spread's pages: right-to-left books put the earlier page on the right. */ +internal fun comicSpreadScreenOrder(spread: ComicSpread, readingDirection: LibraryReadingDirection): List = + if (readingDirection == LibraryReadingDirection.RIGHT_TO_LEFT) spread.pages.reversed() else spread.pages + +/** + * Which page of a spread a horizontal position falls on. Single-page spreads always resolve to + * their page; pairs split at the middle of the spread, honouring reading direction. + */ +internal fun comicSpreadPageAt( + spread: ComicSpread, + x: Float, + width: Float, + readingDirection: LibraryReadingDirection +): Int { + if (!spread.isPair || width <= 0f) return spread.firstPage + val screenOrder = comicSpreadScreenOrder(spread, readingDirection) + return if (x < width / 2f) screenOrder[0] else screenOrder[1] +} + +/** Page persisted as the resume position: the first page so reopening shows the same spread. */ +internal fun comicSpreadResumePage(spread: ComicSpread): Int = spread.firstPage + +/** Page used for progress: the last visible page, so the final spread reports 100%. */ +internal fun comicSpreadProgressPage(spread: ComicSpread): Int = spread.lastPage + +/** + * Re-anchors after the pairing changes (for example when a page turns out to be wide), keeping + * the page the reader was looking at on screen. + */ +internal fun comicSpreadIndexAfterRepairing( + previous: ComicSpread?, + spreads: List +): Int = comicSpreadIndexForPage(spreads, previous?.firstPage ?: 0) + +internal fun comicReaderFooterLabel(currentPage: Int, pageCount: Int, spreadFirstPage: Int? = null): String { + val count = pageCount.coerceAtLeast(1) + val last = (currentPage + 1).coerceIn(1, count) + val first = spreadFirstPage?.let { (it + 1).coerceIn(1, count) } + return if (first != null && first < last) "Pages $first–$last of $count" else "Page $last of $count" +} + +internal fun comicReaderFooterAccessibilityLabel( + currentPage: Int, + pageCount: Int, + spreadFirstPage: Int? = null +): String { + val count = pageCount.coerceAtLeast(1) + val last = (currentPage + 1).coerceIn(1, count) + val first = spreadFirstPage?.let { (it + 1).coerceIn(1, count) } + return if (first != null && first < last) { + "Comic pages $first and $last of $count" + } else { + "Comic page $last of $count" + } +} diff --git a/app/src/main/java/com/vangeaux/lagrange/ComicSpreadReader.kt b/app/src/main/java/com/vangeaux/lagrange/ComicSpreadReader.kt new file mode 100644 index 00000000..a809cdea --- /dev/null +++ b/app/src/main/java/com/vangeaux/lagrange/ComicSpreadReader.kt @@ -0,0 +1,400 @@ +package com.vangeaux.lagrange + +import android.graphics.Bitmap +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.calculatePan +import androidx.compose.foundation.gestures.calculateZoom +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChanged +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +internal const val COMIC_SPREAD_PREFETCH_SPREAD_COUNT = 2 +internal const val COMIC_SPREAD_READER_TEST_TAG = "comic-spread-reader" + +/** Activity-facing navigation for the spread pager (tap zones, volume buttons, page list). */ +internal interface ComicSpreadNavigator { + fun goToPage(page: Int) + fun step(delta: Int) +} + +/** Decode widths for the two halves of a spread around [gutter], and for a lone page. */ +internal data class ComicSpreadWidths(val leftPx: Int, val gutterPx: Int, val rightPx: Int, val singlePx: Int) { + val pairDecodePx: Int get() = maxOf(leftPx, rightPx, 1) +} + +internal fun comicSpreadWidths(totalWidthPx: Int, gutter: IntRange): ComicSpreadWidths { + val total = totalWidthPx.coerceAtLeast(0) + val start = gutter.first.coerceIn(0, total) + val end = (gutter.last + 1).coerceIn(start, total) + return ComicSpreadWidths( + leftPx = start, + gutterPx = end - start, + rightPx = total - end, + singlePx = total.coerceAtLeast(1) + ) +} + +internal fun comicSpreadDecodeWidth(spread: ComicSpread, widths: ComicSpreadWidths): Int = + if (spread.isPair) widths.pairDecodePx else widths.singlePx + +/** Spread indexes to warm around the settled one, nearest first. */ +internal fun comicSpreadPrefetchIndexes(settled: Int, spreadCount: Int, radius: Int): List = + (1..radius.coerceAtLeast(0)) + .flatMap { distance -> listOf(settled + distance, settled - distance) } + .filter { it in 0 until spreadCount } + +private sealed interface SpreadPageState { + data object Loading : SpreadPageState + data class Loaded(val bitmap: Bitmap) : SpreadPageState + data object Failed : SpreadPageState +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun ComicSpreadReader( + pageCount: Int, + initialPage: Int, + coverAlone: Boolean, + shift: Boolean, + widePages: Set, + readingDirection: LibraryReadingDirection, + tapZoneLayout: ReaderTapZoneLayout, + tapZoneInvertMode: ReaderTapZoneInvertMode, + gutterForWidth: (widthPx: Int) -> IntRange, + animateNavigation: () -> Boolean, + cachedPage: (pageIndex: Int, targetWidthPx: Int) -> Bitmap?, + loadPage: suspend (pageIndex: Int, targetWidthPx: Int) -> Bitmap?, + onSpreadChanged: (ComicSpread) -> Unit, + onTap: (ContinuousComicTapAction) -> Unit, + onLongPress: (pageIndex: Int, bitmap: Bitmap) -> Unit, + onNavigatorAvailable: (ComicSpreadNavigator?) -> Unit, + modifier: Modifier = Modifier +) { + val spreads = remember(pageCount, coverAlone, shift, widePages) { + comicSpreads(pageCount, coverAlone, shift) { it in widePages } + } + if (spreads.isEmpty()) return + // Plain holder: re-anchoring must not trigger recomposition. + val anchor = remember { + arrayOf(ComicSpread(listOf(initialPage.coerceIn(0, (pageCount - 1).coerceAtLeast(0))))) + } + val pagerState = rememberPagerState( + initialPage = comicSpreadIndexForPage(spreads, initialPage) + ) { spreads.size } + val currentSpreads by rememberUpdatedState(spreads) + val currentOnSpreadChanged by rememberUpdatedState(onSpreadChanged) + val scope = rememberCoroutineScope() + var zoomScale by remember { mutableFloatStateOf(1f) } + + DisposableEffect(pagerState) { + val navigator = object : ComicSpreadNavigator { + override fun goToPage(page: Int) { + scrollTo(scope, pagerState, comicSpreadIndexForPage(currentSpreads, page), animateNavigation()) + } + + override fun step(delta: Int) { + val target = (pagerState.currentPage + delta).coerceIn(0, currentSpreads.lastIndex) + scrollTo(scope, pagerState, target, animateNavigation()) + } + } + onNavigatorAvailable(navigator) + onDispose { onNavigatorAvailable(null) } + } + + // Re-anchor first when pairing changes (e.g. a wide page was discovered), then report settles. + LaunchedEffect(pagerState, spreads) { + val target = comicSpreadIndexAfterRepairing(anchor[0], spreads) + if (pagerState.currentPage != target) pagerState.scrollToPage(target) + snapshotFlow { pagerState.settledPage } + .distinctUntilChanged() + .collect { index -> + spreads.getOrNull(index)?.let { spread -> + anchor[0] = spread + zoomScale = 1f + currentOnSpreadChanged(spread) + } + } + } + + BoxWithConstraints( + modifier = modifier + .fillMaxSize() + .testTag(COMIC_SPREAD_READER_TEST_TAG) + ) { + val totalWidthPx = with(LocalDensity.current) { maxWidth.roundToPx() } + val widths = remember(totalWidthPx) { comicSpreadWidths(totalWidthPx, gutterForWidth(totalWidthPx)) } + + LaunchedEffect(pagerState, spreads, widths) { + snapshotFlow { pagerState.settledPage } + .distinctUntilChanged() + .collectLatest { settled -> + coroutineScope { + comicSpreadPrefetchIndexes(settled, spreads.size, COMIC_SPREAD_PREFETCH_SPREAD_COUNT) + .flatMap { index -> + val spread = spreads[index] + spread.pages.map { page -> page to comicSpreadDecodeWidth(spread, widths) } + } + .map { (page, width) -> + async(Dispatchers.IO) { runCatching { loadPage(page, width) } } + } + .awaitAll() + } + } + } + + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + // Right-to-left books advance leftwards, like a printed manga. + reverseLayout = readingDirection == LibraryReadingDirection.RIGHT_TO_LEFT, + userScrollEnabled = zoomScale <= 1.01f, + beyondBoundsPageCount = 1, + key = { index -> spreads.getOrNull(index)?.firstPage ?: index } + ) { index -> + val spread = spreads.getOrNull(index) ?: return@HorizontalPager + ComicSpreadPage( + spread = spread, + widths = widths, + readingDirection = readingDirection, + isCurrent = index == pagerState.currentPage, + zoomScale = if (index == pagerState.currentPage) zoomScale else 1f, + onZoomChange = { zoomScale = it }, + cachedPage = cachedPage, + loadPage = loadPage, + onTap = { position, width, height -> + onTap( + continuousComicTapAction( + x = position.x, + y = position.y, + width = width, + height = height, + readingDirection = readingDirection, + layout = tapZoneLayout, + invertMode = tapZoneInvertMode + ) + ) + }, + onLongPress = onLongPress + ) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +private fun scrollTo(scope: CoroutineScope, pagerState: PagerState, target: Int, animate: Boolean) { + scope.launch { + if (animate) pagerState.animateScrollToPage(target) else pagerState.scrollToPage(target) + } +} + +@Composable +private fun ComicSpreadPage( + spread: ComicSpread, + widths: ComicSpreadWidths, + readingDirection: LibraryReadingDirection, + isCurrent: Boolean, + zoomScale: Float, + onZoomChange: (Float) -> Unit, + cachedPage: (pageIndex: Int, targetWidthPx: Int) -> Bitmap?, + loadPage: suspend (pageIndex: Int, targetWidthPx: Int) -> Bitmap?, + onTap: (position: Offset, width: Float, height: Float) -> Unit, + onLongPress: (pageIndex: Int, bitmap: Bitmap) -> Unit +) { + val decodeWidth = comicSpreadDecodeWidth(spread, widths) + val loaded = remember(spread, decodeWidth) { mutableStateMapOf() } + var offset by remember(spread) { mutableStateOf(Offset.Zero) } + val currentZoom by rememberUpdatedState(zoomScale) + val density = LocalDensity.current + val pageDescription = if (spread.isPair) { + "Pages ${spread.firstPage + 1} and ${spread.lastPage + 1}" + } else { + "Page ${spread.firstPage + 1}" + } + LaunchedEffect(isCurrent) { if (!isCurrent) offset = Offset.Zero } + + Box( + modifier = Modifier + .fillMaxSize() + .semantics { contentDescription = pageDescription } + .pointerInput(spread) { + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false) + do { + val event = awaitPointerEvent() + val pressed = event.changes.count { it.pressed } + if (pressed >= 2 || currentZoom > 1.01f) { + val nextScale = boundedComicImageScale(currentZoom * event.calculateZoom()) + val nextOffset = boundedComicImagePan( + offset + event.calculatePan(), + size.width.toFloat(), + size.height.toFloat(), + nextScale + ) + if (nextScale <= 1.01f) { + onZoomChange(1f) + offset = Offset.Zero + } else { + onZoomChange(nextScale) + offset = nextOffset + } + event.changes.forEach { change -> + if (change.positionChanged()) change.consume() + } + } + } while (event.changes.any { it.pressed }) + } + } + .pointerInput(spread, readingDirection) { + detectTapGestures( + onTap = { position -> + onTap(position, size.width.toFloat(), size.height.toFloat()) + }, + onLongPress = { position -> + val page = comicSpreadPageAt(spread, position.x, size.width.toFloat(), readingDirection) + loaded[page]?.let { bitmap -> onLongPress(page, bitmap) } + } + ) + } + .graphicsLayer { + scaleX = zoomScale + scaleY = zoomScale + translationX = offset.x + translationY = offset.y + } + ) { + if (spread.isPair) { + val screenOrder = comicSpreadScreenOrder(spread, readingDirection) + Row(modifier = Modifier.fillMaxSize()) { + SpreadPageImage( + page = screenOrder[0], + decodeWidth = decodeWidth, + // Pages meet at the gutter like a bound book. + alignment = Alignment.CenterEnd, + modifier = Modifier.width(with(density) { widths.leftPx.toDp() }).fillMaxHeight(), + cachedPage = cachedPage, + loadPage = loadPage, + onLoaded = { loaded[screenOrder[0]] = it } + ) + Spacer(modifier = Modifier.width(with(density) { widths.gutterPx.toDp() })) + SpreadPageImage( + page = screenOrder[1], + decodeWidth = decodeWidth, + alignment = Alignment.CenterStart, + modifier = Modifier.width(with(density) { widths.rightPx.toDp() }).fillMaxHeight(), + cachedPage = cachedPage, + loadPage = loadPage, + onLoaded = { loaded[screenOrder[1]] = it } + ) + } + } else { + SpreadPageImage( + page = spread.firstPage, + decodeWidth = decodeWidth, + alignment = Alignment.Center, + modifier = Modifier.fillMaxSize(), + cachedPage = cachedPage, + loadPage = loadPage, + onLoaded = { loaded[spread.firstPage] = it } + ) + } + } +} + +@Composable +private fun SpreadPageImage( + page: Int, + decodeWidth: Int, + alignment: Alignment, + modifier: Modifier, + cachedPage: (pageIndex: Int, targetWidthPx: Int) -> Bitmap?, + loadPage: suspend (pageIndex: Int, targetWidthPx: Int) -> Bitmap?, + onLoaded: (Bitmap) -> Unit +) { + var retryKey by remember { mutableIntStateOf(0) } + val state by produceState( + initialValue = cachedPage(page, decodeWidth)?.let(SpreadPageState::Loaded) ?: SpreadPageState.Loading, + page, + decodeWidth, + retryKey + ) { + value = withContext(Dispatchers.IO) { + cachedPage(page, decodeWidth) ?: runCatching { loadPage(page, decodeWidth) }.getOrNull() + }?.let(SpreadPageState::Loaded) ?: SpreadPageState.Failed + } + Box(modifier = modifier, contentAlignment = alignment) { + when (val pageState = state) { + SpreadPageState.Loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = Color.White) + } + is SpreadPageState.Loaded -> { + LaunchedEffect(pageState.bitmap) { onLoaded(pageState.bitmap) } + Image( + bitmap = pageState.bitmap.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Fit, + alignment = alignment, + modifier = Modifier.fillMaxSize() + ) + } + SpreadPageState.Failed -> Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center + ) { + Text("Page ${page + 1} could not be loaded", color = Color.White) + TextButton(onClick = { retryKey++ }) { Text("Retry") } + } + } + } +} diff --git a/app/src/main/java/com/vangeaux/lagrange/ReadiumComicReaderActivity.kt b/app/src/main/java/com/vangeaux/lagrange/ReadiumComicReaderActivity.kt index b95120b4..07e5ee1f 100644 --- a/app/src/main/java/com/vangeaux/lagrange/ReadiumComicReaderActivity.kt +++ b/app/src/main/java/com/vangeaux/lagrange/ReadiumComicReaderActivity.kt @@ -233,6 +233,12 @@ class ReadiumComicReaderActivity : FragmentActivity() { private val continuousComicPageLoadLocks = ConcurrentHashMap() private var continuousListState: LazyListState? = null private var continuousComicView: ComposeView? = null + private val windowTracker by lazy { ReaderWindowTracker(this) } + private var comicSpreadView: ComposeView? = null + private var spreadNavigator: ComicSpreadNavigator? = null + private var spreadActive = false + private var spreadFirstPage by mutableStateOf(null) + private var wideComicPages by mutableStateOf(emptySet()) private var navigatorLocationJob: Job? = null private var currentPage by mutableStateOf(0) private var currentPageCount by mutableStateOf(1) @@ -263,10 +269,10 @@ class ReadiumComicReaderActivity : FragmentActivity() { if (event.action != KeyEvent.ACTION_DOWN) return true if (event.repeatCount > 0) return true when (action) { - VolumeButtonNavigationAction.PREVIOUS -> - volumeButtonNavigationTarget(action, currentPage, currentPageCount)?.let(::goToPage) - VolumeButtonNavigationAction.NEXT -> - volumeButtonNavigationTarget(action, currentPage, currentPageCount)?.let(::goToPage) + VolumeButtonNavigationAction.PREVIOUS -> spreadNavigator?.step(-1) + ?: volumeButtonNavigationTarget(action, currentPage, currentPageCount)?.let(::goToPage) + VolumeButtonNavigationAction.NEXT -> spreadNavigator?.step(1) + ?: volumeButtonNavigationTarget(action, currentPage, currentPageCount)?.let(::goToPage) } return true } @@ -307,6 +313,12 @@ class ReadiumComicReaderActivity : FragmentActivity() { fileId = intent.getStringExtra(EXTRA_FILE_ID), enabled = !isPreview ) + windowTracker.start() + lifecycleScope.launch { + windowTracker.spreadChanges(::computeComicSpread).collect { + if (publication != null && computeComicSpread() != spreadActive) rebuildPublication() + } + } configureSystemBars() createReaderViews() restoreReaderUi(savedInstanceState) @@ -420,6 +432,7 @@ class ReadiumComicReaderActivity : FragmentActivity() { ComicReaderProgressFooter( currentPage = currentPage, pageCount = currentPageCount, + spreadFirstPage = spreadFirstPage, modifier = Modifier.fillMaxSize() ) } @@ -584,6 +597,7 @@ class ReadiumComicReaderActivity : FragmentActivity() { currentPageCount = openedPublication.readingOrder.size.coerceAtLeast(1) val initialLocator = requestedLocator ?: initialLocator(openedPublication) if (readerPreferences.comicLayoutMode == ReaderLayoutMode.CONTINUOUS) { + spreadActive = false showContinuousPublication(openedPublication, initialLocator) progressView?.visibility = View.GONE if (showTutorial && showTapZoneTutorialAfterChanges) { @@ -591,6 +605,13 @@ class ReadiumComicReaderActivity : FragmentActivity() { } return } + spreadActive = computeComicSpread() + if (spreadActive) { + showSpreadPublication(openedPublication, initialLocator) + progressView?.visibility = View.GONE + if (showTutorial && showTapZoneTutorialAfterChanges) showTapZoneTutorial() + return + } val fragmentFactory = ImageNavigatorFragment.createFactory( publication = openedPublication, initialLocator = initialLocator @@ -764,6 +785,81 @@ class ReadiumComicReaderActivity : FragmentActivity() { } } + private fun computeComicSpread(): Boolean = shouldUseTwoPageSpread( + mode = readerPreferences.twoPageSpread, + window = windowTracker.state.value, + layoutMode = readerPreferences.comicLayoutMode + ) + + private fun showSpreadPublication( + openedPublication: Publication, + initialLocator: Locator + ) { + val initialPage = openedPublication.readingOrder.indexOfFirst { link -> + link.url().isEquivalent(initialLocator.href.removeFragment()) + }.coerceAtLeast(0) + currentPage = initialPage + comicSpreadView = ComposeView(this).apply { + setContent { + BookOrbitTheme { + ComicSpreadReader( + pageCount = openedPublication.readingOrder.size, + initialPage = initialPage, + coverAlone = readerPreferences.comicSpreadCoverAlone, + shift = readerPreferences.comicSpreadShift, + widePages = wideComicPages, + readingDirection = readingDirection, + tapZoneLayout = readerPreferences.tapZoneLayout, + tapZoneInvertMode = readerPreferences.tapZoneInvertMode, + gutterForWidth = { widthPx -> + // Pages meet with no gap; only a separating hinge pushes them apart. + readerSpreadGutterPx(widthPx, windowTracker.state.value.hinge, minimumGapPx = 0) + }, + animateNavigation = { + !AppPreferencesStore(this@ReadiumComicReaderActivity).read().reduceMotion + }, + cachedPage = ::cachedContinuousComicPage, + loadPage = { pageIndex, targetWidthPx -> + loadContinuousComicPage(openedPublication, pageIndex, targetWidthPx) + }, + onSpreadChanged = { spread -> updateSpread(spread, openedPublication) }, + onTap = ::handleSpreadTap, + onLongPress = { pageIndex, bitmap -> comicPageViewer = pageIndex to bitmap }, + onNavigatorAvailable = { spreadNavigator = it }, + modifier = Modifier.fillMaxSize() + ) + } + } + }.also { view -> + readerContainer.addView( + view, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + ) + } + } + + private fun updateSpread(spread: ComicSpread, openedPublication: Publication) { + spreadFirstPage = spread.firstPage.takeIf { spread.isPair } + currentPage = comicSpreadProgressPage(spread) + .coerceIn(0, openedPublication.readingOrder.lastIndex) + val resumeLink = openedPublication.readingOrder.getOrNull(comicSpreadResumePage(spread)) ?: return + openedPublication.locatorFromLink(resumeLink)?.let(::persistLocation) + } + + private fun handleSpreadTap(action: ContinuousComicTapAction) { + when (action) { + ContinuousComicTapAction.PREVIOUS -> spreadNavigator?.step(-1) + ContinuousComicTapAction.NEXT -> spreadNavigator?.step(1) + ContinuousComicTapAction.MENU -> toggleChrome() + } + } + + /** First page of the visible spread, or the current page outside spread mode. */ + private fun resumePageIndex(): Int = spreadFirstPage ?: currentPage + private suspend fun loadContinuousComicPage( openedPublication: Publication, pageIndex: Int, @@ -785,11 +881,16 @@ class ReadiumComicReaderActivity : FragmentActivity() { ?: return@withLock null val decoded = decodeContinuousComicPage(bytes, targetWidthPx) if (decoded != null) { + val aspectRatio = decoded.width.toFloat() / decoded.height.coerceAtLeast(1) synchronized(continuousComicPageCache) { - continuousComicPageAspectRatios[cacheKey] = - decoded.width.toFloat() / decoded.height.coerceAtLeast(1) + continuousComicPageAspectRatios[cacheKey] = aspectRatio continuousComicPageCache.put(cacheKey, decoded) } + if (isWideComicPage(aspectRatio)) { + runOnUiThread { + if (pageIndex !in wideComicPages) wideComicPages = wideComicPages + pageIndex + } + } } decoded } finally { @@ -892,6 +993,10 @@ class ReadiumComicReaderActivity : FragmentActivity() { } private fun goToPage(index: Int) { + spreadNavigator?.let { navigator -> + navigator.goToPage(index.coerceIn(0, currentPageCount - 1)) + return + } continuousListState?.let { listState -> lifecycleScope.launch { val target = index.coerceIn(0, currentPageCount - 1) @@ -911,7 +1016,12 @@ class ReadiumComicReaderActivity : FragmentActivity() { private fun applyReaderPreferences(next: LibraryReaderPreferences) { val normalized = next.normalized() - val layoutChanged = normalized.comicLayoutMode != readerPreferences.comicLayoutMode + val layoutChanged = normalized.comicLayoutMode != readerPreferences.comicLayoutMode || + normalized.twoPageSpread != readerPreferences.twoPageSpread || + (spreadActive && ( + normalized.comicSpreadCoverAlone != readerPreferences.comicSpreadCoverAlone || + normalized.comicSpreadShift != readerPreferences.comicSpreadShift + )) val tapZoneChanged = readerTapZonePreferencesChanged(readerPreferences, normalized) readerPreferences = normalized readingDirection = normalized.readingDirection @@ -939,9 +1049,13 @@ class ReadiumComicReaderActivity : FragmentActivity() { private fun rebuildPublication() { val openedPublication = publication ?: return - val locator = openedPublication.readingOrder.getOrNull(currentPage) + val locator = openedPublication.readingOrder.getOrNull(resumePageIndex()) ?.let(openedPublication::locatorFromLink) ?: return + comicSpreadView?.let(readerContainer::removeView) + comicSpreadView = null + spreadNavigator = null + spreadFirstPage = null supportFragmentManager.findFragmentByTag(NAVIGATOR_TAG)?.let { fragment -> supportFragmentManager.beginTransaction().remove(fragment).commitNow() } @@ -1034,6 +1148,9 @@ class ReadiumComicReaderActivity : FragmentActivity() { internal fun isContinuousComicReaderVisible(): Boolean = continuousComicView?.isAttachedToWindow == true + internal fun isComicSpreadReaderVisible(): Boolean = + comicSpreadView?.isAttachedToWindow == true + private fun updateResult(reason: ReaderCompletionReason? = null) { val percent = if (currentPageCount <= 1) { 100f @@ -1124,7 +1241,7 @@ class ReadiumComicReaderActivity : FragmentActivity() { override fun onSaveInstanceState(outState: Bundle) { val currentLocator = navigator?.currentLocator?.value - ?: publication?.readingOrder?.getOrNull(currentPage)?.let { link -> + ?: publication?.readingOrder?.getOrNull(resumePageIndex())?.let { link -> publication?.locatorFromLink(link) } ?: restoredLocator diff --git a/app/src/main/java/com/vangeaux/lagrange/ReadiumEpubReaderActivity.kt b/app/src/main/java/com/vangeaux/lagrange/ReadiumEpubReaderActivity.kt index 64dac97d..97367e11 100644 --- a/app/src/main/java/com/vangeaux/lagrange/ReadiumEpubReaderActivity.kt +++ b/app/src/main/java/com/vangeaux/lagrange/ReadiumEpubReaderActivity.kt @@ -2843,6 +2843,11 @@ class ReadiumEpubReaderActivity : FragmentActivity() { internal fun hasShownTapZoneTutorial(): Boolean = tapZoneTutorialHasShown + internal fun isTwoPageSpreadActive(): Boolean = twoPageSpreadActive + + internal suspend fun evaluateReaderJavascript(script: String): String? = + navigator?.evaluateJavascript(script) + private fun updateResult(reason: ReaderCompletionReason? = null) { val data = Intent().apply { reason?.let { putExtra(EXTRA_READER_COMPLETION_REASON, it.name) } diff --git a/app/src/test/java/com/vangeaux/lagrange/ComicSpreadLayoutTest.kt b/app/src/test/java/com/vangeaux/lagrange/ComicSpreadLayoutTest.kt new file mode 100644 index 00000000..40232ec6 --- /dev/null +++ b/app/src/test/java/com/vangeaux/lagrange/ComicSpreadLayoutTest.kt @@ -0,0 +1,65 @@ +package com.vangeaux.lagrange + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ComicSpreadLayoutTest { + @Test + fun `flat Fold8 spread splits the window in half with no gutter`() { + val widths = comicSpreadWidths(2448, readerSpreadGutterPx(2448, hinge = null, minimumGapPx = 0)) + + assertEquals(ComicSpreadWidths(leftPx = 1224, gutterPx = 0, rightPx = 1224, singlePx = 2448), widths) + assertEquals(1224, widths.pairDecodePx) + } + + @Test + fun `odd width keeps every pixel`() { + val widths = comicSpreadWidths(2449, readerSpreadGutterPx(2449, hinge = null, minimumGapPx = 0)) + + assertEquals(2449, widths.leftPx + widths.gutterPx + widths.rightPx) + assertEquals(1225, widths.pairDecodePx) + } + + @Test + fun `separating hinge is kept clear between the pages`() { + val hinge = readerHingeFrom( + isVertical = true, + isHalfOpened = true, + isSeparating = true, + leftPx = 1200, + rightPx = 1260 + ) + val widths = comicSpreadWidths(2448, readerSpreadGutterPx(2448, hinge, minimumGapPx = 0)) + + assertEquals(1200, widths.leftPx) + assertEquals(60, widths.gutterPx) + assertEquals(1188, widths.rightPx) + assertEquals(1200, widths.pairDecodePx) + } + + @Test + fun `degenerate widths stay non negative`() { + assertEquals(ComicSpreadWidths(0, 0, 0, 1), comicSpreadWidths(0, 0 until 0)) + assertEquals(ComicSpreadWidths(0, 0, 0, 1), comicSpreadWidths(-10, 5 until 9)) + assertEquals(1, comicSpreadWidths(0, 0 until 0).pairDecodePx) + val clamped = comicSpreadWidths(100, 90 until 400) + assertEquals(ComicSpreadWidths(90, 10, 0, 100), clamped) + } + + @Test + fun `pairs decode at half width and lone pages at full width`() { + val widths = comicSpreadWidths(2000, 1000 until 1000) + + assertEquals(1000, comicSpreadDecodeWidth(ComicSpread(listOf(3, 4)), widths)) + assertEquals(2000, comicSpreadDecodeWidth(ComicSpread(listOf(0)), widths)) + } + + @Test + fun `prefetch warms nearest spreads first and stays in bounds`() { + assertEquals(listOf(6, 4, 7, 3), comicSpreadPrefetchIndexes(settled = 5, spreadCount = 20, radius = 2)) + assertEquals(listOf(1, 2), comicSpreadPrefetchIndexes(settled = 0, spreadCount = 20, radius = 2)) + assertEquals(listOf(8, 7), comicSpreadPrefetchIndexes(settled = 9, spreadCount = 10, radius = 2)) + assertEquals(emptyList(), comicSpreadPrefetchIndexes(settled = 0, spreadCount = 1, radius = 2)) + assertEquals(emptyList(), comicSpreadPrefetchIndexes(settled = 3, spreadCount = 10, radius = 0)) + } +} diff --git a/app/src/test/java/com/vangeaux/lagrange/ComicSpreadPairingTest.kt b/app/src/test/java/com/vangeaux/lagrange/ComicSpreadPairingTest.kt new file mode 100644 index 00000000..77354207 --- /dev/null +++ b/app/src/test/java/com/vangeaux/lagrange/ComicSpreadPairingTest.kt @@ -0,0 +1,216 @@ +package com.vangeaux.lagrange + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ComicSpreadPairingTest { + private fun pairs( + pageCount: Int, + coverAlone: Boolean = true, + shift: Boolean = false, + wide: Set = emptySet() + ): List> = comicSpreads(pageCount, coverAlone, shift) { it in wide }.map { it.pages } + + @Test + fun `cover alone pairs the following pages like a printed book`() { + assertEquals( + listOf(listOf(0), listOf(1, 2), listOf(3, 4), listOf(5, 6)), + pairs(7) + ) + } + + @Test + fun `cover alone with an even page count leaves the last page alone`() { + assertEquals( + listOf(listOf(0), listOf(1, 2), listOf(3, 4), listOf(5)), + pairs(6) + ) + } + + @Test + fun `without cover alone pages pair from the first page`() { + assertEquals(listOf(listOf(0, 1), listOf(2, 3), listOf(4)), pairs(5, coverAlone = false)) + } + + @Test + fun `shift moves every pair by one page`() { + assertEquals( + listOf(listOf(0), listOf(1), listOf(2, 3), listOf(4, 5)), + pairs(6, coverAlone = true, shift = true) + ) + assertEquals( + listOf(listOf(0), listOf(1, 2), listOf(3, 4)), + pairs(5, coverAlone = false, shift = true) + ) + } + + @Test + fun `wide pages stand alone and pairing restarts after them`() { + assertEquals( + listOf(listOf(0), listOf(1, 2), listOf(3), listOf(4, 5), listOf(6)), + pairs(7, wide = setOf(3)) + ) + } + + @Test + fun `a page before a wide page is not paired with it`() { + assertEquals( + listOf(listOf(0), listOf(1), listOf(2), listOf(3, 4)), + pairs(5, wide = setOf(2)) + ) + } + + @Test + fun `consecutive wide pages each stand alone`() { + assertEquals( + listOf(listOf(0), listOf(1), listOf(2), listOf(3, 4)), + pairs(5, wide = setOf(1, 2)) + ) + } + + @Test + fun `wide cover counts as the lone cover`() { + assertEquals(listOf(listOf(0), listOf(1, 2), listOf(3)), pairs(4, wide = setOf(0))) + } + + @Test + fun `tiny and empty books`() { + assertEquals(emptyList>(), pairs(0)) + assertEquals(emptyList>(), pairs(-3)) + assertEquals(listOf(listOf(0)), pairs(1)) + assertEquals(listOf(listOf(0)), pairs(1, coverAlone = false)) + assertEquals(listOf(listOf(0), listOf(1)), pairs(2)) + assertEquals(listOf(listOf(0, 1)), pairs(2, coverAlone = false)) + } + + @Test + fun `every page appears exactly once in reading order for many layouts`() { + for (count in 0..41) { + for (cover in listOf(true, false)) { + for (shift in listOf(true, false)) { + val wide = (0 until count).filter { it % 7 == 5 || it % 11 == 3 }.toSet() + val flattened = pairs(count, cover, shift, wide).flatten() + assertEquals("count=$count cover=$cover shift=$shift", (0 until count).toList(), flattened) + comicSpreads(count, cover, shift) { it in wide }.forEach { spread -> + if (spread.isPair) { + assertFalse(spread.firstPage in wide) + assertFalse(spread.lastPage in wide) + } + } + } + } + } + } + + @Test + fun `spread index lookup finds the spread showing a page`() { + val spreads = comicSpreads(7, coverAlone = true, shift = false) + + assertEquals(0, comicSpreadIndexForPage(spreads, 0)) + assertEquals(1, comicSpreadIndexForPage(spreads, 1)) + assertEquals(1, comicSpreadIndexForPage(spreads, 2)) + assertEquals(3, comicSpreadIndexForPage(spreads, 6)) + assertEquals(3, comicSpreadIndexForPage(spreads, 99)) + assertEquals(0, comicSpreadIndexForPage(spreads, -4)) + assertEquals(0, comicSpreadIndexForPage(emptyList(), 3)) + for (page in 0 until 7) { + assertTrue(page in spreads[comicSpreadIndexForPage(spreads, page)]) + } + } + + @Test + fun `right to left books show the earlier page on the right`() { + val spread = ComicSpread(listOf(4, 5)) + + assertEquals(listOf(4, 5), comicSpreadScreenOrder(spread, LibraryReadingDirection.LEFT_TO_RIGHT)) + assertEquals(listOf(5, 4), comicSpreadScreenOrder(spread, LibraryReadingDirection.RIGHT_TO_LEFT)) + assertEquals(listOf(7), comicSpreadScreenOrder(ComicSpread(listOf(7)), LibraryReadingDirection.RIGHT_TO_LEFT)) + } + + @Test + fun `long press position resolves to the page under the finger`() { + val spread = ComicSpread(listOf(4, 5)) + val ltr = LibraryReadingDirection.LEFT_TO_RIGHT + val rtl = LibraryReadingDirection.RIGHT_TO_LEFT + + assertEquals(4, comicSpreadPageAt(spread, 100f, 1000f, ltr)) + assertEquals(5, comicSpreadPageAt(spread, 900f, 1000f, ltr)) + assertEquals(5, comicSpreadPageAt(spread, 100f, 1000f, rtl)) + assertEquals(4, comicSpreadPageAt(spread, 900f, 1000f, rtl)) + assertEquals(7, comicSpreadPageAt(ComicSpread(listOf(7)), 900f, 1000f, ltr)) + assertEquals(4, comicSpreadPageAt(spread, 10f, 0f, rtl)) + } + + @Test + fun `resume uses the first page and progress uses the last visible page`() { + val pair = ComicSpread(listOf(8, 9)) + val single = ComicSpread(listOf(0)) + + assertEquals(8, comicSpreadResumePage(pair)) + assertEquals(9, comicSpreadProgressPage(pair)) + assertEquals(0, comicSpreadResumePage(single)) + assertEquals(0, comicSpreadProgressPage(single)) + } + + @Test + fun `final spread reports the last page so progress reaches the end`() { + val spreads = comicSpreads(9, coverAlone = true, shift = false) + assertEquals(8, comicSpreadProgressPage(spreads.last())) + } + + @Test + fun `resume page reopens the same spread`() { + val spreads = comicSpreads(20, coverAlone = true, shift = false) + spreads.forEachIndexed { index, spread -> + assertEquals(index, comicSpreadIndexForPage(spreads, comicSpreadResumePage(spread))) + } + } + + @Test + fun `discovering a wide page keeps the current page on screen`() { + val before = comicSpreads(10, coverAlone = true, shift = false) + val current = before[comicSpreadIndexForPage(before, 5)] + val after = comicSpreads(10, coverAlone = true, shift = false) { it == 4 } + + val index = comicSpreadIndexAfterRepairing(current, after) + + assertTrue(current.firstPage in after[index]) + assertEquals(0, comicSpreadIndexAfterRepairing(null, after)) + } + + @Test + fun `aspect ratio classification`() { + assertFalse(isWideComicPage(null)) + assertFalse(isWideComicPage(0.66f)) + assertFalse(isWideComicPage(0.99f)) + assertTrue(isWideComicPage(1.0f)) + assertTrue(isWideComicPage(1.4f)) + assertFalse(isWideComicPage(Float.NaN)) + assertFalse(isWideComicPage(Float.POSITIVE_INFINITY)) + } + + @Test(expected = IllegalArgumentException::class) + fun `spread rejects non consecutive pages`() { + ComicSpread(listOf(2, 4)) + } + + @Test(expected = IllegalArgumentException::class) + fun `spread rejects more than two pages`() { + ComicSpread(listOf(1, 2, 3)) + } + + @Test + fun `footer shows a page range for pairs`() { + assertEquals("Pages 2–3 of 40", comicReaderFooterLabel(currentPage = 2, pageCount = 40, spreadFirstPage = 1)) + assertEquals("Page 1 of 40", comicReaderFooterLabel(currentPage = 0, pageCount = 40, spreadFirstPage = 0)) + assertEquals("Page 5 of 40", comicReaderFooterLabel(currentPage = 4, pageCount = 40)) + assertEquals("Page 1 of 1", comicReaderFooterLabel(currentPage = 0, pageCount = 0)) + assertEquals( + "Comic pages 2 and 3 of 40", + comicReaderFooterAccessibilityLabel(currentPage = 2, pageCount = 40, spreadFirstPage = 1) + ) + assertEquals("Comic page 5 of 40", comicReaderFooterAccessibilityLabel(currentPage = 4, pageCount = 40)) + } +} From 105e21785dfa130429f9b3a1fde38094d858eff4 Mon Sep 17 00:00:00 2001 From: Andriy Chortkivskyy Date: Fri, 9 Oct 2026 12:53:58 +0200 Subject: [PATCH 05/13] docs: document foldable spreads and OLED EPUB theme Co-Authored-By: Claude Opus 5.5 --- docs/architecture.md | 10 ++++++++++ docs/fold-dual-page-plan.md | 13 +++++++++++++ docs/testing.md | 28 ++++++++++++++++++++++++++++ docs/ui-ux.md | 9 +++++++++ 4 files changed, 60 insertions(+) diff --git a/docs/architecture.md b/docs/architecture.md index becf7c93..b292a29b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -57,6 +57,16 @@ Lagrange is a native Android client for BookOrbit focused on reading, listening, - Continuous EPUB uses active-resource progression for its right-side rail; paginated EPUB, PDF, and comics retain format-appropriate page navigation. - PDF uses Readium PDFium. Paginated comics use Readium image navigation; continuous comics use a bounded book-scoped lazy surface with page/read limits and decoded-bitmap limits. - Comic page images support bounded pinch zoom and long-press page download in both paginated and continuous CBZ/CBR reading. Downloads reuse the existing image export path and work from the decoded/local page data. A narrowly scoped Readium JavaScript image listener gives image-only EPUB documents bounded in-reader pinch/pan without enabling WebView zoom on mixed text-and-image content. At 1×, ordinary EPUB taps and swipes remain available to the reader's pagination and menu controls; EPUB image long-presses open the shared fullscreen image viewer because Readium 3.0.2 does not expose a public image-level callback or bridge registration surface. +- **Foldable spreads.** + - `ReaderSpreadPolicy` is the pure geometry decision. + - `ReaderWindowTracker` combines the activity configuration with Jetpack WindowManager `FoldingFeature` posture. It seeds from the last known hinge and debounces changes so posture-driven recreation cannot loop. + - EPUB spreads use Readium `columnCount = TWO` / fixed-layout `Spread.ALWAYS`, plus `RsProperties` (`--RS__colCount: 2`, `--RS__colWidth: auto`, `--RS__pageGutter`) fixed at navigator creation. Inline Readium CSS properties bypass ReadiumCSS's 60em two-column media query, which the Fold8 inner display does not reach. + - Column gap stays zero so Readium's viewport-width pagination stays exact. The gap between pages is the per-column page gutter enabled by `pageMargins = 1`. + - Spread changes without a size change (posture, preference) recreate the EPUB activity at the saved locator. + - Paginated comics replace the Readium image navigator with `ComicSpreadReader`, a Compose pager over `comicSpreads()` pairing. It reuses the continuous-comic decode loader and LRU cache, decoding pairs at half width. It persists the spread's first page as the resume locator and reports the last visible page as progress. +- **OLED EPUB theme.** + - `EpubOledTheme` supplies the pure black/white Material scheme, the `Theme.BookOrbit.Reader.Oled` window theme (applied before `super.onCreate`) and the in-page OLED link/selection stylesheet. + - The stylesheet is installed by the existing current-document bridge loop, separate from the image colour preservation script. - `ReaderTapZones` is the shared pure region model for Readium and continuous-comic tap handling and tutorial rendering. It stores normalized action rectangles, applies reading-direction and axis-inversion transforms, and keeps Menu handling separate from format-specific navigator progression. - Connected standalone audio uses authenticated direct Media3 streaming. Explicit downloaded/local audio remains on the local Readium/media path. - Connected and downloaded multi-file audiobooks preserve BookOrbit's ordered audio-file list and per-file durations. Media3 receives one authenticated or local-file item per available file and advances through the playlist automatically. Local-only opening reconstructs the playlist from cached detail metadata plus one `DownloadRecord` per file, and never falls back to a remote stream URL. diff --git a/docs/fold-dual-page-plan.md b/docs/fold-dual-page-plan.md index f9e4ed00..af3c3e04 100644 --- a/docs/fold-dual-page-plan.md +++ b/docs/fold-dual-page-plan.md @@ -162,3 +162,16 @@ If this is revisited, the path would be to reuse the Phase 3 spread pager throug 1. **Target device: Z Fold8 (wide).** The primary posture is the natural unfolded landscape, with the hinge vertical and one page on each side of it. Test first on Fold8 (physical device or Remote Test Lab SM-F971). The Ultra and other foldables are covered by the same geometry policy but are secondary in the test matrix. 2. **PDF is out of scope.** Phase 4 is dropped, PDF keeps native single-page PDFium behavior, and there's no divergence from upstream PR #269. 3. **Work happens on a GitHub fork** (`chortya/lagrange-reader`) with `upstream` → `van-geaux/lagrange-reader`. Upstreaming the EPUB part stays optional. +4. **Added scope: true OLED white-on-black design for EPUB only.** Comics stay full colour. + +## 6. Implementation status (2026-10-09) + +| Phase | Status | Notes | +|---|---|---| +| 0 Setup | Done | Fork `chortya/lagrange-reader`. Baseline 837 JVM tests green. The 5 parser tests that fail on a non-English host locale are pre-existing; run Gradle with `JAVA_TOOL_OPTIONS="-Duser.language=en -Duser.country=US"`. Pixel Fold API 36 AVD used as the Fold8 stand-in. | +| 1 Foundation | Done | `ReaderSpreadPolicy`, `ReaderWindowTracker` (`androidx.window` 1.5.1, compileSdk 35 unchanged), per-library preferences and options UI. | +| 2 EPUB | Done | Readium reflowable `columnCount` / fixed-layout `spread`, plus `RsProperties` to bypass ReadiumCSS's 60em media query (found during implementation: the Fold8 window is under 960 CSS px), plus a per-column gutter. | +| OLED | Done | Pure black/white page, chrome, sheets, dialogs, window and status bar. In-page white links and selection. Images untouched. | +| 3 Comics | Done | `ComicSpreadReader` pager: cover alone, shift, wide pages shown alone, RTL, zoom, export, spread navigation, first-page resume / last-page progress. | +| 4 PDF | Out of scope | — | +| 5 Validation | Automated done; device pending | JVM 905 tests, lint clean, connected suite on Pixel Fold AVD. A physical Fold8 / Remote Test Lab pass is still required (see `docs/testing.md` → Foldable spreads and OLED EPUB). | diff --git a/docs/testing.md b/docs/testing.md index 3886c11a..a8e941bd 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -67,6 +67,34 @@ When the affected scope requires it, verify normal versus Preview launch isolati For EPUB, verify chapter/page navigation, continuous active-resource seeking, text size, margins, reading direction, custom/accessibility fonts, image handling, and optional read-along narration. When Android TTS is available, long-press a word and a sentence, choose `Listen from here`, and confirm playback starts at the selected word/sentence rather than the chapter beginning; on EPUB 3 media-overlay books, confirm `Play narration` remains a separate action. Open TTS settings during playback, confirm the Voice selector lists only voices compatible with the EPUB language, selecting a voice changes subsequent speech, the choice persists after reopening the reader, and `Default Android voice` clears the override. Force a selected voice failure and confirm playback stops, the failed override is cleared, the player returns with `Default Android voice` selected and paused, the reading locator is preserved, and no automatic speech starts until Play is pressed. Confirm network-dependent voices are identified and the existing missing-voice-data action opens Android voice installation. Verify read-along speed changes apply immediately through the five adjustment controls and that `Apply` is disabled. Verify TTS speed and pitch apply immediately, custom punctuation pauses are hidden by default and appear only after enabling the switch, and punctuation/lock-screen changes require `Apply`. Repeat once in Preview and confirm the listen actions are unavailable. For PDF and comics, verify page navigation, progress, links where applicable, layout modes, page gaps, and reader chrome. For audiobook playback, verify compact/full players, chapters, seeking, speed, sleep timer, session history, notification controls, backgrounding, screen lock, audio focus, and process/task recreation. +### Foldable spreads and OLED EPUB + +Automated coverage: `ReaderSpreadPolicyTest`, `EpubSpreadPreferencesTest`, `ComicSpreadPairingTest`, `ComicSpreadLayoutTest`, `EpubOledThemeTest` (JVM); `ComicSpreadReaderInstrumentedTest`, `EpubFoldOledWebViewInstrumentedTest`, `FoldSpreadOledActivityInstrumentedTest` (connected). Spread activity assertions need a large horizontal window, such as the Android Studio Pixel Fold AVD unfolded (2208×1840, 420 dpi). On phone-sized devices they are skipped through a JUnit assumption, while single-page and OLED assertions always run. Run connected tests on a non-English host with `JAVA_TOOL_OPTIONS="-Duser.language=en -Duser.country=US"`: several existing parser tests assert `.`-formatted percentages. + +On a Galaxy Z Fold8 (physical device or Samsung Remote Test Lab SM-F971): + +- **EPUB:** + - Unfolded in the natural landscape posture, paginated EPUB shows two columns with a gutter on the hinge. A page turn advances both columns. + - Rotate to portrait, fold to the cover screen, open split-screen, and switch to continuous layout: each shows one column. + - Confirm the following still work: resume position after fold/unfold, TTS sentence highlighting and auto page-turn across columns, highlights spanning the column boundary, and tap zones. +- **Comics:** + - Unfolded, paginated CBZ/CBR shows the cover alone, then facing pairs. + - A double-page image is shown alone, and the pairs after it re-align. + - Right-to-left libraries put the earlier page on the right and advance leftwards. + - `Shift page pairs` moves the pairing by one page. + - Pinch-zoom works on the spread; long-pressing a half exports that page. + - Tap zones, volume buttons and the page list all move by spread. + - The footer reads `Pages N–M of T`. + - Folding reopens on the first page of the spread; unfolding reopens on the spread containing the saved page. + - Turning off `Two-page spread when unfolded` restores the single-page navigator immediately. +- **PDF:** stays single-page on every screen. +- **OLED (EPUB only):** + - Choosing `OLED black` shows pure white text and links on pure black. + - The following are all black, with white primary content and hairline edges: the status-bar strip, reader chrome, options sheet, chapter list, TTS/read-along panels, and system dialogs. + - Text selection is white with black text, and illustrations keep their original colours. + - Switching into or out of OLED reopens the reader at the same position. + - Comics remain full colour. + ### EPUB Image Library For a downloaded EPUB, verify the EPUB-only Image Library action, document-order image listing, duplicate suppression, SVG exclusion, minimum-size filtering, thumbnail navigation, bounded zoom/pan, outside-tap and Back dismissal, and original-image export. For an online EPUB, consent must start the normal durable selected-file download; the gallery opens only after download success. Offline missing content must report unavailable without a consent prompt. diff --git a/docs/ui-ux.md b/docs/ui-ux.md index 0240d326..01cc70ed 100644 --- a/docs/ui-ux.md +++ b/docs/ui-ux.md @@ -58,6 +58,15 @@ This document contains current interaction contracts, design rules, and unresolv - Portrait and landscape use grouped height-responsive sizing rather than shrinking only the bottom buttons. The complete group reduces spacing, typography, cover allocation, seek-row height, and control artwork when constrained; Chapter, Speed, Sleep, and transport touch targets retain usable minimums. An overflowing one-line chapter title uses marquee auto-scroll, matching the existing book-title behavior. - A connected split-MP3 audiobook is presented as one continuous audiobook: the compact player's time and slider use the complete duration, chapter and ±10/30-second seeks may cross file boundaries, and playback automatically continues into the next ordered file. Single-file and downloaded/local audio behavior is unchanged. - EPUB/PDF/comic readers may recreate for orientation, screen-size, and fold changes, but must remain visible by reopening one navigator at the exact saved locator. Configuration recreation must not look like user closure, duplicate the reader, or clear active-reader state; process recovery is validated separately. +- **Two-page spreads.** On a window that is at least 600dp on its shortest side and wider than tall (an unfolded Galaxy Z Fold8 in its natural landscape posture, or a tablet in landscape), paginated EPUBs and comics show two pages side by side. + - Cover screens, portrait, split-screen halves, continuous layout and a half-opened tabletop posture keep one page. + - The per-library `Two-page spread when unfolded` switch (default on) disables spreads. + - Comics add `Show cover alone` (default on) and `Shift page pairs`. Double-page images are shown alone. Comic pages meet at the gutter with no gap; only a separating hinge pushes them apart. + - PDFs intentionally remain single-page native PDFium. +- **OLED black (EPUB).** Pure #FFFFFF primary text and links on #000000 across the page and all reader UI. Secondary text and outlines are neutral greys; selected states invert to white. Shadows and tonal fills are replaced by hairline outlines because they are invisible on black. + - Illustrations keep their colours. + - Comics and PDFs are unaffected. + - Switching into or out of OLED recreates the reader because the window theme changes. - Reader options are format-specific: EPUB options show EPUB typography, margins, and layout; PDF options show PDF typography, layout, and page gap; comic options omit the Typography section and show only CBR/CBZ layout and page gap. A reader options surface must not show controls for other publication formats. - EPUB hyperlinks use the Readium HTML navigator, so internal and external EPUB links remain tappable without competing with the reader tap zones; EPUB text remains selectable through the existing WebView-based selection path. - PDF hyperlink hit-testing reads PDFium link metadata before the existing directional tap-zone listener. Internal destination links navigate within the PDF; external HTTP/HTTPS and `mailto:` links open through Android `ACTION_VIEW` using the installed system handler. Unsupported or malformed schemes are rejected and fall back to the existing tap handling. From 4f7d92796a459588cf1ef4dda8f76bea064855b4 Mon Sep 17 00:00:00 2001 From: Andriy Chortkivskyy Date: Fri, 9 Oct 2026 13:27:36 +0200 Subject: [PATCH 06/13] test: pin single-page comic tests and cover spread zoom and taps Existing comic navigator tests now pin twoPageSpread=OFF so they test the Readium image navigator on any window size; add spread-mode center tap and pinch-zoom paging-lock tests. Use a gesture-local zoom scale so fast pinches do not compound a stale scale. Co-Authored-By: Claude Opus 5.5 --- .../ComicSpreadReaderInstrumentedTest.kt | 36 +++++++++ .../ReadiumComicOpenInstrumentedTest.kt | 77 +++++++++++++++++-- .../vangeaux/lagrange/ComicSpreadReader.kt | 8 +- 3 files changed, 113 insertions(+), 8 deletions(-) diff --git a/app/src/androidTest/java/com/vangeaux/lagrange/ComicSpreadReaderInstrumentedTest.kt b/app/src/androidTest/java/com/vangeaux/lagrange/ComicSpreadReaderInstrumentedTest.kt index 7c1b103d..8bf78810 100644 --- a/app/src/androidTest/java/com/vangeaux/lagrange/ComicSpreadReaderInstrumentedTest.kt +++ b/app/src/androidTest/java/com/vangeaux/lagrange/ComicSpreadReaderInstrumentedTest.kt @@ -15,6 +15,9 @@ import androidx.compose.ui.test.longClick import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.pinch +import androidx.compose.ui.test.swipeLeft +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.unit.dp import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.Assert.assertEquals @@ -176,6 +179,39 @@ class ComicSpreadReaderInstrumentedTest { assertTrue("cover $coverWidth vs paired $pairedWidth", coverWidth >= pairedWidth * 2 - 1) } + @Test + fun pinchZoomLocksPageSwipesUntilZoomedBackOut() { + val recorder = Recorder() + setReader(recorder, initialPage = 2) + val reader = composeRule.onNodeWithContentDescription("Pages 2 and 3") + + reader.performTouchInput { + pinch( + start0 = center - Offset(20f, 0f), + end0 = center - Offset(200f, 0f), + start1 = center + Offset(20f, 0f), + end1 = center + Offset(200f, 0f) + ) + } + composeRule.waitForIdle() + reader.performTouchInput { swipeLeft() } + composeRule.waitForIdle() + assertEquals("zoomed spread must not page", ComicSpread(listOf(1, 2)), recorder.spreads.last()) + + reader.performTouchInput { + pinch( + start0 = center - Offset(250f, 0f), + end0 = center - Offset(5f, 0f), + start1 = center + Offset(250f, 0f), + end1 = center + Offset(5f, 0f) + ) + } + composeRule.waitForIdle() + reader.performTouchInput { swipeLeft() } + composeRule.waitForIdle() + assertEquals(ComicSpread(listOf(3, 4)), recorder.spreads.last()) + } + @Test fun discoveringAWidePageRepairsWithoutLosingTheCurrentPage() { val recorder = Recorder() diff --git a/app/src/androidTest/java/com/vangeaux/lagrange/ReadiumComicOpenInstrumentedTest.kt b/app/src/androidTest/java/com/vangeaux/lagrange/ReadiumComicOpenInstrumentedTest.kt index 6bce8955..e149b516 100644 --- a/app/src/androidTest/java/com/vangeaux/lagrange/ReadiumComicOpenInstrumentedTest.kt +++ b/app/src/androidTest/java/com/vangeaux/lagrange/ReadiumComicOpenInstrumentedTest.kt @@ -162,12 +162,14 @@ class ReadiumComicOpenInstrumentedTest { val readerKey = "instrumented-comic-recreate-${System.nanoTime()}" writeCbz(cbz, pageBytes(Color.RED), pageBytes(Color.BLUE)) + withSinglePageComicProfile(context) { libraryId -> ActivityScenario.launch( ReadiumComicReaderActivity.createIntent( context = context, file = cbz, title = "Comic recreation", readerKey = readerKey, + libraryId = libraryId, launchMode = ReaderLaunchMode.NORMAL, initialPage = 0 ) @@ -189,20 +191,63 @@ class ReadiumComicOpenInstrumentedTest { } } } + } cbz.delete() } @Test fun centerTapOpensComicChrome() { val context = ApplicationProvider.getApplicationContext() - val cbz = File(context.cacheDir, "readium-comic-center-tap.cbz") - writeCbz(cbz, pageBytes(Color.MAGENTA), pageBytes(Color.GREEN)) + withSinglePageComicProfile(context) { libraryId -> + assertCenterTapOpensChrome(context, libraryId, "single") { activity -> + activity.supportFragmentManager + .findFragmentByTag("readium_comic_navigator") is ImageNavigatorFragment + } + } + } + + @Test + fun centerTapOpensComicChromeInTwoPageSpread() { + val context = ApplicationProvider.getApplicationContext() + val store = AppPreferencesStore(context) + val original = store.read() + val libraryId = "instrumented-comic-spread-${System.nanoTime()}" + store.save( + original.copy(lockOrientation = true, lockedOrientation = LockedOrientation.LANDSCAPE) + .withReaderPreferences(libraryId, LibraryReaderPreferences(twoPageSpread = ReaderSpreadMode.AUTO)) + ) + try { + assertCenterTapOpensChrome(context, libraryId, "spread") { activity -> + org.junit.Assume.assumeTrue( + "Requires a large horizontal window (unfolded foldable or tablet)", + shouldUseTwoPageSpread( + ReaderSpreadMode.AUTO, + ReaderWindowState.fromConfiguration(activity.resources.configuration), + ReaderLayoutMode.PAGINATED + ) + ) + activity.isComicSpreadReaderVisible() + } + } finally { + store.save(original) + } + } + + private fun assertCenterTapOpensChrome( + context: android.content.Context, + libraryId: String, + label: String, + isReady: (ReadiumComicReaderActivity) -> Boolean + ) { + val cbz = File(context.cacheDir, "readium-comic-center-tap-$label.cbz") + writeCbz(cbz, pageBytes(Color.MAGENTA), pageBytes(Color.GREEN), pageBytes(Color.BLUE)) ActivityScenario.launch( ReadiumComicReaderActivity.createIntent( context = context, file = cbz, title = "Comic test", - readerKey = "instrumented-comic-center-tap", + readerKey = "instrumented-comic-center-tap-$label", + libraryId = libraryId, launchMode = ReaderLaunchMode.NORMAL, initialPage = 0 ) @@ -211,13 +256,12 @@ class ReadiumComicOpenInstrumentedTest { var attempts = 0 while (!navigatorReady && attempts < 40) { scenario.onActivity { activity -> - navigatorReady = activity.supportFragmentManager - .findFragmentByTag("readium_comic_navigator") is ImageNavigatorFragment + navigatorReady = isReady(activity) } if (!navigatorReady) SystemClock.sleep(250) attempts += 1 } - assertTrue("Readium comic navigator did not become ready", navigatorReady) + assertTrue("Comic reader ($label) did not become ready", navigatorReady) var tutorialWasShown = false var tutorialVisible = false scenario.onActivity { activity -> @@ -264,6 +308,27 @@ class ReadiumComicOpenInstrumentedTest { cbz.delete() } + /** Pins the Readium single-page image navigator regardless of device window size. */ + private fun withSinglePageComicProfile( + context: android.content.Context, + block: (libraryId: String) -> Unit + ) { + val store = AppPreferencesStore(context) + val original = store.read() + val libraryId = "instrumented-comic-single-${System.nanoTime()}" + store.save( + original.withReaderPreferences( + libraryId, + LibraryReaderPreferences(twoPageSpread = ReaderSpreadMode.OFF) + ) + ) + try { + block(libraryId) + } finally { + store.save(original) + } + } + private fun awaitComicLocator( scenario: ActivityScenario, context: android.content.Context, diff --git a/app/src/main/java/com/vangeaux/lagrange/ComicSpreadReader.kt b/app/src/main/java/com/vangeaux/lagrange/ComicSpreadReader.kt index a809cdea..cff1d67a 100644 --- a/app/src/main/java/com/vangeaux/lagrange/ComicSpreadReader.kt +++ b/app/src/main/java/com/vangeaux/lagrange/ComicSpreadReader.kt @@ -270,11 +270,13 @@ private fun ComicSpreadPage( .pointerInput(spread) { awaitEachGesture { awaitFirstDown(requireUnconsumed = false) + // Gesture-local scale: the hoisted value only updates after recomposition. + var scale = currentZoom do { val event = awaitPointerEvent() val pressed = event.changes.count { it.pressed } - if (pressed >= 2 || currentZoom > 1.01f) { - val nextScale = boundedComicImageScale(currentZoom * event.calculateZoom()) + if (pressed >= 2 || scale > 1.01f) { + val nextScale = boundedComicImageScale(scale * event.calculateZoom()) val nextOffset = boundedComicImagePan( offset + event.calculatePan(), size.width.toFloat(), @@ -282,9 +284,11 @@ private fun ComicSpreadPage( nextScale ) if (nextScale <= 1.01f) { + scale = 1f onZoomChange(1f) offset = Offset.Zero } else { + scale = nextScale onZoomChange(nextScale) offset = nextOffset } From 2ed42daf61e5f4149cd6e551739440bf1d350ee5 Mon Sep 17 00:00:00 2001 From: chortya Date: Fri, 9 Oct 2026 20:21:13 +0200 Subject: [PATCH 07/13] fix: EPUB spread margins follow the reader margin setting on both pages Each spread page now gets the same margin on its outer and inner side, sized like the single-page margin for the same setting, and changes to margins apply live. The container drops its horizontal padding in a spread; Readium's per-column gutter is a 1px unit scaled by pageMargins. Tests: JVM margin mapping; on-device measurement of all four page margins, line pitch on both pages under line-spacing and font-size changes, and the rotate-to-portrait fallback for EPUB and comics. Co-Authored-By: Claude Opus 5.5 --- .../FoldSpreadOledActivityInstrumentedTest.kt | 255 ++++++++++++++++++ .../ReadiumEpubOpenInstrumentedTest.kt | 19 +- .../lagrange/ReadiumEpubReaderActivity.kt | 57 +++- .../lagrange/EpubSpreadPreferencesTest.kt | 25 +- docs/architecture.md | 3 +- docs/fold-dual-page-plan.md | 2 +- docs/testing.md | 3 + 7 files changed, 343 insertions(+), 21 deletions(-) diff --git a/app/src/androidTest/java/com/vangeaux/lagrange/FoldSpreadOledActivityInstrumentedTest.kt b/app/src/androidTest/java/com/vangeaux/lagrange/FoldSpreadOledActivityInstrumentedTest.kt index 5d3a9eb7..cd244415 100644 --- a/app/src/androidTest/java/com/vangeaux/lagrange/FoldSpreadOledActivityInstrumentedTest.kt +++ b/app/src/androidTest/java/com/vangeaux/lagrange/FoldSpreadOledActivityInstrumentedTest.kt @@ -197,6 +197,261 @@ class FoldSpreadOledActivityInstrumentedTest { } } + // ---- EPUB spread layout: margins, line spacing, reflow ---- + + /** Text geometry of the two pages on screen, in CSS px, measured inside the WebView. */ + private data class SpreadLayout( + val viewportWidth: Double, + val leftOuter: Double, + val leftInner: Double, + val rightInner: Double, + val rightOuter: Double, + val leftLinePitch: Double, + val rightLinePitch: Double, + val leftLines: Int, + val rightLines: Int + ) + + private val spreadLayoutScript = """ + (function() { + var vw = window.innerWidth, mid = vw / 2; + var onScreen = function(r) { return r.width > 0 && r.right > 0 && r.left < vw; }; + var frags = []; + document.querySelectorAll('p').forEach(function(p) { + Array.prototype.forEach.call(p.getClientRects(), function(r) { if (onScreen(r)) frags.push(r); }); + }); + var side = function(a, left) { return a.filter(function(r) { return ((r.left + r.right) / 2 < mid) === left; }); }; + var lo = function(a) { return Math.min.apply(null, a.map(function(r) { return r.left; })); }; + var hi = function(a) { return Math.max.apply(null, a.map(function(r) { return r.right; })); }; + var range = document.createRange(); + range.selectNodeContents(document.querySelectorAll('p')[1]); + var lines = Array.prototype.filter.call(range.getClientRects(), onScreen); + var pitch = function(a) { + var tops = a.map(function(r) { return Math.round(r.top * 10) / 10; }) + .filter(function(v, i, all) { return all.indexOf(v) === i; }).sort(function(x, y) { return x - y; }); + var d = []; + for (var i = 1; i < tops.length; i++) d.push(tops[i] - tops[i - 1]); + d.sort(function(x, y) { return x - y; }); + return [d.length ? d[Math.floor(d.length / 2)] : -1, tops.length]; + }; + var lf = side(frags, true), rf = side(frags, false); + var lp = pitch(side(lines, true)), rp = pitch(side(lines, false)); + return [vw, lo(lf), mid - hi(lf), lo(rf) - mid, vw - hi(rf), lp[0], rp[0], lp[1], rp[1]].join(','); + })() + """.trimIndent() + + private suspend fun measureSpread(scenario: ActivityScenario): SpreadLayout { + val raw = requireNotNull( + awaitJavascript(scenario, spreadLayoutScript) { it != null && it != "null" && !it.contains("Infinity") } + ) { "spread layout script returned nothing" } + val v = raw.trim('"').split(',').map { it.toDouble() } + return SpreadLayout(v[0], v[1], v[2], v[3], v[4], v[5], v[6], v[7].toInt(), v[8].toInt()) + } + + /** Polls the layout until [accept] holds (Readium applies preferences asynchronously). */ + private suspend fun awaitSpread( + scenario: ActivityScenario, + message: String, + accept: (SpreadLayout) -> Boolean + ): SpreadLayout { + var last: SpreadLayout? = null + repeat(40) { + last = measureSpread(scenario) + if (accept(last!!)) return last!! + SystemClock.sleep(250) + } + throw AssertionError("$message; last layout $last") + } + + private fun assertMargins(layout: SpreadLayout, expected: Double) { + val tolerance = 2.0 + listOf( + "left page outer" to layout.leftOuter, + "left page inner" to layout.leftInner, + "right page inner" to layout.rightInner, + "right page outer" to layout.rightOuter + ).forEach { (name, actual) -> + assertEquals("$name margin in $layout", expected, actual, tolerance) + } + } + + private fun launchSpreadEpub( + profile: LibraryReaderPreferences, + block: suspend (ActivityScenario, Int) -> Unit + ) = runBlocking { + val epub = File(context.cacheDir, "fold-layout-${System.nanoTime()}.epub") + val readerKey = "fold-layout-epub-${System.nanoTime()}" + writeEpub(epub) + useProfile(profile) + try { + launchEpub(epub, readerKey).use { scenario -> + awaitEpubReady(scenario, readerKey) + var qualifies = false + var widthDp = 0 + scenario.onActivity { activity -> + qualifies = windowQualifiesForSpread(activity) + widthDp = activity.resources.configuration.screenWidthDp + } + assumeTrue("Requires a large horizontal window (unfolded foldable or tablet)", qualifies) + block(scenario, widthDp) + } + } finally { + epub.delete() + } + } + + @Test + fun epubSpreadMarginsAreEqualOnBothSidesOfBothPagesAndFollowTheMarginSetting() = launchSpreadEpub( + LibraryReaderPreferences( + twoPageSpread = ReaderSpreadMode.AUTO, + padding = EpubPaddingPercentages(left = 15f, right = 15f) + ) + ) { scenario, widthDp -> + val narrow = epubSpreadPageMarginCssPx(widthDp, 15f, 15f) + val initial = awaitSpread(scenario, "both pages did not get the 15% margin") { + it.leftLines > 0 && it.rightLines > 0 && kotlin.math.abs(it.leftInner - narrow) < 2.0 + } + // The WebView spans the whole window: the container adds no outer padding in a spread. + assertEquals(widthDp.toDouble(), initial.viewportWidth, 2.0) + assertMargins(initial, narrow) + + // Widening the margins live widens all four page edges equally; no recreation needed. + scenario.onActivity { activity -> + activity.applyReaderPreferencesForTest { + it.copy(padding = it.padding.copy(left = 40f, right = 40f)) + } + } + val wide = epubSpreadPageMarginCssPx(widthDp, 40f, 40f) + val widened = awaitSpread(scenario, "margins did not follow the 40% setting") { + kotlin.math.abs(it.leftOuter - wide) < 2.0 && kotlin.math.abs(it.rightOuter - wide) < 2.0 + } + assertMargins(widened, wide) + assertTrue("text must reflow into both narrower pages", widened.leftLines > 0 && widened.rightLines > 0) + scenario.onActivity { activity -> assertTrue(activity.isTwoPageSpreadActive()) } + + // Uneven left/right settings still give both pages symmetric margins (their mean). + scenario.onActivity { activity -> + activity.applyReaderPreferencesForTest { + it.copy(padding = it.padding.copy(left = 10f, right = 30f)) + } + } + val mean = epubSpreadPageMarginCssPx(widthDp, 10f, 30f) + assertMargins( + awaitSpread(scenario, "uneven margins were not averaged") { kotlin.math.abs(it.leftOuter - mean) < 2.0 }, + mean + ) + } + + @Test + fun epubSpreadLineSpacingAndFontSizeApplyEquallyToBothPages() = launchSpreadEpub( + LibraryReaderPreferences(twoPageSpread = ReaderSpreadMode.AUTO, lineSpacing = 1f, fontScale = 1f) + ) { scenario, widthDp -> + val base = awaitSpread(scenario, "both pages did not show text") { + it.leftLines > 2 && it.rightLines > 2 + } + assertEquals("both pages share one line pitch", base.leftLinePitch, base.rightLinePitch, 0.5) + val margin = epubSpreadPageMarginCssPx(widthDp, EPUB_DEFAULT_PADDING_PERCENT, EPUB_DEFAULT_PADDING_PERCENT) + + scenario.onActivity { activity -> + activity.applyReaderPreferencesForTest { it.copy(lineSpacing = 1.8f) } + } + val spaced = awaitSpread(scenario, "line spacing did not change") { + it.leftLinePitch > base.leftLinePitch * 1.3 && it.rightLines > 1 + } + assertEquals("both pages share the new line pitch", spaced.leftLinePitch, spaced.rightLinePitch, 0.5) + assertTrue("wider spacing fits fewer lines on each page", spaced.leftLines < base.leftLines) + assertTrue(spaced.rightLines < base.rightLines) + assertMargins(spaced, margin) + + scenario.onActivity { activity -> + activity.applyReaderPreferencesForTest { it.copy(fontScale = 1.6f) } + } + val larger = awaitSpread(scenario, "font size did not change") { + it.leftLinePitch > spaced.leftLinePitch * 1.3 && it.rightLines > 1 + } + assertEquals("both pages share the reflowed line pitch", larger.leftLinePitch, larger.rightLinePitch, 0.5) + assertTrue(larger.leftLines < spaced.leftLines) + assertMargins(larger, margin) + scenario.onActivity { activity -> assertTrue(activity.isTwoPageSpreadActive()) } + } + + // ---- Rotation ---- + + @Test + fun epubSpreadFallsBackToOneColumnWhenRotatedToPortrait(): Unit = runBlocking { + val epub = File(context.cacheDir, "fold-rotate-${System.nanoTime()}.epub") + val readerKey = "fold-rotate-epub-${System.nanoTime()}" + writeEpub(epub) + useProfile(LibraryReaderPreferences(twoPageSpread = ReaderSpreadMode.AUTO)) + try { + launchEpub(epub, readerKey).use { scenario -> + awaitEpubReady(scenario, readerKey) + var qualifies = false + scenario.onActivity { activity -> qualifies = windowQualifiesForSpread(activity) } + assumeTrue("Requires a large horizontal window (unfolded foldable or tablet)", qualifies) + scenario.onActivity { activity -> assertTrue(activity.isTwoPageSpreadActive()) } + + rotateToPortrait(scenario) + awaitCondition(scenario, "EPUB did not return to one column in portrait") { activity -> + !activity.resources.configuration.let { it.screenWidthDp > it.screenHeightDp } && + !activity.isTwoPageSpreadActive() + } + awaitEpubReady(scenario, readerKey) + val columns = awaitJavascript(scenario, "getComputedStyle(document.documentElement).columnCount") + assertEquals("\"1\"", columns) + } + } finally { + epub.delete() + } + } + + @Test + fun comicSpreadFallsBackToSinglePageWhenRotatedToPortrait() { + val cbz = File(context.cacheDir, "fold-rotate-${System.nanoTime()}.cbz") + val readerKey = "fold-rotate-comic-${System.nanoTime()}" + writeCbz(cbz, 7) + useProfile(LibraryReaderPreferences(twoPageSpread = ReaderSpreadMode.AUTO)) + try { + ActivityScenario.launch( + ReadiumComicReaderActivity.createIntent( + context = context, + file = cbz, + title = "Fold rotate", + readerKey = readerKey, + libraryId = libraryId, + launchMode = ReaderLaunchMode.NORMAL, + initialPage = 4 + ) + ).use { scenario -> + var qualifies = false + awaitCondition(scenario, "comic reader did not settle") { activity -> + qualifies = windowQualifiesForSpread(activity) + activity.isComicSpreadReaderVisible() == qualifies + } + assumeTrue("Requires a large horizontal window (unfolded foldable or tablet)", qualifies) + + rotateToPortrait(scenario) + awaitCondition(scenario, "comic did not return to the single-page navigator in portrait") { activity -> + !activity.isComicSpreadReaderVisible() && + activity.supportFragmentManager.fragments.any { it.tag == "readium_comic_navigator" } + } + // Spread 4–5 resumes on its first page, so portrait reopens on page 4 (index 3). + val stored = requireNotNull(ReadiumComicLocatorStore(context).read(readerKey)) + assertTrue(stored.href.toString().endsWith("0003.jpg")) + } + } finally { + cbz.delete() + } + } + + /** Rotates like a user would; the lock is switched too so the recreated reader stays portrait. */ + private fun rotateToPortrait(scenario: ActivityScenario) { + store.save(store.read().copy(lockedOrientation = LockedOrientation.PORTRAIT)) + scenario.onActivity { activity -> + activity.requestedOrientation = android.content.pm.ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } + } + @Test fun oledEpubRendersPureWhiteOnPureBlackInPageAndWindow(): Unit = runBlocking { val epub = File(context.cacheDir, "oled-${System.nanoTime()}.epub") diff --git a/app/src/androidTest/java/com/vangeaux/lagrange/ReadiumEpubOpenInstrumentedTest.kt b/app/src/androidTest/java/com/vangeaux/lagrange/ReadiumEpubOpenInstrumentedTest.kt index a6982042..28e56d5b 100644 --- a/app/src/androidTest/java/com/vangeaux/lagrange/ReadiumEpubOpenInstrumentedTest.kt +++ b/app/src/androidTest/java/com/vangeaux/lagrange/ReadiumEpubOpenInstrumentedTest.kt @@ -476,11 +476,28 @@ class ReadiumEpubOpenInstrumentedTest { ) assertEquals(ColumnCount.TWO, preferences.columnCount) - assertEquals(1.0, requireNotNull(preferences.pageMargins), 0.0) + assertEquals(EPUB_SPREAD_MIN_PAGE_MARGIN_CSS_PX, requireNotNull(preferences.pageMargins), 0.0) assertEquals(Spread.ALWAYS, preferences.spread) assertEquals(false, preferences.scroll) } + @OptIn(ExperimentalReadiumApi::class) + @Test + fun spreadPreferencesCarryThePageMarginAndSinglePageKeepsNone() { + val spread = readiumPreferences( + EpubReaderTheme.Light, fontScale = 1f, twoPageSpread = true, spreadPageMarginCssPx = 40.0 + ) + assertEquals(40.0, requireNotNull(spread.pageMargins), 0.0) + val tooSmall = readiumPreferences( + EpubReaderTheme.Light, fontScale = 1f, twoPageSpread = true, spreadPageMarginCssPx = 2.0 + ) + assertEquals(EPUB_SPREAD_MIN_PAGE_MARGIN_CSS_PX, requireNotNull(tooSmall.pageMargins), 0.0) + val single = readiumPreferences( + EpubReaderTheme.Light, fontScale = 1f, twoPageSpread = false, spreadPageMarginCssPx = 40.0 + ) + assertEquals(0.0, requireNotNull(single.pageMargins), 0.0) + } + @OptIn(ExperimentalReadiumApi::class) @Test fun continuousLayoutIgnoresSpreadRequest() { diff --git a/app/src/main/java/com/vangeaux/lagrange/ReadiumEpubReaderActivity.kt b/app/src/main/java/com/vangeaux/lagrange/ReadiumEpubReaderActivity.kt index 97367e11..8381fa20 100644 --- a/app/src/main/java/com/vangeaux/lagrange/ReadiumEpubReaderActivity.kt +++ b/app/src/main/java/com/vangeaux/lagrange/ReadiumEpubReaderActivity.kt @@ -166,8 +166,25 @@ internal fun readiumFontFamily(fontFamily: EpubReaderFontFamily): ReadiumFontFam EpubReaderFontFamily.CUSTOM -> null } -/** CSS px of Readium page gutter on each side of a spread column; the pages are 2x this apart. */ -internal const val EPUB_SPREAD_PAGE_GUTTER_CSS_PX = 24.0 +/** + * Base unit of the Readium page gutter in a spread. Readium pads every column with + * `pageGutter * pageMargins`, so with a 1px base `pageMargins` is the page margin in CSS px and can + * follow the reader margin setting live, without recreating the navigator. + */ +internal const val EPUB_SPREAD_PAGE_GUTTER_UNIT_CSS_PX = 1.0 + +/** Smallest margin on each side of a spread page so the two pages' text never touches. */ +internal const val EPUB_SPREAD_MIN_PAGE_MARGIN_CSS_PX = 12.0 + +/** + * Margin on each side of each spread page, in CSS px (= dp). It is the same size the left/right + * margin setting gives a single page on this window, so changing margins changes all four page + * edges equally and both pages stay symmetric around the fold. + */ +internal fun epubSpreadPageMarginCssPx(windowWidthDp: Int, leftPercent: Float, rightPercent: Float): Double { + val mean = (leftPercent.coerceIn(0f, 100f) + rightPercent.coerceIn(0f, 100f)) / 2f + return maxOf(windowWidthDp.coerceAtLeast(0) * mean / 400.0, EPUB_SPREAD_MIN_PAGE_MARGIN_CSS_PX) +} internal fun epubSpreadActive(twoPageSpread: Boolean, layoutMode: ReaderLayoutMode): Boolean = twoPageSpread && layoutMode == ReaderLayoutMode.PAGINATED @@ -182,24 +199,24 @@ internal fun epubSpreadActive(twoPageSpread: Boolean, layoutMode: ReaderLayoutMo internal fun epubSpreadRsProperties(twoPageSpread: Boolean): RsProperties = if (twoPageSpread) { RsProperties( colCount = ReadiumCssColCount.TWO, - pageGutter = ReadiumCssLength.Px(EPUB_SPREAD_PAGE_GUTTER_CSS_PX), + pageGutter = ReadiumCssLength.Px(EPUB_SPREAD_PAGE_GUTTER_UNIT_CSS_PX), overrides = mapOf("--RS__colWidth" to "auto") ) } else { RsProperties() } -/** Left/right reader padding percentages; a spread uses their mean so the gutter stays centred. */ +/** + * Left/right reader container padding percentages. A spread moves the horizontal margins into each + * Readium column (see [epubSpreadPageMarginCssPx]) so both pages get the same margin on both sides. + */ internal fun epubHorizontalPaddingPercents( left: Float, right: Float, twoPageSpread: Boolean ): Pair { - val safeLeft = left.coerceIn(0f, 100f) - val safeRight = right.coerceIn(0f, 100f) - if (!twoPageSpread) return safeLeft to safeRight - val mean = (safeLeft + safeRight) / 2f - return mean to mean + if (twoPageSpread) return 0f to 0f + return left.coerceIn(0f, 100f) to right.coerceIn(0f, 100f) } internal fun readiumEpubLineHeight(lineSpacing: Float): Double = lineSpacing @@ -219,7 +236,8 @@ internal fun readiumPreferences( layoutMode: ReaderLayoutMode = ReaderLayoutMode.PAGINATED, fontFamily: EpubReaderFontFamily = EpubReaderFontFamily.PUBLISHER_DEFAULT, wordSpacing: Float = DEFAULT_EPUB_WORD_SPACING, - twoPageSpread: Boolean = false + twoPageSpread: Boolean = false, + spreadPageMarginCssPx: Double = EPUB_SPREAD_MIN_PAGE_MARGIN_CSS_PX ): EpubPreferences = EpubPreferences( backgroundColor = ReadiumColor(theme.backgroundColor), textColor = ReadiumColor(cssHexColorInt(theme.foregroundCss)), @@ -233,8 +251,10 @@ internal fun readiumPreferences( wordSpacing = readiumEpubWordSpacing(wordSpacing), fontFamily = readiumFontFamily(fontFamily), readingProgression = readiumEpubReadingProgression(readingDirection), - // Spreads give each column its own Readium page gutter so the two pages never touch. - pageMargins = if (epubSpreadActive(twoPageSpread, layoutMode)) 1.0 else 0.0, + // Spreads give each column its own margin (in units of the 1px spread page gutter). + pageMargins = if (epubSpreadActive(twoPageSpread, layoutMode)) { + spreadPageMarginCssPx.coerceAtLeast(EPUB_SPREAD_MIN_PAGE_MARGIN_CSS_PX) / EPUB_SPREAD_PAGE_GUTTER_UNIT_CSS_PX + } else 0.0, columnCount = if (epubSpreadActive(twoPageSpread, layoutMode)) ColumnCount.TWO else ColumnCount.ONE, // Fixed-layout only. Readium 3.x treats Spread.AUTO as single page, so it must be explicit. spread = if (epubSpreadActive(twoPageSpread, layoutMode)) Spread.ALWAYS else Spread.NEVER, @@ -1219,7 +1239,8 @@ class ReadiumEpubReaderActivity : FragmentActivity() { epubLayoutMode, selectedFontFamily, readerPreferences.wordSpacing, - twoPageSpreadActive + twoPageSpreadActive, + spreadPageMarginCssPx() ), paginationListener = paginationListener, configuration = EpubNavigatorFragment.Configuration( @@ -2699,7 +2720,8 @@ class ReadiumEpubReaderActivity : FragmentActivity() { normalized.epubLayoutMode, normalized.fontFamily, normalized.wordSpacing, - twoPageSpreadActive + twoPageSpreadActive, + spreadPageMarginCssPx() ) ) val current = appPreferencesStore.read() @@ -2722,6 +2744,9 @@ class ReadiumEpubReaderActivity : FragmentActivity() { ?: themeStore.read() } + private fun spreadPageMarginCssPx(): Double = + epubSpreadPageMarginCssPx(resources.configuration.screenWidthDp, padding.left, padding.right) + private fun computeTwoPageSpread(): Boolean = shouldUseTwoPageSpread( mode = readerPreferences.twoPageSpread, window = windowTracker.state.value, @@ -2845,6 +2870,10 @@ class ReadiumEpubReaderActivity : FragmentActivity() { internal fun isTwoPageSpreadActive(): Boolean = twoPageSpreadActive + /** Applies reader options the same way the options sheet does; for instrumented tests. */ + internal fun applyReaderPreferencesForTest(change: (LibraryReaderPreferences) -> LibraryReaderPreferences) = + applyReaderPreferences(change(readerPreferences)) + internal suspend fun evaluateReaderJavascript(script: String): String? = navigator?.evaluateJavascript(script) diff --git a/app/src/test/java/com/vangeaux/lagrange/EpubSpreadPreferencesTest.kt b/app/src/test/java/com/vangeaux/lagrange/EpubSpreadPreferencesTest.kt index 39a65f56..161eb0fe 100644 --- a/app/src/test/java/com/vangeaux/lagrange/EpubSpreadPreferencesTest.kt +++ b/app/src/test/java/com/vangeaux/lagrange/EpubSpreadPreferencesTest.kt @@ -15,7 +15,8 @@ class EpubSpreadPreferencesTest { assertEquals("2", css["--RS__colCount"]) assertEquals("auto", css["--RS__colWidth"]) - assertEquals("24px", css["--RS__pageGutter"]) + // 1px unit: pageMargins then carries the page margin in CSS px. + assertEquals("1px", css["--RS__pageGutter"]) // A column gap would break Readium's viewport-width pagination. assertFalse(css.containsKey("--RS__colGap")) assertEquals(setOf("--RS__colCount", "--RS__colWidth", "--RS__pageGutter"), css.keys) @@ -39,10 +40,26 @@ class EpubSpreadPreferencesTest { } @Test - fun `spread padding uses the mean of left and right so the gutter stays centred`() { + fun `spread moves horizontal margins from the container into each page`() { assertEquals(10f to 30f, epubHorizontalPaddingPercents(10f, 30f, twoPageSpread = false)) - assertEquals(20f to 20f, epubHorizontalPaddingPercents(10f, 30f, twoPageSpread = true)) assertEquals(0f to 100f, epubHorizontalPaddingPercents(-5f, 150f, twoPageSpread = false)) - assertEquals(50f to 50f, epubHorizontalPaddingPercents(-5f, 150f, twoPageSpread = true)) + assertEquals(0f to 0f, epubHorizontalPaddingPercents(10f, 30f, twoPageSpread = true)) + } + + @Test + fun `spread page margin matches the single page margin for the same setting`() { + // Fold8-sized window, 841dp wide: 15% gives 841 * 15 / 400 dp on every page edge. + assertEquals(841 * 15 / 400.0, epubSpreadPageMarginCssPx(841, 15f, 15f), 0.0001) + // Uneven left/right settings use their mean so both pages stay symmetric. + assertEquals(841 * 20 / 400.0, epubSpreadPageMarginCssPx(841, 10f, 30f), 0.0001) + // Larger setting, larger margins. + assertTrue(epubSpreadPageMarginCssPx(841, 40f, 40f) > epubSpreadPageMarginCssPx(841, 15f, 15f)) + } + + @Test + fun `spread page margin never lets the two pages touch`() { + assertEquals(EPUB_SPREAD_MIN_PAGE_MARGIN_CSS_PX, epubSpreadPageMarginCssPx(841, 0f, 0f), 0.0) + assertEquals(EPUB_SPREAD_MIN_PAGE_MARGIN_CSS_PX, epubSpreadPageMarginCssPx(841, -5f, -5f), 0.0) + assertEquals(841 * 100 / 400.0, epubSpreadPageMarginCssPx(841, 150f, 150f), 0.0001) } } diff --git a/docs/architecture.md b/docs/architecture.md index b292a29b..6129ed75 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -61,7 +61,8 @@ Lagrange is a native Android client for BookOrbit focused on reading, listening, - `ReaderSpreadPolicy` is the pure geometry decision. - `ReaderWindowTracker` combines the activity configuration with Jetpack WindowManager `FoldingFeature` posture. It seeds from the last known hinge and debounces changes so posture-driven recreation cannot loop. - EPUB spreads use Readium `columnCount = TWO` / fixed-layout `Spread.ALWAYS`, plus `RsProperties` (`--RS__colCount: 2`, `--RS__colWidth: auto`, `--RS__pageGutter`) fixed at navigator creation. Inline Readium CSS properties bypass ReadiumCSS's 60em two-column media query, which the Fold8 inner display does not reach. - - Column gap stays zero so Readium's viewport-width pagination stays exact. The gap between pages is the per-column page gutter enabled by `pageMargins = 1`. + - Column gap stays zero so Readium's viewport-width pagination stays exact. The gap between pages comes from each column's own padding, `--RS__pageGutter × pageMargins`. + - Spread margins: the page gutter is a 1px unit and `pageMargins` carries the page margin in CSS px (`epubSpreadPageMarginCssPx`). It equals what the left/right margin setting gives a single page on the same window, using the mean of left and right, with a 12px minimum. The reader container drops its horizontal padding in a spread. So every page has the same margin on both sides, the inner gap is twice the margin, and margin changes apply live through `submitPreferences` without recreating the navigator. Top/bottom margins, line spacing, word spacing and font size are document-wide and apply to both pages equally. - Spread changes without a size change (posture, preference) recreate the EPUB activity at the saved locator. - Paginated comics replace the Readium image navigator with `ComicSpreadReader`, a Compose pager over `comicSpreads()` pairing. It reuses the continuous-comic decode loader and LRU cache, decoding pairs at half width. It persists the spread's first page as the resume locator and reports the last visible page as progress. - **OLED EPUB theme.** diff --git a/docs/fold-dual-page-plan.md b/docs/fold-dual-page-plan.md index af3c3e04..eecc4255 100644 --- a/docs/fold-dual-page-plan.md +++ b/docs/fold-dual-page-plan.md @@ -170,7 +170,7 @@ If this is revisited, the path would be to reuse the Phase 3 spread pager throug |---|---|---| | 0 Setup | Done | Fork `chortya/lagrange-reader`. Baseline 837 JVM tests green. The 5 parser tests that fail on a non-English host locale are pre-existing; run Gradle with `JAVA_TOOL_OPTIONS="-Duser.language=en -Duser.country=US"`. Pixel Fold API 36 AVD used as the Fold8 stand-in. | | 1 Foundation | Done | `ReaderSpreadPolicy`, `ReaderWindowTracker` (`androidx.window` 1.5.1, compileSdk 35 unchanged), per-library preferences and options UI. | -| 2 EPUB | Done | Readium reflowable `columnCount` / fixed-layout `spread`, plus `RsProperties` to bypass ReadiumCSS's 60em media query (found during implementation: the Fold8 window is under 960 CSS px), plus a per-column gutter. | +| 2 EPUB | Done | Readium reflowable `columnCount` / fixed-layout `spread`, plus `RsProperties` to bypass ReadiumCSS's 60em media query (found during implementation: the Fold8 window is under 960 CSS px), plus per-page margins that follow the reader margin setting live (symmetric on both pages; verified on device for margins, line spacing and font size). Rotating to portrait falls back to one page (instrumented test). | | OLED | Done | Pure black/white page, chrome, sheets, dialogs, window and status bar. In-page white links and selection. Images untouched. | | 3 Comics | Done | `ComicSpreadReader` pager: cover alone, shift, wide pages shown alone, RTL, zoom, export, spread navigation, first-page resume / last-page progress. | | 4 PDF | Out of scope | — | diff --git a/docs/testing.md b/docs/testing.md index a8e941bd..a5693581 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -76,6 +76,9 @@ On a Galaxy Z Fold8 (physical device or Samsung Remote Test Lab SM-F971): - **EPUB:** - Unfolded in the natural landscape posture, paginated EPUB shows two columns with a gutter on the hinge. A page turn advances both columns. - Rotate to portrait, fold to the cover screen, open split-screen, and switch to continuous layout: each shows one column. + - Margins: each page has the same margin on its outer and inner side, and the gap between the pages is twice that. Raising or lowering the left/right margin widens or narrows all four page edges at once, without reopening the book. Uneven left/right settings use their mean. + - Line spacing, word spacing, font size and font family change both pages identically and reflow text across both columns. + - Automated: `FoldSpreadOledActivityInstrumentedTest` measures the four page margins, the line pitch on each page, and the rotate-to-portrait fallback. - Confirm the following still work: resume position after fold/unfold, TTS sentence highlighting and auto page-turn across columns, highlights spanning the column boundary, and tap zones. - **Comics:** - Unfolded, paginated CBZ/CBR shows the cover alone, then facing pairs. From aa2d993cfe6534b8a52a19df0dc2b72f6e7ad9c9 Mon Sep 17 00:00:00 2001 From: chortya Date: Fri, 9 Oct 2026 22:23:52 +0200 Subject: [PATCH 08/13] docs: record Fold8 device validation of the spread test build Co-Authored-By: Claude Opus 5.5 --- docs/fold-dual-page-plan.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/fold-dual-page-plan.md b/docs/fold-dual-page-plan.md index eecc4255..430c9450 100644 --- a/docs/fold-dual-page-plan.md +++ b/docs/fold-dual-page-plan.md @@ -73,7 +73,7 @@ internal fun shouldUseTwoPageSpread( - [ ] Create a GitHub fork (`chortya/lagrange-reader`), push the branch, and add `upstream` as a remote. - [ ] Run the baseline gates green: `./gradlew :app:testDebugUnitTest :app:lintDebug :app:assembleDebug`. - [ ] Set up AVDs: "7.6\" Fold-in with outer display", Pixel 9 Pro Fold, and "Resizable (Experimental)". Install the Samsung Galaxy emulator skin for the Fold8 aspect ratio. -- [ ] Book a Remote Test Lab slot on Z Fold8 (SM-F971) for later phases if no physical device is available. +- [x] ~~Book a Remote Test Lab slot on Z Fold8 (SM-F971)~~ Not needed: validated on a physical Fold8 (2026-10-09). ### Phase 1 — Foundation: policy, preference, window tracking (1–2 days) @@ -174,4 +174,4 @@ If this is revisited, the path would be to reuse the Phase 3 spread pager throug | OLED | Done | Pure black/white page, chrome, sheets, dialogs, window and status bar. In-page white links and selection. Images untouched. | | 3 Comics | Done | `ComicSpreadReader` pager: cover alone, shift, wide pages shown alone, RTL, zoom, export, spread navigation, first-page resume / last-page progress. | | 4 PDF | Out of scope | — | -| 5 Validation | Automated done; device pending | JVM 905 tests, lint clean, connected suite on Pixel Fold AVD. A physical Fold8 / Remote Test Lab pass is still required (see `docs/testing.md` → Foldable spreads and OLED EPUB). | +| 5 Validation | Automated done; device validated | JVM 907 tests, lint clean; fold/spread/OLED connected tests (including rotate-to-portrait and on-screen measurement of EPUB spread margins, line spacing and font size) pass on a Pixel Fold API 36 AVD. Comic spreads meet at the fold with no gap on the AVD; the ~40px gap seen earlier did not reproduce. **2026-10-09: test build `fold-dual-page-test-20261009` (719b3de) checked on a physical Galaxy Z Fold8 by the owner and looks good.** Postures not yet explicitly confirmed on the device: half-open book/tabletop, split-screen, fold/unfold mid-chapter with TTS, RTL manga, orientation lock, Fold8 Ultra. | From ed3d0e033dbe7e16a884abce51fe88e41d572f22 Mon Sep 17 00:00:00 2001 From: chortya Date: Sat, 10 Oct 2026 16:39:18 +0200 Subject: [PATCH 09/13] feat: EPUB text alignment and hyphenation settings Adds per-library Text alignment (Left/Justified) and Hyphenation (Auto/On/Off; Auto hyphenates justified text only) to the reader settings and the in-book options sheet, mapped to Readium textAlign and hyphens and applied live. Books that declare no language get the device language so the browser can hyphenate; RTL and CJK device languages are skipped because Readium derives layout from the language. Tests: JVM storage/fallback rules; on-device measurement of hyphenated words and flush lines on both spread pages, the no-language fallback, and the options-sheet controls. Co-Authored-By: Claude Opus 5.5 --- ...pubReaderOptionsOverlayInstrumentedTest.kt | 44 +++++ .../FoldSpreadOledActivityInstrumentedTest.kt | 153 +++++++++++++++++- .../ReadiumEpubOpenInstrumentedTest.kt | 27 ++++ .../vangeaux/lagrange/BookOrbitHomeScreen.kt | 37 +++++ .../lagrange/LibraryReaderPreferences.kt | 39 +++++ .../lagrange/ReadiumEpubReaderActivity.kt | 46 +++++- .../EpubTypesettingPreferencesTest.kt | 84 ++++++++++ docs/architecture.md | 1 + docs/testing.md | 3 +- 9 files changed, 426 insertions(+), 8 deletions(-) create mode 100644 app/src/test/java/com/vangeaux/lagrange/EpubTypesettingPreferencesTest.kt diff --git a/app/src/androidTest/java/com/vangeaux/lagrange/EpubReaderOptionsOverlayInstrumentedTest.kt b/app/src/androidTest/java/com/vangeaux/lagrange/EpubReaderOptionsOverlayInstrumentedTest.kt index d703304b..f2fef8a8 100644 --- a/app/src/androidTest/java/com/vangeaux/lagrange/EpubReaderOptionsOverlayInstrumentedTest.kt +++ b/app/src/androidTest/java/com/vangeaux/lagrange/EpubReaderOptionsOverlayInstrumentedTest.kt @@ -173,6 +173,8 @@ class EpubReaderOptionsOverlayInstrumentedTest { .assertIsDisplayed() composeRule.onAllNodesWithText("Fonts").assertCountEquals(0) composeRule.onAllNodesWithText("Accessibility fonts").assertCountEquals(0) + composeRule.onAllNodesWithText("Text alignment").assertCountEquals(0) + composeRule.onAllNodesWithText("Hyphenation").assertCountEquals(0) } @Test @@ -229,6 +231,48 @@ class EpubReaderOptionsOverlayInstrumentedTest { } } + @Test + fun epubAlignmentAndHyphenationChoicesUpdateOnlyTheirSettings() { + val profile = mutableStateOf( + LibraryReaderPreferences( + theme = EpubReaderTheme.OledBlack, + fontScale = 1.3f, + lineSpacing = 1.4f, + padding = EpubPaddingPercentages(11f, 22f, 33f, 44f) + ) + ) + composeRule.setContent { + EpubReaderOptionsBottomSheet( + title = "Typesetting test", + status = "Chapter 1/1 · Page 1/1", + preferences = profile.value, + onContinueReading = {}, + onCloseBook = {}, + onPreferencesChange = { profile.value = it } + ) + } + + composeRule.onNodeWithText("Text alignment").performScrollTo().assertIsDisplayed() + composeRule.onNodeWithText("Auto hyphenates justified text only", substring = true) + .performScrollTo() + .assertIsDisplayed() + composeRule.onNodeWithTag("reader-options-reading-text-align-justify").performScrollTo().performClick() + composeRule.runOnIdle { + assertEquals(EpubTextAlignment.JUSTIFY, profile.value.textAlignment) + assertEquals(EpubHyphenation.AUTO, profile.value.hyphenation) + } + composeRule.onNodeWithTag("reader-options-reading-hyphenation-off").performScrollTo().performClick() + + composeRule.runOnIdle { + assertEquals(EpubTextAlignment.JUSTIFY, profile.value.textAlignment) + assertEquals(EpubHyphenation.OFF, profile.value.hyphenation) + assertEquals(EpubReaderTheme.OledBlack, profile.value.theme) + assertEquals(1.3f, profile.value.fontScale) + assertEquals(1.4f, profile.value.lineSpacing) + assertEquals(EpubPaddingPercentages(11f, 22f, 33f, 44f), profile.value.padding) + } + } + @Test fun readerConfigurationEditsPreserveAllFormatLayoutsAndGaps() { val profile = mutableStateOf( diff --git a/app/src/androidTest/java/com/vangeaux/lagrange/FoldSpreadOledActivityInstrumentedTest.kt b/app/src/androidTest/java/com/vangeaux/lagrange/FoldSpreadOledActivityInstrumentedTest.kt index cd244415..346d9009 100644 --- a/app/src/androidTest/java/com/vangeaux/lagrange/FoldSpreadOledActivityInstrumentedTest.kt +++ b/app/src/androidTest/java/com/vangeaux/lagrange/FoldSpreadOledActivityInstrumentedTest.kt @@ -277,11 +277,12 @@ class FoldSpreadOledActivityInstrumentedTest { private fun launchSpreadEpub( profile: LibraryReaderPreferences, + fixture: (File) -> Unit = { writeEpub(it) }, block: suspend (ActivityScenario, Int) -> Unit ) = runBlocking { val epub = File(context.cacheDir, "fold-layout-${System.nanoTime()}.epub") val readerKey = "fold-layout-epub-${System.nanoTime()}" - writeEpub(epub) + fixture(epub) useProfile(profile) try { launchEpub(epub, readerKey).use { scenario -> @@ -375,6 +376,146 @@ class FoldSpreadOledActivityInstrumentedTest { scenario.onActivity { activity -> assertTrue(activity.isTwoPageSpreadActive()) } } + // ---- EPUB spread typesetting: alignment and hyphenation ---- + + /** Hyphenation and justification on each spread page, measured inside the WebView. */ + private data class SpreadTypesetting( + val align: String, + val hyphens: String, + val contentLanguage: String, + val leftSplitWords: Int, + val rightSplitWords: Int, + val leftFlushRatio: Double, + val rightFlushRatio: Double + ) + + private val typesettingScript = """ + (function() { + var vw = window.innerWidth, mid = vw / 2; + var p = document.querySelectorAll('p')[1], cs = getComputedStyle(p); + var onScreen = function(r) { return r.width > 0 && r.right > 0 && r.left < vw; }; + var split = [0, 0]; + var walker = document.createTreeWalker(p, NodeFilter.SHOW_TEXT), node; + while ((node = walker.nextNode())) { + var re = /[A-Za-z]{9,}/g, m; + while ((m = re.exec(node.data))) { + var r = document.createRange(); r.setStart(node, m.index); r.setEnd(node, m.index + m[0].length); + var rects = Array.prototype.filter.call(r.getClientRects(), onScreen); + if (!rects.length) continue; + var tops = rects.map(function(x) { return Math.round(x.top); }) + .filter(function(v, i, a) { return a.indexOf(v) === i; }); + if (tops.length > 1) split[(rects[0].left + rects[0].right) / 2 < mid ? 0 : 1]++; + } + } + // Share of full lines whose right edge is flush with the column edge (justified text). + var whole = document.createRange(); whole.selectNodeContents(p); + var lines = {}; + Array.prototype.forEach.call(whole.getClientRects(), function(x) { + if (!onScreen(x)) return; + var key = ((x.left + x.right) / 2 < mid ? 'L' : 'R') + Math.round(x.top); + lines[key] = Math.max(lines[key] || 0, x.right); + }); + var flush = function(side) { + var rights = Object.keys(lines).filter(function(k) { return k[0] === side; }) + .map(function(k) { return lines[k]; }); + if (rights.length < 3) return -1; + var edge = Math.max.apply(null, rights); + return rights.filter(function(v) { return edge - v < 1.5; }).length / rights.length; + }; + var html = document.documentElement; + var lang = html.getAttribute('lang') || html.getAttribute('xml:lang') || ''; + return [cs.textAlign, cs.hyphens, lang, split[0], split[1], flush('L'), flush('R')].join('|'); + })() + """.trimIndent() + + private suspend fun awaitTypesetting( + scenario: ActivityScenario, + message: String, + accept: (SpreadTypesetting) -> Boolean + ): SpreadTypesetting { + var last: SpreadTypesetting? = null + repeat(40) { + val raw = awaitJavascript(scenario, typesettingScript) + if (raw != null && raw != "null") { + val v = raw.trim('"').split('|') + last = SpreadTypesetting( + v[0], v[1], v[2], v[3].toInt(), v[4].toInt(), v[5].toDouble(), v[6].toDouble() + ) + if (accept(last!!)) return last!! + } + SystemClock.sleep(250) + } + throw AssertionError("$message; last typesetting $last") + } + + private val longWordParagraph = + ("Referred to the embryo's troublesome tendency to anaemia, the extraordinarily " + + "uncharacteristically incomprehensible institutionalization of representatives, " + + "simultaneously shaken into familiarity with movement and enumerated precautions. ").repeat(60) + + @Test + fun epubSpreadAlignmentAndHyphenationApplyToBothPagesLive() = launchSpreadEpub( + LibraryReaderPreferences(twoPageSpread = ReaderSpreadMode.AUTO), + fixture = { writeEpub(it, language = "en", paragraph = longWordParagraph) } + ) { scenario, _ -> + // Default: left aligned, Auto hyphenation, so nothing is hyphenated (unchanged behaviour). + val defaults = awaitTypesetting(scenario, "default typesetting not applied") { + it.align == "start" && it.leftFlushRatio >= 0 && it.rightFlushRatio >= 0 + } + assertEquals(0, defaults.leftSplitWords) + assertEquals(0, defaults.rightSplitWords) + assertEquals("en", defaults.contentLanguage) + + // Justified + Auto: both pages justify and hyphenate. + scenario.onActivity { activity -> + activity.applyReaderPreferencesForTest { it.copy(textAlignment = EpubTextAlignment.JUSTIFY) } + } + val justified = awaitTypesetting(scenario, "justified text was not hyphenated on both pages") { + it.align == "justify" && it.leftSplitWords > 0 && it.rightSplitWords > 0 + } + assertEquals("auto", justified.hyphens) + assertTrue("left page lines are flush: $justified", justified.leftFlushRatio > 0.8) + assertTrue("right page lines are flush: $justified", justified.rightFlushRatio > 0.8) + assertTrue("left-aligned text is ragged: $defaults", defaults.leftFlushRatio < justified.leftFlushRatio) + + // Justified + Off: no hyphenation. + scenario.onActivity { activity -> + activity.applyReaderPreferencesForTest { it.copy(hyphenation = EpubHyphenation.OFF) } + } + val unhyphenated = awaitTypesetting(scenario, "hyphenation did not turn off") { + it.align == "justify" && it.leftSplitWords == 0 && it.rightSplitWords == 0 + } + // ReadiumCSS writes `none`; either way the browser must not hyphenate. + assertTrue(unhyphenated.hyphens != "auto") + + // Left + On: ragged right but hyphenated. + scenario.onActivity { activity -> + activity.applyReaderPreferencesForTest { + it.copy(textAlignment = EpubTextAlignment.START, hyphenation = EpubHyphenation.ON) + } + } + awaitTypesetting(scenario, "left-aligned text was not hyphenated on both pages") { + it.align == "start" && it.leftSplitWords > 0 && it.rightSplitWords > 0 + } + scenario.onActivity { activity -> assertTrue(activity.isTwoPageSpreadActive()) } + } + + @Test + fun epubWithoutDeclaredLanguageHyphenatesWithTheDeviceLanguage() = launchSpreadEpub( + LibraryReaderPreferences( + twoPageSpread = ReaderSpreadMode.AUTO, + textAlignment = EpubTextAlignment.JUSTIFY + ), + fixture = { writeEpub(it, language = null, paragraph = longWordParagraph) } + ) { scenario, _ -> + val expected = epubFallbackContentLanguage(emptyList(), java.util.Locale.getDefault()) + assumeTrue("Device language must be one that hyphenates", expected != null) + val typeset = awaitTypesetting(scenario, "book without a language was not hyphenated") { + it.align == "justify" && it.leftSplitWords > 0 && it.rightSplitWords > 0 + } + assertEquals(expected, typeset.contentLanguage) + } + // ---- Rotation ---- @Test @@ -580,7 +721,11 @@ class FoldSpreadOledActivityInstrumentedTest { } } - private fun writeEpub(target: File) { + private fun writeEpub( + target: File, + language: String? = "en", + paragraph: String = "Fold and OLED reading content. ".repeat(400) + ) { ZipOutputStream(target.outputStream().buffered()).use { zip -> val mimetype = "application/epub+zip".toByteArray() zip.putNextEntry( @@ -617,7 +762,7 @@ class FoldSpreadOledActivityInstrumentedTest { fold-oled-fixture Fold OLED Fixture - en + ${language?.let { "$it" }.orEmpty()} 2026-10-09T00:00:00Z @@ -627,7 +772,7 @@ class FoldSpreadOledActivityInstrumentedTest { """ ) - val text = "Fold and OLED reading content. ".repeat(400) + val text = paragraph deflated( "OEBPS/Text/chapter-1.xhtml", """ diff --git a/app/src/androidTest/java/com/vangeaux/lagrange/ReadiumEpubOpenInstrumentedTest.kt b/app/src/androidTest/java/com/vangeaux/lagrange/ReadiumEpubOpenInstrumentedTest.kt index 28e56d5b..984a40c1 100644 --- a/app/src/androidTest/java/com/vangeaux/lagrange/ReadiumEpubOpenInstrumentedTest.kt +++ b/app/src/androidTest/java/com/vangeaux/lagrange/ReadiumEpubOpenInstrumentedTest.kt @@ -481,6 +481,33 @@ class ReadiumEpubOpenInstrumentedTest { assertEquals(false, preferences.scroll) } + @OptIn(ExperimentalReadiumApi::class) + @Test + fun typesettingPreferencesMapToReadiumAlignmentHyphensAndLanguage() { + val defaults = readiumPreferences(EpubReaderTheme.Light, fontScale = 1f) + assertEquals(org.readium.r2.navigator.preferences.TextAlign.START, defaults.textAlign) + assertEquals(false, defaults.hyphens) + assertEquals(null, defaults.language) + + val justified = readiumPreferences( + EpubReaderTheme.Light, + fontScale = 1f, + textAlignment = EpubTextAlignment.JUSTIFY, + fallbackLanguage = "de-DE" + ) + assertEquals(org.readium.r2.navigator.preferences.TextAlign.JUSTIFY, justified.textAlign) + assertEquals(true, justified.hyphens) + assertEquals("de-DE", justified.language?.code) + + val forcedOff = readiumPreferences( + EpubReaderTheme.Light, + fontScale = 1f, + textAlignment = EpubTextAlignment.JUSTIFY, + hyphenation = EpubHyphenation.OFF + ) + assertEquals(false, forcedOff.hyphens) + } + @OptIn(ExperimentalReadiumApi::class) @Test fun spreadPreferencesCarryThePageMarginAndSinglePageKeepsNone() { diff --git a/app/src/main/java/com/vangeaux/lagrange/BookOrbitHomeScreen.kt b/app/src/main/java/com/vangeaux/lagrange/BookOrbitHomeScreen.kt index 8a70be92..e79b4096 100644 --- a/app/src/main/java/com/vangeaux/lagrange/BookOrbitHomeScreen.kt +++ b/app/src/main/java/com/vangeaux/lagrange/BookOrbitHomeScreen.kt @@ -4608,6 +4608,43 @@ internal fun ReaderConfigurationControls( modifier = Modifier.testTag("$testTagPrefix-word-spacing") ) } + Text("Text alignment", style = MaterialTheme.typography.titleMedium) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + EpubTextAlignment.values().forEach { alignment -> + FilterChip( + selected = value.textAlignment == alignment, + onClick = { onPreferencesChange(value.copy(textAlignment = alignment)) }, + label = { Text(alignment.displayName) }, + modifier = Modifier.testTag("$testTagPrefix-text-align-${alignment.name.lowercase()}") + ) + } + } + Text("Hyphenation", style = MaterialTheme.typography.titleMedium) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + EpubHyphenation.values().forEach { hyphenation -> + FilterChip( + selected = value.hyphenation == hyphenation, + onClick = { onPreferencesChange(value.copy(hyphenation = hyphenation)) }, + label = { Text(hyphenation.displayName) }, + modifier = Modifier.testTag("$testTagPrefix-hyphenation-${hyphenation.name.lowercase()}") + ) + } + } + Text( + if (value.hyphenation == EpubHyphenation.AUTO) { + "Auto hyphenates justified text only. Uses the book's language, or the device language if the book declares none." + } else { + "Uses the book's language, or the device language if the book declares none." + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } if (format != ReaderConfigurationFormat.COMIC) { Row( diff --git a/app/src/main/java/com/vangeaux/lagrange/LibraryReaderPreferences.kt b/app/src/main/java/com/vangeaux/lagrange/LibraryReaderPreferences.kt index 13f7428d..1b6067fa 100644 --- a/app/src/main/java/com/vangeaux/lagrange/LibraryReaderPreferences.kt +++ b/app/src/main/java/com/vangeaux/lagrange/LibraryReaderPreferences.kt @@ -22,6 +22,39 @@ enum class EpubReaderFontFamily(val displayName: String) { CUSTOM("Custom font") } +enum class EpubTextAlignment(val displayName: String) { + START("Left"), + JUSTIFY("Justified") +} + +/** AUTO hyphenates justified text only, which is when ragged gaps would otherwise appear. */ +enum class EpubHyphenation(val displayName: String) { + AUTO("Auto"), + ON("On"), + OFF("Off") +} + +internal fun epubHyphenationActive(alignment: EpubTextAlignment, hyphenation: EpubHyphenation): Boolean = + when (hyphenation) { + EpubHyphenation.ON -> true + EpubHyphenation.OFF -> false + EpubHyphenation.AUTO -> alignment == EpubTextAlignment.JUSTIFY + } + +internal fun epubTextAlignmentStorageValue(value: EpubTextAlignment): String = value.name.lowercase() + +internal fun epubTextAlignmentFromStorage(value: String?): EpubTextAlignment = + if (value?.trim()?.lowercase() == "justify") EpubTextAlignment.JUSTIFY else EpubTextAlignment.START + +internal fun epubHyphenationStorageValue(value: EpubHyphenation): String = value.name.lowercase() + +internal fun epubHyphenationFromStorage(value: String?): EpubHyphenation = + when (value?.trim()?.lowercase()) { + "on" -> EpubHyphenation.ON + "off" -> EpubHyphenation.OFF + else -> EpubHyphenation.AUTO + } + internal val EPUB_NORMAL_FONT_FAMILY_OPTIONS = listOf( EpubReaderFontFamily.PUBLISHER_DEFAULT, EpubReaderFontFamily.SYSTEM_SERIF, @@ -58,6 +91,8 @@ data class LibraryReaderPreferences( val fontScale: Float = 1f, val lineSpacing: Float = DEFAULT_EPUB_LINE_SPACING, val wordSpacing: Float = DEFAULT_EPUB_WORD_SPACING, + val textAlignment: EpubTextAlignment = EpubTextAlignment.START, + val hyphenation: EpubHyphenation = EpubHyphenation.AUTO, val padding: EpubPaddingPercentages = EpubPaddingPercentages(), val epubLayoutMode: ReaderLayoutMode = ReaderLayoutMode.PAGINATED, val pdfLayoutMode: ReaderLayoutMode = ReaderLayoutMode.CONTINUOUS, @@ -172,6 +207,8 @@ private fun libraryReaderPreferenceStorageValue(value: LibraryReaderPreferences) put("fontScale", normalized.fontScale.toDouble()) put("lineSpacing", normalized.lineSpacing.toDouble()) put("wordSpacing", normalized.wordSpacing.toDouble()) + put("textAlignment", epubTextAlignmentStorageValue(normalized.textAlignment)) + put("hyphenation", epubHyphenationStorageValue(normalized.hyphenation)) put("top", normalized.padding.top.toDouble()) put("bottom", normalized.padding.bottom.toDouble()) put("left", normalized.padding.left.toDouble()) @@ -217,6 +254,8 @@ internal fun libraryReaderPreferencesFromStorage(value: String?): Map ReadiumTextAlign.START + EpubTextAlignment.JUSTIFY -> ReadiumTextAlign.JUSTIFY +} + +/** Base languages without system hyphenation patterns, or whose script changes Readium's layout (RTL, CJK). */ +private val EPUB_NON_HYPHENATING_LANGUAGES = setOf( + "ar", "he", "iw", "fa", "ur", "yi", "ji", "ps", "sd", "ckb", "dv", "ug", "zh", "ja", "ko" +) + +/** + * The browser hyphenates only text whose language is known. Books that declare no language get the + * device language so hyphenation still works. It is applied only for languages that hyphenate, + * because Readium also derives vertical text and right-to-left layout from the content language. + */ +internal fun epubFallbackContentLanguage(publicationLanguages: List, deviceLocale: Locale): String? { + if (publicationLanguages.any { it.isNotBlank() }) return null + val base = deviceLocale.language.lowercase(Locale.ROOT) + if (base.isBlank() || base == "und" || base in EPUB_NON_HYPHENATING_LANGUAGES) return null + return deviceLocale.toLanguageTag() +} + internal fun readiumEpubWordSpacing(wordSpacing: Float): Double = wordSpacing .coerceIn(DEFAULT_EPUB_WORD_SPACING, MAX_EPUB_WORD_SPACING) .toDouble() @@ -237,7 +263,10 @@ internal fun readiumPreferences( fontFamily: EpubReaderFontFamily = EpubReaderFontFamily.PUBLISHER_DEFAULT, wordSpacing: Float = DEFAULT_EPUB_WORD_SPACING, twoPageSpread: Boolean = false, - spreadPageMarginCssPx: Double = EPUB_SPREAD_MIN_PAGE_MARGIN_CSS_PX + spreadPageMarginCssPx: Double = EPUB_SPREAD_MIN_PAGE_MARGIN_CSS_PX, + textAlignment: EpubTextAlignment = EpubTextAlignment.START, + hyphenation: EpubHyphenation = EpubHyphenation.AUTO, + fallbackLanguage: String? = null ): EpubPreferences = EpubPreferences( backgroundColor = ReadiumColor(theme.backgroundColor), textColor = ReadiumColor(cssHexColorInt(theme.foregroundCss)), @@ -249,6 +278,9 @@ internal fun readiumPreferences( fontSize = fontScale.coerceIn(MIN_EPUB_FONT_SCALE, MAX_EPUB_FONT_SCALE).toDouble(), lineHeight = readiumEpubLineHeight(lineSpacing), wordSpacing = readiumEpubWordSpacing(wordSpacing), + textAlign = readiumEpubTextAlign(textAlignment), + hyphens = epubHyphenationActive(textAlignment, hyphenation), + language = fallbackLanguage?.let(::ReadiumLanguage), fontFamily = readiumFontFamily(fontFamily), readingProgression = readiumEpubReadingProgression(readingDirection), // Spreads give each column its own margin (in units of the 1px spread page gutter). @@ -624,6 +656,7 @@ internal fun shouldLoadNextAnnotationPage( class ReadiumEpubReaderActivity : FragmentActivity() { private var publication: Publication? = null private var navigator: EpubNavigatorFragment? = null + private var fallbackContentLanguage: String? = null private var progressView: ProgressBar? = null private val readerContainerId: Int = R.id.readium_reader_container private lateinit var rootView: FrameLayout @@ -1207,6 +1240,7 @@ class ReadiumEpubReaderActivity : FragmentActivity() { return } publication = openedPublication + fallbackContentLanguage = epubFallbackContentLanguage(openedPublication.metadata.languages, Locale.getDefault()) ttsAvailable = !isPreview && AndroidTtsNavigatorFactory(application, openedPublication) != null readingSessionReporter.start( intent.getFloatExtra(EXTRA_INITIAL_PERCENT, Float.NaN).takeUnless(Float::isNaN) @@ -1240,7 +1274,10 @@ class ReadiumEpubReaderActivity : FragmentActivity() { selectedFontFamily, readerPreferences.wordSpacing, twoPageSpreadActive, - spreadPageMarginCssPx() + spreadPageMarginCssPx(), + readerPreferences.textAlignment, + readerPreferences.hyphenation, + fallbackContentLanguage ), paginationListener = paginationListener, configuration = EpubNavigatorFragment.Configuration( @@ -2721,7 +2758,10 @@ class ReadiumEpubReaderActivity : FragmentActivity() { normalized.fontFamily, normalized.wordSpacing, twoPageSpreadActive, - spreadPageMarginCssPx() + spreadPageMarginCssPx(), + normalized.textAlignment, + normalized.hyphenation, + fallbackContentLanguage ) ) val current = appPreferencesStore.read() diff --git a/app/src/test/java/com/vangeaux/lagrange/EpubTypesettingPreferencesTest.kt b/app/src/test/java/com/vangeaux/lagrange/EpubTypesettingPreferencesTest.kt new file mode 100644 index 00000000..5731e463 --- /dev/null +++ b/app/src/test/java/com/vangeaux/lagrange/EpubTypesettingPreferencesTest.kt @@ -0,0 +1,84 @@ +package com.vangeaux.lagrange + +import java.util.Locale +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class EpubTypesettingPreferencesTest { + @Test + fun `alignment and hyphenation default to left aligned with automatic hyphenation`() { + val defaults = LibraryReaderPreferences() + assertEquals(EpubTextAlignment.START, defaults.textAlignment) + assertEquals(EpubHyphenation.AUTO, defaults.hyphenation) + // Existing left-aligned reading is unchanged until the reader opts in. + assertFalse(epubHyphenationActive(defaults.textAlignment, defaults.hyphenation)) + } + + @Test + fun `automatic hyphenation follows justified text and explicit choices win`() { + assertTrue(epubHyphenationActive(EpubTextAlignment.JUSTIFY, EpubHyphenation.AUTO)) + assertFalse(epubHyphenationActive(EpubTextAlignment.START, EpubHyphenation.AUTO)) + assertTrue(epubHyphenationActive(EpubTextAlignment.START, EpubHyphenation.ON)) + assertTrue(epubHyphenationActive(EpubTextAlignment.JUSTIFY, EpubHyphenation.ON)) + assertFalse(epubHyphenationActive(EpubTextAlignment.JUSTIFY, EpubHyphenation.OFF)) + assertFalse(epubHyphenationActive(EpubTextAlignment.START, EpubHyphenation.OFF)) + } + + @Test + fun `alignment and hyphenation round trip per library`() { + val justified = LibraryReaderPreferences( + textAlignment = EpubTextAlignment.JUSTIFY, + hyphenation = EpubHyphenation.OFF + ) + val hyphenatedLeft = LibraryReaderPreferences(hyphenation = EpubHyphenation.ON) + + val decoded = libraryReaderPreferencesFromStorage( + libraryReaderPreferencesStorageValue(mapOf("novels" to justified, "essays" to hyphenatedLeft)) + ) + + assertEquals(justified, decoded["novels"]) + assertEquals(hyphenatedLeft, decoded["essays"]) + } + + @Test + fun `legacy and unknown typesetting values fall back without dropping the profile`() { + val legacy = libraryReaderPreferencesFromStorage( + """{"version":2,"profiles":{"library":{"theme":"oled_black"}}}""" + ).getValue("library") + assertEquals(EpubTextAlignment.START, legacy.textAlignment) + assertEquals(EpubHyphenation.AUTO, legacy.hyphenation) + assertEquals(EpubReaderTheme.OledBlack, legacy.theme) + + val unknown = libraryReaderPreferencesFromStorage( + """{"version":2,"profiles":{"library":{"textAlignment":"centre","hyphenation":"maybe","fontScale":1.5}}}""" + ).getValue("library") + assertEquals(EpubTextAlignment.START, unknown.textAlignment) + assertEquals(EpubHyphenation.AUTO, unknown.hyphenation) + assertEquals(1.5f, unknown.fontScale) + } + + @Test + fun `books that declare a language keep it`() { + assertNull(epubFallbackContentLanguage(listOf("en"), Locale.GERMANY)) + assertNull(epubFallbackContentLanguage(listOf("", "fr"), Locale.GERMANY)) + } + + @Test + fun `books without a language use the device language for hyphenation`() { + assertEquals("de-DE", epubFallbackContentLanguage(emptyList(), Locale.GERMANY)) + assertEquals("en-US", epubFallbackContentLanguage(listOf(" "), Locale.US)) + assertEquals("uk-UA", epubFallbackContentLanguage(emptyList(), Locale.forLanguageTag("uk-UA"))) + } + + @Test + fun `device languages that change Readium layout or cannot hyphenate are not applied`() { + // Readium derives right-to-left and vertical layout from the content language. + listOf("ar-EG", "he-IL", "fa-IR", "ur-PK", "ja-JP", "zh-CN", "zh-TW", "ko-KR").forEach { tag -> + assertNull(tag, epubFallbackContentLanguage(emptyList(), Locale.forLanguageTag(tag))) + } + assertNull(epubFallbackContentLanguage(emptyList(), Locale.ROOT)) + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 6129ed75..084e4ad0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -63,6 +63,7 @@ Lagrange is a native Android client for BookOrbit focused on reading, listening, - EPUB spreads use Readium `columnCount = TWO` / fixed-layout `Spread.ALWAYS`, plus `RsProperties` (`--RS__colCount: 2`, `--RS__colWidth: auto`, `--RS__pageGutter`) fixed at navigator creation. Inline Readium CSS properties bypass ReadiumCSS's 60em two-column media query, which the Fold8 inner display does not reach. - Column gap stays zero so Readium's viewport-width pagination stays exact. The gap between pages comes from each column's own padding, `--RS__pageGutter × pageMargins`. - Spread margins: the page gutter is a 1px unit and `pageMargins` carries the page margin in CSS px (`epubSpreadPageMarginCssPx`). It equals what the left/right margin setting gives a single page on the same window, using the mean of left and right, with a 12px minimum. The reader container drops its horizontal padding in a spread. So every page has the same margin on both sides, the inner gap is twice the margin, and margin changes apply live through `submitPreferences` without recreating the navigator. Top/bottom margins, line spacing, word spacing and font size are document-wide and apply to both pages equally. +- EPUB typesetting (all reader modes): per-library `textAlignment` (Left → Readium `TextAlign.START`, so right-to-left text keeps its own edge; Justified → `JUSTIFY`) and `hyphenation` (Auto/On/Off; Auto hyphenates justified text only) map to Readium `textAlign`/`hyphens` and apply live. Browser hyphenation needs a content language: books that declare none get the device language through Readium's `language` preference (`epubFallbackContentLanguage`). It is skipped for right-to-left and CJK device languages, because Readium also derives RTL and vertical layout from the language and those scripts have no hyphenation patterns. - Spread changes without a size change (posture, preference) recreate the EPUB activity at the saved locator. - Paginated comics replace the Readium image navigator with `ComicSpreadReader`, a Compose pager over `comicSpreads()` pairing. It reuses the continuous-comic decode loader and LRU cache, decoding pairs at half width. It persists the spread's first page as the resume locator and reports the last visible page as progress. - **OLED EPUB theme.** diff --git a/docs/testing.md b/docs/testing.md index a5693581..6fa1bf31 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -78,7 +78,8 @@ On a Galaxy Z Fold8 (physical device or Samsung Remote Test Lab SM-F971): - Rotate to portrait, fold to the cover screen, open split-screen, and switch to continuous layout: each shows one column. - Margins: each page has the same margin on its outer and inner side, and the gap between the pages is twice that. Raising or lowering the left/right margin widens or narrows all four page edges at once, without reopening the book. Uneven left/right settings use their mean. - Line spacing, word spacing, font size and font family change both pages identically and reflow text across both columns. - - Automated: `FoldSpreadOledActivityInstrumentedTest` measures the four page margins, the line pitch on each page, and the rotate-to-portrait fallback. + - Text alignment and hyphenation: Left with Auto hyphenation (default) is ragged and unhyphenated. Justified hyphenates long words and gives flush lines on both pages. Hyphenation Off removes hyphens; Left with On hyphenates ragged text. Changes apply without reopening the book. A book whose metadata declares no language is hyphenated in the device language. + - Automated: `FoldSpreadOledActivityInstrumentedTest` measures the four page margins, the line pitch on each page, hyphenated words and flush lines on each page, the no-language fallback, and the rotate-to-portrait fallback. `EpubTypesettingPreferencesTest` (JVM) covers storage and the fallback rules. - Confirm the following still work: resume position after fold/unfold, TTS sentence highlighting and auto page-turn across columns, highlights spanning the column boundary, and tap zones. - **Comics:** - Unfolded, paginated CBZ/CBR shows the cover alone, then facing pairs. From 18764a19b4cbb222ed9203041e4104ed163734a3 Mon Sep 17 00:00:00 2001 From: chortya Date: Sat, 10 Oct 2026 17:29:36 +0200 Subject: [PATCH 10/13] fix: keep the read-aloud position when the EPUB reader returns Background read-aloud saves each utterance position after the reader stopped. Two paths then put the reader back at the older visual page and saved it over the newer position (and reported it to the server): - Rebuilding the reader from saved instance state (process death) preferred the saved-state locator over the newer stored position. The stores, written on every move, now win; saved state is only a fallback (preview). - Returning to the foreground replayed the navigator's last position through the locator flow and the pagination listener. Only real moves are recorded now; re-reports of the same place are written only while the store still holds this reader's own position. Also send the current read-aloud position to the server when playback is paused, past the 15 s / 0.2% progress throttle. Tests: instrumented regression tests for both paths (red before the fix) and for configuration recreation. Co-Authored-By: Claude Opus 5.5 --- .../EpubPositionRestoreInstrumentedTest.kt | 205 ++++++++++++++++++ .../lagrange/EpubTtsPlaybackService.kt | 17 +- .../lagrange/ReadiumEpubReaderActivity.kt | 64 +++++- 3 files changed, 279 insertions(+), 7 deletions(-) create mode 100644 app/src/androidTest/java/com/vangeaux/lagrange/EpubPositionRestoreInstrumentedTest.kt diff --git a/app/src/androidTest/java/com/vangeaux/lagrange/EpubPositionRestoreInstrumentedTest.kt b/app/src/androidTest/java/com/vangeaux/lagrange/EpubPositionRestoreInstrumentedTest.kt new file mode 100644 index 00000000..df08b036 --- /dev/null +++ b/app/src/androidTest/java/com/vangeaux/lagrange/EpubPositionRestoreInstrumentedTest.kt @@ -0,0 +1,205 @@ +package com.vangeaux.lagrange + +import android.os.SystemClock +import androidx.lifecycle.Lifecycle +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import java.io.File +import java.util.zip.CRC32 +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.readium.r2.shared.publication.Locator + +/** + * Background read-aloud keeps saving the reading position after the reader has stopped and Android + * has captured its saved instance state. When Android later rebuilds the reader from that state + * (process death, or the activity being reclaimed), the newer stored position must win. + */ +@RunWith(AndroidJUnit4::class) +class EpubPositionRestoreInstrumentedTest { + private val context = ApplicationProvider.getApplicationContext() + private val epub = File(context.cacheDir, "position-restore-${System.nanoTime()}.epub") + private val readerKey = "position-restore-${System.nanoTime()}" + + @Before + fun writeFixture() = writeThreeChapterEpub(epub) + + @After + fun cleanUp() { + epub.delete() + ReadiumEpubTtsPositionStore(context).remove(readerKey) + } + + private fun launch(): ActivityScenario = ActivityScenario.launch( + ReadiumEpubReaderActivity.createIntent( + context = context, + file = epub, + title = "Position restore", + readerKey = readerKey, + launchMode = ReaderLaunchMode.NORMAL, + initialChapter = 0, + initialPage = 0, + initialPageCount = 1, + initialPercent = null + ) + ) + + /** Waits for a stored position in the accepted resource, settled with its book position. */ + private fun awaitStoredHref(message: String, accept: (String) -> Boolean): Locator { + var last: Locator? = null + repeat(80) { + last = ReadiumEpubLocatorStore(context).read(readerKey) + if (last != null && accept(last!!.href.toString()) && last!!.locations.totalProgression != null) { + return last!! + } + SystemClock.sleep(250) + } + throw AssertionError("$message; stored ${last?.href}") + } + + /** What EpubTtsPlaybackService writes for each utterance while the reader is in the background. */ + private fun simulateBackgroundReadAloudReaching(chapterHref: String): Locator { + val locator = requireNotNull( + Locator.fromJSON( + JSONObject( + """{"href":"$chapterHref","type":"application/xhtml+xml", + "locations":{"progression":0.5,"totalProgression":0.83}}""" + ) + ) + ) + ReadiumEpubTtsPositionStore(context).save(readerKey, locator) + ReadiumEpubLocatorStore(context).save(readerKey, locator) + return locator + } + + @Test + fun restoredReaderPrefersPositionSavedByBackgroundReadAloud() { + launch().use { scenario -> + val opened = awaitStoredHref("reader did not open on chapter 1") { it.endsWith("chapter-1.xhtml") } + + // Leave the reader: Android captures the saved instance state at chapter 1. + scenario.moveToState(Lifecycle.State.CREATED) + val later = simulateBackgroundReadAloudReaching( + opened.href.toString().replace("chapter-1.xhtml", "chapter-3.xhtml") + ) + + SystemClock.sleep(2000) + // Android rebuilds the reader from the saved state captured before read-aloud moved on. + scenario.recreate() + scenario.moveToState(Lifecycle.State.RESUMED) + + val restored = awaitStoredHref("reader jumped back to the stale saved-state position") { + it.endsWith("chapter-3.xhtml") + } + SystemClock.sleep(1500) + val settled = requireNotNull(ReadiumEpubLocatorStore(context).read(readerKey)) + assertEquals(later.href, restored.href) + assertTrue( + "reader must stay on the newer position, not overwrite it with the old one: ${settled.href}", + settled.href.toString().endsWith("chapter-3.xhtml") + ) + } + } + + @Test + fun returningToTheForegroundDoesNotOverwriteThePositionSavedByBackgroundReadAloud() { + launch().use { scenario -> + val opened = awaitStoredHref("reader did not open on chapter 1") { it.endsWith("chapter-1.xhtml") } + + scenario.moveToState(Lifecycle.State.CREATED) + val later = simulateBackgroundReadAloudReaching( + opened.href.toString().replace("chapter-1.xhtml", "chapter-3.xhtml") + ) + // Unlock the phone / switch back: the same reader instance starts again. + scenario.moveToState(Lifecycle.State.RESUMED) + SystemClock.sleep(2000) + + val stored = requireNotNull(ReadiumEpubLocatorStore(context).read(readerKey)) + assertEquals( + "the replayed visual position must not replace the newer read-aloud position", + later.href, + stored.href + ) + } + } + + @Test + fun configurationRecreationStillRestoresTheCurrentPosition() { + launch().use { scenario -> + val opened = awaitStoredHref("reader did not open") { it.endsWith("chapter-1.xhtml") } + scenario.recreate() + val restored = awaitStoredHref("recreation lost the position") { it.endsWith("chapter-1.xhtml") } + assertEquals(opened.href, restored.href) + } + } + + private fun writeThreeChapterEpub(target: File) { + ZipOutputStream(target.outputStream().buffered()).use { zip -> + val mimetype = "application/epub+zip".toByteArray() + zip.putNextEntry( + ZipEntry("mimetype").apply { + method = ZipEntry.STORED + size = mimetype.size.toLong() + compressedSize = mimetype.size.toLong() + crc = CRC32().apply { update(mimetype) }.value + } + ) + zip.write(mimetype) + zip.closeEntry() + fun deflated(name: String, text: String) { + zip.putNextEntry(ZipEntry(name)) + zip.write(text.trimIndent().toByteArray()) + zip.closeEntry() + } + deflated( + "META-INF/container.xml", + """ + + + + + + + """ + ) + val chapters = (1..3) + deflated( + "OEBPS/content.opf", + """ + + + + position-restore-fixture + Position Restore Fixture + en + 2026-10-10T00:00:00Z + + + ${chapters.joinToString("") { """""" }} + + ${chapters.joinToString("") { """""" }} + + """ + ) + chapters.forEach { index -> + deflated( + "OEBPS/Text/chapter-$index.xhtml", + """ + + Chapter $index +

Chapter $index

${"Chapter $index reading content. ".repeat(300)}

+ + """ + ) + } + } + } +} diff --git a/app/src/main/java/com/vangeaux/lagrange/EpubTtsPlaybackService.kt b/app/src/main/java/com/vangeaux/lagrange/EpubTtsPlaybackService.kt index 33df6a0e..06f71db5 100644 --- a/app/src/main/java/com/vangeaux/lagrange/EpubTtsPlaybackService.kt +++ b/app/src/main/java/com/vangeaux/lagrange/EpubTtsPlaybackService.kt @@ -241,6 +241,7 @@ class EpubTtsPlaybackService : Service() { publish { it.copy(isPlaying = false) } updatePlaybackWakeLock(isPlaying = false) updateNotification(isPlayingOverride = false) + flushServerProgress() } fun previous(ownerToken: String? = null) { @@ -612,10 +613,22 @@ class EpubTtsPlaybackService : Service() { } } + /** + * Sends the current read-aloud position past the progress throttle. Called on pause, so the + * last stretch of listening reaches the server even if the app is killed while paused. + */ + private fun flushServerProgress() { + val spec = activeSpec ?: return + val openedPublication = publication ?: return + val locator = binder.state.value.locator ?: return + queueServerProgress(spec, openedPublication, locator, force = true) + } + private fun queueServerProgress( spec: EpubTtsSessionSpec, openedPublication: Publication, - locator: Locator + locator: Locator, + force: Boolean = false ) { if (!isSpecCurrent(spec)) return val currentBookId = spec.bookId?.takeIf { it.isNotBlank() } ?: return @@ -631,7 +644,7 @@ class EpubTtsPlaybackService : Service() { chapterCount = openedPublication.readingOrder.size ) val now = System.currentTimeMillis() - if (!shouldQueueEpubTtsProgress( + if (!force && !shouldQueueEpubTtsProgress( lastQueuedAtMillis = lastQueuedAtMillis, lastQueuedPercent = lastQueuedPercent, lastQueuedChapter = lastQueuedChapter, diff --git a/app/src/main/java/com/vangeaux/lagrange/ReadiumEpubReaderActivity.kt b/app/src/main/java/com/vangeaux/lagrange/ReadiumEpubReaderActivity.kt index 6cbc49fa..e75062cc 100644 --- a/app/src/main/java/com/vangeaux/lagrange/ReadiumEpubReaderActivity.kt +++ b/app/src/main/java/com/vangeaux/lagrange/ReadiumEpubReaderActivity.kt @@ -657,6 +657,7 @@ class ReadiumEpubReaderActivity : FragmentActivity() { private var publication: Publication? = null private var navigator: EpubNavigatorFragment? = null private var fallbackContentLanguage: String? = null + private var lastRecordedVisualLocator: Locator? = null private var progressView: ProgressBar? = null private val readerContainerId: Int = R.id.readium_reader_container private lateinit var rootView: FrameLayout @@ -806,7 +807,6 @@ class ReadiumEpubReaderActivity : FragmentActivity() { super.onCreate(savedInstanceState) mediaOverlayPlayback = ViewModelProvider(this)[EpubMediaOverlayPlaybackViewModel::class.java] mediaOverlayPlaylist = mediaOverlayPlayback.playableClips.map { it.clip } - restoredLocator = savedInstanceState?.readReaderLocator() window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) AppPreferencesStore(this).read().let { preferences -> requestedOrientation = requestedOrientationForLock( @@ -822,9 +822,16 @@ class ReadiumEpubReaderActivity : FragmentActivity() { intent.putExtra(EXTRA_TTS_OWNER_TOKEN, it) } libraryId = intent.getStringExtra(EXTRA_LIBRARY_ID).orEmpty() - restoredTtsLocator = savedInstanceState?.readEpubTtsLocator() - lastTtsLocator = restoredTtsLocator ?: ttsPositionStore.read(readerKey) isPreview = intent.getBooleanExtra(EXTRA_IS_PREVIEW, false) + restoredLocator = epubRestoredLocator( + savedState = savedInstanceState?.readReaderLocator(), + stored = if (isPreview) null else locatorStore.read(readerKey) + ) + restoredTtsLocator = epubRestoredLocator( + savedState = savedInstanceState?.readEpubTtsLocator(), + stored = ttsPositionStore.read(readerKey) + ) + lastTtsLocator = restoredTtsLocator ?: ttsPositionStore.read(readerKey) bookId = epubAnnotationBookId(intent) readingSessionReporter = ViewModelProvider(this)[ReadingSessionReporterViewModel::class.java].reporter( context = this, @@ -1259,7 +1266,7 @@ class ReadiumEpubReaderActivity : FragmentActivity() { override fun onPageChanged(pageIndex: Int, totalPages: Int, locator: Locator) { currentPage = pageIndex.coerceAtLeast(0) currentPageCount = totalPages.coerceAtLeast(1) - updateLocation(locator) + recordVisualLocator(locator) } } val navigatorFactory = EpubNavigatorFactory(openedPublication) @@ -1320,7 +1327,7 @@ class ReadiumEpubReaderActivity : FragmentActivity() { lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { fragment.currentLocator.collect { locator -> - updateLocation(locator) + recordVisualLocator(locator) pendingAnnotationReanchor?.let { target -> pendingAnnotationReanchor = null if (locator != target) fragment.go(target) @@ -2652,6 +2659,26 @@ class ReadiumEpubReaderActivity : FragmentActivity() { ) } + /** + * Records a position reported by the visual navigator. Returning to the foreground replays the + * last visual position (the locator flow re-collects and the WebView re-paginates), while + * background read-aloud may have saved a newer one since. Real moves are always recorded; a + * re-report of the same place (possibly enriched with book positions) is recorded only while + * the stored position is still the one this reader wrote. + */ + private fun recordVisualLocator(locator: Locator) { + val last = lastRecordedVisualLocator + if (!shouldRecordVisualLocator(last, locator)) { + if (last == null || locator == last) return + val storedElsewhere = !isPreview && last.let { mine -> + locatorStore.readJson(readerKey) != mine.toJSON().toString() + } + if (storedElsewhere) return + } + lastRecordedVisualLocator = locator + updateLocation(locator) + } + private fun updateLocation(locator: Locator) { val openedPublication = publication ?: return val chapterIndex = openedPublication.readingOrder.indexOfFirst { link -> @@ -3177,6 +3204,30 @@ internal fun closeEpubReaderPlayback( closeTtsPlayback() } +/** + * Position to restore when Android rebuilds the reader from saved instance state. The stores are + * written on every position change, including by background read-aloud after the saved state was + * captured, so a stored position is never older than the saved state and wins. The saved state is + * only a fallback (for example in preview, which never writes the store). Without saved state this + * is a fresh launch, which resolves its position from the intent and the store itself. + */ +/** + * A visual position is recorded only when the reader actually moved: a different resource or a + * different progression within it. Readium re-reports the same place on resume, sometimes enriched + * with `position`/`totalProgression` once positions are computed; that is not a move. + */ +internal fun shouldRecordVisualLocator(lastRecorded: Locator?, current: Locator): Boolean { + val last = lastRecorded ?: return true + if (last.href.removeFragment().toString() != current.href.removeFragment().toString()) return true + val lastProgression = last.locations.progression + val currentProgression = current.locations.progression + if (lastProgression == null || currentProgression == null) return lastProgression != currentProgression + return kotlin.math.abs(lastProgression - currentProgression) > 1e-6 +} + +internal fun epubRestoredLocator(savedState: Locator?, stored: Locator?): Locator? = + savedState?.let { stored ?: it } + internal class ReadiumEpubLocatorStore(context: Context) { private val preferences = context.getSharedPreferences( "readium_epub_locations", @@ -3189,6 +3240,9 @@ internal class ReadiumEpubLocatorStore(context: Context) { return runCatching { Locator.fromJSON(JSONObject(json)) }.getOrNull() } + fun readJson(readerKey: String): String? = + readerKey.takeIf { it.isNotBlank() }?.let { preferences.getString(it, null) } + fun save(readerKey: String, locator: Locator) { if (readerKey.isBlank()) return preferences.edit().putString(readerKey, locator.toJSON().toString()).apply() From 5ad6f4fdc555eed279c8b664e9e199b83d255c9c Mon Sep 17 00:00:00 2001 From: chortya Date: Sat, 10 Oct 2026 17:55:52 +0200 Subject: [PATCH 11/13] feat: per-book language for read-aloud voices and hyphenation Readium picks one read-aloud language (book metadata, else the device locale) and lists only its voices, with no way to change it; the hyphenation fallback made the same assumption. Adds a Book language setting to the in-book options (This book): Auto shows whether it comes from the book or the device, and an override is remembered per book, sent to the EPUB navigator for hyphenation and to the read-aloud navigator for voices, and applied live during playback. Tests: JVM resolution and option rules; on-device voice lists with the system speech engine (Auto, live switch, start in override), page language and persistence across recreation, and the picker UI. Co-Authored-By: Claude Opus 5.5 --- .../EpubBookLanguageInstrumentedTest.kt | 253 ++++++++++++++++++ ...pubReaderOptionsOverlayInstrumentedTest.kt | 36 +++ .../com/vangeaux/lagrange/BookOrbitApp.kt | 61 +++++ .../vangeaux/lagrange/BookOrbitRepository.kt | 1 + .../com/vangeaux/lagrange/EpubBookLanguage.kt | 127 +++++++++ .../lagrange/EpubTtsPlaybackService.kt | 24 ++ .../lagrange/ReadiumEpubReaderActivity.kt | 33 ++- .../vangeaux/lagrange/EpubBookLanguageTest.kt | 92 +++++++ docs/architecture.md | 1 + docs/testing.md | 1 + 10 files changed, 628 insertions(+), 1 deletion(-) create mode 100644 app/src/androidTest/java/com/vangeaux/lagrange/EpubBookLanguageInstrumentedTest.kt create mode 100644 app/src/main/java/com/vangeaux/lagrange/EpubBookLanguage.kt create mode 100644 app/src/test/java/com/vangeaux/lagrange/EpubBookLanguageTest.kt diff --git a/app/src/androidTest/java/com/vangeaux/lagrange/EpubBookLanguageInstrumentedTest.kt b/app/src/androidTest/java/com/vangeaux/lagrange/EpubBookLanguageInstrumentedTest.kt new file mode 100644 index 00000000..d90141e0 --- /dev/null +++ b/app/src/androidTest/java/com/vangeaux/lagrange/EpubBookLanguageInstrumentedTest.kt @@ -0,0 +1,253 @@ +package com.vangeaux.lagrange + +import android.os.SystemClock +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import java.io.File +import java.util.zip.CRC32 +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** Per-book language override for read-aloud voices and hyphenation (book declares Dutch here). */ +@RunWith(AndroidJUnit4::class) +class EpubBookLanguageInstrumentedTest { + private val context = ApplicationProvider.getApplicationContext() + private val epub = File(context.cacheDir, "book-language-${System.nanoTime()}.epub").also { + writeEpub(it, language = "nl") + } + private val readerKey = "book-language-${System.nanoTime()}" + + @After + fun cleanUp() { + EpubBookLanguageStore(context).save(readerKey, null) + epub.delete() + } + + // ---- Read-aloud voices ---- + + private suspend fun withTtsService(block: suspend (EpubTtsPlaybackService.PlaybackBinder) -> T): T { + val connected = CompletableDeferred() + val connection = EpubTtsServiceConnection( + context = context, + onConnected = { connected.complete(it) }, + onDisconnected = {} + ) + connection.bind() + var service: EpubTtsPlaybackService.PlaybackBinder? = null + try { + service = withTimeout(5_000L) { connected.await() } + return block(service) + } finally { + withContext(Dispatchers.Main.immediate) { service?.stop(OWNER) } + connection.unbind() + } + } + + private suspend fun openSession(service: EpubTtsPlaybackService.PlaybackBinder, languageTag: String?) { + val spec = EpubTtsSessionSpec( + ownerToken = OWNER, + requestId = EpubTtsRequestSession.begin(), + accountEpoch = EpubTtsAccountSession.currentEpoch(), + readerKey = readerKey, + libraryId = "", + bookId = null, + fileId = null, + filePath = epub.absolutePath, + title = "Book language", + initialLocator = null, + settings = EpubTtsSettings(), + languageTag = languageTag, + playWhenReady = false + ) + withContext(Dispatchers.Main.immediate) { + EpubTtsPlaybackService.start( + context = context, + ownerToken = spec.ownerToken, + requestId = spec.requestId, + accountEpoch = spec.accountEpoch, + readerKey = spec.readerKey, + title = spec.title + ) + assertTrue(service.open(spec)) + } + } + + private suspend fun awaitVoiceLanguage( + service: EpubTtsPlaybackService.PlaybackBinder, + expectedBase: String + ): EpubTtsServiceState { + var last: EpubTtsServiceState? = null + repeat(60) { + last = service.state.value + if (last?.voiceLanguageTag?.substringBefore('-') == expectedBase) return last!! + delay(250L) + } + throw AssertionError("voice language never became $expectedBase; last ${last?.voiceLanguageTag}") + } + + private fun assertVoicesAreIn(state: EpubTtsServiceState, base: String) { + assertTrue( + "every offered voice is $base: ${state.voices.map { it.languageTag }.distinct()}", + state.voices.all { it.languageTag.substringBefore('-') == base } + ) + } + + @Test + fun readAloudOffersTheBookLanguageVoicesByDefaultAndTheOverrideLive() = runBlocking { + withTtsService { service -> + openSession(service, languageTag = null) + val auto = awaitVoiceLanguage(service, "nl") + assertVoicesAreIn(auto, "nl") + + withContext(Dispatchers.Main.immediate) { service.setLanguage(OWNER, "en") } + val english = awaitVoiceLanguage(service, "en") + assertVoicesAreIn(english, "en") + assumeTrue("Speech engine offers English voices", english.voices.isNotEmpty()) + + withContext(Dispatchers.Main.immediate) { service.setLanguage(OWNER, null) } + assertVoicesAreIn(awaitVoiceLanguage(service, "nl"), "nl") + } + } + + @Test + fun readAloudStartsInTheOverrideLanguage() = runBlocking { + withTtsService { service -> + openSession(service, languageTag = "en") + assertVoicesAreIn(awaitVoiceLanguage(service, "en"), "en") + } + } + + // ---- Reader: hyphenation language and persistence ---- + + private fun launch(): ActivityScenario = ActivityScenario.launch( + ReadiumEpubReaderActivity.createIntent( + context = context, + file = epub, + title = "Book language", + readerKey = readerKey, + launchMode = ReaderLaunchMode.NORMAL, + initialChapter = 0, + initialPage = 0, + initialPageCount = 1, + initialPercent = null + ) + ) + + private suspend fun pageLanguage(scenario: ActivityScenario): String? { + lateinit var activity: ReadiumEpubReaderActivity + scenario.onActivity { activity = it } + return withContext(Dispatchers.Main) { + activity.evaluateReaderJavascript( + "(function(){var h=document.documentElement;return h.getAttribute('lang')||h.getAttribute('xml:lang')||'';})()" + ) + }?.trim('"') + } + + private suspend fun awaitPageLanguage( + scenario: ActivityScenario, + expected: String + ) { + var last: String? = null + repeat(60) { + last = runCatching { pageLanguage(scenario) }.getOrNull() + if (last == expected) return + SystemClock.sleep(250) + } + throw AssertionError("page language never became $expected; last $last") + } + + @Test + fun overrideChangesThePageLanguageLiveAndIsRememberedPerBook() = runBlocking { + launch().use { scenario -> + awaitPageLanguage(scenario, "nl") + scenario.onActivity { it.setBookLanguageForTest("en") } + awaitPageLanguage(scenario, "en") + assertEquals("en", EpubBookLanguageStore(context).read(readerKey)) + + // Survives the reader being rebuilt. + scenario.recreate() + awaitPageLanguage(scenario, "en") + + scenario.onActivity { it.setBookLanguageForTest(null) } + awaitPageLanguage(scenario, "nl") + assertNull(EpubBookLanguageStore(context).read(readerKey)) + } + } + + private fun writeEpub(target: File, language: String) { + ZipOutputStream(target.outputStream().buffered()).use { zip -> + val mimetype = "application/epub+zip".toByteArray() + zip.putNextEntry( + ZipEntry("mimetype").apply { + method = ZipEntry.STORED + size = mimetype.size.toLong() + compressedSize = mimetype.size.toLong() + crc = CRC32().apply { update(mimetype) }.value + } + ) + zip.write(mimetype) + zip.closeEntry() + fun deflated(name: String, text: String) { + zip.putNextEntry(ZipEntry(name)) + zip.write(text.trimIndent().toByteArray()) + zip.closeEntry() + } + deflated( + "META-INF/container.xml", + """ + + + + + + + """ + ) + deflated( + "OEBPS/content.opf", + """ + + + + book-language-fixture + Book Language Fixture + $language + 2026-10-10T00:00:00Z + + + + + + + """ + ) + deflated( + "OEBPS/Text/chapter-1.xhtml", + """ + + Chapter 1 +

Chapter 1

${"The Director told them everything about the hatchery. ".repeat(80)}

+ + """ + ) + } + } + + private companion object { + const val OWNER = "book-language-test" + } +} diff --git a/app/src/androidTest/java/com/vangeaux/lagrange/EpubReaderOptionsOverlayInstrumentedTest.kt b/app/src/androidTest/java/com/vangeaux/lagrange/EpubReaderOptionsOverlayInstrumentedTest.kt index f2fef8a8..8affdb64 100644 --- a/app/src/androidTest/java/com/vangeaux/lagrange/EpubReaderOptionsOverlayInstrumentedTest.kt +++ b/app/src/androidTest/java/com/vangeaux/lagrange/EpubReaderOptionsOverlayInstrumentedTest.kt @@ -273,6 +273,42 @@ class EpubReaderOptionsOverlayInstrumentedTest { } } + @Test + fun bookLanguagePickerShowsTheResolvedLanguageAndReportsChoices() { + val override = mutableStateOf(null) + val changes = mutableListOf() + composeRule.setContent { + EpubReaderOptionsBottomSheet( + title = "Language test", + status = "Chapter 1/1 · Page 1/1", + preferences = LibraryReaderPreferences(), + onContinueReading = {}, + onCloseBook = {}, + onPreferencesChange = {}, + bookLanguage = epubBookLanguageChoice( + override = override.value, + publicationLanguages = listOf("nl"), + deviceLocale = java.util.Locale.US, + availableLanguages = listOf("nl", "en", "de", "fr") + ), + onBookLanguageChange = { + changes += it + override.value = it + } + ) + } + + composeRule.onNodeWithText("Book language: Auto · Dutch (from book)").assertIsDisplayed() + composeRule.onNodeWithTag("reader-options-book-language-dropdown").performClick() + composeRule.onNodeWithTag("reader-options-book-language-en").performClick() + composeRule.onNodeWithText("Book language: English").assertIsDisplayed() + composeRule.onNodeWithTag("reader-options-book-language-dropdown").performClick() + composeRule.onNodeWithTag("reader-options-book-language-auto").performClick() + + composeRule.runOnIdle { assertEquals(listOf("en", null), changes) } + composeRule.onNodeWithText("Book language: Auto · Dutch (from book)").assertIsDisplayed() + } + @Test fun readerConfigurationEditsPreserveAllFormatLayoutsAndGaps() { val profile = mutableStateOf( diff --git a/app/src/main/java/com/vangeaux/lagrange/BookOrbitApp.kt b/app/src/main/java/com/vangeaux/lagrange/BookOrbitApp.kt index f3872a23..101b15f8 100644 --- a/app/src/main/java/com/vangeaux/lagrange/BookOrbitApp.kt +++ b/app/src/main/java/com/vangeaux/lagrange/BookOrbitApp.kt @@ -1780,6 +1780,8 @@ internal fun EpubReaderOptionsBottomSheet( onShowTapZoneTutorialAfterChangesChange: (Boolean) -> Unit = {}, onCustomFontRequest: () -> Unit = {}, onCustomFontRemove: () -> Unit = {}, + bookLanguage: EpubBookLanguageChoice? = null, + onBookLanguageChange: (String?) -> Unit = {}, modifier: Modifier = Modifier ) { val parentTypography = MaterialTheme.typography @@ -1920,6 +1922,10 @@ internal fun EpubReaderOptionsBottomSheet( Text("Close book") } } + if (bookLanguage != null) { + HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.45f)) + EpubBookLanguageSettings(bookLanguage, onBookLanguageChange) + } HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.45f)) Text("Reading configuration", style = MaterialTheme.typography.titleMedium) ReaderConfigurationControls( @@ -1938,6 +1944,61 @@ internal fun EpubReaderOptionsBottomSheet( } } +@Composable +private fun EpubBookLanguageSettings( + choice: EpubBookLanguageChoice, + onChange: (String?) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + val isOverride = choice.resolved.source == EpubContentLanguageSource.OVERRIDE + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text("This book", style = MaterialTheme.typography.titleMedium) + Text( + "Language for read-aloud voices and hyphenation", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Box { + OutlinedButton( + onClick = { expanded = true }, + modifier = Modifier + .fillMaxWidth() + .testTag("reader-options-book-language-dropdown") + ) { + Text( + "Book language: ${choice.summary}", + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem( + text = { Text(if (isOverride) "Auto (book, else device language)" else "✓ ${choice.summary}") }, + onClick = { + expanded = false + onChange(null) + }, + modifier = Modifier.testTag("reader-options-book-language-auto") + ) + HorizontalDivider() + fun selected(tag: String) = isOverride && choice.resolved.tag == tag + (choice.suggested + choice.all.filterNot { option -> choice.suggested.any { it.tag == option.tag } }) + .forEachIndexed { index, option -> + if (index == choice.suggested.size) HorizontalDivider() + DropdownMenuItem( + text = { Text(if (selected(option.tag)) "✓ ${option.displayName}" else option.displayName) }, + onClick = { + expanded = false + onChange(option.tag) + }, + modifier = Modifier.testTag("reader-options-book-language-${option.tag}") + ) + } + } + } + } +} + private class EpubReaderBridge( private val onCenterTap: () -> Unit, private val onPageChanged: (Int, Int) -> Unit, diff --git a/app/src/main/java/com/vangeaux/lagrange/BookOrbitRepository.kt b/app/src/main/java/com/vangeaux/lagrange/BookOrbitRepository.kt index 664999ca..09d6548a 100644 --- a/app/src/main/java/com/vangeaux/lagrange/BookOrbitRepository.kt +++ b/app/src/main/java/com/vangeaux/lagrange/BookOrbitRepository.kt @@ -712,6 +712,7 @@ class BookOrbitRepository(private val context: Context) : BookOrbitDataSource, P epubReaderPositionStore.clear() ReadiumEpubLocatorStore(context).clear() ReadiumEpubTtsPositionStore(context).clear() + EpubBookLanguageStore(context).clear() clearCookies() } diff --git a/app/src/main/java/com/vangeaux/lagrange/EpubBookLanguage.kt b/app/src/main/java/com/vangeaux/lagrange/EpubBookLanguage.kt new file mode 100644 index 00000000..01d4a95d --- /dev/null +++ b/app/src/main/java/com/vangeaux/lagrange/EpubBookLanguage.kt @@ -0,0 +1,127 @@ +package com.vangeaux.lagrange + +import android.content.Context +import java.util.Locale + +/** Where the language used for hyphenation and read-aloud comes from. */ +internal enum class EpubContentLanguageSource { OVERRIDE, BOOK, DEVICE, NONE } + +internal data class EpubContentLanguage(val tag: String?, val source: EpubContentLanguageSource) + +/** + * Resolves the book's content language. A per-book override wins; otherwise the book's declared + * language, otherwise the device language when it is one that hyphenates (see + * [epubFallbackContentLanguage]). This mirrors what Readium does on its own when nothing is set. + */ +internal fun resolveEpubContentLanguage( + override: String?, + publicationLanguages: List, + deviceLocale: Locale +): EpubContentLanguage { + override?.trim()?.takeIf { it.isNotEmpty() }?.let { + return EpubContentLanguage(it, EpubContentLanguageSource.OVERRIDE) + } + publicationLanguages.firstOrNull { it.isNotBlank() }?.let { + return EpubContentLanguage(it.trim(), EpubContentLanguageSource.BOOK) + } + epubFallbackContentLanguage(publicationLanguages, deviceLocale)?.let { + return EpubContentLanguage(it, EpubContentLanguageSource.DEVICE) + } + return EpubContentLanguage(null, EpubContentLanguageSource.NONE) +} + +/** + * Language to hand to the Readium EPUB navigator. A declared book language is left to Readium so + * per-element `xml:lang` keeps working; an override or the device fallback must be set explicitly. + */ +internal fun epubNavigatorLanguage(language: EpubContentLanguage): String? = when (language.source) { + EpubContentLanguageSource.OVERRIDE, EpubContentLanguageSource.DEVICE -> language.tag + EpubContentLanguageSource.BOOK, EpubContentLanguageSource.NONE -> null +} + +/** Language to hand to the Readium TTS navigator: only an override; Auto keeps Readium's choice. */ +internal fun epubTtsLanguage(language: EpubContentLanguage): String? = + language.tag.takeIf { language.source == EpubContentLanguageSource.OVERRIDE } + +internal fun epubLanguageDisplayName(tag: String, displayLocale: Locale): String { + val locale = Locale.forLanguageTag(tag) + val name = locale.getDisplayName(displayLocale).takeIf { it.isNotBlank() && it != tag } ?: tag + return name.replaceFirstChar { if (it.isLowerCase()) it.titlecase(displayLocale) else it.toString() } +} + +internal fun epubContentLanguageSummary(language: EpubContentLanguage, displayLocale: Locale): String { + val name = language.tag?.let { epubLanguageDisplayName(it, displayLocale) } + return when (language.source) { + EpubContentLanguageSource.OVERRIDE -> name.orEmpty() + EpubContentLanguageSource.BOOK -> "Auto · $name (from book)" + EpubContentLanguageSource.DEVICE -> "Auto · $name (device; book declares none)" + EpubContentLanguageSource.NONE -> "Auto · unknown (book declares none)" + } +} + +internal data class EpubLanguageOption(val tag: String, val displayName: String) + +internal data class EpubBookLanguageChoice( + val resolved: EpubContentLanguage, + val summary: String, + val suggested: List, + val all: List +) + +/** + * Suggested languages (the book's, the device's and English) followed by every base language the + * platform knows, by display name. Base tags only: voices are matched without region and the + * browser picks the regional hyphenation patterns itself. + */ +internal fun epubBookLanguageChoice( + override: String?, + publicationLanguages: List, + deviceLocale: Locale, + availableLanguages: List = Locale.getAvailableLocales().map { it.language } +): EpubBookLanguageChoice { + fun option(tag: String) = EpubLanguageOption(tag, epubLanguageDisplayName(tag, deviceLocale)) + val suggestedTags = ( + publicationLanguages.map { Locale.forLanguageTag(it.trim()).language } + + deviceLocale.language + + "en" + ) + .filter { it.isNotBlank() && it != "und" } + .distinct() + val all = availableLanguages + .filter { it.length in 2..3 && it != "und" } + .distinct() + .map(::option) + .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { it.displayName }) + return EpubBookLanguageChoice( + resolved = resolveEpubContentLanguage(override, publicationLanguages, deviceLocale), + summary = epubContentLanguageSummary( + resolveEpubContentLanguage(override, publicationLanguages, deviceLocale), + deviceLocale + ), + suggested = suggestedTags.map(::option), + all = all + ) +} + +/** Per-book language override, keyed like the reading position. Absent means Auto. */ +internal class EpubBookLanguageStore(context: Context) { + private val preferences = context.getSharedPreferences( + "readium_epub_book_languages", + Context.MODE_PRIVATE + ) + + fun read(readerKey: String): String? = + readerKey.takeIf { it.isNotBlank() }?.let { preferences.getString(it, null) } + + fun save(readerKey: String, languageTag: String?) { + if (readerKey.isBlank()) return + val tag = languageTag?.trim()?.takeIf { it.isNotEmpty() } + preferences.edit().apply { + if (tag == null) remove(readerKey) else putString(readerKey, tag) + }.apply() + } + + fun clear() { + preferences.edit().clear().apply() + } +} diff --git a/app/src/main/java/com/vangeaux/lagrange/EpubTtsPlaybackService.kt b/app/src/main/java/com/vangeaux/lagrange/EpubTtsPlaybackService.kt index 06f71db5..90745afe 100644 --- a/app/src/main/java/com/vangeaux/lagrange/EpubTtsPlaybackService.kt +++ b/app/src/main/java/com/vangeaux/lagrange/EpubTtsPlaybackService.kt @@ -91,6 +91,8 @@ internal data class EpubTtsSessionSpec( val initialLocator: Locator?, val selectionText: String? = null, val settings: EpubTtsSettings, + /** Per-book language override for voices; null keeps Readium's book/device language. */ + val languageTag: String? = null, val playWhenReady: Boolean ) @@ -266,6 +268,26 @@ class EpubTtsPlaybackService : Service() { updateNotification() } + /** Switches the voice language of the running session (per-book override; null = Auto). */ + fun setLanguage(ownerToken: String?, languageTag: String?) { + if (!isOwner(ownerToken)) return + val spec = activeSpec ?: return + if (spec.languageTag == languageTag) return + activeSpec = spec.copy(languageTag = languageTag) + val settings = mutableState.value.settings + navigator?.submitPreferences( + AndroidTtsPreferences( + language = languageTag?.let(::Language), + pitch = settings.pitch.toDouble(), + speed = settings.speed.toDouble(), + voices = settings.voiceIds.mapKeys { (language, _) -> Language(language) } + .mapValues { (_, voiceId) -> AndroidTtsEngine.Voice.Id(voiceId) } + ) + ) + publishNavigatorCapabilities() + updateNotification() + } + fun setSettings(value: EpubTtsSettings, ownerToken: String? = null) { if (!isOwner(ownerToken)) return val normalized = value.normalized() @@ -274,6 +296,7 @@ class EpubTtsPlaybackService : Service() { activeSpec = activeSpec?.copy(settings = normalized) navigator?.submitPreferences( AndroidTtsPreferences( + language = activeSpec?.languageTag?.let(::Language), pitch = normalized.pitch.toDouble(), speed = normalized.speed.toDouble(), voices = normalized.voiceIds.mapKeys { (language, _) -> Language(language) } @@ -485,6 +508,7 @@ class EpubTtsPlaybackService : Service() { }, initialLocator = spec.initialLocator, initialPreferences = AndroidTtsPreferences( + language = spec.languageTag?.let(::Language), pitch = spec.settings.normalized().pitch.toDouble(), speed = spec.settings.normalized().speed.toDouble(), voices = spec.settings.normalized().voiceIds diff --git a/app/src/main/java/com/vangeaux/lagrange/ReadiumEpubReaderActivity.kt b/app/src/main/java/com/vangeaux/lagrange/ReadiumEpubReaderActivity.kt index e75062cc..d4733cf0 100644 --- a/app/src/main/java/com/vangeaux/lagrange/ReadiumEpubReaderActivity.kt +++ b/app/src/main/java/com/vangeaux/lagrange/ReadiumEpubReaderActivity.kt @@ -39,6 +39,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -657,6 +658,9 @@ class ReadiumEpubReaderActivity : FragmentActivity() { private var publication: Publication? = null private var navigator: EpubNavigatorFragment? = null private var fallbackContentLanguage: String? = null + private var publicationLanguages: List = emptyList() + private var bookLanguageOverride by mutableStateOf(null) + private val bookLanguageStore by lazy { EpubBookLanguageStore(this) } private var lastRecordedVisualLocator: Locator? = null private var progressView: ProgressBar? = null private val readerContainerId: Int = R.id.readium_reader_container @@ -823,6 +827,7 @@ class ReadiumEpubReaderActivity : FragmentActivity() { } libraryId = intent.getStringExtra(EXTRA_LIBRARY_ID).orEmpty() isPreview = intent.getBooleanExtra(EXTRA_IS_PREVIEW, false) + bookLanguageOverride = bookLanguageStore.read(readerKey) restoredLocator = epubRestoredLocator( savedState = savedInstanceState?.readReaderLocator(), stored = if (isPreview) null else locatorStore.read(readerKey) @@ -1073,6 +1078,8 @@ class ReadiumEpubReaderActivity : FragmentActivity() { onShowTapZoneTutorialAfterChangesChange = ::updateShowTapZoneTutorialAfterChanges, onCustomFontRequest = ::chooseCustomFont, onCustomFontRemove = ::removeCustomFont, + bookLanguage = remember(bookLanguageOverride, publication) { bookLanguageChoice() }, + onBookLanguageChange = ::setBookLanguage, modifier = Modifier.align(Alignment.BottomCenter).fillMaxWidth() ) } @@ -1247,7 +1254,8 @@ class ReadiumEpubReaderActivity : FragmentActivity() { return } publication = openedPublication - fallbackContentLanguage = epubFallbackContentLanguage(openedPublication.metadata.languages, Locale.getDefault()) + publicationLanguages = openedPublication.metadata.languages + fallbackContentLanguage = epubNavigatorLanguage(currentContentLanguage()) ttsAvailable = !isPreview && AndroidTtsNavigatorFactory(application, openedPublication) != null readingSessionReporter.start( intent.getFloatExtra(EXTRA_INITIAL_PERCENT, Float.NaN).takeUnless(Float::isNaN) @@ -1493,6 +1501,7 @@ class ReadiumEpubReaderActivity : FragmentActivity() { initialLocator = initialLocator, selectionText = selectionText, settings = ttsSettings, + languageTag = epubTtsLanguage(currentContentLanguage()), playWhenReady = playWhenReady ) if (ttsStartRequestId != requestId || @@ -2811,6 +2820,28 @@ class ReadiumEpubReaderActivity : FragmentActivity() { ?: themeStore.read() } + private fun currentContentLanguage(): EpubContentLanguage = + resolveEpubContentLanguage(bookLanguageOverride, publicationLanguages, Locale.getDefault()) + + private fun bookLanguageChoice(): EpubBookLanguageChoice? = + publication?.let { + epubBookLanguageChoice(bookLanguageOverride, publicationLanguages, Locale.getDefault()) + } + + /** Sets or clears (null = Auto) this book's language for hyphenation and read-aloud. */ + private fun setBookLanguage(languageTag: String?) { + val normalized = languageTag?.trim()?.takeIf { it.isNotEmpty() } + if (normalized == bookLanguageOverride) return + bookLanguageOverride = normalized + if (!isPreview) bookLanguageStore.save(readerKey, normalized) + val language = currentContentLanguage() + fallbackContentLanguage = epubNavigatorLanguage(language) + ttsServiceBinder?.setLanguage(ttsOwnerToken, epubTtsLanguage(language)) + applyReaderPreferences(readerPreferences) + } + + internal fun setBookLanguageForTest(languageTag: String?) = setBookLanguage(languageTag) + private fun spreadPageMarginCssPx(): Double = epubSpreadPageMarginCssPx(resources.configuration.screenWidthDp, padding.left, padding.right) diff --git a/app/src/test/java/com/vangeaux/lagrange/EpubBookLanguageTest.kt b/app/src/test/java/com/vangeaux/lagrange/EpubBookLanguageTest.kt new file mode 100644 index 00000000..e040dc76 --- /dev/null +++ b/app/src/test/java/com/vangeaux/lagrange/EpubBookLanguageTest.kt @@ -0,0 +1,92 @@ +package com.vangeaux.lagrange + +import java.util.Locale +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class EpubBookLanguageTest { + private val dutch = Locale.forLanguageTag("nl-NL") + + @Test + fun `override wins over the book and the device`() { + val language = resolveEpubContentLanguage("en", listOf("nl"), dutch) + assertEquals(EpubContentLanguage("en", EpubContentLanguageSource.OVERRIDE), language) + } + + @Test + fun `auto uses the declared book language, then the device language`() { + assertEquals( + EpubContentLanguage("en", EpubContentLanguageSource.BOOK), + resolveEpubContentLanguage(null, listOf("en"), dutch) + ) + assertEquals( + EpubContentLanguage("nl-NL", EpubContentLanguageSource.DEVICE), + resolveEpubContentLanguage(" ", emptyList(), dutch) + ) + assertEquals( + EpubContentLanguage(null, EpubContentLanguageSource.NONE), + resolveEpubContentLanguage(null, emptyList(), Locale.forLanguageTag("ja-JP")) + ) + } + + @Test + fun `navigator gets overrides and the device fallback but leaves declared languages to Readium`() { + assertEquals("en", epubNavigatorLanguage(EpubContentLanguage("en", EpubContentLanguageSource.OVERRIDE))) + assertEquals("nl-NL", epubNavigatorLanguage(EpubContentLanguage("nl-NL", EpubContentLanguageSource.DEVICE))) + assertNull(epubNavigatorLanguage(EpubContentLanguage("en", EpubContentLanguageSource.BOOK))) + assertNull(epubNavigatorLanguage(EpubContentLanguage(null, EpubContentLanguageSource.NONE))) + } + + @Test + fun `read-aloud only receives an explicit override so Auto keeps Readium's choice`() { + assertEquals("en", epubTtsLanguage(EpubContentLanguage("en", EpubContentLanguageSource.OVERRIDE))) + assertNull(epubTtsLanguage(EpubContentLanguage("nl-NL", EpubContentLanguageSource.DEVICE))) + assertNull(epubTtsLanguage(EpubContentLanguage("en", EpubContentLanguageSource.BOOK))) + } + + @Test + fun `summary says where the language comes from`() { + val english = Locale.ENGLISH + assertEquals( + "Auto · Dutch (from book)", + epubContentLanguageSummary(EpubContentLanguage("nl", EpubContentLanguageSource.BOOK), english) + ) + assertEquals( + "Auto · Dutch (Netherlands) (device; book declares none)", + epubContentLanguageSummary(EpubContentLanguage("nl-NL", EpubContentLanguageSource.DEVICE), english) + ) + assertEquals( + "English", + epubContentLanguageSummary(EpubContentLanguage("en", EpubContentLanguageSource.OVERRIDE), english) + ) + } + + @Test + fun `choices suggest the book, device and English languages before all languages`() { + val choice = epubBookLanguageChoice( + override = null, + publicationLanguages = listOf("nl"), + deviceLocale = Locale.forLanguageTag("de-DE"), + availableLanguages = listOf("fr", "en", "nl", "de", "und", "", "fr", "en") + ) + assertEquals(listOf("nl", "de", "en"), choice.suggested.map { it.tag }) + assertEquals(listOf("Englisch", "Französisch", "Niederländisch", "Deutsch").sorted(), choice.all.map { it.displayName }.sorted()) + assertEquals(choice.all.map { it.displayName }.sortedWith(String.CASE_INSENSITIVE_ORDER), choice.all.map { it.displayName }) + assertEquals(EpubContentLanguageSource.BOOK, choice.resolved.source) + assertTrue(choice.summary.startsWith("Auto · ")) + } + + @Test + fun `suggestions do not repeat a book language that matches the device or English`() { + val choice = epubBookLanguageChoice( + override = "en", + publicationLanguages = listOf("en-GB"), + deviceLocale = Locale.US, + availableLanguages = listOf("en") + ) + assertEquals(listOf("en"), choice.suggested.map { it.tag }) + assertEquals(EpubContentLanguageSource.OVERRIDE, choice.resolved.source) + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 084e4ad0..8954d446 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -64,6 +64,7 @@ Lagrange is a native Android client for BookOrbit focused on reading, listening, - Column gap stays zero so Readium's viewport-width pagination stays exact. The gap between pages comes from each column's own padding, `--RS__pageGutter × pageMargins`. - Spread margins: the page gutter is a 1px unit and `pageMargins` carries the page margin in CSS px (`epubSpreadPageMarginCssPx`). It equals what the left/right margin setting gives a single page on the same window, using the mean of left and right, with a 12px minimum. The reader container drops its horizontal padding in a spread. So every page has the same margin on both sides, the inner gap is twice the margin, and margin changes apply live through `submitPreferences` without recreating the navigator. Top/bottom margins, line spacing, word spacing and font size are document-wide and apply to both pages equally. - EPUB typesetting (all reader modes): per-library `textAlignment` (Left → Readium `TextAlign.START`, so right-to-left text keeps its own edge; Justified → `JUSTIFY`) and `hyphenation` (Auto/On/Off; Auto hyphenates justified text only) map to Readium `textAlign`/`hyphens` and apply live. Browser hyphenation needs a content language: books that declare none get the device language through Readium's `language` preference (`epubFallbackContentLanguage`). It is skipped for right-to-left and CJK device languages, because Readium also derives RTL and vertical layout from the language and those scripts have no hyphenation patterns. +- Per-book content language (`EpubBookLanguage.kt`, store `readium_epub_book_languages` keyed by reader key, cleared on logout): Auto resolves to the book's declared language, else the device fallback above. An override is passed to the EPUB navigator (`language`, for hyphenation) and to the read-aloud navigator (`AndroidTtsPreferences.language`, which selects the voice list); both apply live. In Auto, read-aloud keeps Readium's own choice (book language, else device locale). - Spread changes without a size change (posture, preference) recreate the EPUB activity at the saved locator. - Paginated comics replace the Readium image navigator with `ComicSpreadReader`, a Compose pager over `comicSpreads()` pairing. It reuses the continuous-comic decode loader and LRU cache, decoding pairs at half width. It persists the spread's first page as the resume locator and reports the last visible page as progress. - **OLED EPUB theme.** diff --git a/docs/testing.md b/docs/testing.md index 6fa1bf31..a0e1f4bb 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -79,6 +79,7 @@ On a Galaxy Z Fold8 (physical device or Samsung Remote Test Lab SM-F971): - Margins: each page has the same margin on its outer and inner side, and the gap between the pages is twice that. Raising or lowering the left/right margin widens or narrows all four page edges at once, without reopening the book. Uneven left/right settings use their mean. - Line spacing, word spacing, font size and font family change both pages identically and reflow text across both columns. - Text alignment and hyphenation: Left with Auto hyphenation (default) is ragged and unhyphenated. Justified hyphenates long words and gives flush lines on both pages. Hyphenation Off removes hyphens; Left with On hyphenates ragged text. Changes apply without reopening the book. A book whose metadata declares no language is hyphenated in the device language. + - Book language (in-book options → This book): Auto shows the source (book or device). Choosing another language switches read-aloud voices immediately, even while playing, and changes hyphenation; it is remembered for that book only. Auto restores the book's language. Automated: `EpubBookLanguageInstrumentedTest` (voice list per language with the system speech engine, page language, persistence), `EpubBookLanguageTest` (JVM). - Automated: `FoldSpreadOledActivityInstrumentedTest` measures the four page margins, the line pitch on each page, hyphenated words and flush lines on each page, the no-language fallback, and the rotate-to-portrait fallback. `EpubTypesettingPreferencesTest` (JVM) covers storage and the fallback rules. - Confirm the following still work: resume position after fold/unfold, TTS sentence highlighting and auto page-turn across columns, highlights spanning the column boundary, and tap zones. - **Comics:** From 7a61af58112473cf58668d0e8ee776ee92e8da8e Mon Sep 17 00:00:00 2001 From: chortya Date: Sat, 10 Oct 2026 20:08:25 +0200 Subject: [PATCH 12/13] fix: don't jump to an old read-aloud position when the reader is rebuilt After recreation (rotation, fold, process death) the reader followed the saved read-aloud position even with no read-aloud session running. That position is where listening last stopped, often far behind where the book was read since, and the jump was then saved and synced. Only a live session for this reader is followed now; the saved position remains the Keep listening start point. Test: instrumented regression (red before the fix). Co-Authored-By: Claude Opus 5.5 --- .../EpubPositionRestoreInstrumentedTest.kt | 31 +++++++++++++++++++ .../lagrange/ReadiumEpubReaderActivity.kt | 9 ++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/app/src/androidTest/java/com/vangeaux/lagrange/EpubPositionRestoreInstrumentedTest.kt b/app/src/androidTest/java/com/vangeaux/lagrange/EpubPositionRestoreInstrumentedTest.kt index df08b036..a6d10e66 100644 --- a/app/src/androidTest/java/com/vangeaux/lagrange/EpubPositionRestoreInstrumentedTest.kt +++ b/app/src/androidTest/java/com/vangeaux/lagrange/EpubPositionRestoreInstrumentedTest.kt @@ -131,6 +131,37 @@ class EpubPositionRestoreInstrumentedTest { } } + @Test + fun anOldReadAloudPositionDoesNotPullTheReaderBackWhenItIsRebuilt() { + val chapter1 = "OEBPS/Text/chapter-1.xhtml" + val chapter3 = "OEBPS/Text/chapter-3.xhtml" + fun locator(href: String, progression: Double, total: Double) = requireNotNull( + Locator.fromJSON( + JSONObject( + """{"href":"$href","type":"application/xhtml+xml", + "locations":{"progression":$progression,"totalProgression":$total}}""" + ) + ) + ) + // Read-aloud was used early in the book and paused; the reader then read on to chapter 3. + ReadiumEpubTtsPositionStore(context).save(readerKey, locator(chapter1, 0.4, 0.13)) + ReadiumEpubLocatorStore(context).save(readerKey, locator(chapter3, 0.5, 0.83)) + + launch().use { scenario -> + awaitStoredHref("reader did not open on chapter 3") { it.endsWith("chapter-3.xhtml") } + // Android rebuilds the reader (rotation, fold, or reopening after process death). + scenario.moveToState(Lifecycle.State.CREATED) + scenario.recreate() + scenario.moveToState(Lifecycle.State.RESUMED) + SystemClock.sleep(3000) + val stored = requireNotNull(ReadiumEpubLocatorStore(context).read(readerKey)) + assertTrue( + "reader jumped to the old read-aloud position: ${stored.href}", + stored.href.toString().endsWith("chapter-3.xhtml") + ) + } + } + @Test fun configurationRecreationStillRestoresTheCurrentPosition() { launch().use { scenario -> diff --git a/app/src/main/java/com/vangeaux/lagrange/ReadiumEpubReaderActivity.kt b/app/src/main/java/com/vangeaux/lagrange/ReadiumEpubReaderActivity.kt index d4733cf0..bf37151e 100644 --- a/app/src/main/java/com/vangeaux/lagrange/ReadiumEpubReaderActivity.kt +++ b/app/src/main/java/com/vangeaux/lagrange/ReadiumEpubReaderActivity.kt @@ -1536,11 +1536,16 @@ class ReadiumEpubReaderActivity : FragmentActivity() { startJob.start() } + /** + * Follows a read-aloud session that is still running for this reader after recreation. The + * saved read-aloud position ([restoredTtsLocator]) is not followed: without a live session it + * is only where listening last stopped, and jumping there would pull the reader back from where + * it has read since. It stays available as the "Keep listening" start point. + */ private fun restoreTtsPositionAfterRecreation(openedPublication: Publication) { val locator = ttsServiceBinder?.state?.value - ?.takeIf { it.ownerToken == ttsOwnerToken && it.readerKey == readerKey } + ?.takeIf { it.ownerToken == ttsOwnerToken && it.readerKey == readerKey && it.hasSession } ?.locator - ?: restoredTtsLocator restoredTtsLocator = null locator?.takeIf { candidate -> openedPublication.readingOrder.any { link -> From 7308ac079b61d06b188637c3b36009ab15d8432c Mon Sep 17 00:00:00 2001 From: chortya Date: Sun, 11 Oct 2026 10:23:50 +0200 Subject: [PATCH 13/13] chore: restore upstream line endings in touched files Co-Authored-By: Claude Opus 5.5 --- app/build.gradle.kts | 4 +- .../vangeaux/lagrange/BookOrbitRepository.kt | 2 +- docs/testing.md | 66 +++++++++---------- 3 files changed, 36 insertions(+), 36 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 17a95f73..a1ae59b9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -143,7 +143,7 @@ dependencies { implementation("androidx.room:room-ktx:2.6.1") implementation("androidx.webkit:webkit:1.11.0") implementation("androidx.work:work-runtime-ktx:2.9.1") - implementation("androidx.window:window:1.5.1") + implementation("androidx.window:window:1.5.1") implementation("androidx.media3:media3-exoplayer:1.4.1") implementation("androidx.media3:media3-ui:1.4.1") implementation("androidx.media3:media3-session:1.4.1") @@ -168,6 +168,6 @@ dependencies { androidTestImplementation("androidx.compose.ui:ui-test-junit4") androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") androidTestImplementation("androidx.test.ext:junit:1.2.1") - androidTestImplementation("androidx.window:window-testing:1.5.1") + androidTestImplementation("androidx.window:window-testing:1.5.1") androidTestImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") } diff --git a/app/src/main/java/com/vangeaux/lagrange/BookOrbitRepository.kt b/app/src/main/java/com/vangeaux/lagrange/BookOrbitRepository.kt index 09d6548a..f9b6558c 100644 --- a/app/src/main/java/com/vangeaux/lagrange/BookOrbitRepository.kt +++ b/app/src/main/java/com/vangeaux/lagrange/BookOrbitRepository.kt @@ -712,7 +712,7 @@ class BookOrbitRepository(private val context: Context) : BookOrbitDataSource, P epubReaderPositionStore.clear() ReadiumEpubLocatorStore(context).clear() ReadiumEpubTtsPositionStore(context).clear() - EpubBookLanguageStore(context).clear() + EpubBookLanguageStore(context).clear() clearCookies() } diff --git a/docs/testing.md b/docs/testing.md index a0e1f4bb..6fdfd784 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -67,39 +67,39 @@ When the affected scope requires it, verify normal versus Preview launch isolati For EPUB, verify chapter/page navigation, continuous active-resource seeking, text size, margins, reading direction, custom/accessibility fonts, image handling, and optional read-along narration. When Android TTS is available, long-press a word and a sentence, choose `Listen from here`, and confirm playback starts at the selected word/sentence rather than the chapter beginning; on EPUB 3 media-overlay books, confirm `Play narration` remains a separate action. Open TTS settings during playback, confirm the Voice selector lists only voices compatible with the EPUB language, selecting a voice changes subsequent speech, the choice persists after reopening the reader, and `Default Android voice` clears the override. Force a selected voice failure and confirm playback stops, the failed override is cleared, the player returns with `Default Android voice` selected and paused, the reading locator is preserved, and no automatic speech starts until Play is pressed. Confirm network-dependent voices are identified and the existing missing-voice-data action opens Android voice installation. Verify read-along speed changes apply immediately through the five adjustment controls and that `Apply` is disabled. Verify TTS speed and pitch apply immediately, custom punctuation pauses are hidden by default and appear only after enabling the switch, and punctuation/lock-screen changes require `Apply`. Repeat once in Preview and confirm the listen actions are unavailable. For PDF and comics, verify page navigation, progress, links where applicable, layout modes, page gaps, and reader chrome. For audiobook playback, verify compact/full players, chapters, seeking, speed, sleep timer, session history, notification controls, backgrounding, screen lock, audio focus, and process/task recreation. -### Foldable spreads and OLED EPUB - -Automated coverage: `ReaderSpreadPolicyTest`, `EpubSpreadPreferencesTest`, `ComicSpreadPairingTest`, `ComicSpreadLayoutTest`, `EpubOledThemeTest` (JVM); `ComicSpreadReaderInstrumentedTest`, `EpubFoldOledWebViewInstrumentedTest`, `FoldSpreadOledActivityInstrumentedTest` (connected). Spread activity assertions need a large horizontal window, such as the Android Studio Pixel Fold AVD unfolded (2208×1840, 420 dpi). On phone-sized devices they are skipped through a JUnit assumption, while single-page and OLED assertions always run. Run connected tests on a non-English host with `JAVA_TOOL_OPTIONS="-Duser.language=en -Duser.country=US"`: several existing parser tests assert `.`-formatted percentages. - -On a Galaxy Z Fold8 (physical device or Samsung Remote Test Lab SM-F971): - -- **EPUB:** - - Unfolded in the natural landscape posture, paginated EPUB shows two columns with a gutter on the hinge. A page turn advances both columns. - - Rotate to portrait, fold to the cover screen, open split-screen, and switch to continuous layout: each shows one column. - - Margins: each page has the same margin on its outer and inner side, and the gap between the pages is twice that. Raising or lowering the left/right margin widens or narrows all four page edges at once, without reopening the book. Uneven left/right settings use their mean. - - Line spacing, word spacing, font size and font family change both pages identically and reflow text across both columns. - - Text alignment and hyphenation: Left with Auto hyphenation (default) is ragged and unhyphenated. Justified hyphenates long words and gives flush lines on both pages. Hyphenation Off removes hyphens; Left with On hyphenates ragged text. Changes apply without reopening the book. A book whose metadata declares no language is hyphenated in the device language. - - Book language (in-book options → This book): Auto shows the source (book or device). Choosing another language switches read-aloud voices immediately, even while playing, and changes hyphenation; it is remembered for that book only. Auto restores the book's language. Automated: `EpubBookLanguageInstrumentedTest` (voice list per language with the system speech engine, page language, persistence), `EpubBookLanguageTest` (JVM). - - Automated: `FoldSpreadOledActivityInstrumentedTest` measures the four page margins, the line pitch on each page, hyphenated words and flush lines on each page, the no-language fallback, and the rotate-to-portrait fallback. `EpubTypesettingPreferencesTest` (JVM) covers storage and the fallback rules. - - Confirm the following still work: resume position after fold/unfold, TTS sentence highlighting and auto page-turn across columns, highlights spanning the column boundary, and tap zones. -- **Comics:** - - Unfolded, paginated CBZ/CBR shows the cover alone, then facing pairs. - - A double-page image is shown alone, and the pairs after it re-align. - - Right-to-left libraries put the earlier page on the right and advance leftwards. - - `Shift page pairs` moves the pairing by one page. - - Pinch-zoom works on the spread; long-pressing a half exports that page. - - Tap zones, volume buttons and the page list all move by spread. - - The footer reads `Pages N–M of T`. - - Folding reopens on the first page of the spread; unfolding reopens on the spread containing the saved page. - - Turning off `Two-page spread when unfolded` restores the single-page navigator immediately. -- **PDF:** stays single-page on every screen. -- **OLED (EPUB only):** - - Choosing `OLED black` shows pure white text and links on pure black. - - The following are all black, with white primary content and hairline edges: the status-bar strip, reader chrome, options sheet, chapter list, TTS/read-along panels, and system dialogs. - - Text selection is white with black text, and illustrations keep their original colours. - - Switching into or out of OLED reopens the reader at the same position. - - Comics remain full colour. - +### Foldable spreads and OLED EPUB + +Automated coverage: `ReaderSpreadPolicyTest`, `EpubSpreadPreferencesTest`, `ComicSpreadPairingTest`, `ComicSpreadLayoutTest`, `EpubOledThemeTest` (JVM); `ComicSpreadReaderInstrumentedTest`, `EpubFoldOledWebViewInstrumentedTest`, `FoldSpreadOledActivityInstrumentedTest` (connected). Spread activity assertions need a large horizontal window, such as the Android Studio Pixel Fold AVD unfolded (2208×1840, 420 dpi). On phone-sized devices they are skipped through a JUnit assumption, while single-page and OLED assertions always run. Run connected tests on a non-English host with `JAVA_TOOL_OPTIONS="-Duser.language=en -Duser.country=US"`: several existing parser tests assert `.`-formatted percentages. + +On a Galaxy Z Fold8 (physical device or Samsung Remote Test Lab SM-F971): + +- **EPUB:** + - Unfolded in the natural landscape posture, paginated EPUB shows two columns with a gutter on the hinge. A page turn advances both columns. + - Rotate to portrait, fold to the cover screen, open split-screen, and switch to continuous layout: each shows one column. + - Margins: each page has the same margin on its outer and inner side, and the gap between the pages is twice that. Raising or lowering the left/right margin widens or narrows all four page edges at once, without reopening the book. Uneven left/right settings use their mean. + - Line spacing, word spacing, font size and font family change both pages identically and reflow text across both columns. + - Text alignment and hyphenation: Left with Auto hyphenation (default) is ragged and unhyphenated. Justified hyphenates long words and gives flush lines on both pages. Hyphenation Off removes hyphens; Left with On hyphenates ragged text. Changes apply without reopening the book. A book whose metadata declares no language is hyphenated in the device language. + - Book language (in-book options → This book): Auto shows the source (book or device). Choosing another language switches read-aloud voices immediately, even while playing, and changes hyphenation; it is remembered for that book only. Auto restores the book's language. Automated: `EpubBookLanguageInstrumentedTest` (voice list per language with the system speech engine, page language, persistence), `EpubBookLanguageTest` (JVM). + - Automated: `FoldSpreadOledActivityInstrumentedTest` measures the four page margins, the line pitch on each page, hyphenated words and flush lines on each page, the no-language fallback, and the rotate-to-portrait fallback. `EpubTypesettingPreferencesTest` (JVM) covers storage and the fallback rules. + - Confirm the following still work: resume position after fold/unfold, TTS sentence highlighting and auto page-turn across columns, highlights spanning the column boundary, and tap zones. +- **Comics:** + - Unfolded, paginated CBZ/CBR shows the cover alone, then facing pairs. + - A double-page image is shown alone, and the pairs after it re-align. + - Right-to-left libraries put the earlier page on the right and advance leftwards. + - `Shift page pairs` moves the pairing by one page. + - Pinch-zoom works on the spread; long-pressing a half exports that page. + - Tap zones, volume buttons and the page list all move by spread. + - The footer reads `Pages N–M of T`. + - Folding reopens on the first page of the spread; unfolding reopens on the spread containing the saved page. + - Turning off `Two-page spread when unfolded` restores the single-page navigator immediately. +- **PDF:** stays single-page on every screen. +- **OLED (EPUB only):** + - Choosing `OLED black` shows pure white text and links on pure black. + - The following are all black, with white primary content and hairline edges: the status-bar strip, reader chrome, options sheet, chapter list, TTS/read-along panels, and system dialogs. + - Text selection is white with black text, and illustrations keep their original colours. + - Switching into or out of OLED reopens the reader at the same position. + - Comics remain full colour. + ### EPUB Image Library For a downloaded EPUB, verify the EPUB-only Image Library action, document-order image listing, duplicate suppression, SVG exclusion, minimum-size filtering, thumbnail navigation, bounded zoom/pan, outside-tap and Back dismissal, and original-image export. For an online EPUB, consent must start the normal durable selected-file download; the gallery opens only after download success. Offline missing content must report unavailable without a consent prompt.