Skip to content

feat: two-page comic spreads on unfolded foldables and landscape tablets (stacked on #300, refs #286) - #304

Merged
van-geaux merged 12 commits into
van-geaux:mainfrom
chortya:up/feat-comic-two-page-spreads
Nov 9, 2026
Merged

van-geaux merged 12 commits into
van-geaux:mainfrom
chortya:up/feat-comic-two-page-spreads

Conversation

@chortya

@chortya chortya commented Oct 18, 2026

Copy link
Copy Markdown
Contributor

Contribution pause

The maintainer lifted the pause and invited split PRs in #286.

Summary

On an unfolded foldable or a landscape tablet, paginated CBZ/CBR/CB7 shows facing pages like a printed book. The same Two-page spread when unfolded switch as EPUB controls it.

Piece What it does
comicSpreads() pairing Cover alone (setting Show cover alone, default on), double-page (landscape) images alone with the following pairs re-aligned, optional one-page shift (Shift page pairs), right-to-left libraries put the earlier page on the right.
ComicSpreadReader Compose pager that replaces the Readium image navigator while a spread is active. It reuses the continuous-comic decode loader and LRU cache, decoding pairs at half width (at most two prefetch decodes at a time). Pinch-zoom works on the spread, and long-pressing a half exports that page. Tap zones, volume buttons and the page list move by spread. The footer reads Pages N–M of T.
Resume/progress The spread's first page is persisted as the resume locator; the last visible page is reported as progress. Folding reopens on the first page of the spread; unfolding reopens on the spread containing the saved page. Detected wide pages are remembered per book (ComicWidePageStore) so a reopened book shows the same facing pages.
Hinge A separating vertical hinge is kept free of artwork (readerSpreadGutterPx, hinge converted from window to reader coordinates); otherwise the pages meet with no gap. A hinge change re-measures the gutter without a rebuild.
Layout direction The spread reader lays out left-to-right regardless of the device locale, because page order, swipes and hit-testing already follow the book's reading direction. Long-press undoes zoom/pan and splits at the actual gutter.
Lifecycle Spread changes rebuild the reader in place only while it is started. A debounced change arriving after onSaveInstanceState waits for the next start instead of running a fragment transaction (that crashed the fork build).

PDF stays single-page. New test dependency: androidx.window:window-testing 1.5.1, used to simulate fold postures.

Verification

  • ./gradlew :app:testDebugUnitTest — 1005/1005 pass (new ComicSpreadPairingTest, ComicSpreadLayoutTest, gutter cases in ReaderSpreadPolicyTest, comic settings in LibraryReaderPreferencesTest). :app:lintDebug — 0 errors. :app:assembleDebug succeeds. No added trailing whitespace; line endings preserved.

  • Connected, Pixel Fold API 36 emulator unfolded (2208×1840):

    • New ComicSpreadActivityInstrumentedTest 4/4 — spread on the unfolded window and resume after recreation, a remembered wide page keeps the same pairs on reopening, setting off keeps the Readium navigator, rotate-to-portrait fallback.
    • New ComicSpreadReaderInstrumentedTest 12/12 — pairing, navigation, tap zones applied to the spread on screen, hinge re-measure, long-press in RTL books, an RTL device locale not mirroring the spread (fails without the fix), half-width decodes, pinch-zoom locking swipes, re-pairing after a wide page is found, and a wide page found elsewhere keeping the current spread zoomed (fails without the fix).
    • New ComicSpreadPostureInstrumentedTest 2/2 (window-testing fold postures) — a posture change after state is saved does not crash and is applied on return.
    • ReadiumComicOpenInstrumentedTest 7/8 (single-page cases pinned to a phone-sized window). continuousRemotePageAllowsChunkedResponsesWithoutContentLength also fails on main on this emulator. centerTapOpensComicChromeInTwoPageSpread failed once and passed on rerun.
    • EpubSpreadActivityInstrumentedTest 5/5 (no EPUB regression).
  • Device: comic spreads are used on a Galaxy Z Fold8 from the fork build. This branch itself was run only on the emulator. Real hinge hardware with a side inset is not covered.

  • Peer review (Codex GPT-6-Astra), four rounds. Findings, all fixed:

    • wide-page classifications were lost on reopening;
    • an RTL device locale mirrored the spread a second time;
    • long-press ignored zoom/pan and an off-centre hinge;
    • hinge bounds were used in window rather than reader coordinates;
    • a wide page discovered mid-swipe jumped back to the previous spread (no automated test, timing-dependent);
    • re-pairing reset the zoom but not the pan.

    Each round found new edge cases. The last round's fixes have not had a further review pass.

🤖 Generated with Claude Code

@chortya
chortya force-pushed the up/feat-comic-two-page-spreads branch from 162e427 to 106129b Compare October 21, 2026 06:20
@van-geaux

Copy link
Copy Markdown
Owner

Thanks @chortya, the comic spread implementation looks good, and the test coverage is thorough. As this PR is stacked on #300, please rebase it after #300 is merged, resolve the remaining documentation conflict, and rerun CI. Once that is done, it should be ready to merge.

@chortya
chortya force-pushed the up/feat-comic-two-page-spreads branch from 106129b to 3fc7627 Compare October 21, 2026 14:50
@chortya

chortya commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current main (with #300 and #301). The architecture doc conflict is resolved: the comic spread notes sit under the spread section, followed by the OLED section. CI is green.

@van-geaux

Copy link
Copy Markdown
Owner

Thanks @chortya, the comic spread implementation and test coverage look thorough, especially the hinge handling and spread re-pairing cases.

When reviewing the code, I found one account-isolation issue in ComicWidePageStore (ReadiumComicReaderActivity.kt:1390-1405). The stored wide-page classifications are keyed only by readerKey, which is built from book.id|fileId (BookOrbitApp.kt:785-790). If the same book or file ID exists on another server or account, it can inherit the previous profile’s wide-page classifications and produce incorrect spread pairing.

Could you scope this store key to the active server profile/provider, similar to the other account-owned reader state, and add a cross-profile regression test?

@chortya
chortya force-pushed the up/feat-comic-two-page-spreads branch from 3fc7627 to 331f134 Compare November 1, 2026 10:53
@chortya

chortya commented Nov 1, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, good catch. Rebased onto current main and fixed:

  • Wide-page store per profile: ComicWidePageStore is now keyed by the active server profile as well as book.id|fileId, the same ServerProfileStore.activeId() boundary the per-book EPUB language uses. A profile belongs to one provider, so that covers the provider too. The reader captures the profile when it opens. Saving an empty set now removes the entry.
  • Cross-profile regression tests: ComicSpreadPairingTest checks the per-profile keys. In ComicSpreadActivityInstrumentedTest, widePageStoreKeepsProfilesApart checks the store, and widePagesRememberedForAnotherProfileDoNotChangeThePairs opens the reader with page 2 remembered as wide only for another profile and checks it pairs as if nothing were remembered (resumes on page 4, not 5). The existing same-profile test proves the remembered pages are still used.
  • i18n: the spread footer ("Pages 2–3 of 40") and its spoken label, the "Show cover alone" and "Shift page pairs" settings, the page-load error, Retry, and the TalkBack page descriptions now use string resources with zh-CN translations. The localization validator and the new hardcoded-string guard pass.
  • Rotation tests and target SDK 36: with targetSdk = 36, Android ignores requestedOrientation on large screens, so the comic and the already-merged EPUB "rotated to portrait" tests timed out on an unfolded foldable. Both now rotate the device through UiAutomation.setRotation and unfreeze rotation afterwards.

Verified: JVM tests, lint, androidTest compile, and all 25 tests in ComicSpreadActivityInstrumentedTest, ComicSpreadReaderInstrumentedTest, ComicSpreadPostureInstrumentedTest and EpubSpreadActivityInstrumentedTest pass on a Pixel Fold API 36 emulator; CI is green.

@van-geaux

Copy link
Copy Markdown
Owner

Thanks @chortya, the comic spread implementation and test coverage look thorough. I found no functional blockers in the source review.

Before merge, please remove these generated Python bytecode files from the PR:

  • tools/__pycache__/test_validate_android_localizations.cpython-313.pyc
  • tools/__pycache__/validate_android_localizations.cpython-313.pyc

The JVM tests, Android test compilation, debug build, and CI check pass. The connected tests were not independently validated on my attached Samsung device because Espresso failed before reaching the test assertions with the existing InputManager.getInstance reflection error.

Andriy Chortkivskyy and others added 11 commits November 8, 2026 08:32
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
- Posture changes are applied only while the reader is started, and a
  debounced change landing after onSaveInstanceState no longer runs a
  fragment transaction; the next start re-checks the spread.
- The gutter is re-measured when the hinge changes without a rebuild.
- Tap-zone layout and inversion changes apply to the spread on screen, and
  long-press uses the current bitmaps (rememberUpdatedState).
- Prefetch decodes are bounded to two at a time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Show cover alone and Shift page pairs (per library) next to the shared
  Two-page spread switch; the library summary reflects EPUB or comic
  paginated layout.
- readerSpreadGutterPx keeps a separating hinge free of artwork.
- ComicSpreadActivityInstrumentedTest: spread on a large window and
  resume after recreation, setting off keeps the Readium navigator,
  rotate-to-portrait fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ocale, and long-press the page under the finger

- Wide (double-page) pages are remembered per book (ComicWidePageStore),
  so pages decoded in an earlier session still shape the pairing and a
  reopened book shows the same facing pages.
- The spread reader lays out left-to-right regardless of the device
  locale: page order, swipe direction and hit-testing already follow the
  book's reading direction, and an RTL locale mirrored them again.
- Long-press undoes zoom and pan and splits at the actual gutter, so an
  off-centre hinge or a zoomed spread exports the page that is shown.
- The long-press tests wait for the pressed page's bitmap instead of
  racing its decode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FoldingFeature bounds are window coordinates; the gutter is measured
inside the reader. When the reader is inset from the window's left edge
the hinge is shifted by that offset, and a change of offset re-measures
the gutter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… zoom

- A pairing change during a swipe or animated page turn lets the turn land
  instead of jumping back to the previously settled spread.
- Zoom is reset only when the visible spread changes, so it stays in step
  with the spread's pan offset when a page elsewhere turns out wide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rror

The spread footer ("Pages 2–3 of 40") and its spoken label, the "Show cover
alone" and "Shift page pairs" settings, and the spread reader's page-load
error and Retry button now use string resources, with Simplified Chinese
translations so the localization check passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ComicWidePageStore was keyed only by bookId|fileId, so the same book ID on
another server or account inherited the previous profile's double-page
classifications and paired its pages wrongly. The store is now keyed by the
active server profile (which also fixes the provider) as well as the book,
the same boundary the per-book EPUB language uses.

Saving an empty set removes the entry. Tests: per-profile keys (JVM), two
profiles keeping separate classifications, and a reader opened with wide
pages remembered only for another profile pairing as if none were
remembered (connected).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Apps targeting SDK 36 cannot request a fixed orientation on large screens,
so setting requestedOrientation no longer rotates an unfolded foldable and
the EPUB and comic "rotated to portrait" tests timed out. They now turn the
device a quarter turn through UiAutomation and unfreeze rotation after each
test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chortya
chortya force-pushed the up/feat-comic-two-page-spreads branch from 331f134 to 1e2e0ef Compare November 8, 2026 07:44
@chortya

chortya commented Nov 8, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current main again. The conflict was with #390: the comic footer now takes both showBorder and the spread page range, and the spread reader shares that one footer, so the border option applies there too. JVM tests, lint, androidTest compile, the localization and hardcoded-string checks, and all 25 comic/EPUB spread connected tests (Pixel Fold API 36 emulator) pass; CI is green.

@van-geaux

Copy link
Copy Markdown
Owner

Thanks, @chortya. The implementation and tests look good. Since the generated bytecode files are still in the PR, would you prefer to remove them in a follow-up PR after this one merges, or update this PR before merge?

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chortya

chortya commented Nov 9, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, and sorry about that. They were Python __pycache__ files from running the localization validator locally. I removed them in this PR (62bef02), so they won't reach main; the PR diff now has no generated files. CI is green.

@van-geaux van-geaux left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@chortya, I found no blocking issues. This PR looks ready to merge. Thanks for the thorough work and tests!

@van-geaux
van-geaux merged commit ea3ebff into van-geaux:main Nov 9, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants