From 291898bb7f1e83fa7bbe7d12c7a75bd6db71e672 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Mon, 27 Apr 2026 12:50:14 +0200 Subject: [PATCH 1/5] Add settings --- src/CrossPointSettings.h | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 75212422..8d0f7fbf 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -246,6 +246,10 @@ class CrossPointSettings { uint8_t imageDithering = IMAGE_DITHER_BAYER; // Enable synthetic TOC fallback for malformed/sparse TOC books (1 = enabled, 0 = disabled) uint8_t syntheticTocFallback = 1; + // Automatically push reading progress to the KOReader sync server when leaving the reader + // (1 = enabled, 0 = disabled). The push only fires when credentials are configured and the + // reader session advanced at least 3 pages, and is skipped when remote progress is already ahead. + uint8_t koSyncOnBookClose = 0; // Show clock in the reader status bar uint8_t statusBarClock = 0; // Clock format: 0 = 24h (14:00), 1 = 12h (2:00pm) From aed297b2b1d8e090e43235ed4049ce74b8244ff2 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Mon, 27 Apr 2026 15:30:43 +0200 Subject: [PATCH 2/5] First attempt Co-authored-by: Copilot --- lib/I18n/translations/english.yaml | 4 ++ src/CrossPointState.h | 14 ++++ src/JsonSettingsIO.cpp | 4 ++ src/SettingsList.h | 2 + src/activities/home/FileBrowserActivity.cpp | 15 ++++- src/activities/home/RecentBooksActivity.cpp | 14 +++- src/activities/reader/EpubReaderActivity.cpp | 46 +++++++++++++ src/activities/reader/EpubReaderActivity.h | 13 +++- .../reader/KOReaderSyncActivity.cpp | 64 +++++++++++++++++-- src/activities/reader/ReaderActivity.cpp | 33 ++++++++++ .../settings/KOReaderSettingsActivity.cpp | 21 +++--- 11 files changed, 213 insertions(+), 17 deletions(-) diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 5a929c21..3b001ead 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -164,6 +164,10 @@ STR_KOREADER_USERNAME: "KOReader Username" STR_KOREADER_PASSWORD: "KOReader Password" STR_FILENAME: "Filename" STR_BINARY: "Binary" +STR_MENU_KOSYNC_BEHAVIOR: "Sync Behavior" +STR_KO_SYNC_ON_BOOK_CLOSE: "Auto-Push on Book Close" +STR_KO_AUTO_SYNC_SKIPPED: "Remote ahead, skipping" +STR_KO_LONGPRESS_HINT: "Hold Confirm to open with sync" STR_SET_CREDENTIALS_FIRST: "Set credentials first" STR_WIFI_CONN_FAILED: "WiFi connection failed" STR_AUTHENTICATING: "Authenticating..." diff --git a/src/CrossPointState.h b/src/CrossPointState.h index e5079210..ee38d5eb 100644 --- a/src/CrossPointState.h +++ b/src/CrossPointState.h @@ -7,6 +7,11 @@ enum class KOReaderSyncIntentState : uint8_t { COMPARE = 0, PULL_REMOTE = 1, PUSH_LOCAL = 2, + // Auto variants compare progress before writing and skip silently when the other side + // is already ahead. AUTO_PUSH fires from the reader-close auto-sync path; AUTO_PULL fires + // when the user opens a book with long-press Confirm. Neither prompts the user. + AUTO_PUSH = 3, + AUTO_PULL = 4, }; enum class KOReaderSyncOutcomeState : uint8_t { @@ -47,6 +52,13 @@ struct KOReaderSyncSessionState { int resultPage = 0; uint16_t resultParagraphIndex = 0; bool resultHasParagraphIndex = false; + // When true (auto-push-on-close), the sync activity goes to home instead of the reader on + // completion. Without this, AUTO_PUSH would bounce back into the reader the user just left. + bool exitToHomeAfterSync = false; + // Set by RecentBooks / FileBrowser long-press to ask the reader to perform an AUTO_PULL + // before rendering its first page. Consumed once on reader entry. Stored separately from + // `intent` because the long-press path bypasses `launchKOReaderSync`'s reader-state capture. + bool autoPullOnOpen = false; void clear() { active = false; @@ -63,6 +75,8 @@ struct KOReaderSyncSessionState { resultPage = 0; resultParagraphIndex = 0; resultHasParagraphIndex = false; + exitToHomeAfterSync = false; + autoPullOnOpen = false; } }; diff --git a/src/JsonSettingsIO.cpp b/src/JsonSettingsIO.cpp index d095010b..28989d66 100644 --- a/src/JsonSettingsIO.cpp +++ b/src/JsonSettingsIO.cpp @@ -90,6 +90,8 @@ bool JsonSettingsIO::saveState(const CrossPointState& s, const char* path) { sync["resultPage"] = s.koReaderSyncSession.resultPage; sync["resultParagraphIndex"] = s.koReaderSyncSession.resultParagraphIndex; sync["resultHasParagraphIndex"] = s.koReaderSyncSession.resultHasParagraphIndex; + sync["exitToHomeAfterSync"] = s.koReaderSyncSession.exitToHomeAfterSync; + sync["autoPullOnOpen"] = s.koReaderSyncSession.autoPullOnOpen; // Information about a pending bookmark jump JsonObject jump = doc["pendingBookmarkJump"].to(); jump["active"] = s.pendingBookmarkJump.active; @@ -145,6 +147,8 @@ bool JsonSettingsIO::loadState(CrossPointState& s, const char* json) { s.koReaderSyncSession.resultPage = sync["resultPage"] | 0; s.koReaderSyncSession.resultParagraphIndex = sync["resultParagraphIndex"] | (uint16_t)0; s.koReaderSyncSession.resultHasParagraphIndex = sync["resultHasParagraphIndex"] | false; + s.koReaderSyncSession.exitToHomeAfterSync = sync["exitToHomeAfterSync"] | false; + s.koReaderSyncSession.autoPullOnOpen = sync["autoPullOnOpen"] | false; JsonObject jump = doc["pendingBookmarkJump"].as(); s.pendingBookmarkJump.active = jump["active"] | false; diff --git a/src/SettingsList.h b/src/SettingsList.h index f106470d..2f7d8860 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -268,6 +268,8 @@ inline const std::vector list = { KOREADER_STORE.saveToFile(); }, "koMatchMethod", StrId::STR_KOREADER_SYNC), + SettingInfo::Toggle(StrId::STR_KO_SYNC_ON_BOOK_CLOSE, &CrossPointSettings::koSyncOnBookClose, "koSyncOnBookClose", + StrId::STR_KOREADER_SYNC), // --- OPDS Browser (web-only, uses CrossPointSettings char arrays) --- SettingInfo::String(StrId::STR_OPDS_SERVER_URL, SETTINGS.opdsServerUrl, sizeof(SETTINGS.opdsServerUrl), diff --git a/src/activities/home/FileBrowserActivity.cpp b/src/activities/home/FileBrowserActivity.cpp index feb43aea..ba495a74 100644 --- a/src/activities/home/FileBrowserActivity.cpp +++ b/src/activities/home/FileBrowserActivity.cpp @@ -12,6 +12,8 @@ #include "../util/ConfirmationActivity.h" #include "BookInfoActivity.h" #include "CrossPointSettings.h" +#include "CrossPointState.h" +#include "KOReaderCredentialStore.h" #include "MappedInputManager.h" #include "components/UITheme.h" #include "fontIds.h" @@ -165,14 +167,17 @@ void FileBrowserActivity::loop() { return; } - // Confirm short press opens selected entry; long press does nothing - if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() < GO_HOME_MS) { + // Confirm short press opens selected entry; long press on a file opens it with KOReader sync. + // Long press on a directory is ignored (no useful directory-level sync action). + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (files.empty()) return; const std::string& entry = files[selectorIndex]; const bool isDirectory = (entry.back() == '/'); + const bool longPress = mappedInput.getHeldTime() >= GO_HOME_MS; if (isDirectory) { + if (longPress) return; if (basepath.back() != '/') basepath += "/"; basepath += entry.substr(0, entry.length() - 1); loadFiles(); @@ -182,6 +187,12 @@ void FileBrowserActivity::loop() { std::string fullPath = basepath; if (fullPath.back() != '/') fullPath += "/"; fullPath += entry; + if (longPress && KOREADER_STORE.hasCredentials()) { + auto& sync = APP_STATE.koReaderSyncSession; + sync.autoPullOnOpen = true; + sync.exitToHomeAfterSync = false; + APP_STATE.saveToFile(); + } ReturnHint hint; hint.target = ReturnTo::FileBrowser; hint.path = basepath; diff --git a/src/activities/home/RecentBooksActivity.cpp b/src/activities/home/RecentBooksActivity.cpp index 750546ad..827b7117 100644 --- a/src/activities/home/RecentBooksActivity.cpp +++ b/src/activities/home/RecentBooksActivity.cpp @@ -8,8 +8,11 @@ #include #include "../ActivityManager.h" +#include "../reader/ReaderUtils.h" #include "../util/ConfirmationActivity.h" #include "BookInfoActivity.h" +#include "CrossPointState.h" +#include "KOReaderCredentialStore.h" #include "MappedInputManager.h" #include "RecentBooksStore.h" #include "components/UITheme.h" @@ -53,7 +56,16 @@ void RecentBooksActivity::loop() { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) && !recentBooks.empty() && selectorIndex < static_cast(recentBooks.size())) { - LOG_DBG("RBA", "Selected recent book: %s", recentBooks[selectorIndex].path.c_str()); + // Long-press Confirm signals "open with KOReader sync": the reader will perform an + // AUTO_PULL before rendering its first page. Short-press is the unchanged direct open. + const bool longPress = mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS && KOREADER_STORE.hasCredentials(); + LOG_DBG("RBA", "Selected recent book: %s (sync=%d)", recentBooks[selectorIndex].path.c_str(), longPress ? 1 : 0); + if (longPress) { + auto& sync = APP_STATE.koReaderSyncSession; + sync.autoPullOnOpen = true; + sync.exitToHomeAfterSync = false; + APP_STATE.saveToFile(); + } ReturnHint hint; hint.target = ReturnTo::RecentBooks; hint.selectIndex = static_cast(selectorIndex); diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 26621ec8..2eaa2ec7 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -294,6 +294,7 @@ void EpubReaderActivity::loop() { // Long press BACK (1s+) goes to home screen if (mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS) { ReaderUtils::enforceExitFullRefresh(renderer); + if (tryAutoPushOnClose()) return; onGoHome(); return; } @@ -306,6 +307,7 @@ void EpubReaderActivity::loop() { return; } ReaderUtils::enforceExitFullRefresh(renderer); + if (tryAutoPushOnClose()) return; finish(); return; } @@ -318,6 +320,7 @@ void EpubReaderActivity::loop() { // At end of the book, forward button returns to caller and back button returns to last page if (currentSpineIndex > 0 && currentSpineIndex >= epub->getSpineItemsCount()) { if (nextTriggered) { + if (tryAutoPushOnClose()) return; finish(); } else { currentSpineIndex = epub->getSpineItemsCount() - 1; @@ -568,6 +571,7 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction break; } case EpubReaderMenuActivity::MenuAction::GO_HOME: { + if (tryAutoPushOnClose()) return; onGoHome(); return; } @@ -831,6 +835,8 @@ void EpubReaderActivity::launchKOReaderSync(const SyncLaunchMode mode) { syncIntent = KOReaderSyncIntentState::PULL_REMOTE; } else if (mode == SyncLaunchMode::PUSH_LOCAL) { syncIntent = KOReaderSyncIntentState::PUSH_LOCAL; + } else if (mode == SyncLaunchMode::AUTO_PUSH) { + syncIntent = KOReaderSyncIntentState::AUTO_PUSH; } auto& sync = APP_STATE.koReaderSyncSession; @@ -865,6 +871,10 @@ void EpubReaderActivity::launchKOReaderSync(const SyncLaunchMode mode) { sync.resultPage = 0; sync.resultParagraphIndex = 0; sync.resultHasParagraphIndex = false; + // Only auto-push-on-close should bypass the reader on resume; explicit syncs from the + // reader menu always come back to the reader. Reset here so a stale flag from a prior + // run cannot steal the user back to home. + sync.exitToHomeAfterSync = (mode == SyncLaunchMode::AUTO_PUSH); APP_STATE.saveToFile(); LOG_DBG("ERS", "Standalone sync handoff: spine=%d page=%d/%d", currentSpineIndex, currentPage, totalPages); @@ -872,6 +882,27 @@ void EpubReaderActivity::launchKOReaderSync(const SyncLaunchMode mode) { activityManager.goToKOReaderSync(); } +bool EpubReaderActivity::tryAutoPushOnClose() { + // Three-page minimum filters out brief inspections — opening to check the cover or + // skim the TOC shouldn't burn a network round-trip. Counter is per-activity-instance. + constexpr int MIN_SESSION_PAGES = 3; + if (!SETTINGS.koSyncOnBookClose) { + return false; + } + if (!KOREADER_STORE.hasCredentials()) { + return false; + } + if (sessionPagesAdvanced < MIN_SESSION_PAGES) { + return false; + } + if (!epub) { + return false; + } + // exitToHomeAfterSync flag is set inside launchKOReaderSync for AUTO_PUSH mode. + launchKOReaderSync(SyncLaunchMode::AUTO_PUSH); + return true; +} + void EpubReaderActivity::applyPendingSyncSession() { auto& sync = APP_STATE.koReaderSyncSession; if (!sync.active || !epub || sync.epubPath != epub->getPath()) { @@ -891,6 +922,17 @@ void EpubReaderActivity::applyPendingSyncSession() { return; } + // AUTO_PULL handed off zeroed local state (the reader was not yet running when sync started), + // so on cancel/fail we must NOT restore those zeros to progress.bin — they would clobber the + // user's real local progress. Just clear the session and let the normal startup load progress.bin. + if (sync.intent == KOReaderSyncIntentState::AUTO_PULL && sync.outcome != KOReaderSyncOutcomeState::APPLIED_REMOTE) { + LOG_DBG("ERS", "AUTO_PULL non-success outcome=%d: leaving progress.bin untouched", static_cast(sync.outcome)); + sync.clear(); + APP_STATE.saveToFile(); + logReaderMemSnapshot("after_apply_pending_sync_session"); + return; + } + int restoreSpineIndex = sync.spineIndex; int restorePage = sync.page; pendingParagraphLookup = sync.hasParagraphIndex; @@ -1142,6 +1184,9 @@ void EpubReaderActivity::pageTurn(bool isForwardTurn) { if (!stepPageState(isForwardTurn)) { return; } + // Track real progress within this session so auto-push-on-close can ignore brief + // book inspections. Counts both directions — the user is engaging with the book either way. + sessionPagesAdvanced++; requestUpdate(); } @@ -1888,6 +1933,7 @@ void EpubReaderActivity::onButtonAction(const CrossPointSettings::BUTTON_ACTION } case BA::BTN_EXIT_READER: ReaderUtils::enforceExitFullRefresh(renderer); + if (tryAutoPushOnClose()) break; finish(); break; case BA::BTN_READER_MENU: diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 42c8e332..811ae077 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -11,15 +11,17 @@ #include "activities/Activity.h" class EpubReaderActivity final : public Activity { - // Reader can launch sync in three UX modes: + // Reader can launch sync in several UX modes: // - COMPARE: legacy chooser (apply/upload) for power users. // - PULL_REMOTE / PUSH_LOCAL: direct one-step actions from menu entries. + // - AUTO_PUSH: silent push on reader exit; skips when remote is already ahead. // Keeping this split in the caller avoids branching on menu semantics deep // inside generic reader state handling. enum class SyncLaunchMode { COMPARE, PULL_REMOTE, PUSH_LOCAL, + AUTO_PUSH, }; std::shared_ptr epub; @@ -124,6 +126,10 @@ class EpubReaderActivity final : public Activity { bool skipNextButtonCheck = false; // Skip button processing for one frame after subactivity exit ReaderUtils::InputDrainGuard inputDrainGuard; bool automaticPageTurnActive = false; + // Pages turned in the current reader session. Used to gate auto-push-on-close: a brief + // inspection of a book should not trigger a network round-trip. Reset on every reader + // entry; not persisted, since "session" means the lifetime of this activity instance. + int sessionPagesAdvanced = 0; // -1 means use global SETTINGS value. int8_t bookEmbeddedStyleOverride = -1; int8_t bookImageRenderingOverride = -1; @@ -152,6 +158,11 @@ class EpubReaderActivity final : public Activity { void jumpToPercent(int percent); void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action); void launchKOReaderSync(SyncLaunchMode mode = SyncLaunchMode::COMPARE); + // Reader-close auto-push gate. Returns true if AUTO_PUSH was launched (the caller + // must not perform its own exit — the sync activity will route to home on completion). + // Returns false when any of the gates fails (setting off, no credentials, < 3 pages), + // letting the caller take its normal exit path. + bool tryAutoPushOnClose(); // 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 diff --git a/src/activities/reader/KOReaderSyncActivity.cpp b/src/activities/reader/KOReaderSyncActivity.cpp index 8d695369..ea15c253 100644 --- a/src/activities/reader/KOReaderSyncActivity.cpp +++ b/src/activities/reader/KOReaderSyncActivity.cpp @@ -115,8 +115,8 @@ void KOReaderSyncActivity::performSync() { LOG_DBG("KOSync", "Document hash: %s", documentHash.c_str()); // 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 != KOReaderSyncIntentState::PULL_REMOTE) { + // Pull-only modes can skip this expensive step and go straight to remote fetch. + if (syncIntent != KOReaderSyncIntentState::PULL_REMOTE && syncIntent != KOReaderSyncIntentState::AUTO_PULL) { // 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. @@ -141,7 +141,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 == KOReaderSyncIntentState::PUSH_LOCAL) { + if (syncIntent == KOReaderSyncIntentState::PUSH_LOCAL || syncIntent == KOReaderSyncIntentState::AUTO_PUSH) { // 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 @@ -164,6 +164,22 @@ void KOReaderSyncActivity::performSync() { requestUpdate(true); return; } + // Auto-push must not overwrite progress that is already further along on the server. + // Compare percentages from the warmup GET; users opted into automatic sync, so a + // remote-ahead state is treated as "nothing to do" and falls straight back to home. + if (syncIntent == KOReaderSyncIntentState::AUTO_PUSH && warmupResult == KOReaderSyncClient::OK && + warmupProgress.percentage > localProgress.percentage) { + LOG_DBG("KOSync", "AUTO_PUSH skipped: remote %.4f >= local %.4f", warmupProgress.percentage, + localProgress.percentage); + KOReaderSyncClient::endPersistentSession(); + HalClock::wifiOff(true); + // Reuse UPLOAD_COMPLETE outcome so the resume path is identical to a successful push; + // there is nothing to apply to the reader and progress.bin already reflects local state. + APP_STATE.koReaderSyncSession.outcome = KOReaderSyncOutcomeState::UPLOAD_COMPLETE; + APP_STATE.saveToFile(); + resumeReader(KOReaderSyncOutcomeState::UPLOAD_COMPLETE); + return; + } performUpload(); return; } @@ -195,6 +211,15 @@ void KOReaderSyncActivity::performSync() { return; } + if (syncIntent == KOReaderSyncIntentState::AUTO_PULL) { + // Auto-pull at book open: nothing to apply, just open the book with local progress. + // No user-visible failure since the user opted into "open with sync, if available". + KOReaderSyncClient::endPersistentSession(); + HalClock::wifiOff(true); + resumeReader(KOReaderSyncOutcomeState::CANCELLED); + return; + } + // Keep session open so an immediate upload can reuse the same connection. // No remote progress - offer to upload { @@ -234,10 +259,16 @@ void KOReaderSyncActivity::performSync() { remotePosition.hasParagraphIndex = false; remoteChapterLabel.clear(); - if (syncIntent == KOReaderSyncIntentState::PULL_REMOTE) { + if (syncIntent == KOReaderSyncIntentState::PULL_REMOTE || syncIntent == KOReaderSyncIntentState::AUTO_PULL) { // Pull intent applies immediately and exits. We bypass chooser UI to keep // reader menu actions deterministic ("pull" always means apply remote). if (!ensureRemotePositionMapped()) { + if (syncIntent == KOReaderSyncIntentState::AUTO_PULL) { + // Auto-pull was best-effort. Fail silently and just open the book. + HalClock::wifiOff(true); + resumeReader(KOReaderSyncOutcomeState::CANCELLED); + return; + } { RenderLock lock(*this); state = SYNC_FAILED; @@ -256,6 +287,14 @@ void KOReaderSyncActivity::performSync() { sync.resultParagraphIndex = remotePosition.paragraphIndex; sync.resultHasParagraphIndex = remotePosition.hasParagraphIndex; APP_STATE.saveToFile(); + + if (syncIntent == KOReaderSyncIntentState::AUTO_PULL) { + // Auto-pull skips the success-screen dwell — the reader will render the new + // position immediately, which is the only visible feedback the user needs. + HalClock::wifiOff(true); + resumeReader(KOReaderSyncOutcomeState::APPLIED_REMOTE); + return; + } { RenderLock lock(*this); state = APPLY_COMPLETE; @@ -390,6 +429,12 @@ void KOReaderSyncActivity::performUpload() { HalClock::wifiOff(true); APP_STATE.koReaderSyncSession.outcome = KOReaderSyncOutcomeState::UPLOAD_COMPLETE; APP_STATE.saveToFile(); + if (syncIntent == KOReaderSyncIntentState::AUTO_PUSH) { + // Auto-push doesn't need user acknowledgement on success; resume immediately + // back to the calling activity (RecentBooks / FileBrowser via reader). + resumeReader(KOReaderSyncOutcomeState::UPLOAD_COMPLETE); + return; + } { RenderLock lock(*this); state = UPLOAD_COMPLETE; @@ -464,8 +509,19 @@ void KOReaderSyncActivity::resumeReader(const KOReaderSyncOutcomeState outcome, sync.resultParagraphIndex = 0; sync.resultHasParagraphIndex = false; } + // Honor exit-to-home flag set by reader-close auto-sync — bouncing back into the reader + // the user just left would be jarring. The session state is consumed and cleared by the + // home destination's normal flow (no reader to apply it to in this case). + const bool exitToHome = sync.exitToHomeAfterSync; + if (exitToHome) { + sync.clear(); + } APP_STATE.saveToFile(); logSyncMemSnapshot("before_resume_reader"); + if (exitToHome) { + activityManager.goHome(); + return; + } activityManager.goToReader(epubPath); } diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index 06a5c05a..889246ed 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -11,6 +11,7 @@ #include "CrossPointState.h" #include "Epub.h" #include "EpubReaderActivity.h" +#include "KOReaderCredentialStore.h" #include "MdReaderActivity.h" #include "Txt.h" #include "TxtReaderActivity.h" @@ -110,6 +111,38 @@ void ReaderActivity::goToLibrary(const std::string& fromBookPath) { void ReaderActivity::onGoToEpubReader(std::unique_ptr epub) { const auto epubPath = epub->getPath(); currentBookPath = epubPath; + + // Long-press Confirm on RecentBooks/FileBrowser sets autoPullOnOpen so the user can ask + // for KOReader sync at open time. Route into the sync activity instead of creating the + // reader; sync's resumeReader() will create the reader once the remote position is applied. + // Pull-only mode does not need accurate local reader state, so we hand off zeros for spine/page. + auto& sync = APP_STATE.koReaderSyncSession; + if (sync.autoPullOnOpen && KOREADER_STORE.hasCredentials()) { + LOG_DBG("READER", "AUTO_PULL on open: %s", epubPath.c_str()); + sync.autoPullOnOpen = false; // consume the flag + sync.active = true; + sync.epubPath = epubPath; + sync.spineIndex = 0; + sync.page = 0; + sync.totalPagesInSpine = 0; + sync.paragraphIndex = 0; + sync.hasParagraphIndex = false; + sync.xhtmlSeekHint = 0; + sync.intent = KOReaderSyncIntentState::AUTO_PULL; + sync.outcome = KOReaderSyncOutcomeState::PENDING; + sync.resultSpineIndex = 0; + sync.resultPage = 0; + sync.resultParagraphIndex = 0; + sync.resultHasParagraphIndex = false; + sync.exitToHomeAfterSync = false; + APP_STATE.saveToFile(); + // Drop the loaded Epub before TLS — sync activity will reload it for remote-position + // mapping. Holding it here would needlessly inflate the heap during WiFi/TLS work. + epub.reset(); + activityManager.goToKOReaderSync(); + return; + } + logReaderLaunchMemSnapshot("before_replace_epub_reader"); activityManager.replaceActivity(std::make_unique(renderer, mappedInput, std::move(epub))); } diff --git a/src/activities/settings/KOReaderSettingsActivity.cpp b/src/activities/settings/KOReaderSettingsActivity.cpp index 2681be27..091e1020 100644 --- a/src/activities/settings/KOReaderSettingsActivity.cpp +++ b/src/activities/settings/KOReaderSettingsActivity.cpp @@ -3,6 +3,7 @@ #include #include +#include "CrossPointSettings.h" #include "KOReaderAuthActivity.h" #include "KOReaderCredentialStore.h" #include "MappedInputManager.h" @@ -16,7 +17,7 @@ KOReaderSettingsActivity::KOReaderSettingsActivity(GfxRenderer& renderer, Mapped } void KOReaderSettingsActivity::buildMenuItems() { - menuItems.reserve(6); + menuItems.reserve(7); // Username, Password, Server URL: ACTION items with custom value display menuItems.push_back(SettingInfo::Action(StrId::STR_SYNC_SERVER_URL, SettingAction::None) .withSubcategory(StrId::STR_MENU_KOSYNC_SERVER)); @@ -24,14 +25,16 @@ void KOReaderSettingsActivity::buildMenuItems() { menuItems.push_back(SettingInfo::Action(StrId::STR_PASSWORD, SettingAction::None)); // Document matching: DynamicEnum toggling between Filename and Binary - menuItems.push_back(SettingInfo::DynamicEnum(StrId::STR_DOCUMENT_MATCHING, {StrId::STR_FILENAME, StrId::STR_BINARY}, - static_cast([](const void*) -> uint8_t { - return static_cast(KOREADER_STORE.getMatchMethod()); - }), - [](void*, uint8_t v) { - KOREADER_STORE.setMatchMethod(static_cast(v)); - KOREADER_STORE.saveToFile(); - })); + menuItems.push_back(SettingInfo::DynamicEnum( + StrId::STR_DOCUMENT_MATCHING, {StrId::STR_FILENAME, StrId::STR_BINARY}, + [](const void*) -> uint8_t { return static_cast(KOREADER_STORE.getMatchMethod()); }, + [](void*, uint8_t v) { + KOREADER_STORE.setMatchMethod(static_cast(v)); + KOREADER_STORE.saveToFile(); + }) + .withSubcategory(StrId::STR_MENU_KOSYNC_BEHAVIOR)); + menuItems.push_back(SettingInfo::Toggle(StrId::STR_KO_SYNC_ON_BOOK_CLOSE, &CrossPointSettings::koSyncOnBookClose, + "koSyncOnBookClose")); // Authenticate and Register: ACTION items menuItems.push_back( From 066ec3d56b04da87aed8a9a27b91550c1a57b72d Mon Sep 17 00:00:00 2001 From: jpirnay Date: Mon, 27 Apr 2026 18:45:16 +0200 Subject: [PATCH 3/5] Fixes Co-authored-by: Copilot --- src/CrossPointState.h | 7 +++---- src/JsonSettingsIO.cpp | 7 +++++-- src/activities/home/FileBrowserActivity.cpp | 4 ++-- src/activities/home/RecentBooksActivity.cpp | 13 ++++++++----- src/activities/reader/EpubReaderActivity.cpp | 8 ++++++++ src/activities/reader/ReaderActivity.cpp | 8 ++++---- 6 files changed, 30 insertions(+), 17 deletions(-) diff --git a/src/CrossPointState.h b/src/CrossPointState.h index ee38d5eb..d7d58c1d 100644 --- a/src/CrossPointState.h +++ b/src/CrossPointState.h @@ -56,9 +56,8 @@ struct KOReaderSyncSessionState { // completion. Without this, AUTO_PUSH would bounce back into the reader the user just left. bool exitToHomeAfterSync = false; // Set by RecentBooks / FileBrowser long-press to ask the reader to perform an AUTO_PULL - // before rendering its first page. Consumed once on reader entry. Stored separately from - // `intent` because the long-press path bypasses `launchKOReaderSync`'s reader-state capture. - bool autoPullOnOpen = false; + // before rendering its first page. Stored by EPUB path so the flag cannot leak across books. + std::string autoPullEpubPath; void clear() { active = false; @@ -76,7 +75,7 @@ struct KOReaderSyncSessionState { resultParagraphIndex = 0; resultHasParagraphIndex = false; exitToHomeAfterSync = false; - autoPullOnOpen = false; + autoPullEpubPath.clear(); } }; diff --git a/src/JsonSettingsIO.cpp b/src/JsonSettingsIO.cpp index 28989d66..d079e189 100644 --- a/src/JsonSettingsIO.cpp +++ b/src/JsonSettingsIO.cpp @@ -91,7 +91,7 @@ bool JsonSettingsIO::saveState(const CrossPointState& s, const char* path) { sync["resultParagraphIndex"] = s.koReaderSyncSession.resultParagraphIndex; sync["resultHasParagraphIndex"] = s.koReaderSyncSession.resultHasParagraphIndex; sync["exitToHomeAfterSync"] = s.koReaderSyncSession.exitToHomeAfterSync; - sync["autoPullOnOpen"] = s.koReaderSyncSession.autoPullOnOpen; + sync["autoPullEpubPath"] = s.koReaderSyncSession.autoPullEpubPath; // Information about a pending bookmark jump JsonObject jump = doc["pendingBookmarkJump"].to(); jump["active"] = s.pendingBookmarkJump.active; @@ -148,7 +148,10 @@ bool JsonSettingsIO::loadState(CrossPointState& s, const char* json) { s.koReaderSyncSession.resultParagraphIndex = sync["resultParagraphIndex"] | (uint16_t)0; s.koReaderSyncSession.resultHasParagraphIndex = sync["resultHasParagraphIndex"] | false; s.koReaderSyncSession.exitToHomeAfterSync = sync["exitToHomeAfterSync"] | false; - s.koReaderSyncSession.autoPullOnOpen = sync["autoPullOnOpen"] | false; + s.koReaderSyncSession.autoPullEpubPath = sync["autoPullEpubPath"] | std::string(""); + if (s.koReaderSyncSession.autoPullEpubPath.empty() && (sync["autoPullOnOpen"] | false)) { + LOG_DBG("CPS", "Legacy autoPullOnOpen state found without epubPath - ignoring"); + } JsonObject jump = doc["pendingBookmarkJump"].as(); s.pendingBookmarkJump.active = jump["active"] | false; diff --git a/src/activities/home/FileBrowserActivity.cpp b/src/activities/home/FileBrowserActivity.cpp index ba495a74..0e537524 100644 --- a/src/activities/home/FileBrowserActivity.cpp +++ b/src/activities/home/FileBrowserActivity.cpp @@ -187,9 +187,9 @@ void FileBrowserActivity::loop() { std::string fullPath = basepath; if (fullPath.back() != '/') fullPath += "/"; fullPath += entry; - if (longPress && KOREADER_STORE.hasCredentials()) { + if (longPress && KOREADER_STORE.hasCredentials() && FsHelpers::hasEpubExtension(fullPath)) { auto& sync = APP_STATE.koReaderSyncSession; - sync.autoPullOnOpen = true; + sync.autoPullEpubPath = fullPath; sync.exitToHomeAfterSync = false; APP_STATE.saveToFile(); } diff --git a/src/activities/home/RecentBooksActivity.cpp b/src/activities/home/RecentBooksActivity.cpp index 827b7117..b2125f67 100644 --- a/src/activities/home/RecentBooksActivity.cpp +++ b/src/activities/home/RecentBooksActivity.cpp @@ -56,13 +56,16 @@ void RecentBooksActivity::loop() { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) && !recentBooks.empty() && selectorIndex < static_cast(recentBooks.size())) { - // Long-press Confirm signals "open with KOReader sync": the reader will perform an - // AUTO_PULL before rendering its first page. Short-press is the unchanged direct open. + // Long-press Confirm signals "open with KOReader sync" only for EPUBs. + // Short-press is unchanged direct open. const bool longPress = mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS && KOREADER_STORE.hasCredentials(); - LOG_DBG("RBA", "Selected recent book: %s (sync=%d)", recentBooks[selectorIndex].path.c_str(), longPress ? 1 : 0); - if (longPress) { + const std::string& selectedPath = recentBooks[selectorIndex].path; + const bool isEpubBook = FsHelpers::hasEpubExtension(selectedPath); + LOG_DBG("RBA", "Selected recent book: %s (sync=%d epub=%d)", selectedPath.c_str(), longPress ? 1 : 0, + isEpubBook ? 1 : 0); + if (longPress && isEpubBook) { auto& sync = APP_STATE.koReaderSyncSession; - sync.autoPullOnOpen = true; + sync.autoPullEpubPath = selectedPath; sync.exitToHomeAfterSync = false; APP_STATE.saveToFile(); } diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 2eaa2ec7..5f70f5bf 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -898,6 +898,14 @@ bool EpubReaderActivity::tryAutoPushOnClose() { if (!epub) { return false; } + + const int spineCount = epub->getSpineItemsCount(); + if (spineCount == 0 || currentSpineIndex >= spineCount || !section) { + LOG_DBG("ERS", "Skipping AUTO_PUSH on end-of-book sentinel: spine=%d section=%s", currentSpineIndex, + section ? "present" : "null"); + return false; + } + // exitToHomeAfterSync flag is set inside launchKOReaderSync for AUTO_PUSH mode. launchKOReaderSync(SyncLaunchMode::AUTO_PUSH); return true; diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index 889246ed..0a7a045e 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -112,14 +112,14 @@ void ReaderActivity::onGoToEpubReader(std::unique_ptr epub) { const auto epubPath = epub->getPath(); currentBookPath = epubPath; - // Long-press Confirm on RecentBooks/FileBrowser sets autoPullOnOpen so the user can ask - // for KOReader sync at open time. Route into the sync activity instead of creating the + // Long-press Confirm on RecentBooks/FileBrowser sets autoPullEpubPath so the user can + // ask for KOReader sync at open time. Route into the sync activity instead of creating the // reader; sync's resumeReader() will create the reader once the remote position is applied. // Pull-only mode does not need accurate local reader state, so we hand off zeros for spine/page. auto& sync = APP_STATE.koReaderSyncSession; - if (sync.autoPullOnOpen && KOREADER_STORE.hasCredentials()) { + if (!sync.autoPullEpubPath.empty() && sync.autoPullEpubPath == epubPath && KOREADER_STORE.hasCredentials()) { LOG_DBG("READER", "AUTO_PULL on open: %s", epubPath.c_str()); - sync.autoPullOnOpen = false; // consume the flag + sync.autoPullEpubPath.clear(); // consume the flag sync.active = true; sync.epubPath = epubPath; sync.spineIndex = 0; From 1f6ddddd5929e61f7e239462c2b44097bfa3ac1b Mon Sep 17 00:00:00 2001 From: jpirnay Date: Mon, 27 Apr 2026 20:56:06 +0200 Subject: [PATCH 4/5] Use new method to decide button press --- src/activities/home/RecentBooksActivity.cpp | 124 ++++++++++---------- 1 file changed, 65 insertions(+), 59 deletions(-) diff --git a/src/activities/home/RecentBooksActivity.cpp b/src/activities/home/RecentBooksActivity.cpp index b2125f67..1102ec5a 100644 --- a/src/activities/home/RecentBooksActivity.cpp +++ b/src/activities/home/RecentBooksActivity.cpp @@ -8,7 +8,6 @@ #include #include "../ActivityManager.h" -#include "../reader/ReaderUtils.h" #include "../util/ConfirmationActivity.h" #include "BookInfoActivity.h" #include "CrossPointState.h" @@ -54,67 +53,74 @@ void RecentBooksActivity::onExit() { void RecentBooksActivity::loop() { const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, true); - if (mappedInput.wasReleased(MappedInputManager::Button::Confirm) && !recentBooks.empty() && - selectorIndex < static_cast(recentBooks.size())) { - // Long-press Confirm signals "open with KOReader sync" only for EPUBs. - // Short-press is unchanged direct open. - const bool longPress = mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS && KOREADER_STORE.hasCredentials(); - const std::string& selectedPath = recentBooks[selectorIndex].path; - const bool isEpubBook = FsHelpers::hasEpubExtension(selectedPath); - LOG_DBG("RBA", "Selected recent book: %s (sync=%d epub=%d)", selectedPath.c_str(), longPress ? 1 : 0, - isEpubBook ? 1 : 0); - if (longPress && isEpubBook) { - auto& sync = APP_STATE.koReaderSyncSession; - sync.autoPullEpubPath = selectedPath; - sync.exitToHomeAfterSync = false; - APP_STATE.saveToFile(); - } - ReturnHint hint; - hint.target = ReturnTo::RecentBooks; - hint.selectIndex = static_cast(selectorIndex); - activityManager.replaceWithReader(recentBooks[selectorIndex].path, std::move(hint)); - return; - } - - if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { - onGoHome(); - } - - // Left button: remove selected book from recent list - if (!recentBooks.empty() && selectorIndex < recentBooks.size() && - mappedInput.wasReleased(MappedInputManager::Button::Left)) { - const std::string bookPath = recentBooks[selectorIndex].path; - const std::string bookTitle = recentBooks[selectorIndex].title; - - auto handler = [this, bookPath](const ActivityResult& res) { - if (!res.isCancelled) { - LOG_DBG("RBA", "Removing from recent books: %s", bookPath.c_str()); - RECENT_BOOKS.removeBook(bookPath); - loadRecentBooks(); - if (recentBooks.empty()) { - selectorIndex = 0; - } else if (selectorIndex >= recentBooks.size()) { - selectorIndex = recentBooks.size() - 1; - } - requestUpdate(true); - } else { - LOG_DBG("RBA", "Remove cancelled by user"); + ButtonEventManager::ButtonEvent ev; + while (buttonEvents.consumeEvent(ev)) { + if (ev.button == MappedInputManager::Button::Confirm && + (ev.type == ButtonEventManager::PressType::Short || ev.type == ButtonEventManager::PressType::Long)) { + if (recentBooks.empty() || selectorIndex >= recentBooks.size()) { + return; } - }; - - std::string heading = tr(STR_REMOVE) + std::string("? "); - startActivityForResult(std::make_unique(renderer, mappedInput, heading, bookTitle), handler); - return; - } - - if (mappedInput.wasReleased(MappedInputManager::Button::Right) && !recentBooks.empty() && - selectorIndex < static_cast(recentBooks.size())) { - const std::string& path = recentBooks[selectorIndex].path; - if (FsHelpers::hasEpubExtension(path) || FsHelpers::hasXtcExtension(path)) { - startActivityForResult(std::make_unique(renderer, mappedInput, path), - [this](const ActivityResult&) { requestUpdate(); }); + // Long-press Confirm signals "open with KOReader sync" only for EPUBs. + // Short-press is unchanged direct open. + const bool longPress = (ev.type == ButtonEventManager::PressType::Long) && KOREADER_STORE.hasCredentials(); + const std::string& selectedPath = recentBooks[selectorIndex].path; + const bool isEpubBook = FsHelpers::hasEpubExtension(selectedPath); + LOG_DBG("RBA", "Selected recent book: %s (sync=%d epub=%d)", selectedPath.c_str(), longPress ? 1 : 0, + isEpubBook ? 1 : 0); + if (longPress && isEpubBook) { + auto& sync = APP_STATE.koReaderSyncSession; + sync.autoPullEpubPath = selectedPath; + sync.exitToHomeAfterSync = false; + APP_STATE.saveToFile(); + } + ReturnHint hint; + hint.target = ReturnTo::RecentBooks; + hint.selectIndex = static_cast(selectorIndex); + activityManager.replaceWithReader(recentBooks[selectorIndex].path, std::move(hint)); return; } + + if (ev.button == MappedInputManager::Button::Back && ev.type == ButtonEventManager::PressType::Short) { + onGoHome(); + return; + } + + if (ev.button == MappedInputManager::Button::Left && ev.type == ButtonEventManager::PressType::Short) { + if (recentBooks.empty() || selectorIndex >= recentBooks.size()) return; + const std::string bookPath = recentBooks[selectorIndex].path; + const std::string bookTitle = recentBooks[selectorIndex].title; + + auto handler = [this, bookPath](const ActivityResult& res) { + if (!res.isCancelled) { + LOG_DBG("RBA", "Removing from recent books: %s", bookPath.c_str()); + RECENT_BOOKS.removeBook(bookPath); + loadRecentBooks(); + if (recentBooks.empty()) { + selectorIndex = 0; + } else if (selectorIndex >= recentBooks.size()) { + selectorIndex = recentBooks.size() - 1; + } + requestUpdate(true); + } else { + LOG_DBG("RBA", "Remove cancelled by user"); + } + }; + + std::string heading = tr(STR_REMOVE) + std::string("? "); + startActivityForResult(std::make_unique(renderer, mappedInput, heading, bookTitle), + handler); + return; + } + + if (ev.button == MappedInputManager::Button::Right && ev.type == ButtonEventManager::PressType::Short) { + if (recentBooks.empty() || selectorIndex >= recentBooks.size()) return; + const std::string& path = recentBooks[selectorIndex].path; + if (FsHelpers::hasEpubExtension(path) || FsHelpers::hasXtcExtension(path)) { + startActivityForResult(std::make_unique(renderer, mappedInput, path), + [this](const ActivityResult&) { requestUpdate(); }); + return; + } + } } int listSize = static_cast(recentBooks.size()); From bc571771fcc242df0e93e3c3df55189d2d479579 Mon Sep 17 00:00:00 2001 From: jpirnay Date: Mon, 27 Apr 2026 21:14:35 +0200 Subject: [PATCH 5/5] properly separate menus --- src/activities/MenuListActivity.cpp | 1 + src/activities/settings/SettingInfo.h | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/activities/MenuListActivity.cpp b/src/activities/MenuListActivity.cpp index d3b919b5..46222c19 100644 --- a/src/activities/MenuListActivity.cpp +++ b/src/activities/MenuListActivity.cpp @@ -19,6 +19,7 @@ void MenuListActivity::onEnter() { Activity::onEnter(); if (!submenusPrepared) { prepareSubmenus(); + SettingInfo::insertSubcategorySeparators(menuItems); submenusPrepared = true; } initMenuList(); diff --git a/src/activities/settings/SettingInfo.h b/src/activities/settings/SettingInfo.h index 489673ae..9dd105c2 100644 --- a/src/activities/settings/SettingInfo.h +++ b/src/activities/settings/SettingInfo.h @@ -97,6 +97,12 @@ struct SettingInfo { static void prepareSubmenus(std::vector& items, std::vector& submenuData); + // Walks `items` and inserts a SettingInfo::Separator before each item whose subcategory + // differs from the previous one — same rule used by SettingsActivity for the main tabs. + // Existing separator rows preserve their own nameId as the running subcategory so a + // manually placed Separator suppresses an immediate auto-insert for the same group. + static void insertSubcategorySeparators(std::vector& items); + SettingInfo& withObfuscated() { obfuscated = true; return *this; @@ -286,3 +292,23 @@ inline void SettingInfo::prepareSubmenus(std::vector& items, } } } + +inline void SettingInfo::insertSubcategorySeparators(std::vector& items) { + if (items.empty()) return; + std::vector out; + out.reserve(items.size() + 4); + StrId lastSub = StrId::STR_NONE_OPT; + for (auto& item : items) { + if (item.isSeparator) { + lastSub = item.nameId; + out.push_back(std::move(item)); + continue; + } + if (item.subcategory != StrId::STR_NONE_OPT && item.subcategory != lastSub) { + out.push_back(SettingInfo::Separator(item.subcategory)); + lastSub = item.subcategory; + } + out.push_back(std::move(item)); + } + items.swap(out); +}