#3481

Reader rewinds saved progress to the table of contents when a book is closed after following an in-book link

Openjvl-devopened 1mo ago

On a touchscreen device, tapping a chapter link on a book's contents page and then closing the book discards the whole reading session — the book reopens at the contents page. It reproduces every time, and the position is lost permanently, not just for one open.

Reproduced on an Xteink X4 Pro. The firmware was built from a third-party branch based on 1.6.0 — not my own, and not one I can identify, as the commit isn't present in this repository. The affected code is unmodified there: the destructor at EpubReaderActivity.cpp:155 and the link tap at :556 are identical to develop at b5fb406 (2026-09-10). I have not re-tested against a clean develop build.

Steps to reproduce

  1. Open an EPUB whose contents page is part of the spine — Libby/OverDrive books always are
  2. Tap a chapter link on that contents page
  3. Read a few pages
  4. Close the book, then reopen it → back at the contents page

Serial log

Progress is saved correctly throughout, then overwritten 39 ms after the book closes:

[80281] Saved position [1]: spine 2, page 0        <- tapped a link on the contents page
[80292] Navigated to spine 11 for href: 012-Eight.xhtml#ct8
[87107] Progress saved: spine=11 offset=1933 page=4   <- five pages in, saved correctly
[87649] Exiting activity: EpubReader                  <- closed the book
[87688] Progress saved: spine=2 offset=0 page=0       <- rewound to the contents page
[89801] Loaded cache: 2, 0                            <- reopens at spine 2 = contents

Cause

~EpubReaderActivity (src/activities/reader/EpubReaderActivity.cpp:155) restores savedPositions[0] whenever footnoteDepth > 0. That is correct for footnotes and matches the documented behaviour in the user guide.

The problem is that the reader cannot tell a footnote from any other link. ChapterHtmlSlimParser.cpp:1270-1297 sets insideFootnoteLink = true for every internal <a href>, with no epub:type/noteref check — so a contents entry is recorded as a footnote. The tap at EpubReaderActivity.cpp:556 calls navigateToHref(link->href, true), savedPositions[0] becomes the contents page, and closing the book rewinds there.

Libby/OverDrive EPUBs are the worst case: they ship an in-spine contents page and no guide text reference, so a fresh open lands there anyway — which makes it look as though the reader simply never remembers anything.

