diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000..02a4c8c6e9 --- /dev/null +++ b/Makefile @@ -0,0 +1,163 @@ +# Makefile for Capy Reader firmware +# Wraps PlatformIO commands for convenience + +.PHONY: all build build-release release upload upload-release flash flash-release \ + clean format check monitor size erase build-fs upload-fs sleep-screen gh-release changelog help \ + test test-build test-run test-clean fontconvert-bin reader-test + +# Default target +all: help + +# Build targets +build: ## Build firmware (default environment) + pio run + +build-release: ## Build release firmware + pio run -e gh_release + +release: build-release ## Alias for build-release + +# Upload targets +upload: ## Build and flash to device + pio run --target upload + +upload-release: ## Build and flash release firmware + pio run -e gh_release --target upload + +# Aliases +flash: upload ## Alias for upload + +flash-release: upload-release ## Alias for upload-release + +# Clean +clean: ## Clean build artifacts + pio run --target clean + +# Code quality +format: ## Format code with clang-format + ./bin/clang-format-fix + +check: ## Run static analysis (cppcheck) + pio check + +# Device/debug +monitor: ## Open serial monitor + pio device monitor + +size: ## Show firmware size + pio run --target size + +erase: ## Erase device flash + pio run --target erase + +# Filesystem +build-fs: ## Build filesystem image + pio run --target buildfs + +upload-fs: ## Upload filesystem to device + pio run --target uploadfs + +# Release +tag: ## Create and push a version tag (triggers GitHub release) + @read -p "Enter tag version (e.g., 1.0.0): " TAG; \ + if [[ $$TAG =~ ^[0-9]+\.[0-9]+\.[0-9]+$$ ]]; then \ + git tag -a v$$TAG -m "v$$TAG"; \ + git push origin v$$TAG; \ + echo "Tag v$$TAG created and pushed successfully."; \ + else \ + echo "Invalid tag format. Please use X.Y.Z (e.g., 1.0.0)"; \ + exit 1; \ + fi + +gh-release: build-release ## Create GitHub release with firmware +ifndef VERSION + $(error VERSION is required. Usage: make gh-release VERSION=0.1.1 [NOTES="..."]) +endif +ifdef NOTES + gh release create v$(VERSION) .pio/build/gh_release/firmware.bin \ + --repo wildfire070/CapInk \ + --title "Capy v$(VERSION)" \ + --notes "$(NOTES)" +else + gh release create v$(VERSION) .pio/build/gh_release/firmware.bin \ + --repo wildfire070/CapInk \ + --title "Capy v$(VERSION)" \ + --generate-notes +endif + +changelog: ## Generate CHANGELOG.md from git history + @echo "Generating CHANGELOG.md..." + @echo "" > CHANGELOG.md; \ + previous_tag=0; \ + for current_tag in $$(git tag --sort=-creatordate); do \ + if [ "$$previous_tag" != 0 ]; then \ + tag_date=$$(git log -1 --pretty=format:'%ad' --date=short $${previous_tag}); \ + printf "\n## $${previous_tag} ($${tag_date})\n\n" >> CHANGELOG.md; \ + git log $${current_tag}...$${previous_tag} --pretty=format:'* %s [[%an](mailto:%ae)]' --reverse | grep -v Merge >> CHANGELOG.md; \ + printf "\n" >> CHANGELOG.md; \ + fi; \ + previous_tag=$${current_tag}; \ + done; \ + if [ "$$previous_tag" != 0 ]; then \ + tag_date=$$(git log -1 --pretty=format:'%ad' --date=short $${previous_tag}); \ + printf "\n## $${previous_tag} ($${tag_date})\n\n" >> CHANGELOG.md; \ + git log $${previous_tag} --pretty=format:'* %s [[%an](mailto:%ae)]' --reverse | grep -v Merge >> CHANGELOG.md; \ + printf "\n" >> CHANGELOG.md; \ + fi + @echo "CHANGELOG.md generated successfully." + +# Image conversion +sleep-screen: ## Convert image to sleep screen BMP +ifdef INPUT +ifdef OUTPUT + cd scripts && node create-sleep-screen.mjs ../$(INPUT) ../$(OUTPUT) $(ARGS) +else + @echo "Usage: make sleep-screen INPUT= OUTPUT= [ARGS='--dither --bits 8']" +endif +else + @echo "Usage: make sleep-screen INPUT= OUTPUT= [ARGS='--dither --bits 8']" + @echo "Example: make sleep-screen INPUT=photo.jpg OUTPUT=sleep.bmp" +endif + +## Unit Tests: + +test: test-build test-run ## Build and run all unit tests + +test-build: ## Build unit tests + @mkdir -p test/build + @cd test/build && cmake .. -DCMAKE_BUILD_TYPE=Debug && cmake --build . --parallel + +test-run: ## Run unit tests (build first if needed) + @if [ ! -d test/build/bin ]; then $(MAKE) test-build; fi + @test/scripts/run_tests.sh + +test-clean: ## Clean test build artifacts + @rm -rf test/build + +## Tools: + +fontconvert-bin: ## Build Go fontconvert-bin tool (CJK .bin font converter) + $(MAKE) -C tools/fontconvert-bin build + +reader-test: ## Build desktop reader-test tool (process books without flashing) + @mkdir -p tools/reader-test/build + @cd tools/reader-test/build && cmake .. && cmake --build . --parallel + @echo "Built: tools/reader-test/build/reader-test" +ifdef FILE + @tools/reader-test/build/reader-test $(FILE) $(OUTPUT) +endif + +## Help: + +help: ## Show this help + @echo "Capy - Build System" + @echo "" + @echo "Usage: make [target]" + @echo "" + @awk 'BEGIN {FS = ":.*##"; section=""} \ + /^##/ { section=substr($$0, 4); next } \ + /^[a-zA-Z_-]+:.*##/ { \ + if (section != "") { printf "\n\033[1m%s\033[0m\n", section; section="" } \ + printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 \ + }' $(MAKEFILE_LIST) + @echo "" \ No newline at end of file diff --git a/lib/BookFusionSync/BookFusionBookIdStore.cpp b/lib/BookFusionSync/BookFusionBookIdStore.cpp new file mode 100644 index 0000000000..c4252423b4 --- /dev/null +++ b/lib/BookFusionSync/BookFusionBookIdStore.cpp @@ -0,0 +1,69 @@ +#include "BookFusionBookIdStore.h" + +#include +#include +#include +#include + +#include +#include + +void BookFusionBookIdStore::buildSidecarPath(const char* epubPath, char* outPath, size_t maxLen) { + MD5Builder md5; + md5.begin(); + md5.add(epubPath); + md5.calculate(); + + // Result: /.crosspoint/bookfusion_<32hexchars>.json (55 chars total) + snprintf(outPath, maxLen, "/.crosspoint/bookfusion_%s.json", md5.toString().c_str()); +} + +uint32_t BookFusionBookIdStore::loadBookId(const char* epubPath) { + char sidecarPath[64]; + buildSidecarPath(epubPath, sidecarPath, sizeof(sidecarPath)); + + if (!Storage.exists(sidecarPath)) { + return 0; + } + + String json = Storage.readFile(sidecarPath); + if (json.isEmpty()) { + return 0; + } + + JsonDocument doc; + if (deserializeJson(doc, json) != DeserializationError::Ok) { + LOG_ERR("BFS", "Sidecar JSON parse error: %s", sidecarPath); + return 0; + } + + const uint32_t bookId = doc["book_id"] | (uint32_t)0; + LOG_DBG("BFS", "Loaded book_id=%lu for %s", (unsigned long)bookId, epubPath); + return bookId; +} + +bool BookFusionBookIdStore::saveBookId(const char* epubPath, uint32_t bookId) { + if (bookId == 0) { + LOG_ERR("BFS", "Refusing to save book_id=0 for %s", epubPath); + return false; + } + + char sidecarPath[64]; + buildSidecarPath(epubPath, sidecarPath, sizeof(sidecarPath)); + + Storage.mkdir("/.crosspoint"); + + JsonDocument doc; + doc["book_id"] = bookId; + + String json; + serializeJson(doc, json); + + const bool ok = Storage.writeFile(sidecarPath, json); + if (ok) { + LOG_DBG("BFS", "Saved book_id=%lu for %s", (unsigned long)bookId, epubPath); + } else { + LOG_ERR("BFS", "Failed to save sidecar: %s", sidecarPath); + } + return ok; +} diff --git a/lib/BookFusionSync/BookFusionBookIdStore.h b/lib/BookFusionSync/BookFusionBookIdStore.h new file mode 100644 index 0000000000..ece9082d42 --- /dev/null +++ b/lib/BookFusionSync/BookFusionBookIdStore.h @@ -0,0 +1,24 @@ +#pragma once +#include +#include + +/** + * Per-book sidecar for BookFusion book IDs. + * + * Each EPUB that was downloaded from BookFusion has an associated sidecar file + * at /.crosspoint/bookfusion_.json containing its numeric book_id. + * + * Returns 0 from loadBookId() when no sidecar exists — 0 is never a valid BookFusion ID. + */ +class BookFusionBookIdStore { + public: + // Load book_id for the given epub path. Returns 0 if not a BookFusion book. + static uint32_t loadBookId(const char* epubPath); + + // Save book_id for an epub path. Returns false on I/O error or if id == 0. + static bool saveBookId(const char* epubPath, uint32_t bookId); + + private: + // Derives /.crosspoint/bookfusion_<32hexchars>.json from the epub path. + static void buildSidecarPath(const char* epubPath, char* outPath, size_t maxLen); +}; diff --git a/lib/BookFusionSync/BookFusionSyncClient.cpp b/lib/BookFusionSync/BookFusionSyncClient.cpp new file mode 100644 index 0000000000..8cc2709cca --- /dev/null +++ b/lib/BookFusionSync/BookFusionSyncClient.cpp @@ -0,0 +1,395 @@ +#include "BookFusionSyncClient.h" + +#include +#include +#include +#include + +#include +#include + +#include "BookFusionTokenStore.h" + +namespace { +// Add auth and accept headers to an authenticated request. +void addAuthHeaders(HTTPClient& http) { + const std::string bearer = "Bearer " + BF_TOKEN_STORE.getToken(); + http.addHeader("Authorization", bearer.c_str()); + http.addHeader("Accept", BookFusionSyncClient::API_ACCEPT); +} +} // namespace + +// --- Device Code Auth --- + +BookFusionSyncClient::Error BookFusionSyncClient::requestDeviceCode(BookFusionDeviceCodeResponse& out) { + char url[128]; + snprintf(url, sizeof(url), "%s/api/user/auth/device", BASE_URL); + LOG_DBG("BFS", "Requesting device code: %s", url); + + WiFiClientSecure secureClient; + secureClient.setInsecure(); + HTTPClient http; + http.begin(secureClient, url); + http.addHeader("Accept", API_ACCEPT); + http.addHeader("Content-Type", "application/json"); + + JsonDocument body; + body["client_id"] = CLIENT_ID; + String bodyStr; + serializeJson(body, bodyStr); + + const int httpCode = http.POST(bodyStr); + String responseBody = http.getString(); + http.end(); + + LOG_DBG("BFS", "requestDeviceCode response: %d", httpCode); + + if (httpCode < 0) { + return NETWORK_ERROR; + } + if (httpCode != 200) { + return SERVER_ERROR; + } + + JsonDocument doc; + if (deserializeJson(doc, responseBody) != DeserializationError::Ok) { + LOG_ERR("BFS", "requestDeviceCode JSON parse error"); + return JSON_ERROR; + } + + strlcpy(out.deviceCode, doc["device_code"] | "", sizeof(out.deviceCode)); + strlcpy(out.userCode, doc["user_code"] | "", sizeof(out.userCode)); + strlcpy(out.verificationUri, doc["verification_uri"] | "", sizeof(out.verificationUri)); + out.interval = doc["interval"] | 5; + out.expiresIn = doc["expires_in"] | 600; + + LOG_DBG("BFS", "Device code received: user_code=%s, interval=%ds, expires_in=%ds", out.userCode, out.interval, + out.expiresIn); + return OK; +} + +BookFusionSyncClient::Error BookFusionSyncClient::pollForToken(const char* deviceCode, char* outToken, + size_t tokenMaxLen) { + char url[128]; + snprintf(url, sizeof(url), "%s/api/user/auth/token", BASE_URL); + + WiFiClientSecure secureClient; + secureClient.setInsecure(); + HTTPClient http; + http.begin(secureClient, url); + http.addHeader("Accept", API_ACCEPT); + http.addHeader("Content-Type", "application/json"); + + JsonDocument body; + body["grant_type"] = DEVICE_CODE_GRANT_TYPE; + body["client_id"] = CLIENT_ID; + body["device_code"] = deviceCode; + String bodyStr; + serializeJson(body, bodyStr); + + const int httpCode = http.POST(bodyStr); + String responseBody = http.getString(); + http.end(); + + LOG_DBG("BFS", "pollForToken response: %d", httpCode); + + if (httpCode < 0) { + return NETWORK_ERROR; + } + + JsonDocument doc; + if (deserializeJson(doc, responseBody) != DeserializationError::Ok) { + LOG_ERR("BFS", "pollForToken JSON parse error"); + return JSON_ERROR; + } + + if (httpCode == 200) { + const char* token = doc["access_token"] | ""; + if (token[0] == '\0') { + return JSON_ERROR; + } + strlcpy(outToken, token, tokenMaxLen); + LOG_DBG("BFS", "Token received"); + return OK; + } + + // Map OAuth error codes + const char* errCode = doc["error"] | ""; + LOG_DBG("BFS", "pollForToken error: %s", errCode); + + if (strcmp(errCode, "authorization_pending") == 0) return PENDING; + if (strcmp(errCode, "slow_down") == 0) return SLOW_DOWN; + if (strcmp(errCode, "expired_token") == 0) return EXPIRED; + if (strcmp(errCode, "access_denied") == 0) return DENIED; + // BookFusion returns "invalid_grant" (HTTP 400) while authorization is still + // pending — non-standard, but the official Lua plugin keeps polling on any + // unrecognised error, so we do the same. + if (strcmp(errCode, "invalid_grant") == 0) return PENDING; + + return SERVER_ERROR; +} + +// --- Progress --- + +BookFusionSyncClient::Error BookFusionSyncClient::getProgress(uint32_t bookId, BookFusionPosition& out) { + if (!BF_TOKEN_STORE.hasToken()) { + return NO_TOKEN; + } + + char url[128]; + snprintf(url, sizeof(url), "%s/api/user/books/%lu/reading_position", BASE_URL, (unsigned long)bookId); + LOG_DBG("BFS", "getProgress: %s", url); + + WiFiClientSecure secureClient; + secureClient.setInsecure(); + HTTPClient http; + http.begin(secureClient, url); + addAuthHeaders(http); + + const int httpCode = http.GET(); + + if (httpCode == 200) { + String responseBody = http.getString(); + http.end(); + + JsonDocument doc; + if (deserializeJson(doc, responseBody) != DeserializationError::Ok) { + LOG_ERR("BFS", "getProgress JSON parse error"); + return JSON_ERROR; + } + + out.percentage = doc["percentage"] | 0.0f; + out.chapterIndex = doc["chapter_index"] | 0; + out.pagePositionInBook = doc["page_position_in_book"] | 0.0f; + + LOG_DBG("BFS", "Remote progress: %.2f%%, chapter %d", out.percentage, out.chapterIndex); + return OK; + } + + http.end(); + LOG_DBG("BFS", "getProgress response: %d", httpCode); + + if (httpCode == 404) return NOT_FOUND; + if (httpCode == 401) return AUTH_FAILED; + if (httpCode < 0) return NETWORK_ERROR; + return SERVER_ERROR; +} + +BookFusionSyncClient::Error BookFusionSyncClient::setProgress(uint32_t bookId, const BookFusionPosition& pos) { + if (!BF_TOKEN_STORE.hasToken()) { + return NO_TOKEN; + } + + char url[128]; + snprintf(url, sizeof(url), "%s/api/user/books/%lu/reading_position", BASE_URL, (unsigned long)bookId); + LOG_DBG("BFS", "setProgress: %s (%.2f%%)", url, pos.percentage); + + WiFiClientSecure secureClient; + secureClient.setInsecure(); + HTTPClient http; + http.begin(secureClient, url); + addAuthHeaders(http); + http.addHeader("Content-Type", "application/json"); + + JsonDocument body; + body["percentage"] = pos.percentage; + body["chapter_index"] = pos.chapterIndex; + body["page_position_in_book"] = pos.pagePositionInBook; + String bodyStr; + serializeJson(body, bodyStr); + + const int httpCode = http.POST(bodyStr); + http.end(); + + LOG_DBG("BFS", "setProgress response: %d", httpCode); + + if (httpCode == 200 || httpCode == 201) return OK; + if (httpCode == 401) return AUTH_FAILED; + if (httpCode < 0) return NETWORK_ERROR; + return SERVER_ERROR; +} + +// --- Library Browse & Download --- + +BookFusionSyncClient::Error BookFusionSyncClient::searchBooks(int page, BookFusionSearchResult& out, const char* list, + const char* sort) { + if (!BF_TOKEN_STORE.hasToken()) return NO_TOKEN; + + char url[128]; + snprintf(url, sizeof(url), "%s/api/user/books/search", BASE_URL); + + WiFiClientSecure secureClient; + secureClient.setInsecure(); + HTTPClient http; + http.begin(secureClient, url); + addAuthHeaders(http); + http.addHeader("Content-Type", "application/json"); + + // 8 books per display page keeps the raw response under ~20 KB. + // Arduino String grows by doubling: a 53 KB response (21 books) needs a + // ~64 KB buffer during the final realloc, pushing peak heap above 113 KB. + // With 8 books the response is ~20 KB → peak ~40 KB, well within budget. + // Request 9 to detect hasMore without needing response headers. + static constexpr int BOOKS_PER_PAGE = 8; + + JsonDocument reqBody; + reqBody["page"] = page; + reqBody["per_page"] = BOOKS_PER_PAGE + 1; + reqBody["sort"] = (sort != nullptr) ? sort : "added_at-desc"; + if (list != nullptr) { + reqBody["list"] = list; + } + String bodyStr; + serializeJson(reqBody, bodyStr); + + const int httpCode = http.POST(bodyStr); + LOG_DBG("BFS", "searchBooks page=%d response: %d", page, httpCode); + + if (httpCode < 0) { + http.end(); + return NETWORK_ERROR; + } + if (httpCode == 401) { + http.end(); + return AUTH_FAILED; + } + if (httpCode != 200) { + http.end(); + return SERVER_ERROR; + } + + // Read the full response body before parsing. Streaming from WiFiClientSecure + // causes IncompleteInput errors because TLS chunks arrive after ArduinoJson + // has already read past the end of what was buffered. + String responseBody = http.getString(); + http.end(); + + // Build a filter that discards every field except the four we need. + // BookFusion books carry ~20 fields (cover URLs, descriptions, etc.); keeping + // only what we display reduces JsonDocument heap from ~30 KB to ~5 KB. + JsonDocument filter; + filter[0]["id"] = true; + filter[0]["title"] = true; + filter[0]["format"] = true; + filter[0]["authors"][0]["name"] = true; + + JsonDocument doc; + const auto parseErr = deserializeJson(doc, responseBody, DeserializationOption::Filter(filter)); + + if (parseErr != DeserializationError::Ok) { + LOG_ERR("BFS", "searchBooks JSON parse error: %s", parseErr.c_str()); + return JSON_ERROR; + } + + if (!doc.is()) { + LOG_ERR("BFS", "searchBooks: expected JSON array"); + return JSON_ERROR; + } + + JsonArray arr = doc.as(); + out.count = 0; + out.currentPage = page; + out.hasMore = false; + + for (JsonObject book : arr) { + if (out.count >= BOOKS_PER_PAGE) { + out.hasMore = true; + break; + } + + BookFusionBook& b = out.books[out.count]; + b.id = book["id"] | static_cast(0); + if (b.id == 0) continue; + + strlcpy(b.title, book["title"] | "Untitled", sizeof(b.title)); + strlcpy(b.format, book["format"] | "epub", sizeof(b.format)); + + // Concatenate author names from the authors array. + b.authors[0] = '\0'; + JsonArray authors = book["authors"].as(); + bool first = true; + for (JsonObject author : authors) { + const char* name = author["name"] | ""; + if (name[0] != '\0') { + if (!first) strlcat(b.authors, ", ", sizeof(b.authors)); + strlcat(b.authors, name, sizeof(b.authors)); + first = false; + } + } + + out.count++; + } + + LOG_DBG("BFS", "searchBooks: %d books on page %d, hasMore=%d", out.count, page, out.hasMore); + return OK; +} + +BookFusionSyncClient::Error BookFusionSyncClient::getDownloadUrl(uint32_t bookId, char* outUrl, size_t maxLen) { + if (!BF_TOKEN_STORE.hasToken()) return NO_TOKEN; + + char url[128]; + snprintf(url, sizeof(url), "%s/api/user/books/%lu/download", BASE_URL, static_cast(bookId)); + + WiFiClientSecure secureClient; + secureClient.setInsecure(); + HTTPClient http; + http.begin(secureClient, url); + addAuthHeaders(http); + http.addHeader("Content-Type", "application/json"); + + const int httpCode = http.POST("{}"); + String responseBody = http.getString(); + http.end(); + + LOG_DBG("BFS", "getDownloadUrl book=%lu response: %d", static_cast(bookId), httpCode); + + if (httpCode < 0) return NETWORK_ERROR; + if (httpCode == 401) return AUTH_FAILED; + if (httpCode == 403 || httpCode == 404) return NOT_FOUND; + if (httpCode != 200) return SERVER_ERROR; + + JsonDocument doc; + if (deserializeJson(doc, responseBody) != DeserializationError::Ok) { + LOG_ERR("BFS", "getDownloadUrl JSON parse error"); + return JSON_ERROR; + } + + const char* dlUrl = doc["url"] | ""; + if (dlUrl[0] == '\0') { + LOG_ERR("BFS", "getDownloadUrl: missing url field"); + return JSON_ERROR; + } + + strlcpy(outUrl, dlUrl, maxLen); + LOG_DBG("BFS", "getDownloadUrl: ok"); + return OK; +} + +const char* BookFusionSyncClient::errorString(Error error) { + switch (error) { + case OK: + return "Success"; + case NO_TOKEN: + return "Not logged in to BookFusion"; + 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"; + case PENDING: + return "Authorization pending"; + case SLOW_DOWN: + return "Slow down polling"; + case EXPIRED: + return "Device code expired"; + case DENIED: + return "Authorization denied"; + default: + return "Unknown error"; + } +} diff --git a/lib/BookFusionSync/BookFusionSyncClient.h b/lib/BookFusionSync/BookFusionSyncClient.h new file mode 100644 index 0000000000..fad69db96a --- /dev/null +++ b/lib/BookFusionSync/BookFusionSyncClient.h @@ -0,0 +1,103 @@ +#pragma once +#include +#include + +/** + * BookFusion reading position (EPUB). + * + * percentage: 0–100 (note: BookFusion uses 0-100, unlike KOReader's 0.0-1.0) + * chapterIndex: spine index, 0-based + * pagePositionInBook: (chapterIndex + fractional_position_in_chapter) / total_spine_count + */ +struct BookFusionPosition { + float percentage = 0.0f; // 0–100 + float pagePositionInBook = 0.0f; // fractional book position + int chapterIndex = 0; // spine index, 0-based +}; + +/** + * Response from the device-code auth endpoint. + */ +struct BookFusionDeviceCodeResponse { + char deviceCode[256] = {}; + char userCode[16] = {}; + char verificationUri[128] = {}; + int interval = 5; // seconds between polls + int expiresIn = 600; // seconds until code expires +}; + +/** + * A single book from the user's BookFusion library. + */ +struct BookFusionBook { + uint32_t id = 0; + char title[64] = {}; + char authors[48] = {}; + char format[8] = {}; // "EPUB", "PDF", etc. +}; + +/** + * Paginated result from the book search endpoint. + * Request one extra item (MAX_BOOKS + 1) to detect whether more pages exist. + */ +struct BookFusionSearchResult { + static constexpr int MAX_BOOKS = 20; + BookFusionBook books[MAX_BOOKS]; + int count = 0; + int currentPage = 0; + bool hasMore = false; +}; + +/** + * HTTP client for the BookFusion API. + * + * Base URL: https://www.bookfusion.com + * All authenticated requests use: Authorization: Bearer + * Accept: application/json; api_version=10 + * + * Authentication uses the OAuth 2.0 Device Code flow: + * 1. requestDeviceCode() → display verificationUri + userCode to user + * 2. pollForToken() every interval seconds → returns OK + token when authorised + * + * Progress API: + * getProgress(bookId, out) → GET /api/user/books/{id}/reading_position + * setProgress(bookId, pos) → POST /api/user/books/{id}/reading_position + */ +class BookFusionSyncClient { + public: + enum Error { + OK = 0, + NO_TOKEN, // BF_TOKEN_STORE has no token + NETWORK_ERROR, // HTTP/TLS failure + AUTH_FAILED, // 401 Unauthorized + SERVER_ERROR, // 5xx or unexpected code + JSON_ERROR, // Failed to parse response + NOT_FOUND, // 404 — no progress exists yet + PENDING, // authorization_pending — keep polling + SLOW_DOWN, // slow_down — increase poll interval + EXPIRED, // expired_token + DENIED, // access_denied by user + }; + + // --- Device Code Auth (unauthenticated) --- + static Error requestDeviceCode(BookFusionDeviceCodeResponse& out); + static Error pollForToken(const char* deviceCode, char* outToken, size_t tokenMaxLen); + + // --- Progress --- + static Error getProgress(uint32_t bookId, BookFusionPosition& out); + static Error setProgress(uint32_t bookId, const BookFusionPosition& pos); + + // --- Library Browse & Download --- + static Error searchBooks(int page, BookFusionSearchResult& out, const char* list = nullptr, + const char* sort = nullptr); + static Error getDownloadUrl(uint32_t bookId, char* outUrl, size_t maxLen); + + static const char* errorString(Error error); + + static constexpr char API_ACCEPT[] = "application/json; api_version=10"; + + private: + static constexpr char BASE_URL[] = "https://www.bookfusion.com"; + static constexpr char CLIENT_ID[] = "koreader"; + static constexpr char DEVICE_CODE_GRANT_TYPE[] = "urn:ietf:params:oauth:grant-type:device_code"; +}; diff --git a/lib/BookFusionSync/BookFusionTokenStore.cpp b/lib/BookFusionSync/BookFusionTokenStore.cpp new file mode 100644 index 0000000000..4856d208f9 --- /dev/null +++ b/lib/BookFusionSync/BookFusionTokenStore.cpp @@ -0,0 +1,45 @@ +#include "BookFusionTokenStore.h" + +#include +#include +#include + +#include "../../src/JsonSettingsIO.h" + +// Initialise static singleton instance +BookFusionTokenStore BookFusionTokenStore::instance; + +namespace { +constexpr char BF_FILE_JSON[] = "/.crosspoint/bookfusion.json"; +} // namespace + +bool BookFusionTokenStore::saveToFile() const { + Storage.mkdir("/.crosspoint"); + return JsonSettingsIO::saveBookFusion(*this, BF_FILE_JSON); +} + +bool BookFusionTokenStore::loadFromFile() { + if (!Storage.exists(BF_FILE_JSON)) { + LOG_DBG("BFS", "No BookFusion token file found"); + return false; + } + + String json = Storage.readFile(BF_FILE_JSON); + if (json.isEmpty()) { + LOG_DBG("BFS", "BookFusion token file is empty"); + return false; + } + + return JsonSettingsIO::loadBookFusion(*this, json.c_str()); +} + +void BookFusionTokenStore::setToken(const std::string& token) { + accessToken = token; + LOG_DBG("BFS", "BookFusion token set (%zu chars)", token.size()); +} + +void BookFusionTokenStore::clearToken() { + accessToken.clear(); + saveToFile(); + LOG_DBG("BFS", "BookFusion token cleared"); +} diff --git a/lib/BookFusionSync/BookFusionTokenStore.h b/lib/BookFusionSync/BookFusionTokenStore.h new file mode 100644 index 0000000000..e746d846f0 --- /dev/null +++ b/lib/BookFusionSync/BookFusionTokenStore.h @@ -0,0 +1,42 @@ +#pragma once +#include + +class BookFusionTokenStore; +namespace JsonSettingsIO { +bool saveBookFusion(const BookFusionTokenStore& store, const char* path); +bool loadBookFusion(BookFusionTokenStore& store, const char* json); +} // namespace JsonSettingsIO + +/** + * Singleton for storing the BookFusion OAuth Bearer token on the SD card. + * Token is XOR-obfuscated with the device's hardware MAC address and base64-encoded + * before writing to JSON (not cryptographically secure, but device-tied). + * + * File: /.crosspoint/bookfusion.json + */ +class BookFusionTokenStore { + public: + static BookFusionTokenStore& getInstance() { return instance; } + + BookFusionTokenStore(const BookFusionTokenStore&) = delete; + BookFusionTokenStore& operator=(const BookFusionTokenStore&) = delete; + + bool saveToFile() const; + bool loadFromFile(); + + void setToken(const std::string& token); + const std::string& getToken() const { return accessToken; } + bool hasToken() const { return !accessToken.empty(); } + void clearToken(); + + friend bool JsonSettingsIO::saveBookFusion(const BookFusionTokenStore&, const char*); + friend bool JsonSettingsIO::loadBookFusion(BookFusionTokenStore&, const char*); + + private: + static BookFusionTokenStore instance; + BookFusionTokenStore() = default; + + std::string accessToken; +}; + +#define BF_TOKEN_STORE BookFusionTokenStore::getInstance() diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 4873811654..d1064b1e60 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -2,7 +2,8 @@ _language_name: "English" _language_code: "EN" _order: "0" -STR_CROSSINK: "CrossInk" +STR_CROSSPOINT: "CrossPoint" +STR_CROSSINK: "CapInkFusion" STR_BOOTING: "BOOTING" STR_SLEEPING: "SLEEPING" STR_ENTERING_SLEEP: "Going to sleep" @@ -408,3 +409,31 @@ STR_FIRMWARE_WRITE_FAILED: "Firmware write failed" STR_FIRMWARE_UPDATE_DO_NOT_POWER_OFF: "Do not power off!" STR_RECOVERY_MODE: "Recovery Mode" STR_RECOVERY_MODE_HINT: "Place firmware.bin on SD card root and select it" +STR_BF_SYNC: "BookFusion Sync" +STR_BF_LINK_ACCOUNT: "Link Account" +STR_BF_UNLINK: "Unlink Account" +STR_BF_LINKED: "Linked" +STR_BF_NOT_LINKED: "Not linked" +STR_BF_VISIT_URL: "Visit this URL:" +STR_BF_OR_SCAN_QR: "or scan the QR code below" +STR_BF_ENTER_CODE: "Then enter this code:" +STR_BF_LINK_SUCCESS: "Account linked successfully!" +STR_BF_CODE_EXPIRED: "Code expired. Try again." +STR_BF_AUTH_DENIED: "Authorization was denied." +STR_BF_AUTH_FAILED: "Linking failed. Check WiFi." +STR_BF_WAITING: "Waiting for authorization..." +STR_BF_TIME_REMAINING: "Expires in: %ds" +STR_BF_NOT_A_BF_BOOK: "This book was not downloaded from BookFusion" +STR_BF_NO_TOKEN_MSG: "Account not linked" +STR_BF_SETUP_HINT: "Link your BookFusion account in Settings" +STR_BF_AUTH: "BookFusion Auth" +STR_BF_BROWSE_LIBRARY: "Browse Library" +STR_BF_CURRENTLY_READING: "Currently Reading" +STR_BF_FAVORITES: "Favorites" +STR_BF_PLAN_TO_READ: "Plan to Read" +STR_BF_COMPLETED: "Completed" +STR_BF_ALL_BOOKS: "All Books" +STR_BF_NO_BOOKS: "No books found in library" +STR_BF_NEXT_PAGE: "Load next page..." +STR_BF_DOWNLOAD_COMPLETE: "Download complete!" +STR_BF_BOOK_UNAVAILABLE: "Book not available for download" \ No newline at end of file diff --git a/scripts/git_branch.py b/scripts/git_branch.py index f346e394cc..fd3d424c2f 100644 --- a/scripts/git_branch.py +++ b/scripts/git_branch.py @@ -41,25 +41,25 @@ def run_git_value(project_dir, args, label): return ''.join(c for c in value if c not in '"\\') except FileNotFoundError: warn(f'git not found on PATH; {label} suffix will be "unknown"') - return 'unknown' + return 'Capy' except subprocess.CalledProcessError as e: warn( f'git command failed (exit {e.returncode}): ' f'{e.stderr.strip()}; {label} suffix will be "unknown"' ) - return 'unknown' + return 'Capy' except OSError as e: warn( f'OS error reading git {label}: {e}; ' f'{label} suffix will be "unknown"' ) - return 'unknown' + return 'Capy' except Exception as e: # pylint: disable=broad-exception-caught warn( f'Unexpected error reading git {label}: {e}; ' f'{label} suffix will be "unknown"' ) - return 'unknown' + return 'Capy' def get_git_branch(project_dir): @@ -119,7 +119,7 @@ def inject_version(env): if pioenv == 'default': base_version = get_base_version(project_dir) branch = get_git_branch(project_dir) - version_string = f'{base_version}-dev+{branch}' + version_string = f'{base_version}-Capy+{branch}' env.Append(CPPDEFINES=[('CROSSPOINT_VERSION', f'\\"{version_string}\\"')]) print(f'CrossInk build version: {version_string}') diff --git a/src/JsonSettingsIO.cpp b/src/JsonSettingsIO.cpp index d11f4abff5..05ea398bac 100644 --- a/src/JsonSettingsIO.cpp +++ b/src/JsonSettingsIO.cpp @@ -11,6 +11,7 @@ #include #include +#include "BookFusionTokenStore.h" #include "CrossPointSettings.h" #include "CrossPointState.h" #include "KOReaderCredentialStore.h" @@ -297,6 +298,36 @@ bool JsonSettingsIO::loadKOReader(KOReaderCredentialStore& store, const char* js return true; } +// ---- BookFusionTokenStore ---- + +bool JsonSettingsIO::saveBookFusion(const BookFusionTokenStore& store, const char* path) { + JsonDocument doc; + doc["token_obf"] = obfuscation::obfuscateToBase64(store.accessToken); + + String json; + serializeJson(doc, json); + return Storage.writeFile(path, json); +} + +bool JsonSettingsIO::loadBookFusion(BookFusionTokenStore& store, const char* json) { + JsonDocument doc; + auto error = deserializeJson(doc, json); + if (error) { + LOG_ERR("BFS", "JSON parse error loading BookFusion token: %s", error.c_str()); + return false; + } + + bool ok = false; + store.accessToken = obfuscation::deobfuscateFromBase64(doc["token_obf"] | "", &ok); + if (!ok) { + store.accessToken.clear(); + return false; + } + + LOG_DBG("BFS", "Loaded BookFusion token (%zu chars)", store.accessToken.size()); + return true; +} + // ---- WifiCredentialStore ---- bool JsonSettingsIO::saveWifi(const WifiCredentialStore& store, const char* path) { diff --git a/src/JsonSettingsIO.h b/src/JsonSettingsIO.h index 40d6c4d2d9..e0f495f804 100644 --- a/src/JsonSettingsIO.h +++ b/src/JsonSettingsIO.h @@ -4,6 +4,7 @@ class CrossPointSettings; class CrossPointState; class WifiCredentialStore; class KOReaderCredentialStore; +class BookFusionTokenStore; class RecentBooksStore; class OpdsServerStore; @@ -25,6 +26,10 @@ bool loadWifi(WifiCredentialStore& store, const char* json, bool* needsResave = bool saveKOReader(const KOReaderCredentialStore& store, const char* path); bool loadKOReader(KOReaderCredentialStore& store, const char* json, bool* needsResave = nullptr); +// BookFusionTokenStore +bool saveBookFusion(const BookFusionTokenStore& store, const char* path); +bool loadBookFusion(BookFusionTokenStore& store, const char* json); + // RecentBooksStore bool saveRecentBooks(const RecentBooksStore& store, const char* path); bool loadRecentBooks(RecentBooksStore& store, const char* json); diff --git a/src/activities/boot_sleep/BootActivity.cpp b/src/activities/boot_sleep/BootActivity.cpp index 5ab0941a44..6084836b4f 100644 --- a/src/activities/boot_sleep/BootActivity.cpp +++ b/src/activities/boot_sleep/BootActivity.cpp @@ -14,7 +14,7 @@ void BootActivity::onEnter() { const auto pageHeight = renderer.getScreenHeight(); renderer.clearScreen(); - renderer.drawImage(Logo120, (pageWidth - 120) / 2, (pageHeight - 120) / 2, 120, 120); + renderer.drawImage(Logo120, (pageWidth - 128) / 2, (pageHeight - 128) / 2, 128, 128); renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 70, tr(STR_CROSSINK), true, EpdFontFamily::BOLD); renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2 + 95, tr(STR_BOOTING)); renderer.drawCenteredText(SMALL_FONT_ID, pageHeight - 30, CROSSINK_VERSION); diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index b2bf65b4ad..f6e878243b 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -418,7 +418,7 @@ void SleepActivity::renderDefaultSleepScreen() const { const auto pageHeight = renderer.getScreenHeight(); renderer.clearScreen(); - renderer.drawImage(Logo120, (pageWidth - 120) / 2, (pageHeight - 120) / 2, 120, 120); + renderer.drawImage(Logo120, (pageWidth - 128) / 2, (pageHeight - 128) / 2, 128, 128); renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 70, tr(STR_CROSSINK), true, EpdFontFamily::BOLD); renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2 + 95, tr(STR_SLEEPING)); diff --git a/src/activities/reader/BookFusionSyncActivity.cpp b/src/activities/reader/BookFusionSyncActivity.cpp new file mode 100644 index 0000000000..9cfa4be3bc --- /dev/null +++ b/src/activities/reader/BookFusionSyncActivity.cpp @@ -0,0 +1,333 @@ +#include "BookFusionSyncActivity.h" + +#include +#include +#include +#include + +#include + +#include "BookFusionBookIdStore.h" +#include "BookFusionSyncClient.h" +#include "BookFusionTokenStore.h" +#include "MappedInputManager.h" +#include "activities/network/WifiSelectionActivity.h" +#include "components/UITheme.h" +#include "fontIds.h" + +namespace { +void wifiOff() { + WiFi.disconnect(false); + delay(100); + WiFi.mode(WIFI_OFF); + delay(100); +} + +// Convert a BookFusion position (0-100 percentage) to CrossPoint using the KOReader +// percentage mapper. This avoids duplicating the spine-lookup logic. +CrossPointPosition bfToCrossPoint(const std::shared_ptr& epub, const BookFusionPosition& bf, + int currentSpineIndex, int totalPagesInCurrentSpine) { + KOReaderPosition koPos; + koPos.percentage = bf.percentage / 100.0f; + koPos.xpath = ""; + return ProgressMapper::toCrossPoint(epub, koPos, currentSpineIndex, totalPagesInCurrentSpine); +} + +// Convert CrossPoint position to BookFusion format. +BookFusionPosition crossPointToBf(const std::shared_ptr& epub, const CrossPointPosition& pos) { + const KOReaderPosition koPos = ProgressMapper::toKOReader(epub, pos); + const int spineCount = epub->getSpineItemsCount(); + + BookFusionPosition bf; + bf.percentage = koPos.percentage * 100.0f; + bf.chapterIndex = pos.spineIndex; + const float intraSpine = (pos.totalPages > 0) ? static_cast(pos.pageNumber) / pos.totalPages : 0.0f; + bf.pagePositionInBook = (spineCount > 0) ? (pos.spineIndex + intraSpine) / static_cast(spineCount) : 0.0f; + return bf; +} +} // namespace + +void BookFusionSyncActivity::onWifiSelectionComplete(const bool success) { + if (!success) { + LOG_DBG("BFSync", "WiFi connection failed"); + ActivityResult result; + result.isCancelled = true; + setResult(std::move(result)); + finish(); + return; + } + + LOG_DBG("BFSync", "WiFi connected, starting sync"); + + { + RenderLock lock(*this); + state = SYNCING; + statusMessage = tr(STR_FETCH_PROGRESS); + } + requestUpdateAndWait(); + + performSync(); +} + +void BookFusionSyncActivity::performSync() { + const auto result = BookFusionSyncClient::getProgress(bookId, remotePosition); + + if (result == BookFusionSyncClient::NOT_FOUND) { + { + RenderLock lock(*this); + state = NO_REMOTE_PROGRESS; + hasRemoteProgress = false; + } + requestUpdate(true); + return; + } + + if (result != BookFusionSyncClient::OK) { + { + RenderLock lock(*this); + state = SYNC_FAILED; + statusMessage = BookFusionSyncClient::errorString(result); + } + requestUpdate(true); + return; + } + + // Convert remote BF position → CrossPoint + hasRemoteProgress = true; + remoteCrossPoint = bfToCrossPoint(epub, remotePosition, currentSpineIndex, totalPagesInSpine); + + // Compute local position in BF format for display + CrossPointPosition localPos = {currentSpineIndex, currentPage, totalPagesInSpine}; + localPosition = crossPointToBf(epub, localPos); + + { + RenderLock lock(*this); + state = SHOWING_RESULT; + // Default to whichever side is further ahead + selectedOption = (localPosition.percentage > remotePosition.percentage) ? 1 : 0; + } + requestUpdate(true); +} + +void BookFusionSyncActivity::performUpload() { + { + RenderLock lock(*this); + state = UPLOADING; + statusMessage = tr(STR_UPLOAD_PROGRESS); + } + requestUpdateAndWait(); + + CrossPointPosition localPos = {currentSpineIndex, currentPage, totalPagesInSpine}; + const BookFusionPosition bf = crossPointToBf(epub, localPos); + + const auto result = BookFusionSyncClient::setProgress(bookId, bf); + + if (result != BookFusionSyncClient::OK) { + wifiOff(); + { + RenderLock lock(*this); + state = SYNC_FAILED; + statusMessage = BookFusionSyncClient::errorString(result); + } + requestUpdate(); + return; + } + + wifiOff(); + { + RenderLock lock(*this); + state = UPLOAD_COMPLETE; + } + requestUpdate(true); +} + +void BookFusionSyncActivity::onEnter() { + Activity::onEnter(); + + if (!BF_TOKEN_STORE.hasToken()) { + state = NO_TOKEN; + requestUpdate(); + return; + } + + bookId = BookFusionBookIdStore::loadBookId(epubPath.c_str()); + if (bookId == 0) { + state = NOT_A_BF_BOOK; + requestUpdate(); + return; + } + + if (WiFi.status() == WL_CONNECTED) { + onWifiSelectionComplete(true); + return; + } + + startActivityForResult(std::make_unique(renderer, mappedInput), + [this](const ActivityResult& result) { onWifiSelectionComplete(!result.isCancelled); }); +} + +void BookFusionSyncActivity::onExit() { + Activity::onExit(); + wifiOff(); +} + +void BookFusionSyncActivity::render(RenderLock&&) { + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + renderer.clearScreen(); + renderer.drawCenteredText(UI_12_FONT_ID, 15, tr(STR_BF_SYNC), true, EpdFontFamily::BOLD); + + if (state == NO_TOKEN) { + renderer.drawCenteredText(UI_10_FONT_ID, (pageHeight / 2) - 20, tr(STR_BF_NO_TOKEN_MSG), true, EpdFontFamily::BOLD); + renderer.drawCenteredText(UI_10_FONT_ID, (pageHeight / 2) + 10, tr(STR_BF_SETUP_HINT)); + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); + return; + } + + if (state == NOT_A_BF_BOOK) { + renderer.drawCenteredText(UI_10_FONT_ID, (pageHeight / 2) - 10, tr(STR_BF_NOT_A_BF_BOOK), true, + EpdFontFamily::BOLD); + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); + return; + } + + if (state == SYNCING || state == UPLOADING) { + renderer.drawCenteredText(UI_10_FONT_ID, (pageHeight - renderer.getLineHeight(UI_10_FONT_ID)) / 2, + statusMessage.c_str(), true, EpdFontFamily::BOLD); + renderer.displayBuffer(); + return; + } + + if (state == SHOWING_RESULT) { + renderer.drawCenteredText(UI_10_FONT_ID, 120, tr(STR_PROGRESS_FOUND), true, EpdFontFamily::BOLD); + + const int remoteTocIndex = epub->getTocIndexForSpineIndex(remoteCrossPoint.spineIndex); + const int localTocIndex = epub->getTocIndexForSpineIndex(currentSpineIndex); + const std::string remoteChapter = + (remoteTocIndex >= 0) ? epub->getTocItem(remoteTocIndex).title + : (std::string(tr(STR_SECTION_PREFIX)) + std::to_string(remoteCrossPoint.spineIndex + 1)); + const std::string localChapter = + (localTocIndex >= 0) ? epub->getTocItem(localTocIndex).title + : (std::string(tr(STR_SECTION_PREFIX)) + std::to_string(currentSpineIndex + 1)); + + renderer.drawText(UI_10_FONT_ID, 20, 160, tr(STR_REMOTE_LABEL), 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), tr(STR_PAGE_OVERALL_FORMAT), remoteCrossPoint.pageNumber + 1, + remotePosition.percentage); + renderer.drawText(UI_10_FONT_ID, 20, 210, remotePageStr); + + renderer.drawText(UI_10_FONT_ID, 20, 270, tr(STR_LOCAL_LABEL), 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), tr(STR_PAGE_TOTAL_OVERALL_FORMAT), currentPage + 1, totalPagesInSpine, + localPosition.percentage); + renderer.drawText(UI_10_FONT_ID, 20, 320, localPageStr); + + const int optionY = 350; + const int optionHeight = 30; + + if (selectedOption == 0) { + renderer.fillRect(0, optionY - 2, pageWidth - 1, optionHeight); + } + renderer.drawText(UI_10_FONT_ID, 20, optionY, tr(STR_APPLY_REMOTE), selectedOption != 0); + + if (selectedOption == 1) { + renderer.fillRect(0, optionY + optionHeight - 2, pageWidth - 1, optionHeight); + } + renderer.drawText(UI_10_FONT_ID, 20, optionY + optionHeight, tr(STR_UPLOAD_LOCAL), selectedOption != 1); + + const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN)); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); + return; + } + + if (state == NO_REMOTE_PROGRESS) { + renderer.drawCenteredText(UI_10_FONT_ID, (pageHeight / 2) - 20, tr(STR_NO_REMOTE_MSG), true, EpdFontFamily::BOLD); + renderer.drawCenteredText(UI_10_FONT_ID, (pageHeight / 2) + 10, tr(STR_UPLOAD_PROMPT)); + const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_UPLOAD), "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); + return; + } + + if (state == UPLOAD_COMPLETE) { + renderer.drawCenteredText(UI_10_FONT_ID, (pageHeight - renderer.getLineHeight(UI_10_FONT_ID)) / 2, + tr(STR_UPLOAD_SUCCESS), true, EpdFontFamily::BOLD); + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); + return; + } + + if (state == SYNC_FAILED) { + renderer.drawCenteredText(UI_10_FONT_ID, (pageHeight / 2) - 20, tr(STR_SYNC_FAILED_MSG), true, EpdFontFamily::BOLD); + renderer.drawCenteredText(UI_10_FONT_ID, (pageHeight / 2) + 10, statusMessage.c_str()); + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); + return; + } +} + +void BookFusionSyncActivity::loop() { + if (state == NO_TOKEN || state == NOT_A_BF_BOOK || state == SYNC_FAILED || state == UPLOAD_COMPLETE) { + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + ActivityResult result; + result.isCancelled = true; + setResult(std::move(result)); + finish(); + } + return; + } + + if (state == SHOWING_RESULT) { + if (mappedInput.wasReleased(MappedInputManager::Button::Up) || + mappedInput.wasReleased(MappedInputManager::Button::Left) || + mappedInput.wasReleased(MappedInputManager::Button::Down) || + mappedInput.wasReleased(MappedInputManager::Button::Right)) { + selectedOption = (selectedOption + 1) % 2; + requestUpdate(); + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + if (selectedOption == 0) { + setResult(SyncResult{remoteCrossPoint.spineIndex, remoteCrossPoint.pageNumber}); + finish(); + } else { + performUpload(); + } + } + + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + ActivityResult result; + result.isCancelled = true; + setResult(std::move(result)); + finish(); + } + return; + } + + if (state == NO_REMOTE_PROGRESS) { + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + performUpload(); + } + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + ActivityResult result; + result.isCancelled = true; + setResult(std::move(result)); + finish(); + } + return; + } +} diff --git a/src/activities/reader/BookFusionSyncActivity.h b/src/activities/reader/BookFusionSyncActivity.h new file mode 100644 index 0000000000..27d153d662 --- /dev/null +++ b/src/activities/reader/BookFusionSyncActivity.h @@ -0,0 +1,79 @@ +#pragma once +#include + +#include +#include + +#include "BookFusionSyncClient.h" +#include "ProgressMapper.h" +#include "activities/Activity.h" + +/** + * Syncs reading progress with BookFusion. + * + * Only works for books that have a BookFusion book_id sidecar + * (i.e., books downloaded from BookFusion). Shows NOT_A_BF_BOOK otherwise. + * + * Flow: + * 1. Verify token + book_id exist (early-out if not) + * 2. Connect WiFi + * 3. Fetch remote position + * 4. Show comparison + options (Apply / Upload) + * 5. Apply or upload + */ +class BookFusionSyncActivity final : public Activity { + public: + explicit BookFusionSyncActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + const std::shared_ptr& epub, const std::string& epubPath, int currentSpineIndex, + int currentPage, int totalPagesInSpine) + : Activity("BookFusionSync", renderer, mappedInput), + epub(epub), + epubPath(epubPath), + currentSpineIndex(currentSpineIndex), + currentPage(currentPage), + totalPagesInSpine(totalPagesInSpine) {} + + void onEnter() override; + void onExit() override; + void loop() override; + void render(RenderLock&&) override; + bool preventAutoSleep() override { return state == CONNECTING || state == SYNCING || state == UPLOADING; } + + private: + enum State { + WIFI_SELECTION, + CONNECTING, + SYNCING, + SHOWING_RESULT, + UPLOADING, + UPLOAD_COMPLETE, + NO_REMOTE_PROGRESS, + SYNC_FAILED, + NO_TOKEN, + NOT_A_BF_BOOK, + }; + + std::shared_ptr epub; + std::string epubPath; + int currentSpineIndex; + int currentPage; + int totalPagesInSpine; + + State state = WIFI_SELECTION; + std::string statusMessage; + uint32_t bookId = 0; + + // Remote progress + BookFusionPosition remotePosition; + CrossPointPosition remoteCrossPoint{}; + + // Local progress in BF format (for display) + BookFusionPosition localPosition; + + bool hasRemoteProgress = false; + int selectedOption = 0; // 0 = Apply remote, 1 = Upload local + + void onWifiSelectionComplete(bool success); + void performSync(); + void performUpload(); +}; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index c66954a756..dfbd4682c2 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -16,6 +16,9 @@ #include #include "../settings/KOReaderSettingsActivity.h" +#include "BookFusionBookIdStore.h" +#include "BookFusionSyncActivity.h" +#include "BookFusionTokenStore.h" #include "BookStatsActivity.h" #include "CrossPointSettings.h" #include "CrossPointState.h" @@ -748,34 +751,30 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction break; } case EpubReaderMenuActivity::MenuAction::SYNC: { - if (KOREADER_STORE.hasCredentials()) { - const int currentPage = section ? section->currentPage : nextPageNumber; - const int totalPages = section ? section->pageCount : cachedChapterTotalPageCount; - std::optional paragraphIndex; - if (section && currentPage >= 0 && currentPage < section->pageCount) { - const uint16_t paragraphPage = - currentPage > 0 ? static_cast(currentPage - 1) : static_cast(currentPage); - if (const auto pIdx = section->getParagraphIndexForPage(paragraphPage)) { - paragraphIndex = *pIdx; + const int currentPage = section ? section->currentPage : 0; + const int totalPages = section ? section->pageCount : 0; + + auto applySyncResult = [this](const ActivityResult& result) { + if (!result.isCancelled) { + const auto& sync = std::get(result.data); + if (currentSpineIndex != sync.spineIndex || (section && section->currentPage != sync.page)) { + RenderLock lock(*this); + currentSpineIndex = sync.spineIndex; + nextPageNumber = sync.page; + section.reset(); } } - startActivityForResult( - std::make_unique(renderer, mappedInput, epub, epub->getPath(), currentSpineIndex, - currentPage, totalPages, paragraphIndex), - [this](const ActivityResult& result) { - if (!result.isCancelled) { - const auto& sync = std::get(result.data); - if (currentSpineIndex != sync.spineIndex || (section && section->currentPage != sync.page)) { - RenderLock lock(*this); - currentSpineIndex = sync.spineIndex; - nextPageNumber = sync.page; - cachedChapterTotalPageCount = 0; // Prevent rescaling sync page - pendingPageJump.reset(); - saveProgress(currentSpineIndex, nextPageNumber, 0); - section.reset(); - } - } - }); + }; + + // BookFusion takes priority when the book has a BookFusion sidecar. + if (BF_TOKEN_STORE.hasToken() && BookFusionBookIdStore::loadBookId(epub->getPath().c_str()) != 0) { + startActivityForResult(std::make_unique(renderer, mappedInput, epub, epub->getPath(), + currentSpineIndex, currentPage, totalPages), + applySyncResult); + } else if (KOREADER_STORE.hasCredentials()) { + startActivityForResult(std::make_unique(renderer, mappedInput, epub, epub->getPath(), + currentSpineIndex, currentPage, totalPages), + applySyncResult); } break; } diff --git a/src/activities/settings/BookFusionAuthActivity.cpp b/src/activities/settings/BookFusionAuthActivity.cpp new file mode 100644 index 0000000000..18d2a5b3be --- /dev/null +++ b/src/activities/settings/BookFusionAuthActivity.cpp @@ -0,0 +1,278 @@ +#include "BookFusionAuthActivity.h" + +#include +#include +#include +#include +#include + +#include + +#include "BookFusionSyncClient.h" +#include "BookFusionTokenStore.h" +#include "MappedInputManager.h" +#include "activities/network/WifiSelectionActivity.h" +#include "components/UITheme.h" +#include "fontIds.h" +#include "util/QrUtils.h" + +namespace { +constexpr int MAX_NETWORK_RETRIES = 3; +// Only re-render the countdown every 10 s to limit E-ink refreshes. +constexpr unsigned long TIMER_REFRESH_INTERVAL_MS = 10000; +constexpr int QR_CODE_SIZE = 198; +// User-facing activation URL. The OAuth response may return a different +// verification_uri; always display the short, memorable URL instead. +constexpr const char* DEVICE_ACTIVATION_URL = "https://bookfusion.com/device"; +} // namespace + +void BookFusionAuthActivity::onWifiSelectionComplete(const bool success) { + if (!success) { + { + RenderLock lock(*this); + state = FAILED; + } + requestUpdate(true); + return; + } + + { + RenderLock lock(*this); + state = REQUESTING_CODE; + } + requestUpdate(true); + + requestCode(); +} + +void BookFusionAuthActivity::requestCode() { + BookFusionDeviceCodeResponse resp; + const auto result = BookFusionSyncClient::requestDeviceCode(resp); + + if (result != BookFusionSyncClient::OK) { + LOG_ERR("BFAuth", "requestDeviceCode failed: %s", BookFusionSyncClient::errorString(result)); + RenderLock lock(*this); + state = FAILED; + requestUpdate(true); + return; + } + + strlcpy(deviceCode, resp.deviceCode, sizeof(deviceCode)); + strlcpy(userCode, resp.userCode, sizeof(userCode)); + strlcpy(verificationUri, resp.verificationUri, sizeof(verificationUri)); + pollIntervalSec = resp.interval; + + const unsigned long now = millis(); + pollExpireAt = now + static_cast(resp.expiresIn) * 1000UL; + nextPollAt = now + static_cast(pollIntervalSec) * 1000UL; + lastTimerRefresh = now; + networkRetries = 0; + + { + RenderLock lock(*this); + state = WAITING_FOR_USER; + } + requestUpdate(true); +} + +void BookFusionAuthActivity::doPoll() { + { + RenderLock lock(*this); + state = POLLING; + } + + char tokenBuf[512] = {}; + const auto result = BookFusionSyncClient::pollForToken(deviceCode, tokenBuf, sizeof(tokenBuf)); + + switch (result) { + case BookFusionSyncClient::OK: + BF_TOKEN_STORE.setToken(tokenBuf); + BF_TOKEN_STORE.saveToFile(); + { + RenderLock lock(*this); + state = SUCCESS; + } + requestUpdate(true); + return; + + case BookFusionSyncClient::PENDING: + nextPollAt = millis() + static_cast(pollIntervalSec) * 1000UL; + { + RenderLock lock(*this); + state = WAITING_FOR_USER; + } + return; + + case BookFusionSyncClient::SLOW_DOWN: + pollIntervalSec += 5; + nextPollAt = millis() + static_cast(pollIntervalSec) * 1000UL; + { + RenderLock lock(*this); + state = WAITING_FOR_USER; + } + return; + + case BookFusionSyncClient::EXPIRED: { + RenderLock lock(*this); + state = EXPIRED; + } + requestUpdate(true); + return; + + case BookFusionSyncClient::DENIED: { + RenderLock lock(*this); + state = DENIED; + } + requestUpdate(true); + return; + + case BookFusionSyncClient::NETWORK_ERROR: + networkRetries++; + if (networkRetries > MAX_NETWORK_RETRIES) { + RenderLock lock(*this); + state = FAILED; + requestUpdate(true); + } else { + nextPollAt = millis() + static_cast(pollIntervalSec) * 1000UL; + RenderLock lock(*this); + state = WAITING_FOR_USER; + } + return; + + default: { + RenderLock lock(*this); + state = FAILED; + } + requestUpdate(true); + return; + } +} + +void BookFusionAuthActivity::onEnter() { + Activity::onEnter(); + + if (WiFi.status() == WL_CONNECTED) { + onWifiSelectionComplete(true); + return; + } + + startActivityForResult(std::make_unique(renderer, mappedInput), + [this](const ActivityResult& result) { onWifiSelectionComplete(!result.isCancelled); }); +} + +void BookFusionAuthActivity::onExit() { + Activity::onExit(); + // Leave WiFi on — BookFusionSettingsActivity called from settings which may need it. + // The caller (SettingsActivity) doesn't do network ops, so we let the main flow handle WiFi. +} + +void BookFusionAuthActivity::loop() { + if (state == SUCCESS || state == EXPIRED || state == DENIED || state == FAILED) { + if (mappedInput.wasPressed(MappedInputManager::Button::Back) || + mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { + finish(); + } + return; + } + + if (state == WAITING_FOR_USER) { + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + finish(); + return; + } + + const unsigned long now = millis(); + + // Check expiry + if (now - pollExpireAt < 0x80000000UL && now >= pollExpireAt) { + { + RenderLock lock(*this); + state = EXPIRED; + } + requestUpdate(true); + return; + } + + // Refresh countdown display every TIMER_REFRESH_INTERVAL_MS + if (now - lastTimerRefresh >= TIMER_REFRESH_INTERVAL_MS) { + lastTimerRefresh = now; + requestUpdate(); + } + + // Time to poll? + if (now >= nextPollAt) { + doPoll(); + } + } +} + +void BookFusionAuthActivity::render(RenderLock&&) { + renderer.clearScreen(); + + const auto& metrics = UITheme::getInstance().getMetrics(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_BF_AUTH)); + + const int lineH = renderer.getLineHeight(UI_10_FONT_ID); + const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; + + if (state == REQUESTING_CODE || state == CONNECTING) { + renderer.drawCenteredText(UI_10_FONT_ID, (pageHeight - lineH) / 2, tr(STR_BF_WAITING)); + renderer.displayBuffer(); + return; + } + + if (state == WAITING_FOR_USER || state == POLLING) { + int y = contentTop; + + renderer.drawCenteredText(UI_10_FONT_ID, y, tr(STR_BF_VISIT_URL), true, EpdFontFamily::BOLD); + y += lineH + 4; + + renderer.drawCenteredText(UI_12_FONT_ID, y, DEVICE_ACTIVATION_URL, true, EpdFontFamily::REGULAR); + y += renderer.getLineHeight(UI_12_FONT_ID) + 4; + + renderer.drawCenteredText(UI_10_FONT_ID, y, tr(STR_BF_OR_SCAN_QR), true, EpdFontFamily::REGULAR); + y += lineH + 8; + + const Rect qrBounds((pageWidth - QR_CODE_SIZE) / 2, y, QR_CODE_SIZE, QR_CODE_SIZE); + QrUtils::drawQrCode(renderer, qrBounds, DEVICE_ACTIVATION_URL); + y += QR_CODE_SIZE + 12; + + renderer.drawCenteredText(UI_10_FONT_ID, y, tr(STR_BF_ENTER_CODE), true, EpdFontFamily::BOLD); + y += lineH + 8; + + renderer.drawCenteredText(UI_12_FONT_ID, y, userCode, true, EpdFontFamily::BOLD); + y += renderer.getLineHeight(UI_12_FONT_ID) + 12; + + const unsigned long now = millis(); + const int secsLeft = (pollExpireAt > now) ? static_cast((pollExpireAt - now) / 1000UL) : 0; + char countdown[32]; + snprintf(countdown, sizeof(countdown), tr(STR_BF_TIME_REMAINING), secsLeft); + renderer.drawCenteredText(UI_10_FONT_ID, y, countdown); + + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); + return; + } + + if (state == SUCCESS) { + renderer.drawCenteredText(UI_10_FONT_ID, (pageHeight - lineH) / 2, tr(STR_BF_LINK_SUCCESS), true, + EpdFontFamily::BOLD); + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); + return; + } + + // Error states: EXPIRED, DENIED, FAILED + const char* msg = tr(STR_BF_AUTH_FAILED); + if (state == EXPIRED) msg = tr(STR_BF_CODE_EXPIRED); + if (state == DENIED) msg = tr(STR_BF_AUTH_DENIED); + renderer.drawCenteredText(UI_10_FONT_ID, (pageHeight - lineH) / 2, msg, true, EpdFontFamily::BOLD); + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); +} diff --git a/src/activities/settings/BookFusionAuthActivity.h b/src/activities/settings/BookFusionAuthActivity.h new file mode 100644 index 0000000000..a0e05568ea --- /dev/null +++ b/src/activities/settings/BookFusionAuthActivity.h @@ -0,0 +1,54 @@ +#pragma once + +#include "activities/Activity.h" + +/** + * BookFusion account linking via OAuth 2.0 Device Code Flow. + * + * Displays a short code and URL on the E-ink screen. + * The user visits the URL on their phone and enters the code. + * The activity polls the BookFusion server in loop() via millis() until + * authorised, expired, or cancelled — no FreeRTOS task needed. + */ +class BookFusionAuthActivity final : public Activity { + public: + explicit BookFusionAuthActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) + : Activity("BookFusionAuth", renderer, mappedInput) {} + + void onEnter() override; + void onExit() override; + void loop() override; + void render(RenderLock&&) override; + bool preventAutoSleep() override { + return state == CONNECTING || state == REQUESTING_CODE || state == WAITING_FOR_USER || state == POLLING; + } + + private: + enum State { + WIFI_SELECTION, + CONNECTING, + REQUESTING_CODE, + WAITING_FOR_USER, + POLLING, + SUCCESS, + EXPIRED, + DENIED, + FAILED + }; + + State state = WIFI_SELECTION; + + // Device code response data (fixed-size buffers — no heap allocation) + char deviceCode[256] = {}; + char userCode[16] = {}; + char verificationUri[128] = {}; + int pollIntervalSec = 5; + unsigned long pollExpireAt = 0; // millis() deadline + unsigned long nextPollAt = 0; // millis() of next poll attempt + unsigned long lastTimerRefresh = 0; + int networkRetries = 0; + + void onWifiSelectionComplete(bool success); + void requestCode(); + void doPoll(); +}; diff --git a/src/activities/settings/BookFusionBrowserActivity.cpp b/src/activities/settings/BookFusionBrowserActivity.cpp new file mode 100644 index 0000000000..ac068b1349 --- /dev/null +++ b/src/activities/settings/BookFusionBrowserActivity.cpp @@ -0,0 +1,397 @@ +#include "BookFusionBrowserActivity.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "BookFusionBookIdStore.h" +#include "BookFusionTokenStore.h" +#include "MappedInputManager.h" +#include "activities/network/WifiSelectionActivity.h" +#include "components/UITheme.h" +#include "fontIds.h" +#include "network/HttpDownloader.h" +#include "util/StringUtils.h" + +namespace { +constexpr int PAGE_ITEMS = 21; // 20 books + optional "Next page" sentinel + +struct Category { + StrId nameId; + const char* list; + const char* sort; +}; + +constexpr Category CATEGORIES[] = { + {StrId::STR_BF_CURRENTLY_READING, "currently_reading", "last_read_at-desc"}, + {StrId::STR_BF_FAVORITES, "favorites", nullptr}, + {StrId::STR_BF_PLAN_TO_READ, "planned_to_read", nullptr}, + {StrId::STR_BF_COMPLETED, "completed", nullptr}, + {StrId::STR_BF_ALL_BOOKS, nullptr, nullptr}, +}; +constexpr int NUM_CATEGORIES = sizeof(CATEGORIES) / sizeof(CATEGORIES[0]); +} // namespace + +void BookFusionBrowserActivity::onEnter() { + Activity::onEnter(); + + if (!BF_TOKEN_STORE.hasToken()) { + state = ERROR; + strlcpy(errorMsg, tr(STR_BF_NO_TOKEN_MSG), sizeof(errorMsg)); + requestUpdate(); + return; + } + + state = CATEGORY_SELECTION; + requestUpdate(); +} + +void BookFusionBrowserActivity::handleCategorySelection() { + currentCategory = selectedCategory; + currentPage = 1; + + if (WiFi.status() == WL_CONNECTED) { + loadPage(1); + return; + } + + state = WIFI_SELECTION; + startActivityForResult(std::make_unique(renderer, mappedInput), + [this](const ActivityResult& result) { onWifiSelectionComplete(!result.isCancelled); }); +} + +void BookFusionBrowserActivity::onExit() { + Activity::onExit(); + WiFi.mode(WIFI_OFF); +} + +void BookFusionBrowserActivity::onWifiSelectionComplete(bool success) { + if (!success) { + state = ERROR; + strlcpy(errorMsg, tr(STR_WIFI_CONN_FAILED), sizeof(errorMsg)); + requestUpdate(); + return; + } + currentPage = 1; + loadPage(1); +} + +void BookFusionBrowserActivity::loadPage(int page) { + { + RenderLock lock(*this); + state = LOADING; + selectedIndex = 0; + } + requestUpdate(true); + + const auto& cat = CATEGORIES[currentCategory]; + const auto err = BookFusionSyncClient::searchBooks(page, searchResult, cat.list, cat.sort); + + if (err != BookFusionSyncClient::OK) { + { + RenderLock lock(*this); + state = ERROR; + strlcpy(errorMsg, BookFusionSyncClient::errorString(err), sizeof(errorMsg)); + } + requestUpdate(); + return; + } + + if (searchResult.count == 0) { + { + RenderLock lock(*this); + state = ERROR; + strlcpy(errorMsg, tr(STR_BF_NO_BOOKS), sizeof(errorMsg)); + } + requestUpdate(); + return; + } + + { + RenderLock lock(*this); + state = BROWSING; + currentPage = page; + } + requestUpdate(); +} + +void BookFusionBrowserActivity::startDownload(int bookIndex) { + const auto& book = searchResult.books[bookIndex]; + + { + RenderLock lock(*this); + state = DOWNLOADING; + downloadProgress = 0; + downloadTotal = 0; + strlcpy(downloadTitle, book.title, sizeof(downloadTitle)); + } + requestUpdateAndWait(); + + // Fetch the pre-signed download URL from BookFusion. + const auto urlErr = BookFusionSyncClient::getDownloadUrl(book.id, downloadUrl, sizeof(downloadUrl)); + if (urlErr != BookFusionSyncClient::OK) { + { + RenderLock lock(*this); + state = ERROR; + if (urlErr == BookFusionSyncClient::NOT_FOUND) { + strlcpy(errorMsg, tr(STR_BF_BOOK_UNAVAILABLE), sizeof(errorMsg)); + } else { + strlcpy(errorMsg, BookFusionSyncClient::errorString(urlErr), sizeof(errorMsg)); + } + } + requestUpdate(); + return; + } + + // Build destination path: "/Title - Author.ext" (sanitized). + std::string baseName = book.title; + if (book.authors[0] != '\0') { + baseName += " - "; + baseName += book.authors; + } + + char ext[8] = "epub"; + if (book.format[0] != '\0') { + size_t i = 0; + for (; i < sizeof(ext) - 1 && book.format[i] != '\0'; i++) { + ext[i] = static_cast(tolower(static_cast(book.format[i]))); + } + ext[i] = '\0'; + } + + const std::string filename = "/" + StringUtils::sanitizeFilename(baseName) + "." + ext; + LOG_DBG("BFB", "Downloading book_id=%lu -> %s", static_cast(book.id), filename.c_str()); + + const auto dlResult = + HttpDownloader::downloadToFile(downloadUrl, filename, [this](const size_t downloaded, const size_t total) { + downloadProgress = downloaded; + downloadTotal = total; + requestUpdate(true); + }); + + if (dlResult != HttpDownloader::OK) { + { + RenderLock lock(*this); + state = ERROR; + strlcpy(errorMsg, tr(STR_DOWNLOAD_FAILED), sizeof(errorMsg)); + } + requestUpdate(); + return; + } + + // Save sidecar so BookFusionSyncActivity can find the book_id for this file. + BookFusionBookIdStore::saveBookId(filename.c_str(), book.id); + + // Invalidate any stale EPUB cache for this path. + Epub epub(filename, "/.crosspoint"); + epub.clearCache(); + + LOG_DBG("BFB", "Download complete, sidecar saved for book_id=%lu", static_cast(book.id)); + + { + RenderLock lock(*this); + state = DOWNLOAD_COMPLETE; + } + requestUpdate(true); +} + +void BookFusionBrowserActivity::loop() { + if (state == WIFI_SELECTION || state == LOADING || state == DOWNLOADING) { + return; + } + + if (state == CATEGORY_SELECTION) { + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + finish(); + return; + } + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { + handleCategorySelection(); + return; + } + buttonNavigator.onNextRelease([this] { + selectedCategory = ButtonNavigator::nextIndex(selectedCategory, NUM_CATEGORIES); + requestUpdate(); + }); + buttonNavigator.onPreviousRelease([this] { + selectedCategory = ButtonNavigator::previousIndex(selectedCategory, NUM_CATEGORIES); + requestUpdate(); + }); + return; + } + + if (state == ERROR) { + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + if (BF_TOKEN_STORE.hasToken()) { + { + RenderLock lock(*this); + state = CATEGORY_SELECTION; + } + requestUpdate(); + } else { + finish(); + } + } + return; + } + + if (state == DOWNLOAD_COMPLETE) { + if (mappedInput.wasReleased(MappedInputManager::Button::Back) || + mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + { + RenderLock lock(*this); + state = BROWSING; + } + requestUpdate(); + } + return; + } + + if (state == BROWSING) { + const int totalItems = searchResult.count + (searchResult.hasMore ? 1 : 0); + + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + if (currentPage > 1) { + loadPage(currentPage - 1); + } else { + { + RenderLock lock(*this); + state = CATEGORY_SELECTION; + } + requestUpdate(); + } + return; + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { + if (selectedIndex < searchResult.count) { + startDownload(selectedIndex); + } else if (searchResult.hasMore) { + loadPage(currentPage + 1); + } + return; + } + + buttonNavigator.onNextRelease([this, totalItems] { + selectedIndex = ButtonNavigator::nextIndex(selectedIndex, totalItems); + requestUpdate(); + }); + + buttonNavigator.onPreviousRelease([this, totalItems] { + selectedIndex = ButtonNavigator::previousIndex(selectedIndex, totalItems); + requestUpdate(); + }); + + buttonNavigator.onNextContinuous([this, totalItems] { + selectedIndex = ButtonNavigator::nextPageIndex(selectedIndex, totalItems, PAGE_ITEMS); + requestUpdate(); + }); + + buttonNavigator.onPreviousContinuous([this, totalItems] { + selectedIndex = ButtonNavigator::previousPageIndex(selectedIndex, totalItems, PAGE_ITEMS); + requestUpdate(); + }); + } +} + +void BookFusionBrowserActivity::render(RenderLock&&) { + renderer.clearScreen(); + + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + const auto& metrics = UITheme::getInstance().getMetrics(); + + const char* headerTitle = + (state == CATEGORY_SELECTION) ? tr(STR_BF_BROWSE_LIBRARY) : I18N.get(CATEGORIES[currentCategory].nameId); + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, headerTitle); + + if (state == CATEGORY_SELECTION) { + const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; + const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing; + + GUI.drawList( + renderer, Rect{0, contentTop, pageWidth, contentHeight}, NUM_CATEGORIES, selectedCategory, + [](int index) -> std::string { return std::string(I18N.get(CATEGORIES[index].nameId)); }, nullptr, nullptr, + nullptr, true); + + const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_OPEN), tr(STR_DIR_UP), tr(STR_DIR_DOWN)); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); + return; + } + + if (state == WIFI_SELECTION || state == LOADING) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, tr(STR_LOADING)); + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); + return; + } + + if (state == ERROR) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, errorMsg, true, EpdFontFamily::BOLD); + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); + return; + } + + if (state == DOWNLOADING) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 40, tr(STR_DOWNLOADING)); + const int maxWidth = pageWidth - 40; + auto title = renderer.truncatedText(UI_10_FONT_ID, downloadTitle, maxWidth); + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 10, title.c_str()); + if (downloadTotal > 0) { + constexpr int barX = 50; + constexpr int barHeight = 20; + const int barWidth = pageWidth - 100; + const int barY = pageHeight / 2 + 20; + GUI.drawProgressBar(renderer, Rect{barX, barY, barWidth, barHeight}, downloadProgress, downloadTotal); + } + renderer.displayBuffer(); + return; + } + + if (state == DOWNLOAD_COMPLETE) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 15, tr(STR_BF_DOWNLOAD_COMPLETE), true, + EpdFontFamily::BOLD); + const int maxWidth = pageWidth - 40; + auto title = renderer.truncatedText(UI_10_FONT_ID, downloadTitle, maxWidth); + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 15, title.c_str()); + const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); + return; + } + + // BROWSING state — draw paginated book list via UITheme. + const int totalItems = searchResult.count + (searchResult.hasMore ? 1 : 0); + const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; + const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing; + + GUI.drawList( + renderer, Rect{0, contentTop, pageWidth, contentHeight}, totalItems, selectedIndex, + [this](int index) -> std::string { + if (index >= searchResult.count) return std::string(tr(STR_BF_NEXT_PAGE)); + const auto& book = searchResult.books[index]; + std::string text = book.title; + if (book.authors[0] != '\0') { + text += " \xe2\x80\x94 "; // UTF-8 em-dash + text += book.authors; + } + return text; + }, + nullptr, nullptr, nullptr, true); + + const char* confirmLabel = (selectedIndex >= searchResult.count) ? tr(STR_OPEN) : tr(STR_DOWNLOAD); + const auto labels = mappedInput.mapLabels(tr(STR_BACK), confirmLabel, tr(STR_DIR_UP), tr(STR_DIR_DOWN)); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); +} diff --git a/src/activities/settings/BookFusionBrowserActivity.h b/src/activities/settings/BookFusionBrowserActivity.h new file mode 100644 index 0000000000..1f91dec73b --- /dev/null +++ b/src/activities/settings/BookFusionBrowserActivity.h @@ -0,0 +1,56 @@ +#pragma once + +#include + +#include "BookFusionSyncClient.h" +#include "activities/Activity.h" +#include "util/ButtonNavigator.h" + +/** + * Browse and download books from the user's BookFusion library. + * + * Shows the user's library 20 books at a time (paginated). + * Selecting a book fetches a pre-signed download URL, streams the EPUB + * to the SD card, and writes a BookFusion sidecar via BookFusionBookIdStore + * so that progress sync works immediately after download. + * + * Requires a linked BookFusion account (token in BF_TOKEN_STORE). + */ +class BookFusionBrowserActivity final : public Activity { + public: + explicit BookFusionBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) + : Activity("BookFusionBrowser", renderer, mappedInput) {} + + void onEnter() override; + void onExit() override; + void loop() override; + void render(RenderLock&&) override; + bool preventAutoSleep() override { return true; } + + private: + enum State { CATEGORY_SELECTION, WIFI_SELECTION, LOADING, BROWSING, DOWNLOADING, DOWNLOAD_COMPLETE, ERROR }; + + State state = CATEGORY_SELECTION; + ButtonNavigator buttonNavigator; + + BookFusionSearchResult searchResult; // Current page of 20 books (~2.5 KB on heap) + int selectedIndex = 0; + int currentPage = 1; + + // Category menu: which item is highlighted, and which one we're browsing. + int selectedCategory = 0; + int currentCategory = 0; + + // Large enough for pre-signed S3 URLs (typically 500–900 chars). + char downloadUrl[1024] = {}; + char downloadTitle[64] = {}; + size_t downloadProgress = 0; + size_t downloadTotal = 0; + + char errorMsg[128] = {}; + + void onWifiSelectionComplete(bool success); + void handleCategorySelection(); + void loadPage(int page); + void startDownload(int bookIndex); +}; diff --git a/src/activities/settings/BookFusionSettingsActivity.cpp b/src/activities/settings/BookFusionSettingsActivity.cpp new file mode 100644 index 0000000000..798a69da4b --- /dev/null +++ b/src/activities/settings/BookFusionSettingsActivity.cpp @@ -0,0 +1,97 @@ +#include "BookFusionSettingsActivity.h" + +#include +#include + +#include "BookFusionAuthActivity.h" +#include "BookFusionBrowserActivity.h" +#include "BookFusionTokenStore.h" +#include "MappedInputManager.h" +#include "components/UITheme.h" +#include "fontIds.h" + +namespace { +constexpr int LINK_INDEX = 0; +constexpr int UNLINK_INDEX = 1; +constexpr int BROWSE_INDEX = 2; + +const StrId menuNames[BookFusionSettingsActivity::MENU_ITEMS] = {StrId::STR_BF_LINK_ACCOUNT, StrId::STR_BF_UNLINK, + StrId::STR_BF_BROWSE_LIBRARY}; +} // namespace + +void BookFusionSettingsActivity::onEnter() { + Activity::onEnter(); + selectedIndex = 0; + requestUpdate(); +} + +void BookFusionSettingsActivity::onExit() { Activity::onExit(); } + +void BookFusionSettingsActivity::loop() { + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + finish(); + return; + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { + handleSelection(); + return; + } + + buttonNavigator.onNext([this] { + selectedIndex = (selectedIndex + 1) % MENU_ITEMS; + requestUpdate(); + }); + + buttonNavigator.onPrevious([this] { + selectedIndex = (selectedIndex + MENU_ITEMS - 1) % MENU_ITEMS; + requestUpdate(); + }); +} + +void BookFusionSettingsActivity::handleSelection() { + if (selectedIndex == LINK_INDEX) { + startActivityForResult(std::make_unique(renderer, mappedInput), + [this](const ActivityResult&) { requestUpdate(); }); + } else if (selectedIndex == UNLINK_INDEX) { + BF_TOKEN_STORE.clearToken(); + requestUpdate(); + } else if (selectedIndex == BROWSE_INDEX) { + if (BF_TOKEN_STORE.hasToken()) { + startActivityForResult(std::make_unique(renderer, mappedInput), + [this](const ActivityResult&) { requestUpdate(); }); + } + } +} + +void BookFusionSettingsActivity::render(RenderLock&&) { + renderer.clearScreen(); + + const auto& metrics = UITheme::getInstance().getMetrics(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_BF_SYNC)); + + const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; + const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2; + + GUI.drawList( + renderer, Rect{0, contentTop, pageWidth, contentHeight}, MENU_ITEMS, static_cast(selectedIndex), + [](int index) { return std::string(I18N.get(menuNames[index])); }, nullptr, nullptr, + [](int index) -> std::string { + if (index == LINK_INDEX) { + return BF_TOKEN_STORE.hasToken() ? std::string(tr(STR_BF_LINKED)) : std::string(tr(STR_BF_NOT_LINKED)); + } + if (index == BROWSE_INDEX && !BF_TOKEN_STORE.hasToken()) { + return std::string(tr(STR_BF_NOT_LINKED)); + } + return ""; + }, + true); + + const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), tr(STR_DIR_UP), tr(STR_DIR_DOWN)); + GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(); +} diff --git a/src/activities/settings/BookFusionSettingsActivity.h b/src/activities/settings/BookFusionSettingsActivity.h new file mode 100644 index 0000000000..cde15c81e8 --- /dev/null +++ b/src/activities/settings/BookFusionSettingsActivity.h @@ -0,0 +1,27 @@ +#pragma once + +#include "activities/Activity.h" +#include "util/ButtonNavigator.h" + +/** + * Settings submenu for BookFusion Sync. + * Shows "Link Account" and "Unlink Account" options with linked/not-linked status. + */ +class BookFusionSettingsActivity final : public Activity { + public: + explicit BookFusionSettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) + : Activity("BookFusionSettings", renderer, mappedInput) {} + + void onEnter() override; + void onExit() override; + void loop() override; + void render(RenderLock&&) override; + + static constexpr int MENU_ITEMS = 3; + + private: + ButtonNavigator buttonNavigator; + size_t selectedIndex = 0; + + void handleSelection(); +}; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 0d7e8d6e39..97aac9ac2d 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -6,6 +6,7 @@ #include #include "AppVersion.h" +#include "BookFusionSettingsActivity.h" #include "ButtonRemapActivity.h" #include "ClearCacheActivity.h" #include "CrossPointSettings.h" @@ -59,6 +60,7 @@ void SettingsActivity::onEnter() { // Append device-only ACTION items systemSettings.push_back(SettingInfo::Action(StrId::STR_WIFI_NETWORKS, SettingAction::Network)); systemSettings.push_back(SettingInfo::Action(StrId::STR_KOREADER_SYNC, SettingAction::KOReaderSync)); + systemSettings.push_back(SettingInfo::Action(StrId::STR_BF_SYNC, SettingAction::BookFusionSync)); systemSettings.push_back(SettingInfo::Action(StrId::STR_OPDS_SERVERS, SettingAction::OPDSBrowser)); systemSettings.push_back(SettingInfo::Action(StrId::STR_CLEAR_READING_CACHE, SettingAction::ClearCache)); systemSettings.push_back(SettingInfo::Action(StrId::STR_CHECK_UPDATES, SettingAction::CheckForUpdates)); @@ -239,6 +241,9 @@ void SettingsActivity::toggleCurrentSetting() { case SettingAction::KOReaderSync: startActivityForResult(std::make_unique(renderer, mappedInput), resultHandler); break; + case SettingAction::BookFusionSync: + startActivityForResult(std::make_unique(renderer, mappedInput), resultHandler); + break; case SettingAction::OPDSBrowser: startActivityForResult(std::make_unique(renderer, mappedInput), resultHandler); break; diff --git a/src/activities/settings/SettingsActivity.h b/src/activities/settings/SettingsActivity.h index cb22b620bf..156f634e01 100644 --- a/src/activities/settings/SettingsActivity.h +++ b/src/activities/settings/SettingsActivity.h @@ -17,6 +17,7 @@ enum class SettingAction { RemapFrontButtonsReader, CustomiseStatusBar, KOReaderSync, + BookFusionSync, OPDSBrowser, Network, ClearCache, diff --git a/src/images/Logo120.h b/src/images/Logo120.h index 2bb437481d..1502b10c39 100644 --- a/src/images/Logo120.h +++ b/src/images/Logo120.h @@ -3,99 +3,112 @@ // 'crossink', 120x120px static const uint8_t Logo120[] = { - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x83, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf0, 0x00, 0x1f, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x80, 0x00, 0x03, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xfe, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xf8, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xe0, 0x00, 0x00, 0x00, - 0x0f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x80, 0x00, 0x00, 0x00, 0x07, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe, 0x00, 0x00, 0x00, 0x00, 0x03, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xf8, 0x00, 0x00, 0x00, 0x00, 0x01, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf0, 0x00, - 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xfc, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf8, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x0f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xe0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0f, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf8, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x03, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xe0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xfe, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf8, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x01, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0xff, 0xff, - 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xf8, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0xff, 0xff, 0xff, 0xff, 0xf8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, - 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc0, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf0, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x01, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xe0, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x3c, 0x00, 0x03, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf8, 0x00, 0x03, 0x80, 0x00, 0x00, - 0xfe, 0x00, 0x03, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe, 0x00, 0x01, 0xf8, 0x00, 0x03, 0xfe, 0x00, 0x03, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x7f, 0x80, 0x1f, 0xff, 0x00, 0x07, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xc0, 0x00, 0x3f, 0xff, 0xff, 0xff, 0x00, 0x07, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xe0, 0x00, - 0x0f, 0xff, 0xff, 0xff, 0x00, 0x0f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf8, 0x00, 0x07, 0xff, 0xff, 0xfe, - 0x00, 0x0f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x00, 0x01, 0xff, 0xff, 0xfc, 0x00, 0x1f, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x7f, 0xff, 0xf8, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0x80, 0x00, 0x1f, 0xff, 0xf0, 0x00, 0x3f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xc0, 0x00, - 0x03, 0xff, 0xc0, 0x00, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf0, 0x00, 0x00, 0x3c, 0x00, 0x00, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf8, 0x00, 0x00, 0x00, 0x00, 0x01, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe, 0x00, 0x00, 0x00, 0x00, 0x03, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0x80, 0x00, 0x00, 0x00, 0x07, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xe0, 0x00, - 0x00, 0x00, 0x1f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xf8, 0x00, 0x00, 0x00, 0x3f, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x80, 0x00, 0x03, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xf0, 0x00, 0x1f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x83, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}; -static_assert(sizeof(Logo120) == 1800, "Logo120 must be exactly 120x120 / 8 bytes"); \ No newline at end of file + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xF8, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF3, + 0xC3, 0xFF, 0xF8, 0x03, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF3, 0xF1, 0xFF, 0xF1, + 0xE0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF7, 0xFC, 0xFF, 0xE7, 0xFC, 0x3F, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF7, 0xBE, 0x7F, 0xCF, 0xFF, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF7, 0xDE, 0x7F, 0x9F, 0x87, 0x8F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xF7, 0xEF, 0x3F, 0x9F, 0x00, 0xE3, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xF3, 0xE7, 0x3F, 0x3E, 0x00, 0x71, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF3, 0xF3, 0xBF, + 0x3E, 0x38, 0x19, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF9, 0xF9, 0xBF, 0x3C, 0x7E, 0x0C, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF9, 0xF9, 0xBF, 0x3E, 0x7F, 0x0E, 0x7F, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0xFC, 0xBF, 0x3E, 0x7F, 0x8E, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x7C, 0x3F, 0x3E, 0x7F, 0x87, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, + 0x00, 0x1F, 0x1E, 0x3F, 0x3E, 0x3F, 0xC3, 0x9F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0xFE, 0x07, 0x86, + 0x7F, 0x9F, 0x3F, 0xE3, 0x9F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF3, 0xFF, 0xE0, 0xE0, 0x7F, 0x90, 0x3F, + 0xE3, 0x9F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFB, 0xFF, 0xFC, 0x7C, 0xFC, 0x00, 0x1F, 0xF1, 0xCF, 0x87, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF9, 0xFF, 0xFF, 0x3F, 0xF0, 0x00, 0x03, 0xF1, 0xCE, 0x01, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xF9, 0xFF, 0xFF, 0x9F, 0xE7, 0x1F, 0x80, 0x70, 0xCC, 0x20, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFD, 0xF7, 0xFF, 0xCF, 0xC8, 0x98, 0xFC, 0x00, 0x40, 0xF8, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0xF8, 0xFF, + 0xEF, 0x80, 0x48, 0x07, 0x80, 0x00, 0x7C, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0xFE, 0x1F, 0xEF, 0x80, 0x0C, + 0x00, 0x70, 0x00, 0x3E, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x7F, 0x81, 0xEF, 0x00, 0x04, 0x00, 0x1C, 0x00, + 0x1E, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x3F, 0xE0, 0x0F, 0x00, 0x06, 0x00, 0x06, 0x00, 0x1F, 0x1F, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x9F, 0xFE, 0x0E, 0x00, 0x06, 0x00, 0x01, 0x80, 0x0F, 0x0F, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0x8F, 0xFF, 0x9E, 0x00, 0xF3, 0x00, 0x00, 0xC0, 0x0F, 0x8F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC7, + 0xFF, 0x1C, 0x07, 0xF3, 0x00, 0x00, 0x30, 0x0F, 0x8F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE3, 0xFE, 0x3C, 0x3F, + 0xF3, 0x00, 0x00, 0x18, 0x07, 0xC7, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x30, 0xF8, 0x7F, 0xF9, 0x00, 0x00, + 0x08, 0x07, 0xC7, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x03, 0xF1, 0xFF, 0x19, 0x80, 0x00, 0x04, 0x03, 0xC7, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE3, 0xFE, 0x79, 0xFE, 0x00, 0x02, 0x03, 0xE3, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xCF, 0xFF, 0xF9, 0xFF, 0xC0, 0x03, 0x01, 0xE3, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0x9F, 0x9F, 0xF9, 0xFF, 0xF0, 0x01, 0x01, 0xE3, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x3F, + 0x3F, 0xF9, 0xFF, 0xFC, 0x01, 0x81, 0xE3, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0xFF, 0x7E, 0x3D, 0xFF, + 0xFF, 0x00, 0x80, 0xF1, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x01, 0xFF, 0xFC, 0xFD, 0xFF, 0xFF, 0x80, 0xC0, + 0xF1, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF0, 0x0F, 0xFF, 0xF9, 0xFC, 0xFF, 0xFF, 0xC0, 0x40, 0xF1, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0x07, 0xC1, 0xFF, 0xFF, 0xFF, 0xFC, 0xFF, 0xFF, 0xE0, 0x40, 0x71, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, + 0x63, 0x8F, 0xFF, 0xFF, 0xFF, 0xFC, 0xFF, 0xFF, 0xF0, 0x20, 0x71, 0xFF, 0xFF, 0xFF, 0xFF, 0xFD, 0xF8, 0x3F, 0xFF, + 0xFF, 0xFF, 0xFC, 0xFF, 0xFF, 0xF8, 0x20, 0x71, 0xFF, 0xFF, 0xFF, 0xFF, 0xF9, 0xBC, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, + 0xFF, 0xFF, 0xFC, 0x20, 0x79, 0xFF, 0xFF, 0xFF, 0xFF, 0xFB, 0x3C, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0xFF, 0xFF, 0xFE, + 0x20, 0x39, 0xFF, 0xFF, 0xFF, 0xFF, 0xE3, 0xBE, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0xFF, 0xFF, 0xFE, 0x20, 0x38, 0xFF, + 0xFF, 0xFF, 0xFF, 0xE3, 0x87, 0xFF, 0xFF, 0xFF, 0xFF, 0x84, 0xFF, 0xFF, 0xFF, 0x20, 0x38, 0xFF, 0xFF, 0xFF, 0xFF, + 0xC9, 0xC7, 0xFF, 0xFF, 0xFF, 0xFF, 0xE0, 0xFF, 0xFF, 0xFF, 0x30, 0x38, 0xFF, 0xFF, 0xFF, 0xFF, 0xC9, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xF0, 0xFF, 0xFF, 0xFF, 0x90, 0x38, 0xFF, 0xFF, 0xFF, 0xFF, 0xDC, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xF8, 0xFF, 0xFF, 0xFF, 0x90, 0x38, 0xFF, 0xFF, 0xFF, 0xFF, 0xDE, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0xFF, 0xFF, + 0xFF, 0xF0, 0x38, 0xFF, 0xFF, 0xFF, 0xF0, 0xDF, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC, 0xFF, 0xFF, 0xFF, 0xF0, 0x38, + 0xFF, 0xFF, 0xF8, 0xE7, 0xDF, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0xFF, 0xFF, 0xFF, 0xF0, 0x38, 0xFF, 0xFF, 0xFE, + 0x0F, 0xDF, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0xFF, 0xFF, 0xFF, 0xF0, 0x38, 0xFF, 0xFF, 0xFF, 0xFF, 0xDF, 0x7F, + 0xF1, 0xFF, 0xFF, 0xFF, 0xFE, 0xFF, 0xFF, 0xFF, 0xF0, 0x38, 0xFF, 0xFF, 0xFF, 0xFF, 0xDE, 0x7F, 0xF8, 0xFF, 0xFF, + 0xFF, 0xFE, 0xFF, 0xFF, 0xFF, 0xF0, 0x38, 0xFF, 0xFF, 0xFF, 0xFF, 0xDE, 0x7F, 0xFC, 0xFF, 0xFF, 0xFF, 0xFE, 0xFF, + 0xFF, 0xFF, 0xF0, 0x38, 0xFF, 0xFF, 0xFF, 0xF0, 0x5E, 0x7F, 0xFE, 0xFF, 0xFF, 0xFE, 0x7E, 0x3F, 0xFF, 0xFF, 0xF0, + 0x38, 0xFF, 0xFF, 0xFD, 0xE7, 0xDE, 0x7F, 0xFE, 0xFF, 0xFF, 0xFE, 0x7F, 0x0F, 0xFF, 0xFF, 0xF0, 0x38, 0xFF, 0xFF, + 0xFC, 0x4F, 0xDE, 0x7F, 0xFC, 0xFF, 0xFF, 0xFF, 0x7F, 0xCF, 0xFF, 0xFF, 0xF0, 0x38, 0xFF, 0xFF, 0xFF, 0x1F, 0xDE, + 0x7F, 0xF8, 0xFF, 0xFF, 0xFF, 0x3F, 0xC7, 0xFF, 0xFF, 0xF0, 0x38, 0xFF, 0xFF, 0xFF, 0xFF, 0xDE, 0x7F, 0xF3, 0xFF, + 0xFF, 0xFF, 0x3F, 0xE7, 0xFF, 0xFF, 0xF0, 0x38, 0xFF, 0xFF, 0xFF, 0xFF, 0xDE, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0x9F, + 0xE7, 0xFF, 0xFF, 0xE0, 0x39, 0xFF, 0xFF, 0xFF, 0xFF, 0xCE, 0x7F, 0xFF, 0xFF, 0xFE, 0x7F, 0xCF, 0x07, 0xFF, 0xFF, + 0xE0, 0x39, 0xFF, 0xFF, 0xFF, 0xFF, 0xEC, 0xFF, 0xFF, 0xFF, 0xFE, 0x7F, 0xC7, 0xC7, 0xFF, 0xFF, 0xE0, 0x31, 0xFF, + 0xFF, 0xFF, 0xFF, 0xE0, 0x7F, 0xFF, 0xFF, 0xFF, 0x7F, 0xE3, 0xCF, 0xFF, 0xFF, 0xE0, 0x71, 0xFF, 0xFF, 0xFF, 0xFF, + 0xF8, 0x7F, 0xFF, 0xFF, 0xFF, 0x3F, 0xF8, 0x0F, 0xFF, 0xFF, 0xE0, 0x71, 0xFF, 0xFF, 0xFF, 0xFF, 0xF3, 0x3F, 0xFF, + 0xFF, 0xFF, 0x3F, 0xFC, 0x3F, 0xFF, 0xFF, 0xC0, 0x71, 0xFF, 0xFF, 0xFF, 0xFF, 0xF7, 0x3F, 0xFF, 0xFF, 0xFF, 0x3F, + 0xFC, 0xFF, 0xFF, 0xFF, 0xC0, 0x71, 0xFF, 0xFF, 0xFF, 0xFF, 0xF3, 0xBF, 0xFF, 0xFF, 0xFF, 0x3F, 0xC0, 0xFF, 0xFF, + 0xFF, 0xC0, 0xF3, 0xFF, 0xFF, 0xFF, 0xFF, 0xF3, 0x9F, 0xFF, 0xFF, 0xFF, 0x3F, 0x80, 0xFF, 0xFF, 0xFF, 0xC0, 0xF3, + 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x1F, 0xFF, 0xFE, 0xFF, 0x3F, 0x3C, 0xFF, 0xFF, 0xFF, 0x80, 0xE3, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFE, 0x4F, 0xFF, 0xFE, 0x1F, 0x3E, 0x7D, 0xFF, 0xFF, 0xFF, 0x81, 0xE3, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE7, + 0xFF, 0xFE, 0x0F, 0x3E, 0xFD, 0xFF, 0xFF, 0xFF, 0x01, 0xE3, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF7, 0xFF, 0xFF, 0xE7, + 0x3C, 0xF8, 0xFF, 0xFF, 0xFF, 0x03, 0xE7, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF3, 0xFF, 0x7F, 0xE7, 0x3C, 0xF8, 0x7F, + 0xFF, 0xFE, 0x03, 0xC7, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFB, 0xFE, 0x3F, 0xE7, 0x04, 0xFE, 0x3F, 0xFF, 0xFC, 0x07, + 0xC7, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF9, 0xFE, 0x3F, 0xE7, 0x00, 0xFF, 0x3F, 0xFF, 0xFC, 0x0F, 0xCF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFC, 0xFF, 0x3F, 0xE6, 0x00, 0xFF, 0x1F, 0xFF, 0xF0, 0x1F, 0x8F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFE, 0xFF, 0xFF, 0xCE, 0x00, 0x7F, 0xBF, 0xFF, 0xE0, 0x3F, 0x8F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x7F, 0xEF, + 0x9E, 0x00, 0x7C, 0x3F, 0xFF, 0xC3, 0xFF, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x3F, 0xE0, 0x0C, 0x20, 0x3E, + 0x3F, 0xFF, 0x83, 0xFF, 0x1F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xBF, 0x7F, 0xCC, 0x80, 0x0E, 0x3F, 0xFE, 0x13, + 0xFE, 0x3F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xDE, 0x7F, 0xC1, 0x90, 0x00, 0x7F, 0xF8, 0x39, 0xFE, 0x3F, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xCE, 0xFF, 0xC3, 0xC8, 0x47, 0xFF, 0xE0, 0xF8, 0xFC, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xE3, 0xFF, 0x9F, 0xC4, 0xCF, 0xFF, 0x03, 0xFC, 0x78, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, + 0x7F, 0x1F, 0xE7, 0x8F, 0xFC, 0x0F, 0xFE, 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, 0x00, 0x7F, 0xF0, + 0x1F, 0xC0, 0x7F, 0xFF, 0x87, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xF8, 0x00, 0x03, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; +static_assert(sizeof(Logo120) == 2048, "Logo120 must be exactly 2048 bytes"); diff --git a/src/images/crossink.png b/src/images/crossink.png index 2b0980b11e..6efac975da 100644 Binary files a/src/images/crossink.png and b/src/images/crossink.png differ diff --git a/src/main.cpp b/src/main.cpp index 2c22becdd8..9cdc53e364 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -19,6 +19,7 @@ #include #include "AppVersion.h" +#include "BookFusionTokenStore.h" #include "CrossPointSettings.h" #include "CrossPointState.h" #include "GlobalActions.h" @@ -514,6 +515,7 @@ void setup() { SETTINGS.loadFromFile(); I18N.setLanguage(static_cast(SETTINGS.language)); KOREADER_STORE.loadFromFile(); + BF_TOKEN_STORE.loadFromFile(); OPDS_STORE.loadFromFile(); UITheme::getInstance().reload(); ButtonNavigator::setMappedInputManager(mappedInputManager); diff --git a/src/network/WebDAVHandler.cpp b/src/network/WebDAVHandler.cpp index a07e33ebbd..6e1b648766 100644 --- a/src/network/WebDAVHandler.cpp +++ b/src/network/WebDAVHandler.cpp @@ -7,8 +7,11 @@ #include #include +#include + namespace { -constexpr const char* HIDDEN_ITEMS[] = {"System Volume Information", "XTCache"}; +const char* HIDDEN_ITEMS[] = {"System Volume Information", "XTCache"}; +constexpr size_t HIDDEN_ITEMS_COUNT = sizeof(HIDDEN_ITEMS) / sizeof(HIDDEN_ITEMS[0]); // RFC 1123 date format helper: "Sun, 06 Nov 1994 08:49:37 GMT" // ESP32 doesn't have real-time clock set by default, so we use a fixed epoch date @@ -229,12 +232,8 @@ void WebDAVHandler::handlePropfind(WebServer& s) { // Skip hidden/protected items bool shouldHide = (name[0] == '.'); if (!shouldHide) { - for (const auto* item : HIDDEN_ITEMS) { - if (strcmp(name, item) == 0) { - shouldHide = true; - break; - } - } + shouldHide = std::any_of(std::begin(HIDDEN_ITEMS), std::end(HIDDEN_ITEMS), + [name](const char* item) { return strcmp(name, item) == 0; }); } if (!shouldHide) { @@ -773,8 +772,8 @@ bool WebDAVHandler::isProtectedPath(const String& path) const { if (segment.startsWith(".")) return true; - for (const auto* item : HIDDEN_ITEMS) { - if (segment.equals(item)) return true; + for (size_t i = 0; i < HIDDEN_ITEMS_COUNT; i++) { + if (segment.equals(HIDDEN_ITEMS[i])) return true; } start = end + 1;