mirror of
https://github.com/daveallie/crosspoint-reader.git
synced 2026-02-04 14:47:37 +03:00
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>
This commit is contained in:
parent
a5ade3fe81
commit
511fa485be
@ -609,14 +609,15 @@ int Epub::getSpineIndexForTextReference() const {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate progress in book
|
// Calculate progress in book (returns 0.0-1.0)
|
||||||
uint8_t Epub::calculateProgress(const int currentSpineIndex, const float currentSpineRead) const {
|
float Epub::calculateProgress(const int currentSpineIndex, const float currentSpineRead) const {
|
||||||
const size_t bookSize = getBookSize();
|
const size_t bookSize = getBookSize();
|
||||||
if (bookSize == 0) {
|
if (bookSize == 0) {
|
||||||
return 0;
|
return 0.0f;
|
||||||
}
|
}
|
||||||
const size_t prevChapterSize = (currentSpineIndex >= 1) ? getCumulativeSpineItemSize(currentSpineIndex - 1) : 0;
|
const size_t prevChapterSize = (currentSpineIndex >= 1) ? getCumulativeSpineItemSize(currentSpineIndex - 1) : 0;
|
||||||
const size_t curChapterSize = getCumulativeSpineItemSize(currentSpineIndex) - prevChapterSize;
|
const size_t curChapterSize = getCumulativeSpineItemSize(currentSpineIndex) - prevChapterSize;
|
||||||
const size_t sectionProgSize = currentSpineRead * curChapterSize;
|
const float sectionProgSize = currentSpineRead * static_cast<float>(curChapterSize);
|
||||||
return round(static_cast<float>(prevChapterSize + sectionProgSize) / bookSize * 100.0);
|
const float totalProgress = static_cast<float>(prevChapterSize) + sectionProgSize;
|
||||||
|
return totalProgress / static_cast<float>(bookSize);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -62,5 +62,5 @@ class Epub {
|
|||||||
int getSpineIndexForTextReference() const;
|
int getSpineIndexForTextReference() const;
|
||||||
|
|
||||||
size_t getBookSize() const;
|
size_t getBookSize() const;
|
||||||
uint8_t calculateProgress(int currentSpineIndex, float currentSpineRead) const;
|
float calculateProgress(int currentSpineIndex, float currentSpineRead) const;
|
||||||
};
|
};
|
||||||
|
|||||||
168
lib/KOReaderSync/KOReaderCredentialStore.cpp
Normal file
168
lib/KOReaderSync/KOReaderCredentialStore.cpp
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
#include "KOReaderCredentialStore.h"
|
||||||
|
|
||||||
|
#include <HardwareSerial.h>
|
||||||
|
#include <MD5Builder.h>
|
||||||
|
#include <SDCardManager.h>
|
||||||
|
#include <Serialization.h>
|
||||||
|
|
||||||
|
// Initialize the static instance
|
||||||
|
KOReaderCredentialStore KOReaderCredentialStore::instance;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// File format version
|
||||||
|
constexpr uint8_t KOREADER_FILE_VERSION = 1;
|
||||||
|
|
||||||
|
// KOReader credentials file path
|
||||||
|
constexpr char KOREADER_FILE[] = "/.crosspoint/koreader.bin";
|
||||||
|
|
||||||
|
// Default sync server URL
|
||||||
|
constexpr char DEFAULT_SERVER_URL[] = "https://sync.koreader.rocks:443";
|
||||||
|
|
||||||
|
// Obfuscation key - "KOReader" in ASCII
|
||||||
|
// This is NOT cryptographic security, just prevents casual file reading
|
||||||
|
constexpr uint8_t OBFUSCATION_KEY[] = {0x4B, 0x4F, 0x52, 0x65, 0x61, 0x64, 0x65, 0x72};
|
||||||
|
constexpr size_t KEY_LENGTH = sizeof(OBFUSCATION_KEY);
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void KOReaderCredentialStore::obfuscate(std::string& data) const {
|
||||||
|
for (size_t i = 0; i < data.size(); i++) {
|
||||||
|
data[i] ^= OBFUSCATION_KEY[i % KEY_LENGTH];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool KOReaderCredentialStore::saveToFile() const {
|
||||||
|
// Make sure the directory exists
|
||||||
|
SdMan.mkdir("/.crosspoint");
|
||||||
|
|
||||||
|
FsFile file;
|
||||||
|
if (!SdMan.openFileForWrite("KRS", KOREADER_FILE, file)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write header
|
||||||
|
serialization::writePod(file, KOREADER_FILE_VERSION);
|
||||||
|
|
||||||
|
// Write username (plaintext - not particularly sensitive)
|
||||||
|
serialization::writeString(file, username);
|
||||||
|
Serial.printf("[%lu] [KRS] Saving username: %s\n", millis(), username.c_str());
|
||||||
|
|
||||||
|
// Write password (obfuscated)
|
||||||
|
std::string obfuscatedPwd = password;
|
||||||
|
obfuscate(obfuscatedPwd);
|
||||||
|
serialization::writeString(file, obfuscatedPwd);
|
||||||
|
|
||||||
|
// Write server URL
|
||||||
|
serialization::writeString(file, serverUrl);
|
||||||
|
|
||||||
|
// Write match method
|
||||||
|
serialization::writePod(file, static_cast<uint8_t>(matchMethod));
|
||||||
|
|
||||||
|
file.close();
|
||||||
|
Serial.printf("[%lu] [KRS] Saved KOReader credentials to file\n", millis());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool KOReaderCredentialStore::loadFromFile() {
|
||||||
|
FsFile file;
|
||||||
|
if (!SdMan.openFileForRead("KRS", KOREADER_FILE, file)) {
|
||||||
|
Serial.printf("[%lu] [KRS] No credentials file found\n", millis());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read and verify version
|
||||||
|
uint8_t version;
|
||||||
|
serialization::readPod(file, version);
|
||||||
|
if (version != KOREADER_FILE_VERSION) {
|
||||||
|
Serial.printf("[%lu] [KRS] Unknown file version: %u\n", millis(), version);
|
||||||
|
file.close();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read username
|
||||||
|
if (file.available()) {
|
||||||
|
serialization::readString(file, username);
|
||||||
|
} else {
|
||||||
|
username.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read and deobfuscate password
|
||||||
|
if (file.available()) {
|
||||||
|
serialization::readString(file, password);
|
||||||
|
obfuscate(password); // XOR is symmetric, so same function deobfuscates
|
||||||
|
} else {
|
||||||
|
password.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read server URL
|
||||||
|
if (file.available()) {
|
||||||
|
serialization::readString(file, serverUrl);
|
||||||
|
} else {
|
||||||
|
serverUrl.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read match method
|
||||||
|
if (file.available()) {
|
||||||
|
uint8_t method;
|
||||||
|
serialization::readPod(file, method);
|
||||||
|
matchMethod = static_cast<DocumentMatchMethod>(method);
|
||||||
|
} else {
|
||||||
|
matchMethod = DocumentMatchMethod::FILENAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
file.close();
|
||||||
|
Serial.printf("[%lu] [KRS] Loaded KOReader credentials for user: %s\n", millis(), username.c_str());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void KOReaderCredentialStore::setCredentials(const std::string& user, const std::string& pass) {
|
||||||
|
username = user;
|
||||||
|
password = pass;
|
||||||
|
Serial.printf("[%lu] [KRS] Set credentials for user: %s\n", millis(), user.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string KOReaderCredentialStore::getMd5Password() const {
|
||||||
|
if (password.empty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate MD5 hash of password using ESP32's MD5Builder
|
||||||
|
MD5Builder md5;
|
||||||
|
md5.begin();
|
||||||
|
md5.add(password.c_str());
|
||||||
|
md5.calculate();
|
||||||
|
|
||||||
|
return md5.toString().c_str();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool KOReaderCredentialStore::hasCredentials() const { return !username.empty() && !password.empty(); }
|
||||||
|
|
||||||
|
void KOReaderCredentialStore::clearCredentials() {
|
||||||
|
username.clear();
|
||||||
|
password.clear();
|
||||||
|
saveToFile();
|
||||||
|
Serial.printf("[%lu] [KRS] Cleared KOReader credentials\n", millis());
|
||||||
|
}
|
||||||
|
|
||||||
|
void KOReaderCredentialStore::setServerUrl(const std::string& url) {
|
||||||
|
serverUrl = url;
|
||||||
|
Serial.printf("[%lu] [KRS] Set server URL: %s\n", millis(), url.empty() ? "(default)" : url.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string KOReaderCredentialStore::getBaseUrl() const {
|
||||||
|
if (serverUrl.empty()) {
|
||||||
|
return DEFAULT_SERVER_URL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize URL: add http:// if no protocol specified (local servers typically don't have SSL)
|
||||||
|
if (serverUrl.find("://") == std::string::npos) {
|
||||||
|
return "http://" + serverUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return serverUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
void KOReaderCredentialStore::setMatchMethod(DocumentMatchMethod method) {
|
||||||
|
matchMethod = method;
|
||||||
|
Serial.printf("[%lu] [KRS] Set match method: %s\n", millis(),
|
||||||
|
method == DocumentMatchMethod::FILENAME ? "Filename" : "Binary");
|
||||||
|
}
|
||||||
69
lib/KOReaderSync/KOReaderCredentialStore.h
Normal file
69
lib/KOReaderSync/KOReaderCredentialStore.h
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
// Document matching method for KOReader sync
|
||||||
|
enum class DocumentMatchMethod : uint8_t {
|
||||||
|
FILENAME = 0, // Match by filename (simpler, works across different file sources)
|
||||||
|
BINARY = 1, // Match by partial MD5 of file content (more accurate, but files must be identical)
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton class for storing KOReader sync credentials on the SD card.
|
||||||
|
* Credentials are stored in /sd/.crosspoint/koreader.bin with basic
|
||||||
|
* XOR obfuscation to prevent casual reading (not cryptographically secure).
|
||||||
|
*/
|
||||||
|
class KOReaderCredentialStore {
|
||||||
|
private:
|
||||||
|
static KOReaderCredentialStore instance;
|
||||||
|
std::string username;
|
||||||
|
std::string password;
|
||||||
|
std::string serverUrl; // Custom sync server URL (empty = default)
|
||||||
|
DocumentMatchMethod matchMethod = DocumentMatchMethod::FILENAME; // Default to filename for compatibility
|
||||||
|
|
||||||
|
// Private constructor for singleton
|
||||||
|
KOReaderCredentialStore() = default;
|
||||||
|
|
||||||
|
// XOR obfuscation (symmetric - same for encode/decode)
|
||||||
|
void obfuscate(std::string& data) const;
|
||||||
|
|
||||||
|
public:
|
||||||
|
// Delete copy constructor and assignment
|
||||||
|
KOReaderCredentialStore(const KOReaderCredentialStore&) = delete;
|
||||||
|
KOReaderCredentialStore& operator=(const KOReaderCredentialStore&) = delete;
|
||||||
|
|
||||||
|
// Get singleton instance
|
||||||
|
static KOReaderCredentialStore& getInstance() { return instance; }
|
||||||
|
|
||||||
|
// Save/load from SD card
|
||||||
|
bool saveToFile() const;
|
||||||
|
bool loadFromFile();
|
||||||
|
|
||||||
|
// Credential management
|
||||||
|
void setCredentials(const std::string& user, const std::string& pass);
|
||||||
|
const std::string& getUsername() const { return username; }
|
||||||
|
const std::string& getPassword() const { return password; }
|
||||||
|
|
||||||
|
// Get MD5 hash of password for API authentication
|
||||||
|
std::string getMd5Password() const;
|
||||||
|
|
||||||
|
// Check if credentials are set
|
||||||
|
bool hasCredentials() const;
|
||||||
|
|
||||||
|
// Clear credentials
|
||||||
|
void clearCredentials();
|
||||||
|
|
||||||
|
// Server URL management
|
||||||
|
void setServerUrl(const std::string& url);
|
||||||
|
const std::string& getServerUrl() const { return serverUrl; }
|
||||||
|
|
||||||
|
// Get base URL for API calls (with http:// normalization if no protocol, falls back to default)
|
||||||
|
std::string getBaseUrl() const;
|
||||||
|
|
||||||
|
// Document matching method
|
||||||
|
void setMatchMethod(DocumentMatchMethod method);
|
||||||
|
DocumentMatchMethod getMatchMethod() const { return matchMethod; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper macro to access credential store
|
||||||
|
#define KOREADER_STORE KOReaderCredentialStore::getInstance()
|
||||||
96
lib/KOReaderSync/KOReaderDocumentId.cpp
Normal file
96
lib/KOReaderSync/KOReaderDocumentId.cpp
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
#include "KOReaderDocumentId.h"
|
||||||
|
|
||||||
|
#include <HardwareSerial.h>
|
||||||
|
#include <MD5Builder.h>
|
||||||
|
#include <SDCardManager.h>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// Extract filename from path (everything after last '/')
|
||||||
|
std::string getFilename(const std::string& path) {
|
||||||
|
const size_t pos = path.rfind('/');
|
||||||
|
if (pos == std::string::npos) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
return path.substr(pos + 1);
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::string KOReaderDocumentId::calculateFromFilename(const std::string& filePath) {
|
||||||
|
const std::string filename = getFilename(filePath);
|
||||||
|
if (filename.empty()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
MD5Builder md5;
|
||||||
|
md5.begin();
|
||||||
|
md5.add(filename.c_str());
|
||||||
|
md5.calculate();
|
||||||
|
|
||||||
|
std::string result = md5.toString().c_str();
|
||||||
|
Serial.printf("[%lu] [KODoc] Filename hash: %s (from '%s')\n", millis(), result.c_str(), filename.c_str());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t KOReaderDocumentId::getOffset(int i) {
|
||||||
|
// Offset = 1024 << (2*i)
|
||||||
|
// For i = -1: 1024 >> 2 = 256
|
||||||
|
// For i >= 0: 1024 << (2*i)
|
||||||
|
if (i < 0) {
|
||||||
|
return CHUNK_SIZE >> (-2 * i);
|
||||||
|
}
|
||||||
|
return CHUNK_SIZE << (2 * i);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string KOReaderDocumentId::calculate(const std::string& filePath) {
|
||||||
|
FsFile file;
|
||||||
|
if (!SdMan.openFileForRead("KODoc", filePath, file)) {
|
||||||
|
Serial.printf("[%lu] [KODoc] Failed to open file: %s\n", millis(), filePath.c_str());
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const size_t fileSize = file.fileSize();
|
||||||
|
Serial.printf("[%lu] [KODoc] Calculating hash for file: %s (size: %zu)\n", millis(), filePath.c_str(), fileSize);
|
||||||
|
|
||||||
|
// Initialize MD5 builder
|
||||||
|
MD5Builder md5;
|
||||||
|
md5.begin();
|
||||||
|
|
||||||
|
// Buffer for reading chunks
|
||||||
|
uint8_t buffer[CHUNK_SIZE];
|
||||||
|
size_t totalBytesRead = 0;
|
||||||
|
|
||||||
|
// Read from each offset (i = -1 to 10)
|
||||||
|
for (int i = -1; i < OFFSET_COUNT - 1; i++) {
|
||||||
|
const size_t offset = getOffset(i);
|
||||||
|
|
||||||
|
// Skip if offset is beyond file size
|
||||||
|
if (offset >= fileSize) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seek to offset
|
||||||
|
if (!file.seekSet(offset)) {
|
||||||
|
Serial.printf("[%lu] [KODoc] Failed to seek to offset %zu\n", millis(), offset);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read up to CHUNK_SIZE bytes
|
||||||
|
const size_t bytesToRead = std::min(CHUNK_SIZE, fileSize - offset);
|
||||||
|
const size_t bytesRead = file.read(buffer, bytesToRead);
|
||||||
|
|
||||||
|
if (bytesRead > 0) {
|
||||||
|
md5.add(buffer, bytesRead);
|
||||||
|
totalBytesRead += bytesRead;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
file.close();
|
||||||
|
|
||||||
|
// Calculate final hash
|
||||||
|
md5.calculate();
|
||||||
|
std::string result = md5.toString().c_str();
|
||||||
|
|
||||||
|
Serial.printf("[%lu] [KODoc] Hash calculated: %s (from %zu bytes)\n", millis(), result.c_str(), totalBytesRead);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
45
lib/KOReaderSync/KOReaderDocumentId.h
Normal file
45
lib/KOReaderSync/KOReaderDocumentId.h
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate KOReader document ID (partial MD5 hash).
|
||||||
|
*
|
||||||
|
* KOReader identifies documents using a partial MD5 hash of the file content.
|
||||||
|
* The algorithm reads 1024 bytes at specific offsets and computes the MD5 hash
|
||||||
|
* of the concatenated data.
|
||||||
|
*
|
||||||
|
* Offsets are calculated as: 1024 << (2*i) for i = -1 to 10
|
||||||
|
* Producing: 256, 1024, 4096, 16384, 65536, 262144, 1048576, 4194304,
|
||||||
|
* 16777216, 67108864, 268435456, 1073741824 bytes
|
||||||
|
*
|
||||||
|
* If an offset is beyond the file size, it is skipped.
|
||||||
|
*/
|
||||||
|
class KOReaderDocumentId {
|
||||||
|
public:
|
||||||
|
/**
|
||||||
|
* Calculate the KOReader document hash for a file (binary/content-based).
|
||||||
|
*
|
||||||
|
* @param filePath Path to the file (typically an EPUB)
|
||||||
|
* @return 32-character lowercase hex string, or empty string on failure
|
||||||
|
*/
|
||||||
|
static std::string calculate(const std::string& filePath);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate document hash from filename only (filename-based sync mode).
|
||||||
|
* This is simpler and works when files have the same name across devices.
|
||||||
|
*
|
||||||
|
* @param filePath Path to the file (only the filename portion is used)
|
||||||
|
* @return 32-character lowercase hex MD5 of the filename
|
||||||
|
*/
|
||||||
|
static std::string calculateFromFilename(const std::string& filePath);
|
||||||
|
|
||||||
|
private:
|
||||||
|
// Size of each chunk to read at each offset
|
||||||
|
static constexpr size_t CHUNK_SIZE = 1024;
|
||||||
|
|
||||||
|
// Number of offsets to try (i = -1 to 10, so 12 offsets)
|
||||||
|
static constexpr int OFFSET_COUNT = 12;
|
||||||
|
|
||||||
|
// Calculate offset for index i: 1024 << (2*i)
|
||||||
|
static size_t getOffset(int i);
|
||||||
|
};
|
||||||
198
lib/KOReaderSync/KOReaderSyncClient.cpp
Normal file
198
lib/KOReaderSync/KOReaderSyncClient.cpp
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
#include "KOReaderSyncClient.h"
|
||||||
|
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
#include <HTTPClient.h>
|
||||||
|
#include <HardwareSerial.h>
|
||||||
|
#include <WiFi.h>
|
||||||
|
#include <WiFiClientSecure.h>
|
||||||
|
|
||||||
|
#include <ctime>
|
||||||
|
|
||||||
|
#include "KOReaderCredentialStore.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// Device identifier for CrossPoint reader
|
||||||
|
constexpr char DEVICE_NAME[] = "CrossPoint";
|
||||||
|
constexpr char DEVICE_ID[] = "crosspoint-reader";
|
||||||
|
|
||||||
|
void addAuthHeaders(HTTPClient& http) {
|
||||||
|
http.addHeader("Accept", "application/vnd.koreader.v1+json");
|
||||||
|
http.addHeader("x-auth-user", KOREADER_STORE.getUsername().c_str());
|
||||||
|
http.addHeader("x-auth-key", KOREADER_STORE.getMd5Password().c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isHttpsUrl(const std::string& url) { return url.rfind("https://", 0) == 0; }
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
KOReaderSyncClient::Error KOReaderSyncClient::authenticate() {
|
||||||
|
if (!KOREADER_STORE.hasCredentials()) {
|
||||||
|
Serial.printf("[%lu] [KOSync] No credentials configured\n", millis());
|
||||||
|
return NO_CREDENTIALS;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string url = KOREADER_STORE.getBaseUrl() + "/users/auth";
|
||||||
|
Serial.printf("[%lu] [KOSync] Authenticating: %s\n", millis(), url.c_str());
|
||||||
|
|
||||||
|
HTTPClient http;
|
||||||
|
std::unique_ptr<WiFiClientSecure> secureClient;
|
||||||
|
WiFiClient plainClient;
|
||||||
|
|
||||||
|
if (isHttpsUrl(url)) {
|
||||||
|
secureClient.reset(new WiFiClientSecure);
|
||||||
|
secureClient->setInsecure();
|
||||||
|
http.begin(*secureClient, url.c_str());
|
||||||
|
} else {
|
||||||
|
http.begin(plainClient, url.c_str());
|
||||||
|
}
|
||||||
|
addAuthHeaders(http);
|
||||||
|
|
||||||
|
const int httpCode = http.GET();
|
||||||
|
http.end();
|
||||||
|
|
||||||
|
Serial.printf("[%lu] [KOSync] Auth response: %d\n", millis(), httpCode);
|
||||||
|
|
||||||
|
if (httpCode == 200) {
|
||||||
|
return OK;
|
||||||
|
} else if (httpCode == 401) {
|
||||||
|
return AUTH_FAILED;
|
||||||
|
} else if (httpCode < 0) {
|
||||||
|
return NETWORK_ERROR;
|
||||||
|
}
|
||||||
|
return SERVER_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& documentHash,
|
||||||
|
KOReaderProgress& outProgress) {
|
||||||
|
if (!KOREADER_STORE.hasCredentials()) {
|
||||||
|
Serial.printf("[%lu] [KOSync] No credentials configured\n", millis());
|
||||||
|
return NO_CREDENTIALS;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string url = KOREADER_STORE.getBaseUrl() + "/syncs/progress/" + documentHash;
|
||||||
|
Serial.printf("[%lu] [KOSync] Getting progress: %s\n", millis(), url.c_str());
|
||||||
|
|
||||||
|
HTTPClient http;
|
||||||
|
std::unique_ptr<WiFiClientSecure> secureClient;
|
||||||
|
WiFiClient plainClient;
|
||||||
|
|
||||||
|
if (isHttpsUrl(url)) {
|
||||||
|
secureClient.reset(new WiFiClientSecure);
|
||||||
|
secureClient->setInsecure();
|
||||||
|
http.begin(*secureClient, url.c_str());
|
||||||
|
} else {
|
||||||
|
http.begin(plainClient, url.c_str());
|
||||||
|
}
|
||||||
|
addAuthHeaders(http);
|
||||||
|
|
||||||
|
const int httpCode = http.GET();
|
||||||
|
|
||||||
|
if (httpCode == 200) {
|
||||||
|
// Parse JSON response from response string
|
||||||
|
String responseBody = http.getString();
|
||||||
|
http.end();
|
||||||
|
|
||||||
|
JsonDocument doc;
|
||||||
|
const DeserializationError error = deserializeJson(doc, responseBody);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
Serial.printf("[%lu] [KOSync] JSON parse failed: %s\n", millis(), error.c_str());
|
||||||
|
return JSON_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
outProgress.document = documentHash;
|
||||||
|
outProgress.progress = doc["progress"].as<std::string>();
|
||||||
|
outProgress.percentage = doc["percentage"].as<float>();
|
||||||
|
outProgress.device = doc["device"].as<std::string>();
|
||||||
|
outProgress.deviceId = doc["device_id"].as<std::string>();
|
||||||
|
outProgress.timestamp = doc["timestamp"].as<int64_t>();
|
||||||
|
|
||||||
|
Serial.printf("[%lu] [KOSync] Got progress: %.2f%% at %s\n", millis(), outProgress.percentage * 100,
|
||||||
|
outProgress.progress.c_str());
|
||||||
|
return OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
http.end();
|
||||||
|
|
||||||
|
Serial.printf("[%lu] [KOSync] Get progress response: %d\n", millis(), httpCode);
|
||||||
|
|
||||||
|
if (httpCode == 401) {
|
||||||
|
return AUTH_FAILED;
|
||||||
|
} else if (httpCode == 404) {
|
||||||
|
return NOT_FOUND;
|
||||||
|
} else if (httpCode < 0) {
|
||||||
|
return NETWORK_ERROR;
|
||||||
|
}
|
||||||
|
return SERVER_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
KOReaderSyncClient::Error KOReaderSyncClient::updateProgress(const KOReaderProgress& progress) {
|
||||||
|
if (!KOREADER_STORE.hasCredentials()) {
|
||||||
|
Serial.printf("[%lu] [KOSync] No credentials configured\n", millis());
|
||||||
|
return NO_CREDENTIALS;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string url = KOREADER_STORE.getBaseUrl() + "/syncs/progress";
|
||||||
|
Serial.printf("[%lu] [KOSync] Updating progress: %s\n", millis(), url.c_str());
|
||||||
|
|
||||||
|
HTTPClient http;
|
||||||
|
std::unique_ptr<WiFiClientSecure> secureClient;
|
||||||
|
WiFiClient plainClient;
|
||||||
|
|
||||||
|
if (isHttpsUrl(url)) {
|
||||||
|
secureClient.reset(new WiFiClientSecure);
|
||||||
|
secureClient->setInsecure();
|
||||||
|
http.begin(*secureClient, url.c_str());
|
||||||
|
} else {
|
||||||
|
http.begin(plainClient, url.c_str());
|
||||||
|
}
|
||||||
|
addAuthHeaders(http);
|
||||||
|
http.addHeader("Content-Type", "application/json");
|
||||||
|
|
||||||
|
// Build JSON body (timestamp not required per API spec)
|
||||||
|
JsonDocument doc;
|
||||||
|
doc["document"] = progress.document;
|
||||||
|
doc["progress"] = progress.progress;
|
||||||
|
doc["percentage"] = progress.percentage;
|
||||||
|
doc["device"] = DEVICE_NAME;
|
||||||
|
doc["device_id"] = DEVICE_ID;
|
||||||
|
|
||||||
|
std::string body;
|
||||||
|
serializeJson(doc, body);
|
||||||
|
|
||||||
|
Serial.printf("[%lu] [KOSync] Request body: %s\n", millis(), body.c_str());
|
||||||
|
|
||||||
|
const int httpCode = http.PUT(body.c_str());
|
||||||
|
http.end();
|
||||||
|
|
||||||
|
Serial.printf("[%lu] [KOSync] Update progress response: %d\n", millis(), httpCode);
|
||||||
|
|
||||||
|
if (httpCode == 200 || httpCode == 202) {
|
||||||
|
return OK;
|
||||||
|
} else if (httpCode == 401) {
|
||||||
|
return AUTH_FAILED;
|
||||||
|
} else if (httpCode < 0) {
|
||||||
|
return NETWORK_ERROR;
|
||||||
|
}
|
||||||
|
return SERVER_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* KOReaderSyncClient::errorString(Error error) {
|
||||||
|
switch (error) {
|
||||||
|
case OK:
|
||||||
|
return "Success";
|
||||||
|
case NO_CREDENTIALS:
|
||||||
|
return "No credentials configured";
|
||||||
|
case NETWORK_ERROR:
|
||||||
|
return "Network error";
|
||||||
|
case AUTH_FAILED:
|
||||||
|
return "Authentication failed";
|
||||||
|
case SERVER_ERROR:
|
||||||
|
return "Server error (try again later)";
|
||||||
|
case JSON_ERROR:
|
||||||
|
return "JSON parse error";
|
||||||
|
case NOT_FOUND:
|
||||||
|
return "No progress found";
|
||||||
|
default:
|
||||||
|
return "Unknown error";
|
||||||
|
}
|
||||||
|
}
|
||||||
59
lib/KOReaderSync/KOReaderSyncClient.h
Normal file
59
lib/KOReaderSync/KOReaderSyncClient.h
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Progress data from KOReader sync server.
|
||||||
|
*/
|
||||||
|
struct KOReaderProgress {
|
||||||
|
std::string document; // Document hash
|
||||||
|
std::string progress; // XPath-like progress string
|
||||||
|
float percentage; // Progress percentage (0.0 to 1.0)
|
||||||
|
std::string device; // Device name
|
||||||
|
std::string deviceId; // Device ID
|
||||||
|
int64_t timestamp; // Unix timestamp of last update
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP client for KOReader sync API.
|
||||||
|
*
|
||||||
|
* Base URL: https://sync.koreader.rocks:443/
|
||||||
|
*
|
||||||
|
* API Endpoints:
|
||||||
|
* GET /users/auth - Authenticate (validate credentials)
|
||||||
|
* GET /syncs/progress/:document - Get progress for a document
|
||||||
|
* PUT /syncs/progress - Update progress for a document
|
||||||
|
*
|
||||||
|
* Authentication:
|
||||||
|
* x-auth-user: username
|
||||||
|
* x-auth-key: MD5 hash of password
|
||||||
|
*/
|
||||||
|
class KOReaderSyncClient {
|
||||||
|
public:
|
||||||
|
enum Error { OK = 0, NO_CREDENTIALS, NETWORK_ERROR, AUTH_FAILED, SERVER_ERROR, JSON_ERROR, NOT_FOUND };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate with the sync server (validate credentials).
|
||||||
|
* @return OK on success, error code on failure
|
||||||
|
*/
|
||||||
|
static Error authenticate();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get reading progress for a document.
|
||||||
|
* @param documentHash The document hash (from KOReaderDocumentId)
|
||||||
|
* @param outProgress Output: the progress data
|
||||||
|
* @return OK on success, NOT_FOUND if no progress exists, error code on failure
|
||||||
|
*/
|
||||||
|
static Error getProgress(const std::string& documentHash, KOReaderProgress& outProgress);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update reading progress for a document.
|
||||||
|
* @param progress The progress data to upload
|
||||||
|
* @return OK on success, error code on failure
|
||||||
|
*/
|
||||||
|
static Error updateProgress(const KOReaderProgress& progress);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get human-readable error message.
|
||||||
|
*/
|
||||||
|
static const char* errorString(Error error);
|
||||||
|
};
|
||||||
112
lib/KOReaderSync/ProgressMapper.cpp
Normal file
112
lib/KOReaderSync/ProgressMapper.cpp
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
#include "ProgressMapper.h"
|
||||||
|
|
||||||
|
#include <HardwareSerial.h>
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
KOReaderPosition ProgressMapper::toKOReader(const std::shared_ptr<Epub>& epub, const CrossPointPosition& pos) {
|
||||||
|
KOReaderPosition result;
|
||||||
|
|
||||||
|
// Calculate page progress within current spine item
|
||||||
|
float intraSpineProgress = 0.0f;
|
||||||
|
if (pos.totalPages > 0) {
|
||||||
|
intraSpineProgress = static_cast<float>(pos.pageNumber) / static_cast<float>(pos.totalPages);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate overall book progress (0.0-1.0)
|
||||||
|
result.percentage = epub->calculateProgress(pos.spineIndex, intraSpineProgress);
|
||||||
|
|
||||||
|
// Generate XPath with estimated paragraph position based on page
|
||||||
|
result.xpath = generateXPath(pos.spineIndex, pos.pageNumber, pos.totalPages);
|
||||||
|
|
||||||
|
// Get chapter info for logging
|
||||||
|
const int tocIndex = epub->getTocIndexForSpineIndex(pos.spineIndex);
|
||||||
|
const std::string chapterName = (tocIndex >= 0) ? epub->getTocItem(tocIndex).title : "unknown";
|
||||||
|
|
||||||
|
Serial.printf("[%lu] [ProgressMapper] CrossPoint -> KOReader: chapter='%s', page=%d/%d -> %.2f%% at %s\n", millis(),
|
||||||
|
chapterName.c_str(), pos.pageNumber, pos.totalPages, result.percentage * 100, result.xpath.c_str());
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epub, const KOReaderPosition& koPos,
|
||||||
|
int totalPagesInSpine) {
|
||||||
|
CrossPointPosition result;
|
||||||
|
result.spineIndex = 0;
|
||||||
|
result.pageNumber = 0;
|
||||||
|
result.totalPages = totalPagesInSpine;
|
||||||
|
|
||||||
|
const size_t bookSize = epub->getBookSize();
|
||||||
|
if (bookSize == 0) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First, try to get spine index from XPath (DocFragment)
|
||||||
|
int xpathSpineIndex = parseDocFragmentIndex(koPos.xpath);
|
||||||
|
if (xpathSpineIndex >= 0 && xpathSpineIndex < epub->getSpineItemsCount()) {
|
||||||
|
result.spineIndex = xpathSpineIndex;
|
||||||
|
// When we have XPath, go to page 0 of the spine - byte-based page calculation is unreliable
|
||||||
|
result.pageNumber = 0;
|
||||||
|
} else {
|
||||||
|
// Fall back to percentage-based lookup for both spine and page
|
||||||
|
const size_t targetBytes = static_cast<size_t>(bookSize * koPos.percentage);
|
||||||
|
|
||||||
|
// Find the spine item that contains this byte position
|
||||||
|
for (int i = 0; i < epub->getSpineItemsCount(); i++) {
|
||||||
|
const size_t cumulativeSize = epub->getCumulativeSpineItemSize(i);
|
||||||
|
if (cumulativeSize >= targetBytes) {
|
||||||
|
result.spineIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estimate page number within the spine item using percentage (only when no XPath)
|
||||||
|
if (totalPagesInSpine > 0 && result.spineIndex < epub->getSpineItemsCount()) {
|
||||||
|
const size_t prevCumSize = (result.spineIndex > 0) ? epub->getCumulativeSpineItemSize(result.spineIndex - 1) : 0;
|
||||||
|
const size_t currentCumSize = epub->getCumulativeSpineItemSize(result.spineIndex);
|
||||||
|
const size_t spineSize = currentCumSize - prevCumSize;
|
||||||
|
|
||||||
|
if (spineSize > 0) {
|
||||||
|
const size_t bytesIntoSpine = (targetBytes > prevCumSize) ? (targetBytes - prevCumSize) : 0;
|
||||||
|
const float intraSpineProgress = static_cast<float>(bytesIntoSpine) / static_cast<float>(spineSize);
|
||||||
|
const float clampedProgress = std::max(0.0f, std::min(1.0f, intraSpineProgress));
|
||||||
|
result.pageNumber = static_cast<int>(clampedProgress * totalPagesInSpine);
|
||||||
|
result.pageNumber = std::max(0, std::min(result.pageNumber, totalPagesInSpine - 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.printf("[%lu] [ProgressMapper] KOReader -> CrossPoint: %.2f%% at %s -> spine=%d, page=%d\n", millis(),
|
||||||
|
koPos.percentage * 100, koPos.xpath.c_str(), result.spineIndex, result.pageNumber);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string ProgressMapper::generateXPath(int spineIndex, int pageNumber, int totalPages) {
|
||||||
|
// KOReader uses 1-based DocFragment indices
|
||||||
|
// Use a simple xpath pointing to the DocFragment - KOReader will use the percentage for fine positioning
|
||||||
|
// Avoid specifying paragraph numbers as they may not exist in the target document
|
||||||
|
return "/body/DocFragment[" + std::to_string(spineIndex + 1) + "]/body";
|
||||||
|
}
|
||||||
|
|
||||||
|
int ProgressMapper::parseDocFragmentIndex(const std::string& xpath) {
|
||||||
|
// Look for DocFragment[N] pattern
|
||||||
|
const size_t start = xpath.find("DocFragment[");
|
||||||
|
if (start == std::string::npos) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const size_t numStart = start + 12; // Length of "DocFragment["
|
||||||
|
const size_t numEnd = xpath.find(']', numStart);
|
||||||
|
if (numEnd == std::string::npos) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const int docFragmentIndex = std::stoi(xpath.substr(numStart, numEnd - numStart));
|
||||||
|
// KOReader uses 1-based indices, we use 0-based
|
||||||
|
return docFragmentIndex - 1;
|
||||||
|
} catch (...) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
72
lib/KOReaderSync/ProgressMapper.h
Normal file
72
lib/KOReaderSync/ProgressMapper.h
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <Epub.h>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CrossPoint position representation.
|
||||||
|
*/
|
||||||
|
struct CrossPointPosition {
|
||||||
|
int spineIndex; // Current spine item (chapter) index
|
||||||
|
int pageNumber; // Current page within the spine item
|
||||||
|
int totalPages; // Total pages in the current spine item
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KOReader position representation.
|
||||||
|
*/
|
||||||
|
struct KOReaderPosition {
|
||||||
|
std::string xpath; // XPath-like progress string
|
||||||
|
float percentage; // Progress percentage (0.0 to 1.0)
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps between CrossPoint and KOReader position formats.
|
||||||
|
*
|
||||||
|
* CrossPoint tracks position as (spineIndex, pageNumber).
|
||||||
|
* KOReader uses XPath-like strings + percentage.
|
||||||
|
*
|
||||||
|
* Since CrossPoint discards HTML structure during parsing, we generate
|
||||||
|
* synthetic XPath strings based on spine index, using percentage as the
|
||||||
|
* primary sync mechanism.
|
||||||
|
*/
|
||||||
|
class ProgressMapper {
|
||||||
|
public:
|
||||||
|
/**
|
||||||
|
* Convert CrossPoint position to KOReader format.
|
||||||
|
*
|
||||||
|
* @param epub The EPUB book
|
||||||
|
* @param pos CrossPoint position
|
||||||
|
* @return KOReader position
|
||||||
|
*/
|
||||||
|
static KOReaderPosition toKOReader(const std::shared_ptr<Epub>& epub, const CrossPointPosition& pos);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert KOReader position to CrossPoint format.
|
||||||
|
*
|
||||||
|
* Note: The returned pageNumber may be approximate since different
|
||||||
|
* rendering settings produce different page counts.
|
||||||
|
*
|
||||||
|
* @param epub The EPUB book
|
||||||
|
* @param koPos KOReader position
|
||||||
|
* @param totalPagesInSpine Total pages in the target spine item (for page estimation)
|
||||||
|
* @return CrossPoint position
|
||||||
|
*/
|
||||||
|
static CrossPointPosition toCrossPoint(const std::shared_ptr<Epub>& epub, const KOReaderPosition& koPos,
|
||||||
|
int totalPagesInSpine = 0);
|
||||||
|
|
||||||
|
private:
|
||||||
|
/**
|
||||||
|
* Generate XPath for KOReader compatibility.
|
||||||
|
* Format: /body/DocFragment[spineIndex+1]/body/p[estimatedParagraph]
|
||||||
|
* Paragraph is estimated based on page position within the chapter.
|
||||||
|
*/
|
||||||
|
static std::string generateXPath(int spineIndex, int pageNumber, int totalPages);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse DocFragment index from XPath string.
|
||||||
|
* Returns -1 if not found.
|
||||||
|
*/
|
||||||
|
static int parseDocFragmentIndex(const std::string& xpath);
|
||||||
|
};
|
||||||
@ -118,9 +118,11 @@ void EpubReaderActivity::loop() {
|
|||||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||||
// Don't start activity transition while rendering
|
// Don't start activity transition while rendering
|
||||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
|
const int currentPage = section ? section->currentPage : 0;
|
||||||
|
const int totalPages = section ? section->pageCount : 0;
|
||||||
exitActivity();
|
exitActivity();
|
||||||
enterNewActivity(new EpubReaderChapterSelectionActivity(
|
enterNewActivity(new EpubReaderChapterSelectionActivity(
|
||||||
this->renderer, this->mappedInput, epub, currentSpineIndex,
|
this->renderer, this->mappedInput, epub, epub->getPath(), currentSpineIndex, currentPage, totalPages,
|
||||||
[this] {
|
[this] {
|
||||||
exitActivity();
|
exitActivity();
|
||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
@ -133,6 +135,16 @@ void EpubReaderActivity::loop() {
|
|||||||
}
|
}
|
||||||
exitActivity();
|
exitActivity();
|
||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
|
},
|
||||||
|
[this](const int newSpineIndex, const int newPage) {
|
||||||
|
// Handle sync position
|
||||||
|
if (currentSpineIndex != newSpineIndex || (section && section->currentPage != newPage)) {
|
||||||
|
currentSpineIndex = newSpineIndex;
|
||||||
|
nextPageNumber = newPage;
|
||||||
|
section.reset();
|
||||||
|
}
|
||||||
|
exitActivity();
|
||||||
|
updateRequired = true;
|
||||||
}));
|
}));
|
||||||
xSemaphoreGive(renderingMutex);
|
xSemaphoreGive(renderingMutex);
|
||||||
}
|
}
|
||||||
@ -430,11 +442,13 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in
|
|||||||
if (showProgress) {
|
if (showProgress) {
|
||||||
// Calculate progress in book
|
// Calculate progress in book
|
||||||
const float sectionChapterProg = static_cast<float>(section->currentPage) / section->pageCount;
|
const float sectionChapterProg = static_cast<float>(section->currentPage) / section->pageCount;
|
||||||
const uint8_t bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg);
|
const float bookProgress = epub->calculateProgress(currentSpineIndex, sectionChapterProg) * 100;
|
||||||
|
|
||||||
// Right aligned text for progress counter
|
// Right aligned text for progress counter
|
||||||
const std::string progress = std::to_string(section->currentPage + 1) + "/" + std::to_string(section->pageCount) +
|
char progressStr[32];
|
||||||
" " + std::to_string(bookProgress) + "%";
|
snprintf(progressStr, sizeof(progressStr), "%d/%d %.1f%%", section->currentPage + 1, section->pageCount,
|
||||||
|
bookProgress);
|
||||||
|
const std::string progress = progressStr;
|
||||||
progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progress.c_str());
|
progressTextWidth = renderer.getTextWidth(SMALL_FONT_ID, progress.c_str());
|
||||||
renderer.drawText(SMALL_FONT_ID, renderer.getScreenWidth() - orientedMarginRight - progressTextWidth, textY,
|
renderer.drawText(SMALL_FONT_ID, renderer.getScreenWidth() - orientedMarginRight - progressTextWidth, textY,
|
||||||
progress.c_str());
|
progress.c_str());
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
|
|
||||||
|
#include "KOReaderCredentialStore.h"
|
||||||
|
#include "KOReaderSyncActivity.h"
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
|
|
||||||
@ -10,6 +12,26 @@ namespace {
|
|||||||
constexpr int SKIP_PAGE_MS = 700;
|
constexpr int SKIP_PAGE_MS = 700;
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
bool EpubReaderChapterSelectionActivity::hasSyncOption() const { return KOREADER_STORE.hasCredentials(); }
|
||||||
|
|
||||||
|
int EpubReaderChapterSelectionActivity::getTotalItems() const {
|
||||||
|
// Add 2 for sync options (top and bottom) if credentials are configured
|
||||||
|
const int syncCount = hasSyncOption() ? 2 : 0;
|
||||||
|
return epub->getTocItemsCount() + syncCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool EpubReaderChapterSelectionActivity::isSyncItem(int index) const {
|
||||||
|
if (!hasSyncOption()) return false;
|
||||||
|
// First item and last item are sync options
|
||||||
|
return index == 0 || index == getTotalItems() - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int EpubReaderChapterSelectionActivity::tocIndexFromItemIndex(int itemIndex) const {
|
||||||
|
// Account for the sync option at the top
|
||||||
|
const int offset = hasSyncOption() ? 1 : 0;
|
||||||
|
return itemIndex - offset;
|
||||||
|
}
|
||||||
|
|
||||||
int EpubReaderChapterSelectionActivity::getPageItems() const {
|
int EpubReaderChapterSelectionActivity::getPageItems() const {
|
||||||
// Layout constants used in renderScreen
|
// Layout constants used in renderScreen
|
||||||
constexpr int startY = 60;
|
constexpr int startY = 60;
|
||||||
@ -34,17 +56,21 @@ void EpubReaderChapterSelectionActivity::taskTrampoline(void* param) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void EpubReaderChapterSelectionActivity::onEnter() {
|
void EpubReaderChapterSelectionActivity::onEnter() {
|
||||||
Activity::onEnter();
|
ActivityWithSubactivity::onEnter();
|
||||||
|
|
||||||
if (!epub) {
|
if (!epub) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderingMutex = xSemaphoreCreateMutex();
|
renderingMutex = xSemaphoreCreateMutex();
|
||||||
|
|
||||||
|
// Account for sync option offset when finding current TOC index
|
||||||
|
const int syncOffset = hasSyncOption() ? 1 : 0;
|
||||||
selectorIndex = epub->getTocIndexForSpineIndex(currentSpineIndex);
|
selectorIndex = epub->getTocIndexForSpineIndex(currentSpineIndex);
|
||||||
if (selectorIndex == -1) {
|
if (selectorIndex == -1) {
|
||||||
selectorIndex = 0;
|
selectorIndex = 0;
|
||||||
}
|
}
|
||||||
|
selectorIndex += syncOffset; // Offset for top sync option
|
||||||
|
|
||||||
// Trigger first update
|
// Trigger first update
|
||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
@ -57,7 +83,7 @@ void EpubReaderChapterSelectionActivity::onEnter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void EpubReaderChapterSelectionActivity::onExit() {
|
void EpubReaderChapterSelectionActivity::onExit() {
|
||||||
Activity::onExit();
|
ActivityWithSubactivity::onExit();
|
||||||
|
|
||||||
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD
|
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD
|
||||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
@ -69,7 +95,30 @@ void EpubReaderChapterSelectionActivity::onExit() {
|
|||||||
renderingMutex = nullptr;
|
renderingMutex = nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void EpubReaderChapterSelectionActivity::launchSyncActivity() {
|
||||||
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
|
exitActivity();
|
||||||
|
enterNewActivity(new KOReaderSyncActivity(
|
||||||
|
renderer, mappedInput, epub, epubPath, currentSpineIndex, currentPage, totalPagesInSpine,
|
||||||
|
[this]() {
|
||||||
|
// On cancel
|
||||||
|
exitActivity();
|
||||||
|
updateRequired = true;
|
||||||
|
},
|
||||||
|
[this](int newSpineIndex, int newPage) {
|
||||||
|
// On sync complete
|
||||||
|
exitActivity();
|
||||||
|
onSyncPosition(newSpineIndex, newPage);
|
||||||
|
}));
|
||||||
|
xSemaphoreGive(renderingMutex);
|
||||||
|
}
|
||||||
|
|
||||||
void EpubReaderChapterSelectionActivity::loop() {
|
void EpubReaderChapterSelectionActivity::loop() {
|
||||||
|
if (subActivity) {
|
||||||
|
subActivity->loop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::Up) ||
|
const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::Up) ||
|
||||||
mappedInput.wasReleased(MappedInputManager::Button::Left);
|
mappedInput.wasReleased(MappedInputManager::Button::Left);
|
||||||
const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::Down) ||
|
const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::Down) ||
|
||||||
@ -77,9 +126,18 @@ void EpubReaderChapterSelectionActivity::loop() {
|
|||||||
|
|
||||||
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
|
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
|
||||||
const int pageItems = getPageItems();
|
const int pageItems = getPageItems();
|
||||||
|
const int totalItems = getTotalItems();
|
||||||
|
|
||||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||||
const auto newSpineIndex = epub->getSpineIndexForTocIndex(selectorIndex);
|
// Check if sync option is selected (first or last item)
|
||||||
|
if (isSyncItem(selectorIndex)) {
|
||||||
|
launchSyncActivity();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get TOC index (account for top sync offset)
|
||||||
|
const int tocIndex = tocIndexFromItemIndex(selectorIndex);
|
||||||
|
const auto newSpineIndex = epub->getSpineIndexForTocIndex(tocIndex);
|
||||||
if (newSpineIndex == -1) {
|
if (newSpineIndex == -1) {
|
||||||
onGoBack();
|
onGoBack();
|
||||||
} else {
|
} else {
|
||||||
@ -89,17 +147,16 @@ void EpubReaderChapterSelectionActivity::loop() {
|
|||||||
onGoBack();
|
onGoBack();
|
||||||
} else if (prevReleased) {
|
} else if (prevReleased) {
|
||||||
if (skipPage) {
|
if (skipPage) {
|
||||||
selectorIndex =
|
selectorIndex = ((selectorIndex / pageItems - 1) * pageItems + totalItems) % totalItems;
|
||||||
((selectorIndex / pageItems - 1) * pageItems + epub->getTocItemsCount()) % epub->getTocItemsCount();
|
|
||||||
} else {
|
} else {
|
||||||
selectorIndex = (selectorIndex + epub->getTocItemsCount() - 1) % epub->getTocItemsCount();
|
selectorIndex = (selectorIndex + totalItems - 1) % totalItems;
|
||||||
}
|
}
|
||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
} else if (nextReleased) {
|
} else if (nextReleased) {
|
||||||
if (skipPage) {
|
if (skipPage) {
|
||||||
selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % epub->getTocItemsCount();
|
selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % totalItems;
|
||||||
} else {
|
} else {
|
||||||
selectorIndex = (selectorIndex + 1) % epub->getTocItemsCount();
|
selectorIndex = (selectorIndex + 1) % totalItems;
|
||||||
}
|
}
|
||||||
updateRequired = true;
|
updateRequired = true;
|
||||||
}
|
}
|
||||||
@ -107,7 +164,7 @@ void EpubReaderChapterSelectionActivity::loop() {
|
|||||||
|
|
||||||
void EpubReaderChapterSelectionActivity::displayTaskLoop() {
|
void EpubReaderChapterSelectionActivity::displayTaskLoop() {
|
||||||
while (true) {
|
while (true) {
|
||||||
if (updateRequired) {
|
if (updateRequired && !subActivity) {
|
||||||
updateRequired = false;
|
updateRequired = false;
|
||||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
renderScreen();
|
renderScreen();
|
||||||
@ -122,6 +179,7 @@ void EpubReaderChapterSelectionActivity::renderScreen() {
|
|||||||
|
|
||||||
const auto pageWidth = renderer.getScreenWidth();
|
const auto pageWidth = renderer.getScreenWidth();
|
||||||
const int pageItems = getPageItems();
|
const int pageItems = getPageItems();
|
||||||
|
const int totalItems = getTotalItems();
|
||||||
|
|
||||||
const std::string title =
|
const std::string title =
|
||||||
renderer.truncatedText(UI_12_FONT_ID, epub->getTitle().c_str(), pageWidth - 40, EpdFontFamily::BOLD);
|
renderer.truncatedText(UI_12_FONT_ID, epub->getTitle().c_str(), pageWidth - 40, EpdFontFamily::BOLD);
|
||||||
@ -129,11 +187,20 @@ void EpubReaderChapterSelectionActivity::renderScreen() {
|
|||||||
|
|
||||||
const auto pageStartIndex = selectorIndex / pageItems * pageItems;
|
const auto pageStartIndex = selectorIndex / pageItems * pageItems;
|
||||||
renderer.fillRect(0, 60 + (selectorIndex % pageItems) * 30 - 2, pageWidth - 1, 30);
|
renderer.fillRect(0, 60 + (selectorIndex % pageItems) * 30 - 2, pageWidth - 1, 30);
|
||||||
for (int tocIndex = pageStartIndex; tocIndex < epub->getTocItemsCount() && tocIndex < pageStartIndex + pageItems;
|
|
||||||
tocIndex++) {
|
for (int itemIndex = pageStartIndex; itemIndex < totalItems && itemIndex < pageStartIndex + pageItems; itemIndex++) {
|
||||||
auto item = epub->getTocItem(tocIndex);
|
const int displayY = 60 + (itemIndex % pageItems) * 30;
|
||||||
renderer.drawText(UI_10_FONT_ID, 20 + (item.level - 1) * 15, 60 + (tocIndex % pageItems) * 30, item.title.c_str(),
|
const bool isSelected = (itemIndex == selectorIndex);
|
||||||
tocIndex != selectorIndex);
|
|
||||||
|
if (isSyncItem(itemIndex)) {
|
||||||
|
// Draw sync option (at top or bottom)
|
||||||
|
renderer.drawText(UI_10_FONT_ID, 20, displayY, ">> Sync Progress", !isSelected);
|
||||||
|
} else {
|
||||||
|
// Draw TOC item (account for top sync offset)
|
||||||
|
const int tocIndex = tocIndexFromItemIndex(itemIndex);
|
||||||
|
auto item = epub->getTocItem(tocIndex);
|
||||||
|
renderer.drawText(UI_10_FONT_ID, 20 + (item.level - 1) * 15, displayY, item.title.c_str(), !isSelected);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down");
|
const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down");
|
||||||
|
|||||||
@ -6,36 +6,59 @@
|
|||||||
|
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
|
||||||
#include "../Activity.h"
|
#include "../ActivityWithSubactivity.h"
|
||||||
|
|
||||||
class EpubReaderChapterSelectionActivity final : public Activity {
|
class EpubReaderChapterSelectionActivity final : public ActivityWithSubactivity {
|
||||||
std::shared_ptr<Epub> epub;
|
std::shared_ptr<Epub> epub;
|
||||||
|
std::string epubPath;
|
||||||
TaskHandle_t displayTaskHandle = nullptr;
|
TaskHandle_t displayTaskHandle = nullptr;
|
||||||
SemaphoreHandle_t renderingMutex = nullptr;
|
SemaphoreHandle_t renderingMutex = nullptr;
|
||||||
int currentSpineIndex = 0;
|
int currentSpineIndex = 0;
|
||||||
|
int currentPage = 0;
|
||||||
|
int totalPagesInSpine = 0;
|
||||||
int selectorIndex = 0;
|
int selectorIndex = 0;
|
||||||
bool updateRequired = false;
|
bool updateRequired = false;
|
||||||
const std::function<void()> onGoBack;
|
const std::function<void()> onGoBack;
|
||||||
const std::function<void(int newSpineIndex)> onSelectSpineIndex;
|
const std::function<void(int newSpineIndex)> onSelectSpineIndex;
|
||||||
|
const std::function<void(int newSpineIndex, int newPage)> onSyncPosition;
|
||||||
|
|
||||||
// Number of items that fit on a page, derived from logical screen height.
|
// Number of items that fit on a page, derived from logical screen height.
|
||||||
// This adapts automatically when switching between portrait and landscape.
|
// This adapts automatically when switching between portrait and landscape.
|
||||||
int getPageItems() const;
|
int getPageItems() const;
|
||||||
|
|
||||||
|
// Total items including sync options (top and bottom)
|
||||||
|
int getTotalItems() const;
|
||||||
|
|
||||||
|
// Check if sync option is available (credentials configured)
|
||||||
|
bool hasSyncOption() const;
|
||||||
|
|
||||||
|
// Check if given item index is a sync option (first or last)
|
||||||
|
bool isSyncItem(int index) const;
|
||||||
|
|
||||||
|
// Convert item index to TOC index (accounting for top sync option offset)
|
||||||
|
int tocIndexFromItemIndex(int itemIndex) const;
|
||||||
|
|
||||||
static void taskTrampoline(void* param);
|
static void taskTrampoline(void* param);
|
||||||
[[noreturn]] void displayTaskLoop();
|
[[noreturn]] void displayTaskLoop();
|
||||||
void renderScreen();
|
void renderScreen();
|
||||||
|
void launchSyncActivity();
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit EpubReaderChapterSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
explicit EpubReaderChapterSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||||
const std::shared_ptr<Epub>& epub, const int currentSpineIndex,
|
const std::shared_ptr<Epub>& epub, const std::string& epubPath,
|
||||||
const std::function<void()>& onGoBack,
|
const int currentSpineIndex, const int currentPage,
|
||||||
const std::function<void(int newSpineIndex)>& onSelectSpineIndex)
|
const int totalPagesInSpine, const std::function<void()>& onGoBack,
|
||||||
: Activity("EpubReaderChapterSelection", renderer, mappedInput),
|
const std::function<void(int newSpineIndex)>& onSelectSpineIndex,
|
||||||
|
const std::function<void(int newSpineIndex, int newPage)>& onSyncPosition)
|
||||||
|
: ActivityWithSubactivity("EpubReaderChapterSelection", renderer, mappedInput),
|
||||||
epub(epub),
|
epub(epub),
|
||||||
|
epubPath(epubPath),
|
||||||
currentSpineIndex(currentSpineIndex),
|
currentSpineIndex(currentSpineIndex),
|
||||||
|
currentPage(currentPage),
|
||||||
|
totalPagesInSpine(totalPagesInSpine),
|
||||||
onGoBack(onGoBack),
|
onGoBack(onGoBack),
|
||||||
onSelectSpineIndex(onSelectSpineIndex) {}
|
onSelectSpineIndex(onSelectSpineIndex),
|
||||||
|
onSyncPosition(onSyncPosition) {}
|
||||||
void onEnter() override;
|
void onEnter() override;
|
||||||
void onExit() override;
|
void onExit() override;
|
||||||
void loop() override;
|
void loop() override;
|
||||||
|
|||||||
439
src/activities/reader/KOReaderSyncActivity.cpp
Normal file
439
src/activities/reader/KOReaderSyncActivity.cpp
Normal file
@ -0,0 +1,439 @@
|
|||||||
|
#include "KOReaderSyncActivity.h"
|
||||||
|
|
||||||
|
#include <GfxRenderer.h>
|
||||||
|
#include <WiFi.h>
|
||||||
|
#include <esp_sntp.h>
|
||||||
|
|
||||||
|
#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<KOReaderSyncActivity*>(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<KOReaderSyncActivity*>(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, 15, "KOReader Sync", true, EpdFontFamily::BOLD);
|
||||||
|
|
||||||
|
if (state == NO_CREDENTIALS) {
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, 280, "No credentials configured", true, EpdFontFamily::BOLD);
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, 320, "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, 300, statusMessage.c_str(), true, EpdFontFamily::BOLD);
|
||||||
|
renderer.displayBuffer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state == SHOWING_RESULT) {
|
||||||
|
// Show comparison
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, 120, "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, 20, 160, "Remote:", true);
|
||||||
|
char remoteChapterStr[128];
|
||||||
|
snprintf(remoteChapterStr, sizeof(remoteChapterStr), " %s", remoteChapter.c_str());
|
||||||
|
renderer.drawText(UI_10_FONT_ID, 20, 185, remoteChapterStr);
|
||||||
|
char remotePageStr[64];
|
||||||
|
snprintf(remotePageStr, sizeof(remotePageStr), " Page %d, %.2f%% overall", remotePosition.pageNumber + 1,
|
||||||
|
remoteProgress.percentage * 100);
|
||||||
|
renderer.drawText(UI_10_FONT_ID, 20, 210, remotePageStr);
|
||||||
|
|
||||||
|
if (!remoteProgress.device.empty()) {
|
||||||
|
char deviceStr[64];
|
||||||
|
snprintf(deviceStr, sizeof(deviceStr), " From: %s", remoteProgress.device.c_str());
|
||||||
|
renderer.drawText(UI_10_FONT_ID, 20, 235, deviceStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local progress - chapter and page
|
||||||
|
renderer.drawText(UI_10_FONT_ID, 20, 270, "Local:", true);
|
||||||
|
char localChapterStr[128];
|
||||||
|
snprintf(localChapterStr, sizeof(localChapterStr), " %s", localChapter.c_str());
|
||||||
|
renderer.drawText(UI_10_FONT_ID, 20, 295, localChapterStr);
|
||||||
|
char localPageStr[64];
|
||||||
|
snprintf(localPageStr, sizeof(localPageStr), " Page %d/%d, %.2f%% overall", currentPage + 1, totalPagesInSpine,
|
||||||
|
localProgress.percentage * 100);
|
||||||
|
renderer.drawText(UI_10_FONT_ID, 20, 320, localPageStr);
|
||||||
|
|
||||||
|
// Options
|
||||||
|
const int optionY = 350;
|
||||||
|
const int optionHeight = 30;
|
||||||
|
|
||||||
|
// Apply option
|
||||||
|
if (selectedOption == 0) {
|
||||||
|
renderer.fillRect(0, optionY - 2, pageWidth - 1, optionHeight);
|
||||||
|
}
|
||||||
|
renderer.drawText(UI_10_FONT_ID, 20, optionY, "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, 20, 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, 20, 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, 280, "No remote progress found", true, EpdFontFamily::BOLD);
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, 320, "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, 300, "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, 280, "Sync failed", true, EpdFontFamily::BOLD);
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, 320, 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
98
src/activities/reader/KOReaderSyncActivity.h
Normal file
98
src/activities/reader/KOReaderSyncActivity.h
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <Epub.h>
|
||||||
|
#include <freertos/FreeRTOS.h>
|
||||||
|
#include <freertos/semphr.h>
|
||||||
|
#include <freertos/task.h>
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#include "KOReaderSyncClient.h"
|
||||||
|
#include "ProgressMapper.h"
|
||||||
|
#include "activities/ActivityWithSubactivity.h"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activity for syncing reading progress with KOReader sync server.
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. Connect to WiFi (if not connected)
|
||||||
|
* 2. Calculate document hash
|
||||||
|
* 3. Fetch remote progress
|
||||||
|
* 4. Show comparison and options (Apply/Upload/Cancel)
|
||||||
|
* 5. Apply or upload progress
|
||||||
|
*/
|
||||||
|
class KOReaderSyncActivity final : public ActivityWithSubactivity {
|
||||||
|
public:
|
||||||
|
using OnCancelCallback = std::function<void()>;
|
||||||
|
using OnSyncCompleteCallback = std::function<void(int newSpineIndex, int newPageNumber)>;
|
||||||
|
|
||||||
|
explicit KOReaderSyncActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||||
|
const std::shared_ptr<Epub>& epub, const std::string& epubPath, int currentSpineIndex,
|
||||||
|
int currentPage, int totalPagesInSpine, OnCancelCallback onCancel,
|
||||||
|
OnSyncCompleteCallback onSyncComplete)
|
||||||
|
: ActivityWithSubactivity("KOReaderSync", renderer, mappedInput),
|
||||||
|
epub(epub),
|
||||||
|
epubPath(epubPath),
|
||||||
|
currentSpineIndex(currentSpineIndex),
|
||||||
|
currentPage(currentPage),
|
||||||
|
totalPagesInSpine(totalPagesInSpine),
|
||||||
|
remoteProgress{},
|
||||||
|
remotePosition{},
|
||||||
|
localProgress{},
|
||||||
|
onCancel(std::move(onCancel)),
|
||||||
|
onSyncComplete(std::move(onSyncComplete)) {}
|
||||||
|
|
||||||
|
void onEnter() override;
|
||||||
|
void onExit() override;
|
||||||
|
void loop() override;
|
||||||
|
bool preventAutoSleep() override { return state == CONNECTING || state == SYNCING; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
enum State {
|
||||||
|
WIFI_SELECTION,
|
||||||
|
CONNECTING,
|
||||||
|
SYNCING,
|
||||||
|
SHOWING_RESULT,
|
||||||
|
UPLOADING,
|
||||||
|
UPLOAD_COMPLETE,
|
||||||
|
NO_REMOTE_PROGRESS,
|
||||||
|
SYNC_FAILED,
|
||||||
|
NO_CREDENTIALS
|
||||||
|
};
|
||||||
|
|
||||||
|
std::shared_ptr<Epub> epub;
|
||||||
|
std::string epubPath;
|
||||||
|
int currentSpineIndex;
|
||||||
|
int currentPage;
|
||||||
|
int totalPagesInSpine;
|
||||||
|
|
||||||
|
TaskHandle_t displayTaskHandle = nullptr;
|
||||||
|
SemaphoreHandle_t renderingMutex = nullptr;
|
||||||
|
bool updateRequired = false;
|
||||||
|
|
||||||
|
State state = WIFI_SELECTION;
|
||||||
|
std::string statusMessage;
|
||||||
|
std::string documentHash;
|
||||||
|
|
||||||
|
// Remote progress data
|
||||||
|
bool hasRemoteProgress = false;
|
||||||
|
KOReaderProgress remoteProgress;
|
||||||
|
CrossPointPosition remotePosition;
|
||||||
|
|
||||||
|
// Local progress as KOReader format (for display)
|
||||||
|
KOReaderPosition localProgress;
|
||||||
|
|
||||||
|
// Selection in result screen (0=Apply, 1=Upload, 2=Cancel)
|
||||||
|
int selectedOption = 0;
|
||||||
|
|
||||||
|
OnCancelCallback onCancel;
|
||||||
|
OnSyncCompleteCallback onSyncComplete;
|
||||||
|
|
||||||
|
void onWifiSelectionComplete(bool success);
|
||||||
|
void performSync();
|
||||||
|
void performUpload();
|
||||||
|
|
||||||
|
static void taskTrampoline(void* param);
|
||||||
|
[[noreturn]] void displayTaskLoop();
|
||||||
|
void render();
|
||||||
|
};
|
||||||
167
src/activities/settings/KOReaderAuthActivity.cpp
Normal file
167
src/activities/settings/KOReaderAuthActivity.cpp
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
#include "KOReaderAuthActivity.h"
|
||||||
|
|
||||||
|
#include <GfxRenderer.h>
|
||||||
|
#include <WiFi.h>
|
||||||
|
|
||||||
|
#include "KOReaderCredentialStore.h"
|
||||||
|
#include "KOReaderSyncClient.h"
|
||||||
|
#include "MappedInputManager.h"
|
||||||
|
#include "activities/network/WifiSelectionActivity.h"
|
||||||
|
#include "fontIds.h"
|
||||||
|
|
||||||
|
void KOReaderAuthActivity::taskTrampoline(void* param) {
|
||||||
|
auto* self = static_cast<KOReaderAuthActivity*>(param);
|
||||||
|
self->displayTaskLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
void KOReaderAuthActivity::onWifiSelectionComplete(const bool success) {
|
||||||
|
exitActivity();
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
|
state = FAILED;
|
||||||
|
errorMessage = "WiFi connection failed";
|
||||||
|
xSemaphoreGive(renderingMutex);
|
||||||
|
updateRequired = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
|
state = AUTHENTICATING;
|
||||||
|
statusMessage = "Authenticating...";
|
||||||
|
xSemaphoreGive(renderingMutex);
|
||||||
|
updateRequired = true;
|
||||||
|
|
||||||
|
performAuthentication();
|
||||||
|
}
|
||||||
|
|
||||||
|
void KOReaderAuthActivity::performAuthentication() {
|
||||||
|
const auto result = KOReaderSyncClient::authenticate();
|
||||||
|
|
||||||
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
|
if (result == KOReaderSyncClient::OK) {
|
||||||
|
state = SUCCESS;
|
||||||
|
statusMessage = "Successfully authenticated!";
|
||||||
|
} else {
|
||||||
|
state = FAILED;
|
||||||
|
errorMessage = KOReaderSyncClient::errorString(result);
|
||||||
|
}
|
||||||
|
xSemaphoreGive(renderingMutex);
|
||||||
|
updateRequired = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void KOReaderAuthActivity::onEnter() {
|
||||||
|
ActivityWithSubactivity::onEnter();
|
||||||
|
|
||||||
|
renderingMutex = xSemaphoreCreateMutex();
|
||||||
|
|
||||||
|
xTaskCreate(&KOReaderAuthActivity::taskTrampoline, "KOAuthTask",
|
||||||
|
4096, // Stack size
|
||||||
|
this, // Parameters
|
||||||
|
1, // Priority
|
||||||
|
&displayTaskHandle // Task handle
|
||||||
|
);
|
||||||
|
|
||||||
|
// Turn on WiFi
|
||||||
|
WiFi.mode(WIFI_STA);
|
||||||
|
|
||||||
|
// Check if already connected
|
||||||
|
if (WiFi.status() == WL_CONNECTED) {
|
||||||
|
state = AUTHENTICATING;
|
||||||
|
statusMessage = "Authenticating...";
|
||||||
|
updateRequired = true;
|
||||||
|
|
||||||
|
// Perform authentication in a separate task
|
||||||
|
xTaskCreate(
|
||||||
|
[](void* param) {
|
||||||
|
auto* self = static_cast<KOReaderAuthActivity*>(param);
|
||||||
|
self->performAuthentication();
|
||||||
|
vTaskDelete(nullptr);
|
||||||
|
},
|
||||||
|
"AuthTask", 4096, this, 1, nullptr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Launch WiFi selection
|
||||||
|
enterNewActivity(new WifiSelectionActivity(renderer, mappedInput,
|
||||||
|
[this](const bool connected) { onWifiSelectionComplete(connected); }));
|
||||||
|
}
|
||||||
|
|
||||||
|
void KOReaderAuthActivity::onExit() {
|
||||||
|
ActivityWithSubactivity::onExit();
|
||||||
|
|
||||||
|
// Turn off wifi
|
||||||
|
WiFi.disconnect(false);
|
||||||
|
delay(100);
|
||||||
|
WiFi.mode(WIFI_OFF);
|
||||||
|
delay(100);
|
||||||
|
|
||||||
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
|
if (displayTaskHandle) {
|
||||||
|
vTaskDelete(displayTaskHandle);
|
||||||
|
displayTaskHandle = nullptr;
|
||||||
|
}
|
||||||
|
vSemaphoreDelete(renderingMutex);
|
||||||
|
renderingMutex = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void KOReaderAuthActivity::displayTaskLoop() {
|
||||||
|
while (true) {
|
||||||
|
if (updateRequired && !subActivity) {
|
||||||
|
updateRequired = false;
|
||||||
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
|
render();
|
||||||
|
xSemaphoreGive(renderingMutex);
|
||||||
|
}
|
||||||
|
vTaskDelay(10 / portTICK_PERIOD_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void KOReaderAuthActivity::render() {
|
||||||
|
if (subActivity) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer.clearScreen();
|
||||||
|
renderer.drawCenteredText(UI_12_FONT_ID, 15, "KOReader Auth", true, EpdFontFamily::BOLD);
|
||||||
|
|
||||||
|
if (state == AUTHENTICATING) {
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, 300, statusMessage.c_str(), true, EpdFontFamily::BOLD);
|
||||||
|
renderer.displayBuffer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state == SUCCESS) {
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, 280, "Success!", true, EpdFontFamily::BOLD);
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, 320, "KOReader sync is ready to use");
|
||||||
|
|
||||||
|
const auto labels = mappedInput.mapLabels("Done", "", "", "");
|
||||||
|
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||||
|
renderer.displayBuffer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state == FAILED) {
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, 280, "Authentication Failed", true, EpdFontFamily::BOLD);
|
||||||
|
renderer.drawCenteredText(UI_10_FONT_ID, 320, errorMessage.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 KOReaderAuthActivity::loop() {
|
||||||
|
if (subActivity) {
|
||||||
|
subActivity->loop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state == SUCCESS || state == FAILED) {
|
||||||
|
if (mappedInput.wasPressed(MappedInputManager::Button::Back) ||
|
||||||
|
mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||||
|
onComplete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/activities/settings/KOReaderAuthActivity.h
Normal file
44
src/activities/settings/KOReaderAuthActivity.h
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <freertos/FreeRTOS.h>
|
||||||
|
#include <freertos/semphr.h>
|
||||||
|
#include <freertos/task.h>
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
|
|
||||||
|
#include "activities/ActivityWithSubactivity.h"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activity for testing KOReader credentials.
|
||||||
|
* Connects to WiFi and authenticates with the KOReader sync server.
|
||||||
|
*/
|
||||||
|
class KOReaderAuthActivity final : public ActivityWithSubactivity {
|
||||||
|
public:
|
||||||
|
explicit KOReaderAuthActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||||
|
const std::function<void()>& onComplete)
|
||||||
|
: ActivityWithSubactivity("KOReaderAuth", renderer, mappedInput), onComplete(onComplete) {}
|
||||||
|
|
||||||
|
void onEnter() override;
|
||||||
|
void onExit() override;
|
||||||
|
void loop() override;
|
||||||
|
bool preventAutoSleep() override { return state == CONNECTING || state == AUTHENTICATING; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
enum State { WIFI_SELECTION, CONNECTING, AUTHENTICATING, SUCCESS, FAILED };
|
||||||
|
|
||||||
|
TaskHandle_t displayTaskHandle = nullptr;
|
||||||
|
SemaphoreHandle_t renderingMutex = nullptr;
|
||||||
|
bool updateRequired = false;
|
||||||
|
|
||||||
|
State state = WIFI_SELECTION;
|
||||||
|
std::string statusMessage;
|
||||||
|
std::string errorMessage;
|
||||||
|
|
||||||
|
const std::function<void()> onComplete;
|
||||||
|
|
||||||
|
void onWifiSelectionComplete(bool success);
|
||||||
|
void performAuthentication();
|
||||||
|
|
||||||
|
static void taskTrampoline(void* param);
|
||||||
|
[[noreturn]] void displayTaskLoop();
|
||||||
|
void render();
|
||||||
|
};
|
||||||
213
src/activities/settings/KOReaderSettingsActivity.cpp
Normal file
213
src/activities/settings/KOReaderSettingsActivity.cpp
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
#include "KOReaderSettingsActivity.h"
|
||||||
|
|
||||||
|
#include <GfxRenderer.h>
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
#include "KOReaderAuthActivity.h"
|
||||||
|
#include "KOReaderCredentialStore.h"
|
||||||
|
#include "MappedInputManager.h"
|
||||||
|
#include "activities/util/KeyboardEntryActivity.h"
|
||||||
|
#include "fontIds.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
constexpr int MENU_ITEMS = 5;
|
||||||
|
const char* menuNames[MENU_ITEMS] = {"Username", "Password", "Sync Server URL", "Document Matching", "Authenticate"};
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void KOReaderSettingsActivity::taskTrampoline(void* param) {
|
||||||
|
auto* self = static_cast<KOReaderSettingsActivity*>(param);
|
||||||
|
self->displayTaskLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
void KOReaderSettingsActivity::onEnter() {
|
||||||
|
ActivityWithSubactivity::onEnter();
|
||||||
|
|
||||||
|
renderingMutex = xSemaphoreCreateMutex();
|
||||||
|
selectedIndex = 0;
|
||||||
|
updateRequired = true;
|
||||||
|
|
||||||
|
xTaskCreate(&KOReaderSettingsActivity::taskTrampoline, "KOReaderSettingsTask",
|
||||||
|
4096, // Stack size
|
||||||
|
this, // Parameters
|
||||||
|
1, // Priority
|
||||||
|
&displayTaskHandle // Task handle
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void KOReaderSettingsActivity::onExit() {
|
||||||
|
ActivityWithSubactivity::onExit();
|
||||||
|
|
||||||
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
|
if (displayTaskHandle) {
|
||||||
|
vTaskDelete(displayTaskHandle);
|
||||||
|
displayTaskHandle = nullptr;
|
||||||
|
}
|
||||||
|
vSemaphoreDelete(renderingMutex);
|
||||||
|
renderingMutex = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void KOReaderSettingsActivity::loop() {
|
||||||
|
if (subActivity) {
|
||||||
|
subActivity->loop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
||||||
|
onBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
||||||
|
handleSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mappedInput.wasPressed(MappedInputManager::Button::Up) ||
|
||||||
|
mappedInput.wasPressed(MappedInputManager::Button::Left)) {
|
||||||
|
selectedIndex = (selectedIndex + MENU_ITEMS - 1) % MENU_ITEMS;
|
||||||
|
updateRequired = true;
|
||||||
|
} else if (mappedInput.wasPressed(MappedInputManager::Button::Down) ||
|
||||||
|
mappedInput.wasPressed(MappedInputManager::Button::Right)) {
|
||||||
|
selectedIndex = (selectedIndex + 1) % MENU_ITEMS;
|
||||||
|
updateRequired = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void KOReaderSettingsActivity::handleSelection() {
|
||||||
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
|
|
||||||
|
if (selectedIndex == 0) {
|
||||||
|
// Username
|
||||||
|
exitActivity();
|
||||||
|
enterNewActivity(new KeyboardEntryActivity(
|
||||||
|
renderer, mappedInput, "KOReader Username", KOREADER_STORE.getUsername(), 10,
|
||||||
|
64, // maxLength
|
||||||
|
false, // not password
|
||||||
|
[this](const std::string& username) {
|
||||||
|
KOREADER_STORE.setCredentials(username, KOREADER_STORE.getPassword());
|
||||||
|
KOREADER_STORE.saveToFile();
|
||||||
|
exitActivity();
|
||||||
|
updateRequired = true;
|
||||||
|
},
|
||||||
|
[this]() {
|
||||||
|
exitActivity();
|
||||||
|
updateRequired = true;
|
||||||
|
}));
|
||||||
|
} else if (selectedIndex == 1) {
|
||||||
|
// Password
|
||||||
|
exitActivity();
|
||||||
|
enterNewActivity(new KeyboardEntryActivity(
|
||||||
|
renderer, mappedInput, "KOReader Password", KOREADER_STORE.getPassword(), 10,
|
||||||
|
64, // maxLength
|
||||||
|
false, // show characters
|
||||||
|
[this](const std::string& password) {
|
||||||
|
KOREADER_STORE.setCredentials(KOREADER_STORE.getUsername(), password);
|
||||||
|
KOREADER_STORE.saveToFile();
|
||||||
|
exitActivity();
|
||||||
|
updateRequired = true;
|
||||||
|
},
|
||||||
|
[this]() {
|
||||||
|
exitActivity();
|
||||||
|
updateRequired = true;
|
||||||
|
}));
|
||||||
|
} else if (selectedIndex == 2) {
|
||||||
|
// Sync Server URL - prefill with https:// if empty to save typing
|
||||||
|
const std::string currentUrl = KOREADER_STORE.getServerUrl();
|
||||||
|
const std::string prefillUrl = currentUrl.empty() ? "https://" : currentUrl;
|
||||||
|
exitActivity();
|
||||||
|
enterNewActivity(new KeyboardEntryActivity(
|
||||||
|
renderer, mappedInput, "Sync Server URL", prefillUrl, 10,
|
||||||
|
128, // maxLength - URLs can be long
|
||||||
|
false, // not password
|
||||||
|
[this](const std::string& url) {
|
||||||
|
// Clear if user just left the prefilled https://
|
||||||
|
const std::string urlToSave = (url == "https://" || url == "http://") ? "" : url;
|
||||||
|
KOREADER_STORE.setServerUrl(urlToSave);
|
||||||
|
KOREADER_STORE.saveToFile();
|
||||||
|
exitActivity();
|
||||||
|
updateRequired = true;
|
||||||
|
},
|
||||||
|
[this]() {
|
||||||
|
exitActivity();
|
||||||
|
updateRequired = true;
|
||||||
|
}));
|
||||||
|
} else if (selectedIndex == 3) {
|
||||||
|
// Document Matching - toggle between Filename and Binary
|
||||||
|
const auto current = KOREADER_STORE.getMatchMethod();
|
||||||
|
const auto newMethod =
|
||||||
|
(current == DocumentMatchMethod::FILENAME) ? DocumentMatchMethod::BINARY : DocumentMatchMethod::FILENAME;
|
||||||
|
KOREADER_STORE.setMatchMethod(newMethod);
|
||||||
|
KOREADER_STORE.saveToFile();
|
||||||
|
updateRequired = true;
|
||||||
|
} else if (selectedIndex == 4) {
|
||||||
|
// Authenticate
|
||||||
|
if (!KOREADER_STORE.hasCredentials()) {
|
||||||
|
// Can't authenticate without credentials - just show message briefly
|
||||||
|
xSemaphoreGive(renderingMutex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
exitActivity();
|
||||||
|
enterNewActivity(new KOReaderAuthActivity(renderer, mappedInput, [this] {
|
||||||
|
exitActivity();
|
||||||
|
updateRequired = true;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
xSemaphoreGive(renderingMutex);
|
||||||
|
}
|
||||||
|
|
||||||
|
void KOReaderSettingsActivity::displayTaskLoop() {
|
||||||
|
while (true) {
|
||||||
|
if (updateRequired && !subActivity) {
|
||||||
|
updateRequired = false;
|
||||||
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
|
render();
|
||||||
|
xSemaphoreGive(renderingMutex);
|
||||||
|
}
|
||||||
|
vTaskDelay(10 / portTICK_PERIOD_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void KOReaderSettingsActivity::render() {
|
||||||
|
renderer.clearScreen();
|
||||||
|
|
||||||
|
const auto pageWidth = renderer.getScreenWidth();
|
||||||
|
|
||||||
|
// Draw header
|
||||||
|
renderer.drawCenteredText(UI_12_FONT_ID, 15, "KOReader Sync", true, EpdFontFamily::BOLD);
|
||||||
|
|
||||||
|
// Draw selection highlight
|
||||||
|
renderer.fillRect(0, 60 + selectedIndex * 30 - 2, pageWidth - 1, 30);
|
||||||
|
|
||||||
|
// Draw menu items
|
||||||
|
for (int i = 0; i < MENU_ITEMS; i++) {
|
||||||
|
const int settingY = 60 + i * 30;
|
||||||
|
const bool isSelected = (i == selectedIndex);
|
||||||
|
|
||||||
|
renderer.drawText(UI_10_FONT_ID, 20, settingY, menuNames[i], !isSelected);
|
||||||
|
|
||||||
|
// Draw status for each item
|
||||||
|
const char* status = "";
|
||||||
|
if (i == 0) {
|
||||||
|
status = KOREADER_STORE.getUsername().empty() ? "[Not Set]" : "[Set]";
|
||||||
|
} else if (i == 1) {
|
||||||
|
status = KOREADER_STORE.getPassword().empty() ? "[Not Set]" : "[Set]";
|
||||||
|
} else if (i == 2) {
|
||||||
|
status = KOREADER_STORE.getServerUrl().empty() ? "[Not Set]" : "[Set]";
|
||||||
|
} else if (i == 3) {
|
||||||
|
status = KOREADER_STORE.getMatchMethod() == DocumentMatchMethod::FILENAME ? "[Filename]" : "[Binary]";
|
||||||
|
} else if (i == 4) {
|
||||||
|
status = KOREADER_STORE.hasCredentials() ? "" : "[Set credentials first]";
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto width = renderer.getTextWidth(UI_10_FONT_ID, status);
|
||||||
|
renderer.drawText(UI_10_FONT_ID, pageWidth - 20 - width, settingY, status, !isSelected);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw button hints
|
||||||
|
const auto labels = mappedInput.mapLabels("« Back", "Select", "", "");
|
||||||
|
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||||
|
|
||||||
|
renderer.displayBuffer();
|
||||||
|
}
|
||||||
36
src/activities/settings/KOReaderSettingsActivity.h
Normal file
36
src/activities/settings/KOReaderSettingsActivity.h
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <freertos/FreeRTOS.h>
|
||||||
|
#include <freertos/semphr.h>
|
||||||
|
#include <freertos/task.h>
|
||||||
|
|
||||||
|
#include <functional>
|
||||||
|
|
||||||
|
#include "activities/ActivityWithSubactivity.h"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submenu for KOReader Sync settings.
|
||||||
|
* Shows username, password, and authenticate options.
|
||||||
|
*/
|
||||||
|
class KOReaderSettingsActivity final : public ActivityWithSubactivity {
|
||||||
|
public:
|
||||||
|
explicit KOReaderSettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||||
|
const std::function<void()>& onBack)
|
||||||
|
: ActivityWithSubactivity("KOReaderSettings", renderer, mappedInput), onBack(onBack) {}
|
||||||
|
|
||||||
|
void onEnter() override;
|
||||||
|
void onExit() override;
|
||||||
|
void loop() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
TaskHandle_t displayTaskHandle = nullptr;
|
||||||
|
SemaphoreHandle_t renderingMutex = nullptr;
|
||||||
|
bool updateRequired = false;
|
||||||
|
|
||||||
|
int selectedIndex = 0;
|
||||||
|
const std::function<void()> onBack;
|
||||||
|
|
||||||
|
static void taskTrampoline(void* param);
|
||||||
|
[[noreturn]] void displayTaskLoop();
|
||||||
|
void render();
|
||||||
|
void handleSelection();
|
||||||
|
};
|
||||||
@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
#include "CalibreSettingsActivity.h"
|
#include "CalibreSettingsActivity.h"
|
||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
|
#include "KOReaderSettingsActivity.h"
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
#include "OtaUpdateActivity.h"
|
#include "OtaUpdateActivity.h"
|
||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
@ -42,6 +43,7 @@ const SettingInfo settingsList[settingsCount] = {
|
|||||||
{"1 min", "5 min", "10 min", "15 min", "30 min"}),
|
{"1 min", "5 min", "10 min", "15 min", "30 min"}),
|
||||||
SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency,
|
SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency,
|
||||||
{"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}),
|
{"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}),
|
||||||
|
SettingInfo::Action("KOReader Sync"),
|
||||||
SettingInfo::Action("Calibre Settings"),
|
SettingInfo::Action("Calibre Settings"),
|
||||||
SettingInfo::Action("Check for updates")};
|
SettingInfo::Action("Check for updates")};
|
||||||
} // namespace
|
} // namespace
|
||||||
@ -116,7 +118,6 @@ void SettingsActivity::loop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void SettingsActivity::toggleCurrentSetting() {
|
void SettingsActivity::toggleCurrentSetting() {
|
||||||
// Validate index
|
|
||||||
if (selectedSettingIndex < 0 || selectedSettingIndex >= settingsCount) {
|
if (selectedSettingIndex < 0 || selectedSettingIndex >= settingsCount) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -140,7 +141,15 @@ void SettingsActivity::toggleCurrentSetting() {
|
|||||||
SETTINGS.*(setting.valuePtr) = currentValue + setting.valueRange.step;
|
SETTINGS.*(setting.valuePtr) = currentValue + setting.valueRange.step;
|
||||||
}
|
}
|
||||||
} else if (setting.type == SettingType::ACTION) {
|
} else if (setting.type == SettingType::ACTION) {
|
||||||
if (strcmp(setting.name, "Calibre Settings") == 0) {
|
if (strcmp(setting.name, "KOReader Sync") == 0) {
|
||||||
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
|
exitActivity();
|
||||||
|
enterNewActivity(new KOReaderSettingsActivity(renderer, mappedInput, [this] {
|
||||||
|
exitActivity();
|
||||||
|
updateRequired = true;
|
||||||
|
}));
|
||||||
|
xSemaphoreGive(renderingMutex);
|
||||||
|
} else if (strcmp(setting.name, "Calibre Settings") == 0) {
|
||||||
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
xSemaphoreTake(renderingMutex, portMAX_DELAY);
|
||||||
exitActivity();
|
exitActivity();
|
||||||
enterNewActivity(new CalibreSettingsActivity(renderer, mappedInput, [this] {
|
enterNewActivity(new CalibreSettingsActivity(renderer, mappedInput, [this] {
|
||||||
@ -187,18 +196,19 @@ void SettingsActivity::render() const {
|
|||||||
// Draw header
|
// Draw header
|
||||||
renderer.drawCenteredText(UI_12_FONT_ID, 15, "Settings", true, EpdFontFamily::BOLD);
|
renderer.drawCenteredText(UI_12_FONT_ID, 15, "Settings", true, EpdFontFamily::BOLD);
|
||||||
|
|
||||||
// Draw selection
|
// Draw selection highlight
|
||||||
renderer.fillRect(0, 60 + selectedSettingIndex * 30 - 2, pageWidth - 1, 30);
|
renderer.fillRect(0, 60 + selectedSettingIndex * 30 - 2, pageWidth - 1, 30);
|
||||||
|
|
||||||
// Draw all settings
|
// Draw all settings
|
||||||
for (int i = 0; i < settingsCount; i++) {
|
for (int i = 0; i < settingsCount; i++) {
|
||||||
const int settingY = 60 + i * 30; // 30 pixels between settings
|
const int settingY = 60 + i * 30; // 30 pixels between settings
|
||||||
|
const bool isSelected = (i == selectedSettingIndex);
|
||||||
|
|
||||||
// Draw setting name
|
// Draw setting name
|
||||||
renderer.drawText(UI_10_FONT_ID, 20, settingY, settingsList[i].name, i != selectedSettingIndex);
|
renderer.drawText(UI_10_FONT_ID, 20, settingY, settingsList[i].name, !isSelected);
|
||||||
|
|
||||||
// Draw value based on setting type
|
// Draw value based on setting type
|
||||||
std::string valueText = "";
|
std::string valueText;
|
||||||
if (settingsList[i].type == SettingType::TOGGLE && settingsList[i].valuePtr != nullptr) {
|
if (settingsList[i].type == SettingType::TOGGLE && settingsList[i].valuePtr != nullptr) {
|
||||||
const bool value = SETTINGS.*(settingsList[i].valuePtr);
|
const bool value = SETTINGS.*(settingsList[i].valuePtr);
|
||||||
valueText = value ? "ON" : "OFF";
|
valueText = value ? "ON" : "OFF";
|
||||||
@ -208,8 +218,10 @@ void SettingsActivity::render() const {
|
|||||||
} else if (settingsList[i].type == SettingType::VALUE && settingsList[i].valuePtr != nullptr) {
|
} else if (settingsList[i].type == SettingType::VALUE && settingsList[i].valuePtr != nullptr) {
|
||||||
valueText = std::to_string(SETTINGS.*(settingsList[i].valuePtr));
|
valueText = std::to_string(SETTINGS.*(settingsList[i].valuePtr));
|
||||||
}
|
}
|
||||||
const auto width = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str());
|
if (!valueText.empty()) {
|
||||||
renderer.drawText(UI_10_FONT_ID, pageWidth - 20 - width, settingY, valueText.c_str(), i != selectedSettingIndex);
|
const auto width = renderer.getTextWidth(UI_10_FONT_ID, valueText.c_str());
|
||||||
|
renderer.drawText(UI_10_FONT_ID, pageWidth - 20 - width, settingY, valueText.c_str(), !isSelected);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw version text above button hints
|
// Draw version text above button hints
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
#include "Battery.h"
|
#include "Battery.h"
|
||||||
#include "CrossPointSettings.h"
|
#include "CrossPointSettings.h"
|
||||||
#include "CrossPointState.h"
|
#include "CrossPointState.h"
|
||||||
|
#include "KOReaderCredentialStore.h"
|
||||||
#include "MappedInputManager.h"
|
#include "MappedInputManager.h"
|
||||||
#include "activities/boot_sleep/BootActivity.h"
|
#include "activities/boot_sleep/BootActivity.h"
|
||||||
#include "activities/boot_sleep/SleepActivity.h"
|
#include "activities/boot_sleep/SleepActivity.h"
|
||||||
@ -289,6 +290,7 @@ void setup() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SETTINGS.loadFromFile();
|
SETTINGS.loadFromFile();
|
||||||
|
KOREADER_STORE.loadFromFile();
|
||||||
|
|
||||||
// verify power button press duration after we've read settings.
|
// verify power button press duration after we've read settings.
|
||||||
verifyWakeupLongPress();
|
verifyWakeupLongPress();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user