From dae21117fda164f438a3ce7b6caf9945e6e00243 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Mon, 13 Apr 2026 17:54:23 +0200 Subject: [PATCH 1/7] Try to force embedtls to use less memory --- sdkconfig.defaults | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 sdkconfig.defaults diff --git a/sdkconfig.defaults b/sdkconfig.defaults new file mode 100644 index 00000000..cd557c33 --- /dev/null +++ b/sdkconfig.defaults @@ -0,0 +1,5 @@ +CONFIG_MBEDTLS_SSL_IN_CONTENT_LEN=4096 +CONFIG_MBEDTLS_SSL_OUT_CONTENT_LEN=2048 +CONFIG_MBEDTLS_DYNAMIC_BUFFER_ALLOCATION=y +CONFIG_MBEDTLS_DYNAMIC_FREE_CA_CERT=y +CONFIG_MBEDTLS_DYNAMIC_FREE_CONFIG_DATA=y \ No newline at end of file From e768db2cebe70f1d9e8adcc66200a73ae617d915 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Mon, 13 Apr 2026 17:54:39 +0200 Subject: [PATCH 2/7] Less memory --- lib/KOReaderSync/KOReaderSyncClient.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/KOReaderSync/KOReaderSyncClient.cpp b/lib/KOReaderSync/KOReaderSyncClient.cpp index 2a9d9a31..b65d98ab 100644 --- a/lib/KOReaderSync/KOReaderSyncClient.cpp +++ b/lib/KOReaderSync/KOReaderSyncClient.cpp @@ -240,9 +240,9 @@ esp_http_client_handle_t createClient(const char* url, ResponseBuffer* buf, config.event_handler = httpEventHandler; config.user_data = activeBuf; config.method = method; - config.timeout_ms = 15000; + config.timeout_ms = 5000; config.buffer_size = HTTP_BUF_SIZE; - config.buffer_size_tx = HTTP_BUF_SIZE; + config.buffer_size_tx = 512; config.crt_bundle_attach = esp_crt_bundle_attach; config.keep_alive_enable = g_keepSessionOpen; // Follow up to 3 redirects (e.g. HTTP→HTTPS, path normalization, DuckDNS proxy). From 1c3ba1156303aba055bd5cc9759169d5f8cfdef0 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Mon, 13 Apr 2026 19:10:32 +0200 Subject: [PATCH 3/7] Attempt at closing reader and sync --- lib/KOReaderSync/KOReaderSyncClient.cpp | 45 +++++- src/CrossPointState.cpp | 2 + src/CrossPointState.h | 48 ++++++ src/JsonSettingsIO.cpp | 30 ++++ src/activities/ActivityManager.cpp | 15 ++ src/activities/ActivityManager.h | 1 + src/activities/reader/EpubReaderActivity.cpp | 151 +++++++++++------- src/activities/reader/EpubReaderActivity.h | 3 +- .../reader/KOReaderSyncActivity.cpp | 81 +++++++--- src/activities/reader/KOReaderSyncActivity.h | 26 +-- src/activities/reader/ReaderActivity.cpp | 18 +++ 11 files changed, 315 insertions(+), 105 deletions(-) diff --git a/lib/KOReaderSync/KOReaderSyncClient.cpp b/lib/KOReaderSync/KOReaderSyncClient.cpp index b65d98ab..a32e0f40 100644 --- a/lib/KOReaderSync/KOReaderSyncClient.cpp +++ b/lib/KOReaderSync/KOReaderSyncClient.cpp @@ -32,6 +32,30 @@ esp_http_client_handle_t g_sessionClient = nullptr; // numbers, and HTTP status. Single-threaded sync flow makes static safe. char g_failureDetailBuf[160] = {0}; +std::string previewBody(const char* body, const size_t maxLen = 120) { + if (!body || !*body) { + return ""; + } + + std::string preview; + preview.reserve(maxLen); + for (const char* p = body; *p && preview.size() < maxLen; ++p) { + const unsigned char c = static_cast(*p); + if (c == '\r' || c == '\n' || c == '\t') { + preview.push_back(' '); + } else if (std::isprint(c)) { + preview.push_back(static_cast(c)); + } else { + preview.push_back('?'); + } + } + + if (strlen(body) > preview.size()) { + preview += "..."; + } + return preview; +} + // Reset the static diagnostic state at the start of each request and capture pre-flight // heap so failure reporting always reflects what was available when the request started. void beginRequest(const char* operation) { @@ -447,7 +471,12 @@ KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& doc esp_http_client_cleanup(client); } - LOG_DBG("KOSync", "Get progress response: %d (err: %s) [attempt %d]", httpCode, esp_err_to_name(err), attempt); + const size_t bodyLen = activeBuf->data ? strlen(activeBuf->data) : 0; + LOG_DBG("KOSync", "GET %s -> %d (err: %s) [attempt %d body_len=%u]", url.c_str(), httpCode, esp_err_to_name(err), + attempt, static_cast(bodyLen)); + if (err == ESP_OK && (httpCode < 200 || httpCode >= 300)) { + LOG_ERR("KOSync", "GET failure body preview: %s", previewBody(activeBuf->data).c_str()); + } // Retry exactly once for connect-level failures only. // Why: this recovers short AP/roaming hiccups without masking persistent @@ -497,7 +526,10 @@ KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& doc } if (httpCode == 401) return AUTH_FAILED; - if (httpCode == 404) return NOT_FOUND; + if (httpCode == 404) { + LOG_DBG("KOSync", "GET progress returned 404 for %s - treating as NOT_FOUND", url.c_str()); + return NOT_FOUND; + } return SERVER_ERROR; } @@ -561,7 +593,14 @@ KOReaderSyncClient::Error KOReaderSyncClient::updateProgress(const KOReaderProgr esp_http_client_cleanup(client); } - LOG_DBG("KOSync", "Update progress response: %d (err: %s) [attempt %d]", httpCode, esp_err_to_name(err), attempt); + const size_t bodyLen = activeBuf->data ? strlen(activeBuf->data) : 0; + LOG_DBG("KOSync", "PUT %s -> %d (err: %s) [attempt %d body_len=%u]", url.c_str(), httpCode, esp_err_to_name(err), + attempt, static_cast(bodyLen)); + if (err == ESP_OK && (httpCode < 200 || httpCode >= 300)) { + LOG_ERR("KOSync", "PUT failure body preview: %s", previewBody(activeBuf->data).c_str()); + LOG_ERR("KOSync", "PUT failure request summary: document=%s percentage=%.4f progress=%s", progress.document.c_str(), + progress.percentage, progress.progress.c_str()); + } // Retry exactly once for connect-level failures only. // Why: same policy as GET keeps behavior predictable across both endpoints. diff --git a/src/CrossPointState.cpp b/src/CrossPointState.cpp index 3d6c2b70..35bcd827 100644 --- a/src/CrossPointState.cpp +++ b/src/CrossPointState.cpp @@ -76,6 +76,8 @@ bool CrossPointState::loadFromBinaryFile() { lastSleepFromReader = false; } + koReaderSyncSession.clear(); + inputFile.close(); return true; } diff --git a/src/CrossPointState.h b/src/CrossPointState.h index 9e7795b9..bcf74e8a 100644 --- a/src/CrossPointState.h +++ b/src/CrossPointState.h @@ -3,6 +3,53 @@ #include #include +enum class KOReaderSyncIntentState : uint8_t { + COMPARE = 0, + PULL_REMOTE = 1, + PUSH_LOCAL = 2, +}; + +enum class KOReaderSyncOutcomeState : uint8_t { + NONE = 0, + PENDING = 1, + CANCELLED = 2, + FAILED = 3, + UPLOAD_COMPLETE = 4, + APPLIED_REMOTE = 5, +}; + +struct KOReaderSyncSessionState { + bool active = false; + std::string epubPath; + int spineIndex = 0; + int page = 0; + int totalPagesInSpine = 0; + uint16_t paragraphIndex = 0; + bool hasParagraphIndex = false; + KOReaderSyncIntentState intent = KOReaderSyncIntentState::COMPARE; + KOReaderSyncOutcomeState outcome = KOReaderSyncOutcomeState::NONE; + int resultSpineIndex = 0; + int resultPage = 0; + uint16_t resultParagraphIndex = 0; + bool resultHasParagraphIndex = false; + + void clear() { + active = false; + epubPath.clear(); + spineIndex = 0; + page = 0; + totalPagesInSpine = 0; + paragraphIndex = 0; + hasParagraphIndex = false; + intent = KOReaderSyncIntentState::COMPARE; + outcome = KOReaderSyncOutcomeState::NONE; + resultSpineIndex = 0; + resultPage = 0; + resultParagraphIndex = 0; + resultHasParagraphIndex = false; + } +}; + class CrossPointState { // Static instance static CrossPointState instance; @@ -12,6 +59,7 @@ class CrossPointState { size_t lastSleepImage = SIZE_MAX; // SIZE_MAX = unset sentinel uint8_t readerActivityLoadCount = 0; bool lastSleepFromReader = false; + KOReaderSyncSessionState koReaderSyncSession; ~CrossPointState() = default; // Get singleton instance diff --git a/src/JsonSettingsIO.cpp b/src/JsonSettingsIO.cpp index bfa0e011..ef1e8d49 100644 --- a/src/JsonSettingsIO.cpp +++ b/src/JsonSettingsIO.cpp @@ -73,6 +73,20 @@ bool JsonSettingsIO::saveState(const CrossPointState& s, const char* path) { doc["lastSleepImage"] = s.lastSleepImage; doc["readerActivityLoadCount"] = s.readerActivityLoadCount; doc["lastSleepFromReader"] = s.lastSleepFromReader; + JsonObject sync = doc["koReaderSyncSession"].to(); + sync["active"] = s.koReaderSyncSession.active; + sync["epubPath"] = s.koReaderSyncSession.epubPath; + sync["spineIndex"] = s.koReaderSyncSession.spineIndex; + sync["page"] = s.koReaderSyncSession.page; + sync["totalPagesInSpine"] = s.koReaderSyncSession.totalPagesInSpine; + sync["paragraphIndex"] = s.koReaderSyncSession.paragraphIndex; + sync["hasParagraphIndex"] = s.koReaderSyncSession.hasParagraphIndex; + sync["intent"] = static_cast(s.koReaderSyncSession.intent); + sync["outcome"] = static_cast(s.koReaderSyncSession.outcome); + sync["resultSpineIndex"] = s.koReaderSyncSession.resultSpineIndex; + sync["resultPage"] = s.koReaderSyncSession.resultPage; + sync["resultParagraphIndex"] = s.koReaderSyncSession.resultParagraphIndex; + sync["resultHasParagraphIndex"] = s.koReaderSyncSession.resultHasParagraphIndex; String json; serializeJson(doc, json); @@ -91,6 +105,22 @@ bool JsonSettingsIO::loadState(CrossPointState& s, const char* json) { s.lastSleepImage = doc["lastSleepImage"] | SIZE_MAX; s.readerActivityLoadCount = doc["readerActivityLoadCount"] | (uint8_t)0; s.lastSleepFromReader = doc["lastSleepFromReader"] | false; + JsonObject sync = doc["koReaderSyncSession"].as(); + s.koReaderSyncSession.active = sync["active"] | false; + s.koReaderSyncSession.epubPath = sync["epubPath"] | std::string(""); + s.koReaderSyncSession.spineIndex = sync["spineIndex"] | 0; + s.koReaderSyncSession.page = sync["page"] | 0; + s.koReaderSyncSession.totalPagesInSpine = sync["totalPagesInSpine"] | 0; + s.koReaderSyncSession.paragraphIndex = sync["paragraphIndex"] | (uint16_t)0; + s.koReaderSyncSession.hasParagraphIndex = sync["hasParagraphIndex"] | false; + s.koReaderSyncSession.intent = + static_cast(sync["intent"] | static_cast(KOReaderSyncIntentState::COMPARE)); + s.koReaderSyncSession.outcome = + static_cast(sync["outcome"] | static_cast(KOReaderSyncOutcomeState::NONE)); + s.koReaderSyncSession.resultSpineIndex = sync["resultSpineIndex"] | 0; + s.koReaderSyncSession.resultPage = sync["resultPage"] | 0; + s.koReaderSyncSession.resultParagraphIndex = sync["resultParagraphIndex"] | (uint16_t)0; + s.koReaderSyncSession.resultHasParagraphIndex = sync["resultHasParagraphIndex"] | false; return true; } diff --git a/src/activities/ActivityManager.cpp b/src/activities/ActivityManager.cpp index b17d4542..47b2d76f 100644 --- a/src/activities/ActivityManager.cpp +++ b/src/activities/ActivityManager.cpp @@ -4,6 +4,7 @@ #include #include +#include "CrossPointState.h" #include "boot_sleep/BootActivity.h" #include "boot_sleep/SleepActivity.h" #include "browser/OpdsBookBrowserActivity.h" @@ -11,6 +12,7 @@ #include "home/HomeActivity.h" #include "home/RecentBooksActivity.h" #include "network/CrossPointWebServerActivity.h" +#include "reader/KOReaderSyncActivity.h" #include "reader/ReaderActivity.h" #include "settings/SettingsActivity.h" #include "util/FullScreenMessageActivity.h" @@ -227,6 +229,19 @@ void ActivityManager::goToReader(std::string path) { replaceActivity(std::make_unique(renderer, mappedInput, std::move(path))); } +void ActivityManager::goToKOReaderSync() { + const auto& sync = APP_STATE.koReaderSyncSession; + if (!sync.active || sync.epubPath.empty()) { + LOG_ERR("ACT", "Cannot launch KOReader sync without an active EPUB handoff"); + goHome(); + return; + } + + replaceActivity(std::make_unique(renderer, mappedInput, sync.epubPath, sync.spineIndex, + sync.page, sync.totalPagesInSpine, sync.paragraphIndex, + sync.hasParagraphIndex, sync.intent)); +} + void ActivityManager::pushReader(std::string path) { pushActivity(std::make_unique(renderer, mappedInput, std::move(path))); } diff --git a/src/activities/ActivityManager.h b/src/activities/ActivityManager.h index 33de24b2..a907b8a9 100644 --- a/src/activities/ActivityManager.h +++ b/src/activities/ActivityManager.h @@ -89,6 +89,7 @@ class ActivityManager { void goToRecentBooks(); void goToBrowser(); void goToReader(std::string path); + void goToKOReaderSync(); void pushReader(std::string path); void goToSleep(); void goToBoot(); diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 0826f3ab..baf492f8 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -18,7 +19,6 @@ #include "EpubReaderFootnotesActivity.h" #include "EpubReaderPercentSelectionActivity.h" #include "KOReaderCredentialStore.h" -#include "KOReaderSyncActivity.h" #include "MappedInputManager.h" #include "QrDisplayActivity.h" #include "ReaderUtils.h" @@ -33,6 +33,31 @@ constexpr unsigned long skipChapterMs = 700; // pages per minute, first item is 1 to prevent division by zero if accessed const std::vector PAGE_TURN_LABELS = {1, 1, 3, 6, 12}; +void logReaderMemSnapshot(const char* stage) { + const uint32_t freeHeap = esp_get_free_heap_size(); + const uint32_t contigHeap = heap_caps_get_largest_free_block(MALLOC_CAP_8BIT | MALLOC_CAP_DEFAULT); + LOG_DBG("ERS", "Reader mem[%s]: free=%lu contig=%lu", stage, freeHeap, contigHeap); +} + +bool writeReaderProgressCache(const std::string& cachePath, const int spineIndex, const int currentPage, const int pageCount) { + FsFile f; + if (!Storage.openFileForWrite("ERS", cachePath + "/progress.bin", f)) { + LOG_ERR("ERS", "Failed to open progress cache for sync restore: %s", cachePath.c_str()); + return false; + } + + uint8_t data[6]; + data[0] = spineIndex & 0xFF; + data[1] = (spineIndex >> 8) & 0xFF; + data[2] = currentPage & 0xFF; + data[3] = (currentPage >> 8) & 0xFF; + data[4] = pageCount & 0xFF; + data[5] = (pageCount >> 8) & 0xFF; + f.write(data, 6); + f.close(); + return true; +} + int clampPercent(int percent) { if (percent < 0) { return 0; @@ -47,6 +72,7 @@ int clampPercent(int percent) { void EpubReaderActivity::onEnter() { Activity::onEnter(); + logReaderMemSnapshot("onEnter_begin"); // Drop any input events that arrived from the activity that launched us (e.g. a wake-up power // button hold) before they reach detectPageTurn() — see ReaderUtils::InputDrainGuard. @@ -64,6 +90,7 @@ void EpubReaderActivity::onEnter() { } epub->setupCacheDir(); + applyPendingSyncSession(); FsFile f; if (Storage.openFileForRead("ERS", epub->getCachePath() + "/progress.bin", f)) { @@ -104,10 +131,12 @@ void EpubReaderActivity::onEnter() { // Trigger first update requestUpdate(); + logReaderMemSnapshot("onEnter_ready"); } void EpubReaderActivity::onExit() { Activity::onExit(); + logReaderMemSnapshot("onExit_before_release"); // Reset orientation back to portrait for the rest of the UI renderer.setOrientation(GfxRenderer::Orientation::Portrait); @@ -116,6 +145,9 @@ void EpubReaderActivity::onExit() { APP_STATE.saveToFile(); section.reset(); epub.reset(); + currentPageFootnotes.clear(); + currentPageFootnotes.shrink_to_fit(); + logReaderMemSnapshot("onExit_after_release"); } void EpubReaderActivity::loop() { @@ -498,70 +530,77 @@ void EpubReaderActivity::launchKOReaderSync(const SyncLaunchMode mode) { return; } - const std::string syncEpubPath = epub->getPath(); const int currentPage = section ? section->currentPage : 0; const int totalPages = section ? section->pageCount : 0; - - { - // Drop large reader state before TLS-heavy sync to improve contiguous heap - // and reduce long-run fragmentation across repeated sync attempts. - RenderLock lock(*this); - nextPageNumber = currentPage; - cachedSpineIndex = currentSpineIndex; - cachedChapterTotalPageCount = totalPages; - section.reset(); - epub.reset(); - currentPageFootnotes.clear(); - currentPageFootnotes.shrink_to_fit(); - } - deferredSyncEpubPath = syncEpubPath; - - renderer.cleanupGrayscaleWithFrameBuffer(); - if (auto* cacheManager = renderer.getFontCacheManager()) { - cacheManager->clearCache(); - cacheManager->resetStats(); - } - - LOG_DBG("ERS", "Pre-sync trim: spine=%d page=%d/%d heap=%lu", currentSpineIndex, currentPage, totalPages, - static_cast(esp_get_free_heap_size())); - - // Map reader-level launch mode to activity-level intent once, then pass a - // stable intent into KOReaderSyncActivity so it can own the sync state machine. - KOReaderSyncActivity::SyncIntent syncIntent = KOReaderSyncActivity::SyncIntent::COMPARE; + KOReaderSyncIntentState syncIntent = KOReaderSyncIntentState::COMPARE; if (mode == SyncLaunchMode::PULL_REMOTE) { - syncIntent = KOReaderSyncActivity::SyncIntent::PULL_REMOTE; + syncIntent = KOReaderSyncIntentState::PULL_REMOTE; } else if (mode == SyncLaunchMode::PUSH_LOCAL) { - syncIntent = KOReaderSyncActivity::SyncIntent::PUSH_LOCAL; + syncIntent = KOReaderSyncIntentState::PUSH_LOCAL; } - startActivityForResult( - std::make_unique(renderer, mappedInput, std::shared_ptr{}, syncEpubPath, - currentSpineIndex, currentPage, totalPages, 0, false, syncIntent), - [this](const ActivityResult& result) { handleSyncResult(result); }); + auto& sync = APP_STATE.koReaderSyncSession; + sync.active = true; + sync.epubPath = epub->getPath(); + sync.spineIndex = currentSpineIndex; + sync.page = currentPage; + sync.totalPagesInSpine = totalPages; + sync.paragraphIndex = 0; + sync.hasParagraphIndex = false; + sync.intent = syncIntent; + sync.outcome = KOReaderSyncOutcomeState::PENDING; + sync.resultSpineIndex = 0; + sync.resultPage = 0; + sync.resultParagraphIndex = 0; + sync.resultHasParagraphIndex = false; + APP_STATE.saveToFile(); + + LOG_DBG("ERS", "Standalone sync handoff: spine=%d page=%d/%d", currentSpineIndex, currentPage, totalPages); + logReaderMemSnapshot("before_replace_with_sync"); + activityManager.goToKOReaderSync(); } -void EpubReaderActivity::handleSyncResult(const ActivityResult& result) { - if (!epub && !deferredSyncEpubPath.empty()) { - epub = std::make_shared(deferredSyncEpubPath, "/.crosspoint"); - if (!epub->load(true, true)) { - LOG_ERR("ERS", "Failed to reload EPUB after sync: %s", deferredSyncEpubPath.c_str()); - finish(); - return; - } - epub->setupCacheDir(); - LOG_DBG("ERS", "Reloaded EPUB after sync: %s", deferredSyncEpubPath.c_str()); - deferredSyncEpubPath.clear(); +void EpubReaderActivity::applyPendingSyncSession() { + auto& sync = APP_STATE.koReaderSyncSession; + if (!sync.active || !epub || sync.epubPath != epub->getPath()) { + return; } - if (!result.isCancelled) { - const auto& sync = std::get(result.data); - if (currentSpineIndex != sync.spineIndex || (section && section->currentPage != sync.page)) { - RenderLock lock(*this); - currentSpineIndex = sync.spineIndex; - nextPageNumber = sync.page; - section.reset(); - } + LOG_DBG("ERS", "Applying pending sync session outcome=%d path=%s", static_cast(sync.outcome), sync.epubPath.c_str()); + + int restoreSpineIndex = sync.spineIndex; + int restorePage = sync.page; + pendingParagraphLookup = sync.hasParagraphIndex; + pendingParagraphIndex = sync.paragraphIndex; + + if (sync.outcome == KOReaderSyncOutcomeState::APPLIED_REMOTE) { + restoreSpineIndex = sync.resultSpineIndex; + restorePage = sync.resultPage; + pendingParagraphLookup = sync.resultHasParagraphIndex; + pendingParagraphIndex = sync.resultParagraphIndex; + LOG_DBG("ERS", "Applied synced remote position: spine=%d page=%d paragraph=%u hasParagraph=%s", + restoreSpineIndex, restorePage, pendingParagraphIndex, pendingParagraphLookup ? "yes" : "no"); + } else { + LOG_DBG("ERS", "Restored local pre-sync position: spine=%d page=%d paragraph=%u hasParagraph=%s", restoreSpineIndex, + restorePage, pendingParagraphIndex, pendingParagraphLookup ? "yes" : "no"); } + + if (writeReaderProgressCache(epub->getCachePath(), restoreSpineIndex, restorePage, sync.totalPagesInSpine)) { + cachedSpineIndex = restoreSpineIndex; + cachedChapterTotalPageCount = sync.totalPagesInSpine; + LOG_DBG("ERS", "Prepared progress.bin for sync restore: spine=%d page=%d/%d", restoreSpineIndex, restorePage, + sync.totalPagesInSpine); + } else { + // Fall back to directly seeding live state if cache write fails. + currentSpineIndex = restoreSpineIndex; + nextPageNumber = restorePage; + cachedSpineIndex = restoreSpineIndex; + cachedChapterTotalPageCount = sync.totalPagesInSpine; + } + + sync.clear(); + APP_STATE.saveToFile(); + logReaderMemSnapshot("after_apply_pending_sync_session"); } void EpubReaderActivity::applyOrientation(const uint8_t orientation) { @@ -914,8 +953,8 @@ void EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageC FsFile f; if (Storage.openFileForWrite("ERS", epub->getCachePath() + "/progress.bin", f)) { uint8_t data[6]; - data[0] = currentSpineIndex & 0xFF; - data[1] = (currentSpineIndex >> 8) & 0xFF; + data[0] = spineIndex & 0xFF; + data[1] = (spineIndex >> 8) & 0xFF; data[2] = currentPage & 0xFF; data[3] = (currentPage >> 8) & 0xFF; data[4] = pageCount & 0xFF; diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 5cd77a37..07cb710a 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -48,7 +48,6 @@ class EpubReaderActivity final : public Activity { bool skipNextButtonCheck = false; // Skip button processing for one frame after subactivity exit ReaderUtils::InputDrainGuard inputDrainGuard; bool automaticPageTurnActive = false; - std::string deferredSyncEpubPath; // -1 means use global SETTINGS value. int8_t bookEmbeddedStyleOverride = -1; int8_t bookImageRenderingOverride = -1; @@ -72,7 +71,7 @@ class EpubReaderActivity final : public Activity { void jumpToPercent(int percent); void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action); void launchKOReaderSync(SyncLaunchMode mode = SyncLaunchMode::COMPARE); - void handleSyncResult(const ActivityResult& result); + void applyPendingSyncSession(); void applyOrientation(uint8_t orientation); void applyTextDarkness(uint8_t textDarkness); void toggleAutoPageTurn(uint8_t selectedPageTurnOption); diff --git a/src/activities/reader/KOReaderSyncActivity.cpp b/src/activities/reader/KOReaderSyncActivity.cpp index 522080c9..496c5423 100644 --- a/src/activities/reader/KOReaderSyncActivity.cpp +++ b/src/activities/reader/KOReaderSyncActivity.cpp @@ -57,11 +57,8 @@ bool shouldSyncNtpNow() { void KOReaderSyncActivity::onWifiSelectionComplete(const bool success) { if (!success) { - LOG_DBG("KOSync", "WiFi connection failed, exiting"); - ActivityResult result; - result.isCancelled = true; - setResult(std::move(result)); - finish(); + LOG_DBG("KOSync", "WiFi connection failed, resuming reader"); + resumeReader(KOReaderSyncOutcomeState::CANCELLED); return; } @@ -118,7 +115,7 @@ void KOReaderSyncActivity::performSync() { // Local mapping is only needed for compare/upload paths. // Pull-only mode can skip this expensive step and go straight to remote fetch. - if (syncIntent != SyncIntent::PULL_REMOTE) { + if (syncIntent != KOReaderSyncIntentState::PULL_REMOTE) { // Precompute local mapping before first network request so the expensive // inflate/index work happens before TLS. This avoids a second local mapping // pass later and keeps the upload path lightweight. @@ -143,7 +140,7 @@ void KOReaderSyncActivity::performSync() { // Push intent skips comparison UI but still warms an HTTP/TLS session first // so PUT can reuse the connection instead of forcing a fresh handshake. - if (syncIntent == SyncIntent::PUSH_LOCAL) { + if (syncIntent == KOReaderSyncIntentState::PUSH_LOCAL) { // Direct push previously started with no reusable HTTP/TLS session, forcing // a fresh handshake in updateProgress. Compare flow often succeeds because // upload reuses the GET session. Warm the session here so push can take the @@ -184,7 +181,7 @@ void KOReaderSyncActivity::performSync() { const auto result = KOReaderSyncClient::getProgress(documentHash, remoteProgress); if (result == KOReaderSyncClient::NOT_FOUND) { - if (syncIntent == SyncIntent::PULL_REMOTE) { + if (syncIntent == KOReaderSyncIntentState::PULL_REMOTE) { // Pull intent must not silently fall back to upload when server has no // remote progress. Failing explicitly keeps action semantics predictable. KOReaderSyncClient::endPersistentSession(); @@ -236,7 +233,7 @@ void KOReaderSyncActivity::performSync() { remotePosition.hasParagraphIndex = false; remoteChapterLabel.clear(); - if (syncIntent == SyncIntent::PULL_REMOTE) { + if (syncIntent == KOReaderSyncIntentState::PULL_REMOTE) { // Pull intent applies immediately and exits. We bypass chooser UI to keep // reader menu actions deterministic ("pull" always means apply remote). if (!ensureRemotePositionMapped()) { @@ -251,8 +248,13 @@ void KOReaderSyncActivity::performSync() { // Preserve the apply result and show explicit confirmation before returning // to the reader so users can tell pull succeeded. - setResult(SyncResult{remotePosition.spineIndex, remotePosition.pageNumber, remotePosition.paragraphIndex, - remotePosition.hasParagraphIndex}); + auto& sync = APP_STATE.koReaderSyncSession; + sync.outcome = KOReaderSyncOutcomeState::APPLIED_REMOTE; + sync.resultSpineIndex = remotePosition.spineIndex; + sync.resultPage = remotePosition.pageNumber; + sync.resultParagraphIndex = remotePosition.paragraphIndex; + sync.resultHasParagraphIndex = remotePosition.hasParagraphIndex; + APP_STATE.saveToFile(); { RenderLock lock(*this); state = APPLY_COMPLETE; @@ -370,6 +372,8 @@ void KOReaderSyncActivity::performUpload() { } HalClock::wifiOff(true); + APP_STATE.koReaderSyncSession.outcome = KOReaderSyncOutcomeState::UPLOAD_COMPLETE; + APP_STATE.saveToFile(); { RenderLock lock(*this); state = UPLOAD_COMPLETE; @@ -380,6 +384,9 @@ void KOReaderSyncActivity::performUpload() { void KOReaderSyncActivity::onEnter() { Activity::onEnter(); + logSyncMemSnapshot("onEnter_begin"); + LOG_DBG("KOSync", "Standalone sync start: path=%s spine=%d page=%d/%d intent=%d", epubPath.c_str(), currentSpineIndex, + currentPage, totalPagesInSpine, static_cast(syncIntent)); // Check for credentials first if (!KOREADER_STORE.hasCredentials()) { @@ -404,8 +411,11 @@ void KOReaderSyncActivity::onEnter() { void KOReaderSyncActivity::onExit() { Activity::onExit(); + logSyncMemSnapshot("onExit_before_cleanup"); KOReaderSyncClient::endPersistentSession(); HalClock::wifiOff(true); + releaseEpubForMapping(); + logSyncMemSnapshot("onExit_after_cleanup"); } void KOReaderSyncActivity::closeCancelled() { @@ -413,11 +423,31 @@ void KOReaderSyncActivity::closeCancelled() { return; } + resumeReader(KOReaderSyncOutcomeState::CANCELLED); +} + +void KOReaderSyncActivity::resumeReader(const KOReaderSyncOutcomeState outcome, const SyncResult* appliedResult) { + if (closeRequested) { + return; + } + closeRequested = true; - ActivityResult result; - result.isCancelled = true; - setResult(std::move(result)); - finish(); + auto& sync = APP_STATE.koReaderSyncSession; + sync.outcome = outcome; + if (appliedResult) { + sync.resultSpineIndex = appliedResult->spineIndex; + sync.resultPage = appliedResult->page; + sync.resultParagraphIndex = appliedResult->paragraphIndex; + sync.resultHasParagraphIndex = appliedResult->hasParagraphIndex; + } else { + sync.resultSpineIndex = 0; + sync.resultPage = 0; + sync.resultParagraphIndex = 0; + sync.resultHasParagraphIndex = false; + } + APP_STATE.saveToFile(); + logSyncMemSnapshot("before_resume_reader"); + activityManager.goToReader(epubPath); } void KOReaderSyncActivity::render(RenderLock&&) { @@ -626,22 +656,23 @@ void KOReaderSyncActivity::computeRemoteChapter() { void KOReaderSyncActivity::loop() { if (state == NO_CREDENTIALS || state == SYNC_FAILED || state == UPLOAD_COMPLETE || state == APPLY_COMPLETE) { if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { - // APPLY_COMPLETE already has a valid SyncResult, so exit normally. - // Other terminal states are treated as cancelled when backing out. if (state == APPLY_COMPLETE) { - finish(); + resumeReader(KOReaderSyncOutcomeState::APPLIED_REMOTE); + } else if (state == UPLOAD_COMPLETE) { + resumeReader(KOReaderSyncOutcomeState::UPLOAD_COMPLETE); + } else if (state == SYNC_FAILED || state == NO_CREDENTIALS) { + resumeReader(KOReaderSyncOutcomeState::FAILED); } else { - closeCancelled(); + resumeReader(KOReaderSyncOutcomeState::CANCELLED); } return; } if ((state == UPLOAD_COMPLETE || state == APPLY_COMPLETE) && millis() - uploadCompleteTime >= 3000) { - // Keep pull/apply result on auto-close; upload-complete remains cancel-style. if (state == APPLY_COMPLETE) { - finish(); + resumeReader(KOReaderSyncOutcomeState::APPLIED_REMOTE); } else { - closeCancelled(); + resumeReader(KOReaderSyncOutcomeState::UPLOAD_COMPLETE); } } return; @@ -671,9 +702,9 @@ void KOReaderSyncActivity::loop() { return; } // Wifi will be turned off in onExit() - setResult(SyncResult{remotePosition.spineIndex, remotePosition.pageNumber, remotePosition.paragraphIndex, - remotePosition.hasParagraphIndex}); - finish(); + const SyncResult result = {remotePosition.spineIndex, remotePosition.pageNumber, remotePosition.paragraphIndex, + remotePosition.hasParagraphIndex}; + resumeReader(KOReaderSyncOutcomeState::APPLIED_REMOTE, &result); } else if (selectedOption == 1) { // Upload local progress performUpload(); diff --git a/src/activities/reader/KOReaderSyncActivity.h b/src/activities/reader/KOReaderSyncActivity.h index da7eb124..06ac7bb5 100644 --- a/src/activities/reader/KOReaderSyncActivity.h +++ b/src/activities/reader/KOReaderSyncActivity.h @@ -1,12 +1,12 @@ #pragma once #include -#include #include #include "ChapterXPathIndexer.h" #include "KOReaderSyncClient.h" #include "ProgressMapper.h" +#include "CrossPointState.h" #include "activities/Activity.h" /** @@ -27,24 +27,11 @@ */ class KOReaderSyncActivity final : public Activity { public: - // Intent controls UI/behavior split for the same sync pipeline. - // - COMPARE: fetch then let user choose apply/upload. - // - PULL_REMOTE: fetch and apply immediately. - // - PUSH_LOCAL: upload immediately. - // This keeps WiFi/NTP/hash/memory handling centralized while enabling a - // simpler KOReader-like reader menu UX. - enum class SyncIntent { - COMPARE, - PULL_REMOTE, - PUSH_LOCAL, - }; - - explicit KOReaderSyncActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, - const std::shared_ptr& epub, const std::string& epubPath, int currentSpineIndex, - int currentPage, int totalPagesInSpine, uint16_t paragraphIndex = 0, - bool hasParagraphIndex = false, SyncIntent syncIntent = SyncIntent::COMPARE) + explicit KOReaderSyncActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& epubPath, + int currentSpineIndex, int currentPage, int totalPagesInSpine, + uint16_t paragraphIndex = 0, bool hasParagraphIndex = false, + KOReaderSyncIntentState syncIntent = KOReaderSyncIntentState::COMPARE) : Activity("KOReaderSync", renderer, mappedInput), - epub(epub), epubPath(epubPath), currentSpineIndex(currentSpineIndex), currentPage(currentPage), @@ -83,7 +70,7 @@ class KOReaderSyncActivity final : public Activity { int totalPagesInSpine; uint16_t localParagraphIndex; bool hasLocalParagraphIndex; - SyncIntent syncIntent = SyncIntent::COMPARE; + KOReaderSyncIntentState syncIntent = KOReaderSyncIntentState::COMPARE; State state = WIFI_SELECTION; std::string statusMessage; @@ -111,6 +98,7 @@ class KOReaderSyncActivity final : public Activity { void performSync(); void performUpload(); void closeCancelled(); + void resumeReader(KOReaderSyncOutcomeState outcome, const SyncResult* appliedResult = nullptr); bool ensureEpubLoadedForMapping(); void releaseEpubForMapping(); bool computeLocalProgressAndChapter(); diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index 3f702ab8..ee7eb060 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -3,8 +3,12 @@ #include #include #include +#include +#include +#include #include "CrossPointSettings.h" +#include "CrossPointState.h" #include "Epub.h" #include "EpubReaderActivity.h" #include "Txt.h" @@ -16,6 +20,14 @@ #include "components/UITheme.h" #include "fontIds.h" +namespace { +void logReaderLaunchMemSnapshot(const char* stage) { + const uint32_t freeHeap = esp_get_free_heap_size(); + const uint32_t contigHeap = heap_caps_get_largest_free_block(MALLOC_CAP_8BIT | MALLOC_CAP_DEFAULT); + LOG_DBG("READER", "Reader mem[%s]: free=%lu contig=%lu", stage, freeHeap, contigHeap); +} +} // namespace + std::string ReaderActivity::extractFolderPath(const std::string& filePath) { const auto lastSlash = filePath.find_last_of('/'); if (lastSlash == std::string::npos || lastSlash == 0) { @@ -90,6 +102,7 @@ void ReaderActivity::goToLibrary(const std::string& fromBookPath) { void ReaderActivity::onGoToEpubReader(std::unique_ptr epub) { const auto epubPath = epub->getPath(); currentBookPath = epubPath; + logReaderLaunchMemSnapshot("before_push_epub_reader"); startActivityForResult(std::make_unique(renderer, mappedInput, std::move(epub)), [this](const ActivityResult&) { finish(); }); } @@ -115,12 +128,17 @@ void ReaderActivity::onGoToTxtReader(std::unique_ptr txt) { void ReaderActivity::onEnter() { Activity::onEnter(); + logReaderLaunchMemSnapshot("onEnter_begin"); if (initialBookPath.empty()) { goToLibrary(); // Start from root when entering via Browse return; } + if (APP_STATE.koReaderSyncSession.active && APP_STATE.koReaderSyncSession.epubPath == initialBookPath) { + LOG_DBG("READER", "Opening EPUB with pending KOReader sync outcome=%d", static_cast(APP_STATE.koReaderSyncSession.outcome)); + } + currentBookPath = initialBookPath; if (isImageFile(initialBookPath)) { onGoToBmpViewer(initialBookPath); From a03d232dd3248522c8c203abf650c11ed92a56ed Mon Sep 17 00:00:00 2001 From: jpirnay Date: Mon, 13 Apr 2026 20:24:28 +0200 Subject: [PATCH 4/7] Finalize implementation --- docs/contributing/architecture.md | 2 +- docs/contributing/koreader-synchronization.md | 33 +++++++++++++++++++ lib/KOReaderSync/KOReaderSyncClient.cpp | 33 ++++++++++++++++--- src/activities/reader/EpubReaderActivity.cpp | 20 ++++++++--- src/activities/reader/EpubReaderActivity.h | 4 +++ .../reader/KOReaderSyncActivity.cpp | 11 ++++--- src/activities/reader/KOReaderSyncActivity.h | 9 +++-- 7 files changed, 96 insertions(+), 16 deletions(-) diff --git a/docs/contributing/architecture.md b/docs/contributing/architecture.md index cab0db95..d7c98394 100644 --- a/docs/contributing/architecture.md +++ b/docs/contributing/architecture.md @@ -147,7 +147,7 @@ are documented in: Two singletons are central: - `src/CrossPointSettings.h` (`SETTINGS`): user preferences and behavior flags -- `src/CrossPointState.h` (`APP_STATE`): runtime/session state such as current book and sleep context +- `src/CrossPointState.h` (`APP_STATE`): runtime/session state such as current book, sleep context, and standalone KOReader sync handoff/outcome state Typical persisted areas on SD: diff --git a/docs/contributing/koreader-synchronization.md b/docs/contributing/koreader-synchronization.md index 31aa74ad..8fa08606 100644 --- a/docs/contributing/koreader-synchronization.md +++ b/docs/contributing/koreader-synchronization.md @@ -17,6 +17,39 @@ The synchronization layer is designed to: - Keep transport/client logic separated from parsing/mapping logic. - Prefer precise anchors when available, but degrade gracefully. +## Standalone Sync Lifecycle + +KOReader sync no longer runs as a child activity on top of a live EPUB reader. +Instead, the reader persists a compact handoff record in `APP_STATE`, then the +activity stack is replaced with `KOReaderSyncActivity`. + +Why this exists: +- HTTPS/TLS setup on ESP32-C3 is sensitive to heap fragmentation. +- Keeping the full reader activity alive underneath sync reclaimed too little + memory and made failures harder to reason about. +- A standalone sync screen makes memory snapshots and failure modes easier to + interpret. + +Current lifecycle: +1. `EpubReaderActivity` stores the EPUB path, current local position, sync + intent, and any future sync result slots in `APP_STATE.koReaderSyncSession`. +2. The reader activity stack is replaced with `KOReaderSyncActivity`. +3. `KOReaderSyncActivity` lazily reloads EPUB data only for mapping work and + releases it again before network-heavy phases. +4. On completion, cancel, or failure, sync persists an outcome and reopens the + book through the normal `ReaderActivity -> EpubReaderActivity` path. +5. `EpubReaderActivity::applyPendingSyncSession()` consumes that outcome: + - remote-apply writes the reopen position into `progress.bin` before normal + reader startup loads it + - upload-complete keeps the existing local `progress.bin` unchanged + - paragraph-level correction metadata is still carried separately because + `progress.bin` stores only spine/page/pageCount + +Memory notes: +- Reader-owned state is reclaimed by fully exiting the reader before sync. +- Sync still trims its own renderer/font caches right before TLS because sync UI + rendering can repopulate those caches after the reader is gone. + ## Data Model Mismatch CrossPoint stores position as chapter/page-centric state. diff --git a/lib/KOReaderSync/KOReaderSyncClient.cpp b/lib/KOReaderSync/KOReaderSyncClient.cpp index a32e0f40..b3a05a9b 100644 --- a/lib/KOReaderSync/KOReaderSyncClient.cpp +++ b/lib/KOReaderSync/KOReaderSyncClient.cpp @@ -31,6 +31,7 @@ esp_http_client_handle_t g_sessionClient = nullptr; // the longest expected message including esp_err name (~32 chars), opcode (~10), heap // numbers, and HTTP status. Single-threaded sync flow makes static safe. char g_failureDetailBuf[160] = {0}; +char g_lastResponsePreview[160] = {0}; std::string previewBody(const char* body, const size_t maxLen = 120) { if (!body || !*body) { @@ -56,6 +57,12 @@ std::string previewBody(const char* body, const size_t maxLen = 120) { return preview; } +void rememberResponsePreview(const char* body) { + const std::string preview = previewBody(body, sizeof(g_lastResponsePreview) - 1); + strncpy(g_lastResponsePreview, preview.c_str(), sizeof(g_lastResponsePreview) - 1); + g_lastResponsePreview[sizeof(g_lastResponsePreview) - 1] = '\0'; +} + // Reset the static diagnostic state at the start of each request and capture pre-flight // heap so failure reporting always reflects what was available when the request started. void beginRequest(const char* operation) { @@ -64,6 +71,7 @@ void beginRequest(const char* operation) { KOReaderSyncClient::lastHttpCode = 0; KOReaderSyncClient::lastHeapAtFailure = ESP.getFreeHeap(); KOReaderSyncClient::lastContigHeapAtFailure = heap_caps_get_largest_free_block(MALLOC_CAP_8BIT | MALLOC_CAP_DEFAULT); + g_lastResponsePreview[0] = '\0'; } // Skip a leading UTF-8 BOM (EF BB BF) and ASCII whitespace, returning a pointer @@ -475,7 +483,8 @@ KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& doc LOG_DBG("KOSync", "GET %s -> %d (err: %s) [attempt %d body_len=%u]", url.c_str(), httpCode, esp_err_to_name(err), attempt, static_cast(bodyLen)); if (err == ESP_OK && (httpCode < 200 || httpCode >= 300)) { - LOG_ERR("KOSync", "GET failure body preview: %s", previewBody(activeBuf->data).c_str()); + rememberResponsePreview(activeBuf->data); + LOG_ERR("KOSync", "GET failure body preview: %s", g_lastResponsePreview); } // Retry exactly once for connect-level failures only. @@ -597,9 +606,10 @@ KOReaderSyncClient::Error KOReaderSyncClient::updateProgress(const KOReaderProgr LOG_DBG("KOSync", "PUT %s -> %d (err: %s) [attempt %d body_len=%u]", url.c_str(), httpCode, esp_err_to_name(err), attempt, static_cast(bodyLen)); if (err == ESP_OK && (httpCode < 200 || httpCode >= 300)) { - LOG_ERR("KOSync", "PUT failure body preview: %s", previewBody(activeBuf->data).c_str()); - LOG_ERR("KOSync", "PUT failure request summary: document=%s percentage=%.4f progress=%s", progress.document.c_str(), - progress.percentage, progress.progress.c_str()); + rememberResponsePreview(activeBuf->data); + LOG_ERR("KOSync", "PUT failure body preview: %s", g_lastResponsePreview); + LOG_ERR("KOSync", "PUT failure request summary: document=%s percentage=%.4f progress=%s", + progress.document.c_str(), progress.percentage, progress.progress.c_str()); } // Retry exactly once for connect-level failures only. @@ -670,6 +680,21 @@ const char* KOReaderSyncClient::lastFailureDetail() { } // Server case: got an HTTP status the client didn't recognize as success. if (lastHttpCode != 0) { + if (lastHttpCode == 404 && lastOperation && strcmp(lastOperation, "update progress") == 0) { + std::string lowerPreview = g_lastResponsePreview; + std::transform(lowerPreview.begin(), lowerPreview.end(), lowerPreview.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (lowerPreview.find("book not found") != std::string::npos) { + snprintf(g_failureDetailBuf, sizeof(g_failureDetailBuf), + "%s: server does not know this book yet; server expects the same file to already exist there, usually " + "downloaded via OPDS", + lastOperation); + return g_failureDetailBuf; + } + snprintf(g_failureDetailBuf, sizeof(g_failureDetailBuf), + "%s: HTTP 404 (upload rejected; server may require book to be known)", lastOperation); + return g_failureDetailBuf; + } snprintf(g_failureDetailBuf, sizeof(g_failureDetailBuf), "%s: HTTP %d", lastOperation, lastHttpCode); return g_failureDetailBuf; } diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index baf492f8..8a5047e6 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -39,7 +39,8 @@ void logReaderMemSnapshot(const char* stage) { LOG_DBG("ERS", "Reader mem[%s]: free=%lu contig=%lu", stage, freeHeap, contigHeap); } -bool writeReaderProgressCache(const std::string& cachePath, const int spineIndex, const int currentPage, const int pageCount) { +bool writeReaderProgressCache(const std::string& cachePath, const int spineIndex, const int currentPage, + const int pageCount) { FsFile f; if (!Storage.openFileForWrite("ERS", cachePath + "/progress.bin", f)) { LOG_ERR("ERS", "Failed to open progress cache for sync restore: %s", cachePath.c_str()); @@ -566,7 +567,18 @@ void EpubReaderActivity::applyPendingSyncSession() { return; } - LOG_DBG("ERS", "Applying pending sync session outcome=%d path=%s", static_cast(sync.outcome), sync.epubPath.c_str()); + LOG_DBG("ERS", "Applying pending sync session outcome=%d path=%s", static_cast(sync.outcome), + sync.epubPath.c_str()); + + // Upload-complete returns to the same local position the reader already persisted + // before sync launched, so there is no need to rewrite progress.bin here. + if (sync.outcome == KOReaderSyncOutcomeState::UPLOAD_COMPLETE) { + LOG_DBG("ERS", "Upload-complete resume keeps existing local progress.bin unchanged"); + sync.clear(); + APP_STATE.saveToFile(); + logReaderMemSnapshot("after_apply_pending_sync_session"); + return; + } int restoreSpineIndex = sync.spineIndex; int restorePage = sync.page; @@ -578,8 +590,8 @@ void EpubReaderActivity::applyPendingSyncSession() { restorePage = sync.resultPage; pendingParagraphLookup = sync.resultHasParagraphIndex; pendingParagraphIndex = sync.resultParagraphIndex; - LOG_DBG("ERS", "Applied synced remote position: spine=%d page=%d paragraph=%u hasParagraph=%s", - restoreSpineIndex, restorePage, pendingParagraphIndex, pendingParagraphLookup ? "yes" : "no"); + LOG_DBG("ERS", "Applied synced remote position: spine=%d page=%d paragraph=%u hasParagraph=%s", restoreSpineIndex, + restorePage, pendingParagraphIndex, pendingParagraphLookup ? "yes" : "no"); } else { LOG_DBG("ERS", "Restored local pre-sync position: spine=%d page=%d paragraph=%u hasParagraph=%s", restoreSpineIndex, restorePage, pendingParagraphIndex, pendingParagraphLookup ? "yes" : "no"); diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 07cb710a..8e23dd18 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -71,6 +71,10 @@ class EpubReaderActivity final : public Activity { void jumpToPercent(int percent); void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action); void launchKOReaderSync(SyncLaunchMode mode = SyncLaunchMode::COMPARE); + // Consume a persisted standalone KOReader sync session for this EPUB. Remote + // apply writes the mapped reopen position into progress.bin before the normal + // reader startup path reads it. Upload-complete leaves the existing local + // progress.bin untouched and simply clears the pending session marker. void applyPendingSyncSession(); void applyOrientation(uint8_t orientation); void applyTextDarkness(uint8_t textDarkness); diff --git a/src/activities/reader/KOReaderSyncActivity.cpp b/src/activities/reader/KOReaderSyncActivity.cpp index 496c5423..871a7630 100644 --- a/src/activities/reader/KOReaderSyncActivity.cpp +++ b/src/activities/reader/KOReaderSyncActivity.cpp @@ -29,9 +29,10 @@ void logSyncMemSnapshot(const char* stage) { integrityOk ? "ok" : "fail"); } -// Frees renderer-owned caches right before network work. -// Why: TLS handshake needs a large contiguous block, and font cache memory can -// increase fragmentation even when total free heap looks acceptable. +// Frees renderer-owned caches inside the standalone sync activity right before +// network work. The reader activity is already gone by this point, but sync UI +// rendering (status popups, compare screen, result screen) can repopulate font +// caches and chip away at the largest free block needed for TLS. void trimMemoryBeforeTls(const GfxRenderer& renderer) { if (auto* cacheManager = renderer.getFontCacheManager()) { cacheManager->clearCache(); @@ -330,8 +331,8 @@ void KOReaderSyncActivity::performUpload() { return; } - // Result screen rendering repopulates glyph caches; trim again right before - // the upload handshake to maximize contiguous heap for TLS. + // Sync UI rendering can repopulate glyph caches after the initial GET / compare + // phase, so trim again right before the upload request. trimMemoryBeforeTls(renderer); logSyncMemSnapshot("after_trim_before_updateProgress"); diff --git a/src/activities/reader/KOReaderSyncActivity.h b/src/activities/reader/KOReaderSyncActivity.h index 06ac7bb5..6f827623 100644 --- a/src/activities/reader/KOReaderSyncActivity.h +++ b/src/activities/reader/KOReaderSyncActivity.h @@ -4,14 +4,19 @@ #include #include "ChapterXPathIndexer.h" +#include "CrossPointState.h" #include "KOReaderSyncClient.h" #include "ProgressMapper.h" -#include "CrossPointState.h" #include "activities/Activity.h" /** * Activity for syncing reading progress with KOReader sync server. * + * This activity is launched as a standalone replacement screen, not as a + * child activity of the reader. The reader persists a compact handoff record, + * is destroyed to reclaim memory before WiFi/TLS work begins, and a fresh + * reader instance is reopened after sync completes or is cancelled. + * * Shared pipeline: * 1. Connect to WiFi (if not connected) * 2. Optionally sync NTP (if stale) @@ -21,7 +26,7 @@ * - COMPARE: fetch remote progress, show full comparison screen, let user * choose Apply or Upload. * - PULL_REMOTE: fetch and map remote progress, show success feedback, then - * return applied SyncResult to reader. + * persist an applied SyncResult for the reopened reader. * - PUSH_LOCAL: compute local mapping, warm session with GET, then upload via * reused connection to avoid a second full TLS handshake. */ From 62ba6b8410e6b0d48c1c50c0d3a6d89ac8dad086 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Mon, 13 Apr 2026 20:33:05 +0200 Subject: [PATCH 5/7] Fix application of remote progress --- src/activities/reader/KOReaderSyncActivity.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/activities/reader/KOReaderSyncActivity.cpp b/src/activities/reader/KOReaderSyncActivity.cpp index 871a7630..05810e77 100644 --- a/src/activities/reader/KOReaderSyncActivity.cpp +++ b/src/activities/reader/KOReaderSyncActivity.cpp @@ -440,7 +440,10 @@ void KOReaderSyncActivity::resumeReader(const KOReaderSyncOutcomeState outcome, sync.resultPage = appliedResult->page; sync.resultParagraphIndex = appliedResult->paragraphIndex; sync.resultHasParagraphIndex = appliedResult->hasParagraphIndex; - } else { + } else if (outcome != KOReaderSyncOutcomeState::APPLIED_REMOTE) { + // Only zero the result fields when not resuming an already-applied remote + // position. The PULL_REMOTE path pre-saves the mapped result into APP_STATE + // before entering APPLY_COMPLETE; zeroing here would overwrite it. sync.resultSpineIndex = 0; sync.resultPage = 0; sync.resultParagraphIndex = 0; From d4a7c156f20c2e1a1eb5c4b1b56d78d8b961bc99 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Mon, 13 Apr 2026 20:42:51 +0200 Subject: [PATCH 6/7] Deal with out of bounds scenario --- src/activities/reader/EpubReaderActivity.cpp | 21 +++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 8a5047e6..3968753c 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -597,9 +597,16 @@ void EpubReaderActivity::applyPendingSyncSession() { restorePage, pendingParagraphIndex, pendingParagraphLookup ? "yes" : "no"); } - if (writeReaderProgressCache(epub->getCachePath(), restoreSpineIndex, restorePage, sync.totalPagesInSpine)) { + // sync.totalPagesInSpine is the page count of the local spine at launch time. + // When the restore targets a different spine, that count is meaningless for the + // rescaling logic in render() and can cause out-of-bounds pages (the estimated + // page number may exceed the local spine's count, producing progress > 1.0). + // Store 0 to disable rescaling; the paragraph lookup handles precise positioning. + const int restorePageCount = (restoreSpineIndex == sync.spineIndex) ? sync.totalPagesInSpine : 0; + + if (writeReaderProgressCache(epub->getCachePath(), restoreSpineIndex, restorePage, restorePageCount)) { cachedSpineIndex = restoreSpineIndex; - cachedChapterTotalPageCount = sync.totalPagesInSpine; + cachedChapterTotalPageCount = restorePageCount; LOG_DBG("ERS", "Prepared progress.bin for sync restore: spine=%d page=%d/%d", restoreSpineIndex, restorePage, sync.totalPagesInSpine); } else { @@ -607,7 +614,7 @@ void EpubReaderActivity::applyPendingSyncSession() { currentSpineIndex = restoreSpineIndex; nextPageNumber = restorePage; cachedSpineIndex = restoreSpineIndex; - cachedChapterTotalPageCount = sync.totalPagesInSpine; + cachedChapterTotalPageCount = restorePageCount; } sync.clear(); @@ -869,6 +876,14 @@ void EpubReaderActivity::render(RenderLock&& lock) { cachedChapterTotalPageCount = 0; // resets to 0 to prevent reading cached progress again } + // Safety clamp: estimated page numbers from sync or progress.bin may exceed + // the actual page count when the section was built with different settings or + // the estimate was based on a different spine's density. + if (section->pageCount > 0 && section->currentPage >= section->pageCount) { + LOG_DBG("ERS", "Clamping page %d to last page %d", section->currentPage, section->pageCount - 1); + section->currentPage = section->pageCount - 1; + } + if (pendingPercentJump && section->pageCount > 0) { // Apply the pending percent jump now that we know the new section's page count. int newPage = static_cast(pendingSpineProgress * static_cast(section->pageCount)); From 9b2f08809344e399dfad02707eddd28264c9d365 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Mon, 13 Apr 2026 20:47:38 +0200 Subject: [PATCH 7/7] yaclf --- src/activities/reader/ReaderActivity.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index ee7eb060..4c293b8e 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -136,7 +136,8 @@ void ReaderActivity::onEnter() { } if (APP_STATE.koReaderSyncSession.active && APP_STATE.koReaderSyncSession.epubPath == initialBookPath) { - LOG_DBG("READER", "Opening EPUB with pending KOReader sync outcome=%d", static_cast(APP_STATE.koReaderSyncSession.outcome)); + LOG_DBG("READER", "Opening EPUB with pending KOReader sync outcome=%d", + static_cast(APP_STATE.koReaderSyncSession.outcome)); } currentBookPath = initialBookPath;