From 1cf223974272c859311a2ee45f86bf46405b724b Mon Sep 17 00:00:00 2001 From: Arthur Tazhitdinov Date: Wed, 22 Apr 2026 02:35:41 +0300 Subject: [PATCH] feat: Support for multiple OPDS servers (#1209) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary * Add support for configuring and using multiple OPDS servers, replacing the previous single-server limitation. Closes https://github.com/crosspoint-reader/crosspoint-reader/issues/1178 * New OpdsServerStore singleton (modeled after WifiCredentialStore) that persists up to 8 OPDS servers to /.crosspoint/opds.json with MAC-based password obfuscation. * One-time migration from legacy single-server fields in CrossPointSettings to the new store on first boot. * New OpdsServerListActivity for the device UI — works in two modes: a settings list (add/edit/delete servers) and a picker (select which server to browse). When only one server is configured, the picker is skipped automatically. * Renamed CalibreSettingsActivity → OpdsSettingsActivity for clarity. It now edits individual OpdsServer entries (name, URL, username, password, delete). * OpdsBookBrowserActivity now receives an OpdsServer at construction and uses its credentials for all fetches/downloads, and shows the server name in the header. * HttpDownloader::fetchUrl and downloadToFile accept optional per-call username/password parameters instead of reading from global settings. * REST API endpoints on CrossPointWebServer: GET /api/opds, POST /api/opds, POST /api/opds/delete — passwords are never exposed over the API (only a hasPassword flag), and omitting the password field on update preserves the existing one. * Web UI (SettingsPage.html) with dynamic OPDS server management cards — add, edit, save, and delete servers from the browser. SCR-20260416-stvu ## Additional Context * The OpdsServerStore JSON format and obfuscation scheme are identical to WifiCredentialStore, so the same JsonSettingsIO infrastructure handles both. * The web API uses POST /api/opds/delete instead of DELETE /api/opds because the ESP32 WebServer doesn't support the DELETE method with a request body. * Existing single-server configurations are migrated automatically — no user action required. After migration the legacy CrossPointSettings fields are cleared so it only runs once. * The HttpDownloader changes are backward-compatible: the credential parameters default to empty strings, so existing callers are unaffected. --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**< YES >**_ --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- USER_GUIDE.md | 30 ++- lib/I18n/translations/english.yaml | 6 + lib/I18n/translations/russian.yaml | 6 + src/JsonSettingsIO.cpp | 54 +++++ src/JsonSettingsIO.h | 5 + src/OpdsServerStore.cpp | 110 +++++++++ src/OpdsServerStore.h | 60 +++++ src/SettingsList.h | 9 - src/activities/ActivityManager.cpp | 10 +- .../browser/OpdsBookBrowserActivity.cpp | 25 +- .../browser/OpdsBookBrowserActivity.h | 9 +- src/activities/home/HomeActivity.cpp | 11 +- src/activities/home/HomeActivity.h | 2 +- .../settings/CalibreSettingsActivity.cpp | 131 ----------- .../settings/CalibreSettingsActivity.h | 25 -- .../settings/OpdsServerListActivity.cpp | 133 +++++++++++ .../settings/OpdsServerListActivity.h | 29 +++ .../settings/OpdsSettingsActivity.cpp | 222 ++++++++++++++++++ .../settings/OpdsSettingsActivity.h | 37 +++ src/activities/settings/SettingsActivity.cpp | 6 +- src/main.cpp | 2 + src/network/CrossPointWebServer.cpp | 122 ++++++++++ src/network/CrossPointWebServer.h | 5 + src/network/HttpDownloader.cpp | 24 +- src/network/HttpDownloader.h | 20 +- src/network/html/SettingsPage.html | 160 +++++++++++++ 26 files changed, 1035 insertions(+), 218 deletions(-) create mode 100644 src/OpdsServerStore.cpp create mode 100644 src/OpdsServerStore.h delete mode 100644 src/activities/settings/CalibreSettingsActivity.cpp delete mode 100644 src/activities/settings/CalibreSettingsActivity.h create mode 100644 src/activities/settings/OpdsServerListActivity.cpp create mode 100644 src/activities/settings/OpdsServerListActivity.h create mode 100644 src/activities/settings/OpdsSettingsActivity.cpp create mode 100644 src/activities/settings/OpdsSettingsActivity.h diff --git a/USER_GUIDE.md b/USER_GUIDE.md index c918f0cba..f79d35d89 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -20,7 +20,8 @@ Welcome to the **CrossPoint** firmware. This guide outlines the hardware control - [3.6.2 Reader](#362-reader) - [3.6.3 Controls](#363-controls) - [3.6.4 System](#364-system) - - [3.6.5 KOReader Sync Quick Setup](#365-koreader-sync-quick-setup) + - [3.6.5 OPDS Servers (Multiple Libraries)](#365-opds-servers-multiple-libraries) + - [3.6.6 KOReader Sync Quick Setup](#366-koreader-sync-quick-setup) - [3.7 Sleep Screen](#37-sleep-screen) - [4. Reading Mode](#4-reading-mode) - [Page Turning](#page-turning) @@ -194,12 +195,35 @@ The Settings screen allows you to configure the device's behavior. There are a f - **WiFi Networks**: Connect to WiFi networks for file transfers and firmware updates. - **KOReader Sync**: Options for setting up KOReader for syncing book progress. -- **OPDS Browser**: Configure OPDS server settings for browsing and downloading books. Set the server URL (for Calibre Content Server, add `/opds` to the end), and optionally configure username and password for servers requiring authentication. Note: Only HTTP Basic authentication is supported. If using Calibre Content Server with authentication enabled, you must set it to use Basic authentication instead of the default Digest authentication. +- **OPDS Servers**: Manage one or more OPDS libraries for browsing and downloading books. See [OPDS Servers (Multiple Libraries)](#365-opds-servers-multiple-libraries) below. - **Clear Reading Cache**: Clear the internal SD card cache. - **Check for updates**: Check for Crosspoint firmware updates over WiFi. - **Language**: Set the system language (see **[Supported Languages](#supported-languages)** for more information). -#### 3.6.5 KOReader Sync Quick Setup +#### 3.6.5 OPDS Servers (Multiple Libraries) + +CrossPoint supports saving multiple OPDS servers and switching between them when browsing catalogs. + +1. Open **Settings -> System -> OPDS Servers**. +2. Select **Add Server** to create a new entry, or select an existing server to edit it. +3. Configure these fields: + - **Server Name**: Optional display name (for example, "Home Calibre" or "Public Catalog"). + - **OPDS Server URL**: Full catalog root URL (for Calibre Content Server, usually ends with `/opds`). + - **Username / Password**: Optional credentials for authenticated servers. +4. Use **Delete Server** inside a server entry to remove it. + +Behavior notes: + +- You can store up to 8 OPDS servers. +- OPDS authentication supports HTTP Basic auth. If you use Calibre Content Server with authentication enabled, set it to Basic (not Digest). + +You can also manage OPDS servers from the web interface while in File Transfer mode: + +1. Connect to the device web UI. +2. Open `http:///settings`. +3. Use the **OPDS Servers** card to add, edit, or delete entries. + +#### 3.6.6 KOReader Sync Quick Setup CrossPoint can sync reading progress with KOReader-compatible sync servers. It also interoperates with KOReader apps/devices when they use the same server and credentials. diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 08979da72..64316bcf4 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -292,6 +292,12 @@ STR_FOOTNOTES: "Footnotes" STR_NO_FOOTNOTES: "No footnotes on this page" STR_LINK: "[link]" STR_SCREENSHOT_BUTTON: "Take screenshot" +STR_ADD_SERVER: "Add Server" +STR_SERVER_NAME: "Server Name" +STR_NO_SERVERS: "No OPDS servers configured" +STR_DELETE_SERVER: "Delete Server" +STR_DELETE_CONFIRM: "Delete this server?" +STR_OPDS_SERVERS: "OPDS Servers" STR_AUTO_TURN_ENABLED: "Auto Turn Enabled: " STR_AUTO_TURN_PAGES_PER_MIN: "Auto Turn (Pages Per Minute)" STR_CRASH_TITLE: "System Crash" diff --git a/lib/I18n/translations/russian.yaml b/lib/I18n/translations/russian.yaml index 81c25a9d9..bb5f263bb 100644 --- a/lib/I18n/translations/russian.yaml +++ b/lib/I18n/translations/russian.yaml @@ -290,3 +290,9 @@ STR_OPDS_SERVER_URL: "URL OPDS сервера" STR_SCREENSHOT_BUTTON: "Сделать снимок экрана" STR_AUTO_TURN_ENABLED: "Автоперелистывание: " STR_AUTO_TURN_PAGES_PER_MIN: "Автоперелистывание (стр./мин)" +STR_ADD_SERVER: "Добавить сервер" +STR_SERVER_NAME: "Имя сервера" +STR_NO_SERVERS: "Нет настроенных серверов OPDS" +STR_DELETE_SERVER: "Удалить сервер" +STR_DELETE_CONFIRM: "Удалить этот сервер?" +STR_OPDS_SERVERS: "Серверы OPDS" diff --git a/src/JsonSettingsIO.cpp b/src/JsonSettingsIO.cpp index eeb454dff..487ac6f32 100644 --- a/src/JsonSettingsIO.cpp +++ b/src/JsonSettingsIO.cpp @@ -11,6 +11,7 @@ #include "CrossPointSettings.h" #include "CrossPointState.h" #include "KOReaderCredentialStore.h" +#include "OpdsServerStore.h" #include "RecentBooksStore.h" #include "SettingsList.h" #include "WifiCredentialStore.h" @@ -351,3 +352,56 @@ bool JsonSettingsIO::loadRecentBooks(RecentBooksStore& store, const char* json) LOG_DBG("RBS", "Recent books loaded from file (%d entries)", store.getCount()); return true; } + +// ---- OpdsServerStore ---- +// Follows the same save/load pattern as WifiCredentialStore above. +// Passwords are XOR-obfuscated with the device MAC and base64-encoded ("password_obf" key). + +bool JsonSettingsIO::saveOpds(const OpdsServerStore& store, const char* path) { + JsonDocument doc; + + JsonArray arr = doc["servers"].to(); + for (const auto& server : store.getServers()) { + JsonObject obj = arr.add(); + obj["name"] = server.name; + obj["url"] = server.url; + obj["username"] = server.username; + obj["password_obf"] = obfuscation::obfuscateToBase64(server.password); + } + + String json; + serializeJson(doc, json); + return Storage.writeFile(path, json); +} + +bool JsonSettingsIO::loadOpds(OpdsServerStore& store, const char* json, bool* needsResave) { + if (needsResave) *needsResave = false; + JsonDocument doc; + auto error = deserializeJson(doc, json); + if (error) { + LOG_ERR("OPS", "JSON parse error: %s", error.c_str()); + return false; + } + + store.servers.clear(); + JsonArray arr = doc["servers"].as(); + for (JsonObject obj : arr) { + if (store.servers.size() >= OpdsServerStore::MAX_SERVERS) break; + OpdsServer server; + server.name = obj["name"] | std::string(""); + server.url = obj["url"] | std::string(""); + server.username = obj["username"] | std::string(""); + // Try the obfuscated key first; fall back to plaintext "password" for + // files written before obfuscation was added (or hand-edited JSON). + bool ok = false; + server.password = obfuscation::deobfuscateFromBase64(obj["password_obf"] | "", &ok); + if (!ok || server.password.empty()) { + server.password = obj["password"] | std::string(""); + if (!server.password.empty() && needsResave) *needsResave = true; + } + store.servers.push_back(std::move(server)); + } + + LOG_DBG("OPS", "Loaded %zu OPDS servers from file", store.servers.size()); + return true; +} diff --git a/src/JsonSettingsIO.h b/src/JsonSettingsIO.h index f1528565d..40d6c4d2d 100644 --- a/src/JsonSettingsIO.h +++ b/src/JsonSettingsIO.h @@ -5,6 +5,7 @@ class CrossPointState; class WifiCredentialStore; class KOReaderCredentialStore; class RecentBooksStore; +class OpdsServerStore; namespace JsonSettingsIO { @@ -28,4 +29,8 @@ bool loadKOReader(KOReaderCredentialStore& store, const char* json, bool* needsR bool saveRecentBooks(const RecentBooksStore& store, const char* path); bool loadRecentBooks(RecentBooksStore& store, const char* json); +// OpdsServerStore +bool saveOpds(const OpdsServerStore& store, const char* path); +bool loadOpds(OpdsServerStore& store, const char* json, bool* needsResave = nullptr); + } // namespace JsonSettingsIO diff --git a/src/OpdsServerStore.cpp b/src/OpdsServerStore.cpp new file mode 100644 index 000000000..b21516828 --- /dev/null +++ b/src/OpdsServerStore.cpp @@ -0,0 +1,110 @@ +#include "OpdsServerStore.h" + +#include +#include +#include + +#include + +#include "CrossPointSettings.h" + +OpdsServerStore OpdsServerStore::instance; + +namespace { +constexpr char OPDS_FILE_JSON[] = "/.crosspoint/opds.json"; +} // namespace + +bool OpdsServerStore::saveToFile() const { + Storage.mkdir("/.crosspoint"); + return JsonSettingsIO::saveOpds(*this, OPDS_FILE_JSON); +} + +bool OpdsServerStore::loadFromFile() { + if (Storage.exists(OPDS_FILE_JSON)) { + String json = Storage.readFile(OPDS_FILE_JSON); + if (!json.isEmpty()) { + // resave flag is set when passwords were stored in plaintext and need re-obfuscation + bool resave = false; + bool result = JsonSettingsIO::loadOpds(*this, json.c_str(), &resave); + if (result && resave) { + LOG_DBG("OPS", "Resaving JSON with obfuscated passwords"); + saveToFile(); + } + return result; + } + } + + // No opds.json found — attempt one-time migration from the legacy single-server + // fields in CrossPointSettings (opdsServerUrl/opdsUsername/opdsPassword). + if (migrateFromSettings()) { + LOG_DBG("OPS", "Migrated legacy OPDS settings"); + return true; + } + + return false; +} + +bool OpdsServerStore::migrateFromSettings() { + if (strlen(SETTINGS.opdsServerUrl) == 0) { + return false; + } + + OpdsServer server; + server.name = "OPDS Server"; + server.url = SETTINGS.opdsServerUrl; + server.username = SETTINGS.opdsUsername; + server.password = SETTINGS.opdsPassword; + servers.push_back(std::move(server)); + + if (saveToFile()) { + // Clear legacy fields so migration won't run again on next boot + SETTINGS.opdsServerUrl[0] = '\0'; + SETTINGS.opdsUsername[0] = '\0'; + SETTINGS.opdsPassword[0] = '\0'; + SETTINGS.saveToFile(); + LOG_DBG("OPS", "Migrated single-server OPDS config to opds.json"); + return true; + } + + // Save failed — roll back in-memory state so we don't have a partial migration + servers.clear(); + return false; +} + +bool OpdsServerStore::addServer(const OpdsServer& server) { + if (servers.size() >= MAX_SERVERS) { + LOG_DBG("OPS", "Cannot add more servers, limit of %zu reached", MAX_SERVERS); + return false; + } + + servers.push_back(server); + LOG_DBG("OPS", "Added server: %s", server.name.c_str()); + return saveToFile(); +} + +bool OpdsServerStore::updateServer(size_t index, const OpdsServer& server) { + if (index >= servers.size()) { + return false; + } + + servers[index] = server; + LOG_DBG("OPS", "Updated server: %s", server.name.c_str()); + return saveToFile(); +} + +bool OpdsServerStore::removeServer(size_t index) { + if (index >= servers.size()) { + return false; + } + + LOG_DBG("OPS", "Removed server: %s", servers[index].name.c_str()); + servers.erase(servers.begin() + static_cast(index)); + return saveToFile(); +} + +const OpdsServer* OpdsServerStore::getServer(size_t index) const { + if (index >= servers.size()) { + return nullptr; + } + return &servers[index]; +} diff --git a/src/OpdsServerStore.h b/src/OpdsServerStore.h new file mode 100644 index 000000000..87571f651 --- /dev/null +++ b/src/OpdsServerStore.h @@ -0,0 +1,60 @@ +#pragma once +#include +#include + +struct OpdsServer { + std::string name; + std::string url; + std::string username; + std::string password; // Plaintext in memory; obfuscated with hardware key on disk +}; + +class OpdsServerStore; +namespace JsonSettingsIO { +bool saveOpds(const OpdsServerStore& store, const char* path); +bool loadOpds(OpdsServerStore& store, const char* json, bool* needsResave); +} // namespace JsonSettingsIO + +/** + * Singleton class for storing OPDS server configurations on the SD card. + * Passwords are XOR-obfuscated with the device's unique hardware MAC address + * and base64-encoded before writing to JSON. + */ +class OpdsServerStore { + private: + static OpdsServerStore instance; + std::vector servers; + + static constexpr size_t MAX_SERVERS = 8; + + OpdsServerStore() = default; + + friend bool JsonSettingsIO::saveOpds(const OpdsServerStore&, const char*); + friend bool JsonSettingsIO::loadOpds(OpdsServerStore&, const char*, bool*); + + public: + OpdsServerStore(const OpdsServerStore&) = delete; + OpdsServerStore& operator=(const OpdsServerStore&) = delete; + + static OpdsServerStore& getInstance() { return instance; } + + bool saveToFile() const; + bool loadFromFile(); + + bool addServer(const OpdsServer& server); + bool updateServer(size_t index, const OpdsServer& server); + bool removeServer(size_t index); + + const std::vector& getServers() const { return servers; } + const OpdsServer* getServer(size_t index) const; + size_t getCount() const { return servers.size(); } + bool hasServers() const { return !servers.empty(); } + + /** + * Migrate from legacy single-server settings in CrossPointSettings. + * Called once during first load if no opds.json exists. + */ + bool migrateFromSettings(); +}; + +#define OPDS_STORE OpdsServerStore::getInstance() diff --git a/src/SettingsList.h b/src/SettingsList.h index 72f19dc3c..70b4c4a36 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -111,15 +111,6 @@ inline const std::vector& getSettingsList() { KOREADER_STORE.saveToFile(); }, "koMatchMethod", StrId::STR_KOREADER_SYNC), - - // --- OPDS Browser (web-only, uses CrossPointSettings char arrays) --- - SettingInfo::String(StrId::STR_OPDS_SERVER_URL, SETTINGS.opdsServerUrl, sizeof(SETTINGS.opdsServerUrl), - "opdsServerUrl", StrId::STR_OPDS_BROWSER), - SettingInfo::String(StrId::STR_USERNAME, SETTINGS.opdsUsername, sizeof(SETTINGS.opdsUsername), "opdsUsername", - StrId::STR_OPDS_BROWSER), - SettingInfo::String(StrId::STR_PASSWORD, SETTINGS.opdsPassword, sizeof(SETTINGS.opdsPassword), "opdsPassword", - StrId::STR_OPDS_BROWSER) - .withObfuscated(), // --- Status Bar Settings (web-only, uses StatusBarSettingsActivity) --- SettingInfo::Toggle(StrId::STR_CHAPTER_PAGE_COUNT, &CrossPointSettings::statusBarChapterPageCount, "statusBarChapterPageCount", StrId::STR_CUSTOMISE_STATUS_BAR), diff --git a/src/activities/ActivityManager.cpp b/src/activities/ActivityManager.cpp index bd15abbaf..915f956e1 100644 --- a/src/activities/ActivityManager.cpp +++ b/src/activities/ActivityManager.cpp @@ -2,6 +2,7 @@ #include +#include "OpdsServerStore.h" #include "boot_sleep/BootActivity.h" #include "boot_sleep/SleepActivity.h" #include "browser/OpdsBookBrowserActivity.h" @@ -11,6 +12,7 @@ #include "home/RecentBooksActivity.h" #include "network/CrossPointWebServerActivity.h" #include "reader/ReaderActivity.h" +#include "settings/OpdsServerListActivity.h" #include "settings/SettingsActivity.h" #include "util/FullScreenMessageActivity.h" @@ -179,7 +181,13 @@ void ActivityManager::goToRecentBooks() { } void ActivityManager::goToBrowser() { - replaceActivity(std::make_unique(renderer, mappedInput)); + const auto& servers = OPDS_STORE.getServers(); + // Skip the server picker when there's only one server configured + if (servers.size() == 1) { + replaceActivity(std::make_unique(renderer, mappedInput, servers[0])); + } else { + replaceActivity(std::make_unique(renderer, mappedInput, true)); + } } void ActivityManager::goToReader(std::string path) { diff --git a/src/activities/browser/OpdsBookBrowserActivity.cpp b/src/activities/browser/OpdsBookBrowserActivity.cpp index e78e9373a..3fe2fa865 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.cpp +++ b/src/activities/browser/OpdsBookBrowserActivity.cpp @@ -7,7 +7,6 @@ #include #include -#include "CrossPointSettings.h" #include "MappedInputManager.h" #include "activities/network/WifiSelectionActivity.h" #include "activities/util/KeyboardEntryActivity.h" @@ -123,7 +122,9 @@ void OpdsBookBrowserActivity::render(RenderLock&&) { const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); - renderer.drawCenteredText(UI_12_FONT_ID, 15, tr(STR_OPDS_BROWSER), true, EpdFontFamily::BOLD); + // Show server name in header if available, otherwise generic title + const char* headerTitle = server.name.empty() ? tr(STR_OPDS_BROWSER) : server.name.c_str(); + renderer.drawCenteredText(UI_12_FONT_ID, 15, headerTitle, true, EpdFontFamily::BOLD); if (state == BrowserState::CHECK_WIFI || state == BrowserState::LOADING) { renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str()); @@ -179,18 +180,19 @@ void OpdsBookBrowserActivity::render(RenderLock&&) { } void OpdsBookBrowserActivity::fetchFeed(const std::string& path) { - if (strlen(SETTINGS.opdsServerUrl) == 0) { + if (server.url.empty()) { state = BrowserState::ERROR; errorMessage = tr(STR_NO_SERVER_URL); requestUpdate(); return; } - std::string url = (path.find("http") == 0) ? path : UrlUtils::buildUrl(SETTINGS.opdsServerUrl, path); + std::string url = (path.find("http") == 0) ? path : UrlUtils::buildUrl(server.url, path); + LOG_DBG("OPDS", "Fetching: %s", url.c_str()); OpdsParser parser; { OpdsParserStream stream{parser}; - if (!HttpDownloader::fetchUrl(url, stream)) { + if (!HttpDownloader::fetchUrl(url, stream, server.username, server.password)) { state = BrowserState::ERROR; errorMessage = tr(STR_FETCH_FEED_FAILED); requestUpdate(); @@ -226,7 +228,7 @@ void OpdsBookBrowserActivity::fetchFeed(const std::string& path) { void OpdsBookBrowserActivity::navigateToEntry(const OpdsEntry& entry) { navigationHistory.push_back(currentPath); // Resolve to a full URL so sub-sub-navigation retains parent path context - const std::string feedUrl = UrlUtils::buildUrl(SETTINGS.opdsServerUrl, currentPath); + const std::string feedUrl = UrlUtils::buildUrl(server.url, currentPath); currentPath = UrlUtils::buildUrl(feedUrl, entry.href); state = BrowserState::LOADING; @@ -259,18 +261,20 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) { requestUpdate(true); // Build full download URL relative to the current feed, not the root server URL - const std::string feedUrl = UrlUtils::buildUrl(SETTINGS.opdsServerUrl, currentPath); + const std::string feedUrl = UrlUtils::buildUrl(server.url, currentPath); std::string downloadUrl = UrlUtils::buildUrl(feedUrl, book.href); std::string filename = "/" + StringUtils::sanitizeFilename(book.title + (book.author.empty() ? "" : " - " + book.author)) + ".epub"; LOG_DBG("OPDS", "Downloading: %s -> %s", downloadUrl.c_str(), filename.c_str()); - const auto result = - HttpDownloader::downloadToFile(downloadUrl, filename, [this](const size_t downloaded, const size_t total) { + const auto result = HttpDownloader::downloadToFile( + downloadUrl, filename, + [this](const size_t downloaded, const size_t total) { downloadProgress = downloaded; downloadTotal = total; requestUpdate(true); - }); + }, + server.username, server.password); if (result == HttpDownloader::OK) { Epub(filename, "/.crosspoint").clearCache(); @@ -346,7 +350,6 @@ void OpdsBookBrowserActivity::checkAndConnectWifi() { } void OpdsBookBrowserActivity::launchWifiSelection() { - consumeBack = consumeConfirm = true; state = BrowserState::WIFI_SELECTION; requestUpdate(); diff --git a/src/activities/browser/OpdsBookBrowserActivity.h b/src/activities/browser/OpdsBookBrowserActivity.h index 8b55343a3..853f23e1d 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.h +++ b/src/activities/browser/OpdsBookBrowserActivity.h @@ -1,11 +1,12 @@ #pragma once #include -#include #include +#include #include #include "../Activity.h" +#include "OpdsServerStore.h" #include "util/ButtonNavigator.h" /** @@ -16,8 +17,8 @@ class OpdsBookBrowserActivity final : public Activity { public: enum class BrowserState { CHECK_WIFI, WIFI_SELECTION, LOADING, BROWSING, DOWNLOADING, ERROR, SEARCH_INPUT }; - explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) - : Activity("OpdsBookBrowser", renderer, mappedInput), buttonNavigator() {} + explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, OpdsServer server) + : Activity("OpdsBookBrowser", renderer, mappedInput), buttonNavigator(), server(std::move(server)) {} void onEnter() override; void onExit() override; @@ -39,6 +40,8 @@ class OpdsBookBrowserActivity final : public Activity { size_t downloadProgress = 0; size_t downloadTotal = 0; + OpdsServer server; // Copied at construction — safe even if the store changes during browsing + void checkAndConnectWifi(); void launchWifiSelection(); void onWifiSelectionComplete(bool connected); diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 3cc72fb85..4e1338292 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -15,6 +15,7 @@ #include "CrossPointSettings.h" #include "CrossPointState.h" #include "MappedInputManager.h" +#include "OpdsServerStore.h" #include "RecentBooksStore.h" #include "components/UITheme.h" #include "fontIds.h" @@ -24,7 +25,7 @@ int HomeActivity::getMenuItemCount() const { if (!recentBooks.empty()) { count += recentBooks.size(); } - if (hasOpdsUrl) { + if (hasOpdsServers) { count++; } return count; @@ -110,8 +111,7 @@ void HomeActivity::loadRecentCovers(int coverHeight) { void HomeActivity::onEnter() { Activity::onEnter(); - // Check if OPDS browser URL is configured - hasOpdsUrl = strlen(SETTINGS.opdsServerUrl) > 0; + hasOpdsServers = OPDS_STORE.hasServers(); selectorIndex = 0; @@ -190,7 +190,7 @@ void HomeActivity::loop() { int menuSelectedIndex = selectorIndex - static_cast(recentBooks.size()); const int fileBrowserIdx = idx++; const int recentsIdx = idx++; - const int opdsLibraryIdx = hasOpdsUrl ? idx++ : -1; + const int opdsLibraryIdx = hasOpdsServers ? idx++ : -1; const int fileTransferIdx = idx++; const int settingsIdx = idx; @@ -229,8 +229,7 @@ void HomeActivity::render(RenderLock&&) { tr(STR_SETTINGS_TITLE)}; std::vector menuIcons = {Folder, Recent, Transfer, Settings}; - if (hasOpdsUrl) { - // Insert OPDS Browser after File Browser + if (hasOpdsServers) { menuItems.insert(menuItems.begin() + 2, tr(STR_OPDS_BROWSER)); menuIcons.insert(menuIcons.begin() + 2, Library); } diff --git a/src/activities/home/HomeActivity.h b/src/activities/home/HomeActivity.h index e2b24beaf..7b743e771 100644 --- a/src/activities/home/HomeActivity.h +++ b/src/activities/home/HomeActivity.h @@ -15,7 +15,7 @@ class HomeActivity final : public Activity { bool recentsLoading = false; bool recentsLoaded = false; bool firstRenderDone = false; - bool hasOpdsUrl = false; + bool hasOpdsServers = false; bool coverRendered = false; // Track if cover has been rendered once bool coverBufferStored = false; // Track if cover buffer is stored uint8_t* coverBuffer = nullptr; // HomeActivity's own buffer for cover image diff --git a/src/activities/settings/CalibreSettingsActivity.cpp b/src/activities/settings/CalibreSettingsActivity.cpp deleted file mode 100644 index a353dd4a3..000000000 --- a/src/activities/settings/CalibreSettingsActivity.cpp +++ /dev/null @@ -1,131 +0,0 @@ -#include "CalibreSettingsActivity.h" - -#include -#include - -#include - -#include "CrossPointSettings.h" -#include "MappedInputManager.h" -#include "activities/util/KeyboardEntryActivity.h" -#include "components/UITheme.h" -#include "fontIds.h" - -namespace { -constexpr int MENU_ITEMS = 3; -const StrId menuNames[MENU_ITEMS] = {StrId::STR_CALIBRE_WEB_URL, StrId::STR_USERNAME, StrId::STR_PASSWORD}; -} // namespace - -void CalibreSettingsActivity::onEnter() { - Activity::onEnter(); - - selectedIndex = 0; - requestUpdate(); -} - -void CalibreSettingsActivity::onExit() { Activity::onExit(); } - -void CalibreSettingsActivity::loop() { - if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { - finish(); - return; - } - - if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { - handleSelection(); - return; - } - - // Handle navigation - buttonNavigator.onNext([this] { - selectedIndex = (selectedIndex + 1) % MENU_ITEMS; - requestUpdate(); - }); - - buttonNavigator.onPrevious([this] { - selectedIndex = (selectedIndex + MENU_ITEMS - 1) % MENU_ITEMS; - requestUpdate(); - }); -} - -void CalibreSettingsActivity::handleSelection() { - if (selectedIndex == 0) { - // OPDS Server URL - prefill with https:// if empty to save typing - const std::string currentUrl = SETTINGS.opdsServerUrl; - const std::string prefillUrl = currentUrl.empty() ? "https://" : currentUrl; - startActivityForResult(std::make_unique(renderer, mappedInput, tr(STR_CALIBRE_WEB_URL), - prefillUrl, 127, InputType::Url), - [this](const ActivityResult& result) { - if (!result.isCancelled) { - const auto& kb = std::get(result.data); - const std::string urlToSave = - (kb.text == "https://" || kb.text == "http://") ? "" : kb.text; - strncpy(SETTINGS.opdsServerUrl, urlToSave.c_str(), sizeof(SETTINGS.opdsServerUrl) - 1); - SETTINGS.opdsServerUrl[sizeof(SETTINGS.opdsServerUrl) - 1] = '\0'; - SETTINGS.saveToFile(); - } - }); - } else if (selectedIndex == 1) { - // Username - startActivityForResult(std::make_unique(renderer, mappedInput, tr(STR_USERNAME), - SETTINGS.opdsUsername, 63, InputType::Text), - [this](const ActivityResult& result) { - if (!result.isCancelled) { - const auto& kb = std::get(result.data); - strncpy(SETTINGS.opdsUsername, kb.text.c_str(), sizeof(SETTINGS.opdsUsername) - 1); - SETTINGS.opdsUsername[sizeof(SETTINGS.opdsUsername) - 1] = '\0'; - SETTINGS.saveToFile(); - } - }); - } else if (selectedIndex == 2) { - // Password - startActivityForResult(std::make_unique(renderer, mappedInput, tr(STR_PASSWORD), - SETTINGS.opdsPassword, 63, InputType::Password), - [this](const ActivityResult& result) { - if (!result.isCancelled) { - const auto& kb = std::get(result.data); - strncpy(SETTINGS.opdsPassword, kb.text.c_str(), sizeof(SETTINGS.opdsPassword) - 1); - SETTINGS.opdsPassword[sizeof(SETTINGS.opdsPassword) - 1] = '\0'; - SETTINGS.saveToFile(); - } - }); - } -} - -void CalibreSettingsActivity::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_OPDS_BROWSER)); - GUI.drawSubHeader(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight}, - tr(STR_CALIBRE_URL_HINT)); - - const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing + metrics.tabBarHeight; - const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2; - GUI.drawList( - renderer, Rect{0, contentTop, pageWidth, contentHeight}, static_cast(MENU_ITEMS), - static_cast(selectedIndex), [](int index) { return std::string(I18N.get(menuNames[index])); }, nullptr, - nullptr, - [this](int index) { - // Draw status for each setting - if (index == 0) { - return (strlen(SETTINGS.opdsServerUrl) > 0) ? std::string(SETTINGS.opdsServerUrl) - : std::string(tr(STR_NOT_SET)); - } else if (index == 1) { - return (strlen(SETTINGS.opdsUsername) > 0) ? std::string(SETTINGS.opdsUsername) - : std::string(tr(STR_NOT_SET)); - } else if (index == 2) { - return (strlen(SETTINGS.opdsPassword) > 0) ? std::string("******") : std::string(tr(STR_NOT_SET)); - } - return std::string(tr(STR_NOT_SET)); - }, - true); - - // Draw help text at bottom - 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/CalibreSettingsActivity.h b/src/activities/settings/CalibreSettingsActivity.h deleted file mode 100644 index c7990d9a1..000000000 --- a/src/activities/settings/CalibreSettingsActivity.h +++ /dev/null @@ -1,25 +0,0 @@ -#pragma once - -#include "activities/Activity.h" -#include "util/ButtonNavigator.h" - -/** - * Submenu for OPDS Browser settings. - * Shows OPDS Server URL and HTTP authentication options. - */ -class CalibreSettingsActivity final : public Activity { - public: - explicit CalibreSettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) - : Activity("CalibreSettings", renderer, mappedInput) {} - - void onEnter() override; - void onExit() override; - void loop() override; - void render(RenderLock&&) override; - - private: - ButtonNavigator buttonNavigator; - - size_t selectedIndex = 0; - void handleSelection(); -}; diff --git a/src/activities/settings/OpdsServerListActivity.cpp b/src/activities/settings/OpdsServerListActivity.cpp new file mode 100644 index 000000000..52987d250 --- /dev/null +++ b/src/activities/settings/OpdsServerListActivity.cpp @@ -0,0 +1,133 @@ +#include "OpdsServerListActivity.h" + +#include +#include + +#include "MappedInputManager.h" +#include "OpdsServerStore.h" +#include "OpdsSettingsActivity.h" +#include "activities/ActivityManager.h" +#include "activities/browser/OpdsBookBrowserActivity.h" +#include "components/UITheme.h" +#include "fontIds.h" + +int OpdsServerListActivity::getItemCount() const { + int count = static_cast(OPDS_STORE.getCount()); + // In settings mode, append a virtual "Add Server" item; in picker mode, only show real servers + if (!pickerMode) { + count++; + } + return count; +} + +void OpdsServerListActivity::onEnter() { + Activity::onEnter(); + + // Reload from disk in case servers were added/removed by a subactivity or the web UI + OPDS_STORE.loadFromFile(); + selectedIndex = 0; + requestUpdate(); +} + +void OpdsServerListActivity::onExit() { Activity::onExit(); } + +void OpdsServerListActivity::loop() { + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + if (pickerMode) { + activityManager.goHome(); + } else { + finish(); + } + return; + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { + handleSelection(); + return; + } + + const int itemCount = getItemCount(); + if (itemCount > 0) { + buttonNavigator.onNext([this, itemCount] { + selectedIndex = ButtonNavigator::nextIndex(selectedIndex, itemCount); + requestUpdate(); + }); + + buttonNavigator.onPrevious([this, itemCount] { + selectedIndex = ButtonNavigator::previousIndex(selectedIndex, itemCount); + requestUpdate(); + }); + } +} + +void OpdsServerListActivity::handleSelection() { + const auto serverCount = static_cast(OPDS_STORE.getCount()); + + if (pickerMode) { + // Picker mode: selecting a server navigates to the OPDS browser + if (selectedIndex < serverCount) { + const auto* server = OPDS_STORE.getServer(static_cast(selectedIndex)); + if (server) { + activityManager.replaceActivity(std::make_unique(renderer, mappedInput, *server)); + } + } + return; + } + + // Settings mode: open editor for selected server, or create a new one + auto resultHandler = [this](const ActivityResult&) { + // Reload server list when returning from editor + OPDS_STORE.loadFromFile(); + selectedIndex = 0; + }; + + if (selectedIndex < serverCount) { + startActivityForResult(std::make_unique(renderer, mappedInput, selectedIndex), resultHandler); + } else { + startActivityForResult(std::make_unique(renderer, mappedInput, -1), resultHandler); + } +} + +void OpdsServerListActivity::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_OPDS_SERVERS)); + + const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; + const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2; + const int itemCount = getItemCount(); + + if (itemCount == 0) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, tr(STR_NO_SERVERS)); + } else { + const auto& servers = OPDS_STORE.getServers(); + const auto serverCount = static_cast(servers.size()); + + // Primary label: server name (falling back to URL if unnamed). + // Secondary label: server URL (shown as subtitle when name is set). + GUI.drawList( + renderer, Rect{0, contentTop, pageWidth, contentHeight}, itemCount, selectedIndex, + [&servers, serverCount](int index) { + if (index < serverCount) { + const auto& server = servers[index]; + return server.name.empty() ? server.url : server.name; + } + return std::string(I18n::getInstance().get(StrId::STR_ADD_SERVER)); + }, + [&servers, serverCount](int index) { + if (index < serverCount && !servers[index].name.empty()) { + return servers[index].url; + } + return std::string(""); + }); + } + + 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/OpdsServerListActivity.h b/src/activities/settings/OpdsServerListActivity.h new file mode 100644 index 000000000..b03c3a92e --- /dev/null +++ b/src/activities/settings/OpdsServerListActivity.h @@ -0,0 +1,29 @@ +#pragma once + +#include "activities/Activity.h" +#include "util/ButtonNavigator.h" + +/** + * Activity showing the list of configured OPDS servers. + * Allows adding new servers and editing/deleting existing ones. + * When pickerMode is true, selecting a server navigates to the OPDS browser + * instead of opening the editor (used from the home screen). + */ +class OpdsServerListActivity final : public Activity { + public: + explicit OpdsServerListActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, bool pickerMode = false) + : Activity("OpdsServerList", renderer, mappedInput), pickerMode(pickerMode) {} + + void onEnter() override; + void onExit() override; + void loop() override; + void render(RenderLock&&) override; + + private: + ButtonNavigator buttonNavigator; + int selectedIndex = 0; + bool pickerMode = false; + + int getItemCount() const; + void handleSelection(); +}; diff --git a/src/activities/settings/OpdsSettingsActivity.cpp b/src/activities/settings/OpdsSettingsActivity.cpp new file mode 100644 index 000000000..ec07c1f9d --- /dev/null +++ b/src/activities/settings/OpdsSettingsActivity.cpp @@ -0,0 +1,222 @@ +#include "OpdsSettingsActivity.h" + +#include +#include +#include + +#include + +#include "MappedInputManager.h" +#include "OpdsServerStore.h" +#include "activities/util/KeyboardEntryActivity.h" +#include "components/UITheme.h" +#include "fontIds.h" + +namespace { +// Editable fields: Name, URL, Username, Password. +// Existing servers also show a Delete option (BASE_ITEMS + 1). +constexpr int BASE_ITEMS = 4; +} // namespace + +int OpdsSettingsActivity::getMenuItemCount() const { + return isNewServer ? BASE_ITEMS : BASE_ITEMS + 1; // +1 for Delete +} + +void OpdsSettingsActivity::onEnter() { + Activity::onEnter(); + + selectedIndex = 0; + isNewServer = (serverIndex < 0); + showSaveError = false; + + if (!isNewServer) { + // Edit flow: copy the selected server into local editable state. + // Changes are persisted field-by-field through saveServer(). + const auto* server = OPDS_STORE.getServer(static_cast(serverIndex)); + if (server) { + editServer = *server; + } else { + // Server was deleted between navigation and entering this screen — treat as new + isNewServer = true; + serverIndex = -1; + } + } + + requestUpdate(); +} + +void OpdsSettingsActivity::onExit() { Activity::onExit(); } + +void OpdsSettingsActivity::loop() { + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + finish(); + return; + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { + handleSelection(); + return; + } + + const int menuItems = getMenuItemCount(); + buttonNavigator.onNext([this, menuItems] { + selectedIndex = (selectedIndex + 1) % menuItems; + requestUpdate(); + }); + + buttonNavigator.onPrevious([this, menuItems] { + selectedIndex = (selectedIndex + menuItems - 1) % menuItems; + requestUpdate(); + }); +} + +bool OpdsSettingsActivity::saveServer() { + bool success = false; + + if (isNewServer) { + // Create flow: first save inserts a new server record into the multi-server store. + success = OPDS_STORE.addServer(editServer); + if (success) { + // After the first successful save, promote to an existing server so + // subsequent field edits update in-place rather than creating duplicates. + isNewServer = false; + serverIndex = static_cast(OPDS_STORE.getCount()) - 1; + } else { + LOG_ERR("OPS", "Failed to add OPDS server"); + } + } else { + // Edit flow: update the same server entry in-place. + success = OPDS_STORE.updateServer(static_cast(serverIndex), editServer); + if (!success) { + LOG_ERR("OPS", "Failed to update OPDS server at index %d", serverIndex); + } + } + + showSaveError = !success; + if (showSaveError) { + requestUpdate(); + } + + return success; +} + +void OpdsSettingsActivity::handleSelection() { + // Each field edit is saved immediately so partially configured servers + // survive navigation and power-loss scenarios. + if (selectedIndex == 0) { + // Server Name + auto handler = [this](const ActivityResult& result) { + if (!result.isCancelled) { + const auto& kb = std::get(result.data); + editServer.name = kb.text; + saveServer(); + requestUpdate(); + } + }; + startActivityForResult(std::make_unique(renderer, mappedInput, tr(STR_SERVER_NAME), + editServer.name, 63, InputType::Text), + handler); + } else if (selectedIndex == 1) { + // Server URL + const std::string prefillUrl = editServer.url.empty() ? "https://" : editServer.url; + auto handler = [this](const ActivityResult& result) { + if (!result.isCancelled) { + const auto& kb = std::get(result.data); + editServer.url = (kb.text == "https://" || kb.text == "http://") ? "" : kb.text; + saveServer(); + requestUpdate(); + } + }; + startActivityForResult(std::make_unique(renderer, mappedInput, tr(STR_OPDS_SERVER_URL), + prefillUrl, 127, InputType::Url), + handler); + } else if (selectedIndex == 2) { + // Username + auto handler = [this](const ActivityResult& result) { + if (!result.isCancelled) { + const auto& kb = std::get(result.data); + editServer.username = kb.text; + saveServer(); + requestUpdate(); + } + }; + startActivityForResult(std::make_unique(renderer, mappedInput, tr(STR_USERNAME), + editServer.username, 63, InputType::Text), + handler); + } else if (selectedIndex == 3) { + // Password + auto handler = [this](const ActivityResult& result) { + if (!result.isCancelled) { + const auto& kb = std::get(result.data); + editServer.password = kb.text; + saveServer(); + requestUpdate(); + } + }; + startActivityForResult(std::make_unique(renderer, mappedInput, tr(STR_PASSWORD), + editServer.password, 63, InputType::Password), + handler); + } else if (selectedIndex == 4 && !isNewServer) { + // Delete flow is only available for existing servers. + if (!OPDS_STORE.removeServer(static_cast(serverIndex))) { + LOG_ERR("OPS", "Failed to remove OPDS server at index %d", serverIndex); + showSaveError = true; + requestUpdate(); + return; + } + finish(); + } +} + +void OpdsSettingsActivity::render(RenderLock&&) { + renderer.clearScreen(); + + const auto& metrics = UITheme::getInstance().getMetrics(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + // Reuse STR_OPDS_BROWSER as the "edit existing server" title. + // New server creation uses STR_ADD_SERVER. + const char* header = isNewServer ? tr(STR_ADD_SERVER) : tr(STR_OPDS_BROWSER); + GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, header); + GUI.drawSubHeader(renderer, Rect{0, metrics.topPadding + metrics.headerHeight, pageWidth, metrics.tabBarHeight}, + tr(STR_CALIBRE_URL_HINT)); + + const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing + metrics.tabBarHeight; + const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing * 2; + const int menuItems = getMenuItemCount(); + + const StrId fieldNames[] = {StrId::STR_SERVER_NAME, StrId::STR_OPDS_SERVER_URL, StrId::STR_USERNAME, + StrId::STR_PASSWORD}; + + GUI.drawList( + renderer, Rect{0, contentTop, pageWidth, contentHeight}, menuItems, static_cast(selectedIndex), + [this, &fieldNames](int index) { + if (index < BASE_ITEMS) { + return std::string(I18N.get(fieldNames[index])); + } + return std::string(tr(STR_DELETE_SERVER)); + }, + nullptr, nullptr, + [this](int index) { + if (index == 0) { + return editServer.name.empty() ? std::string(tr(STR_NOT_SET)) : editServer.name; + } else if (index == 1) { + return editServer.url.empty() ? std::string(tr(STR_NOT_SET)) : editServer.url; + } else if (index == 2) { + return editServer.username.empty() ? std::string(tr(STR_NOT_SET)) : editServer.username; + } else if (index == 3) { + return editServer.password.empty() ? std::string(tr(STR_NOT_SET)) : std::string("******"); + } + return std::string(""); + }, + 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); + + if (showSaveError) { + GUI.drawPopup(renderer, tr(STR_ERROR_GENERAL_FAILURE)); + } + + renderer.displayBuffer(); +} diff --git a/src/activities/settings/OpdsSettingsActivity.h b/src/activities/settings/OpdsSettingsActivity.h new file mode 100644 index 000000000..4ea2d61b6 --- /dev/null +++ b/src/activities/settings/OpdsSettingsActivity.h @@ -0,0 +1,37 @@ +#pragma once + +#include "OpdsServerStore.h" +#include "activities/Activity.h" +#include "util/ButtonNavigator.h" + +/** + * Edit screen for a single OPDS server. + * Shows Name, URL, Username, Password fields and a Delete option. + * Used for both adding new servers and editing existing ones. + */ +class OpdsSettingsActivity final : public Activity { + public: + /** + * @param serverIndex Index into OpdsServerStore, or -1 for a new server + */ + explicit OpdsSettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, int serverIndex = -1) + : Activity("OpdsSettings", renderer, mappedInput), serverIndex(serverIndex) {} + + void onEnter() override; + void onExit() override; + void loop() override; + void render(RenderLock&&) override; + + private: + ButtonNavigator buttonNavigator; + + size_t selectedIndex = 0; + int serverIndex; + OpdsServer editServer; + bool isNewServer = false; + bool showSaveError = false; + + int getMenuItemCount() const; + void handleSelection(); + bool saveServer(); +}; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index e5824a89a..f93600123 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -4,12 +4,12 @@ #include #include "ButtonRemapActivity.h" -#include "CalibreSettingsActivity.h" #include "ClearCacheActivity.h" #include "CrossPointSettings.h" #include "KOReaderSettingsActivity.h" #include "LanguageSelectActivity.h" #include "MappedInputManager.h" +#include "OpdsServerListActivity.h" #include "OtaUpdateActivity.h" #include "SettingsList.h" #include "StatusBarSettingsActivity.h" @@ -48,7 +48,7 @@ void SettingsActivity::onEnter() { SettingInfo::Action(StrId::STR_REMAP_FRONT_BUTTONS, SettingAction::RemapFrontButtons)); 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_OPDS_BROWSER, SettingAction::OPDSBrowser)); + 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)); systemSettings.push_back(SettingInfo::Action(StrId::STR_LANGUAGE, SettingAction::Language)); @@ -178,7 +178,7 @@ void SettingsActivity::toggleCurrentSetting() { startActivityForResult(std::make_unique(renderer, mappedInput), resultHandler); break; case SettingAction::OPDSBrowser: - startActivityForResult(std::make_unique(renderer, mappedInput), resultHandler); + startActivityForResult(std::make_unique(renderer, mappedInput), resultHandler); break; case SettingAction::Network: startActivityForResult(std::make_unique(renderer, mappedInput, false), resultHandler); diff --git a/src/main.cpp b/src/main.cpp index 4aeb5a855..a3929d069 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -19,6 +19,7 @@ #include "CrossPointState.h" #include "KOReaderCredentialStore.h" #include "MappedInputManager.h" +#include "OpdsServerStore.h" #include "RecentBooksStore.h" #include "activities/Activity.h" #include "activities/ActivityManager.h" @@ -257,6 +258,7 @@ void setup() { SETTINGS.loadFromFile(); I18N.loadSettings(); KOREADER_STORE.loadFromFile(); + OPDS_STORE.loadFromFile(); UITheme::getInstance().reload(); ButtonNavigator::setMappedInputManager(mappedInputManager); diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index d18eebd13..0ad2f1ab9 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -11,6 +11,7 @@ #include #include "CrossPointSettings.h" +#include "OpdsServerStore.h" #include "SettingsList.h" #include "WebDAVHandler.h" #include "html/FilesPageHtml.generated.h" @@ -160,6 +161,11 @@ void CrossPointWebServer::begin() { server->on("/api/settings", HTTP_GET, [this] { handleGetSettings(); }); server->on("/api/settings", HTTP_POST, [this] { handlePostSettings(); }); + // OPDS server endpoints + server->on("/api/opds", HTTP_GET, [this] { handleGetOpdsServers(); }); + server->on("/api/opds", HTTP_POST, [this] { handlePostOpdsServer(); }); + server->on("/api/opds/delete", HTTP_POST, [this] { handleDeleteOpdsServer(); }); + server->onNotFound([this] { handleNotFound(); }); LOG_DBG("WEB", "[MEM] Free heap after route setup: %d bytes", ESP.getFreeHeap()); @@ -1244,6 +1250,122 @@ void CrossPointWebServer::handlePostSettings() { server->send(200, "text/plain", String("Applied ") + String(applied) + " setting(s)"); } +// ---- OPDS Server API ---- + +void CrossPointWebServer::handleGetOpdsServers() const { + const auto& servers = OPDS_STORE.getServers(); + + // Stream JSON array incrementally to avoid allocating the full response in memory + server->setContentLength(CONTENT_LENGTH_UNKNOWN); + server->send(200, "application/json", ""); + server->sendContent("["); + + char output[512]; + constexpr size_t outputSize = sizeof(output); + JsonDocument doc; + + for (size_t i = 0; i < servers.size(); i++) { + doc.clear(); + doc["index"] = i; + doc["name"] = servers[i].name; + doc["url"] = servers[i].url; + doc["username"] = servers[i].username; + // Never expose passwords over the API — only indicate whether one is set + doc["hasPassword"] = !servers[i].password.empty(); + + const size_t written = serializeJson(doc, output, outputSize); + if (written >= outputSize) continue; + + if (i > 0) server->sendContent(","); + server->sendContent(output); + } + + server->sendContent("]"); + server->sendContent(""); + LOG_DBG("WEB", "Served OPDS servers API (%zu servers)", servers.size()); +} + +void CrossPointWebServer::handlePostOpdsServer() { + if (!server->hasArg("plain")) { + server->send(400, "text/plain", "Missing JSON body"); + return; + } + + const String body = server->arg("plain"); + JsonDocument doc; + const DeserializationError err = deserializeJson(doc, body); + if (err) { + server->send(400, "text/plain", String("Invalid JSON: ") + err.c_str()); + return; + } + + OpdsServer opdsServer; + opdsServer.name = doc["name"] | std::string(""); + opdsServer.url = doc["url"] | std::string(""); + opdsServer.username = doc["username"] | std::string(""); + + // The password field is optional in the JSON payload. When absent (vs. present but empty), + // we preserve the existing password — the web UI omits it when the user hasn't changed it. + bool hasPasswordField = doc["password"].is() || doc["password"].is(); + std::string password = doc["password"] | std::string(""); + + if (doc["index"].is()) { + int idx = doc["index"].as(); + if (idx < 0 || idx >= static_cast(OPDS_STORE.getCount())) { + server->send(400, "text/plain", "Invalid server index"); + return; + } + // Preserve existing password if not explicitly provided + if (!hasPasswordField) { + const auto* existing = OPDS_STORE.getServer(static_cast(idx)); + if (existing) password = existing->password; + } + opdsServer.password = password; + OPDS_STORE.updateServer(static_cast(idx), opdsServer); + LOG_DBG("WEB", "Updated OPDS server at index %d", idx); + } else { + opdsServer.password = password; + if (!OPDS_STORE.addServer(opdsServer)) { + server->send(400, "text/plain", "Cannot add server (limit reached)"); + return; + } + LOG_DBG("WEB", "Added new OPDS server: %s", opdsServer.name.c_str()); + } + + server->send(200, "text/plain", "OK"); +} + +// Uses POST (not HTTP DELETE) because ESP32 WebServer doesn't support DELETE with body. +void CrossPointWebServer::handleDeleteOpdsServer() { + if (!server->hasArg("plain")) { + server->send(400, "text/plain", "Missing JSON body"); + return; + } + + const String body = server->arg("plain"); + JsonDocument doc; + const DeserializationError err = deserializeJson(doc, body); + if (err) { + server->send(400, "text/plain", String("Invalid JSON: ") + err.c_str()); + return; + } + + if (!doc["index"].is()) { + server->send(400, "text/plain", "Missing index"); + return; + } + + int idx = doc["index"].as(); + if (idx < 0 || idx >= static_cast(OPDS_STORE.getCount())) { + server->send(400, "text/plain", "Invalid server index"); + return; + } + + OPDS_STORE.removeServer(static_cast(idx)); + LOG_DBG("WEB", "Deleted OPDS server at index %d", idx); + server->send(200, "text/plain", "OK"); +} + // WebSocket callback trampoline void CrossPointWebServer::wsEventCallback(uint8_t num, WStype_t type, uint8_t* payload, size_t length) { if (wsInstance) { diff --git a/src/network/CrossPointWebServer.h b/src/network/CrossPointWebServer.h index 6568d30ac..aac60e19e 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -107,4 +107,9 @@ class CrossPointWebServer { void handleSettingsPage() const; void handleGetSettings() const; void handlePostSettings(); + + // OPDS server handlers + void handleGetOpdsServers() const; + void handlePostOpdsServer(); + void handleDeleteOpdsServer(); }; diff --git a/src/network/HttpDownloader.cpp b/src/network/HttpDownloader.cpp index 799b88746..b881c859c 100644 --- a/src/network/HttpDownloader.cpp +++ b/src/network/HttpDownloader.cpp @@ -11,7 +11,6 @@ #include #include -#include "CrossPointSettings.h" #include "util/UrlUtils.h" namespace { @@ -52,8 +51,8 @@ class FileWriteStream final : public Stream { }; } // namespace -bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent) { - // Use NetworkClientSecure for HTTPS, regular NetworkClient for HTTP +bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent, const std::string& username, + const std::string& password) { std::unique_ptr client; if (UrlUtils::isHttpsUrl(url)) { auto* secureClient = new NetworkClientSecure(); @@ -70,9 +69,8 @@ bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent) { http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION); - // Add Basic HTTP auth if credentials are configured - if (strlen(SETTINGS.opdsUsername) > 0 && strlen(SETTINGS.opdsPassword) > 0) { - std::string credentials = std::string(SETTINGS.opdsUsername) + ":" + SETTINGS.opdsPassword; + if (!username.empty() && !password.empty()) { + std::string credentials = username + ":" + password; String encoded = base64::encode(credentials.c_str()); http.addHeader("Authorization", "Basic " + encoded); } @@ -92,9 +90,10 @@ bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent) { return true; } -bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent) { +bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent, const std::string& username, + const std::string& password) { StreamString stream; - if (!fetchUrl(url, stream)) { + if (!fetchUrl(url, stream, username, password)) { return false; } outContent = stream.c_str(); @@ -102,8 +101,8 @@ bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent) { } HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string& url, const std::string& destPath, - ProgressCallback progress) { - // Use NetworkClientSecure for HTTPS, regular NetworkClient for HTTP + ProgressCallback progress, const std::string& username, + const std::string& password) { std::unique_ptr client; if (UrlUtils::isHttpsUrl(url)) { auto* secureClient = new NetworkClientSecure(); @@ -121,9 +120,8 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string& http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION); - // Add Basic HTTP auth if credentials are configured - if (strlen(SETTINGS.opdsUsername) > 0 && strlen(SETTINGS.opdsPassword) > 0) { - std::string credentials = std::string(SETTINGS.opdsUsername) + ":" + SETTINGS.opdsPassword; + if (!username.empty() && !password.empty()) { + std::string credentials = username + ":" + password; String encoded = base64::encode(credentials.c_str()); http.addHeader("Authorization", "Basic " + encoded); } diff --git a/src/network/HttpDownloader.h b/src/network/HttpDownloader.h index a8f1cd252..216840a57 100644 --- a/src/network/HttpDownloader.h +++ b/src/network/HttpDownloader.h @@ -20,22 +20,18 @@ class HttpDownloader { }; /** - * Fetch text content from a URL. - * @param url The URL to fetch - * @param outContent The fetched content (output) - * @return true if fetch succeeded, false on error + * Fetch text content from a URL with optional credentials. */ - static bool fetchUrl(const std::string& url, std::string& outContent); + static bool fetchUrl(const std::string& url, std::string& outContent, const std::string& username = "", + const std::string& password = ""); - static bool fetchUrl(const std::string& url, Stream& stream); + static bool fetchUrl(const std::string& url, Stream& stream, const std::string& username = "", + const std::string& password = ""); /** - * Download a file to the SD card. - * @param url The URL to download - * @param destPath The destination path on SD card - * @param progress Optional progress callback - * @return DownloadError indicating success or failure type + * Download a file to the SD card with optional credentials. */ static DownloadError downloadToFile(const std::string& url, const std::string& destPath, - ProgressCallback progress = nullptr); + ProgressCallback progress = nullptr, const std::string& username = "", + const std::string& password = ""); }; diff --git a/src/network/html/SettingsPage.html b/src/network/html/SettingsPage.html index fa1f06544..9b9ac8ec4 100644 --- a/src/network/html/SettingsPage.html +++ b/src/network/html/SettingsPage.html @@ -207,6 +207,48 @@ from { transform: rotate(0deg); } to { transform: rotate(360deg); } } + .opds-server { + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 12px; + margin: 10px 0; + } + .opds-server .setting-row:last-child { + border-bottom: none; + } + .opds-actions { + display: flex; + gap: 8px; + margin-top: 8px; + } + .btn-small { + padding: 6px 14px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.9em; + } + .btn-add { + background-color: var(--accent-color); + color: white; + } + .btn-add:hover { + background-color: var(--accent-hover-color); + } + .btn-delete { + background-color: #e74c3c; + color: white; + } + .btn-delete:hover { + background-color: #c0392b; + } + .btn-save-server { + background-color: #27ae60; + color: white; + } + .btn-save-server:hover { + background-color: #219a52; + } @media (max-width: 600px) { body { padding: 10px; @@ -257,6 +299,8 @@ +
+

CrossPoint E-Reader • Open Source @@ -435,6 +479,122 @@ } loadSettings(); + + // --- OPDS Server Management --- + // Dynamically renders an editable list of OPDS servers, communicating with the + // /api/opds REST endpoints. Password fields are never pre-filled for security; + // the "(unchanged)" placeholder indicates an existing password is preserved on save. + let opdsServers = []; + + function renderOpdsServer(srv, idx) { + const isNew = idx === -1; + const id = isNew ? 'new' : idx; + return '

