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:
co-authored by
Alexander Hoffer
Alexander Hoffer
parent
35a45f9c1e
commit
a4ac3b2788
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user