mirror of
https://github.com/crosspoint-reader/crosspoint-reader.git
synced 2026-04-29 10:26:52 -07:00
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:
co-authored by
Copilot
parent
c5f82709c0
commit
1cf2239742
+27
-3
@@ -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://<device-ip>/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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<JsonArray>();
|
||||
for (const auto& server : store.getServers()) {
|
||||
JsonObject obj = arr.add<JsonObject>();
|
||||
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<JsonArray>();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
#include "OpdsServerStore.h"
|
||||
|
||||
#include <HalStorage.h>
|
||||
#include <JsonSettingsIO.h>
|
||||
#include <Logging.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#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<ptrdiff_t>(index));
|
||||
return saveToFile();
|
||||
}
|
||||
|
||||
const OpdsServer* OpdsServerStore::getServer(size_t index) const {
|
||||
if (index >= servers.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
return &servers[index];
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<OpdsServer> 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<OpdsServer>& 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()
|
||||
@@ -111,15 +111,6 @@ inline const std::vector<SettingInfo>& 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),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <HalPowerManager.h>
|
||||
|
||||
#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<OpdsBookBrowserActivity>(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<OpdsBookBrowserActivity>(renderer, mappedInput, servers[0]));
|
||||
} else {
|
||||
replaceActivity(std::make_unique<OpdsServerListActivity>(renderer, mappedInput, true));
|
||||
}
|
||||
}
|
||||
|
||||
void ActivityManager::goToReader(std::string path) {
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
#include <OpdsStream.h>
|
||||
#include <WiFi.h>
|
||||
|
||||
#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();
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
#pragma once
|
||||
#include <OpdsParser.h>
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#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);
|
||||
|
||||
@@ -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<int>(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<UIIcon> 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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
#include "CalibreSettingsActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#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<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_CALIBRE_WEB_URL),
|
||||
prefillUrl, 127, InputType::Url),
|
||||
[this](const ActivityResult& result) {
|
||||
if (!result.isCancelled) {
|
||||
const auto& kb = std::get<KeyboardResult>(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<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_USERNAME),
|
||||
SETTINGS.opdsUsername, 63, InputType::Text),
|
||||
[this](const ActivityResult& result) {
|
||||
if (!result.isCancelled) {
|
||||
const auto& kb = std::get<KeyboardResult>(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<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_PASSWORD),
|
||||
SETTINGS.opdsPassword, 63, InputType::Password),
|
||||
[this](const ActivityResult& result) {
|
||||
if (!result.isCancelled) {
|
||||
const auto& kb = std::get<KeyboardResult>(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<int>(MENU_ITEMS),
|
||||
static_cast<int>(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();
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
#include "OpdsServerListActivity.h"
|
||||
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#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<int>(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<int>(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<size_t>(selectedIndex));
|
||||
if (server) {
|
||||
activityManager.replaceActivity(std::make_unique<OpdsBookBrowserActivity>(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<OpdsSettingsActivity>(renderer, mappedInput, selectedIndex), resultHandler);
|
||||
} else {
|
||||
startActivityForResult(std::make_unique<OpdsSettingsActivity>(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<int>(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();
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
@@ -4,12 +4,12 @@
|
||||
#include <Logging.h>
|
||||
|
||||
#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<KOReaderSettingsActivity>(renderer, mappedInput), resultHandler);
|
||||
break;
|
||||
case SettingAction::OPDSBrowser:
|
||||
startActivityForResult(std::make_unique<CalibreSettingsActivity>(renderer, mappedInput), resultHandler);
|
||||
startActivityForResult(std::make_unique<OpdsServerListActivity>(renderer, mappedInput), resultHandler);
|
||||
break;
|
||||
case SettingAction::Network:
|
||||
startActivityForResult(std::make_unique<WifiSelectionActivity>(renderer, mappedInput, false), resultHandler);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user