Compare commits

...

4 Commits

Author SHA1 Message Date
Irene Ying
a7992227e4
Merge 2a65d10925 into f69cddf2cc 2026-01-19 21:55:48 +10:00
Justin Mitchell
f69cddf2cc
Adds KOReader Sync support (#232)
Some checks are pending
CI / build (push) Waiting to run
## 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>
2026-01-19 11:55:35 +00:00
Nathan James
7185e5d287
feat: Change keyboard "caps" to "shift" & Wrap Keyboard (#377)
## Summary

* This PR solves issue
https://github.com/crosspoint-reader/crosspoint-reader/issues/357 in the
first commit
* I then added an additional commit which means when you reach the end
of the keyboard, if you go 'beyond', you wrap back to the other side.
* This replaces existing behaviour, so if you would rather this be
removed, let me know and I'll just do the `caps` -> `shift` change

## Additional Context

### Screenshots for the new shift display

I thought it might not fit and need column size changes, but ended up
fitting fine, see screenshots showing this below:

<img width="573" height="366" alt="image"
src="https://github.com/user-attachments/assets/b8f6a4ec-94f5-4f5e-b9a6-06cc5f250ddb"
/>

<img width="570" height="308" alt="image"
src="https://github.com/user-attachments/assets/7d775518-4784-4120-a20a-a9dc67af8565"
/>


### Gif showing the wrap-around of the text



![IMG_7648](https://github.com/user-attachments/assets/7eec9066-e1cc-49a1-8b6b-a61556038d31)

---

### AI Usage

Did you use AI tools to help write this code? **PARTIALLY** - used to
double check the text wrapping had no edge-cases. (It did also suggest
rewriting the function, but I decided that was too big of a change for a
working part of the codebase, for now!)
2026-01-19 22:50:34 +11:00
Eunchurn Park
12940cc546
fix: XTC 1-bit thumb BMP polarity inversion (#373)
## Summary

* **What is the goal of this PR?** (e.g., Implements the new feature for
file uploading.)
* **What changes are included?**

- Fix inverted colors in Continue Reading cover image for 1-bit XTC
files

## Additional Context

* Add any other information that might be helpful for the reviewer
(e.g., performance implications, potential risks,
  specific areas to focus on).

- Fix `grayValue = pixelBit ? 0 : 255` → `grayValue = pixelBit ? 255 :
0` in `lib/Xtc/Xtc.cpp`
- The thumb BMP generation had inverted polarity compared to cover BMP
generation
- bit=0 should be black, bit=1 should be white (matching the BMP palette
order)
- Update misleading comment about XTC polarity

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**PARTIALLY**_
2026-01-19 22:41:48 +11:00
23 changed files with 2003 additions and 56 deletions

View File

@ -609,14 +609,15 @@ int Epub::getSpineIndexForTextReference() const {
return 0;
}
// Calculate progress in book
uint8_t Epub::calculateProgress(const int currentSpineIndex, const float currentSpineRead) const {
// Calculate progress in book (returns 0.0-1.0)
float Epub::calculateProgress(const int currentSpineIndex, const float currentSpineRead) const {
const size_t bookSize = getBookSize();
if (bookSize == 0) {
return 0;
return 0.0f;
}
const size_t prevChapterSize = (currentSpineIndex >= 1) ? getCumulativeSpineItemSize(currentSpineIndex - 1) : 0;
const size_t curChapterSize = getCumulativeSpineItemSize(currentSpineIndex) - prevChapterSize;
const size_t sectionProgSize = currentSpineRead * curChapterSize;
return round(static_cast<float>(prevChapterSize + sectionProgSize) / bookSize * 100.0);
const float sectionProgSize = currentSpineRead * static_cast<float>(curChapterSize);
const float totalProgress = static_cast<float>(prevChapterSize) + sectionProgSize;
return totalProgress / static_cast<float>(bookSize);
}

View File

@ -62,5 +62,5 @@ class Epub {
int getSpineIndexForTextReference() const;
size_t getBookSize() const;
uint8_t calculateProgress(int currentSpineIndex, float currentSpineRead) const;
float calculateProgress(int currentSpineIndex, float currentSpineRead) const;
};

View 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");
}

View 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()

View 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;
}

View 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);
};

View 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";
}
}

