#include "KOReaderSyncActivity.h" #include #include #include #include #include #include #include #include #include #include "Epub/Section.h" #include "EpubReaderUtils.h" #include "KOReaderCredentialStore.h" #include "KOReaderDocumentId.h" #include "MappedInputManager.h" #include "ReaderUtils.h" #include "SilentRestart.h" #include "activities/ActivityManager.h" #include "activities/network/WifiSelectionActivity.h" #include "components/UITheme.h" #include "fontIds.h" namespace { void syncTimeWithNTP() { // Stop SNTP if already running (can't reconfigure while running) if (esp_sntp_enabled()) { esp_sntp_stop(); } // Configure SNTP esp_sntp_setoperatingmode(ESP_SNTP_OPMODE_POLL); esp_sntp_setservername(0, "pool.ntp.org"); esp_sntp_init(); // Wait for time to sync (with timeout) int retry = 0; const int maxRetries = 50; // 5 seconds max while (sntp_get_sync_status() != SNTP_SYNC_STATUS_COMPLETED && retry < maxRetries) { vTaskDelay(100 / portTICK_PERIOD_MS); retry++; } if (retry < maxRetries) { LOG_DBG("KOSync", "NTP time synced"); } else { LOG_DBG("KOSync", "NTP sync timeout, using fallback"); } } } // namespace void KOReaderSyncActivity::ensureEpubLoaded() { if (!epub) { LOG_DBG("KOSync", "Loading epub for progress mapping (heap: %u)", (unsigned)ESP.getFreeHeap()); epub = std::make_shared(epubPath, "/.crosspoint"); epub->setupCacheDir(); // Load metadata only (no CSS needed for progress mapping, don't rebuild if cache is missing). if (!epub->load(false, true)) { LOG_ERR("KOSync", "Failed to load epub for progress mapping"); epub.reset(); return; } LOG_DBG("KOSync", "Epub loaded (heap: %u)", (unsigned)ESP.getFreeHeap()); } } void KOReaderSyncActivity::saveProgressAndReturn(int spineIndex, int page) { // epub is guaranteed non-null here: ensureEpubLoaded() was called in performSync() before // SHOWING_RESULT state is entered, and this method is only called from that state. assert(epub); if (!EpubReaderUtils::saveProgress(*epub, spineIndex, page, 0)) { { RenderLock lock(*this); state = SYNC_FAILED; statusMessage = tr(STR_SAVE_PROGRESS_FAILED); } requestUpdate(true); return; } returnToReader(); } void KOReaderSyncActivity::returnToReader() { activityManager.goToReader(epubPath); } void KOReaderSyncActivity::onWifiSelectionComplete(const bool success) { if (!success) { LOG_DBG("KOSync", "WiFi connection failed, exiting"); returnToReader(); return; } LOG_DBG("KOSync", "WiFi connected, starting sync"); { RenderLock lock(*this); state = SYNCING; statusMessage = tr(STR_SYNCING_TIME); } requestUpdate(true); // Sync time with NTP before making API requests syncTimeWithNTP(); { RenderLock lock(*this); statusMessage = tr(STR_CALC_HASH); } requestUpdate(true); performSync(); } 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); } if (documentHash.empty()) { { RenderLock lock(*this); state = SYNC_FAILED; statusMessage = tr(STR_HASH_FAILED); } requestUpdate(true); return; } LOG_DBG("KOSync", "Document hash: %s", documentHash.c_str()); { RenderLock lock(*this); statusMessage = tr(STR_FETCH_PROGRESS); } requestUpdateAndWait(); // Fetch remote progress const auto result = KOReaderSyncClient::getProgress(documentHash, remoteProgress); if (result == KOReaderSyncClient::NOT_FOUND) { // No remote progress - offer to upload { RenderLock lock(*this); state = NO_REMOTE_PROGRESS; hasRemoteProgress = false; } requestUpdate(true); return; } if (result != KOReaderSyncClient::OK) { { RenderLock lock(*this); state = SYNC_FAILED; statusMessage = KOReaderSyncClient::errorString(result); } requestUpdate(true); return; } // Epub was released before sync to free RAM for the TLS handshake — reload it now. hasRemoteProgress = true; ensureEpubLoaded(); if (!epub) { { RenderLock lock(*this); state = SYNC_FAILED; statusMessage = ""; } requestUpdate(true); return; } SavedProgressPosition koPos = {remoteProgress.progress, remoteProgress.percentage}; remotePosition = ProgressMapper::toCrossPoint(epub, koPos, renderer, currentSpineIndex, totalPagesInSpine); // localProgress was pre-computed in EpubReaderActivity before the Epub was released. { RenderLock lock(*this); state = SHOWING_RESULT; // Default to the option that corresponds to the furthest progress if (localProgress.percentage > remoteProgress.percentage) { selectedOption = 1; // Upload local progress } else { selectedOption = 0; // Apply remote progress } } requestUpdate(true); } void KOReaderSyncActivity::performUpload() { { RenderLock lock(*this); state = UPLOADING; statusMessage = tr(STR_UPLOAD_PROGRESS); } requestUpdateAndWait(); // localProgress was pre-computed in EpubReaderActivity before the Epub was released. KOReaderProgress progress; progress.document = documentHash; progress.progress = localProgress.xpath; progress.percentage = localProgress.percentage; // Optionally include document metadata (KOReader PR #15306) if (KOREADER_STORE.getSendMetadata()) { KOReaderMetadata meta; // Extract filename from path const auto lastSlash = epubPath.rfind('/'); meta.filename = (lastSlash != std::string::npos) ? epubPath.substr(lastSlash + 1) : epubPath; meta.title = epub->getTitle(); meta.authors = epub->getAuthor(); progress.metadata = std::move(meta); } const auto result = KOReaderSyncClient::updateProgress(progress); // Drop the radio while user reads the result; full teardown happens at silent reboot. esp_wifi_stop(); if (result != KOReaderSyncClient::OK) { { RenderLock lock(*this); state = SYNC_FAILED; statusMessage = KOReaderSyncClient::errorString(result); } requestUpdate(); return; } { RenderLock lock(*this); state = UPLOAD_COMPLETE; } requestUpdate(true); } void KOReaderSyncActivity::onEnter() { Activity::onEnter(); ReaderUtils::applyOrientation(renderer, SETTINGS.orientation); // Check for credentials first if (!KOREADER_STORE.hasCredentials()) { state = NO_CREDENTIALS; requestUpdate(); return; } // Past this point every path uses WiFi. wifiActivated = true; // Check if already connected (e.g. from settings page auth) if (WiFi.status() == WL_CONNECTED) { LOG_DBG("KOSync", "Already connected to WiFi"); onWifiSelectionComplete(true); return; } // Launch WiFi selection subactivity LOG_DBG("KOSync", "Launching WifiSelectionActivity..."); startActivityForResult(std::make_unique(renderer, mappedInput), [this](const ActivityResult& result) { onWifiSelectionComplete(!result.isCancelled); }); } void KOReaderSyncActivity::onExit() { Activity::onExit(); if (wifiActivated) { WiFi.disconnect(false); delay(30); silentRestartToReader(); } } void KOReaderSyncActivity::render(RenderLock&&) { renderer.clearScreen(); auto metrics = UITheme::getInstance().getMetrics(); Rect screen = UITheme::getInstance().getScreenSafeArea(renderer, true, false); GUI.drawHeader(renderer, Rect{screen.x, screen.y + metrics.topPadding, screen.width, metrics.headerHeight}, tr(STR_KOREADER_SYNC)); int top = screen.y + screen.height / 2 - 40; if (state == NO_CREDENTIALS) { UITheme::drawCenteredText(renderer, screen, UI_10_FONT_ID, top, tr(STR_NO_CREDENTIALS_MSG), true, EpdFontFamily::BOLD); UITheme::drawCenteredText(renderer, screen, UI_10_FONT_ID, top + 40, tr(STR_KOREADER_SETUP_HINT), 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 == SYNCING || state == UPLOADING) { UITheme::drawCenteredText(renderer, screen, UI_10_FONT_ID, top, statusMessage.c_str(), true, EpdFontFamily::BOLD); renderer.displayBuffer(); return; } if (state == SHOWING_RESULT) { // Show comparison top = screen.y + metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; renderer.drawCenteredText(UI_10_FONT_ID, top, tr(STR_PROGRESS_FOUND), true, EpdFontFamily::BOLD); // Remote chapter name requires Epub (loaded lazily in performSync before this state). const int remoteTocIndex = epub->getTocIndexForSpineIndex(remotePosition.spineIndex); const std::string remoteChapter = (remoteTocIndex >= 0) ? epub->getTocItem(remoteTocIndex).title : (std::string(tr(STR_SECTION_PREFIX)) + std::to_string(remotePosition.spineIndex + 1)); // Local chapter name was pre-computed before Epub was released. const std::string localChapter = !localChapterName.empty() ? localChapterName : (std::string(tr(STR_SECTION_PREFIX)) + std::to_string(currentSpineIndex + 1)); // Remote progress - chapter and page renderer.drawText(UI_10_FONT_ID, screen.x + metrics.contentSidePadding, top + 40, tr(STR_REMOTE_LABEL), true); char remoteChapterStr[128]; snprintf(remoteChapterStr, sizeof(remoteChapterStr), " %s", remoteChapter.c_str()); renderer.drawText(UI_10_FONT_ID, screen.x + metrics.contentSidePadding, top + 65, remoteChapterStr); char remotePageStr[64]; snprintf(remotePageStr, sizeof(remotePageStr), tr(STR_PAGE_OVERALL_FORMAT), remotePosition.pageNumber + 1, remoteProgress.percentage * 100); renderer.drawText(UI_10_FONT_ID, screen.x + metrics.contentSidePadding, top + 90, remotePageStr); if (!remoteProgress.device.empty()) { char deviceStr[64]; snprintf(deviceStr, sizeof(deviceStr), tr(STR_DEVICE_FROM_FORMAT), remoteProgress.device.c_str()); renderer.drawText(UI_10_FONT_ID, screen.x + metrics.contentSidePadding, top + 115, deviceStr); } // Local progress - chapter and page renderer.drawText(UI_10_FONT_ID, screen.x + metrics.contentSidePadding, top + 150, tr(STR_LOCAL_LABEL), true); char localChapterStr[128]; snprintf(localChapterStr, sizeof(localChapterStr), " %s", localChapter.c_str()); renderer.drawText(UI_10_FONT_ID, screen.x + metrics.contentSidePadding, top + 175, localChapterStr); char localPageStr[64]; snprintf(localPageStr, sizeof(localPageStr), tr(STR_PAGE_TOTAL_OVERALL_FORMAT), currentPage + 1, totalPagesInSpine, localProgress.percentage * 100); renderer.drawText(UI_10_FONT_ID, screen.x + metrics.contentSidePadding, top + 200, localPageStr); const int optionY = top + 230; const int optionHeight = 30; // Apply option if (selectedOption == 0) { renderer.fillRect(screen.x, optionY - 2, screen.width - 1, optionHeight); } renderer.drawText(UI_10_FONT_ID, screen.x + metrics.contentSidePadding, optionY, tr(STR_APPLY_REMOTE), selectedOption != 0); // Upload option if (selectedOption == 1) { renderer.fillRect(screen.x, optionY + optionHeight - 2, screen.width - 1, optionHeight); } renderer.drawText(UI_10_FONT_ID, screen.x + metrics.contentSidePadding, optionY + optionHeight, tr(STR_UPLOAD_LOCAL), selectedOption != 1); // Bottom button hints const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN)); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); return; } if (state == NO_REMOTE_PROGRESS) { UITheme::drawCenteredText(renderer, screen, UI_10_FONT_ID, top, tr(STR_NO_REMOTE_MSG), true, EpdFontFamily::BOLD); UITheme::drawCenteredText(renderer, screen, UI_10_FONT_ID, top + 40, tr(STR_UPLOAD_PROMPT)); const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_UPLOAD), "", ""); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); return; } if (state == UPLOAD_COMPLETE) { UITheme::drawCenteredText(renderer, screen, UI_10_FONT_ID, top, tr(STR_UPLOAD_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) { UITheme::drawCenteredText(renderer, screen, UI_10_FONT_ID, top, tr(STR_SYNC_FAILED_MSG), true, EpdFontFamily::BOLD); UITheme::drawCenteredText(renderer, screen, UI_10_FONT_ID, top + 40, statusMessage.c_str()); const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); return; } } void KOReaderSyncActivity::loop() { if (state == NO_CREDENTIALS || state == SYNC_FAILED || state == UPLOAD_COMPLETE) { if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { returnToReader(); } return; } if (state == SHOWING_RESULT) { // Navigate options if (mappedInput.wasReleased(MappedInputManager::Button::Up) || mappedInput.wasReleased(MappedInputManager::Button::Left)) { selectedOption = (selectedOption + 1) % 2; // Wrap around among 2 options requestUpdate(); } else if (mappedInput.wasReleased(MappedInputManager::Button::Down) || mappedInput.wasReleased(MappedInputManager::Button::Right)) { selectedOption = (selectedOption + 1) % 2; // Wrap around among 2 options requestUpdate(); } if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (selectedOption == 0) { saveProgressAndReturn(remotePosition.spineIndex, remotePosition.pageNumber); } else if (selectedOption == 1) { // Upload local progress performUpload(); } } if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { returnToReader(); } return; } if (state == NO_REMOTE_PROGRESS) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { // Calculate hash if not done yet if (documentHash.empty()) { if (KOREADER_STORE.getMatchMethod() == DocumentMatchMethod::FILENAME) { documentHash = KOReaderDocumentId::calculateFromFilename(epubPath); } else { documentHash = KOReaderDocumentId::calculate(epubPath); } } performUpload(); } if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { returnToReader(); } return; } }