Compare commits

...

4 Commits

Author SHA1 Message Date
Justin Mitchell
ba6257a8c9 Apply clang-format fixes 2026-01-04 23:52:35 -05:00
Justin Mitchell
a382341ac5 fixes pushes progress 2026-01-04 23:52:25 -05:00
Justin Mitchell
77bb97339d Add a setting for document matching method 2026-01-04 23:48:42 -05:00
Justin Mitchell
ae34fc76e1 support for 3rd party server urls 2026-01-04 23:02:48 -05:00
10 changed files with 208 additions and 41 deletions

View File

@ -15,6 +15,9 @@ 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};
@ -48,6 +51,12 @@ bool KOReaderCredentialStore::saveToFile() const {
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;
@ -70,11 +79,35 @@ bool KOReaderCredentialStore::loadFromFile() {
}
// Read username
serialization::readString(file, username);
if (file.available()) {
serialization::readString(file, username);
} else {
username.clear();
}
// Read and deobfuscate password
serialization::readString(file, password);
obfuscate(password); // XOR is symmetric, so same function deobfuscates
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());
@ -109,3 +142,27 @@ void KOReaderCredentialStore::clearCredentials() {
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 https:// if no protocol specified
if (serverUrl.find("://") == std::string::npos) {
return "https://" + 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

@ -1,6 +1,13 @@
#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
@ -11,6 +18,8 @@ class KOReaderCredentialStore {
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;
@ -43,6 +52,17 @@ class KOReaderCredentialStore {
// 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 https:// normalization, 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

View File

@ -4,6 +4,33 @@
#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

View File

@ -17,13 +17,22 @@
class KOReaderDocumentId {
public:
/**
* Calculate the KOReader document hash for a file.
* 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;

View File

@ -10,9 +10,6 @@
#include "KOReaderCredentialStore.h"
namespace {
// Base URL for KOReader sync server
constexpr char BASE_URL[] = "https://sync.koreader.rocks:443";
// Device identifier for CrossPoint reader
constexpr char DEVICE_NAME[] = "CrossPoint";
constexpr char DEVICE_ID[] = "crosspoint-reader";
@ -34,7 +31,7 @@ KOReaderSyncClient::Error KOReaderSyncClient::authenticate() {
client->setInsecure();
HTTPClient http;
std::string url = std::string(BASE_URL) + "/users/auth";
std::string url = KOREADER_STORE.getBaseUrl() + "/users/auth";
Serial.printf("[%lu] [KOSync] Authenticating: %s\n", millis(), url.c_str());
http.begin(*client, url.c_str());
@ -66,7 +63,7 @@ KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& doc
client->setInsecure();
HTTPClient http;
std::string url = std::string(BASE_URL) + "/syncs/progress/" + documentHash;
std::string url = KOREADER_STORE.getBaseUrl() + "/syncs/progress/" + documentHash;
Serial.printf("[%lu] [KOSync] Getting progress: %s\n", millis(), url.c_str());
http.begin(*client, url.c_str());
@ -121,7 +118,7 @@ KOReaderSyncClient::Error KOReaderSyncClient::updateProgress(const KOReaderProgr
client->setInsecure();
HTTPClient http;
std::string url = std::string(BASE_URL) + "/syncs/progress";
std::string url = KOREADER_STORE.getBaseUrl() + "/syncs/progress";
Serial.printf("[%lu] [KOSync] Updating progress: %s\n", millis(), url.c_str());
http.begin(*client, url.c_str());

View File

@ -94,12 +94,9 @@ CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epu
std::string ProgressMapper::generateXPath(int spineIndex, int pageNumber, int totalPages) {
// KOReader uses 1-based DocFragment indices
// Estimate paragraph number based on page position
// Assume ~3 paragraphs per page on average for e-reader screens
constexpr int paragraphsPerPage = 3;
const int estimatedParagraph = (pageNumber * paragraphsPerPage) + 1; // 1-based
return "/body/DocFragment[" + std::to_string(spineIndex + 1) + "]/body/p[" + std::to_string(estimatedParagraph) + "]";
// 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) {

View File

@ -10,15 +10,26 @@
namespace {
// Time threshold for treating a long press as a page-up/page-down
constexpr int SKIP_PAGE_MS = 700;
// Sync option is shown at index 0 if credentials are configured
constexpr int SYNC_ITEM_INDEX = 0;
} // namespace
bool EpubReaderChapterSelectionActivity::hasSyncOption() const { return KOREADER_STORE.hasCredentials(); }
int EpubReaderChapterSelectionActivity::getTotalItems() const {
// Add 1 for sync option if credentials are configured
const int syncOffset = KOREADER_STORE.hasCredentials() ? 1 : 0;
return epub->getTocItemsCount() + syncOffset;
// 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 {
@ -52,12 +63,12 @@ void EpubReaderChapterSelectionActivity::onEnter() {
renderingMutex = xSemaphoreCreateMutex();
// Account for sync option offset when finding current TOC index
const int syncOffset = KOREADER_STORE.hasCredentials() ? 1 : 0;
const int syncOffset = hasSyncOption() ? 1 : 0;
selectorIndex = epub->getTocIndexForSpineIndex(currentSpineIndex);
if (selectorIndex == -1) {
selectorIndex = 0;
}
selectorIndex += syncOffset; // Offset for sync option
selectorIndex += syncOffset; // Offset for top sync option
// Trigger first update
updateRequired = true;
@ -114,17 +125,16 @@ void EpubReaderChapterSelectionActivity::loop() {
const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS;
const int pageItems = getPageItems();
const int totalItems = getTotalItems();
const int syncOffset = KOREADER_STORE.hasCredentials() ? 1 : 0;
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
// Check if sync option is selected
if (syncOffset > 0 && selectorIndex == SYNC_ITEM_INDEX) {
// Check if sync option is selected (first or last item)
if (isSyncItem(selectorIndex)) {
launchSyncActivity();
return;
}
// Get TOC index (account for sync offset)
const int tocIndex = selectorIndex - syncOffset;
// Get TOC index (account for top sync offset)
const int tocIndex = tocIndexFromItemIndex(selectorIndex);
const auto newSpineIndex = epub->getSpineIndexForTocIndex(tocIndex);
if (newSpineIndex == -1) {
onGoBack();
@ -168,7 +178,6 @@ void EpubReaderChapterSelectionActivity::renderScreen() {
const auto pageWidth = renderer.getScreenWidth();
const int pageItems = getPageItems();
const int totalItems = getTotalItems();
const int syncOffset = KOREADER_STORE.hasCredentials() ? 1 : 0;
const std::string title =
renderer.truncatedText(UI_12_FONT_ID, epub->getTitle().c_str(), pageWidth - 40, EpdFontFamily::BOLD);
@ -181,12 +190,12 @@ void EpubReaderChapterSelectionActivity::renderScreen() {
const int displayY = 60 + (itemIndex % pageItems) * 30;
const bool isSelected = (itemIndex == selectorIndex);
if (syncOffset > 0 && itemIndex == SYNC_ITEM_INDEX) {
// Draw sync option
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 sync offset)
const int tocIndex = itemIndex - syncOffset;
// 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);
}

View File

@ -26,9 +26,18 @@ class EpubReaderChapterSelectionActivity final : public ActivityWithSubactivity
// This adapts automatically when switching between portrait and landscape.
int getPageItems() const;
// Total items including the sync option
// 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();

View File

@ -12,6 +12,11 @@
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");
@ -67,8 +72,12 @@ void KOReaderSyncActivity::onWifiSelectionComplete(const bool success) {
}
void KOReaderSyncActivity::performSync() {
// Calculate document hash
documentHash = KOReaderDocumentId::calculate(epubPath);
// 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;
@ -413,7 +422,11 @@ void KOReaderSyncActivity::loop() {
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
// Calculate hash if not done yet
if (documentHash.empty()) {
documentHash = KOReaderDocumentId::calculate(epubPath);
if (KOREADER_STORE.getMatchMethod() == DocumentMatchMethod::FILENAME) {
documentHash = KOReaderDocumentId::calculateFromFilename(epubPath);
} else {
documentHash = KOReaderDocumentId::calculate(epubPath);
}
}
performUpload();
}

View File

@ -11,8 +11,8 @@
#include "fontIds.h"
namespace {
constexpr int MENU_ITEMS = 3;
const char* menuNames[MENU_ITEMS] = {"Username", "Password", "Authenticate"};
constexpr int MENU_ITEMS = 5;
const char* menuNames[MENU_ITEMS] = {"Username", "Password", "Sync Server URL", "Document Matching", "Authenticate"};
} // namespace
void KOReaderSettingsActivity::taskTrampoline(void* param) {
@ -112,6 +112,31 @@ void KOReaderSettingsActivity::handleSelection() {
updateRequired = true;
}));
} else if (selectedIndex == 2) {
// Sync Server URL
exitActivity();
enterNewActivity(new KeyboardEntryActivity(
renderer, mappedInput, "Sync Server URL", KOREADER_STORE.getServerUrl(), 10,
128, // maxLength - URLs can be long
false, // not password
[this](const std::string& url) {
KOREADER_STORE.setServerUrl(url);
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
@ -158,13 +183,17 @@ void KOReaderSettingsActivity::render() {
renderer.drawText(UI_10_FONT_ID, 20, settingY, menuNames[i], !isSelected);
// Draw status for username/password
// 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]";
}