First attempt

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
jpirnay
2026-04-27 15:30:43 +02:00
co-authored by Copilot
parent 291898bb7f
commit aed297b2b1
11 changed files with 213 additions and 17 deletions
+14
View File
@@ -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;
}
};
+4
View File
@@ -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<JsonObject>();
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<JsonObject>();
s.pendingBookmarkJump.active = jump["active"] | false;
+2
View File
@@ -268,6 +268,8 @@ inline const std::vector<SettingInfo> 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),
+13 -2
View File
@@ -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;
+13 -1
View File
@@ -8,8 +8,11 @@
#include <algorithm>
#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<int>(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<int>(selectorIndex);
@@ -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<int>(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:
+12 -1
View File
@@ -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> 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
+60 -4
View File
@@ -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);
}
+33
View File
@@ -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> 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<EpubReaderActivity>(renderer, mappedInput, std::move(epub)));
}
@@ -3,6 +3,7 @@
#include <GfxRenderer.h>
#include <I18n.h>
#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<SettingInfo::ValueGetterFn>([](const void*) -> uint8_t {
return static_cast<uint8_t>(KOREADER_STORE.getMatchMethod());
}),
[](void*, uint8_t v) {
KOREADER_STORE.setMatchMethod(static_cast<DocumentMatchMethod>(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<uint8_t>(KOREADER_STORE.getMatchMethod()); },
[](void*, uint8_t v) {
KOREADER_STORE.setMatchMethod(static_cast<DocumentMatchMethod>(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(