feat: Support for multiple OPDS servers (#1209)

## 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.
<img width="932" height="906" alt="SCR-20260416-stvu"
src="https://github.com/user-attachments/assets/a8f18d84-4204-46a0-bb31-b73d24b3255f"
/>


## 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>
This commit is contained in:
Arthur Tazhitdinov
2026-04-21 18:35:41 -05:00
committed by GitHub
co-authored by Copilot
parent c5f82709c0
commit 1cf2239742
26 changed files with 1035 additions and 218 deletions
@@ -0,0 +1,222 @@
#include "OpdsSettingsActivity.h"
#include <GfxRenderer.h>
#include <I18n.h>
#include <Logging.h>
#include <cstring>
#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<size_t>(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<int>(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<size_t>(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<KeyboardResult>(result.data);
editServer.name = kb.text;
saveServer();
requestUpdate();
}
};
startActivityForResult(std::make_unique<KeyboardEntryActivity>(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<KeyboardResult>(result.data);
editServer.url = (kb.text == "https://" || kb.text == "http://") ? "" : kb.text;
saveServer();
requestUpdate();
}
};
startActivityForResult(std::make_unique<KeyboardEntryActivity>(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<KeyboardResult>(result.data);
editServer.username = kb.text;
saveServer();
requestUpdate();
}
};
startActivityForResult(std::make_unique<KeyboardEntryActivity>(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<KeyboardResult>(result.data);
editServer.password = kb.text;
saveServer();
requestUpdate();
}
};
startActivityForResult(std::make_unique<KeyboardEntryActivity>(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<size_t>(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<int>(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();
}