feat: Add kosync user registration and switch to crosspoint-sync server (#2587)
Implements createUser() endpoint to allow account creation via the KOSync protocol. Changes default sync server from sync.koreader.rocks to sync.crosspointreader.com with migration logic to preserve existing users' server settings. Extends sync protocol to include position data (spine index, page numbers, xpath) that crosspoint-sync supports while remaining compatible with standard kosync servers.
This commit is contained in:
+41
-26
@@ -28,8 +28,10 @@ Welcome to the **CrossPoint** firmware. This guide outlines the hardware control
|
|||||||
- [3.6.5 OPDS Servers (Multiple Libraries)](#365-opds-servers-multiple-libraries)
|
- [3.6.5 OPDS Servers (Multiple Libraries)](#365-opds-servers-multiple-libraries)
|
||||||
- [3.6.6 Web Settings (Wi-Fi + OPDS)](#366-web-settings-wi-fi--opds)
|
- [3.6.6 Web Settings (Wi-Fi + OPDS)](#366-web-settings-wi-fi--opds)
|
||||||
- [3.6.7 KOReader Sync Quick Setup](#367-koreader-sync-quick-setup)
|
- [3.6.7 KOReader Sync Quick Setup](#367-koreader-sync-quick-setup)
|
||||||
- [Option A: Free Public Server (`sync.koreader.rocks`)](#option-a-free-public-server-synckoreaderrocks)
|
- [Option A: CrossPoint Sync Server (`sync.crosspointreader.com`, default)](#option-a-crosspoint-sync-server-synccrosspointreadercom-default)
|
||||||
- [Option B: Self-Hosted Server (Docker Compose)](#option-b-self-hosted-server-docker-compose)
|
- [Option B: Legacy Public KOReader Server (`sync.koreader.rocks`)](#option-b-legacy-public-koreader-server-synckoreaderrocks)
|
||||||
|
- [Option C: Self-Hosted Server (Docker Compose)](#option-c-self-hosted-server-docker-compose)
|
||||||
|
- [Syncing While Reading](#syncing-while-reading)
|
||||||
- [3.7 Sleep Screen](#37-sleep-screen)
|
- [3.7 Sleep Screen](#37-sleep-screen)
|
||||||
- [Cover settings](#cover-settings)
|
- [Cover settings](#cover-settings)
|
||||||
- [Custom images](#custom-images)
|
- [Custom images](#custom-images)
|
||||||
@@ -360,9 +362,37 @@ Behavior notes:
|
|||||||
CrossPoint can sync reading progress with KOReader-compatible sync servers.
|
CrossPoint can sync reading progress with KOReader-compatible sync servers.
|
||||||
It also interoperates with KOReader apps/devices when they use the same server and credentials.
|
It also interoperates with KOReader apps/devices when they use the same server and credentials.
|
||||||
|
|
||||||
##### Option A: Free Public Server (`sync.koreader.rocks`)
|
##### Option A: CrossPoint Sync Server (`sync.crosspointreader.com`, default)
|
||||||
|
|
||||||
1. Register a user once (only if needed):
|
When **Sync Server URL** is left empty, CrossPoint uses the free CrossPoint sync server at `https://sync.crosspointreader.com`. It speaks the standard KOReader sync protocol (so KOReader apps can use it too) and additionally stores an exact spine/page position for lossless CrossPoint-to-CrossPoint sync.
|
||||||
|
|
||||||
|
1. On each CrossPoint device:
|
||||||
|
|
||||||
|
- Go to **Settings -> System -> KOReader Sync**.
|
||||||
|
|
||||||
|
- Set **Username** and **Password** (enter the plain password; CrossPoint computes MD5 internally, and use the same values on all devices).
|
||||||
|
|
||||||
|
- Leave **Sync Server URL** empty (or set it to `https://sync.crosspointreader.com`).
|
||||||
|
|
||||||
|
- On the first device, run **Sign Up** once to create the account directly from the device. On every other device, just run **Authenticate**.
|
||||||
|
|
||||||
|
Accounts are per server. Existing `sync.koreader.rocks` credentials do not exist on the CrossPoint server; either sign up again with the same username/password or use Option B to keep using the legacy server.
|
||||||
|
|
||||||
|
##### Option B: Legacy Public KOReader Server (`sync.koreader.rocks`)
|
||||||
|
|
||||||
|
Use this if you already sync KOReader devices against the official public server.
|
||||||
|
|
||||||
|
1. On each CrossPoint device:
|
||||||
|
|
||||||
|
- Go to **Settings -> System -> KOReader Sync**.
|
||||||
|
|
||||||
|
- Set **Sync Server URL** to `https://sync.koreader.rocks` (required; an empty URL now points at the CrossPoint server instead).
|
||||||
|
|
||||||
|
- Set **Username** and **Password** to your existing KOReader Sync credentials.
|
||||||
|
|
||||||
|
- Run **Authenticate**.
|
||||||
|
|
||||||
|
2. If you do not have an account yet, run **Sign Up** on the device, or register once with curl:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
USERNAME="user"
|
USERNAME="user"
|
||||||
@@ -375,26 +405,9 @@ curl -i "https://sync.koreader.rocks/users/create" \
|
|||||||
--data "{\"username\":\"$USERNAME\",\"password\":\"$PASSWORD_MD5\"}"
|
--data "{\"username\":\"$USERNAME\",\"password\":\"$PASSWORD_MD5\"}"
|
||||||
```
|
```
|
||||||
|
|
||||||
Already have KOReader Sync credentials? Skip registration; basic sync only requires using the same existing username/password on all devices.
|
|
||||||
|
|
||||||
When this returns `HTTP 402` with `{"code":2002,"message":"Username is already registered."}`, pick a different username or use that existing account.
|
When this returns `HTTP 402` with `{"code":2002,"message":"Username is already registered."}`, pick a different username or use that existing account.
|
||||||
|
|
||||||
2. On each CrossPoint device:
|
##### Option C: Self-Hosted Server (Docker Compose)
|
||||||
|
|
||||||
- Go to **Settings -> System -> KOReader Sync**.
|
|
||||||
|
|
||||||
- Set **Username** and **Password** (enter the plain password; CrossPoint computes MD5 internally, and use the same values on all devices).
|
|
||||||
|
|
||||||
- Set **Sync Server URL** to `https://sync.koreader.rocks`, or leave it empty (both use the same default KOReader sync server).
|
|
||||||
|
|
||||||
- Run **Authenticate**.
|
|
||||||
|
|
||||||
3. While reading, press **Confirm** to open the reader menu, then select **Sync Progress**. Alternatively, set **Settings -> Controls -> Long-press Menu** to **KOSync** and hold Confirm to launch sync directly.
|
|
||||||
|
|
||||||
- With **Sync Behavior** set to **Ask every time**, choose **Apply Remote** to jump to remote progress or **Upload Local** to push current progress.
|
|
||||||
- With **Sync Behavior** set to **Smart sync**, CrossPoint auto-resolves simple cases: upload when no remote progress exists, confirm and leave both unchanged when local and remote progress are already synchronized, upload when local progress is further ahead, or apply remote when remote progress is further ahead.
|
|
||||||
|
|
||||||
##### Option B: Self-Hosted Server (Docker Compose)
|
|
||||||
|
|
||||||
1. Start a sync server:
|
1. Start a sync server:
|
||||||
|
|
||||||
@@ -467,10 +480,12 @@ If this returns `HTTP 402` with `{"code":2002,"message":"Username is already reg
|
|||||||
|
|
||||||
If you use the HTTPS listener, use `https://<server-ip>:7200` (`curl -k` only for self-signed certificate testing).
|
If you use the HTTPS listener, use `https://<server-ip>:7200` (`curl -k` only for self-signed certificate testing).
|
||||||
|
|
||||||
5. While reading, press **Confirm** to open the reader menu, then select **Sync Progress**. Alternatively, set **Settings -> Controls -> Long-press Menu** to **KOSync** and hold Confirm to launch sync directly.
|
##### Syncing While Reading
|
||||||
|
|
||||||
- With **Sync Behavior** set to **Ask every time**, choose **Apply Remote** to jump to remote progress or **Upload Local** to push current progress.
|
Once any of the options above is set up, press **Confirm** while reading to open the reader menu, then select **Sync Progress**. Alternatively, set **Settings -> Controls -> Long-press Menu** to **KOSync** and hold Confirm to launch sync directly.
|
||||||
- With **Sync Behavior** set to **Smart sync**, CrossPoint auto-resolves simple cases: upload when no remote progress exists, confirm and leave both unchanged when local and remote progress are already synchronized, upload when local progress is further ahead, or apply remote when remote progress is further ahead.
|
|
||||||
|
- With **Sync Behavior** set to **Ask every time**, choose **Apply Remote** to jump to remote progress or **Upload Local** to push current progress.
|
||||||
|
- With **Sync Behavior** set to **Smart sync**, CrossPoint auto-resolves simple cases: upload when no remote progress exists, confirm and leave both unchanged when local and remote progress are already synchronized, upload when local progress is further ahead, or apply remote when remote progress is further ahead.
|
||||||
|
|
||||||
### 3.7 Sleep Screen
|
### 3.7 Sleep Screen
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,11 @@ STR_SYNC_BEHAVIOR: "Sync Behavior"
|
|||||||
STR_ASK_EVERY_TIME: "Ask every time"
|
STR_ASK_EVERY_TIME: "Ask every time"
|
||||||
STR_SMART_SYNC: "Smart sync"
|
STR_SMART_SYNC: "Smart sync"
|
||||||
STR_AUTHENTICATE: "Authenticate"
|
STR_AUTHENTICATE: "Authenticate"
|
||||||
|
STR_SIGN_UP: "Sign Up"
|
||||||
|
STR_CREATING_ACCOUNT: "Creating account..."
|
||||||
|
STR_ACCOUNT_CREATED: "Account created"
|
||||||
|
STR_SIGNUP_FAILED: "Sign up failed"
|
||||||
|
STR_USERNAME_TAKEN: "Username is already registered"
|
||||||
STR_KOREADER_USERNAME: "KOReader Username"
|
STR_KOREADER_USERNAME: "KOReader Username"
|
||||||
STR_KOREADER_PASSWORD: "KOReader Password"
|
STR_KOREADER_PASSWORD: "KOReader Password"
|
||||||
STR_FILENAME: "Filename"
|
STR_FILENAME: "Filename"
|
||||||
|
|||||||
@@ -5,11 +5,21 @@
|
|||||||
#include <ObfuscationUtils.h>
|
#include <ObfuscationUtils.h>
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
// Default sync server URL
|
// Default sync server URL. crosspoint-sync speaks the full KOSync protocol, so
|
||||||
constexpr char DEFAULT_SERVER_URL[] = "https://sync.koreader.rocks:443";
|
// pointing at any other kosync server (e.g. https://sync.koreader.rocks:443)
|
||||||
|
// still works via the custom server URL setting.
|
||||||
|
constexpr char DEFAULT_SERVER_URL[] = "https://sync.crosspointreader.com";
|
||||||
|
|
||||||
|
// Default before config version 2. Configs saved without a version stamp and an
|
||||||
|
// empty serverUrl were implicitly syncing here — they get pinned on upgrade.
|
||||||
|
constexpr char LEGACY_DEFAULT_SERVER_URL[] = "https://sync.koreader.rocks:443";
|
||||||
|
|
||||||
|
// Bumped when a change to defaults would alter behavior for existing configs.
|
||||||
|
constexpr uint8_t CONFIG_VERSION = 2;
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
void KOReaderCredentialStore::toJson(JsonDocument& doc) const {
|
void KOReaderCredentialStore::toJson(JsonDocument& doc) const {
|
||||||
|
doc["cfgVersion"] = CONFIG_VERSION;
|
||||||
doc["username"] = getUsername();
|
doc["username"] = getUsername();
|
||||||
doc["password_obf"] = obfuscation::obfuscateToBase64(getPassword());
|
doc["password_obf"] = obfuscation::obfuscateToBase64(getPassword());
|
||||||
doc["serverUrl"] = getServerUrl();
|
doc["serverUrl"] = getServerUrl();
|
||||||
@@ -27,6 +37,19 @@ bool KOReaderCredentialStore::fromJson(JsonVariantConst doc) {
|
|||||||
setCredentials(user, pass);
|
setCredentials(user, pass);
|
||||||
setServerUrl(doc["serverUrl"] | "");
|
setServerUrl(doc["serverUrl"] | "");
|
||||||
|
|
||||||
|
// The default server changed in config v2 (sync.koreader.rocks -> crosspoint-sync).
|
||||||
|
// A pre-v2 config with credentials and no explicit URL was actively syncing
|
||||||
|
// against the old default — pin that URL so the upgrade doesn't switch servers
|
||||||
|
// out from under the user. Fresh setups get the new default.
|
||||||
|
const uint8_t cfgVersion = doc["cfgVersion"] | (uint8_t)1;
|
||||||
|
if (cfgVersion < CONFIG_VERSION) {
|
||||||
|
if (getServerUrl().empty() && hasCredentials()) {
|
||||||
|
LOG_DBG("KRS", "Pre-v2 config used the old default server; pinning %s", LEGACY_DEFAULT_SERVER_URL);
|
||||||
|
setServerUrl(LEGACY_DEFAULT_SERVER_URL);
|
||||||
|
}
|
||||||
|
needsResave = true; // stamp cfgVersion so this migration runs once
|
||||||
|
}
|
||||||
|
|
||||||
uint8_t method = doc["matchMethod"] | (uint8_t)0;
|
uint8_t method = doc["matchMethod"] | (uint8_t)0;
|
||||||
if (method <= static_cast<uint8_t>(DocumentMatchMethod::BINARY)) {
|
if (method <= static_cast<uint8_t>(DocumentMatchMethod::BINARY)) {
|
||||||
setMatchMethod(static_cast<DocumentMatchMethod>(method));
|
setMatchMethod(static_cast<DocumentMatchMethod>(method));
|
||||||
|
|||||||
@@ -78,6 +78,43 @@ KOReaderSyncClient::Error KOReaderSyncClient::authenticate() {
|
|||||||
return SERVER_ERROR;
|
return SERVER_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
KOReaderSyncClient::Error KOReaderSyncClient::createUser() {
|
||||||
|
lastHttpCode = 0;
|
||||||
|
if (!KOREADER_STORE.hasCredentials()) {
|
||||||
|
LOG_DBG("KOSync", "No credentials configured");
|
||||||
|
return NO_CREDENTIALS;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string url = KOREADER_STORE.getBaseUrl() + "/users/create";
|
||||||
|
LOG_DBG("KOSync", "Creating account: %s (heap: %u)", url.c_str(), (unsigned)ESP.getFreeHeap());
|
||||||
|
if (insufficientHeap()) return LOW_MEMORY;
|
||||||
|
|
||||||
|
JsonDocument doc;
|
||||||
|
doc["username"] = KOREADER_STORE.getUsername();
|
||||||
|
doc["password"] = KOREADER_STORE.getMd5Password();
|
||||||
|
std::string body;
|
||||||
|
serializeJson(doc, body);
|
||||||
|
|
||||||
|
freeink::SecureHttpClient http;
|
||||||
|
http.setInsecure();
|
||||||
|
if (!http.begin(url)) {
|
||||||
|
LOG_ERR("KOSync", "Bad URL: %s", url.c_str());
|
||||||
|
return NETWORK_ERROR;
|
||||||
|
}
|
||||||
|
http.addHeader("Accept", "application/vnd.koreader.v1+json");
|
||||||
|
http.addHeader("Content-Type", "application/json");
|
||||||
|
const int httpCode = http.sendRequest("POST", body);
|
||||||
|
http.end();
|
||||||
|
lastHttpCode = httpCode;
|
||||||
|
|
||||||
|
LOG_DBG("KOSync", "Create user response: %d", httpCode);
|
||||||
|
|
||||||
|
if (httpCode <= 0) return NETWORK_ERROR;
|
||||||
|
if (httpCode == 200 || httpCode == 201) return OK;
|
||||||
|
if (httpCode == 402) return USER_EXISTS;
|
||||||
|
return SERVER_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& documentHash,
|
KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& documentHash,
|
||||||
KOReaderProgress& outProgress) {
|
KOReaderProgress& outProgress) {
|
||||||
lastHttpCode = 0;
|
lastHttpCode = 0;
|
||||||
@@ -124,6 +161,24 @@ KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& doc
|
|||||||
outProgress.deviceId = doc["device_id"].as<std::string>();
|
outProgress.deviceId = doc["device_id"].as<std::string>();
|
||||||
outProgress.timestamp = doc["timestamp"].as<int64_t>();
|
outProgress.timestamp = doc["timestamp"].as<int64_t>();
|
||||||
|
|
||||||
|
// Extended crosspoint-sync field; absent on plain kosync servers.
|
||||||
|
outProgress.position.reset();
|
||||||
|
const JsonObjectConst pos = doc["position"].as<JsonObjectConst>();
|
||||||
|
if (!pos.isNull()) {
|
||||||
|
KOReaderRichPosition rich;
|
||||||
|
rich.pctQ = pos["pctQ"].as<uint32_t>();
|
||||||
|
rich.spineIndex = pos["spine"].as<uint16_t>();
|
||||||
|
rich.pageNumber = pos["page"].as<uint16_t>();
|
||||||
|
const uint16_t pages = pos["pages"].as<uint16_t>();
|
||||||
|
rich.totalPages = pages > 0 ? pages : 1;
|
||||||
|
const uint16_t para = pos["para"].as<uint16_t>();
|
||||||
|
if (para > 0) rich.paragraphIndex = para;
|
||||||
|
rich.xpath = pos["xpath"].as<const char*>() ? pos["xpath"].as<const char*>() : "";
|
||||||
|
LOG_DBG("KOSync", "Got rich position: spine=%u page=%u/%u para=%u", rich.spineIndex, rich.pageNumber,
|
||||||
|
rich.totalPages, para);
|
||||||
|
outProgress.position = std::move(rich);
|
||||||
|
}
|
||||||
|
|
||||||
LOG_DBG("KOSync", "Got progress: %.2f%% at %s", outProgress.percentage * 100, outProgress.progress.c_str());
|
LOG_DBG("KOSync", "Got progress: %.2f%% at %s", outProgress.percentage * 100, outProgress.progress.c_str());
|
||||||
return OK;
|
return OK;
|
||||||
}
|
}
|
||||||
@@ -158,6 +213,18 @@ KOReaderSyncClient::Error KOReaderSyncClient::updateProgress(const KOReaderProgr
|
|||||||
doc["percentage"] = progress.percentage;
|
doc["percentage"] = progress.percentage;
|
||||||
doc["device"] = DEVICE_NAME;
|
doc["device"] = DEVICE_NAME;
|
||||||
doc["device_id"] = DEVICE_ID;
|
doc["device_id"] = DEVICE_ID;
|
||||||
|
if (progress.position.has_value()) {
|
||||||
|
// Extended crosspoint-sync field; kosync servers ignore unknown keys.
|
||||||
|
const auto& p = *progress.position;
|
||||||
|
auto pos = doc["position"].to<JsonObject>();
|
||||||
|
pos["pctQ"] = p.pctQ;
|
||||||
|
pos["spine"] = p.spineIndex;
|
||||||
|
pos["page"] = p.pageNumber;
|
||||||
|
pos["pages"] = p.totalPages;
|
||||||
|
if (p.paragraphIndex.has_value()) pos["para"] = *p.paragraphIndex;
|
||||||
|
// Server rejects the whole position object if xpath exceeds 120 bytes.
|
||||||
|
if (!p.xpath.empty() && p.xpath.size() <= 120) pos["xpath"] = p.xpath;
|
||||||
|
}
|
||||||
|
|
||||||
std::string body;
|
std::string body;
|
||||||
serializeJson(doc, body);
|
serializeJson(doc, body);
|
||||||
|
|||||||
@@ -14,17 +14,33 @@ struct KOReaderMetadata {
|
|||||||
std::string authors; // Author(s) from EPUB metadata
|
std::string authors; // Author(s) from EPUB metadata
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rich CrossPoint position sent alongside progress uploads. Maps 1:1 onto the
|
||||||
|
* crosspoint-sync extended `position` object (see crosspoint-sync docs/API.md).
|
||||||
|
* The official KOSync server ignores unknown fields; crosspoint-sync stores it
|
||||||
|
* so CrossPoint<->CrossPoint sync is lossless instead of xpath-approximated.
|
||||||
|
*/
|
||||||
|
struct KOReaderRichPosition {
|
||||||
|
uint32_t pctQ = 0; // Percentage quantized 0..1,000,000 (authoritative)
|
||||||
|
uint16_t spineIndex = 0; // Spine (chapter) index
|
||||||
|
uint16_t pageNumber = 0; // Page within spine (layout-dependent hint)
|
||||||
|
uint16_t totalPages = 1; // Spine page count (layout-dependent hint)
|
||||||
|
std::optional<uint16_t> paragraphIndex; // Synthetic 1-based paragraph index
|
||||||
|
std::string xpath; // KOReader-style xpath (server cap: 120 bytes)
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Progress data from KOReader sync server.
|
* Progress data from KOReader sync server.
|
||||||
*/
|
*/
|
||||||
struct KOReaderProgress {
|
struct KOReaderProgress {
|
||||||
std::string document; // Document hash
|
std::string document; // Document hash
|
||||||
std::string progress; // XPath-like progress string
|
std::string progress; // XPath-like progress string
|
||||||
float percentage; // Progress percentage (0.0 to 1.0)
|
float percentage; // Progress percentage (0.0 to 1.0)
|
||||||
std::string device; // Device name
|
std::string device; // Device name
|
||||||
std::string deviceId; // Device ID
|
std::string deviceId; // Device ID
|
||||||
int64_t timestamp; // Unix timestamp of last update
|
int64_t timestamp; // Unix timestamp of last update
|
||||||
std::optional<KOReaderMetadata> metadata; // Optional document metadata
|
std::optional<KOReaderMetadata> metadata; // Optional document metadata
|
||||||
|
std::optional<KOReaderRichPosition> position; // Optional rich position (crosspoint-sync servers only)
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,7 +59,17 @@ struct KOReaderProgress {
|
|||||||
*/
|
*/
|
||||||
class KOReaderSyncClient {
|
class KOReaderSyncClient {
|
||||||
public:
|
public:
|
||||||
enum Error { OK = 0, NO_CREDENTIALS, NETWORK_ERROR, AUTH_FAILED, SERVER_ERROR, JSON_ERROR, NOT_FOUND, LOW_MEMORY };
|
enum Error {
|
||||||
|
OK = 0,
|
||||||
|
NO_CREDENTIALS,
|
||||||
|
NETWORK_ERROR,
|
||||||
|
AUTH_FAILED,
|
||||||
|
SERVER_ERROR,
|
||||||
|
JSON_ERROR,
|
||||||
|
NOT_FOUND,
|
||||||
|
LOW_MEMORY,
|
||||||
|
USER_EXISTS
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authenticate with the sync server (validate credentials).
|
* Authenticate with the sync server (validate credentials).
|
||||||
@@ -51,6 +77,14 @@ class KOReaderSyncClient {
|
|||||||
*/
|
*/
|
||||||
static Error authenticate();
|
static Error authenticate();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a new account on the sync server using the stored credentials
|
||||||
|
* (POST /users/create with the MD5 auth key — the server never sees the
|
||||||
|
* plain password).
|
||||||
|
* @return OK on success, USER_EXISTS if the username is taken
|
||||||
|
*/
|
||||||
|
static Error createUser();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get reading progress for a document.
|
* Get reading progress for a document.
|
||||||
* @param documentHash The document hash (from KOReaderDocumentId)
|
* @param documentHash The document hash (from KOReaderDocumentId)
|
||||||
|
|||||||
@@ -724,6 +724,59 @@ SavedProgressPosition ProgressMapper::toSavedProgress(const std::shared_ptr<Epub
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::optional<CrossPointPosition> ProgressMapper::fromRichPosition(const std::shared_ptr<Epub>& epub,
|
||||||
|
const KOReaderRichPosition& rich,
|
||||||
|
GfxRenderer& renderer) {
|
||||||
|
const int spineCount = epub->getSpineItemsCount();
|
||||||
|
if (static_cast<int>(rich.spineIndex) >= spineCount) {
|
||||||
|
LOG_DBG("PM", "Rich position spine %u out of range (%d spine items)", rich.spineIndex, spineCount);
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
CrossPointPosition result{};
|
||||||
|
result.spineIndex = rich.spineIndex;
|
||||||
|
|
||||||
|
Section tempSection(epub, result.spineIndex, renderer);
|
||||||
|
const auto cachedCount = tempSection.getCachedPageCount();
|
||||||
|
if (!cachedCount || *cachedCount <= 0) {
|
||||||
|
// No local layout for the target spine yet; the percentage/xpath mapping
|
||||||
|
// handles density estimation better than a blind copy of remote pages.
|
||||||
|
LOG_DBG("PM", "Rich position spine %u has no cached page count", rich.spineIndex);
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
result.totalPages = *cachedCount;
|
||||||
|
|
||||||
|
const int remotePages = rich.totalPages > 0 ? rich.totalPages : 1;
|
||||||
|
if (result.totalPages == remotePages) {
|
||||||
|
// Identical layout (same render settings) — the page transfers losslessly.
|
||||||
|
result.pageNumber = std::min<int>(rich.pageNumber, result.totalPages - 1);
|
||||||
|
LOG_DBG("PM", "Rich position exact: spine=%d page=%d/%d", result.spineIndex, result.pageNumber, result.totalPages);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layout differs; the paragraph LUT is the most accurate anchor we have.
|
||||||
|
if (rich.paragraphIndex.has_value()) {
|
||||||
|
const auto lutPage = tempSection.getPageForParagraphIndex(*rich.paragraphIndex);
|
||||||
|
if (lutPage.has_value()) {
|
||||||
|
result.paragraphIndex = *rich.paragraphIndex;
|
||||||
|
result.hasParagraphIndex = true;
|
||||||
|
result.pageNumber = std::min<int>(*lutPage, result.totalPages - 1);
|
||||||
|
LOG_DBG("PM", "Rich position para %u -> spine=%d page=%d/%d", *rich.paragraphIndex, result.spineIndex,
|
||||||
|
result.pageNumber, result.totalPages);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to the intra-spine page fraction.
|
||||||
|
const float intra =
|
||||||
|
(remotePages > 1) ? static_cast<float>(rich.pageNumber) / static_cast<float>(remotePages - 1) : 0.0f;
|
||||||
|
result.pageNumber = std::max(
|
||||||
|
0, std::min(static_cast<int>(intra * static_cast<float>(result.totalPages - 1) + 0.5f), result.totalPages - 1));
|
||||||
|
LOG_DBG("PM", "Rich position scaled: spine=%d remote %u/%d -> page=%d/%d", result.spineIndex, rich.pageNumber,
|
||||||
|
remotePages, result.pageNumber, result.totalPages);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epub, const SavedProgressPosition& koPos,
|
CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epub, const SavedProgressPosition& koPos,
|
||||||
GfxRenderer& renderer, int currentSpineIndex,
|
GfxRenderer& renderer, int currentSpineIndex,
|
||||||
int totalPagesInCurrentSpine, int fallbackTotalPages) {
|
int totalPagesInCurrentSpine, int fallbackTotalPages) {
|
||||||
|
|||||||
@@ -3,8 +3,11 @@
|
|||||||
#include <GfxRenderer.h>
|
#include <GfxRenderer.h>
|
||||||
|
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
|
#include "KOReaderSyncClient.h"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CrossPoint position representation.
|
* CrossPoint position representation.
|
||||||
*/
|
*/
|
||||||
@@ -65,6 +68,20 @@ class ProgressMapper {
|
|||||||
GfxRenderer& renderer, int currentSpineIndex = -1,
|
GfxRenderer& renderer, int currentSpineIndex = -1,
|
||||||
int totalPagesInCurrentSpine = 0, int fallbackTotalPages = 0);
|
int totalPagesInCurrentSpine = 0, int fallbackTotalPages = 0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a rich CrossPoint position (downloaded from a crosspoint-sync
|
||||||
|
* server) directly to a CrossPoint position, without XPath approximation.
|
||||||
|
* When the local layout matches the uploader's (same spine page count) the
|
||||||
|
* page transfers losslessly; otherwise the paragraph LUT or the intra-spine
|
||||||
|
* page fraction is used.
|
||||||
|
*
|
||||||
|
* @return The position, or std::nullopt when the rich position cannot be
|
||||||
|
* applied (spine out of range, no section cache) and the caller
|
||||||
|
* should fall back to toCrossPoint().
|
||||||
|
*/
|
||||||
|
static std::optional<CrossPointPosition> fromRichPosition(const std::shared_ptr<Epub>& epub,
|
||||||
|
const KOReaderRichPosition& rich, GfxRenderer& renderer);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
/**
|
/**
|
||||||
* Generate a fallback XPath by streaming the spine item's XHTML and resolving
|
* Generate a fallback XPath by streaming the spine item's XHTML and resolving
|
||||||
|
|||||||
@@ -232,8 +232,19 @@ void KOReaderSyncActivity::performSync() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
SavedProgressPosition koPos = {remoteProgress.progress, remoteProgress.percentage};
|
// Prefer the exact spine/page from a crosspoint-sync rich position (lossless
|
||||||
remotePosition = ProgressMapper::toCrossPoint(epub, koPos, renderer, currentSpineIndex, totalPagesInSpine);
|
// CrossPoint<->CrossPoint sync); fall back to the approximate XPath mapping
|
||||||
|
// for plain kosync servers or when the rich position cannot be applied.
|
||||||
|
std::optional<CrossPointPosition> richMapped;
|
||||||
|
if (remoteProgress.position.has_value()) {
|
||||||
|
richMapped = ProgressMapper::fromRichPosition(epub, *remoteProgress.position, renderer);
|
||||||
|
}
|
||||||
|
if (richMapped.has_value()) {
|
||||||
|
remotePosition = *richMapped;
|
||||||
|
} else {
|
||||||
|
SavedProgressPosition koPos = {remoteProgress.progress, remoteProgress.percentage};
|
||||||
|
remotePosition = ProgressMapper::toCrossPoint(epub, koPos, renderer, currentSpineIndex, totalPagesInSpine);
|
||||||
|
}
|
||||||
|
|
||||||
if (smartSyncEnabled()) {
|
if (smartSyncEnabled()) {
|
||||||
static constexpr float SAME_PROGRESS_EPSILON = 0.001f; // 0.1 percentage points
|
static constexpr float SAME_PROGRESS_EPSILON = 0.001f; // 0.1 percentage points
|
||||||
@@ -287,6 +298,22 @@ void KOReaderSyncActivity::performUpload() {
|
|||||||
progress.progress = localProgress.xpath;
|
progress.progress = localProgress.xpath;
|
||||||
progress.percentage = localProgress.percentage;
|
progress.percentage = localProgress.percentage;
|
||||||
|
|
||||||
|
// Rich CrossPoint position for crosspoint-sync servers (lossless
|
||||||
|
// CrossPoint<->CrossPoint sync); plain kosync servers ignore the extra field.
|
||||||
|
{
|
||||||
|
KOReaderRichPosition pos;
|
||||||
|
const float pct = localProgress.percentage < 0.0f ? 0.0f
|
||||||
|
: localProgress.percentage > 1.0f ? 1.0f
|
||||||
|
: localProgress.percentage;
|
||||||
|
pos.pctQ = static_cast<uint32_t>(pct * 1000000.0f + 0.5f);
|
||||||
|
pos.spineIndex = static_cast<uint16_t>(currentSpineIndex);
|
||||||
|
pos.pageNumber = static_cast<uint16_t>(currentPage);
|
||||||
|
pos.totalPages = static_cast<uint16_t>(totalPagesInSpine > 0 ? totalPagesInSpine : 1);
|
||||||
|
pos.paragraphIndex = currentParagraphIndex;
|
||||||
|
pos.xpath = localProgress.xpath;
|
||||||
|
progress.position = std::move(pos);
|
||||||
|
}
|
||||||
|
|
||||||
// Optionally include document metadata (KOReader PR #15306)
|
// Optionally include document metadata (KOReader PR #15306)
|
||||||
if (KOREADER_STORE.getSendMetadata()) {
|
if (KOREADER_STORE.getSendMetadata()) {
|
||||||
// The Epub is released before the sync network calls and is only reloaded on the
|
// The Epub is released before the sync network calls and is only reloaded on the
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ void KOReaderAuthActivity::onWifiSelectionComplete(const bool success) {
|
|||||||
{
|
{
|
||||||
RenderLock lock(*this);
|
RenderLock lock(*this);
|
||||||
state = AUTHENTICATING;
|
state = AUTHENTICATING;
|
||||||
statusMessage = tr(STR_AUTHENTICATING);
|
statusMessage = mode == Mode::SIGN_UP ? tr(STR_CREATING_ACCOUNT) : tr(STR_AUTHENTICATING);
|
||||||
}
|
}
|
||||||
requestUpdate();
|
requestUpdate();
|
||||||
|
|
||||||
@@ -34,16 +34,17 @@ void KOReaderAuthActivity::onWifiSelectionComplete(const bool success) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void KOReaderAuthActivity::performAuthentication() {
|
void KOReaderAuthActivity::performAuthentication() {
|
||||||
const auto result = KOReaderSyncClient::authenticate();
|
const auto result = mode == Mode::SIGN_UP ? KOReaderSyncClient::createUser() : KOReaderSyncClient::authenticate();
|
||||||
|
|
||||||
{
|
{
|
||||||
RenderLock lock(*this);
|
RenderLock lock(*this);
|
||||||
if (result == KOReaderSyncClient::OK) {
|
if (result == KOReaderSyncClient::OK) {
|
||||||
state = SUCCESS;
|
state = SUCCESS;
|
||||||
statusMessage = tr(STR_AUTH_SUCCESS);
|
statusMessage = mode == Mode::SIGN_UP ? tr(STR_ACCOUNT_CREATED) : tr(STR_AUTH_SUCCESS);
|
||||||
} else {
|
} else {
|
||||||
state = FAILED;
|
state = FAILED;
|
||||||
errorMessage = KOReaderSyncClient::errorString(result);
|
errorMessage =
|
||||||
|
result == KOReaderSyncClient::USER_EXISTS ? tr(STR_USERNAME_TAKEN) : KOReaderSyncClient::errorString(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
requestUpdate();
|
requestUpdate();
|
||||||
@@ -80,17 +81,21 @@ void KOReaderAuthActivity::render(RenderLock&&) {
|
|||||||
const auto pageWidth = renderer.getScreenWidth();
|
const auto pageWidth = renderer.getScreenWidth();
|
||||||
const auto pageHeight = renderer.getScreenHeight();
|
const auto pageHeight = renderer.getScreenHeight();
|
||||||
|
|
||||||
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_KOREADER_AUTH));
|
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight},
|
||||||
|
mode == Mode::SIGN_UP ? tr(STR_SIGN_UP) : tr(STR_KOREADER_AUTH));
|
||||||
const auto height = renderer.getLineHeight(UI_10_FONT_ID);
|
const auto height = renderer.getLineHeight(UI_10_FONT_ID);
|
||||||
const auto top = (pageHeight - height) / 2;
|
const auto top = (pageHeight - height) / 2;
|
||||||
|
|
||||||
if (state == AUTHENTICATING) {
|
if (state == AUTHENTICATING) {
|
||||||
renderer.drawCenteredText(UI_10_FONT_ID, top, statusMessage.c_str());
|
renderer.drawCenteredText(UI_10_FONT_ID, top, statusMessage.c_str());
|
||||||
} else if (state == SUCCESS) {
|
} else if (state == SUCCESS) {
|
||||||
renderer.drawCenteredText(UI_10_FONT_ID, top, tr(STR_AUTH_SUCCESS), true, EpdFontFamily::BOLD);
|
renderer.drawCenteredText(UI_10_FONT_ID, top,
|
||||||
|
mode == Mode::SIGN_UP ? tr(STR_ACCOUNT_CREATED) : tr(STR_AUTH_SUCCESS), true,
|
||||||
|
EpdFontFamily::BOLD);
|
||||||
renderer.drawCenteredText(UI_10_FONT_ID, top + height + 10, tr(STR_SYNC_READY));
|
renderer.drawCenteredText(UI_10_FONT_ID, top + height + 10, tr(STR_SYNC_READY));
|
||||||
} else if (state == FAILED) {
|
} else if (state == FAILED) {
|
||||||
renderer.drawCenteredText(UI_10_FONT_ID, top, tr(STR_AUTH_FAILED), true, EpdFontFamily::BOLD);
|
renderer.drawCenteredText(UI_10_FONT_ID, top, mode == Mode::SIGN_UP ? tr(STR_SIGNUP_FAILED) : tr(STR_AUTH_FAILED),
|
||||||
|
true, EpdFontFamily::BOLD);
|
||||||
renderer.drawCenteredText(UI_10_FONT_ID, top + height + 10, errorMessage.c_str());
|
renderer.drawCenteredText(UI_10_FONT_ID, top + height + 10, errorMessage.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,16 @@
|
|||||||
#include "activities/Activity.h"
|
#include "activities/Activity.h"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Activity for testing KOReader credentials.
|
* Activity for testing KOReader credentials, or — in sign-up mode — creating a
|
||||||
* Connects to WiFi and authenticates with the KOReader sync server.
|
* new account on the sync server with the entered username/password.
|
||||||
|
* Connects to WiFi, then authenticates or registers.
|
||||||
*/
|
*/
|
||||||
class KOReaderAuthActivity final : public Activity {
|
class KOReaderAuthActivity final : public Activity {
|
||||||
public:
|
public:
|
||||||
explicit KOReaderAuthActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
|
enum class Mode { AUTHENTICATE, SIGN_UP };
|
||||||
: Activity("KOReaderAuth", renderer, mappedInput) {}
|
|
||||||
|
explicit KOReaderAuthActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, Mode mode = Mode::AUTHENTICATE)
|
||||||
|
: Activity("KOReaderAuth", renderer, mappedInput), mode(mode) {}
|
||||||
|
|
||||||
void onEnter() override;
|
void onEnter() override;
|
||||||
void onExit() override;
|
void onExit() override;
|
||||||
@@ -22,6 +25,7 @@ class KOReaderAuthActivity final : public Activity {
|
|||||||
private:
|
private:
|
||||||
enum State { WIFI_SELECTION, CONNECTING, AUTHENTICATING, SUCCESS, FAILED };
|
enum State { WIFI_SELECTION, CONNECTING, AUTHENTICATING, SUCCESS, FAILED };
|
||||||
|
|
||||||
|
Mode mode = Mode::AUTHENTICATE;
|
||||||
State state = WIFI_SELECTION;
|
State state = WIFI_SELECTION;
|
||||||
std::string statusMessage;
|
std::string statusMessage;
|
||||||
std::string errorMessage;
|
std::string errorMessage;
|
||||||
|
|||||||
@@ -13,10 +13,10 @@
|
|||||||
#include "fontIds.h"
|
#include "fontIds.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
constexpr int MENU_ITEMS = 7;
|
constexpr int MENU_ITEMS = 8;
|
||||||
const StrId menuNames[MENU_ITEMS] = {StrId::STR_USERNAME, StrId::STR_PASSWORD, StrId::STR_SYNC_SERVER_URL,
|
const StrId menuNames[MENU_ITEMS] = {StrId::STR_USERNAME, StrId::STR_PASSWORD, StrId::STR_SYNC_SERVER_URL,
|
||||||
StrId::STR_DOCUMENT_MATCHING, StrId::STR_SEND_METADATA, StrId::STR_SYNC_BEHAVIOR,
|
StrId::STR_DOCUMENT_MATCHING, StrId::STR_SEND_METADATA, StrId::STR_SYNC_BEHAVIOR,
|
||||||
StrId::STR_AUTHENTICATE};
|
StrId::STR_SIGN_UP, StrId::STR_AUTHENTICATE};
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
void KOReaderSettingsActivity::onEnter() {
|
void KOReaderSettingsActivity::onEnter() {
|
||||||
@@ -112,6 +112,14 @@ void KOReaderSettingsActivity::handleSelection() {
|
|||||||
KOREADER_STORE.saveToFile();
|
KOREADER_STORE.saveToFile();
|
||||||
requestUpdate();
|
requestUpdate();
|
||||||
} else if (selectedIndex == 6) {
|
} else if (selectedIndex == 6) {
|
||||||
|
// Sign Up - create a new account on the sync server with the entered credentials
|
||||||
|
if (!KOREADER_STORE.hasCredentials()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startActivityForResult(
|
||||||
|
std::make_unique<KOReaderAuthActivity>(renderer, mappedInput, KOReaderAuthActivity::Mode::SIGN_UP),
|
||||||
|
[](const ActivityResult&) {});
|
||||||
|
} else if (selectedIndex == 7) {
|
||||||
// Authenticate
|
// Authenticate
|
||||||
if (!KOREADER_STORE.hasCredentials()) {
|
if (!KOREADER_STORE.hasCredentials()) {
|
||||||
// Can't authenticate without credentials - just show message briefly
|
// Can't authenticate without credentials - just show message briefly
|
||||||
@@ -145,7 +153,16 @@ void KOReaderSettingsActivity::render(RenderLock&&) {
|
|||||||
return KOREADER_STORE.getPassword().empty() ? std::string(tr(STR_NOT_SET)) : std::string("******");
|
return KOREADER_STORE.getPassword().empty() ? std::string(tr(STR_NOT_SET)) : std::string("******");
|
||||||
} else if (index == 2) {
|
} else if (index == 2) {
|
||||||
auto serverUrl = KOREADER_STORE.getServerUrl();
|
auto serverUrl = KOREADER_STORE.getServerUrl();
|
||||||
return serverUrl.empty() ? std::string(tr(STR_DEFAULT_VALUE)) : serverUrl;
|
if (!serverUrl.empty()) {
|
||||||
|
return serverUrl;
|
||||||
|
}
|
||||||
|
// Show which server the default actually is, scheme stripped for space
|
||||||
|
std::string defaultUrl = KOREADER_STORE.getBaseUrl();
|
||||||
|
const auto schemeEnd = defaultUrl.find("://");
|
||||||
|
if (schemeEnd != std::string::npos) {
|
||||||
|
defaultUrl.erase(0, schemeEnd + 3);
|
||||||
|
}
|
||||||
|
return std::string(tr(STR_DEFAULT_VALUE)) + ": " + defaultUrl;
|
||||||
} else if (index == 3) {
|
} else if (index == 3) {
|
||||||
return KOREADER_STORE.getMatchMethod() == DocumentMatchMethod::FILENAME ? std::string(tr(STR_FILENAME))
|
return KOREADER_STORE.getMatchMethod() == DocumentMatchMethod::FILENAME ? std::string(tr(STR_FILENAME))
|
||||||
: std::string(tr(STR_BINARY));
|
: std::string(tr(STR_BINARY));
|
||||||
@@ -154,7 +171,7 @@ void KOReaderSettingsActivity::render(RenderLock&&) {
|
|||||||
} else if (index == 5) {
|
} else if (index == 5) {
|
||||||
return KOREADER_STORE.getSyncBehavior() == KOReaderSyncBehavior::SMART ? std::string(tr(STR_SMART_SYNC))
|
return KOREADER_STORE.getSyncBehavior() == KOReaderSyncBehavior::SMART ? std::string(tr(STR_SMART_SYNC))
|
||||||
: std::string(tr(STR_ASK_EVERY_TIME));
|
: std::string(tr(STR_ASK_EVERY_TIME));
|
||||||
} else if (index == 6) {
|
} else if (index == 6 || index == 7) {
|
||||||
return KOREADER_STORE.hasCredentials() ? "" : std::string("[") + tr(STR_SET_CREDENTIALS_FIRST) + "]";
|
return KOREADER_STORE.hasCredentials() ? "" : std::string("[") + tr(STR_SET_CREDENTIALS_FIRST) + "]";
|
||||||
}
|
}
|
||||||
return std::string(tr(STR_NOT_SET));
|
return std::string(tr(STR_NOT_SET));
|
||||||
|
|||||||
Reference in New Issue
Block a user