diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 109cd5ba..4edefd80 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -302,6 +302,8 @@ STR_HW_RIGHT_LABEL: "Right (4th button)" STR_GO_TO_PERCENT: "Go to %" STR_GO_HOME_BUTTON: "Go Home" STR_SYNC_PROGRESS: "Sync Progress" +STR_PUSH_PROGRESS_FROM_THIS_DEVICE: "Push progress from this device" +STR_PULL_PROGRESS_FROM_OTHER_DEVICES: "Pull progress from other devices" STR_DELETE_CACHE: "Delete Book Cache" STR_DELETE: "Delete" STR_REMOVE: "Remove" @@ -331,6 +333,7 @@ STR_UPLOAD_LOCAL: "Upload local progress" STR_NO_REMOTE_MSG: "No remote progress found" STR_UPLOAD_PROMPT: "Upload current position?" STR_UPLOAD_SUCCESS: "Progress uploaded!" +STR_PULL_SUCCESS: "Remote progress applied!" STR_SYNC_FAILED_MSG: "Sync failed" STR_SECTION_PREFIX: "Section " STR_UPLOAD: "Upload" diff --git a/lib/KOReaderSync/KOReaderSyncClient.cpp b/lib/KOReaderSync/KOReaderSyncClient.cpp index d98a0d89..123d449b 100644 --- a/lib/KOReaderSync/KOReaderSyncClient.cpp +++ b/lib/KOReaderSync/KOReaderSyncClient.cpp @@ -11,8 +11,8 @@ #include #include -#include #include +#include #include #include "KOReaderCredentialStore.h" @@ -50,7 +50,9 @@ constexpr char DEVICE_ID[] = "crosspoint-reader"; // Payloads are tiny JSON, so throughput impact is minimal while avoiding // large transient allocations from default client buffer sizes. constexpr int HTTP_BUF_SIZE = 1024; -constexpr unsigned TLS_CONTIG_HEAP_TOLERANCE = 512; +// Keep strict thresholding here. A small tolerance caused repeated handshake +// attempts in borderline-fragmented states that still failed in mbedTLS. +constexpr unsigned TLS_CONTIG_HEAP_TOLERANCE = 0; // Captures radio/link state around failed connects. // Why: many field failures look like TLS errors but are actually weak WiFi. @@ -135,10 +137,9 @@ std::string base64Encode(const std::string& input) { // lastFailureDetail() will report the heap shortage instead of attempting a doomed handshake. bool checkHeapForTls() { const bool hasReusableSession = g_keepSessionOpen && g_sessionClient != nullptr; - const bool isUpload = (KOReaderSyncClient::lastOperation && - strcmp(KOReaderSyncClient::lastOperation, "update progress") == 0); - const unsigned requiredContig = - isUpload ? KOReaderSyncClient::MIN_CONTIG_HEAP_FOR_TLS_UPLOAD : KOReaderSyncClient::MIN_CONTIG_HEAP_FOR_TLS; + const bool isUpload = + (KOReaderSyncClient::lastOperation && strcmp(KOReaderSyncClient::lastOperation, "update progress") == 0); + const unsigned requiredContig = KOReaderSyncClient::MIN_CONTIG_HEAP_FOR_TLS; // Upload can often reuse the already-established GET connection. In that case // a full handshake allocation is typically unnecessary, so avoid failing fast @@ -158,6 +159,30 @@ bool checkHeapForTls() { return true; } +void refreshHeapSnapshot() { + KOReaderSyncClient::lastHeapAtFailure = ESP.getFreeHeap(); + KOReaderSyncClient::lastContigHeapAtFailure = heap_caps_get_largest_free_block(MALLOC_CAP_8BIT | MALLOC_CAP_DEFAULT); +} + +void logTlsAttemptPlan(const char* operation, int attempt) { + const bool isUpload = (operation && strcmp(operation, "update progress") == 0); + const bool hasReusableSession = g_keepSessionOpen && g_sessionClient != nullptr; + const unsigned requiredContig = (isUpload && hasReusableSession) ? KOReaderSyncClient::MIN_CONTIG_HEAP_FOR_TLS_UPLOAD + : KOReaderSyncClient::MIN_CONTIG_HEAP_FOR_TLS; + + LOG_DBG("KOSync", "%s attempt %d: keep_session=%s reusable_session=%s tls_mode=%s heap=%u contig=%u need=%u", + operation ? operation : "request", attempt, g_keepSessionOpen ? "yes" : "no", + hasReusableSession ? "yes" : "no", (isUpload && hasReusableSession) ? "reuse" : "handshake", + KOReaderSyncClient::lastHeapAtFailure, KOReaderSyncClient::lastContigHeapAtFailure, requiredContig); +} + +void resetSessionClientForRetry() { + if (g_sessionClient) { + esp_http_client_cleanup(g_sessionClient); + g_sessionClient = nullptr; + } +} + // Create configured esp_http_client with small TLS buffers esp_http_client_handle_t createClient(const char* url, ResponseBuffer* buf, esp_http_client_method_t method = HTTP_METHOD_GET) { @@ -352,6 +377,14 @@ KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& doc int httpCode = 0; for (int attempt = 1; attempt <= 2; attempt++) { + // Retry attempts can happen after memory churn from a failed handshake. + // Refresh heap snapshot each pass so preflight and diagnostics use current values. + refreshHeapSnapshot(); + logTlsAttemptPlan("get progress", attempt); + if (!checkHeapForTls()) { + return NETWORK_ERROR; + } + clearResponseBuffer(activeBuf); esp_http_client_handle_t client = createClient(url.c_str(), &buf); @@ -378,6 +411,10 @@ KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& doc break; } + // Failed connect can leave a persistent client handle in a bad state. + // Recreate it before retry so we don't repeat work on a stale transport. + resetSessionClientForRetry(); + LOG_ERR("KOSync", "getProgress connect failed on attempt %d, retrying once", attempt); logWifiSnapshot("WiFi before getProgress retry"); delay(400); @@ -442,6 +479,14 @@ KOReaderSyncClient::Error KOReaderSyncClient::updateProgress(const KOReaderProgr int httpCode = 0; for (int attempt = 1; attempt <= 2; attempt++) { + // Retry attempts can happen after memory churn from a failed handshake. + // Refresh heap snapshot each pass so preflight and diagnostics use current values. + refreshHeapSnapshot(); + logTlsAttemptPlan("update progress", attempt); + if (!checkHeapForTls()) { + return NETWORK_ERROR; + } + clearResponseBuffer(activeBuf); esp_http_client_handle_t client = createClient(url.c_str(), &buf, HTTP_METHOD_PUT); @@ -462,8 +507,7 @@ KOReaderSyncClient::Error KOReaderSyncClient::updateProgress(const KOReaderProgr esp_http_client_cleanup(client); } - LOG_DBG("KOSync", "Update progress response: %d (err: %s) [attempt %d]", httpCode, esp_err_to_name(err), - attempt); + LOG_DBG("KOSync", "Update progress response: %d (err: %s) [attempt %d]", httpCode, esp_err_to_name(err), attempt); // Retry exactly once for connect-level failures only. // Why: same policy as GET keeps behavior predictable across both endpoints. @@ -471,6 +515,10 @@ KOReaderSyncClient::Error KOReaderSyncClient::updateProgress(const KOReaderProgr break; } + // Failed connect can leave a persistent client handle in a bad state. + // Recreate it before retry so we don't repeat work on a stale transport. + resetSessionClientForRetry(); + LOG_ERR("KOSync", "updateProgress connect failed on attempt %d, retrying once", attempt); logWifiSnapshot("WiFi before updateProgress retry"); delay(400); @@ -484,7 +532,9 @@ KOReaderSyncClient::Error KOReaderSyncClient::updateProgress(const KOReaderProgr const char* KOReaderSyncClient::lastFailureDetail() { const bool isUpload = (lastOperation && strcmp(lastOperation, "update progress") == 0); - const unsigned requiredContig = isUpload ? MIN_CONTIG_HEAP_FOR_TLS_UPLOAD : MIN_CONTIG_HEAP_FOR_TLS; + const bool hasReusableSession = g_keepSessionOpen && g_sessionClient != nullptr; + const unsigned requiredContig = + (isUpload && hasReusableSession) ? MIN_CONTIG_HEAP_FOR_TLS_UPLOAD : MIN_CONTIG_HEAP_FOR_TLS; // Heap-pressure case: surfaced when checkHeapForTls() refused before any TCP/TLS work happened. if (lastEspError == ESP_ERR_NO_MEM && lastHttpCode == 0) { diff --git a/lib/KOReaderSync/KOReaderSyncClient.h b/lib/KOReaderSync/KOReaderSyncClient.h index d98e0423..8260cdf6 100644 --- a/lib/KOReaderSync/KOReaderSyncClient.h +++ b/lib/KOReaderSync/KOReaderSyncClient.h @@ -112,5 +112,7 @@ class KOReaderSyncClient { * reports a heap-pressure message instead of attempting (and crashing) the TLS handshake. */ static constexpr unsigned MIN_CONTIG_HEAP_FOR_TLS = 36 * 1024; + // Relaxed threshold only for upload when reusing an already-established session. + // Uploads that must perform a fresh handshake still require MIN_CONTIG_HEAP_FOR_TLS. static constexpr unsigned MIN_CONTIG_HEAP_FOR_TLS_UPLOAD = 34 * 1024; }; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 2cce6c0d..3ac6436f 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -145,10 +145,12 @@ void EpubReaderActivity::loop() { } // Long press CONFIRM (1s+) goes directly to KOReader sync when credentials are configured. + // We intentionally keep long-press on the richer compare flow so advanced + // conflict-resolution behavior stays available even after simplifying menu UX. // Without credentials, fall through to the regular menu on release. if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS && KOREADER_STORE.hasCredentials()) { - launchKOReaderSync(); + launchKOReaderSync(SyncLaunchMode::COMPARE); return; } @@ -458,16 +460,26 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction requestUpdate(); break; } - case EpubReaderMenuActivity::MenuAction::SYNC: { + case EpubReaderMenuActivity::MenuAction::PULL_REMOTE: { + // One-tap pull path: run network preconditions and apply remote progress + // directly instead of showing an intermediate chooser screen. if (KOREADER_STORE.hasCredentials()) { - launchKOReaderSync(); + launchKOReaderSync(SyncLaunchMode::PULL_REMOTE); + } + break; + } + case EpubReaderMenuActivity::MenuAction::PUSH_LOCAL: { + // One-tap push path: run network preconditions and upload local progress + // directly for KOReader-like "sync now" behavior. + if (KOREADER_STORE.hasCredentials()) { + launchKOReaderSync(SyncLaunchMode::PUSH_LOCAL); } break; } } } -void EpubReaderActivity::launchKOReaderSync() { +void EpubReaderActivity::launchKOReaderSync(const SyncLaunchMode mode) { if (!epub) { return; } @@ -499,10 +511,19 @@ void EpubReaderActivity::launchKOReaderSync() { LOG_DBG("ERS", "Pre-sync trim: spine=%d page=%d/%d heap=%lu", currentSpineIndex, currentPage, totalPages, static_cast(esp_get_free_heap_size())); - startActivityForResult(std::make_unique(renderer, mappedInput, std::shared_ptr{}, - syncEpubPath, - currentSpineIndex, currentPage, totalPages), - [this](const ActivityResult& result) { handleSyncResult(result); }); + // Map reader-level launch mode to activity-level intent once, then pass a + // stable intent into KOReaderSyncActivity so it can own the sync state machine. + KOReaderSyncActivity::SyncIntent syncIntent = KOReaderSyncActivity::SyncIntent::COMPARE; + if (mode == SyncLaunchMode::PULL_REMOTE) { + syncIntent = KOReaderSyncActivity::SyncIntent::PULL_REMOTE; + } else if (mode == SyncLaunchMode::PUSH_LOCAL) { + syncIntent = KOReaderSyncActivity::SyncIntent::PUSH_LOCAL; + } + + startActivityForResult( + std::make_unique(renderer, mappedInput, std::shared_ptr{}, syncEpubPath, + currentSpineIndex, currentPage, totalPages, 0, false, syncIntent), + [this](const ActivityResult& result) { handleSyncResult(result); }); } void EpubReaderActivity::handleSyncResult(const ActivityResult& result) { diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 5d6f41c8..f284d228 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -9,6 +9,17 @@ #include "activities/Activity.h" class EpubReaderActivity final : public Activity { + // Reader can launch sync in three UX modes: + // - COMPARE: legacy chooser (apply/upload) for power users. + // - PULL_REMOTE / PUSH_LOCAL: direct one-step actions from menu entries. + // 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, + }; + std::shared_ptr epub; std::unique_ptr
section = nullptr; int currentSpineIndex = 0; @@ -58,7 +69,7 @@ class EpubReaderActivity final : public Activity { // Jump to a percentage of the book (0-100), mapping it to spine and page. void jumpToPercent(int percent); void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action); - void launchKOReaderSync(); + void launchKOReaderSync(SyncLaunchMode mode = SyncLaunchMode::COMPARE); void handleSyncResult(const ActivityResult& result); void applyOrientation(uint8_t orientation); void toggleAutoPageTurn(uint8_t selectedPageTurnOption); diff --git a/src/activities/reader/EpubReaderMenuActivity.cpp b/src/activities/reader/EpubReaderMenuActivity.cpp index 9e5fe4f8..ef08bfd5 100644 --- a/src/activities/reader/EpubReaderMenuActivity.cpp +++ b/src/activities/reader/EpubReaderMenuActivity.cpp @@ -25,7 +25,7 @@ EpubReaderMenuActivity::EpubReaderMenuActivity(GfxRenderer& renderer, MappedInpu std::vector EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes) { std::vector items; - items.reserve(10); + items.reserve(12); items.push_back({MenuAction::SELECT_CHAPTER, StrId::STR_SELECT_CHAPTER}); if (hasFootnotes) { items.push_back({MenuAction::FOOTNOTES, StrId::STR_FOOTNOTES}); @@ -39,7 +39,8 @@ std::vector EpubReaderMenuActivity::buildMenuI items.push_back({MenuAction::DISPLAY_QR, StrId::STR_DISPLAY_QR}); items.push_back({MenuAction::GO_HOME, StrId::STR_GO_HOME_BUTTON}); if (KOREADER_STORE.hasCredentials()) { - items.push_back({MenuAction::SYNC, StrId::STR_SYNC_PROGRESS}); + items.push_back({MenuAction::PULL_REMOTE, StrId::STR_PULL_PROGRESS_FROM_OTHER_DEVICES}); + items.push_back({MenuAction::PUSH_LOCAL, StrId::STR_PUSH_PROGRESS_FROM_THIS_DEVICE}); } items.push_back({MenuAction::DELETE_CACHE, StrId::STR_DELETE_CACHE}); return items; diff --git a/src/activities/reader/EpubReaderMenuActivity.h b/src/activities/reader/EpubReaderMenuActivity.h index ecb2d308..80d70c96 100644 --- a/src/activities/reader/EpubReaderMenuActivity.h +++ b/src/activities/reader/EpubReaderMenuActivity.h @@ -22,7 +22,8 @@ class EpubReaderMenuActivity final : public Activity { SCREENSHOT, DISPLAY_QR, GO_HOME, - SYNC, + PULL_REMOTE, + PUSH_LOCAL, DELETE_CACHE }; diff --git a/src/activities/reader/KOReaderSyncActivity.cpp b/src/activities/reader/KOReaderSyncActivity.cpp index 9167db42..86ec916b 100644 --- a/src/activities/reader/KOReaderSyncActivity.cpp +++ b/src/activities/reader/KOReaderSyncActivity.cpp @@ -1,10 +1,10 @@ #include "KOReaderSyncActivity.h" +#include #include #include #include #include -#include #include #include #include @@ -116,19 +116,52 @@ void KOReaderSyncActivity::performSync() { LOG_DBG("KOSync", "Document hash: %s", documentHash.c_str()); - // 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. - { - RenderLock lock(*this); - statusMessage = tr(STR_MAPPING_LOCAL); + // Local mapping is only needed for compare/upload paths. + // Pull-only mode can skip this expensive step and go straight to remote fetch. + if (syncIntent != SyncIntent::PULL_REMOTE) { + // 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. + { + RenderLock lock(*this); + statusMessage = tr(STR_MAPPING_LOCAL); + } + requestUpdateAndWait(); + computeLocalProgressAndChapter(); } - requestUpdateAndWait(); - computeLocalProgressAndChapter(); // Drop EPUB state before HTTPS to maximize contiguous heap for TLS. releaseEpubForMapping(); + // Push intent skips comparison UI but still warms an HTTP/TLS session first + // so PUT can reuse the connection instead of forcing a fresh handshake. + if (syncIntent == SyncIntent::PUSH_LOCAL) { + // 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 + // same reuse path without showing comparison UI. + KOReaderSyncClient::beginPersistentSession(); + KOReaderProgress warmupProgress; + const auto warmupResult = KOReaderSyncClient::getProgress(documentHash, warmupProgress); + if (warmupResult != KOReaderSyncClient::OK && warmupResult != KOReaderSyncClient::NOT_FOUND) { + KOReaderSyncClient::endPersistentSession(); + { + RenderLock lock(*this); + state = SYNC_FAILED; + statusMessage = KOReaderSyncClient::errorString(warmupResult); + const char* detail = KOReaderSyncClient::lastFailureDetail(); + if (detail && detail[0]) { + statusMessage += " — "; + statusMessage += detail; + } + } + requestUpdate(true); + return; + } + performUpload(); + return; + } + { RenderLock lock(*this); statusMessage = tr(STR_FETCH_PROGRESS); @@ -143,6 +176,19 @@ void KOReaderSyncActivity::performSync() { const auto result = KOReaderSyncClient::getProgress(documentHash, remoteProgress); if (result == KOReaderSyncClient::NOT_FOUND) { + if (syncIntent == SyncIntent::PULL_REMOTE) { + // Pull intent must not silently fall back to upload when server has no + // remote progress. Failing explicitly keeps action semantics predictable. + KOReaderSyncClient::endPersistentSession(); + { + RenderLock lock(*this); + state = SYNC_FAILED; + statusMessage = tr(STR_NO_REMOTE_MSG); + } + requestUpdate(true); + return; + } + // Keep session open so an immediate upload can reuse the same connection. // No remote progress - offer to upload { @@ -189,6 +235,34 @@ void KOReaderSyncActivity::performSync() { } remoteChapterLabel = tr(STR_UNNAMED); + if (syncIntent == SyncIntent::PULL_REMOTE) { + // Pull intent applies immediately and exits. We bypass chooser UI to keep + // reader menu actions deterministic ("pull" always means apply remote). + if (!ensureRemotePositionMapped()) { + { + RenderLock lock(*this); + state = SYNC_FAILED; + statusMessage = tr(STR_SYNC_FAILED_MSG); + } + requestUpdate(true); + return; + } + + // Preserve the apply result and show explicit confirmation before returning + // to the reader so users can tell pull succeeded. + setResult(SyncResult{remotePosition.spineIndex, remotePosition.pageNumber, remotePosition.paragraphIndex, + remotePosition.hasParagraphIndex}); + { + RenderLock lock(*this); + state = APPLY_COMPLETE; + uploadCompleteTime = millis(); + } + requestUpdate(true); + return; + } + + // Compare intent keeps the legacy chooser flow (apply vs upload), which is + // still useful for manual conflict decisions. // Local progress was precomputed before network; keep using the cached value. releaseEpubForMapping(); @@ -242,8 +316,9 @@ void KOReaderSyncActivity::performUpload() { // that only appear on PUT due to allocator state changes. logSyncMemSnapshot("before_updateProgress"); - // Ensure a session exists for upload. When GET succeeded, this should reuse - // the existing connection and typically skip a second handshake. + // Ensure a session exists for upload. In compare flow this comes from the + // earlier GET; in direct-push flow it comes from the warmup GET above. + // In both cases, reuse avoids a second full TLS handshake. KOReaderSyncClient::beginPersistentSession(); KOReaderProgress progress; @@ -341,7 +416,7 @@ void KOReaderSyncActivity::render(RenderLock&&) { } if (state == SYNCING || state == UPLOADING) { - renderer.drawCenteredText(UI_10_FONT_ID, 300, statusMessage.c_str(), true, EpdFontFamily::BOLD); + GUI.drawPopup(renderer, statusMessage.c_str()); renderer.displayBuffer(); return; } @@ -422,6 +497,15 @@ void KOReaderSyncActivity::render(RenderLock&&) { return; } + if (state == APPLY_COMPLETE) { + renderer.drawCenteredText(UI_10_FONT_ID, 300, tr(STR_PULL_SUCCESS), true, EpdFontFamily::BOLD); + + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); + return; + } + if (state == SYNC_FAILED) { renderer.drawCenteredText(UI_10_FONT_ID, 280, tr(STR_SYNC_FAILED_MSG), true, EpdFontFamily::BOLD); renderer.drawCenteredText(UI_10_FONT_ID, 320, statusMessage.c_str()); @@ -488,9 +572,9 @@ void KOReaderSyncActivity::computeLocalProgressAndChapter() { localProgress = ProgressMapper::toKOReader(epub, localPos); const int localTocIndex = epub->getTocIndexForSpineIndex(currentSpineIndex); - localChapterLabel = - (localTocIndex >= 0) ? epub->getTocItem(localTocIndex).title - : (std::string(tr(STR_SECTION_PREFIX)) + std::to_string(currentSpineIndex + 1)); + localChapterLabel = (localTocIndex >= 0) + ? epub->getTocItem(localTocIndex).title + : (std::string(tr(STR_SECTION_PREFIX)) + std::to_string(currentSpineIndex + 1)); } void KOReaderSyncActivity::computeRemoteChapter() { @@ -498,20 +582,31 @@ void KOReaderSyncActivity::computeRemoteChapter() { return; } const int remoteTocIndex = epub->getTocIndexForSpineIndex(remotePosition.spineIndex); - remoteChapterLabel = - (remoteTocIndex >= 0) ? epub->getTocItem(remoteTocIndex).title - : (std::string(tr(STR_SECTION_PREFIX)) + std::to_string(remotePosition.spineIndex + 1)); + remoteChapterLabel = (remoteTocIndex >= 0) + ? epub->getTocItem(remoteTocIndex).title + : (std::string(tr(STR_SECTION_PREFIX)) + std::to_string(remotePosition.spineIndex + 1)); } void KOReaderSyncActivity::loop() { - if (state == NO_CREDENTIALS || state == SYNC_FAILED || state == UPLOAD_COMPLETE) { + if (state == NO_CREDENTIALS || state == SYNC_FAILED || state == UPLOAD_COMPLETE || state == APPLY_COMPLETE) { if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { - closeCancelled(); + // APPLY_COMPLETE already has a valid SyncResult, so exit normally. + // Other terminal states are treated as cancelled when backing out. + if (state == APPLY_COMPLETE) { + finish(); + } else { + closeCancelled(); + } return; } - if (state == UPLOAD_COMPLETE && millis() - uploadCompleteTime >= 3000) { - closeCancelled(); + if ((state == UPLOAD_COMPLETE || state == APPLY_COMPLETE) && millis() - uploadCompleteTime >= 3000) { + // Keep pull/apply result on auto-close; upload-complete remains cancel-style. + if (state == APPLY_COMPLETE) { + finish(); + } else { + closeCancelled(); + } } return; } diff --git a/src/activities/reader/KOReaderSyncActivity.h b/src/activities/reader/KOReaderSyncActivity.h index 6edb8fc2..25b87599 100644 --- a/src/activities/reader/KOReaderSyncActivity.h +++ b/src/activities/reader/KOReaderSyncActivity.h @@ -12,19 +12,37 @@ /** * Activity for syncing reading progress with KOReader sync server. * - * Flow: + * Shared pipeline: * 1. Connect to WiFi (if not connected) - * 2. Calculate document hash - * 3. Fetch remote progress - * 4. Show comparison and options (Apply/Upload) - * 5. Apply or upload progress + * 2. Optionally sync NTP (if stale) + * 3. Calculate document hash + * + * Intent-specific behavior: + * - COMPARE: fetch remote progress, show full comparison screen, let user + * choose Apply or Upload. + * - PULL_REMOTE: fetch and map remote progress, show success feedback, then + * return applied SyncResult to reader. + * - PUSH_LOCAL: compute local mapping, warm session with GET, then upload via + * reused connection to avoid a second full TLS handshake. */ class KOReaderSyncActivity final : public Activity { public: + // Intent controls UI/behavior split for the same sync pipeline. + // - COMPARE: fetch then let user choose apply/upload. + // - PULL_REMOTE: fetch and apply immediately. + // - PUSH_LOCAL: upload immediately. + // This keeps WiFi/NTP/hash/memory handling centralized while enabling a + // simpler KOReader-like reader menu UX. + enum class SyncIntent { + COMPARE, + PULL_REMOTE, + PUSH_LOCAL, + }; + explicit KOReaderSyncActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::shared_ptr& epub, const std::string& epubPath, int currentSpineIndex, int currentPage, int totalPagesInSpine, uint16_t paragraphIndex = 0, - bool hasParagraphIndex = false) + bool hasParagraphIndex = false, SyncIntent syncIntent = SyncIntent::COMPARE) : Activity("KOReaderSync", renderer, mappedInput), epub(epub), epubPath(epubPath), @@ -33,6 +51,7 @@ class KOReaderSyncActivity final : public Activity { totalPagesInSpine(totalPagesInSpine), localParagraphIndex(paragraphIndex), hasLocalParagraphIndex(hasParagraphIndex), + syncIntent(syncIntent), remoteProgress{}, remotePosition{}, localProgress{} {} @@ -51,6 +70,7 @@ class KOReaderSyncActivity final : public Activity { SHOWING_RESULT, UPLOADING, UPLOAD_COMPLETE, + APPLY_COMPLETE, NO_REMOTE_PROGRESS, SYNC_FAILED, NO_CREDENTIALS @@ -63,6 +83,7 @@ class KOReaderSyncActivity final : public Activity { int totalPagesInSpine; uint16_t localParagraphIndex; bool hasLocalParagraphIndex; + SyncIntent syncIntent = SyncIntent::COMPARE; State state = WIFI_SELECTION; std::string statusMessage; @@ -82,7 +103,7 @@ class KOReaderSyncActivity final : public Activity { // Selection in result screen (0=Apply, 1=Upload) int selectedOption = 0; - // Timestamp when UPLOAD_COMPLETE state was entered (for auto-close) + // Timestamp when completion state was entered (for auto-close) unsigned long uploadCompleteTime = 0; bool closeRequested = false;