#include "KOReaderSyncActivity.h" #include #include #include #include "KOReaderCredentialStore.h" #include "KOReaderDocumentId.h" #include "MappedInputManager.h" #include "activities/network/WifiSelectionActivity.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) { Serial.printf("[%lu] [KOSync] NTP time synced\n", millis()); } else { Serial.printf("[%lu] [KOSync] NTP sync timeout, using fallback\n", millis()); } } } // namespace void KOReaderSyncActivity::taskTrampoline(void* param) { auto* self = static_cast(param); self->displayTaskLoop(); } void KOReaderSyncActivity::onWifiSelectionComplete(const bool success) { exitActivity(); if (!success) { Serial.printf("[%lu] [KOSync] WiFi connection failed, exiting\n", millis()); onCancel(); return; } Serial.printf("[%lu] [KOSync] WiFi connected, starting sync\n", millis()); xSemaphoreTake(renderingMutex, portMAX_DELAY); state = SYNCING; statusMessage = "Syncing time..."; xSemaphoreGive(renderingMutex); updateRequired = true; // Sync time with NTP before making API requests syncTimeWithNTP(); xSemaphoreTake(renderingMutex, portMAX_DELAY); statusMessage = "Calculating document hash..."; xSemaphoreGive(renderingMutex); updateRequired = 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()) { xSemaphoreTake(renderingMutex, portMAX_DELAY); state = SYNC_FAILED; statusMessage = "Failed to calculate document hash"; xSemaphoreGive(renderingMutex); updateRequired = true; return; } Serial.printf("[%lu] [KOSync] Document hash: %s\n", millis(), documentHash.c_str()); xSemaphoreTake(renderingMutex, portMAX_DELAY); statusMessage = "Fetching remote progress..."; xSemaphoreGive(renderingMutex); updateRequired = true; vTaskDelay(10 / portTICK_PERIOD_MS); // Fetch remote progress const auto result = KOReaderSyncClient::getProgress(documentHash, remoteProgress); if (result == KOReaderSyncClient::NOT_FOUND) { // No remote progress - offer to upload xSemaphoreTake(renderingMutex, portMAX_DELAY); state = NO_REMOTE_PROGRESS; hasRemoteProgress = false; xSemaphoreGive(renderingMutex); updateRequired = true; return; } if (result != KOReaderSyncClient::OK) { xSemaphoreTake(renderingMutex, portMAX_DELAY); state = SYNC_FAILED; statusMessage = KOReaderSyncClient::errorString(result); xSemaphoreGive(renderingMutex); updateRequired = true; return; } // Convert remote progress to CrossPoint position hasRemoteProgress = true; KOReaderPosition koPos = {remoteProgress.progress, remoteProgress.percentage}; remotePosition = ProgressMapper::toCrossPoint(epub, koPos, totalPagesInSpine); // Calculate local progress in KOReader format (for display) CrossPointPosition localPos = {currentSpineIndex, currentPage, totalPagesInSpine}; localProgress = ProgressMapper::toKOReader(epub, localPos); xSemaphoreTake(renderingMutex, portMAX_DELAY); state = SHOWING_RESULT; selectedOption = 0; // Default to "Apply" xSemaphoreGive(renderingMutex); updateRequired = true; } void KOReaderSyncActivity::performUpload() { xSemaphoreTake(renderingMutex, portMAX_DELAY); state = UPLOADING; statusMessage = "Uploading progress..."; xSemaphoreGive(renderingMutex); updateRequired = true; vTaskDelay(10 / portTICK_PERIOD_MS); // 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) { xSemaphoreTake(renderingMutex, portMAX_DELAY); state = SYNC_FAILED; statusMessage = KOReaderSyncClient::errorString(result); xSemaphoreGive(renderingMutex); updateRequired = true; return; } xSemaphoreTake(renderingMutex, portMAX_DELAY); state = UPLOAD_COMPLETE; xSemaphoreGive(renderingMutex); updateRequired = true; } void KOReaderSyncActivity::onEnter() { ActivityWithSubactivity::onEnter(); renderingMutex = xSemaphoreCreateMutex(); xTaskCreate(&KOReaderSyncActivity::taskTrampoline, "KOSyncTask", 4096, // Stack size (larger for network operations) this, // Parameters 1, // Priority &displayTaskHandle // Task handle ); // Check for credentials first if (!KOREADER_STORE.hasCredentials()) { state = NO_CREDENTIALS; updateRequired = true; return; } // Turn on WiFi Serial.printf("[%lu] [KOSync] Turning on WiFi...\n", millis()); WiFi.mode(WIFI_STA); // Check if already connected if (WiFi.status() == WL_CONNECTED) { Serial.printf("[%lu] [KOSync] Already connected to WiFi\n", millis()); state = SYNCING; statusMessage = "Syncing time..."; updateRequired = true; // Perform sync directly (will be handled in loop) xTaskCreate( [](void* param) { auto* self = static_cast(param); // Sync time first syncTimeWithNTP(); xSemaphoreTake(self->renderingMutex, portMAX_DELAY); self->statusMessage = "Calculating document hash..."; xSemaphoreGive(self->renderingMutex); self->updateRequired = true; self->performSync(); vTaskDelete(nullptr); }, "SyncTask", 4096, this, 1, nullptr); return; } // Launch WiFi selection subactivity Serial.printf("[%lu] [KOSync] Launching WifiSelectionActivity...\n", millis()); enterNewActivity(new WifiSelectionActivity(renderer, mappedInput, [this](const bool connected) { onWifiSelectionComplete(connected); })); } void KOReaderSyncActivity::onExit() { ActivityWithSubactivity::onExit(); // Turn off wifi WiFi.disconnect(false); delay(100); WiFi.mode(WIFI_OFF); delay(100); // Wait until not rendering to delete task xSemaphoreTake(renderingMutex, portMAX_DELAY); if (displayTaskHandle) { vTaskDelete(displayTaskHandle); displayTaskHandle = nullptr; } vSemaphoreDelete(renderingMutex); renderingMutex = nullptr; } void KOReaderSyncActivity::displayTaskLoop() { while (true) { if (updateRequired) { updateRequired = false; xSemaphoreTake(renderingMutex, portMAX_DELAY); render(); xSemaphoreGive(renderingMutex); } vTaskDelay(10 / portTICK_PERIOD_MS); } } void KOReaderSyncActivity::render() { if (subActivity) { return; } const auto pageWidth = renderer.getScreenWidth(); renderer.clearScreen(); renderer.drawCenteredText(UI_12_FONT_ID, marginTop, "KOReader Sync", true, EpdFontFamily::BOLD); if (state == NO_CREDENTIALS) { renderer.drawCenteredText(UI_10_FONT_ID, marginTop + 265, "No credentials configured", true, EpdFontFamily::BOLD); renderer.drawCenteredText(UI_10_FONT_ID, marginTop + 305, "Set up KOReader account in Settings"); const auto labels = mappedInput.mapLabels("Back", "", "", ""); renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); return; } if (state == SYNCING || state == UPLOADING) { renderer.drawCenteredText(UI_10_FONT_ID, marginTop + 285, statusMessage.c_str(), true, EpdFontFamily::BOLD); renderer.displayBuffer(); return; } if (state == SHOWING_RESULT) { // Show comparison renderer.drawCenteredText(UI_10_FONT_ID, marginTop + 105, "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 : ("Section " + std::to_string(remotePosition.spineIndex + 1)); const std::string localChapter = (localTocIndex >= 0) ? epub->getTocItem(localTocIndex).title : ("Section " + std::to_string(currentSpineIndex + 1)); // Remote progress - chapter and page renderer.drawText(UI_10_FONT_ID, marginLeft, marginTop + 145, "Remote:", true); char remoteChapterStr[128]; snprintf(remoteChapterStr, sizeof(remoteChapterStr), " %s", remoteChapter.c_str()); renderer.drawText(UI_10_FONT_ID, marginLeft, marginTop + 170, remoteChapterStr); char remotePageStr[64]; snprintf(remotePageStr, sizeof(remotePageStr), " Page %d, %.2f%% overall", remotePosition.pageNumber + 1, remoteProgress.percentage * 100); renderer.drawText(UI_10_FONT_ID, marginLeft, marginTop + 195, remotePageStr); if (!remoteProgress.device.empty()) { char deviceStr[64]; snprintf(deviceStr, sizeof(deviceStr), " From: %s", remoteProgress.device.c_str()); renderer.drawText(UI_10_FONT_ID, marginLeft, marginTop + 220, deviceStr); } // Local progress - chapter and page renderer.drawText(UI_10_FONT_ID, marginLeft, marginTop + 255, "Local:", true); char localChapterStr[128]; snprintf(localChapterStr, sizeof(localChapterStr), " %s", localChapter.c_str()); renderer.drawText(UI_10_FONT_ID, marginLeft, marginTop + 280, localChapterStr); char localPageStr[64]; snprintf(localPageStr, sizeof(localPageStr), " Page %d/%d, %.2f%% overall", currentPage + 1, totalPagesInSpine, localProgress.percentage * 100); renderer.drawText(UI_10_FONT_ID, marginLeft, marginTop + 305, localPageStr); // Options const int optionY = marginTop + 335; const int optionHeight = 30; // Apply option if (selectedOption == 0) { renderer.fillRect(0, optionY - 2, pageWidth - 1, optionHeight); } renderer.drawText(UI_10_FONT_ID, marginLeft, optionY, "Apply remote progress", selectedOption != 0); // Upload option if (selectedOption == 1) { renderer.fillRect(0, optionY + optionHeight - 2, pageWidth - 1, optionHeight); } renderer.drawText(UI_10_FONT_ID, marginLeft, optionY + optionHeight, "Upload local progress", selectedOption != 1); // Cancel option if (selectedOption == 2) { renderer.fillRect(0, optionY + optionHeight * 2 - 2, pageWidth - 1, optionHeight); } renderer.drawText(UI_10_FONT_ID, marginLeft, optionY + optionHeight * 2, "Cancel", selectedOption != 2); const auto labels = mappedInput.mapLabels("", "Select", "", ""); renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); return; } if (state == NO_REMOTE_PROGRESS) { renderer.drawCenteredText(UI_10_FONT_ID, marginTop + 265, "No remote progress found", true, EpdFontFamily::BOLD); renderer.drawCenteredText(UI_10_FONT_ID, marginTop + 305, "Upload current position?"); const auto labels = mappedInput.mapLabels("Cancel", "Upload", "", ""); renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); return; } if (state == UPLOAD_COMPLETE) { renderer.drawCenteredText(UI_10_FONT_ID, marginTop + 285, "Progress uploaded!", true, EpdFontFamily::BOLD); const auto labels = mappedInput.mapLabels("Back", "", "", ""); renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); return; } if (state == SYNC_FAILED) { renderer.drawCenteredText(UI_10_FONT_ID, marginTop + 265, "Sync failed", true, EpdFontFamily::BOLD); renderer.drawCenteredText(UI_10_FONT_ID, marginTop + 305, statusMessage.c_str()); const auto labels = mappedInput.mapLabels("Back", "", "", ""); renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); return; } } void KOReaderSyncActivity::loop() { if (subActivity) { subActivity->loop(); return; } if (state == NO_CREDENTIALS || state == SYNC_FAILED || state == UPLOAD_COMPLETE) { if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { onCancel(); } return; } if (state == SHOWING_RESULT) { // Navigate options if (mappedInput.wasPressed(MappedInputManager::Button::Up) || mappedInput.wasPressed(MappedInputManager::Button::Left)) { selectedOption = (selectedOption + 2) % 3; // Wrap around updateRequired = true; } else if (mappedInput.wasPressed(MappedInputManager::Button::Down) || mappedInput.wasPressed(MappedInputManager::Button::Right)) { selectedOption = (selectedOption + 1) % 3; updateRequired = true; } if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { if (selectedOption == 0) { // Apply remote progress onSyncComplete(remotePosition.spineIndex, remotePosition.pageNumber); } else if (selectedOption == 1) { // Upload local progress performUpload(); } else { // Cancel onCancel(); } } if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { onCancel(); } return; } if (state == NO_REMOTE_PROGRESS) { if (mappedInput.wasPressed(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.wasPressed(MappedInputManager::Button::Back)) { onCancel(); } return; } }