feat: add smart KOReader progress sync (#2192)

## Summary

Make KOReader progress sync a one-click flow for common cases while
keeping the existing manual mode available.

### Changes

- Add a **Sync Behavior** setting:
  - **Smart sync** for new configurations
  - **Ask every time** for manual control
- Preserve **Ask every time** when migrating an existing credential file
that predates this setting.
- In Smart mode:
  - upload local progress when no remote record exists;
  - show a short confirmation when already synced;
  - upload when local progress is further ahead;
  - apply remote progress when remote progress is further ahead.
- Probe both KOReader document-matching hashes before making the Smart
decision, while keeping uploads on the user's configured matching
method.
- Auto-return after successful Smart terminal states, with Back/Confirm
still available.
- Persist the setting alongside the current credential, matching-method,
and send-metadata fields.
- Document both behaviors in the user guide.

## Additional context

This pairs well with #2189 (smart Wi-Fi auto-connect), but does not
depend on it. It does not add background Wi-Fi or passive network
detection; sync is still triggered by the user from the reader menu.

Smart sync uses the furthest progress because CrossPoint does not
currently persist local progress timestamps. Users who prefer explicit
conflict resolution can select **Ask every time**.

## Verification

- `git diff --check`
- `platformio check --fail-on-defect low --fail-on-defect medium
--fail-on-defect high` — no defects
- `platformio run -e default` — firmware build successful
- The original implementation was manually smoke-tested on an X4 device.

---

### AI Usage

Did you use AI tools to help write this code? _**YES**_

AI tools were used to inspect the codebase, draft and review the
implementation, resolve the rebase against current `develop`, and
prepare the PR text. The resulting firmware was built locally.

---------

Co-authored-by: Alexander Hoffer <git@alexanderhoffer.com>
Co-authored-by: Alexander Hoffer <contact@alexanderhoffer.com>
This commit is contained in:
Alexander Hoff ❍
2026-07-15 14:59:15 -04:00
committed by GitHub
co-authored by Alexander Hoffer Alexander Hoffer
parent 35a45f9c1e
commit a4ac3b2788
8 changed files with 177 additions and 26 deletions
+8
View File
@@ -242,6 +242,14 @@ inline std::vector<SettingInfo> getSettingsList(const SdCardFontRegistry* regist
KOREADER_STORE.saveToFile();
},
"koSendMetadata", StrId::STR_KOREADER_SYNC),
SettingInfo::DynamicEnum(
StrId::STR_SYNC_BEHAVIOR, {StrId::STR_ASK_EVERY_TIME, StrId::STR_SMART_SYNC},
[] { return static_cast<uint8_t>(KOREADER_STORE.getSyncBehavior()); },
[](uint8_t v) {
KOREADER_STORE.setSyncBehavior(static_cast<KOReaderSyncBehavior>(v));
KOREADER_STORE.saveToFile();
},
"koSyncBehavior", StrId::STR_KOREADER_SYNC),
// --- Status Bar Settings (web-only, uses StatusBarSettingsActivity) ---
SettingInfo::Toggle(StrId::STR_CHAPTER_PAGE_COUNT, &CrossPointSettings::statusBarChapterPageCount,
"statusBarChapterPageCount", StrId::STR_CUSTOMISE_STATUS_BAR),
+103 -14
View File
@@ -10,6 +10,7 @@
#include <algorithm>
#include <cassert>
#include <cmath>
#include "Epub/Section.h"
#include "EpubReaderUtils.h"
@@ -24,6 +25,19 @@
#include "fontIds.h"
namespace {
std::string calculateDocumentHashForMethod(const std::string& path, const DocumentMatchMethod method) {
return method == DocumentMatchMethod::FILENAME ? KOReaderDocumentId::calculateFromFilename(path)
: KOReaderDocumentId::calculate(path);
}
DocumentMatchMethod alternateMatchMethod(const DocumentMatchMethod method) {
return method == DocumentMatchMethod::FILENAME ? DocumentMatchMethod::BINARY : DocumentMatchMethod::FILENAME;
}
const char* matchMethodName(const DocumentMatchMethod method) {
return method == DocumentMatchMethod::FILENAME ? "filename" : "binary";
}
void syncTimeWithNTP() {
// Stop SNTP if already running (can't reconfigure while running)
if (esp_sntp_enabled()) {
@@ -84,6 +98,21 @@ void KOReaderSyncActivity::saveProgressAndReturn(int spineIndex, int page) {
void KOReaderSyncActivity::returnToReader() { activityManager.goToReader(epubPath); }
bool KOReaderSyncActivity::smartSyncEnabled() const {
return KOREADER_STORE.getSyncBehavior() == KOReaderSyncBehavior::SMART;
}
void KOReaderSyncActivity::markAutoReturn() { autoReturnAt = millis() + AUTO_RETURN_DELAY_MS; }
void KOReaderSyncActivity::completeAlreadySynced() {
{
RenderLock lock(*this);
state = SYNC_COMPLETE;
}
markAutoReturn();
requestUpdate(true);
}
void KOReaderSyncActivity::onWifiSelectionComplete(const bool success) {
if (!success) {
LOG_DBG("KOSync", "WiFi connection failed, exiting");
@@ -113,12 +142,8 @@ void KOReaderSyncActivity::onWifiSelectionComplete(const bool success) {
}
void KOReaderSyncActivity::performSync() {
// Calculate document hash based on user's preferred method
if (KOREADER_STORE.getMatchMethod() == DocumentMatchMethod::FILENAME) {
documentHash = KOReaderDocumentId::calculateFromFilename(epubPath);
} else {
documentHash = KOReaderDocumentId::calculate(epubPath);
}
const DocumentMatchMethod primaryMethod = KOREADER_STORE.getMatchMethod();
documentHash = calculateDocumentHashForMethod(epubPath, primaryMethod);
if (documentHash.empty()) {
{
RenderLock lock(*this);
@@ -128,8 +153,9 @@ void KOReaderSyncActivity::performSync() {
requestUpdate(true);
return;
}
const std::string primaryHash = documentHash;
LOG_DBG("KOSync", "Document hash: %s", documentHash.c_str());
LOG_DBG("KOSync", "Document hash (%s): %s", matchMethodName(primaryMethod), documentHash.c_str());
{
RenderLock lock(*this);
@@ -137,10 +163,42 @@ void KOReaderSyncActivity::performSync() {
}
requestUpdateAndWait();
// Fetch remote progress
const auto result = KOReaderSyncClient::getProgress(documentHash, remoteProgress);
// Fetch remote progress. In smart mode, also probe the alternate document-id
// method and use the furthest remote state we can find. This avoids a stale
// local upload when another KOReader device synced the same book with a
// different document matching method.
auto result = KOReaderSyncClient::getProgress(documentHash, remoteProgress);
LOG_DBG("KOSync", "Primary remote (%s): result=%d http=%d doc=%s local=%.6f remote=%.6f xpath=%s",
matchMethodName(primaryMethod), result, KOReaderSyncClient::lastHttpCode, documentHash.c_str(),
localProgress.percentage, remoteProgress.percentage, remoteProgress.progress.c_str());
if (smartSyncEnabled()) {
const DocumentMatchMethod altMethod = alternateMatchMethod(primaryMethod);
const std::string altHash = calculateDocumentHashForMethod(epubPath, altMethod);
if (!altHash.empty() && altHash != documentHash) {
KOReaderProgress altProgress;
const auto altResult = KOReaderSyncClient::getProgress(altHash, altProgress);
LOG_DBG("KOSync", "Alternate remote (%s): result=%d http=%d doc=%s local=%.6f remote=%.6f xpath=%s",
matchMethodName(altMethod), altResult, KOReaderSyncClient::lastHttpCode, altHash.c_str(),
localProgress.percentage, altProgress.percentage, altProgress.progress.c_str());
if (altResult == KOReaderSyncClient::OK &&
(result == KOReaderSyncClient::NOT_FOUND || altProgress.percentage > remoteProgress.percentage)) {
documentHash = altHash;
remoteProgress = std::move(altProgress);
result = KOReaderSyncClient::OK;
}
}
}
if (result == KOReaderSyncClient::NOT_FOUND) {
if (smartSyncEnabled()) {
LOG_DBG("KOSync", "Smart sync: no remote progress found for known document hashes; uploading local %.6f",
localProgress.percentage);
performUpload();
return;
}
// No remote progress - offer to upload
{
RenderLock lock(*this);
@@ -177,6 +235,29 @@ void KOReaderSyncActivity::performSync() {
SavedProgressPosition koPos = {remoteProgress.progress, remoteProgress.percentage};
remotePosition = ProgressMapper::toCrossPoint(epub, koPos, renderer, currentSpineIndex, totalPagesInSpine);
if (smartSyncEnabled()) {
static constexpr float SAME_PROGRESS_EPSILON = 0.001f; // 0.1 percentage points
const float delta = localProgress.percentage - remoteProgress.percentage;
LOG_DBG("KOSync", "Smart decision: doc=%s local=%.6f remote=%.6f delta=%.6f remoteXpath=%s mapped=%d/%d",
documentHash.c_str(), localProgress.percentage, remoteProgress.percentage, delta,
remoteProgress.progress.c_str(), remotePosition.spineIndex, remotePosition.pageNumber);
if (std::fabs(delta) <= SAME_PROGRESS_EPSILON) {
completeAlreadySynced();
return;
}
if (delta > 0) {
// Alternate hashes are only probes for newer remote state. Keep uploads
// on the user's configured matching method so its primary record heals.
documentHash = primaryHash;
performUpload();
return;
}
saveProgressAndReturn(remotePosition.spineIndex, remotePosition.pageNumber);
return;
}
// localProgress was pre-computed in EpubReaderActivity before the Epub was released.
{
RenderLock lock(*this);
@@ -248,6 +329,7 @@ void KOReaderSyncActivity::performUpload() {
RenderLock lock(*this);
state = UPLOAD_COMPLETE;
}
markAutoReturn();
requestUpdate(true);
}
@@ -391,10 +473,12 @@ void KOReaderSyncActivity::render(RenderLock&&) {
return;
}
if (state == UPLOAD_COMPLETE) {
UITheme::drawCenteredText(renderer, screen, UI_10_FONT_ID, top, tr(STR_UPLOAD_SUCCESS), true, EpdFontFamily::BOLD);
if (state == UPLOAD_COMPLETE || state == SYNC_COMPLETE) {
UITheme::drawCenteredText(renderer, screen, UI_10_FONT_ID, top,
state == UPLOAD_COMPLETE ? tr(STR_UPLOAD_SUCCESS) : tr(STR_ALREADY_SYNCED), true,
EpdFontFamily::BOLD);
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_DONE), "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
return;
@@ -412,8 +496,13 @@ void KOReaderSyncActivity::render(RenderLock&&) {
}
void KOReaderSyncActivity::loop() {
if (state == NO_CREDENTIALS || state == SYNC_FAILED || state == UPLOAD_COMPLETE) {
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
if (state == NO_CREDENTIALS || state == SYNC_FAILED || state == UPLOAD_COMPLETE || state == SYNC_COMPLETE) {
if (autoReturnAt != 0 && millis() >= autoReturnAt) {
returnToReader();
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back) ||
mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
returnToReader();
}
return;
+9 -1
View File
@@ -40,7 +40,7 @@ class KOReaderSyncActivity final : public Activity {
void onExit() override;
void loop() override;
void render(RenderLock&&) override;
bool preventAutoSleep() override { return state == CONNECTING || state == SYNCING; }
bool preventAutoSleep() override { return state == CONNECTING || state == SYNCING || state == UPLOADING; }
private:
enum State {
@@ -50,6 +50,7 @@ class KOReaderSyncActivity final : public Activity {
SHOWING_RESULT,
UPLOADING,
UPLOAD_COMPLETE,
SYNC_COMPLETE,
NO_REMOTE_PROGRESS,
SYNC_FAILED,
NO_CREDENTIALS
@@ -78,6 +79,10 @@ class KOReaderSyncActivity final : public Activity {
// Selection in result screen (0=Apply, 1=Upload)
int selectedOption = 0;
// Timed return for successful smart-sync terminal states.
unsigned long autoReturnAt = 0;
static constexpr unsigned long AUTO_RETURN_DELAY_MS = 1200;
// Tracks whether this session activated WiFi. Set in onEnter past the credentials
// check; checked in onExit to decide whether to silent-reboot. Can't rely on
// WiFi.getMode() because performUpload() calls esp_wifi_stop() on the way out,
@@ -87,6 +92,9 @@ class KOReaderSyncActivity final : public Activity {
void onWifiSelectionComplete(bool success);
void performSync();
void performUpload();
bool smartSyncEnabled() const;
void markAutoReturn();
void completeAlreadySynced();
void ensureEpubLoaded();
void saveProgressAndReturn(int spineIndex, int page);
void returnToReader();
@@ -13,9 +13,10 @@
#include "fontIds.h"
namespace {
constexpr int MENU_ITEMS = 6;
constexpr int MENU_ITEMS = 7;
const StrId menuNames[MENU_ITEMS] = {StrId::STR_USERNAME, StrId::STR_PASSWORD, StrId::STR_SYNC_SERVER_URL,
StrId::STR_DOCUMENT_MATCHING, StrId::STR_SEND_METADATA, StrId::STR_AUTHENTICATE};
StrId::STR_DOCUMENT_MATCHING, StrId::STR_SEND_METADATA, StrId::STR_SYNC_BEHAVIOR,
StrId::STR_AUTHENTICATE};
} // namespace
void KOReaderSettingsActivity::onEnter() {
@@ -103,6 +104,14 @@ void KOReaderSettingsActivity::handleSelection() {
KOREADER_STORE.saveToFile();
requestUpdate();
} else if (selectedIndex == 5) {
// Sync behavior - toggle between Ask and Smart
const auto current = KOREADER_STORE.getSyncBehavior();
const auto newBehavior = (current == KOReaderSyncBehavior::ASK_EVERY_TIME) ? KOReaderSyncBehavior::SMART
: KOReaderSyncBehavior::ASK_EVERY_TIME;
KOREADER_STORE.setSyncBehavior(newBehavior);
KOREADER_STORE.saveToFile();
requestUpdate();
} else if (selectedIndex == 6) {
// Authenticate
if (!KOREADER_STORE.hasCredentials()) {
// Can't authenticate without credentials - just show message briefly
@@ -143,6 +152,9 @@ void KOReaderSettingsActivity::render(RenderLock&&) {
} else if (index == 4) {
return KOREADER_STORE.getSendMetadata() ? std::string(tr(STR_STATE_ON)) : std::string(tr(STR_STATE_OFF));
} else if (index == 5) {
return KOREADER_STORE.getSyncBehavior() == KOReaderSyncBehavior::SMART ? std::string(tr(STR_SMART_SYNC))
: std::string(tr(STR_ASK_EVERY_TIME));
} else if (index == 6) {
return KOREADER_STORE.hasCredentials() ? "" : std::string("[") + tr(STR_SET_CREDENTIALS_FIRST) + "]";
}
return std::string(tr(STR_NOT_SET));