' + + '
' + + 'Server Name' + + '' + + '
' + + '
' + + 'URL' + + '' + + '
' + + '
' + + 'Username' + + '' + + '
' + + '
' + + 'Password' + + '' + + '
' + + '
' + + '' + + (isNew ? '' : '') + + '
' + + '
'; + } + + function renderOpdsSection() { + const container = document.getElementById('opds-container'); + let html = '

OPDS Servers

'; + + if (opdsServers.length === 0) { + html += '

No OPDS servers configured

'; + } else { + opdsServers.forEach(function(srv, idx) { + html += renderOpdsServer(srv, idx); + }); + } + + html += '
' + + '' + + '
'; + container.innerHTML = html; + } + + async function loadOpdsServers() { + try { + const resp = await fetch('/api/opds'); + if (!resp.ok) throw new Error('Failed to load'); + opdsServers = await resp.json(); + renderOpdsSection(); + } catch (e) { + console.error('OPDS load error:', e); + } + } + + function addOpdsServer() { + const container = document.getElementById('opds-container'); + const card = container.querySelector('.card'); + const addBtn = card.querySelector('.btn-add').parentElement; + // Prevent multiple unsaved new-server forms at once (idx -1 → id "new") + if (document.getElementById('opds-new')) return; + addBtn.insertAdjacentHTML('beforebegin', renderOpdsServer({name:'',url:'',username:'',hasPassword:false}, -1)); + } + + async function saveOpdsServer(idx) { + const id = idx === -1 ? 'new' : idx; + const data = { + name: document.getElementById('opds-name-' + id).value, + url: document.getElementById('opds-url-' + id).value, + username: document.getElementById('opds-user-' + id).value, + }; + // Only include password in payload when the user actually typed something; + // omitting it tells the server to keep the existing password. + const pass = document.getElementById('opds-pass-' + id).value; + if (pass) data.password = pass; + if (idx >= 0) data.index = idx; + + try { + const resp = await fetch('/api/opds', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(data) + }); + if (!resp.ok) throw new Error(await resp.text()); + showMessage('OPDS server saved!', false); + await loadOpdsServers(); + } catch (e) { + showMessage('Error: ' + e.message, true); + } + } + + async function deleteOpdsServer(idx) { + if (!confirm('Delete this OPDS server?')) return; + try { + const resp = await fetch('/api/opds/delete', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({index: idx}) + }); + if (!resp.ok) throw new Error(await resp.text()); + showMessage('OPDS server deleted', false); + await loadOpdsServers(); + } catch (e) { + showMessage('Error: ' + e.message, true); + } + } + + loadOpdsServers();