A fork of https://github.com/crosspoint-reader/crosspoint-reader
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Adds KOReader Sync support (#232)

## Summary

- Adds KOReader progress sync integration, allowing CrossPoint to sync
reading positions with other
KOReader-compatible devices
- Stores credentials securely with XOR obfuscation
- Uses KOReader's partial MD5 document hashing for cross-device book
matching
- Syncs position via percentage with estimated XPath for compatibility

# Features
- Settings: KOReader Username, Password, and Authenticate options
- Sync from chapters menu: "Sync Progress" option appears when
credentials are configured
- Bidirectional sync: Can apply remote progress or upload local progress

---------

Co-authored-by: Dave Allie <dave@daveallie.com>

authored by

Justin Mitchell
Dave Allie
and committed by
GitHub
f69cddf2 7185e5d2

+1974 -39
+6 -5
lib/Epub/Epub.cpp
··· 609 609 return 0; 610 610 } 611 611 612 - // Calculate progress in book 613 - uint8_t Epub::calculateProgress(const int currentSpineIndex, const float currentSpineRead) const { 612 + // Calculate progress in book (returns 0.0-1.0) 613 + float Epub::calculateProgress(const int currentSpineIndex, const float currentSpineRead) const { 614 614 const size_t bookSize = getBookSize(); 615 615 if (bookSize == 0) { 616 - return 0; 616 + return 0.0f; 617 617 } 618 618 const size_t prevChapterSize = (currentSpineIndex >= 1) ? getCumulativeSpineItemSize(currentSpineIndex - 1) : 0; 619 619 const size_t curChapterSize = getCumulativeSpineItemSize(currentSpineIndex) - prevChapterSize; 620 - const size_t sectionProgSize = currentSpineRead * curChapterSize; 621 - return round(static_cast<float>(prevChapterSize + sectionProgSize) / bookSize * 100.0); 620 + const float sectionProgSize = currentSpineRead * static_cast<float>(curChapterSize); 621 + const float totalProgress = static_cast<float>(prevChapterSize) + sectionProgSize; 622 + return totalProgress / static_cast<float>(bookSize); 622 623 }
+1 -1
lib/Epub/Epub.h
··· 62 62 int getSpineIndexForTextReference() const; 63 63 64 64 size_t getBookSize() const; 65 - uint8_t calculateProgress(int currentSpineIndex, float currentSpineRead) const; 65 + float calculateProgress(int currentSpineIndex, float currentSpineRead) const; 66 66 };
+168
lib/KOReaderSync/KOReaderCredentialStore.cpp
··· 1 + #include "KOReaderCredentialStore.h" 2 + 3 + #include <HardwareSerial.h> 4 + #include <MD5Builder.h> 5 + #include <SDCardManager.h> 6 + #include <Serialization.h> 7 + 8 + // Initialize the static instance 9 + KOReaderCredentialStore KOReaderCredentialStore::instance; 10 + 11 + namespace { 12 + // File format version 13 + constexpr uint8_t KOREADER_FILE_VERSION = 1; 14 + 15 + // KOReader credentials file path 16 + constexpr char KOREADER_FILE[] = "/.crosspoint/koreader.bin"; 17 + 18 + // Default sync server URL 19 + constexpr char DEFAULT_SERVER_URL[] = "https://sync.koreader.rocks:443"; 20 + 21 + // Obfuscation key - "KOReader" in ASCII 22 + // This is NOT cryptographic security, just prevents casual file reading 23 + constexpr uint8_t OBFUSCATION_KEY[] = {0x4B, 0x4F, 0x52, 0x65, 0x61, 0x64, 0x65, 0x72}; 24 + constexpr size_t KEY_LENGTH = sizeof(OBFUSCATION_KEY); 25 + } // namespace 26 + 27 + void KOReaderCredentialStore::obfuscate(std::string& data) const { 28 + for (size_t i = 0; i < data.size(); i++) { 29 + data[i] ^= OBFUSCATION_KEY[i % KEY_LENGTH]; 30 + } 31 + } 32 + 33 + bool KOReaderCredentialStore::saveToFile() const { 34 + // Make sure the directory exists 35 + SdMan.mkdir("/.crosspoint"); 36 + 37 + FsFile file; 38 + if (!SdMan.openFileForWrite("KRS", KOREADER_FILE, file)) { 39 + return false; 40 + } 41 + 42 + // Write header 43 + serialization::writePod(file, KOREADER_FILE_VERSION); 44 + 45 + // Write username (plaintext - not particularly sensitive) 46 + serialization::writeString(file, username); 47 + Serial.printf("[%lu] [KRS] Saving username: %s\n", millis(), username.c_str()); 48 + 49 + // Write password (obfuscated) 50 + std::string obfuscatedPwd = password; 51 + obfuscate(obfuscatedPwd); 52 + serialization::writeString(file, obfuscatedPwd); 53 + 54 + // Write server URL 55 + serialization::writeString(file, serverUrl); 56 + 57 + // Write match method 58 + serialization::writePod(file, static_cast<uint8_t>(matchMethod)); 59 + 60 + file.close(); 61 + Serial.printf("[%lu] [KRS] Saved KOReader credentials to file\n", millis()); 62 + return true; 63 + } 64 + 65 + bool KOReaderCredentialStore::loadFromFile() { 66 + FsFile file; 67 + if (!SdMan.openFileForRead("KRS", KOREADER_FILE, file)) { 68 + Serial.printf("[%lu] [KRS] No credentials file found\n", millis()); 69 + return false; 70 + } 71 + 72 + // Read and verify version 73 + uint8_t version; 74 + serialization::readPod(file, version); 75 + if (version != KOREADER_FILE_VERSION) { 76 + Serial.printf("[%lu] [KRS] Unknown file version: %u\n", millis(), version); 77 + file.close(); 78 + return false; 79 + } 80 + 81 + // Read username 82 + if (file.available()) { 83 + serialization::readString(file, username); 84 + } else { 85 + username.clear(); 86 + } 87 + 88 + // Read and deobfuscate password 89 + if (file.available()) { 90 + serialization::readString(file, password); 91 + obfuscate(password); // XOR is symmetric, so same function deobfuscates 92 + } else { 93 + password.clear(); 94 + } 95 + 96 + // Read server URL 97 + if (file.available()) { 98 + serialization::readString(file, serverUrl); 99 + } else { 100 + serverUrl.clear(); 101 + } 102 + 103 + // Read match method 104 + if (file.available()) { 105 + uint8_t method; 106 + serialization::readPod(file, method); 107 + matchMethod = static_cast<DocumentMatchMethod>(method); 108 + } else { 109 + matchMethod = DocumentMatchMethod::FILENAME; 110 + } 111 + 112 + file.close(); 113 + Serial.printf("[%lu] [KRS] Loaded KOReader credentials for user: %s\n", millis(), username.c_str()); 114 + return true; 115 + } 116 + 117 + void KOReaderCredentialStore::setCredentials(const std::string& user, const std::string& pass) { 118 + username = user; 119 + password = pass; 120 + Serial.printf("[%lu] [KRS] Set credentials for user: %s\n", millis(), user.c_str()); 121 + } 122 + 123 + std::string KOReaderCredentialStore::getMd5Password() const { 124 + if (password.empty()) { 125 + return ""; 126 + } 127 + 128 + // Calculate MD5 hash of password using ESP32's MD5Builder 129 + MD5Builder md5; 130 + md5.begin(); 131 + md5.add(password.c_str()); 132 + md5.calculate(); 133 + 134 + return md5.toString().c_str(); 135 + } 136 + 137 + bool KOReaderCredentialStore::hasCredentials() const { return !username.empty() && !password.empty(); } 138 + 139 + void KOReaderCredentialStore::clearCredentials() { 140 + username.clear(); 141 + password.clear(); 142 + saveToFile(); 143 + Serial.printf("[%lu] [KRS] Cleared KOReader credentials\n", millis()); 144 + } 145 + 146 + void KOReaderCredentialStore::setServerUrl(const std::string& url) { 147 + serverUrl = url; 148 + Serial.printf("[%lu] [KRS] Set server URL: %s\n", millis(), url.empty() ? "(default)" : url.c_str()); 149 + } 150 + 151 + std::string KOReaderCredentialStore::getBaseUrl() const { 152 + if (serverUrl.empty()) { 153 + return DEFAULT_SERVER_URL; 154 + } 155 + 156 + // Normalize URL: add http:// if no protocol specified (local servers typically don't have SSL) 157 + if (serverUrl.find("://") == std::string::npos) { 158 + return "http://" + serverUrl; 159 + } 160 + 161 + return serverUrl; 162 + } 163 + 164 + void KOReaderCredentialStore::setMatchMethod(DocumentMatchMethod method) { 165 + matchMethod = method; 166 + Serial.printf("[%lu] [KRS] Set match method: %s\n", millis(), 167 + method == DocumentMatchMethod::FILENAME ? "Filename" : "Binary"); 168 + }
+69
lib/KOReaderSync/KOReaderCredentialStore.h
··· 1 + #pragma once 2 + #include <cstdint> 3 + #include <string> 4 + 5 + // Document matching method for KOReader sync 6 + enum class DocumentMatchMethod : uint8_t { 7 + FILENAME = 0, // Match by filename (simpler, works across different file sources) 8 + BINARY = 1, // Match by partial MD5 of file content (more accurate, but files must be identical) 9 + }; 10 + 11 + /** 12 + * Singleton class for storing KOReader sync credentials on the SD card. 13 + * Credentials are stored in /sd/.crosspoint/koreader.bin with basic 14 + * XOR obfuscation to prevent casual reading (not cryptographically secure). 15 + */ 16 + class KOReaderCredentialStore { 17 + private: 18 + static KOReaderCredentialStore instance; 19 + std::string username; 20 + std::string password; 21 + std::string serverUrl; // Custom sync server URL (empty = default) 22 + DocumentMatchMethod matchMethod = DocumentMatchMethod::FILENAME; // Default to filename for compatibility 23 + 24 + // Private constructor for singleton 25 + KOReaderCredentialStore() = default; 26 + 27 + // XOR obfuscation (symmetric - same for encode/decode) 28 + void obfuscate(std::string& data) const; 29 + 30 + public: 31 + // Delete copy constructor and assignment 32 + KOReaderCredentialStore(const KOReaderCredentialStore&) = delete; 33 + KOReaderCredentialStore& operator=(const KOReaderCredentialStore&) = delete; 34 + 35 + // Get singleton instance 36 + static KOReaderCredentialStore& getInstance() { return instance; } 37 + 38 + // Save/load from SD card 39 + bool saveToFile() const; 40 + bool loadFromFile(); 41 + 42 + // Credential management 43 + void setCredentials(const std::string& user, const std::string& pass); 44 + const std::string& getUsername() const { return username; } 45 + const std::string& getPassword() const { return password; } 46 + 47 + // Get MD5 hash of password for API authentication 48 + std::string getMd5Password() const; 49 + 50 + // Check if credentials are set 51 + bool hasCredentials() const; 52 + 53 + // Clear credentials 54 + void clearCredentials(); 55 + 56 + // Server URL management 57 + void setServerUrl(const std::string& url); 58 + const std::string& getServerUrl() const { return serverUrl; } 59 + 60 + // Get base URL for API calls (with http:// normalization if no protocol, falls back to default) 61 + std::string getBaseUrl() const; 62 + 63 + // Document matching method 64 + void setMatchMethod(DocumentMatchMethod method); 65 + DocumentMatchMethod getMatchMethod() const { return matchMethod; } 66 + }; 67 + 68 + // Helper macro to access credential store 69 + #define KOREADER_STORE KOReaderCredentialStore::getInstance()
+96
lib/KOReaderSync/KOReaderDocumentId.cpp
··· 1 + #include "KOReaderDocumentId.h" 2 + 3 + #include <HardwareSerial.h> 4 + #include <MD5Builder.h> 5 + #include <SDCardManager.h> 6 + 7 + namespace { 8 + // Extract filename from path (everything after last '/') 9 + std::string getFilename(const std::string& path) { 10 + const size_t pos = path.rfind('/'); 11 + if (pos == std::string::npos) { 12 + return path; 13 + } 14 + return path.substr(pos + 1); 15 + } 16 + } // namespace 17 + 18 + std::string KOReaderDocumentId::calculateFromFilename(const std::string& filePath) { 19 + const std::string filename = getFilename(filePath); 20 + if (filename.empty()) { 21 + return ""; 22 + } 23 + 24 + MD5Builder md5; 25 + md5.begin(); 26 + md5.add(filename.c_str()); 27 + md5.calculate(); 28 + 29 + std::string result = md5.toString().c_str(); 30 + Serial.printf("[%lu] [KODoc] Filename hash: %s (from '%s')\n", millis(), result.c_str(), filename.c_str()); 31 + return result; 32 + } 33 + 34 + size_t KOReaderDocumentId::getOffset(int i) { 35 + // Offset = 1024 << (2*i) 36 + // For i = -1: 1024 >> 2 = 256 37 + // For i >= 0: 1024 << (2*i) 38 + if (i < 0) { 39 + return CHUNK_SIZE >> (-2 * i); 40 + } 41 + return CHUNK_SIZE << (2 * i); 42 + } 43 + 44 + std::string KOReaderDocumentId::calculate(const std::string& filePath) { 45 + FsFile file; 46 + if (!SdMan.openFileForRead("KODoc", filePath, file)) { 47 + Serial.printf("[%lu] [KODoc] Failed to open file: %s\n", millis(), filePath.c_str()); 48 + return ""; 49 + } 50 + 51 + const size_t fileSize = file.fileSize(); 52 + Serial.printf("[%lu] [KODoc] Calculating hash for file: %s (size: %zu)\n", millis(), filePath.c_str(), fileSize); 53 + 54 + // Initialize MD5 builder 55 + MD5Builder md5; 56 + md5.begin(); 57 + 58 + // Buffer for reading chunks 59 + uint8_t buffer[CHUNK_SIZE]; 60 + size_t totalBytesRead = 0; 61 + 62 + // Read from each offset (i = -1 to 10) 63 + for (int i = -1; i < OFFSET_COUNT - 1; i++) { 64 + const size_t offset = getOffset(i); 65 + 66 + // Skip if offset is beyond file size 67 + if (offset >= fileSize) { 68 + continue; 69 + } 70 + 71 + // Seek to offset 72 + if (!file.seekSet(offset)) { 73 + Serial.printf("[%lu] [KODoc] Failed to seek to offset %zu\n", millis(), offset); 74 + continue; 75 + } 76 + 77 + // Read up to CHUNK_SIZE bytes 78 + const size_t bytesToRead = std::min(CHUNK_SIZE, fileSize - offset); 79 + const size_t bytesRead = file.read(buffer, bytesToRead); 80 + 81 + if (bytesRead > 0) { 82 + md5.add(buffer, bytesRead); 83 + totalBytesRead += bytesRead; 84 + } 85 + } 86 + 87 + file.close(); 88 + 89 + // Calculate final hash 90 + md5.calculate(); 91 + std::string result = md5.toString().c_str(); 92 + 93 + Serial.printf("[%lu] [KODoc] Hash calculated: %s (from %zu bytes)\n", millis(), result.c_str(), totalBytesRead); 94 + 95 + return result; 96 + }
+45
lib/KOReaderSync/KOReaderDocumentId.h
··· 1 + #pragma once 2 + #include <string> 3 + 4 + /** 5 + * Calculate KOReader document ID (partial MD5 hash). 6 + * 7 + * KOReader identifies documents using a partial MD5 hash of the file content. 8 + * The algorithm reads 1024 bytes at specific offsets and computes the MD5 hash 9 + * of the concatenated data. 10 + * 11 + * Offsets are calculated as: 1024 << (2*i) for i = -1 to 10 12 + * Producing: 256, 1024, 4096, 16384, 65536, 262144, 1048576, 4194304, 13 + * 16777216, 67108864, 268435456, 1073741824 bytes 14 + * 15 + * If an offset is beyond the file size, it is skipped. 16 + */ 17 + class KOReaderDocumentId { 18 + public: 19 + /** 20 + * Calculate the KOReader document hash for a file (binary/content-based). 21 + * 22 + * @param filePath Path to the file (typically an EPUB) 23 + * @return 32-character lowercase hex string, or empty string on failure 24 + */ 25 + static std::string calculate(const std::string& filePath); 26 + 27 + /** 28 + * Calculate document hash from filename only (filename-based sync mode). 29 + * This is simpler and works when files have the same name across devices. 30 + * 31 + * @param filePath Path to the file (only the filename portion is used) 32 + * @return 32-character lowercase hex MD5 of the filename 33 + */ 34 + static std::string calculateFromFilename(const std::string& filePath); 35 + 36 + private: 37 + // Size of each chunk to read at each offset 38 + static constexpr size_t CHUNK_SIZE = 1024; 39 + 40 + // Number of offsets to try (i = -1 to 10, so 12 offsets) 41 + static constexpr int OFFSET_COUNT = 12; 42 + 43 + // Calculate offset for index i: 1024 << (2*i) 44 + static size_t getOffset(int i); 45 + };
+198
lib/KOReaderSync/KOReaderSyncClient.cpp
··· 1 + #include "KOReaderSyncClient.h" 2 + 3 + #include <ArduinoJson.h> 4 + #include <HTTPClient.h> 5 + #include <HardwareSerial.h> 6 + #include <WiFi.h> 7 + #include <WiFiClientSecure.h> 8 + 9 + #include <ctime> 10 + 11 + #include "KOReaderCredentialStore.h" 12 + 13 + namespace { 14 + // Device identifier for CrossPoint reader 15 + constexpr char DEVICE_NAME[] = "CrossPoint"; 16 + constexpr char DEVICE_ID[] = "crosspoint-reader"; 17 + 18 + void addAuthHeaders(HTTPClient& http) { 19 + http.addHeader("Accept", "application/vnd.koreader.v1+json"); 20 + http.addHeader("x-auth-user", KOREADER_STORE.getUsername().c_str()); 21 + http.addHeader("x-auth-key", KOREADER_STORE.getMd5Password().c_str()); 22 + } 23 + 24 + bool isHttpsUrl(const std::string& url) { return url.rfind("https://", 0) == 0; } 25 + } // namespace 26 + 27 + KOReaderSyncClient::Error KOReaderSyncClient::authenticate() { 28 + if (!KOREADER_STORE.hasCredentials()) { 29 + Serial.printf("[%lu] [KOSync] No credentials configured\n", millis()); 30 + return NO_CREDENTIALS; 31 + } 32 + 33 + std::string url = KOREADER_STORE.getBaseUrl() + "/users/auth"; 34 + Serial.printf("[%lu] [KOSync] Authenticating: %s\n", millis(), url.c_str()); 35 + 36 + HTTPClient http; 37 + std::unique_ptr<WiFiClientSecure> secureClient; 38 + WiFiClient plainClient; 39 + 40 + if (isHttpsUrl(url)) { 41 + secureClient.reset(new WiFiClientSecure); 42 + secureClient->setInsecure(); 43 + http.begin(*secureClient, url.c_str()); 44 + } else { 45 + http.begin(plainClient, url.c_str()); 46 + } 47 + addAuthHeaders(http); 48 + 49 + const int httpCode = http.GET(); 50 + http.end(); 51 + 52 + Serial.printf("[%lu] [KOSync] Auth response: %d\n", millis(), httpCode); 53 + 54 + if (httpCode == 200) { 55 + return OK; 56 + } else if (httpCode == 401) { 57 + return AUTH_FAILED; 58 + } else if (httpCode < 0) { 59 + return NETWORK_ERROR; 60 + } 61 + return SERVER_ERROR; 62 + } 63 + 64 + KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& documentHash, 65 + KOReaderProgress& outProgress) { 66 + if (!KOREADER_STORE.hasCredentials()) { 67 + Serial.printf("[%lu] [KOSync] No credentials configured\n", millis()); 68 + return NO_CREDENTIALS; 69 + } 70 + 71 + std::string url = KOREADER_STORE.getBaseUrl() + "/syncs/progress/" + documentHash; 72 + Serial.printf("[%lu] [KOSync] Getting progress: %s\n", millis(), url.c_str()); 73 + 74 + HTTPClient http; 75 + std::unique_ptr<WiFiClientSecure> secureClient; 76 + WiFiClient plainClient; 77 + 78 + if (isHttpsUrl(url)) { 79 + secureClient.reset(new WiFiClientSecure); 80 + secureClient->setInsecure(); 81 + http.begin(*secureClient, url.c_str()); 82 + } else { 83 + http.begin(plainClient, url.c_str()); 84 + } 85 + addAuthHeaders(http); 86 + 87 + const int httpCode = http.GET(); 88 + 89 + if (httpCode == 200) { 90 + // Parse JSON response from response string 91 + String responseBody = http.getString(); 92 + http.end(); 93 + 94 + JsonDocument doc; 95 + const DeserializationError error = deserializeJson(doc, responseBody); 96 + 97 + if (error) { 98 + Serial.printf("[%lu] [KOSync] JSON parse failed: %s\n", millis(), error.c_str()); 99 + return JSON_ERROR; 100 + } 101 + 102 + outProgress.document = documentHash; 103 + outProgress.progress = doc["progress"].as<std::string>(); 104 + outProgress.percentage = doc["percentage"].as<float>(); 105 + outProgress.device = doc["device"].as<std::string>(); 106 + outProgress.deviceId = doc["device_id"].as<std::string>(); 107 + outProgress.timestamp = doc["timestamp"].as<int64_t>(); 108 + 109 + Serial.printf("[%lu] [KOSync] Got progress: %.2f%% at %s\n", millis(), outProgress.percentage * 100, 110 + outProgress.progress.c_str()); 111 + return OK; 112 + } 113 + 114 + http.end(); 115 + 116 + Serial.printf("[%lu] [KOSync] Get progress response: %d\n", millis(), httpCode); 117 + 118 + if (httpCode == 401) { 119 + return AUTH_FAILED; 120 + } else if (httpCode == 404) { 121 + return NOT_FOUND; 122 + } else if (httpCode < 0) { 123 + return NETWORK_ERROR; 124 + } 125 + return SERVER_ERROR; 126 + } 127 + 128 + KOReaderSyncClient::Error KOReaderSyncClient::updateProgress(const KOReaderProgress& progress) { 129 + if (!KOREADER_STORE.hasCredentials()) { 130 + Serial.printf("[%lu] [KOSync] No credentials configured\n", millis()); 131 + return NO_CREDENTIALS; 132 + } 133 + 134 + std::string url = KOREADER_STORE.getBaseUrl() + "/syncs/progress"; 135 + Serial.printf("[%lu] [KOSync] Updating progress: %s\n", millis(), url.c_str()); 136 + 137 + HTTPClient http; 138 + std::unique_ptr<WiFiClientSecure> secureClient; 139 + WiFiClient plainClient; 140 + 141 + if (isHttpsUrl(url)) { 142 + secureClient.reset(new WiFiClientSecure); 143 + secureClient->setInsecure(); 144 + http.begin(*secureClient, url.c_str()); 145 + } else { 146 + http.begin(plainClient, url.c_str()); 147 + } 148 + addAuthHeaders(http); 149 + http.addHeader("Content-Type", "application/json"); 150 + 151 + // Build JSON body (timestamp not required per API spec) 152 + JsonDocument doc; 153 + doc["document"] = progress.document; 154 + doc["progress"] = progress.progress; 155 + doc["percentage"] = progress.percentage; 156 + doc["device"] = DEVICE_NAME; 157 + doc["device_id"] = DEVICE_ID; 158 + 159 + std::string body; 160 + serializeJson(doc, body); 161 + 162 + Serial.printf("[%lu] [KOSync] Request body: %s\n", millis(), body.c_str()); 163 + 164 + const int httpCode = http.PUT(body.c_str()); 165 + http.end(); 166 + 167 + Serial.printf("[%lu] [KOSync] Update progress response: %d\n", millis(), httpCode); 168 + 169 + if (httpCode == 200 || httpCode == 202) { 170 + return OK; 171 + } else if (httpCode == 401) { 172 + return AUTH_FAILED; 173 + } else if (httpCode < 0) { 174 + return NETWORK_ERROR; 175 + } 176 + return SERVER_ERROR; 177 + } 178 + 179 + const char* KOReaderSyncClient::errorString(Error error) { 180 + switch (error) { 181 + case OK: 182 + return "Success"; 183 + case NO_CREDENTIALS: 184 + return "No credentials configured"; 185 + case NETWORK_ERROR: 186 + return "Network error"; 187 + case AUTH_FAILED: 188 + return "Authentication failed"; 189 + case SERVER_ERROR: 190 + return "Server error (try again later)"; 191 + case JSON_ERROR: 192 + return "JSON parse error"; 193 + case NOT_FOUND: 194 + return "No progress found"; 195 + default: 196 + return "Unknown error"; 197 + } 198 + }
+59
lib/KOReaderSync/KOReaderSyncClient.h
··· 1 + #pragma once 2 + #include <string> 3 + 4 + /** 5 + * Progress data from KOReader sync server. 6 + */ 7 + struct KOReaderProgress { 8 + std::string document; // Document hash 9 + std::string progress; // XPath-like progress string 10 + float percentage; // Progress percentage (0.0 to 1.0) 11 + std::string device; // Device name 12 + std::string deviceId; // Device ID 13 + int64_t timestamp; // Unix timestamp of last update 14 + }; 15 + 16 + /** 17 + * HTTP client for KOReader sync API. 18 + * 19 + * Base URL: https://sync.koreader.rocks:443/ 20 + * 21 + * API Endpoints: 22 + * GET /users/auth - Authenticate (validate credentials) 23 + * GET /syncs/progress/:document - Get progress for a document 24 + * PUT /syncs/progress - Update progress for a document 25 + * 26 + * Authentication: 27 + * x-auth-user: username 28 + * x-auth-key: MD5 hash of password 29 + */ 30 + class KOReaderSyncClient { 31 + public: 32 + enum Error { OK = 0, NO_CREDENTIALS, NETWORK_ERROR, AUTH_FAILED, SERVER_ERROR, JSON_ERROR, NOT_FOUND }; 33 + 34 + /** 35 + * Authenticate with the sync server (validate credentials). 36 + * @return OK on success, error code on failure 37 + */ 38 + static Error authenticate(); 39 + 40 + /** 41 + * Get reading progress for a document. 42 + * @param documentHash The document hash (from KOReaderDocumentId) 43 + * @param outProgress Output: the progress data 44 + * @return OK on success, NOT_FOUND if no progress exists, error code on failure 45 + */ 46 + static Error getProgress(const std::string& documentHash, KOReaderProgress& outProgress); 47 + 48 + /** 49 + * Update reading progress for a document. 50 + * @param progress The progress data to upload 51 + * @return OK on success, error code on failure 52 + */ 53 + static Error updateProgress(const KOReaderProgress& progress); 54 + 55 + /** 56 + * Get human-readable error message. 57 + */ 58 + static const char* errorString(Error error); 59 + };
+112
lib/KOReaderSync/ProgressMapper.cpp
··· 1 + #include "ProgressMapper.h" 2 + 3 + #include <HardwareSerial.h> 4 + 5 + #include <cmath> 6 + 7 + KOReaderPosition ProgressMapper::toKOReader(const std::shared_ptr<Epub>& epub, const CrossPointPosition& pos) { 8 + KOReaderPosition result; 9 + 10 + // Calculate page progress within current spine item 11 + float intraSpineProgress = 0.0f; 12 + if (pos.totalPages > 0) { 13 + intraSpineProgress = static_cast<float>(pos.pageNumber) / static_cast<float>(pos.totalPages); 14 + } 15 + 16 + // Calculate overall book progress (0.0-1.0) 17 + result.percentage = epub->calculateProgress(pos.spineIndex, intraSpineProgress); 18 + 19 + // Generate XPath with estimated paragraph position based on page 20 + result.xpath = generateXPath(pos.spineIndex, pos.pageNumber, pos.totalPages); 21 + 22 + // Get chapter info for logging 23 + const int tocIndex = epub->getTocIndexForSpineIndex(pos.spineIndex); 24 + const std::string chapterName = (tocIndex >= 0) ? epub->getTocItem(tocIndex).title : "unknown"; 25 + 26 + Serial.printf("[%lu] [ProgressMapper] CrossPoint -> KOReader: chapter='%s', page=%d/%d -> %.2f%% at %s\n", millis(), 27 + chapterName.c_str(), pos.pageNumber, pos.totalPages, result.percentage * 100, result.xpath.c_str()); 28 + 29 + return result; 30 + } 31 + 32 + CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epub, const KOReaderPosition& koPos, 33 + int totalPagesInSpine) { 34 + CrossPointPosition result; 35 + result.spineIndex = 0; 36 + result.pageNumber = 0; 37 + result.totalPages = totalPagesInSpine; 38 + 39 + const size_t bookSize = epub->getBookSize(); 40 + if (bookSize == 0) { 41 + return result; 42 + } 43 + 44 + // First, try to get spine index from XPath (DocFragment) 45 + int xpathSpineIndex = parseDocFragmentIndex(koPos.xpath); 46 + if (xpathSpineIndex >= 0 && xpathSpineIndex < epub->getSpineItemsCount()) { 47 + result.spineIndex = xpathSpineIndex; 48 + // When we have XPath, go to page 0 of the spine - byte-based page calculation is unreliable 49 + result.pageNumber = 0; 50 + } else { 51 + // Fall back to percentage-based lookup for both spine and page 52 + const size_t targetBytes = static_cast<size_t>(bookSize * koPos.percentage); 53 + 54 + // Find the spine item that contains this byte position 55 + for (int i = 0; i < epub->getSpineItemsCount(); i++) { 56 + const size_t cumulativeSize = epub->getCumulativeSpineItemSize(i); 57 + if (cumulativeSize >= targetBytes) { 58 + result.spineIndex = i; 59 + break; 60 + } 61 + } 62 + 63 + // Estimate page number within the spine item using percentage (only when no XPath) 64 + if (totalPagesInSpine > 0 && result.spineIndex < epub->getSpineItemsCount()) { 65 + const size_t prevCumSize = (result.spineIndex > 0) ? epub->getCumulativeSpineItemSize(result.spineIndex - 1) : 0; 66 + const size_t currentCumSize = epub->getCumulativeSpineItemSize(result.spineIndex); 67 + const size_t spineSize = currentCumSize - prevCumSize; 68 + 69 + if (spineSize > 0) { 70 + const size_t bytesIntoSpine = (targetBytes > prevCumSize) ? (targetBytes - prevCumSize) : 0; 71 + const float intraSpineProgress = static_cast<float>(bytesIntoSpine) / static_cast<float>(spineSize); 72 + const float clampedProgress = std::max(0.0f, std::min(1.0f, intraSpineProgress)); 73 + result.pageNumber = static_cast<int>(clampedProgress * totalPagesInSpine); 74 + result.pageNumber = std::max(0, std::min(result.pageNumber, totalPagesInSpine - 1)); 75 + } 76 + } 77 + } 78 + 79 + Serial.printf("[%lu] [ProgressMapper] KOReader -> CrossPoint: %.2f%% at %s -> spine=%d, page=%d\n", millis(), 80 + koPos.percentage * 100, koPos.xpath.c_str(), result.spineIndex, result.pageNumber); 81 + 82 + return result; 83 + } 84 + 85 + std::string ProgressMapper::generateXPath(int spineIndex, int pageNumber, int totalPages) { 86 + // KOReader uses 1-based DocFragment indices 87 + // Use a simple xpath pointing to the DocFragment - KOReader will use the percentage for fine positioning 88 + // Avoid specifying paragraph numbers as they may not exist in the target document 89 + return "/body/DocFragment[" + std::to_string(spineIndex + 1) + "]/body"; 90 + } 91 + 92 + int ProgressMapper::parseDocFragmentIndex(const std::string& xpath) { 93 + // Look for DocFragment[N] pattern 94 + const size_t start = xpath.find("DocFragment["); 95 + if (start == std::string::npos) { 96 + return -1; 97 + } 98 + 99 + const size_t numStart = start + 12; // Length of "DocFragment[" 100 + const size_t numEnd = xpath.find(']', numStart); 101 + if (numEnd == std::string::npos) { 102 + return -1; 103 + } 104 + 105 + try { 106 + const int docFragmentIndex = std::stoi(xpath.substr(numStart, numEnd - numStart)); 107 + // KOReader uses 1-based indices, we use 0-based 108 + return docFragmentIndex - 1; 109 + } catch (...) { 110 + return -1; 111 + } 112 + }
+72
lib/KOReaderSync/ProgressMapper.h
··· 1 + #pragma once 2 + #include <Epub.h> 3 + 4 + #include <memory> 5 + #include <string> 6 + 7 + /** 8 + * CrossPoint position representation. 9 + */ 10 + struct CrossPointPosition { 11 + int spineIndex; // Current spine item (chapter) index 12 + int pageNumber; // Current page within the spine item 13 + int totalPages; // Total pages in the current spine item 14 + }; 15 + 16 + /** 17 + * KOReader position representation. 18 + */ 19 + struct KOReaderPosition { 20 + std::string xpath; // XPath-like progress string 21 + float percentage; // Progress percentage (0.0 to 1.0) 22 + }; 23 + 24 + /** 25 + * Maps between CrossPoint and KOReader position formats. 26 + * 27 + * CrossPoint tracks position as (spineIndex, pageNumber). 28 + * KOReader uses XPath-like strings + percentage. 29 + * 30 + * Since CrossPoint discards HTML structure during parsing, we generate 31 + * synthetic XPath strings based on spine index, using percentage as the 32 + * primary sync mechanism. 33 + */ 34 + class ProgressMapper { 35 + public: 36 + /** 37 + * Convert CrossPoint position to KOReader format. 38 + * 39 + * @param epub The EPUB book 40 + * @param pos CrossPoint position 41 + * @return KOReader position 42 + */ 43 + static KOReaderPosition toKOReader(const std::shared_ptr<Epub>& epub, const CrossPointPosition& pos); 44 + 45 + /** 46 + * Convert KOReader position to CrossPoint format. 47 + * 48 + * Note: The returned pageNumber may be approximate since different 49 + * rendering settings produce different page counts. 50 + * 51 + * @param epub The EPUB book 52 + * @param koPos KOReader position 53 + * @param totalPagesInSpine Total pages in the target spine item (for page estimation) 54 + * @return CrossPoint position 55 + */ 56 + static CrossPointPosition toCrossPoint(const std::shared_ptr<Epub>& epub, const KOReaderPosition& koPos, 57 + int totalPagesInSpine = 0); 58 + 59 + private: 60 + /** 61 + * Generate XPath for KOReader compatibility. 62 + * Format: /body/DocFragment[spineIndex+1]/body/p[estimatedParagraph] 63 + * Paragraph is estimated based on page position within the chapter. 64 + */ 65 + static std::string generateXPath(int spineIndex, int pageNumber, int totalPages); 66 + 67 + /** 68 + * Parse DocFragment index from XPath string. 69 + * Returns -1 if not found. 70 + */ 71 + static int parseDocFragmentIndex(const std::string& xpath); 72 + };
+18 -4
src/activities/reader/EpubReaderActivity.cpp
··· 118 118 if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { 119 119 // Don't start activity transition while rendering 120 120 xSemaphoreTake(renderingMutex, portMAX_DELAY); 121 + const int currentPage = section ? section->currentPage : 0; 122 + const int totalPages = section ? section->pageCount : 0; 121 123 exitActivity(); 122 124 enterNewActivity(new EpubReaderChapterSelectionActivity( 123 - this->renderer, this->mappedInput, epub, currentSpineIndex, 125 + this->renderer, this->mappedInput, epub, epub->getPath(), currentSpineIndex, currentPage, totalPages, 124 126 [this] { 125 127 exitActivity(); 126 128 updateRequired = true; ··· 129 131 if (currentSpineIndex != newSpineIndex) { 130 132 currentSpineIndex = newSpineIndex; 131 133 nextPageNumber = 0; 134 + section.reset(); 135 + } 136 + exitActivity(); 137 + updateRequired = true; 138 + }, 139 + [this](const int newSpineIndex, const int newPage) { 140 + // Handle sync position 141 + if (currentSpineIndex != newSpineIndex || (section && section->currentPage != newPage)) { 142 + currentSpineIndex = newSpineIndex; 143 + nextPageNumber = newPage; 132 144 section.reset(); 133 145 } 134 146 exitActivity(); ··· 430 442 if (showProgress) { 431 443 // Calculate progress in book 432 444 const float sectionChapterProg = static_cast<float>(section->currentPage) / section->pageCount; 433 - const uint8_t bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg); 445 + const float bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg) * 100; 434 446 435 447 // Right aligned text for progress counter 436 - const std::string progress = std::to_string(section->currentPage + 1) + "/" + std::to_string(section->pageCount) + 437 - " " + std::to_string(bookProgress) + "%"; 448 + char progressStr[32]; 449 + snprintf(progressStr, sizeof(progressStr), "%d/%d %.1f%%", section->currentPage + 1, section->pageCount, 450 + bookProgress); 451 + const std::string progress = progressStr; 438 452 progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progress.c_str()); 439 453 renderer.drawText(SMALL_FONT_ID, renderer.getScreenWidth() - orientedMarginRight - progressTextWidth, textY, 440 454 progress.c_str());
+81 -14
src/activities/reader/EpubReaderChapterSelectionActivity.cpp
··· 2 2 3 3 #include <GfxRenderer.h> 4 4 5 + #include "KOReaderCredentialStore.h" 6 + #include "KOReaderSyncActivity.h" 5 7 #include "MappedInputManager.h" 6 8 #include "fontIds.h" 7 9 ··· 10 12 constexpr int SKIP_PAGE_MS = 700; 11 13 } // namespace 12 14 15 + bool EpubReaderChapterSelectionActivity::hasSyncOption() const { return KOREADER_STORE.hasCredentials(); } 16 + 17 + int EpubReaderChapterSelectionActivity::getTotalItems() const { 18 + // Add 2 for sync options (top and bottom) if credentials are configured 19 + const int syncCount = hasSyncOption() ? 2 : 0; 20 + return epub->getTocItemsCount() + syncCount; 21 + } 22 + 23 + bool EpubReaderChapterSelectionActivity::isSyncItem(int index) const { 24 + if (!hasSyncOption()) return false; 25 + // First item and last item are sync options 26 + return index == 0 || index == getTotalItems() - 1; 27 + } 28 + 29 + int EpubReaderChapterSelectionActivity::tocIndexFromItemIndex(int itemIndex) const { 30 + // Account for the sync option at the top 31 + const int offset = hasSyncOption() ? 1 : 0; 32 + return itemIndex - offset; 33 + } 34 + 13 35 int EpubReaderChapterSelectionActivity::getPageItems() const { 14 36 // Layout constants used in renderScreen 15 37 constexpr int startY = 60; ··· 34 56 } 35 57 36 58 void EpubReaderChapterSelectionActivity::onEnter() { 37 - Activity::onEnter(); 59 + ActivityWithSubactivity::onEnter(); 38 60 39 61 if (!epub) { 40 62 return; 41 63 } 42 64 43 65 renderingMutex = xSemaphoreCreateMutex(); 66 + 67 + // Account for sync option offset when finding current TOC index 68 + const int syncOffset = hasSyncOption() ? 1 : 0; 44 69 selectorIndex = epub->getTocIndexForSpineIndex(currentSpineIndex); 45 70 if (selectorIndex == -1) { 46 71 selectorIndex = 0; 47 72 } 73 + selectorIndex += syncOffset; // Offset for top sync option 48 74 49 75 // Trigger first update 50 76 updateRequired = true; ··· 57 83 } 58 84 59 85 void EpubReaderChapterSelectionActivity::onExit() { 60 - Activity::onExit(); 86 + ActivityWithSubactivity::onExit(); 61 87 62 88 // Wait until not rendering to delete task to avoid killing mid-instruction to EPD 63 89 xSemaphoreTake(renderingMutex, portMAX_DELAY); ··· 67 93 } 68 94 vSemaphoreDelete(renderingMutex); 69 95 renderingMutex = nullptr; 96 + } 97 + 98 + void EpubReaderChapterSelectionActivity::launchSyncActivity() { 99 + xSemaphoreTake(renderingMutex, portMAX_DELAY); 100 + exitActivity(); 101 + enterNewActivity(new KOReaderSyncActivity( 102 + renderer, mappedInput, epub, epubPath, currentSpineIndex, currentPage, totalPagesInSpine, 103 + [this]() { 104 + // On cancel 105 + exitActivity(); 106 + updateRequired = true; 107 + }, 108 + [this](int newSpineIndex, int newPage) { 109 + // On sync complete 110 + exitActivity(); 111 + onSyncPosition(newSpineIndex, newPage); 112 + })); 113 + xSemaphoreGive(renderingMutex); 70 114 } 71 115 72 116 void EpubReaderChapterSelectionActivity::loop() { 117 + if (subActivity) { 118 + subActivity->loop(); 119 + return; 120 + } 121 + 73 122 const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::Up) || 74 123 mappedInput.wasReleased(MappedInputManager::Button::Left); 75 124 const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::Down) || ··· 77 126 78 127 const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS; 79 128 const int pageItems = getPageItems(); 129 + const int totalItems = getTotalItems(); 80 130 81 131 if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { 82 - const auto newSpineIndex = epub->getSpineIndexForTocIndex(selectorIndex); 132 + // Check if sync option is selected (first or last item) 133 + if (isSyncItem(selectorIndex)) { 134 + launchSyncActivity(); 135 + return; 136 + } 137 + 138 + // Get TOC index (account for top sync offset) 139 + const int tocIndex = tocIndexFromItemIndex(selectorIndex); 140 + const auto newSpineIndex = epub->getSpineIndexForTocIndex(tocIndex); 83 141 if (newSpineIndex == -1) { 84 142 onGoBack(); 85 143 } else { ··· 89 147 onGoBack(); 90 148 } else if (prevReleased) { 91 149 if (skipPage) { 92 - selectorIndex = 93 - ((selectorIndex / pageItems - 1) * pageItems + epub->getTocItemsCount()) % epub->getTocItemsCount(); 150 + selectorIndex = ((selectorIndex / pageItems - 1) * pageItems + totalItems) % totalItems; 94 151 } else { 95 - selectorIndex = (selectorIndex + epub->getTocItemsCount() - 1) % epub->getTocItemsCount(); 152 + selectorIndex = (selectorIndex + totalItems - 1) % totalItems; 96 153 } 97 154 updateRequired = true; 98 155 } else if (nextReleased) { 99 156 if (skipPage) { 100 - selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % epub->getTocItemsCount(); 157 + selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % totalItems; 101 158 } else { 102 - selectorIndex = (selectorIndex + 1) % epub->getTocItemsCount(); 159 + selectorIndex = (selectorIndex + 1) % totalItems; 103 160 } 104 161 updateRequired = true; 105 162 } ··· 107 164 108 165 void EpubReaderChapterSelectionActivity::displayTaskLoop() { 109 166 while (true) { 110 - if (updateRequired) { 167 + if (updateRequired && !subActivity) { 111 168 updateRequired = false; 112 169 xSemaphoreTake(renderingMutex, portMAX_DELAY); 113 170 renderScreen(); ··· 122 179 123 180 const auto pageWidth = renderer.getScreenWidth(); 124 181 const int pageItems = getPageItems(); 182 + const int totalItems = getTotalItems(); 125 183 126 184 const std::string title = 127 185 renderer.truncatedText(UI_12_FONT_ID, epub->getTitle().c_str(), pageWidth - 40, EpdFontFamily::BOLD); ··· 129 187 130 188 const auto pageStartIndex = selectorIndex / pageItems * pageItems; 131 189 renderer.fillRect(0, 60 + (selectorIndex % pageItems) * 30 - 2, pageWidth - 1, 30); 132 - for (int tocIndex = pageStartIndex; tocIndex < epub->getTocItemsCount() && tocIndex < pageStartIndex + pageItems; 133 - tocIndex++) { 134 - auto item = epub->getTocItem(tocIndex); 135 - renderer.drawText(UI_10_FONT_ID, 20 + (item.level - 1) * 15, 60 + (tocIndex % pageItems) * 30, item.title.c_str(), 136 - tocIndex != selectorIndex); 190 + 191 + for (int itemIndex = pageStartIndex; itemIndex < totalItems && itemIndex < pageStartIndex + pageItems; itemIndex++) { 192 + const int displayY = 60 + (itemIndex % pageItems) * 30; 193 + const bool isSelected = (itemIndex == selectorIndex); 194 + 195 + if (isSyncItem(itemIndex)) { 196 + // Draw sync option (at top or bottom) 197 + renderer.drawText(UI_10_FONT_ID, 20, displayY, ">> Sync Progress", !isSelected); 198 + } else { 199 + // Draw TOC item (account for top sync offset) 200 + const int tocIndex = tocIndexFromItemIndex(itemIndex); 201 + auto item = epub->getTocItem(tocIndex); 202 + renderer.drawText(UI_10_FONT_ID, 20 + (item.level - 1) * 15, displayY, item.title.c_str(), !isSelected); 203 + } 137 204 } 138 205 139 206 const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down");
+30 -7
src/activities/reader/EpubReaderChapterSelectionActivity.h
··· 6 6 7 7 #include <memory> 8 8 9 - #include "../Activity.h" 9 + #include "../ActivityWithSubactivity.h" 10 10 11 - class EpubReaderChapterSelectionActivity final : public Activity { 11 + class EpubReaderChapterSelectionActivity final : public ActivityWithSubactivity { 12 12 std::shared_ptr<Epub> epub; 13 + std::string epubPath; 13 14 TaskHandle_t displayTaskHandle = nullptr; 14 15 SemaphoreHandle_t renderingMutex = nullptr; 15 16 int currentSpineIndex = 0; 17 + int currentPage = 0; 18 + int totalPagesInSpine = 0; 16 19 int selectorIndex = 0; 17 20 bool updateRequired = false; 18 21 const std::function<void()> onGoBack; 19 22 const std::function<void(int newSpineIndex)> onSelectSpineIndex; 23 + const std::function<void(int newSpineIndex, int newPage)> onSyncPosition; 20 24 21 25 // Number of items that fit on a page, derived from logical screen height. 22 26 // This adapts automatically when switching between portrait and landscape. 23 27 int getPageItems() const; 24 28 29 + // Total items including sync options (top and bottom) 30 + int getTotalItems() const; 31 + 32 + // Check if sync option is available (credentials configured) 33 + bool hasSyncOption() const; 34 + 35 + // Check if given item index is a sync option (first or last) 36 + bool isSyncItem(int index) const; 37 + 38 + // Convert item index to TOC index (accounting for top sync option offset) 39 + int tocIndexFromItemIndex(int itemIndex) const; 40 + 25 41 static void taskTrampoline(void* param); 26 42 [[noreturn]] void displayTaskLoop(); 27 43 void renderScreen(); 44 + void launchSyncActivity(); 28 45 29 46 public: 30 47 explicit EpubReaderChapterSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, 31 - const std::shared_ptr<Epub>& epub, const int currentSpineIndex, 32 - const std::function<void()>& onGoBack, 33 - const std::function<void(int newSpineIndex)>& onSelectSpineIndex) 34 - : Activity("EpubReaderChapterSelection", renderer, mappedInput), 48 + const std::shared_ptr<Epub>& epub, const std::string& epubPath, 49 + const int currentSpineIndex, const int currentPage, 50 + const int totalPagesInSpine, const std::function<void()>& onGoBack, 51 + const std::function<void(int newSpineIndex)>& onSelectSpineIndex, 52 + const std::function<void(int newSpineIndex, int newPage)>& onSyncPosition) 53 + : ActivityWithSubactivity("EpubReaderChapterSelection", renderer, mappedInput), 35 54 epub(epub), 55 + epubPath(epubPath), 36 56 currentSpineIndex(currentSpineIndex), 57 + currentPage(currentPage), 58 + totalPagesInSpine(totalPagesInSpine), 37 59 onGoBack(onGoBack), 38 - onSelectSpineIndex(onSelectSpineIndex) {} 60 + onSelectSpineIndex(onSelectSpineIndex), 61 + onSyncPosition(onSyncPosition) {} 39 62 void onEnter() override; 40 63 void onExit() override; 41 64 void loop() override;
+439
src/activities/reader/KOReaderSyncActivity.cpp
··· 1 + #include "KOReaderSyncActivity.h" 2 + 3 + #include <GfxRenderer.h> 4 + #include <WiFi.h> 5 + #include <esp_sntp.h> 6 + 7 + #include "KOReaderCredentialStore.h" 8 + #include "KOReaderDocumentId.h" 9 + #include "MappedInputManager.h" 10 + #include "activities/network/WifiSelectionActivity.h" 11 + #include "fontIds.h" 12 + 13 + namespace { 14 + void syncTimeWithNTP() { 15 + // Stop SNTP if already running (can't reconfigure while running) 16 + if (esp_sntp_enabled()) { 17 + esp_sntp_stop(); 18 + } 19 + 20 + // Configure SNTP 21 + esp_sntp_setoperatingmode(ESP_SNTP_OPMODE_POLL); 22 + esp_sntp_setservername(0, "pool.ntp.org"); 23 + esp_sntp_init(); 24 + 25 + // Wait for time to sync (with timeout) 26 + int retry = 0; 27 + const int maxRetries = 50; // 5 seconds max 28 + while (sntp_get_sync_status() != SNTP_SYNC_STATUS_COMPLETED && retry < maxRetries) { 29 + vTaskDelay(100 / portTICK_PERIOD_MS); 30 + retry++; 31 + } 32 + 33 + if (retry < maxRetries) { 34 + Serial.printf("[%lu] [KOSync] NTP time synced\n", millis()); 35 + } else { 36 + Serial.printf("[%lu] [KOSync] NTP sync timeout, using fallback\n", millis()); 37 + } 38 + } 39 + } // namespace 40 + 41 + void KOReaderSyncActivity::taskTrampoline(void* param) { 42 + auto* self = static_cast<KOReaderSyncActivity*>(param); 43 + self->displayTaskLoop(); 44 + } 45 + 46 + void KOReaderSyncActivity::onWifiSelectionComplete(const bool success) { 47 + exitActivity(); 48 + 49 + if (!success) { 50 + Serial.printf("[%lu] [KOSync] WiFi connection failed, exiting\n", millis()); 51 + onCancel(); 52 + return; 53 + } 54 + 55 + Serial.printf("[%lu] [KOSync] WiFi connected, starting sync\n", millis()); 56 + 57 + xSemaphoreTake(renderingMutex, portMAX_DELAY); 58 + state = SYNCING; 59 + statusMessage = "Syncing time..."; 60 + xSemaphoreGive(renderingMutex); 61 + updateRequired = true; 62 + 63 + // Sync time with NTP before making API requests 64 + syncTimeWithNTP(); 65 + 66 + xSemaphoreTake(renderingMutex, portMAX_DELAY); 67 + statusMessage = "Calculating document hash..."; 68 + xSemaphoreGive(renderingMutex); 69 + updateRequired = true; 70 + 71 + performSync(); 72 + } 73 + 74 + void KOReaderSyncActivity::performSync() { 75 + // Calculate document hash based on user's preferred method 76 + if (KOREADER_STORE.getMatchMethod() == DocumentMatchMethod::FILENAME) { 77 + documentHash = KOReaderDocumentId::calculateFromFilename(epubPath); 78 + } else { 79 + documentHash = KOReaderDocumentId::calculate(epubPath); 80 + } 81 + if (documentHash.empty()) { 82 + xSemaphoreTake(renderingMutex, portMAX_DELAY); 83 + state = SYNC_FAILED; 84 + statusMessage = "Failed to calculate document hash"; 85 + xSemaphoreGive(renderingMutex); 86 + updateRequired = true; 87 + return; 88 + } 89 + 90 + Serial.printf("[%lu] [KOSync] Document hash: %s\n", millis(), documentHash.c_str()); 91 + 92 + xSemaphoreTake(renderingMutex, portMAX_DELAY); 93 + statusMessage = "Fetching remote progress..."; 94 + xSemaphoreGive(renderingMutex); 95 + updateRequired = true; 96 + vTaskDelay(10 / portTICK_PERIOD_MS); 97 + 98 + // Fetch remote progress 99 + const auto result = KOReaderSyncClient::getProgress(documentHash, remoteProgress); 100 + 101 + if (result == KOReaderSyncClient::NOT_FOUND) { 102 + // No remote progress - offer to upload 103 + xSemaphoreTake(renderingMutex, portMAX_DELAY); 104 + state = NO_REMOTE_PROGRESS; 105 + hasRemoteProgress = false; 106 + xSemaphoreGive(renderingMutex); 107 + updateRequired = true; 108 + return; 109 + } 110 + 111 + if (result != KOReaderSyncClient::OK) { 112 + xSemaphoreTake(renderingMutex, portMAX_DELAY); 113 + state = SYNC_FAILED; 114 + statusMessage = KOReaderSyncClient::errorString(result); 115 + xSemaphoreGive(renderingMutex); 116 + updateRequired = true; 117 + return; 118 + } 119 + 120 + // Convert remote progress to CrossPoint position 121 + hasRemoteProgress = true; 122 + KOReaderPosition koPos = {remoteProgress.progress, remoteProgress.percentage}; 123 + remotePosition = ProgressMapper::toCrossPoint(epub, koPos, totalPagesInSpine); 124 + 125 + // Calculate local progress in KOReader format (for display) 126 + CrossPointPosition localPos = {currentSpineIndex, currentPage, totalPagesInSpine}; 127 + localProgress = ProgressMapper::toKOReader(epub, localPos); 128 + 129 + xSemaphoreTake(renderingMutex, portMAX_DELAY); 130 + state = SHOWING_RESULT; 131 + selectedOption = 0; // Default to "Apply" 132 + xSemaphoreGive(renderingMutex); 133 + updateRequired = true; 134 + } 135 + 136 + void KOReaderSyncActivity::performUpload() { 137 + xSemaphoreTake(renderingMutex, portMAX_DELAY); 138 + state = UPLOADING; 139 + statusMessage = "Uploading progress..."; 140 + xSemaphoreGive(renderingMutex); 141 + updateRequired = true; 142 + vTaskDelay(10 / portTICK_PERIOD_MS); 143 + 144 + // Convert current position to KOReader format 145 + CrossPointPosition localPos = {currentSpineIndex, currentPage, totalPagesInSpine}; 146 + KOReaderPosition koPos = ProgressMapper::toKOReader(epub, localPos); 147 + 148 + KOReaderProgress progress; 149 + progress.document = documentHash; 150 + progress.progress = koPos.xpath; 151 + progress.percentage = koPos.percentage; 152 + 153 + const auto result = KOReaderSyncClient::updateProgress(progress); 154 + 155 + if (result != KOReaderSyncClient::OK) { 156 + xSemaphoreTake(renderingMutex, portMAX_DELAY); 157 + state = SYNC_FAILED; 158 + statusMessage = KOReaderSyncClient::errorString(result); 159 + xSemaphoreGive(renderingMutex); 160 + updateRequired = true; 161 + return; 162 + } 163 + 164 + xSemaphoreTake(renderingMutex, portMAX_DELAY); 165 + state = UPLOAD_COMPLETE; 166 + xSemaphoreGive(renderingMutex); 167 + updateRequired = true; 168 + } 169 + 170 + void KOReaderSyncActivity::onEnter() { 171 + ActivityWithSubactivity::onEnter(); 172 + 173 + renderingMutex = xSemaphoreCreateMutex(); 174 + 175 + xTaskCreate(&KOReaderSyncActivity::taskTrampoline, "KOSyncTask", 176 + 4096, // Stack size (larger for network operations) 177 + this, // Parameters 178 + 1, // Priority 179 + &displayTaskHandle // Task handle 180 + ); 181 + 182 + // Check for credentials first 183 + if (!KOREADER_STORE.hasCredentials()) { 184 + state = NO_CREDENTIALS; 185 + updateRequired = true; 186 + return; 187 + } 188 + 189 + // Turn on WiFi 190 + Serial.printf("[%lu] [KOSync] Turning on WiFi...\n", millis()); 191 + WiFi.mode(WIFI_STA); 192 + 193 + // Check if already connected 194 + if (WiFi.status() == WL_CONNECTED) { 195 + Serial.printf("[%lu] [KOSync] Already connected to WiFi\n", millis()); 196 + state = SYNCING; 197 + statusMessage = "Syncing time..."; 198 + updateRequired = true; 199 + 200 + // Perform sync directly (will be handled in loop) 201 + xTaskCreate( 202 + [](void* param) { 203 + auto* self = static_cast<KOReaderSyncActivity*>(param); 204 + // Sync time first 205 + syncTimeWithNTP(); 206 + xSemaphoreTake(self->renderingMutex, portMAX_DELAY); 207 + self->statusMessage = "Calculating document hash..."; 208 + xSemaphoreGive(self->renderingMutex); 209 + self->updateRequired = true; 210 + self->performSync(); 211 + vTaskDelete(nullptr); 212 + }, 213 + "SyncTask", 4096, this, 1, nullptr); 214 + return; 215 + } 216 + 217 + // Launch WiFi selection subactivity 218 + Serial.printf("[%lu] [KOSync] Launching WifiSelectionActivity...\n", millis()); 219 + enterNewActivity(new WifiSelectionActivity(renderer, mappedInput, 220 + [this](const bool connected) { onWifiSelectionComplete(connected); })); 221 + } 222 + 223 + void KOReaderSyncActivity::onExit() { 224 + ActivityWithSubactivity::onExit(); 225 + 226 + // Turn off wifi 227 + WiFi.disconnect(false); 228 + delay(100); 229 + WiFi.mode(WIFI_OFF); 230 + delay(100); 231 + 232 + // Wait until not rendering to delete task 233 + xSemaphoreTake(renderingMutex, portMAX_DELAY); 234 + if (displayTaskHandle) { 235 + vTaskDelete(displayTaskHandle); 236 + displayTaskHandle = nullptr; 237 + } 238 + vSemaphoreDelete(renderingMutex); 239 + renderingMutex = nullptr; 240 + } 241 + 242 + void KOReaderSyncActivity::displayTaskLoop() { 243 + while (true) { 244 + if (updateRequired) { 245 + updateRequired = false; 246 + xSemaphoreTake(renderingMutex, portMAX_DELAY); 247 + render(); 248 + xSemaphoreGive(renderingMutex); 249 + } 250 + vTaskDelay(10 / portTICK_PERIOD_MS); 251 + } 252 + } 253 + 254 + void KOReaderSyncActivity::render() { 255 + if (subActivity) { 256 + return; 257 + } 258 + 259 + const auto pageWidth = renderer.getScreenWidth(); 260 + 261 + renderer.clearScreen(); 262 + renderer.drawCenteredText(UI_12_FONT_ID, 15, "KOReader Sync", true, EpdFontFamily::BOLD); 263 + 264 + if (state == NO_CREDENTIALS) { 265 + renderer.drawCenteredText(UI_10_FONT_ID, 280, "No credentials configured", true, EpdFontFamily::BOLD); 266 + renderer.drawCenteredText(UI_10_FONT_ID, 320, "Set up KOReader account in Settings"); 267 + 268 + const auto labels = mappedInput.mapLabels("Back", "", "", ""); 269 + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 270 + renderer.displayBuffer(); 271 + return; 272 + } 273 + 274 + if (state == SYNCING || state == UPLOADING) { 275 + renderer.drawCenteredText(UI_10_FONT_ID, 300, statusMessage.c_str(), true, EpdFontFamily::BOLD); 276 + renderer.displayBuffer(); 277 + return; 278 + } 279 + 280 + if (state == SHOWING_RESULT) { 281 + // Show comparison 282 + renderer.drawCenteredText(UI_10_FONT_ID, 120, "Progress found!", true, EpdFontFamily::BOLD); 283 + 284 + // Get chapter names from TOC 285 + const int remoteTocIndex = epub->getTocIndexForSpineIndex(remotePosition.spineIndex); 286 + const int localTocIndex = epub->getTocIndexForSpineIndex(currentSpineIndex); 287 + const std::string remoteChapter = (remoteTocIndex >= 0) 288 + ? epub->getTocItem(remoteTocIndex).title 289 + : ("Section " + std::to_string(remotePosition.spineIndex + 1)); 290 + const std::string localChapter = (localTocIndex >= 0) ? epub->getTocItem(localTocIndex).title 291 + : ("Section " + std::to_string(currentSpineIndex + 1)); 292 + 293 + // Remote progress - chapter and page 294 + renderer.drawText(UI_10_FONT_ID, 20, 160, "Remote:", true); 295 + char remoteChapterStr[128]; 296 + snprintf(remoteChapterStr, sizeof(remoteChapterStr), " %s", remoteChapter.c_str()); 297 + renderer.drawText(UI_10_FONT_ID, 20, 185, remoteChapterStr); 298 + char remotePageStr[64]; 299 + snprintf(remotePageStr, sizeof(remotePageStr), " Page %d, %.2f%% overall", remotePosition.pageNumber + 1, 300 + remoteProgress.percentage * 100); 301 + renderer.drawText(UI_10_FONT_ID, 20, 210, remotePageStr); 302 + 303 + if (!remoteProgress.device.empty()) { 304 + char deviceStr[64]; 305 + snprintf(deviceStr, sizeof(deviceStr), " From: %s", remoteProgress.device.c_str()); 306 + renderer.drawText(UI_10_FONT_ID, 20, 235, deviceStr); 307 + } 308 + 309 + // Local progress - chapter and page 310 + renderer.drawText(UI_10_FONT_ID, 20, 270, "Local:", true); 311 + char localChapterStr[128]; 312 + snprintf(localChapterStr, sizeof(localChapterStr), " %s", localChapter.c_str()); 313 + renderer.drawText(UI_10_FONT_ID, 20, 295, localChapterStr); 314 + char localPageStr[64]; 315 + snprintf(localPageStr, sizeof(localPageStr), " Page %d/%d, %.2f%% overall", currentPage + 1, totalPagesInSpine, 316 + localProgress.percentage * 100); 317 + renderer.drawText(UI_10_FONT_ID, 20, 320, localPageStr); 318 + 319 + // Options 320 + const int optionY = 350; 321 + const int optionHeight = 30; 322 + 323 + // Apply option 324 + if (selectedOption == 0) { 325 + renderer.fillRect(0, optionY - 2, pageWidth - 1, optionHeight); 326 + } 327 + renderer.drawText(UI_10_FONT_ID, 20, optionY, "Apply remote progress", selectedOption != 0); 328 + 329 + // Upload option 330 + if (selectedOption == 1) { 331 + renderer.fillRect(0, optionY + optionHeight - 2, pageWidth - 1, optionHeight); 332 + } 333 + renderer.drawText(UI_10_FONT_ID, 20, optionY + optionHeight, "Upload local progress", selectedOption != 1); 334 + 335 + // Cancel option 336 + if (selectedOption == 2) { 337 + renderer.fillRect(0, optionY + optionHeight * 2 - 2, pageWidth - 1, optionHeight); 338 + } 339 + renderer.drawText(UI_10_FONT_ID, 20, optionY + optionHeight * 2, "Cancel", selectedOption != 2); 340 + 341 + const auto labels = mappedInput.mapLabels("", "Select", "", ""); 342 + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 343 + renderer.displayBuffer(); 344 + return; 345 + } 346 + 347 + if (state == NO_REMOTE_PROGRESS) { 348 + renderer.drawCenteredText(UI_10_FONT_ID, 280, "No remote progress found", true, EpdFontFamily::BOLD); 349 + renderer.drawCenteredText(UI_10_FONT_ID, 320, "Upload current position?"); 350 + 351 + const auto labels = mappedInput.mapLabels("Cancel", "Upload", "", ""); 352 + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 353 + renderer.displayBuffer(); 354 + return; 355 + } 356 + 357 + if (state == UPLOAD_COMPLETE) { 358 + renderer.drawCenteredText(UI_10_FONT_ID, 300, "Progress uploaded!", true, EpdFontFamily::BOLD); 359 + 360 + const auto labels = mappedInput.mapLabels("Back", "", "", ""); 361 + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 362 + renderer.displayBuffer(); 363 + return; 364 + } 365 + 366 + if (state == SYNC_FAILED) { 367 + renderer.drawCenteredText(UI_10_FONT_ID, 280, "Sync failed", true, EpdFontFamily::BOLD); 368 + renderer.drawCenteredText(UI_10_FONT_ID, 320, statusMessage.c_str()); 369 + 370 + const auto labels = mappedInput.mapLabels("Back", "", "", ""); 371 + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 372 + renderer.displayBuffer(); 373 + return; 374 + } 375 + } 376 + 377 + void KOReaderSyncActivity::loop() { 378 + if (subActivity) { 379 + subActivity->loop(); 380 + return; 381 + } 382 + 383 + if (state == NO_CREDENTIALS || state == SYNC_FAILED || state == UPLOAD_COMPLETE) { 384 + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { 385 + onCancel(); 386 + } 387 + return; 388 + } 389 + 390 + if (state == SHOWING_RESULT) { 391 + // Navigate options 392 + if (mappedInput.wasPressed(MappedInputManager::Button::Up) || 393 + mappedInput.wasPressed(MappedInputManager::Button::Left)) { 394 + selectedOption = (selectedOption + 2) % 3; // Wrap around 395 + updateRequired = true; 396 + } else if (mappedInput.wasPressed(MappedInputManager::Button::Down) || 397 + mappedInput.wasPressed(MappedInputManager::Button::Right)) { 398 + selectedOption = (selectedOption + 1) % 3; 399 + updateRequired = true; 400 + } 401 + 402 + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { 403 + if (selectedOption == 0) { 404 + // Apply remote progress 405 + onSyncComplete(remotePosition.spineIndex, remotePosition.pageNumber); 406 + } else if (selectedOption == 1) { 407 + // Upload local progress 408 + performUpload(); 409 + } else { 410 + // Cancel 411 + onCancel(); 412 + } 413 + } 414 + 415 + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { 416 + onCancel(); 417 + } 418 + return; 419 + } 420 + 421 + if (state == NO_REMOTE_PROGRESS) { 422 + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { 423 + // Calculate hash if not done yet 424 + if (documentHash.empty()) { 425 + if (KOREADER_STORE.getMatchMethod() == DocumentMatchMethod::FILENAME) { 426 + documentHash = KOReaderDocumentId::calculateFromFilename(epubPath); 427 + } else { 428 + documentHash = KOReaderDocumentId::calculate(epubPath); 429 + } 430 + } 431 + performUpload(); 432 + } 433 + 434 + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { 435 + onCancel(); 436 + } 437 + return; 438 + } 439 + }
+98
src/activities/reader/KOReaderSyncActivity.h
··· 1 + #pragma once 2 + #include <Epub.h> 3 + #include <freertos/FreeRTOS.h> 4 + #include <freertos/semphr.h> 5 + #include <freertos/task.h> 6 + 7 + #include <functional> 8 + #include <memory> 9 + 10 + #include "KOReaderSyncClient.h" 11 + #include "ProgressMapper.h" 12 + #include "activities/ActivityWithSubactivity.h" 13 + 14 + /** 15 + * Activity for syncing reading progress with KOReader sync server. 16 + * 17 + * Flow: 18 + * 1. Connect to WiFi (if not connected) 19 + * 2. Calculate document hash 20 + * 3. Fetch remote progress 21 + * 4. Show comparison and options (Apply/Upload/Cancel) 22 + * 5. Apply or upload progress 23 + */ 24 + class KOReaderSyncActivity final : public ActivityWithSubactivity { 25 + public: 26 + using OnCancelCallback = std::function<void()>; 27 + using OnSyncCompleteCallback = std::function<void(int newSpineIndex, int newPageNumber)>; 28 + 29 + explicit KOReaderSyncActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, 30 + const std::shared_ptr<Epub>& epub, const std::string& epubPath, int currentSpineIndex, 31 + int currentPage, int totalPagesInSpine, OnCancelCallback onCancel, 32 + OnSyncCompleteCallback onSyncComplete) 33 + : ActivityWithSubactivity("KOReaderSync", renderer, mappedInput), 34 + epub(epub), 35 + epubPath(epubPath), 36 + currentSpineIndex(currentSpineIndex), 37 + currentPage(currentPage), 38 + totalPagesInSpine(totalPagesInSpine), 39 + remoteProgress{}, 40 + remotePosition{}, 41 + localProgress{}, 42 + onCancel(std::move(onCancel)), 43 + onSyncComplete(std::move(onSyncComplete)) {} 44 + 45 + void onEnter() override; 46 + void onExit() override; 47 + void loop() override; 48 + bool preventAutoSleep() override { return state == CONNECTING || state == SYNCING; } 49 + 50 + private: 51 + enum State { 52 + WIFI_SELECTION, 53 + CONNECTING, 54 + SYNCING, 55 + SHOWING_RESULT, 56 + UPLOADING, 57 + UPLOAD_COMPLETE, 58 + NO_REMOTE_PROGRESS, 59 + SYNC_FAILED, 60 + NO_CREDENTIALS 61 + }; 62 + 63 + std::shared_ptr<Epub> epub; 64 + std::string epubPath; 65 + int currentSpineIndex; 66 + int currentPage; 67 + int totalPagesInSpine; 68 + 69 + TaskHandle_t displayTaskHandle = nullptr; 70 + SemaphoreHandle_t renderingMutex = nullptr; 71 + bool updateRequired = false; 72 + 73 + State state = WIFI_SELECTION; 74 + std::string statusMessage; 75 + std::string documentHash; 76 + 77 + // Remote progress data 78 + bool hasRemoteProgress = false; 79 + KOReaderProgress remoteProgress; 80 + CrossPointPosition remotePosition; 81 + 82 + // Local progress as KOReader format (for display) 83 + KOReaderPosition localProgress; 84 + 85 + // Selection in result screen (0=Apply, 1=Upload, 2=Cancel) 86 + int selectedOption = 0; 87 + 88 + OnCancelCallback onCancel; 89 + OnSyncCompleteCallback onSyncComplete; 90 + 91 + void onWifiSelectionComplete(bool success); 92 + void performSync(); 93 + void performUpload(); 94 + 95 + static void taskTrampoline(void* param); 96 + [[noreturn]] void displayTaskLoop(); 97 + void render(); 98 + };
+167
src/activities/settings/KOReaderAuthActivity.cpp
··· 1 + #include "KOReaderAuthActivity.h" 2 + 3 + #include <GfxRenderer.h> 4 + #include <WiFi.h> 5 + 6 + #include "KOReaderCredentialStore.h" 7 + #include "KOReaderSyncClient.h" 8 + #include "MappedInputManager.h" 9 + #include "activities/network/WifiSelectionActivity.h" 10 + #include "fontIds.h" 11 + 12 + void KOReaderAuthActivity::taskTrampoline(void* param) { 13 + auto* self = static_cast<KOReaderAuthActivity*>(param); 14 + self->displayTaskLoop(); 15 + } 16 + 17 + void KOReaderAuthActivity::onWifiSelectionComplete(const bool success) { 18 + exitActivity(); 19 + 20 + if (!success) { 21 + xSemaphoreTake(renderingMutex, portMAX_DELAY); 22 + state = FAILED; 23 + errorMessage = "WiFi connection failed"; 24 + xSemaphoreGive(renderingMutex); 25 + updateRequired = true; 26 + return; 27 + } 28 + 29 + xSemaphoreTake(renderingMutex, portMAX_DELAY); 30 + state = AUTHENTICATING; 31 + statusMessage = "Authenticating..."; 32 + xSemaphoreGive(renderingMutex); 33 + updateRequired = true; 34 + 35 + performAuthentication(); 36 + } 37 + 38 + void KOReaderAuthActivity::performAuthentication() { 39 + const auto result = KOReaderSyncClient::authenticate(); 40 + 41 + xSemaphoreTake(renderingMutex, portMAX_DELAY); 42 + if (result == KOReaderSyncClient::OK) { 43 + state = SUCCESS; 44 + statusMessage = "Successfully authenticated!"; 45 + } else { 46 + state = FAILED; 47 + errorMessage = KOReaderSyncClient::errorString(result); 48 + } 49 + xSemaphoreGive(renderingMutex); 50 + updateRequired = true; 51 + } 52 + 53 + void KOReaderAuthActivity::onEnter() { 54 + ActivityWithSubactivity::onEnter(); 55 + 56 + renderingMutex = xSemaphoreCreateMutex(); 57 + 58 + xTaskCreate(&KOReaderAuthActivity::taskTrampoline, "KOAuthTask", 59 + 4096, // Stack size 60 + this, // Parameters 61 + 1, // Priority 62 + &displayTaskHandle // Task handle 63 + ); 64 + 65 + // Turn on WiFi 66 + WiFi.mode(WIFI_STA); 67 + 68 + // Check if already connected 69 + if (WiFi.status() == WL_CONNECTED) { 70 + state = AUTHENTICATING; 71 + statusMessage = "Authenticating..."; 72 + updateRequired = true; 73 + 74 + // Perform authentication in a separate task 75 + xTaskCreate( 76 + [](void* param) { 77 + auto* self = static_cast<KOReaderAuthActivity*>(param); 78 + self->performAuthentication(); 79 + vTaskDelete(nullptr); 80 + }, 81 + "AuthTask", 4096, this, 1, nullptr); 82 + return; 83 + } 84 + 85 + // Launch WiFi selection 86 + enterNewActivity(new WifiSelectionActivity(renderer, mappedInput, 87 + [this](const bool connected) { onWifiSelectionComplete(connected); })); 88 + } 89 + 90 + void KOReaderAuthActivity::onExit() { 91 + ActivityWithSubactivity::onExit(); 92 + 93 + // Turn off wifi 94 + WiFi.disconnect(false); 95 + delay(100); 96 + WiFi.mode(WIFI_OFF); 97 + delay(100); 98 + 99 + xSemaphoreTake(renderingMutex, portMAX_DELAY); 100 + if (displayTaskHandle) { 101 + vTaskDelete(displayTaskHandle); 102 + displayTaskHandle = nullptr; 103 + } 104 + vSemaphoreDelete(renderingMutex); 105 + renderingMutex = nullptr; 106 + } 107 + 108 + void KOReaderAuthActivity::displayTaskLoop() { 109 + while (true) { 110 + if (updateRequired && !subActivity) { 111 + updateRequired = false; 112 + xSemaphoreTake(renderingMutex, portMAX_DELAY); 113 + render(); 114 + xSemaphoreGive(renderingMutex); 115 + } 116 + vTaskDelay(10 / portTICK_PERIOD_MS); 117 + } 118 + } 119 + 120 + void KOReaderAuthActivity::render() { 121 + if (subActivity) { 122 + return; 123 + } 124 + 125 + renderer.clearScreen(); 126 + renderer.drawCenteredText(UI_12_FONT_ID, 15, "KOReader Auth", true, EpdFontFamily::BOLD); 127 + 128 + if (state == AUTHENTICATING) { 129 + renderer.drawCenteredText(UI_10_FONT_ID, 300, statusMessage.c_str(), true, EpdFontFamily::BOLD); 130 + renderer.displayBuffer(); 131 + return; 132 + } 133 + 134 + if (state == SUCCESS) { 135 + renderer.drawCenteredText(UI_10_FONT_ID, 280, "Success!", true, EpdFontFamily::BOLD); 136 + renderer.drawCenteredText(UI_10_FONT_ID, 320, "KOReader sync is ready to use"); 137 + 138 + const auto labels = mappedInput.mapLabels("Done", "", "", ""); 139 + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 140 + renderer.displayBuffer(); 141 + return; 142 + } 143 + 144 + if (state == FAILED) { 145 + renderer.drawCenteredText(UI_10_FONT_ID, 280, "Authentication Failed", true, EpdFontFamily::BOLD); 146 + renderer.drawCenteredText(UI_10_FONT_ID, 320, errorMessage.c_str()); 147 + 148 + const auto labels = mappedInput.mapLabels("Back", "", "", ""); 149 + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 150 + renderer.displayBuffer(); 151 + return; 152 + } 153 + } 154 + 155 + void KOReaderAuthActivity::loop() { 156 + if (subActivity) { 157 + subActivity->loop(); 158 + return; 159 + } 160 + 161 + if (state == SUCCESS || state == FAILED) { 162 + if (mappedInput.wasPressed(MappedInputManager::Button::Back) || 163 + mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { 164 + onComplete(); 165 + } 166 + } 167 + }
+44
src/activities/settings/KOReaderAuthActivity.h
··· 1 + #pragma once 2 + #include <freertos/FreeRTOS.h> 3 + #include <freertos/semphr.h> 4 + #include <freertos/task.h> 5 + 6 + #include <functional> 7 + 8 + #include "activities/ActivityWithSubactivity.h" 9 + 10 + /** 11 + * Activity for testing KOReader credentials. 12 + * Connects to WiFi and authenticates with the KOReader sync server. 13 + */ 14 + class KOReaderAuthActivity final : public ActivityWithSubactivity { 15 + public: 16 + explicit KOReaderAuthActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, 17 + const std::function<void()>& onComplete) 18 + : ActivityWithSubactivity("KOReaderAuth", renderer, mappedInput), onComplete(onComplete) {} 19 + 20 + void onEnter() override; 21 + void onExit() override; 22 + void loop() override; 23 + bool preventAutoSleep() override { return state == CONNECTING || state == AUTHENTICATING; } 24 + 25 + private: 26 + enum State { WIFI_SELECTION, CONNECTING, AUTHENTICATING, SUCCESS, FAILED }; 27 + 28 + TaskHandle_t displayTaskHandle = nullptr; 29 + SemaphoreHandle_t renderingMutex = nullptr; 30 + bool updateRequired = false; 31 + 32 + State state = WIFI_SELECTION; 33 + std::string statusMessage; 34 + std::string errorMessage; 35 + 36 + const std::function<void()> onComplete; 37 + 38 + void onWifiSelectionComplete(bool success); 39 + void performAuthentication(); 40 + 41 + static void taskTrampoline(void* param); 42 + [[noreturn]] void displayTaskLoop(); 43 + void render(); 44 + };
+213
src/activities/settings/KOReaderSettingsActivity.cpp
··· 1 + #include "KOReaderSettingsActivity.h" 2 + 3 + #include <GfxRenderer.h> 4 + 5 + #include <cstring> 6 + 7 + #include "KOReaderAuthActivity.h" 8 + #include "KOReaderCredentialStore.h" 9 + #include "MappedInputManager.h" 10 + #include "activities/util/KeyboardEntryActivity.h" 11 + #include "fontIds.h" 12 + 13 + namespace { 14 + constexpr int MENU_ITEMS = 5; 15 + const char* menuNames[MENU_ITEMS] = {"Username", "Password", "Sync Server URL", "Document Matching", "Authenticate"}; 16 + } // namespace 17 + 18 + void KOReaderSettingsActivity::taskTrampoline(void* param) { 19 + auto* self = static_cast<KOReaderSettingsActivity*>(param); 20 + self->displayTaskLoop(); 21 + } 22 + 23 + void KOReaderSettingsActivity::onEnter() { 24 + ActivityWithSubactivity::onEnter(); 25 + 26 + renderingMutex = xSemaphoreCreateMutex(); 27 + selectedIndex = 0; 28 + updateRequired = true; 29 + 30 + xTaskCreate(&KOReaderSettingsActivity::taskTrampoline, "KOReaderSettingsTask", 31 + 4096, // Stack size 32 + this, // Parameters 33 + 1, // Priority 34 + &displayTaskHandle // Task handle 35 + ); 36 + } 37 + 38 + void KOReaderSettingsActivity::onExit() { 39 + ActivityWithSubactivity::onExit(); 40 + 41 + xSemaphoreTake(renderingMutex, portMAX_DELAY); 42 + if (displayTaskHandle) { 43 + vTaskDelete(displayTaskHandle); 44 + displayTaskHandle = nullptr; 45 + } 46 + vSemaphoreDelete(renderingMutex); 47 + renderingMutex = nullptr; 48 + } 49 + 50 + void KOReaderSettingsActivity::loop() { 51 + if (subActivity) { 52 + subActivity->loop(); 53 + return; 54 + } 55 + 56 + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { 57 + onBack(); 58 + return; 59 + } 60 + 61 + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { 62 + handleSelection(); 63 + return; 64 + } 65 + 66 + if (mappedInput.wasPressed(MappedInputManager::Button::Up) || 67 + mappedInput.wasPressed(MappedInputManager::Button::Left)) { 68 + selectedIndex = (selectedIndex + MENU_ITEMS - 1) % MENU_ITEMS; 69 + updateRequired = true; 70 + } else if (mappedInput.wasPressed(MappedInputManager::Button::Down) || 71 + mappedInput.wasPressed(MappedInputManager::Button::Right)) { 72 + selectedIndex = (selectedIndex + 1) % MENU_ITEMS; 73 + updateRequired = true; 74 + } 75 + } 76 + 77 + void KOReaderSettingsActivity::handleSelection() { 78 + xSemaphoreTake(renderingMutex, portMAX_DELAY); 79 + 80 + if (selectedIndex == 0) { 81 + // Username 82 + exitActivity(); 83 + enterNewActivity(new KeyboardEntryActivity( 84 + renderer, mappedInput, "KOReader Username", KOREADER_STORE.getUsername(), 10, 85 + 64, // maxLength 86 + false, // not password 87 + [this](const std::string& username) { 88 + KOREADER_STORE.setCredentials(username, KOREADER_STORE.getPassword()); 89 + KOREADER_STORE.saveToFile(); 90 + exitActivity(); 91 + updateRequired = true; 92 + }, 93 + [this]() { 94 + exitActivity(); 95 + updateRequired = true; 96 + })); 97 + } else if (selectedIndex == 1) { 98 + // Password 99 + exitActivity(); 100 + enterNewActivity(new KeyboardEntryActivity( 101 + renderer, mappedInput, "KOReader Password", KOREADER_STORE.getPassword(), 10, 102 + 64, // maxLength 103 + false, // show characters 104 + [this](const std::string& password) { 105 + KOREADER_STORE.setCredentials(KOREADER_STORE.getUsername(), password); 106 + KOREADER_STORE.saveToFile(); 107 + exitActivity(); 108 + updateRequired = true; 109 + }, 110 + [this]() { 111 + exitActivity(); 112 + updateRequired = true; 113 + })); 114 + } else if (selectedIndex == 2) { 115 + // Sync Server URL - prefill with https:// if empty to save typing 116 + const std::string currentUrl = KOREADER_STORE.getServerUrl(); 117 + const std::string prefillUrl = currentUrl.empty() ? "https://" : currentUrl; 118 + exitActivity(); 119 + enterNewActivity(new KeyboardEntryActivity( 120 + renderer, mappedInput, "Sync Server URL", prefillUrl, 10, 121 + 128, // maxLength - URLs can be long 122 + false, // not password 123 + [this](const std::string& url) { 124 + // Clear if user just left the prefilled https:// 125 + const std::string urlToSave = (url == "https://" || url == "http://") ? "" : url; 126 + KOREADER_STORE.setServerUrl(urlToSave); 127 + KOREADER_STORE.saveToFile(); 128 + exitActivity(); 129 + updateRequired = true; 130 + }, 131 + [this]() { 132 + exitActivity(); 133 + updateRequired = true; 134 + })); 135 + } else if (selectedIndex == 3) { 136 + // Document Matching - toggle between Filename and Binary 137 + const auto current = KOREADER_STORE.getMatchMethod(); 138 + const auto newMethod = 139 + (current == DocumentMatchMethod::FILENAME) ? DocumentMatchMethod::BINARY : DocumentMatchMethod::FILENAME; 140 + KOREADER_STORE.setMatchMethod(newMethod); 141 + KOREADER_STORE.saveToFile(); 142 + updateRequired = true; 143 + } else if (selectedIndex == 4) { 144 + // Authenticate 145 + if (!KOREADER_STORE.hasCredentials()) { 146 + // Can't authenticate without credentials - just show message briefly 147 + xSemaphoreGive(renderingMutex); 148 + return; 149 + } 150 + exitActivity(); 151 + enterNewActivity(new KOReaderAuthActivity(renderer, mappedInput, [this] { 152 + exitActivity(); 153 + updateRequired = true; 154 + })); 155 + } 156 + 157 + xSemaphoreGive(renderingMutex); 158 + } 159 + 160 + void KOReaderSettingsActivity::displayTaskLoop() { 161 + while (true) { 162 + if (updateRequired && !subActivity) { 163 + updateRequired = false; 164 + xSemaphoreTake(renderingMutex, portMAX_DELAY); 165 + render(); 166 + xSemaphoreGive(renderingMutex); 167 + } 168 + vTaskDelay(10 / portTICK_PERIOD_MS); 169 + } 170 + } 171 + 172 + void KOReaderSettingsActivity::render() { 173 + renderer.clearScreen(); 174 + 175 + const auto pageWidth = renderer.getScreenWidth(); 176 + 177 + // Draw header 178 + renderer.drawCenteredText(UI_12_FONT_ID, 15, "KOReader Sync", true, EpdFontFamily::BOLD); 179 + 180 + // Draw selection highlight 181 + renderer.fillRect(0, 60 + selectedIndex * 30 - 2, pageWidth - 1, 30); 182 + 183 + // Draw menu items 184 + for (int i = 0; i < MENU_ITEMS; i++) { 185 + const int settingY = 60 + i * 30; 186 + const bool isSelected = (i == selectedIndex); 187 + 188 + renderer.drawText(UI_10_FONT_ID, 20, settingY, menuNames[i], !isSelected); 189 + 190 + // Draw status for each item 191 + const char* status = ""; 192 + if (i == 0) { 193 + status = KOREADER_STORE.getUsername().empty() ? "[Not Set]" : "[Set]"; 194 + } else if (i == 1) { 195 + status = KOREADER_STORE.getPassword().empty() ? "[Not Set]" : "[Set]"; 196 + } else if (i == 2) { 197 + status = KOREADER_STORE.getServerUrl().empty() ? "[Not Set]" : "[Set]"; 198 + } else if (i == 3) { 199 + status = KOREADER_STORE.getMatchMethod() == DocumentMatchMethod::FILENAME ? "[Filename]" : "[Binary]"; 200 + } else if (i == 4) { 201 + status = KOREADER_STORE.hasCredentials() ? "" : "[Set credentials first]"; 202 + } 203 + 204 + const auto width = renderer.getTextWidth(UI_10_FONT_ID, status); 205 + renderer.drawText(UI_10_FONT_ID, pageWidth - 20 - width, settingY, status, !isSelected); 206 + } 207 + 208 + // Draw button hints 209 + const auto labels = mappedInput.mapLabels("« Back", "Select", "", ""); 210 + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); 211 + 212 + renderer.displayBuffer(); 213 + }
+36
src/activities/settings/KOReaderSettingsActivity.h
··· 1 + #pragma once 2 + #include <freertos/FreeRTOS.h> 3 + #include <freertos/semphr.h> 4 + #include <freertos/task.h> 5 + 6 + #include <functional> 7 + 8 + #include "activities/ActivityWithSubactivity.h" 9 + 10 + /** 11 + * Submenu for KOReader Sync settings. 12 + * Shows username, password, and authenticate options. 13 + */ 14 + class KOReaderSettingsActivity final : public ActivityWithSubactivity { 15 + public: 16 + explicit KOReaderSettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, 17 + const std::function<void()>& onBack) 18 + : ActivityWithSubactivity("KOReaderSettings", renderer, mappedInput), onBack(onBack) {} 19 + 20 + void onEnter() override; 21 + void onExit() override; 22 + void loop() override; 23 + 24 + private: 25 + TaskHandle_t displayTaskHandle = nullptr; 26 + SemaphoreHandle_t renderingMutex = nullptr; 27 + bool updateRequired = false; 28 + 29 + int selectedIndex = 0; 30 + const std::function<void()> onBack; 31 + 32 + static void taskTrampoline(void* param); 33 + [[noreturn]] void displayTaskLoop(); 34 + void render(); 35 + void handleSelection(); 36 + };
+20 -8
src/activities/settings/SettingsActivity.cpp
··· 7 7 8 8 #include "CalibreSettingsActivity.h" 9 9 #include "CrossPointSettings.h" 10 + #include "KOReaderSettingsActivity.h" 10 11 #include "MappedInputManager.h" 11 12 #include "OtaUpdateActivity.h" 12 13 #include "fontIds.h" 13 14 14 15 // Define the static settings list 15 16 namespace { 16 - constexpr int settingsCount = 20; 17 + constexpr int settingsCount = 21; 17 18 const SettingInfo settingsList[settingsCount] = { 18 19 // Should match with SLEEP_SCREEN_MODE 19 20 SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}), ··· 41 42 {"1 min", "5 min", "10 min", "15 min", "30 min"}), 42 43 SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency, 43 44 {"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}), 45 + SettingInfo::Action("KOReader Sync"), 44 46 SettingInfo::Action("Calibre Settings"), 45 47 SettingInfo::Action("Check for updates")}; 46 48 } // namespace ··· 115 117 } 116 118 117 119 void SettingsActivity::toggleCurrentSetting() { 118 - // Validate index 119 120 if (selectedSettingIndex < 0 || selectedSettingIndex >= settingsCount) { 120 121 return; 121 122 } ··· 139 140 SETTINGS.*(setting.valuePtr) = currentValue + setting.valueRange.step; 140 141 } 141 142 } else if (setting.type == SettingType::ACTION) { 142 - if (strcmp(setting.name, "Calibre Settings") == 0) { 143 + if (strcmp(setting.name, "KOReader Sync") == 0) { 144 + xSemaphoreTake(renderingMutex, portMAX_DELAY); 145 + exitActivity(); 146 + enterNewActivity(new KOReaderSettingsActivity(renderer, mappedInput, [this] { 147 + exitActivity(); 148 + updateRequired = true; 149 + })); 150 + xSemaphoreGive(renderingMutex); 151 + } else if (strcmp(setting.name, "Calibre Settings") == 0) { 143 152 xSemaphoreTake(renderingMutex, portMAX_DELAY); 144 153 exitActivity(); 145 154 enterNewActivity(new CalibreSettingsActivity(renderer, mappedInput, [this] { ··· 186 195 // Draw header 187 196 renderer.drawCenteredText(UI_12_FONT_ID, 15, "Settings", true, EpdFontFamily::BOLD); 188 197 189 - // Draw selection 198 + // Draw selection highlight 190 199 renderer.fillRect(0, 60 + selectedSettingIndex * 30 - 2, pageWidth - 1, 30); 191 200 192 201 // Draw all settings 193 202 for (int i = 0; i < settingsCount; i++) { 194 203 const int settingY = 60 + i * 30; // 30 pixels between settings 204 + const bool isSelected = (i == selectedSettingIndex); 195 205 196 206 // Draw setting name 197 - renderer.drawText(UI_10_FONT_ID, 20, settingY, settingsList[i].name, i != selectedSettingIndex); 207 + renderer.drawText(UI_10_FONT_ID, 20, settingY, settingsList[i].name, !isSelected); 198 208 199 209 // Draw value based on setting type 200 - std::string valueText = ""; 210 + std::string valueText; 201 211 if (settingsList[i].type == SettingType::TOGGLE && settingsList[i].valuePtr != nullptr) { 202 212 const bool value = SETTINGS.*(settingsList[i].valuePtr); 203 213 valueText = value ? "ON" : "OFF"; ··· 207 217 } else if (settingsList[i].type == SettingType::VALUE && settingsList[i].valuePtr != nullptr) { 208 218 valueText = std::to_string(SETTINGS.*(settingsList[i].valuePtr)); 209 219 } 210 - const auto width = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str()); 211 - renderer.drawText(UI_10_FONT_ID, pageWidth - 20 - width, settingY, valueText.c_str(), i != selectedSettingIndex); 220 + if (!valueText.empty()) { 221 + const auto width = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str()); 222 + renderer.drawText(UI_10_FONT_ID, pageWidth - 20 - width, settingY, valueText.c_str(), !isSelected); 223 + } 212 224 } 213 225 214 226 // Draw version text above button hints
+2
src/main.cpp
··· 12 12 #include "Battery.h" 13 13 #include "CrossPointSettings.h" 14 14 #include "CrossPointState.h" 15 + #include "KOReaderCredentialStore.h" 15 16 #include "MappedInputManager.h" 16 17 #include "activities/boot_sleep/BootActivity.h" 17 18 #include "activities/boot_sleep/SleepActivity.h" ··· 289 290 } 290 291 291 292 SETTINGS.loadFromFile(); 293 + KOREADER_STORE.loadFromFile(); 292 294 293 295 // verify power button press duration after we've read settings. 294 296 verifyWakeupLongPress();