#include "KOReaderSyncActivity.h" #include #include #include #include #include #include "KOReaderCredentialStore.h" #include "KOReaderDocumentId.h" #include "MappedInputManager.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"); } } void wifiOff() { if (esp_sntp_enabled()) { esp_sntp_stop(); } WiFi.disconnect(false); delay(100); WiFi.mode(WIFI_OFF); delay(100); } } // namespace void KOReaderSyncActivity::deferFinish(bool success) { RenderLock lock(*this); pendingFinishSuccess = success; pendingFinish = true; } void KOReaderSyncActivity::onWifiSelectionComplete(const bool success) { if (!success) { LOG_DBG("KOSync", "WiFi connection failed, exiting"); ActivityResult result; result.isCancelled = true; setResult(std::move(result)); finish(); 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()) { if (syncMode == SyncMode::PUSH_ONLY) { deferFinish(false); return; } { 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(); if (syncMode == SyncMode::PUSH_ONLY) { // Skip fetching remote progress entirely -- just upload local position performUpload(); return; } // Fetch remote progress const auto result = KOReaderSyncClient::getProgress(documentHash, remoteProgress); if (result == KOReaderSyncClient::NOT_FOUND) { { 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; } // Convert remote progress to CrossPoint position hasRemoteProgress = true; KOReaderPosition koPos = {remoteProgress.progress, remoteProgress.percentage}; remotePosition = ProgressMapper::toCrossPoint(epub, koPos, currentSpineIndex, totalPagesInSpine); // Calculate local progress in KOReader format (for display) CrossPointPosition localPos = {currentSpineIndex, currentPage, totalPagesInSpine}; localProgress = ProgressMapper::toKOReader(epub, localPos); { 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(); // Convert current position to KOReader format CrossPointPosition localPos = {currentSpineIndex, currentPage, totalPagesInSpine}; KOReaderPosition koPos = ProgressMapper::toKOReader(epub, localPos); KOReaderProgress progress; progress.document = documentHash; progress.progress = koPos.xpath; progress.percentage = koPos.percentage; const auto result = KOReaderSyncClient::updateProgress(progress); if (result != KOReaderSyncClient::OK) { wifiOff(); if (syncMode == SyncMode::PUSH_ONLY) { LOG_DBG("KOSync", "PUSH_ONLY upload failed: %s", KOReaderSyncClient::errorString(result)); deferFinish(false); return; } { RenderLock lock(*this); state = SYNC_FAILED; statusMessage = KOReaderSyncClient::errorString(result); } requestUpdate(); return; } wifiOff(); if (syncMode == SyncMode::PUSH_ONLY) { LOG_DBG("KOSync", "PUSH_ONLY upload succeeded"); deferFinish(true); return; } { RenderLock lock(*this); state = UPLOAD_COMPLETE; } requestUpdate(true); } void KOReaderSyncActivity::onEnter() { Activity::onEnter(); // Check for credentials first if (!KOREADER_STORE.hasCredentials()) { if (syncMode == SyncMode::PUSH_ONLY) { LOG_DBG("KOSync", "PUSH_ONLY: no credentials, finishing silently"); deferFinish(false); return; } state = NO_CREDENTIALS; requestUpdate(); return; } // 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(); wifiOff(); } void KOReaderSyncActivity::render(RenderLock&&) { const auto pageWidth = renderer.getScreenWidth(); renderer.clearScreen(); renderer.drawCenteredText(UI_12_FONT_ID, 15, tr(STR_KOREADER_SYNC), true, EpdFontFamily::BOLD); if (state == NO_CREDENTIALS) { renderer.drawCenteredText(UI_10_FONT_ID, 280, tr(STR_NO_CREDENTIALS_MSG), true, EpdFontFamily::BOLD); renderer.drawCenteredText(UI_10_FONT_ID, 320, tr(STR_KOREADER_SETUP_HINT)); 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) { renderer.drawCenteredText(UI_10_FONT_ID, 300, statusMessage.c_str(), true, EpdFontFamily::BOLD); renderer.displayBuffer(); return; } if (state == SHOWING_RESULT) { // Show comparison renderer.drawCenteredText(UI_10_FONT_ID, 120, tr(STR_PROGRESS_FOUND), true, EpdFontFamily::BOLD); // Get chapter names from TOC const int remoteTocIndex = epub->getTocIndexForSpineIndex(remotePosition.spineIndex); const int localTocIndex = epub->getTocIndexForSpineIndex(currentSpineIndex); const std::string remoteChapter = (remoteTocIndex >= 0) ? epub->getTocItem(remoteTocIndex).title : (std::string(tr(STR_SECTION_PREFIX)) + std::to_string(remotePosition.spineIndex + 1)); const std::string localChapter = (localTocIndex >= 0) ? epub->getTocItem(localTocIndex).title : (std::string(tr(STR_SECTION_PREFIX)) + std::to_string(currentSpineIndex + 1)); // Remote progress - chapter and page renderer.drawText(UI_10_FONT_ID, 20, 160, tr(STR_REMOTE_LABEL), true); char remoteChapterStr[128]; snprintf(remoteChapterStr, sizeof(remoteChapterStr), " %s", remoteChapter.c_str()); renderer.drawText(UI_10_FONT_ID, 20, 185, 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, 20, 210, 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, 20, 235, deviceStr); } // Local progress - chapter and page renderer.drawText(UI_10_FONT_ID, 20, 270, tr(STR_LOCAL_LABEL), true); char localChapterStr[128]; snprintf(localChapterStr, sizeof(localChapterStr), " %s", localChapter.c_str()); renderer.drawText(UI_10_FONT_ID, 20, 295, 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, 20, 320, localPageStr); const int optionY = 350; const int optionHeight = 30; // Apply option if (selectedOption == 0) { renderer.fillRect(0, optionY - 2, pageWidth - 1, optionHeight); } renderer.drawText(UI_10_FONT_ID, 20, optionY, tr(STR_APPLY_REMOTE), selectedOption != 0); // Upload option if (selectedOption == 1) { renderer.fillRect(0, optionY + optionHeight - 2, pageWidth - 1, optionHeight); } renderer.drawText(UI_10_FONT_ID, 20, 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) { renderer.drawCenteredText(UI_10_FONT_ID, 280, tr(STR_NO_REMOTE_MSG), true, EpdFontFamily::BOLD); renderer.drawCenteredText(UI_10_FONT_ID, 320, 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) { renderer.drawCenteredText(UI_10_FONT_ID, 300, 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) { 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()); 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 (pendingFinish) { pendingFinish = false; ActivityResult result; result.isCancelled = !pendingFinishSuccess; setResult(std::move(result)); finish(); return; } if (state == NO_CREDENTIALS || state == SYNC_FAILED || state == UPLOAD_COMPLETE) { if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { ActivityResult result; result.isCancelled = true; setResult(std::move(result)); finish(); } 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) { // Wifi will be turned off in onExit() setResult(SyncResult{remotePosition.spineIndex, remotePosition.pageNumber}); finish(); } else if (selectedOption == 1) { // Upload local progress performUpload(); } } if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { ActivityResult result; result.isCancelled = true; setResult(std::move(result)); finish(); } 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)) { ActivityResult result; result.isCancelled = true; setResult(std::move(result)); finish(); } return; } }