diff --git a/lib/I18n/I18nKeys.h b/lib/I18n/I18nKeys.h index ed93497a..140d694e 100644 --- a/lib/I18n/I18nKeys.h +++ b/lib/I18n/I18nKeys.h @@ -416,6 +416,12 @@ enum class StrId : uint16_t { STR_ACTION_FAILED, STR_BACK_TO_BEGINNING, STR_CLOSE_MENU, + STR_ADD_SERVER, + STR_SERVER_NAME, + STR_NO_SERVERS, + STR_DELETE_SERVER, + STR_DELETE_CONFIRM, + STR_OPDS_SERVERS, // Sentinel - must be last _COUNT }; diff --git a/lib/I18n/translations/czech.yaml b/lib/I18n/translations/czech.yaml index f2fa07f4..cebc9330 100644 --- a/lib/I18n/translations/czech.yaml +++ b/lib/I18n/translations/czech.yaml @@ -344,3 +344,9 @@ STR_INDEXING_STATUS_ICON: "Ikona stavového řádku" STR_SYNC_CLOCK: "Sync Clock" STR_TIME_SYNCED: "Time synced!" STR_AUTO_NTP_SYNC: "Auto Sync on Boot" +STR_ADD_SERVER: "Přidat server" +STR_SERVER_NAME: "Název serveru" +STR_NO_SERVERS: "Žádné OPDS servery nejsou nakonfigurovány" +STR_DELETE_SERVER: "Smazat server" +STR_DELETE_CONFIRM: "Smazat tento server?" +STR_OPDS_SERVERS: "OPDS servery" diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 7ebbc04e..b8cd5bb0 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -380,3 +380,9 @@ STR_BOOK_REINDEXED: "Book reindexed" STR_ACTION_FAILED: "Action failed" STR_BACK_TO_BEGINNING: "Back to Beginning" STR_CLOSE_MENU: "Close Menu" +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" diff --git a/lib/I18n/translations/french.yaml b/lib/I18n/translations/french.yaml index 5704cbcb..faab4577 100644 --- a/lib/I18n/translations/french.yaml +++ b/lib/I18n/translations/french.yaml @@ -344,3 +344,9 @@ STR_INDEXING_STATUS_ICON: "Icône barre d'état" STR_SYNC_CLOCK: "Sync Clock" STR_TIME_SYNCED: "Time synced!" STR_AUTO_NTP_SYNC: "Auto Sync on Boot" +STR_ADD_SERVER: "Ajouter un serveur" +STR_SERVER_NAME: "Nom du serveur" +STR_NO_SERVERS: "Aucun serveur OPDS configuré" +STR_DELETE_SERVER: "Supprimer le serveur" +STR_DELETE_CONFIRM: "Supprimer ce serveur ?" +STR_OPDS_SERVERS: "Serveurs OPDS" diff --git a/lib/I18n/translations/german.yaml b/lib/I18n/translations/german.yaml index e7aeb78b..f5f40a27 100644 --- a/lib/I18n/translations/german.yaml +++ b/lib/I18n/translations/german.yaml @@ -344,3 +344,9 @@ STR_INDEXING_STATUS_ICON: "Statusleistensymbol" STR_SYNC_CLOCK: "Sync Clock" STR_TIME_SYNCED: "Time synced!" STR_AUTO_NTP_SYNC: "Auto Sync on Boot" +STR_ADD_SERVER: "Server hinzufügen" +STR_SERVER_NAME: "Servername" +STR_NO_SERVERS: "Keine OPDS-Server konfiguriert" +STR_DELETE_SERVER: "Server löschen" +STR_DELETE_CONFIRM: "Diesen Server löschen?" +STR_OPDS_SERVERS: "OPDS-Server" diff --git a/lib/I18n/translations/portuguese.yaml b/lib/I18n/translations/portuguese.yaml index 7f5329cb..7cff8584 100644 --- a/lib/I18n/translations/portuguese.yaml +++ b/lib/I18n/translations/portuguese.yaml @@ -344,3 +344,9 @@ STR_INDEXING_STATUS_ICON: "Ícone da barra" STR_SYNC_CLOCK: "Sync Clock" STR_TIME_SYNCED: "Time synced!" STR_AUTO_NTP_SYNC: "Auto Sync on Boot" +STR_ADD_SERVER: "Adicionar servidor" +STR_SERVER_NAME: "Nome do servidor" +STR_NO_SERVERS: "Nenhum servidor OPDS configurado" +STR_DELETE_SERVER: "Excluir servidor" +STR_DELETE_CONFIRM: "Excluir este servidor?" +STR_OPDS_SERVERS: "Servidores OPDS" diff --git a/lib/I18n/translations/romanian.yaml b/lib/I18n/translations/romanian.yaml index 21d98283..bc8301fc 100644 --- a/lib/I18n/translations/romanian.yaml +++ b/lib/I18n/translations/romanian.yaml @@ -319,3 +319,9 @@ STR_OPDS_SERVER_URL: "URL server OPDS" STR_SYNC_CLOCK: "Sync Clock" STR_TIME_SYNCED: "Time synced!" STR_AUTO_NTP_SYNC: "Auto Sync on Boot" +STR_ADD_SERVER: "Adaugă server" +STR_SERVER_NAME: "Numele serverului" +STR_NO_SERVERS: "Niciun server OPDS configurat" +STR_DELETE_SERVER: "Șterge serverul" +STR_DELETE_CONFIRM: "Ștergi acest server?" +STR_OPDS_SERVERS: "Servere OPDS" diff --git a/lib/I18n/translations/russian.yaml b/lib/I18n/translations/russian.yaml index d6ebf82a..ed198678 100644 --- a/lib/I18n/translations/russian.yaml +++ b/lib/I18n/translations/russian.yaml @@ -344,3 +344,9 @@ STR_INDEXING_STATUS_ICON: "Иконка в строке" STR_SYNC_CLOCK: "Sync Clock" STR_TIME_SYNCED: "Time synced!" STR_AUTO_NTP_SYNC: "Auto Sync on Boot" +STR_ADD_SERVER: "Добавить сервер" +STR_SERVER_NAME: "Имя сервера" +STR_NO_SERVERS: "Нет настроенных серверов OPDS" +STR_DELETE_SERVER: "Удалить сервер" +STR_DELETE_CONFIRM: "Удалить этот сервер?" +STR_OPDS_SERVERS: "Серверы OPDS" diff --git a/lib/I18n/translations/spanish.yaml b/lib/I18n/translations/spanish.yaml index de88e2fa..ba4431ce 100644 --- a/lib/I18n/translations/spanish.yaml +++ b/lib/I18n/translations/spanish.yaml @@ -344,3 +344,9 @@ STR_INDEXING_STATUS_ICON: "Icono barra estado" STR_SYNC_CLOCK: "Sync Clock" STR_TIME_SYNCED: "Time synced!" STR_AUTO_NTP_SYNC: "Auto Sync on Boot" +STR_ADD_SERVER: "Añadir servidor" +STR_SERVER_NAME: "Nombre del servidor" +STR_NO_SERVERS: "No hay servidores OPDS configurados" +STR_DELETE_SERVER: "Eliminar servidor" +STR_DELETE_CONFIRM: "¿Eliminar este servidor?" +STR_OPDS_SERVERS: "Servidores OPDS" diff --git a/lib/I18n/translations/swedish.yaml b/lib/I18n/translations/swedish.yaml index 8d4036bf..1ae19c53 100644 --- a/lib/I18n/translations/swedish.yaml +++ b/lib/I18n/translations/swedish.yaml @@ -344,3 +344,9 @@ STR_INDEXING_STATUS_ICON: "Statusfältsikon" STR_SYNC_CLOCK: "Sync Clock" STR_TIME_SYNCED: "Time synced!" STR_AUTO_NTP_SYNC: "Auto Sync on Boot" +STR_ADD_SERVER: "Lägg till server" +STR_SERVER_NAME: "Servernamn" +STR_NO_SERVERS: "Inga OPDS-servrar konfigurerade" +STR_DELETE_SERVER: "Ta bort server" +STR_DELETE_CONFIRM: "Ta bort denna server?" +STR_OPDS_SERVERS: "OPDS-servrar" diff --git a/src/OpdsServerStore.cpp b/src/OpdsServerStore.cpp new file mode 100644 index 00000000..e616d167 --- /dev/null +++ b/src/OpdsServerStore.cpp @@ -0,0 +1,201 @@ +#include "OpdsServerStore.h" + +#include +#include +#include +#include +#include +#include + +#include + +#include "CrossPointSettings.h" + +OpdsServerStore OpdsServerStore::instance; + +namespace { +constexpr char OPDS_FILE_JSON[] = "/.crosspoint/opds.json"; +constexpr size_t HW_KEY_LEN = 6; + +const uint8_t* getHwKey() { + static uint8_t key[HW_KEY_LEN] = {}; + static bool initialized = false; + if (!initialized) { + esp_efuse_mac_get_default(key); + initialized = true; + } + return key; +} + +void xorTransform(std::string& data) { + const uint8_t* key = getHwKey(); + for (size_t i = 0; i < data.size(); i++) { + data[i] ^= key[i % HW_KEY_LEN]; + } +} + +String obfuscateToBase64(const std::string& plaintext) { + if (plaintext.empty()) return ""; + std::string temp = plaintext; + xorTransform(temp); + return base64::encode(reinterpret_cast(temp.data()), temp.size()); +} + +std::string deobfuscateFromBase64(const char* encoded, bool* ok) { + if (encoded == nullptr || encoded[0] == '\0') { + if (ok) *ok = false; + return ""; + } + if (ok) *ok = true; + size_t encodedLen = strlen(encoded); + size_t decodedLen = 0; + int ret = mbedtls_base64_decode(nullptr, 0, &decodedLen, reinterpret_cast(encoded), encodedLen); + if (ret != 0 && ret != MBEDTLS_ERR_BASE64_BUFFER_TOO_SMALL) { + LOG_ERR("OPS", "Base64 decode size query failed (ret=%d)", ret); + if (ok) *ok = false; + return ""; + } + std::string result(decodedLen, '\0'); + ret = mbedtls_base64_decode(reinterpret_cast(&result[0]), decodedLen, &decodedLen, + reinterpret_cast(encoded), encodedLen); + if (ret != 0) { + LOG_ERR("OPS", "Base64 decode failed (ret=%d)", ret); + if (ok) *ok = false; + return ""; + } + result.resize(decodedLen); + xorTransform(result); + return result; +} +} // namespace + +bool OpdsServerStore::saveToFile() const { + Storage.mkdir("/.crosspoint"); + + JsonDocument doc; + JsonArray arr = doc["servers"].to(); + for (const auto& server : servers) { + JsonObject obj = arr.add(); + obj["name"] = server.name; + obj["url"] = server.url; + obj["username"] = server.username; + obj["password_obf"] = obfuscateToBase64(server.password); + } + + String json; + serializeJson(doc, json); + return Storage.writeFile(OPDS_FILE_JSON, json); +} + +bool OpdsServerStore::loadFromFile() { + if (Storage.exists(OPDS_FILE_JSON)) { + String json = Storage.readFile(OPDS_FILE_JSON); + if (!json.isEmpty()) { + JsonDocument doc; + auto error = deserializeJson(doc, json.c_str()); + if (error) { + LOG_ERR("OPS", "JSON parse error: %s", error.c_str()); + return false; + } + + servers.clear(); + bool needsResave = false; + JsonArray arr = doc["servers"].as(); + for (JsonObject obj : arr) { + if (servers.size() >= MAX_SERVERS) break; + OpdsServer server; + server.name = obj["name"] | std::string(""); + server.url = obj["url"] | std::string(""); + server.username = obj["username"] | std::string(""); + + bool ok = false; + server.password = deobfuscateFromBase64(obj["password_obf"] | "", &ok); + if (!ok || server.password.empty()) { + server.password = obj["password"] | std::string(""); + if (!server.password.empty()) needsResave = true; + } + servers.push_back(std::move(server)); + } + + LOG_DBG("OPS", "Loaded %zu OPDS servers from file", servers.size()); + + if (needsResave) { + LOG_DBG("OPS", "Resaving JSON with obfuscated passwords"); + saveToFile(); + } + return true; + } + } + + // 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()) { + 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; + } + + 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 00000000..67d94f10 --- /dev/null +++ b/src/OpdsServerStore.h @@ -0,0 +1,51 @@ +#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 +}; + +/** + * 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; + + 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 f65c7d1c..36bbe50d 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -191,13 +191,5 @@ inline 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), }; } \ No newline at end of file diff --git a/src/activities/browser/OpdsBookBrowserActivity.cpp b/src/activities/browser/OpdsBookBrowserActivity.cpp index ed9cc29b..dfccc15d 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 "components/UITheme.h" @@ -142,7 +141,8 @@ void OpdsBookBrowserActivity::render(Activity::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); + 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) { renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str()); @@ -171,7 +171,9 @@ void OpdsBookBrowserActivity::render(Activity::RenderLock&&) { if (state == BrowserState::DOWNLOADING) { renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 40, tr(STR_DOWNLOADING)); - renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 10, statusMessage.c_str()); + const auto maxWidth = pageWidth - 40; + auto title = renderer.truncatedText(UI_10_FONT_ID, statusMessage.c_str(), maxWidth); + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 10, title.c_str()); if (downloadTotal > 0) { const int barWidth = pageWidth - 100; constexpr int barHeight = 20; @@ -225,22 +227,21 @@ void OpdsBookBrowserActivity::render(Activity::RenderLock&&) { } void OpdsBookBrowserActivity::fetchFeed(const std::string& path) { - const char* serverUrl = SETTINGS.opdsServerUrl; - if (strlen(serverUrl) == 0) { + if (server.url.empty()) { state = BrowserState::ERROR; errorMessage = tr(STR_NO_SERVER_URL); requestUpdate(); return; } - std::string url = UrlUtils::buildUrl(serverUrl, path); + std::string url = 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(); @@ -311,7 +312,7 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) { requestUpdate(); // Build full download URL - std::string downloadUrl = UrlUtils::buildUrl(SETTINGS.opdsServerUrl, book.href); + std::string downloadUrl = UrlUtils::buildUrl(server.url, book.href); // Create sanitized filename: "Title - Author.epub" or just "Title.epub" if no author std::string baseName = book.title; @@ -322,12 +323,14 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) { 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(); - }); + }, + server.username, server.password); if (result == HttpDownloader::OK) { LOG_DBG("OPDS", "Download complete: %s", filename.c_str()); diff --git a/src/activities/browser/OpdsBookBrowserActivity.h b/src/activities/browser/OpdsBookBrowserActivity.h index 3ee94f0a..6aaa582e 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.h +++ b/src/activities/browser/OpdsBookBrowserActivity.h @@ -6,6 +6,7 @@ #include #include "../ActivityWithSubactivity.h" +#include "OpdsServerStore.h" #include "util/ButtonNavigator.h" /** @@ -25,8 +26,8 @@ class OpdsBookBrowserActivity final : public ActivityWithSubactivity { }; explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, - const std::function& onGoHome) - : ActivityWithSubactivity("OpdsBookBrowser", renderer, mappedInput), onGoHome(onGoHome) {} + const std::function& onGoHome, const OpdsServer& server) + : ActivityWithSubactivity("OpdsBookBrowser", renderer, mappedInput), onGoHome(onGoHome), server(server) {} void onEnter() override; void onExit() override; @@ -46,6 +47,7 @@ class OpdsBookBrowserActivity final : public ActivityWithSubactivity { size_t downloadTotal = 0; const std::function onGoHome; + OpdsServer server; // Copied at construction — safe even if the store changes during browsing void checkAndConnectWifi(); void launchWifiSelection(); diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 6e79810d..f749dd2c 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -17,6 +17,7 @@ #include "CrossPointSettings.h" #include "CrossPointState.h" #include "MappedInputManager.h" +#include "OpdsServerStore.h" #include "RecentBooksStore.h" #include "components/UITheme.h" #include "fontIds.h" @@ -28,7 +29,7 @@ int HomeActivity::getMenuItemCount() const { if (!recentBooks.empty()) { count += recentBooks.size(); } - if (hasOpdsUrl) { + if (hasOpdsServers) { count++; } return count; @@ -128,8 +129,7 @@ void HomeActivity::loadRecentCovers(int coverHeight) { void HomeActivity::onEnter() { ActivityWithSubactivity::onEnter(); - // Check if OPDS browser URL is configured - hasOpdsUrl = strlen(SETTINGS.opdsServerUrl) > 0; + hasOpdsServers = OPDS_STORE.hasServers(); selectorIndex = 0; @@ -238,7 +238,7 @@ void HomeActivity::loop() { int menuSelectedIndex = selectorIndex - static_cast(recentBooks.size()); const int myLibraryIdx = 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; @@ -277,7 +277,7 @@ void HomeActivity::render(Activity::RenderLock&&) { tr(STR_SETTINGS_TITLE)}; std::vector menuIcons = {Folder, Recent, Transfer, Settings}; - if (hasOpdsUrl) { + if (hasOpdsServers) { // Insert OPDS Browser after My Library 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 ca0d43f4..b3ae29a7 100644 --- a/src/activities/home/HomeActivity.h +++ b/src/activities/home/HomeActivity.h @@ -15,7 +15,7 @@ class HomeActivity final : public ActivityWithSubactivity { 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 d6981e9f..00000000 --- a/src/activities/settings/CalibreSettingsActivity.cpp +++ /dev/null @@ -1,149 +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() { - ActivityWithSubactivity::onEnter(); - - selectedIndex = 0; - requestUpdate(); -} - -void CalibreSettingsActivity::onExit() { ActivityWithSubactivity::onExit(); } - -void CalibreSettingsActivity::loop() { - if (subActivity) { - subActivity->loop(); - return; - } - - if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { - onBack(); - 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 - exitActivity(); - enterNewActivity(new KeyboardEntryActivity( - renderer, mappedInput, tr(STR_CALIBRE_WEB_URL), SETTINGS.opdsServerUrl, - 127, // maxLength - false, // not password - [this](const std::string& url) { - strncpy(SETTINGS.opdsServerUrl, url.c_str(), sizeof(SETTINGS.opdsServerUrl) - 1); - SETTINGS.opdsServerUrl[sizeof(SETTINGS.opdsServerUrl) - 1] = '\0'; - SETTINGS.saveToFile(); - exitActivity(); - requestUpdate(); - }, - [this]() { - exitActivity(); - requestUpdate(); - })); - } else if (selectedIndex == 1) { - // Username - exitActivity(); - enterNewActivity(new KeyboardEntryActivity( - renderer, mappedInput, tr(STR_USERNAME), SETTINGS.opdsUsername, - 63, // maxLength - false, // not password - [this](const std::string& username) { - strncpy(SETTINGS.opdsUsername, username.c_str(), sizeof(SETTINGS.opdsUsername) - 1); - SETTINGS.opdsUsername[sizeof(SETTINGS.opdsUsername) - 1] = '\0'; - SETTINGS.saveToFile(); - exitActivity(); - requestUpdate(); - }, - [this]() { - exitActivity(); - requestUpdate(); - })); - } else if (selectedIndex == 2) { - // Password - exitActivity(); - enterNewActivity(new KeyboardEntryActivity( - renderer, mappedInput, tr(STR_PASSWORD), SETTINGS.opdsPassword, - 63, // maxLength - false, // not password mode - [this](const std::string& password) { - strncpy(SETTINGS.opdsPassword, password.c_str(), sizeof(SETTINGS.opdsPassword) - 1); - SETTINGS.opdsPassword[sizeof(SETTINGS.opdsPassword) - 1] = '\0'; - SETTINGS.saveToFile(); - exitActivity(); - requestUpdate(); - }, - [this]() { - exitActivity(); - requestUpdate(); - })); - } -} - -void CalibreSettingsActivity::render(Activity::RenderLock&&) { - renderer.clearScreen(); - - 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); - - 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 7f5d4dcd..00000000 --- a/src/activities/settings/CalibreSettingsActivity.h +++ /dev/null @@ -1,29 +0,0 @@ -#pragma once - -#include - -#include "activities/ActivityWithSubactivity.h" -#include "util/ButtonNavigator.h" - -/** - * Submenu for OPDS Browser settings. - * Shows OPDS Server URL and HTTP authentication options. - */ -class CalibreSettingsActivity final : public ActivityWithSubactivity { - public: - explicit CalibreSettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, - const std::function& onBack) - : ActivityWithSubactivity("CalibreSettings", renderer, mappedInput), onBack(onBack) {} - - void onEnter() override; - void onExit() override; - void loop() override; - void render(Activity::RenderLock&&) override; - - private: - ButtonNavigator buttonNavigator; - - size_t selectedIndex = 0; - const std::function onBack; - void handleSelection(); -}; diff --git a/src/activities/settings/OpdsServerListActivity.cpp b/src/activities/settings/OpdsServerListActivity.cpp new file mode 100644 index 00000000..02b91859 --- /dev/null +++ b/src/activities/settings/OpdsServerListActivity.cpp @@ -0,0 +1,131 @@ +#include "OpdsServerListActivity.h" + +#include +#include + +#include "MappedInputManager.h" +#include "OpdsServerStore.h" +#include "OpdsSettingsActivity.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 (!isPickerMode()) { + count++; + } + return count; +} + +void OpdsServerListActivity::onEnter() { + ActivityWithSubactivity::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() { ActivityWithSubactivity::onExit(); } + +void OpdsServerListActivity::loop() { + if (subActivity) { + subActivity->loop(); + return; + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + onBack(); + 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 (isPickerMode()) { + // Picker mode: selecting a server triggers the callback instead of opening the editor + if (selectedIndex < serverCount) { + onServerSelected(static_cast(selectedIndex)); + } + return; + } + + // Settings mode: open editor for selected server, or create a new one + auto onEditDone = [this] { + exitActivity(); + selectedIndex = 0; + requestUpdate(); + }; + + if (selectedIndex < serverCount) { + exitActivity(); + enterNewActivity(new OpdsSettingsActivity(renderer, mappedInput, onEditDone, selectedIndex)); + } else { + exitActivity(); + enterNewActivity(new OpdsSettingsActivity(renderer, mappedInput, onEditDone, -1)); + } +} + +void OpdsServerListActivity::render(Activity::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 00000000..4607b3a8 --- /dev/null +++ b/src/activities/settings/OpdsServerListActivity.h @@ -0,0 +1,41 @@ +#pragma once + +#include + +#include "activities/ActivityWithSubactivity.h" +#include "util/ButtonNavigator.h" + +/** + * Activity showing the list of configured OPDS servers. + * Allows adding new servers and editing/deleting existing ones. + * Used from Settings and also as a server picker from the home screen. + */ +class OpdsServerListActivity final : public ActivityWithSubactivity { + public: + using OnServerSelected = std::function; + + /** + * @param onBack Called when user presses Back + * @param onServerSelected If set, acts as a picker: selecting a server calls this instead of opening editor. + */ + explicit OpdsServerListActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + const std::function& onBack, OnServerSelected onServerSelected = nullptr) + : ActivityWithSubactivity("OpdsServerList", renderer, mappedInput), + onBack(onBack), + onServerSelected(std::move(onServerSelected)) {} + + void onEnter() override; + void onExit() override; + void loop() override; + void render(Activity::RenderLock&&) override; + + private: + ButtonNavigator buttonNavigator; + int selectedIndex = 0; + const std::function onBack; + OnServerSelected onServerSelected; + + bool isPickerMode() const { return onServerSelected != nullptr; } + int getItemCount() const; + void handleSelection(); +}; diff --git a/src/activities/settings/OpdsSettingsActivity.cpp b/src/activities/settings/OpdsSettingsActivity.cpp new file mode 100644 index 00000000..d78deb13 --- /dev/null +++ b/src/activities/settings/OpdsSettingsActivity.cpp @@ -0,0 +1,199 @@ +#include "OpdsSettingsActivity.h" + +#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() { + ActivityWithSubactivity::onEnter(); + + selectedIndex = 0; + isNewServer = (serverIndex < 0); + + if (!isNewServer) { + 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() { ActivityWithSubactivity::onExit(); } + +void OpdsSettingsActivity::loop() { + if (subActivity) { + subActivity->loop(); + return; + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + onBack(); + 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(); + }); +} + +void OpdsSettingsActivity::saveServer() { + if (isNewServer) { + OPDS_STORE.addServer(editServer); + // After the first field is saved, 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 { + OPDS_STORE.updateServer(static_cast(serverIndex), editServer); + } +} + +void OpdsSettingsActivity::handleSelection() { + if (selectedIndex == 0) { + // Server Name + exitActivity(); + enterNewActivity(new KeyboardEntryActivity( + renderer, mappedInput, tr(STR_SERVER_NAME), editServer.name, 63, false, + [this](const std::string& name) { + editServer.name = name; + saveServer(); + exitActivity(); + requestUpdate(); + }, + [this]() { + exitActivity(); + requestUpdate(); + })); + } else if (selectedIndex == 1) { + // Server URL + exitActivity(); + enterNewActivity(new KeyboardEntryActivity( + renderer, mappedInput, tr(STR_OPDS_SERVER_URL), editServer.url, 127, false, + [this](const std::string& url) { + editServer.url = url; + saveServer(); + exitActivity(); + requestUpdate(); + }, + [this]() { + exitActivity(); + requestUpdate(); + })); + } else if (selectedIndex == 2) { + // Username + exitActivity(); + enterNewActivity(new KeyboardEntryActivity( + renderer, mappedInput, tr(STR_USERNAME), editServer.username, 63, false, + [this](const std::string& username) { + editServer.username = username; + saveServer(); + exitActivity(); + requestUpdate(); + }, + [this]() { + exitActivity(); + requestUpdate(); + })); + } else if (selectedIndex == 3) { + // Password + exitActivity(); + enterNewActivity(new KeyboardEntryActivity( + renderer, mappedInput, tr(STR_PASSWORD), editServer.password, 63, false, + [this](const std::string& password) { + editServer.password = password; + saveServer(); + exitActivity(); + requestUpdate(); + }, + [this]() { + exitActivity(); + requestUpdate(); + })); + } else if (selectedIndex == 4 && !isNewServer) { + // Delete server + OPDS_STORE.removeServer(static_cast(serverIndex)); + onBack(); + } +} + +void OpdsSettingsActivity::render(Activity::RenderLock&&) { + renderer.clearScreen(); + + const auto& metrics = UITheme::getInstance().getMetrics(); + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + 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); + + renderer.displayBuffer(); +} diff --git a/src/activities/settings/OpdsSettingsActivity.h b/src/activities/settings/OpdsSettingsActivity.h new file mode 100644 index 00000000..0e14ec8f --- /dev/null +++ b/src/activities/settings/OpdsSettingsActivity.h @@ -0,0 +1,40 @@ +#pragma once + +#include + +#include "OpdsServerStore.h" +#include "activities/ActivityWithSubactivity.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 ActivityWithSubactivity { + public: + /** + * @param serverIndex Index into OpdsServerStore, or -1 for a new server + */ + explicit OpdsSettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + const std::function& onBack, int serverIndex = -1) + : ActivityWithSubactivity("OpdsSettings", renderer, mappedInput), onBack(onBack), serverIndex(serverIndex) {} + + void onEnter() override; + void onExit() override; + void loop() override; + void render(Activity::RenderLock&&) override; + + private: + ButtonNavigator buttonNavigator; + + size_t selectedIndex = 0; + const std::function onBack; + int serverIndex; + OpdsServer editServer; + bool isNewServer = false; + + int getMenuItemCount() const; + void handleSelection(); + void saveServer(); +}; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 6fb4bc04..e29d48c7 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -7,7 +7,7 @@ #include #include "ButtonRemapActivity.h" -#include "CalibreSettingsActivity.h" +#include "OpdsServerListActivity.h" #include "ClearCacheActivity.h" #include "CrossPointSettings.h" #include "KOReaderSettingsActivity.h" @@ -202,7 +202,7 @@ void SettingsActivity::toggleCurrentSetting() { enterSubActivity(new KOReaderSettingsActivity(renderer, mappedInput, onComplete)); break; case SettingAction::OPDSBrowser: - enterSubActivity(new CalibreSettingsActivity(renderer, mappedInput, onComplete)); + enterSubActivity(new OpdsServerListActivity(renderer, mappedInput, onComplete)); break; case SettingAction::Network: enterSubActivity(new WifiSelectionActivity(renderer, mappedInput, onCompleteBool, false)); diff --git a/src/main.cpp b/src/main.cpp index e5dfaec3..3c0138b9 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/boot_sleep/BootActivity.h" #include "activities/boot_sleep/SleepActivity.h" @@ -28,6 +29,7 @@ #include "activities/home/RecentBooksActivity.h" #include "activities/network/CrossPointWebServerActivity.h" #include "activities/reader/ReaderActivity.h" +#include "activities/settings/OpdsServerListActivity.h" #include "activities/settings/SettingsActivity.h" #include "activities/util/FullScreenMessageActivity.h" #include "components/UITheme.h" @@ -261,7 +263,18 @@ void onGoToMyLibraryWithPath(const std::string& path, bool initialSkipRelease) { void onGoToBrowser() { exitActivity(); - enterNewActivity(new OpdsBookBrowserActivity(renderer, mappedInputManager, onGoHome)); + const auto& servers = OPDS_STORE.getServers(); + if (servers.size() == 1) { + enterNewActivity(new OpdsBookBrowserActivity(renderer, mappedInputManager, onGoHome, servers[0])); + } else { + enterNewActivity(new OpdsServerListActivity(renderer, mappedInputManager, onGoHome, [](size_t serverIndex) { + const auto* server = OPDS_STORE.getServer(serverIndex); + if (server) { + exitActivity(); + enterNewActivity(new OpdsBookBrowserActivity(renderer, mappedInputManager, onGoHome, *server)); + } + })); + } } void onGoHome() { @@ -343,6 +356,7 @@ void setup() { I18N.loadSettings(); KOREADER_STORE.loadFromFile(); + OPDS_STORE.loadFromFile(); BootNtpSync::start(); UITheme::getInstance().reload(); ButtonNavigator::setMappedInputManager(mappedInputManager); diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index e2f60ebe..c013a211 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 "html/FilesPageHtml.generated.h" #include "html/HomePageHtml.generated.h" @@ -156,6 +157,11 @@ void CrossPointWebServer::begin() { server->on("/api/settings", HTTP_GET, [this] { handleGetSettings(); }); server->on("/api/settings", HTTP_POST, [this] { handlePostSettings(); }); + // OPDS server management 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()); @@ -1157,6 +1163,116 @@ void CrossPointWebServer::handlePostSettings() { server->send(200, "text/plain", String("Applied ") + String(applied) + " setting(s)"); } +// ---- OPDS Server Management API ---- + +void CrossPointWebServer::handleGetOpdsServers() const { + const auto& servers = OPDS_STORE.getServers(); + + server->setContentLength(CONTENT_LENGTH_UNKNOWN); + server->send(200, "application/json", ""); + server->sendContent("["); + + constexpr size_t outputSize = 512; + char output[outputSize]; + bool first = true; + + for (size_t i = 0; i < servers.size(); i++) { + JsonDocument doc; + doc["name"] = servers[i].name; + doc["url"] = servers[i].url; + doc["username"] = servers[i].username; + doc["hasPassword"] = !servers[i].password.empty(); + + const size_t written = serializeJson(doc, output, outputSize); + if (written >= outputSize) continue; + + if (!first) server->sendContent(","); + server->sendContent(output); + first = false; + } + + 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(""); + + 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; + } + 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 bb2063cb..45cd786f 100644 --- a/src/network/CrossPointWebServer.h +++ b/src/network/CrossPointWebServer.h @@ -105,4 +105,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 dff92e0e..b41bcfae 100644 --- a/src/network/HttpDownloader.cpp +++ b/src/network/HttpDownloader.cpp @@ -9,12 +9,49 @@ #include #include +#include -#include "CrossPointSettings.h" #include "util/UrlUtils.h" -bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent) { - // Use WiFiClientSecure for HTTPS, regular WiFiClient for HTTP +namespace { +class FileWriteStream final : public Stream { + public: + FileWriteStream(FsFile& file, size_t total, HttpDownloader::ProgressCallback progress) + : file_(file), total_(total), progress_(std::move(progress)) {} + + size_t write(uint8_t byte) override { return write(&byte, 1); } + + size_t write(const uint8_t* buffer, size_t size) override { + const size_t written = file_.write(buffer, size); + if (written != size) { + writeOk_ = false; + } + downloaded_ += written; + if (progress_ && total_ > 0) { + progress_(downloaded_, total_); + } + return written; + } + + int available() override { return 0; } + int read() override { return -1; } + int peek() override { return -1; } + void flush() override { file_.flush(); } + + size_t downloaded() const { return downloaded_; } + bool ok() const { return writeOk_; } + + private: + FsFile& file_; + size_t total_; + size_t downloaded_ = 0; + bool writeOk_ = true; + HttpDownloader::ProgressCallback progress_; +}; +} // namespace + +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 WiFiClientSecure(); @@ -31,9 +68,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); } @@ -53,9 +89,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(); @@ -63,8 +100,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 WiFiClientSecure for HTTPS, regular WiFiClient for HTTP + ProgressCallback progress, const std::string& username, + const std::string& password) { std::unique_ptr client; if (UrlUtils::isHttpsUrl(url)) { auto* secureClient = new WiFiClientSecure(); @@ -82,9 +119,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); } @@ -96,8 +132,13 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string& return HTTP_ERROR; } - const size_t contentLength = http.getSize(); - LOG_DBG("HTTP", "Content-Length: %zu", contentLength); + const int64_t reportedLength = http.getSize(); + const size_t contentLength = reportedLength > 0 ? static_cast(reportedLength) : 0; + if (contentLength > 0) { + LOG_DBG("HTTP", "Content-Length: %zu", contentLength); + } else { + LOG_DBG("HTTP", "Content-Length: unknown"); + } // Remove existing file if present if (Storage.exists(destPath.c_str())) { @@ -112,56 +153,29 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string& return FILE_ERROR; } - // Get the stream for chunked reading - WiFiClient* stream = http.getStreamPtr(); - if (!stream) { - LOG_ERR("HTTP", "Failed to get stream"); - file.close(); - Storage.remove(destPath.c_str()); - http.end(); - return HTTP_ERROR; - } - - // Download in chunks - uint8_t buffer[DOWNLOAD_CHUNK_SIZE]; - size_t downloaded = 0; + // Let HTTPClient handle chunked decoding and stream body bytes into the file. const size_t total = contentLength > 0 ? contentLength : 0; - - while (http.connected() && (contentLength == 0 || downloaded < contentLength)) { - const size_t available = stream->available(); - if (available == 0) { - delay(1); - continue; - } - - const size_t toRead = available < DOWNLOAD_CHUNK_SIZE ? available : DOWNLOAD_CHUNK_SIZE; - const size_t bytesRead = stream->readBytes(buffer, toRead); - - if (bytesRead == 0) { - break; - } - - const size_t written = file.write(buffer, bytesRead); - if (written != bytesRead) { - LOG_ERR("HTTP", "Write failed: wrote %zu of %zu bytes", written, bytesRead); - file.close(); - Storage.remove(destPath.c_str()); - http.end(); - return FILE_ERROR; - } - - downloaded += bytesRead; - - if (progress && total > 0) { - progress(downloaded, total); - } - } + FileWriteStream fileStream(file, total, progress); + http.writeToStream(&fileStream); file.close(); http.end(); + const size_t downloaded = fileStream.downloaded(); LOG_DBG("HTTP", "Downloaded %zu bytes", downloaded); + if (!fileStream.ok()) { + LOG_ERR("HTTP", "Write failed during download"); + Storage.remove(destPath.c_str()); + return FILE_ERROR; + } + + if (contentLength == 0 && downloaded == 0) { + LOG_ERR("HTTP", "Download failed: no data received"); + Storage.remove(destPath.c_str()); + return HTTP_ERROR; + } + // Verify download size if known if (contentLength > 0 && downloaded != contentLength) { LOG_ERR("HTTP", "Size mismatch: got %zu, expected %zu", downloaded, contentLength); diff --git a/src/network/HttpDownloader.h b/src/network/HttpDownloader.h index fd18dd4c..373fd87f 100644 --- a/src/network/HttpDownloader.h +++ b/src/network/HttpDownloader.h @@ -20,24 +20,20 @@ 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 = ""); private: static constexpr size_t DOWNLOAD_CHUNK_SIZE = 1024; diff --git a/src/network/html/SettingsPage.html b/src/network/html/SettingsPage.html index 1d25403d..cc7522e9 100644 --- a/src/network/html/SettingsPage.html +++ b/src/network/html/SettingsPage.html @@ -180,6 +180,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; @@ -231,6 +273,8 @@ +
+

CrossPoint E-Reader • Open Source @@ -409,6 +453,116 @@ } loadSettings(); + + // --- OPDS Server Management --- + 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; + 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, + }; + 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();