It needs a touchscreen (mappedInput.hasTouch()), so it cannot be triggered on the buttons-only X3/X4/X4C at all, which may be why it has gone unnoticed. It also resembles progress.bin corruption (#2275), so it may have been reported and triaged as that.

Suggested fix

The footnote/link distinction isn't expressible in the current data model, so distinguish by behaviour instead: a jump is an excursion only while the reader hasn't moved off the page it landed on. Turn a page and the position stands. Returning to the most recent origin rather than savedPositions[0] also avoids ever rewinding past material that was actually read.

Verified on device — same book, same link, same steps:

[381969] Saved position [1]: spine 2, page 0        <- same path, still armed
[381975] Navigated to spine 11 for href: 012-Eight.xhtml#ct8
[387220] Progress saved: spine=11 offset=1403 page=3
[388262] Exiting activity: EpubReader               <- no progress write follows
[391312] Loaded cache: 11, 3                        <- resumes in chapter 8

Known trade-off: closing the book after paging within a long footnote now resumes in the notes rather than at the referring chapter. That seemed clearly better than losing a session, but it is a deliberate behaviour change and worth a maintainer's view.

Patch below, with unit tests (test/jump_return_policy). It applies cleanly to a pristine tree; the full native suite passes at 182/182.

Patch
--- a/src/activities/reader/EpubReaderActivity.h
+++ b/src/activities/reader/EpubReaderActivity.h
@@ -96,6 +96,11 @@
   static constexpr int MAX_FOOTNOTE_DEPTH = 3;
   SavedPosition savedPositions[MAX_FOOTNOTE_DEPTH] = {};
   int footnoteDepth = 0;
+  // Where the most recent jump put the reader, and whether they have read on from it.
+  // JumpReturnPolicy uses these to tell a note peek from following a contents entry.
+  bool awaitingJumpLanding = false;
+  bool movedSinceJumpLanding = false;
+  SavedPosition jumpLanding = {};
 
   uint16_t buildViewportWidth = 0;
   uint16_t buildViewportHeight = 0;
--- a/src/activities/reader/EpubReaderActivity.cpp
+++ b/src/activities/reader/EpubReaderActivity.cpp
@@ -1,5 +1,7 @@
 #include "EpubReaderActivity.h"
 
+#include "JumpReturnPolicy.h"
+
 #include <Epub/Page.h>
 #include <Epub/blocks/TextBlock.h>
 #include <FontCacheManager.h>
@@ -156,8 +158,11 @@
   ImageBlock::setExtractor(nullptr, nullptr);
   discardOverlayPage();  // free the overlay's page snapshot if one is held
 
-  if (footnoteDepth > 0 && epub) {
-    const SavedPosition& origin = savedPositions[0];
+  if (epub && JumpReturnPolicy::shouldReturnToOrigin(footnoteDepth, movedSinceJumpLanding)) {
+    // Only a jump the reader never read on from is treated as a note peek worth undoing,
+    // and it returns to the most recent origin rather than the outermost one -- never past
+    // material they have actually read.
+    const SavedPosition& origin = savedPositions[footnoteDepth - 1];
     saveProgress(origin.spineIndex, origin.pageNumber, 0);
   }
 
@@ -1456,6 +1461,16 @@
     lastRenderCompleteMs = millis();
   }
 
+  if (footnoteDepth > 0) {
+    if (awaitingJumpLanding) {
+      jumpLanding = {currentSpineIndex, section->currentPage};
+      awaitingJumpLanding = false;
+    } else if (JumpReturnPolicy::hasMovedFromLanding(jumpLanding.spineIndex, jumpLanding.pageNumber, currentSpineIndex,
+                                                     section->currentPage)) {
+      movedSinceJumpLanding = true;
+    }
+  }
+
   if (currentSpineIndex != lastSavedSpineIndex || section->currentPage != lastSavedPage ||
       section->pageCount != lastSavedPageCount) {
     if (saveProgress(currentSpineIndex, section->currentPage, section->estimatedTotalPages())) {
@@ -2485,7 +2500,12 @@
   if (savePosition && section && footnoteDepth < MAX_FOOTNOTE_DEPTH) {
     savedPositions[footnoteDepth] = {currentSpineIndex, section->currentPage};
     footnoteDepth++;
-    LOG_DBG("ERS", "Saved position [%d]: spine %d, page %d", footnoteDepth, currentSpineIndex, section->currentPage);
+    // The next rendered page is where this jump lands; reading on from it makes the jump
+    // navigation rather than a peek.
+    awaitingJumpLanding = true;
+    movedSinceJumpLanding = false;
+    LOG_DBG("ERS", "Saved position [%d]: spine %d, page %d", footnoteDepth, currentSpineIndex,
+            section->currentPage);
   }
 
   std::string anchor;
@@ -2519,6 +2539,9 @@
   if (footnoteDepth <= 0) return;
   footnoteDepth--;
   const auto& pos = savedPositions[footnoteDepth];
+  awaitingJumpLanding = false;
+  movedSinceJumpLanding = false;
+  jumpLanding = pos;
   LOG_DBG("ERS", "Restoring position [%d]: spine %d, page %d", footnoteDepth, pos.spineIndex, pos.pageNumber);
 
   {
--- a/test/CMakeLists.txt
+++ b/test/CMakeLists.txt
@@ -55,3 +55,4 @@
 add_subdirectory(chapter_html_slim_parser)
 add_subdirectory(ligature_guard)
 add_subdirectory(page_link)
+add_subdirectory(jump_return_policy)
--- /dev/null
+++ b/src/activities/reader/JumpReturnPolicy.h
@@ -0,0 +1,24 @@
+#pragma once
+
+// What closing the book should do about an in-book jump the reader has not come back from.
+//
+// CrossPoint cannot tell a footnote reference from a table-of-contents entry: the chapter
+// parser records EVERY internal <a href> as a footnote (ChapterHtmlSlimParser, "Detect
+// internal <a href=...> links"), so the two are the same set by construction. The
+// distinction therefore has to come from behaviour rather than structure.
+//
+// Peeking at a note means landing and staying put -- the reader reads the note and goes
+// back. Following a contents entry means landing and then reading on. So a jump is treated
+// as an excursion only while the reader has not moved off the page it landed on; once they
+// turn a page, the jump was navigation and where they are now IS their reading position.
+// Rewinding past it on close is what made every book reopen at its table of contents.
+struct JumpReturnPolicy {
+  static bool hasMovedFromLanding(const int landingSpineIndex, const int landingPageNumber,
+                                  const int currentSpineIndex, const int currentPageNumber) {
+    return landingSpineIndex != currentSpineIndex || landingPageNumber != currentPageNumber;
+  }
+
+  static bool shouldReturnToOrigin(const int outstandingJumps, const bool movedSinceLanding) {
+    return outstandingJumps > 0 && !movedSinceLanding;
+  }
+};
--- /dev/null
+++ b/test/jump_return_policy/JumpReturnPolicyTest.cpp
@@ -0,0 +1,27 @@
+#include <src/activities/reader/JumpReturnPolicy.h>
+#include <gtest/gtest.h>
+
+// Peeking at a note: the reader lands and stays put, so closing returns to the origin.
+TEST(JumpReturnPolicyTest, ReturnsToOriginWhenTheReaderNeverLeftTheLandingPage) {
+  EXPECT_FALSE(JumpReturnPolicy::hasMovedFromLanding(30, 3, 30, 3));
+  EXPECT_TRUE(JumpReturnPolicy::shouldReturnToOrigin(1, /*movedSinceLanding=*/false));
+}
+
+// The regression: a contents entry followed by actual reading. Turning even one page means
+// the jump was navigation, and the new position is the reading position.
+TEST(JumpReturnPolicyTest, KeepsPositionOnceTheReaderTurnsAPage) {
+  EXPECT_TRUE(JumpReturnPolicy::hasMovedFromLanding(11, 0, 11, 1));
+  EXPECT_FALSE(JumpReturnPolicy::shouldReturnToOrigin(1, /*movedSinceLanding=*/true));
+}
+
+// Reading on far enough to leave the chapter entirely is still just reading on.
+TEST(JumpReturnPolicyTest, TreatsACrossChapterMoveAsReadingOn) {
+  EXPECT_TRUE(JumpReturnPolicy::hasMovedFromLanding(11, 0, 12, 0));
+  EXPECT_FALSE(JumpReturnPolicy::shouldReturnToOrigin(2, /*movedSinceLanding=*/true));
+}
+
+// With no outstanding jump there is nothing to return to, however the reader got here.
+TEST(JumpReturnPolicyTest, NeverReturnsWithoutAnOutstandingJump) {
+  EXPECT_FALSE(JumpReturnPolicy::shouldReturnToOrigin(0, /*movedSinceLanding=*/false));
+  EXPECT_FALSE(JumpReturnPolicy::shouldReturnToOrigin(0, /*movedSinceLanding=*/true));
+}
--- /dev/null
+++ b/test/jump_return_policy/CMakeLists.txt
@@ -0,0 +1,3 @@
+add_executable(JumpReturnPolicyTest JumpReturnPolicyTest.cpp)
+target_link_libraries(JumpReturnPolicyTest PRIVATE crosspoint_test_common GTest::gtest_main)
+gtest_discover_tests(JumpReturnPolicyTest)

Investigated and written by Claude (Anthropic) working with the reporter. The repro, device testing and log captures are from a real X4 Pro.

Activity

0

No activity yet.