View 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);
};

View 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;
}
}

View 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);
};

View File

@ -203,7 +203,7 @@ bool Xtc::generateCoverBmp() const {
coverBmp.write(reinterpret_cast<const uint8_t*>(&colorsImportant), 4);
// Color palette (2 colors for 1-bit)
// XTC uses inverted polarity: 0 = black, 1 = white
// XTC 1-bit polarity: 0 = black, 1 = white (standard BMP palette order)
// Color 0: Black (text/foreground in XTC)
uint8_t black[4] = {0x00, 0x00, 0x00, 0x00};
coverBmp.write(black, 4);
@ -506,8 +506,8 @@ bool Xtc::generateThumbBmp() const {
// Bounds check for buffer access
if (byteIdx < bitmapSize) {
const uint8_t pixelBit = (pageBuffer[byteIdx] >> bitIdx) & 1;
// XTC polarity: 1=black, 0=white
grayValue = pixelBit ? 0 : 255;
// XTC 1-bit polarity: 0=black, 1=white (same as BMP palette)
grayValue = pixelBit ? 255 : 0;
}
}

View File

@ -118,9 +118,11 @@ void EpubReaderActivity::loop() {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
// Don't start activity transition while rendering
xSemaphoreTake(renderingMutex, portMAX_DELAY);
const int currentPage = section ? section->currentPage : 0;
const int totalPages = section ? section->pageCount : 0;
exitActivity();
enterNewActivity(new EpubReaderChapterSelectionActivity(
this->renderer, this->mappedInput, epub, currentSpineIndex,
this->renderer, this->mappedInput, epub, epub->getPath(), currentSpineIndex, currentPage, totalPages,
[this] {
exitActivity();
updateRequired = true;
@ -133,6 +135,16 @@ void EpubReaderActivity::loop() {
}
exitActivity();
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);
}
@ -430,11 +442,13 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in
if (showProgress) {
// Calculate progress in book
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
const std::string progress = std::to_string(section->currentPage + 1) + "/" + std::to_string(section->pageCount) +
" " + std::to_string(bookProgress) + "%";
char progressStr[32];
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());
renderer.drawText(SMALL_FONT_ID, renderer.getScreenWidth() - orientedMarginRight - progressTextWidth, textY,
progress.c_str());

View File

@ -2,6 +2,8 @@
#include <GfxRenderer.h>
#include "KOReaderCredentialStore.h"
#include "KOReaderSyncActivity.h"
#include "MappedInputManager.h"
#include "fontIds.h"
@ -10,6 +12,26 @@ namespace {
constexpr int SKIP_PAGE_MS = 700;
} // 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 {
// Layout constants used in renderScreen
constexpr int startY = 60;
@ -34,17 +56,21 @@ void EpubReaderChapterSelectionActivity::taskTrampoline(void* param) {
}
void EpubReaderChapterSelectionActivity::onEnter() {
Activity::onEnter();
ActivityWithSubactivity::onEnter();
if (!epub) {
return;
}
renderingMutex = xSemaphoreCreateMutex();
// Account for sync option offset when finding current TOC index
const int syncOffset = hasSyncOption() ? 1 : 0;
selectorIndex = epub->getTocIndexForSpineIndex(currentSpineIndex);
if (selectorIndex == -1) {
selectorIndex = 0;
}
selectorIndex += syncOffset; // Offset for top sync option
// Trigger first update
updateRequired = true;
@ -57,7 +83,7 @@ void EpubReaderChapterSelectionActivity::onEnter() {
}
void EpubReaderChapterSelectionActivity::onExit() {
Activity::onExit();
ActivityWithSubactivity::onExit();
// Wait until not rendering to delete task to avoid killing mid-instruction to EPD
xSemaphoreTake(renderingMutex, portMAX_DELAY);
@ -69,7 +95,30 @@ void EpubReaderChapterSelectionActivity::onExit() {
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() {
if (subActivity) {
subActivity->loop();
return;
}
const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::Up) ||
mappedInput.wasReleased(MappedInputManager::Button::Left);
const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::Down) ||
@ -77,9 +126,18 @@ void EpubReaderChapterSelectionActivity::loop() {
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
const int pageItems = getPageItems();
const int totalItems = getTotalItems();
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) {
onGoBack();
} else {
@ -89,17 +147,16 @@ void EpubReaderChapterSelectionActivity::loop() {
onGoBack();
} else if (prevReleased) {
if (skipPage) {
selectorIndex =
((selectorIndex / pageItems - 1) * pageItems + epub->getTocItemsCount()) % epub->getTocItemsCount();
selectorIndex = ((selectorIndex / pageItems - 1) * pageItems + totalItems) % totalItems;
} else {
selectorIndex = (selectorIndex + epub->getTocItemsCount() - 1) % epub->getTocItemsCount();
selectorIndex = (selectorIndex + totalItems - 1) % totalItems;
}
updateRequired = true;
} else if (nextReleased) {
if (skipPage) {
selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % epub->getTocItemsCount();
selectorIndex = ((selectorIndex / pageItems + 1) * pageItems) % totalItems;
} else {
selectorIndex = (selectorIndex + 1) % epub->getTocItemsCount();
selectorIndex = (selectorIndex + 1) % totalItems;
}
updateRequired = true;
}
@ -107,7 +164,7 @@ void EpubReaderChapterSelectionActivity::loop() {
void EpubReaderChapterSelectionActivity::displayTaskLoop() {
while (true) {
if (updateRequired) {
if (updateRequired && !subActivity) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
renderScreen();
@ -122,6 +179,7 @@ void EpubReaderChapterSelectionActivity::renderScreen() {
const auto pageWidth = renderer.getScreenWidth();
const int pageItems = getPageItems();
const int totalItems = getTotalItems();
const std::string title =
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;
renderer.fillRect(0, 60 + (selectorIndex % pageItems) * 30 - 2, pageWidth - 1, 30);
for (int tocIndex = pageStartIndex; tocIndex < epub->getTocItemsCount() && tocIndex < pageStartIndex + pageItems;
tocIndex++) {
auto item = epub->getTocItem(tocIndex);
renderer.drawText(UI_10_FONT_ID, 20 + (item.level - 1) * 15, 60 + (tocIndex % pageItems) * 30, item.title.c_str(),
tocIndex != selectorIndex);
for (int itemIndex = pageStartIndex; itemIndex < totalItems && itemIndex < pageStartIndex + pageItems; itemIndex++) {
const int displayY = 60 + (itemIndex % pageItems) * 30;
const bool isSelected = (itemIndex == 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");

View File

@ -6,36 +6,59 @@
#include <memory>
#include "../Activity.h"
#include "../ActivityWithSubactivity.h"
class EpubReaderChapterSelectionActivity final : public Activity {
class EpubReaderChapterSelectionActivity final : public ActivityWithSubactivity {
std::shared_ptr<Epub> epub;
std::string epubPath;
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
int currentSpineIndex = 0;
int currentPage = 0;
int totalPagesInSpine = 0;
int selectorIndex = 0;
bool updateRequired = false;
const std::function<void()> onGoBack;
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.
// This adapts automatically when switching between portrait and landscape.
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);
[[noreturn]] void displayTaskLoop();
void renderScreen();
void launchSyncActivity();
public:
explicit EpubReaderChapterSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::shared_ptr<Epub>& epub, const int currentSpineIndex,
const std::function<void()>& onGoBack,
const std::function<void(int newSpineIndex)>& onSelectSpineIndex)
: Activity("EpubReaderChapterSelection", renderer, mappedInput),
const std::shared_ptr<Epub>& epub, const std::string& epubPath,
const int currentSpineIndex, const int currentPage,
const int totalPagesInSpine, const std::function<void()>& onGoBack,
const std::function<void(int newSpineIndex)>& onSelectSpineIndex,
const std::function<void(int newSpineIndex, int newPage)>& onSyncPosition)
: ActivityWithSubactivity("EpubReaderChapterSelection", renderer, mappedInput),
epub(epub),
epubPath(epubPath),
currentSpineIndex(currentSpineIndex),
currentPage(currentPage),
totalPagesInSpine(totalPagesInSpine),
onGoBack(onGoBack),
onSelectSpineIndex(onSelectSpineIndex) {}
onSelectSpineIndex(onSelectSpineIndex),
onSyncPosition(onSyncPosition) {}
void onEnter() override;
void onExit() override;
void loop() override;

View 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;
}
}

View 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();
};

View 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();
}
}
}

View 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();
};

View 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();
}

View 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();
};

View File

@ -7,13 +7,14 @@
#include "CalibreSettingsActivity.h"
#include "CrossPointSettings.h"
#include "KOReaderSettingsActivity.h"
#include "MappedInputManager.h"
#include "OtaUpdateActivity.h"
#include "fontIds.h"
// Define the static settings list
namespace {
constexpr int settingsCount = 20;
constexpr int settingsCount = 21;
const SettingInfo settingsList[settingsCount] = {
// Should match with SLEEP_SCREEN_MODE
SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen,
@ -42,6 +43,7 @@ const SettingInfo settingsList[settingsCount] = {
{"1 min", "5 min", "10 min", "15 min", "30 min"}),
SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency,
{"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}),
SettingInfo::Action("KOReader Sync"),
SettingInfo::Action("Calibre Settings"),
SettingInfo::Action("Check for updates")};
} // namespace
@ -116,7 +118,6 @@ void SettingsActivity::loop() {
}
void SettingsActivity::toggleCurrentSetting() {
// Validate index
if (selectedSettingIndex < 0 || selectedSettingIndex >= settingsCount) {
return;
}
@ -140,7 +141,15 @@ void SettingsActivity::toggleCurrentSetting() {
SETTINGS.*(setting.valuePtr) = currentValue + setting.valueRange.step;
}
} 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);
exitActivity();
enterNewActivity(new CalibreSettingsActivity(renderer, mappedInput, [this] {
@ -187,18 +196,19 @@ void SettingsActivity::render() const {
// Draw header
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);
// Draw all settings
for (int i = 0; i < settingsCount; i++) {
const int settingY = 60 + i * 30; // 30 pixels between settings
const bool isSelected = (i == selectedSettingIndex);
// 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
std::string valueText = "";
std::string valueText;
if (settingsList[i].type == SettingType::TOGGLE && settingsList[i].valuePtr != nullptr) {
const bool value = SETTINGS.*(settingsList[i].valuePtr);
valueText = value ? "ON" : "OFF";
@ -208,8 +218,10 @@ void SettingsActivity::render() const {
} else if (settingsList[i].type == SettingType::VALUE && settingsList[i].valuePtr != nullptr) {
valueText = std::to_string(SETTINGS.*(settingsList[i].valuePtr));
}
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(), i != selectedSettingIndex);
if (!valueText.empty()) {
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

View File

@ -73,7 +73,7 @@ int KeyboardEntryActivity::getRowLength(const int row) const {
case 3:
return 10; // zxcvbnm,./
case 4:
return 10; // caps (2 wide), space (5 wide), backspace (2 wide), OK
return 10; // shift (2 wide), space (5 wide), backspace (2 wide), OK
default:
return 0;
}
@ -145,6 +145,11 @@ void KeyboardEntryActivity::loop() {
// Clamp column to valid range for new row
const int maxCol = getRowLength(selectedRow) - 1;
if (selectedCol > maxCol) selectedCol = maxCol;
} else {
// Wrap to bottom row
selectedRow = NUM_ROWS - 1;
const int maxCol = getRowLength(selectedRow) - 1;
if (selectedCol > maxCol) selectedCol = maxCol;
}
updateRequired = true;
}
@ -154,16 +159,24 @@ void KeyboardEntryActivity::loop() {
selectedRow++;
const int maxCol = getRowLength(selectedRow) - 1;
if (selectedCol > maxCol) selectedCol = maxCol;
} else {
// Wrap to top row
selectedRow = 0;
const int maxCol = getRowLength(selectedRow) - 1;
if (selectedCol > maxCol) selectedCol = maxCol;
}
updateRequired = true;
}
if (mappedInput.wasPressed(MappedInputManager::Button::Left)) {
const int maxCol = getRowLength(selectedRow) - 1;
// Special bottom row case
if (selectedRow == SPECIAL_ROW) {
// Bottom row has special key widths
if (selectedCol >= SHIFT_COL && selectedCol < SPACE_COL) {
// In shift key, do nothing
// In shift key, wrap to end of row
selectedCol = maxCol;
} else if (selectedCol >= SPACE_COL && selectedCol < BACKSPACE_COL) {
// In space bar, move to shift
selectedCol = SHIFT_COL;
@ -180,10 +193,9 @@ void KeyboardEntryActivity::loop() {
if (selectedCol > 0) {
selectedCol--;
} else if (selectedRow > 0) {
// Wrap to previous row
selectedRow--;
selectedCol = getRowLength(selectedRow) - 1;
} else {
// Wrap to end of current row
selectedCol = maxCol;
}
updateRequired = true;
}
@ -204,7 +216,8 @@ void KeyboardEntryActivity::loop() {
// In backspace, move to done
selectedCol = DONE_COL;
} else if (selectedCol >= DONE_COL) {
// At done button, do nothing
// At done button, wrap to beginning of row
selectedCol = SHIFT_COL;
}
updateRequired = true;
return;
@ -212,9 +225,8 @@ void KeyboardEntryActivity::loop() {
if (selectedCol < maxCol) {
selectedCol++;
} else if (selectedRow < NUM_ROWS - 1) {
// Wrap to next row
selectedRow++;
} else {
// Wrap to beginning of current row
selectedCol = 0;
}
updateRequired = true;
@ -288,14 +300,14 @@ void KeyboardEntryActivity::render() const {
// Handle bottom row (row 4) specially with proper multi-column keys
if (row == 4) {
// Bottom row layout: CAPS (2 cols) | SPACE (5 cols) | <- (2 cols) | OK (2 cols)
// Bottom row layout: SHIFT (2 cols) | SPACE (5 cols) | <- (2 cols) | OK (2 cols)
// Total: 11 visual columns, but we use logical positions for selection
int currentX = startX;
// CAPS key (logical col 0, spans 2 key widths)
const bool capsSelected = (selectedRow == 4 && selectedCol >= SHIFT_COL && selectedCol < SPACE_COL);
renderItemWithSelector(currentX + 2, rowY, shiftActive ? "CAPS" : "caps", capsSelected);
// SHIFT key (logical col 0, spans 2 key widths)
const bool shiftSelected = (selectedRow == 4 && selectedCol >= SHIFT_COL && selectedCol < SPACE_COL);
renderItemWithSelector(currentX + 2, rowY, shiftActive ? "SHIFT" : "shift", shiftSelected);
currentX += 2 * (keyWidth + keySpacing);
// Space bar (logical cols 2-6, spans 5 key widths)

View File

@ -12,6 +12,7 @@
#include "Battery.h"
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "KOReaderCredentialStore.h"
#include "MappedInputManager.h"
#include "activities/boot_sleep/BootActivity.h"
#include "activities/boot_sleep/SleepActivity.h"
@ -289,6 +290,7 @@ void setup() {
}
SETTINGS.loadFromFile();
KOREADER_STORE.loadFromFile();
// verify power button press duration after we've read settings.
verifyWakeupLongPress();