#include "OpdsBookBrowserActivity.h" #include #include #include #include #include #include #include #include "CrossPointSettings.h" #include "MappedInputManager.h" #include "SilentRestart.h" #include "activities/network/WifiSelectionActivity.h" #include "activities/util/KeyboardEntryActivity.h" #include "components/UITheme.h" #include "fontIds.h" #include "network/HttpDownloader.h" #include "util/BookCacheUtils.h" #include "util/OpdsFilename.h" #include "util/StringUtils.h" #include "util/UrlUtils.h" namespace { constexpr int PAGE_ITEMS = 23; constexpr int DOWNLOAD_PROGRESS_STEP_PERCENT = 5; constexpr unsigned long DOWNLOAD_PROGRESS_MIN_UPDATE_MS = 5000; } // namespace void OpdsBookBrowserActivity::onEnter() { Activity::onEnter(); state = BrowserState::CHECK_WIFI; entries.clear(); navigationHistory.clear(); searchTemplate = ""; currentPath = ""; selectorIndex = 0; consumeConfirm = false; consumeBack = false; errorMessage.clear(); statusMessage = tr(STR_CHECKING_WIFI); requestUpdate(); checkAndConnectWifi(); } void OpdsBookBrowserActivity::onExit() { Activity::onExit(); entries.clear(); navigationHistory.clear(); if (WiFi.getMode() != WIFI_MODE_NULL) { WiFi.disconnect(false); delay(30); silentRestart(); } } void OpdsBookBrowserActivity::loop() { if (state == BrowserState::WIFI_SELECTION || state == BrowserState::SEARCH_INPUT) { return; } if (consumeConfirm && mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { consumeConfirm = false; return; } if (consumeBack && mappedInput.wasReleased(MappedInputManager::Button::Back)) { consumeBack = false; return; } if (state == BrowserState::ERROR) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) { state = BrowserState::LOADING; statusMessage = tr(STR_LOADING); requestUpdate(); fetchFeed(currentPath); } else { launchWifiSelection(); } } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { navigateBack(); } return; } if (state == BrowserState::CHECK_WIFI || state == BrowserState::LOADING) { if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { state == BrowserState::CHECK_WIFI ? onGoHome() : navigateBack(); } return; } if (state == BrowserState::DOWNLOADING) return; if (state == BrowserState::BROWSING) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (!entries.empty()) { const auto& entry = entries[selectorIndex]; entry.type == OpdsEntryType::BOOK ? downloadBook(entry) : navigateToEntry(entry); } } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { navigateBack(); } else if (mappedInput.wasReleased(MappedInputManager::Button::Left)) { if (!searchTemplate.empty() && selectorIndex == 0) launchSearch(); } if (!entries.empty()) { buttonNavigator.onNextRelease([this] { selectorIndex = ButtonNavigator::nextIndex(selectorIndex, entries.size()); requestUpdate(); }); buttonNavigator.onPreviousRelease([this] { selectorIndex = ButtonNavigator::previousIndex(selectorIndex, entries.size()); requestUpdate(); }); buttonNavigator.onNextContinuous([this] { selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, entries.size(), PAGE_ITEMS); requestUpdate(); }); buttonNavigator.onPreviousContinuous([this] { selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, entries.size(), PAGE_ITEMS); requestUpdate(); }); } } } void OpdsBookBrowserActivity::render(RenderLock&&) { renderer.clearScreen(); const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); // 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()); const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", ""); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); return; } if (state == BrowserState::ERROR) { renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 20, tr(STR_ERROR_MSG)); renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, errorMessage.c_str()); const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_RETRY), "", ""); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); return; } if (state == BrowserState::DOWNLOADING) { renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 40, tr(STR_DOWNLOADING)); auto title = renderer.truncatedText(UI_10_FONT_ID, statusMessage.c_str(), pageWidth - 40); renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 10, title.c_str()); if (downloadTotal > 0) { GUI.drawProgressBar(renderer, Rect{50, pageHeight / 2 + 20, pageWidth - 100, 20}, downloadProgress, downloadTotal); } renderer.displayBuffer(); return; } const char* confirmLabel = (!entries.empty() && entries[selectorIndex].type == OpdsEntryType::BOOK) ? tr(STR_DOWNLOAD) : tr(STR_OPEN); const char* searchLabel = (!searchTemplate.empty() && selectorIndex == 0) ? tr(STR_SEARCH) : tr(STR_DIR_UP); const auto labels = mappedInput.mapLabels(tr(STR_BACK), confirmLabel, searchLabel, tr(STR_DIR_DOWN)); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); if (entries.empty()) { renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, tr(STR_NO_ENTRIES)); } else { const auto pageStartIndex = selectorIndex / PAGE_ITEMS * PAGE_ITEMS; renderer.fillRect(0, 60 + (selectorIndex % PAGE_ITEMS) * 30 - 2, pageWidth - 1, 30); for (size_t i = pageStartIndex; i < entries.size() && i < static_cast(pageStartIndex + PAGE_ITEMS); i++) { const auto& entry = entries[i]; std::string displayText = (entry.type == OpdsEntryType::NAVIGATION) ? "> " + entry.title : entry.title; if (entry.type == OpdsEntryType::BOOK && !entry.author.empty()) displayText += " - " + entry.author; auto item = renderer.truncatedText(UI_10_FONT_ID, displayText.c_str(), pageWidth - 40); renderer.drawText(UI_10_FONT_ID, 20, 60 + (i % PAGE_ITEMS) * 30, item.c_str(), i != static_cast(selectorIndex)); } } renderer.displayBuffer(); } void OpdsBookBrowserActivity::fetchFeed(const std::string& path) { if (server.url.empty()) { state = BrowserState::ERROR; errorMessage = tr(STR_NO_SERVER_URL); requestUpdate(); return; } 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, server.username, server.password)) { state = BrowserState::ERROR; errorMessage = tr(STR_FETCH_FEED_FAILED); requestUpdate(); return; } } if (!parser) { state = BrowserState::ERROR; errorMessage = tr(STR_PARSE_FEED_FAILED); requestUpdate(); return; } searchTemplate = parser.getSearchTemplate(); const auto& nextUrl = parser.getNextPageUrl(); const auto& prevUrl = parser.getPrevPageUrl(); const bool feedTruncated = parser.truncated(); entries = std::move(parser).getEntries(); entries.reserve(entries.size() + (prevUrl.empty() ? 0 : 1) + (nextUrl.empty() ? 0 : 1)); if (!prevUrl.empty()) { entries.insert(entries.begin(), OpdsEntry{OpdsEntryType::NAVIGATION, tr(STR_PREV_PAGE), "", prevUrl, ""}); } if (!nextUrl.empty()) { entries.push_back(OpdsEntry{OpdsEntryType::NAVIGATION, tr(STR_NEXT_PAGE), "", nextUrl, ""}); } if (feedTruncated) { LOG_INF("OPDS", "Feed truncated to fit memory"); } selectorIndex = 0; state = entries.empty() ? BrowserState::ERROR : BrowserState::BROWSING; if (entries.empty()) errorMessage = tr(STR_NO_ENTRIES); requestUpdate(); } void OpdsBookBrowserActivity::releaseEntries() { std::vector().swap(entries); } 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(server.url, currentPath); currentPath = UrlUtils::buildUrl(feedUrl, entry.href); state = BrowserState::LOADING; statusMessage = tr(STR_LOADING); releaseEntries(); selectorIndex = 0; requestUpdate(true); fetchFeed(currentPath); } void OpdsBookBrowserActivity::navigateBack() { if (navigationHistory.empty()) { onGoHome(); } else { currentPath = navigationHistory.back(); navigationHistory.pop_back(); state = BrowserState::LOADING; statusMessage = tr(STR_LOADING); releaseEntries(); selectorIndex = 0; requestUpdate(); fetchFeed(currentPath); } } void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) { state = BrowserState::DOWNLOADING; statusMessage = book.title; downloadProgress = downloadTotal = 0; requestUpdate(true); // Build full download URL relative to the current feed, not the root server URL const std::string feedUrl = UrlUtils::buildUrl(server.url, currentPath); std::string downloadUrl = UrlUtils::buildUrl(feedUrl, book.href); // opdsDownloadFolder is already a null-terminated char[64]; use it directly — // no std::string copy. exists()/mkdir() take const char*. const char* folder = SETTINGS.opdsDownloadFolder; // "" => SD root bool haveFolder = folder[0] != '\0'; if (haveFolder && !Storage.exists(folder) && !Storage.mkdir(folder)) { // exists()-guard first: mkdir's return-on-existing is unconfirmed, and every // existing caller checks exists() before mkdir. On real failure, fall back // to SD root so the download is never lost. LOG_ERR("OPDS", "mkdir failed for %s, using SD root", folder); haveFolder = false; } // downloadToFile() needs a std::string, and titles are unbounded (a fixed // char[] would truncate). Cold path (a multi-second download follows), so one // reserve'd, in-place-appended owning string is the right call. std::string filename; filename.reserve(96); if (haveFolder) filename += folder; filename += '/'; filename += opdsBookFilename(book.author, book.title, static_cast(SETTINGS.opdsFilenameFormat)); LOG_DBG("OPDS", "Downloading: %s -> %s", downloadUrl.c_str(), filename.c_str()); int lastRenderedPercent = -1; unsigned long lastProgressUpdateMs = 0; const auto result = HttpDownloader::downloadToFile( downloadUrl, filename, [this, &lastRenderedPercent, &lastProgressUpdateMs](const size_t downloaded, const size_t total) { downloadProgress = downloaded; downloadTotal = total; const int percent = total > 0 ? static_cast(static_cast(downloaded) * 100 / total) : 0; const unsigned long now = millis(); if (percent >= 100 || lastRenderedPercent < 0 || percent >= lastRenderedPercent + DOWNLOAD_PROGRESS_STEP_PERCENT || now - lastProgressUpdateMs >= DOWNLOAD_PROGRESS_MIN_UPDATE_MS) { lastRenderedPercent = percent; lastProgressUpdateMs = now; requestUpdate(true); } }, nullptr, server.username, server.password); if (result == HttpDownloader::OK) { clearBookCache(filename); state = BrowserState::BROWSING; } else { LOG_ERR("OPDS", "Download failed: %d", static_cast(result)); state = BrowserState::ERROR; errorMessage = tr(STR_DOWNLOAD_FAILED); } requestUpdate(); } void OpdsBookBrowserActivity::launchSearch() { consumeConfirm = true; state = BrowserState::SEARCH_INPUT; requestUpdate(); auto keyboard = std::make_unique(renderer, mappedInput, tr(STR_SEARCH)); startActivityForResult(std::move(keyboard), [this](const ActivityResult& result) { state = BrowserState::BROWSING; if (!result.isCancelled) { performSearch(std::get(result.data).text); } else { requestUpdate(); } }); } void OpdsBookBrowserActivity::performSearch(const std::string& query) { if (query.empty() || searchTemplate.empty()) { state = BrowserState::BROWSING; requestUpdate(); return; } auto urlEncode = [](const std::string& s) { std::string out; out.reserve(s.size() * 3); for (unsigned char c : s) { if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') out += static_cast(c); else { char buf[4]; snprintf(buf, sizeof(buf), "%%%02X", c); out += buf; } } return out; }; std::string url = searchTemplate; const std::string placeholder = "{searchTerms}"; const size_t pos = url.find(placeholder); if (pos != std::string::npos) url.replace(pos, placeholder.length(), urlEncode(query)); navigationHistory.push_back(currentPath); // <-- add this currentPath = url; // <-- add this state = BrowserState::LOADING; statusMessage = tr(STR_LOADING); releaseEntries(); selectorIndex = 0; requestUpdate(true); fetchFeed(url); } void OpdsBookBrowserActivity::checkAndConnectWifi() { if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) { state = BrowserState::LOADING; statusMessage = tr(STR_LOADING); requestUpdate(); fetchFeed(currentPath); return; } launchWifiSelection(); } void OpdsBookBrowserActivity::launchWifiSelection() { state = BrowserState::WIFI_SELECTION; requestUpdate(); startActivityForResult(std::make_unique(renderer, mappedInput), [this](const ActivityResult& result) { onWifiSelectionComplete(!result.isCancelled); }); } void OpdsBookBrowserActivity::onWifiSelectionComplete(const bool connected) { if (connected) { state = BrowserState::LOADING; statusMessage = tr(STR_LOADING); requestUpdate(true); fetchFeed(currentPath); } else { // Leave WiFi up; onExit's silent reboot handles teardown without fragmenting. state = BrowserState::ERROR; errorMessage = tr(STR_WIFI_CONN_FAILED); requestUpdate(); } }