711 lines
24 KiB
C++
711 lines
24 KiB
C++
#include "OpdsBookBrowserActivity.h"
|
|
|
|
#include <Epub.h>
|
|
#include <GfxRenderer.h>
|
|
#include <HalClock.h>
|
|
#include <HalStorage.h>
|
|
#include <I18n.h>
|
|
#include <Logging.h>
|
|
#include <OpdsStream.h>
|
|
#include <WiFi.h>
|
|
#include <Xtc.h>
|
|
#include <expat.h>
|
|
|
|
#include <cctype>
|
|
#include <cstdio>
|
|
#include <memory>
|
|
|
|
#include "MappedInputManager.h"
|
|
#include "OpdsFormatLabel.h"
|
|
#include "activities/network/WifiSelectionActivity.h"
|
|
#include "activities/util/KeyboardEntryActivity.h"
|
|
#include "components/UITheme.h"
|
|
#include "fontIds.h"
|
|
#include "network/HttpDownloader.h"
|
|
#include "util/StringUtils.h"
|
|
#include "util/UrlUtils.h"
|
|
|
|
namespace {
|
|
constexpr int PAGE_ITEMS = 23;
|
|
constexpr int FORMAT_ITEM_HEIGHT = 30;
|
|
constexpr int FORMAT_LIST_TOP_OFFSET = 20;
|
|
constexpr int FORMAT_LIST_BOTTOM_PADDING = 20;
|
|
|
|
int formatItemsPerPage(const Rect& contentRect) {
|
|
const int midY = contentRect.y + contentRect.height / 2;
|
|
const int listTop = midY + FORMAT_LIST_TOP_OFFSET;
|
|
const int listBottom = contentRect.y + contentRect.height - FORMAT_LIST_BOTTOM_PADDING;
|
|
const int rawAvailableHeight = listBottom - listTop;
|
|
const int availableHeight = rawAvailableHeight > FORMAT_ITEM_HEIGHT ? rawAvailableHeight : FORMAT_ITEM_HEIGHT;
|
|
const int itemsPerPage = availableHeight / FORMAT_ITEM_HEIGHT;
|
|
return itemsPerPage > 0 ? itemsPerPage : 1;
|
|
}
|
|
|
|
void writeString(HalFile& f, const std::string& s) {
|
|
uint16_t len = s.length();
|
|
f.write(reinterpret_cast<const uint8_t*>(&len), sizeof(len));
|
|
if (len > 0) f.write(reinterpret_cast<const void*>(s.data()), len);
|
|
}
|
|
|
|
std::string readString(HalFile& f) {
|
|
uint16_t len = 0;
|
|
if (f.read(&len, sizeof(len)) != sizeof(len)) return "";
|
|
if (len == 0) return "";
|
|
std::string s(len, '\0');
|
|
f.read(s.data(), len);
|
|
return s;
|
|
}
|
|
|
|
void writeEntryToCache(HalFile& f, const OpdsEntry& entry) {
|
|
uint8_t type = static_cast<uint8_t>(entry.type);
|
|
f.write(&type, sizeof(type));
|
|
writeString(f, entry.title);
|
|
writeString(f, entry.author);
|
|
writeString(f, entry.href);
|
|
writeString(f, entry.id);
|
|
writeString(f, entry.imageHref);
|
|
uint16_t numLinks = entry.acquisitionLinks.size();
|
|
f.write(reinterpret_cast<const uint8_t*>(&numLinks), sizeof(numLinks));
|
|
for (const auto& link : entry.acquisitionLinks) {
|
|
writeString(f, link.href);
|
|
writeString(f, link.mimeType);
|
|
writeString(f, link.formatKey);
|
|
writeString(f, link.fileExtension);
|
|
}
|
|
}
|
|
|
|
OpdsEntry readEntryFromCache(HalFile& f) {
|
|
OpdsEntry entry;
|
|
uint8_t type = 0;
|
|
if (f.read(&type, sizeof(type)) == sizeof(type)) {
|
|
entry.type = static_cast<OpdsEntryType>(type);
|
|
}
|
|
entry.title = readString(f);
|
|
entry.author = readString(f);
|
|
entry.href = readString(f);
|
|
entry.id = readString(f);
|
|
entry.imageHref = readString(f);
|
|
uint16_t numLinks = 0;
|
|
if (f.read(&numLinks, sizeof(numLinks)) == sizeof(numLinks)) {
|
|
for (uint16_t i = 0; i < numLinks; ++i) {
|
|
OpdsAcquisitionLink link;
|
|
link.href = readString(f);
|
|
link.mimeType = readString(f);
|
|
link.formatKey = readString(f);
|
|
link.fileExtension = readString(f);
|
|
entry.acquisitionLinks.push_back(link);
|
|
}
|
|
}
|
|
return entry;
|
|
}
|
|
} // namespace
|
|
|
|
OpdsEntry OpdsBookBrowserActivity::getEntry(size_t index) const {
|
|
if (index >= entryOffsets.size()) return {};
|
|
HalFile f;
|
|
if (!Storage.openFileForRead("OPDS", "/.tmp_opds_cache.dat", f)) return {};
|
|
f.seek(entryOffsets[index]);
|
|
OpdsEntry entry = readEntryFromCache(f);
|
|
return entry;
|
|
}
|
|
|
|
void OpdsBookBrowserActivity::onEnter() {
|
|
Activity::onEnter();
|
|
|
|
state = BrowserState::CHECK_WIFI;
|
|
entryOffsets.clear();
|
|
navigationHistory.clear();
|
|
currentPath = ""; // Root path - user provides full URL in settings
|
|
searchTemplate.clear();
|
|
selectorIndex = 0;
|
|
selectedBookIndex = -1;
|
|
formatSelectorIndex = 0;
|
|
formatSelectionLabels.clear();
|
|
consumeConfirm = false;
|
|
consumeBack = false;
|
|
errorMessage.clear();
|
|
statusMessage = tr(STR_CHECKING_WIFI);
|
|
requestUpdate();
|
|
|
|
checkAndConnectWifi();
|
|
}
|
|
|
|
void OpdsBookBrowserActivity::onExit() {
|
|
Activity::onExit();
|
|
|
|
HalClock::wifiOff();
|
|
|
|
entryOffsets.clear();
|
|
navigationHistory.clear();
|
|
formatSelectionLabels.clear();
|
|
Storage.remove("/.tmp_opds_cache.dat");
|
|
}
|
|
|
|
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::FORMAT_SELECTION) {
|
|
if (selectedBookIndex < 0 || selectedBookIndex >= static_cast<int>(entryOffsets.size())) {
|
|
state = BrowserState::BROWSING;
|
|
requestUpdate();
|
|
return;
|
|
}
|
|
|
|
const auto entry = getEntry(selectedBookIndex);
|
|
if (entry.acquisitionLinks.empty()) {
|
|
state = BrowserState::BROWSING;
|
|
selectedBookIndex = -1;
|
|
formatSelectionLabels.clear();
|
|
requestUpdate();
|
|
return;
|
|
}
|
|
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
|
state = BrowserState::BROWSING;
|
|
selectedBookIndex = -1;
|
|
formatSelectionLabels.clear();
|
|
requestUpdate();
|
|
return;
|
|
}
|
|
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
|
downloadBook(entry, entry.acquisitionLinks[formatSelectorIndex]);
|
|
return;
|
|
}
|
|
|
|
buttonNavigator.onNextList({MappedInputManager::Button::Down}, formatSelectorIndex,
|
|
static_cast<int>(entry.acquisitionLinks.size()), [this] { requestUpdate(); });
|
|
buttonNavigator.onPreviousList({MappedInputManager::Button::Up}, formatSelectorIndex,
|
|
static_cast<int>(entry.acquisitionLinks.size()), [this] { requestUpdate(); });
|
|
return;
|
|
}
|
|
|
|
if (state == BrowserState::BROWSING) {
|
|
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
|
if (!entryOffsets.empty()) {
|
|
const auto entry = getEntry(selectorIndex);
|
|
entry.type == OpdsEntryType::BOOK ? chooseBookFormat(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 (!entryOffsets.empty()) {
|
|
// Left/Right are reserved for Back and Search, so restrict to Up/Down only.
|
|
buttonNavigator.onNextList({MappedInputManager::Button::Down}, selectorIndex,
|
|
static_cast<int>(entryOffsets.size()), [this] { requestUpdate(); });
|
|
buttonNavigator.onPreviousList({MappedInputManager::Button::Up}, selectorIndex,
|
|
static_cast<int>(entryOffsets.size()), [this] { requestUpdate(); });
|
|
}
|
|
}
|
|
}
|
|
|
|
void OpdsBookBrowserActivity::render(RenderLock&&) {
|
|
renderer.clearScreen();
|
|
|
|
const Rect contentRect = UITheme::getContentRect(renderer, true, false);
|
|
const int midY = contentRect.y + contentRect.height / 2;
|
|
|
|
// 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) {
|
|
renderer.drawCenteredText(UI_10_FONT_ID, midY, 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::LOADING) {
|
|
renderer.drawCenteredText(UI_10_FONT_ID, midY, 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, midY - 20, tr(STR_ERROR_MSG));
|
|
renderer.drawCenteredText(UI_10_FONT_ID, midY + 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, midY - 40, tr(STR_DOWNLOADING));
|
|
// Trim long titles to keep them within the content bounds.
|
|
auto title = renderer.truncatedText(UI_10_FONT_ID, statusMessage.c_str(), contentRect.width - 40);
|
|
renderer.drawCenteredText(UI_10_FONT_ID, midY - 10, title.c_str());
|
|
if (downloadTotal > 0) {
|
|
const int barWidth = contentRect.width - 100;
|
|
constexpr int barHeight = 20;
|
|
const int barX = contentRect.x + 50;
|
|
const int barY = midY + 20;
|
|
GUI.drawProgressBar(renderer, Rect{barX, barY, barWidth, barHeight}, downloadProgress, downloadTotal);
|
|
}
|
|
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::FORMAT_SELECTION) {
|
|
const auto entry = getEntry(selectedBookIndex);
|
|
auto title = renderer.truncatedText(UI_10_FONT_ID, entry.title.c_str(), contentRect.width - 40);
|
|
renderer.drawCenteredText(UI_10_FONT_ID, midY - 40, title.c_str(), true, EpdFontFamily::BOLD);
|
|
if (!entry.author.empty()) {
|
|
auto author = renderer.truncatedText(UI_10_FONT_ID, entry.author.c_str(), contentRect.width - 40);
|
|
renderer.drawCenteredText(UI_10_FONT_ID, midY - 10, author.c_str());
|
|
}
|
|
|
|
const int listTop = midY + 20;
|
|
const int itemsPerPage = formatItemsPerPage(contentRect);
|
|
const int pageStartIndex = formatSelectorIndex / itemsPerPage * itemsPerPage;
|
|
renderer.fillRect(contentRect.x, listTop + (formatSelectorIndex - pageStartIndex) * FORMAT_ITEM_HEIGHT - 2,
|
|
contentRect.width - 1, FORMAT_ITEM_HEIGHT);
|
|
for (int i = pageStartIndex;
|
|
i < static_cast<int>(entry.acquisitionLinks.size()) && i < pageStartIndex + itemsPerPage; i++) {
|
|
const char* label = i < static_cast<int>(formatSelectionLabels.size()) ? formatSelectionLabels[i].c_str() : "";
|
|
auto item = renderer.truncatedText(UI_10_FONT_ID, label, contentRect.width - 40);
|
|
renderer.drawText(UI_10_FONT_ID, contentRect.x + 20, listTop + (i - pageStartIndex) * FORMAT_ITEM_HEIGHT,
|
|
item.c_str(), i != formatSelectorIndex);
|
|
}
|
|
|
|
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_DOWNLOAD), tr(STR_DIR_UP), tr(STR_DIR_DOWN));
|
|
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
|
renderer.displayBuffer();
|
|
return;
|
|
}
|
|
|
|
// Browsing state
|
|
// Show appropriate button hint based on selected entry type
|
|
const char* confirmLabel =
|
|
(!entryOffsets.empty() && getEntry(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 (entryOffsets.empty()) {
|
|
renderer.drawCenteredText(UI_10_FONT_ID, midY, tr(STR_NO_ENTRIES));
|
|
renderer.displayBuffer();
|
|
return;
|
|
}
|
|
|
|
const auto pageStartIndex = selectorIndex / PAGE_ITEMS * PAGE_ITEMS;
|
|
renderer.fillRect(contentRect.x, 60 + (selectorIndex % PAGE_ITEMS) * 30 - 2, contentRect.width - 1, 30);
|
|
|
|
for (size_t i = pageStartIndex; i < entryOffsets.size() && i < static_cast<size_t>(pageStartIndex + PAGE_ITEMS);
|
|
i++) {
|
|
const auto entry = getEntry(i);
|
|
|
|
// Format display text with type indicator
|
|
std::string displayText;
|
|
if (entry.type == OpdsEntryType::NAVIGATION) {
|
|
displayText = "> " + entry.title; // Folder/navigation indicator
|
|
} else {
|
|
// Book: "Title - Author" or just "Title"
|
|
displayText = entry.title;
|
|
if (!entry.author.empty()) {
|
|
displayText += " - " + entry.author;
|
|
}
|
|
}
|
|
|
|
auto item = renderer.truncatedText(UI_10_FONT_ID, displayText.c_str(), contentRect.width - 40);
|
|
renderer.drawText(UI_10_FONT_ID, contentRect.x + 20, 60 + (i % PAGE_ITEMS) * 30, item.c_str(),
|
|
i != static_cast<size_t>(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;
|
|
}
|
|
|
|
entryOffsets.clear();
|
|
|
|
std::string url = (path.find("http") == 0) ? path : UrlUtils::buildUrl(server.url, path);
|
|
LOG_DBG("OPDS", "Fetching: %s", url.c_str());
|
|
|
|
OpdsParser parser;
|
|
HalFile cacheFile;
|
|
|
|
if (!Storage.openFileForWrite("OPDS", "/.tmp_opds_cache.dat", cacheFile)) {
|
|
LOG_ERR("OPDS", "Could not open cache file");
|
|
state = BrowserState::ERROR;
|
|
errorMessage = "Cache Error";
|
|
requestUpdate();
|
|
return;
|
|
}
|
|
|
|
parser.onEntryParsed = [&](const OpdsEntry& entry) {
|
|
uint32_t offset = cacheFile.position();
|
|
entryOffsets.push_back(offset);
|
|
writeEntryToCache(cacheFile, entry);
|
|
};
|
|
|
|
{
|
|
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();
|
|
if (searchTemplate.empty() && !parser.getOsdUrl().empty()) {
|
|
fetchOsdTemplate(UrlUtils::buildUrl(url, parser.getOsdUrl()));
|
|
}
|
|
const auto& nextUrl = parser.getNextPageUrl();
|
|
const auto& prevUrl = parser.getPrevPageUrl();
|
|
|
|
if (!prevUrl.empty()) {
|
|
std::string resolvedPrevUrl = UrlUtils::buildUrl(url, prevUrl);
|
|
OpdsEntry prevEntry{OpdsEntryType::NAVIGATION, tr(STR_PREV_PAGE), "", resolvedPrevUrl, ""};
|
|
entryOffsets.insert(entryOffsets.begin(), cacheFile.position());
|
|
writeEntryToCache(cacheFile, prevEntry);
|
|
}
|
|
if (!nextUrl.empty()) {
|
|
std::string resolvedNextUrl = UrlUtils::buildUrl(url, nextUrl);
|
|
OpdsEntry nextEntry{OpdsEntryType::NAVIGATION, tr(STR_NEXT_PAGE), "", resolvedNextUrl, ""};
|
|
entryOffsets.push_back(cacheFile.position());
|
|
writeEntryToCache(cacheFile, nextEntry);
|
|
}
|
|
|
|
selectorIndex = 0;
|
|
state = entryOffsets.empty() ? BrowserState::ERROR : BrowserState::BROWSING;
|
|
if (entryOffsets.empty()) errorMessage = tr(STR_NO_ENTRIES);
|
|
requestUpdate();
|
|
}
|
|
|
|
void OpdsBookBrowserActivity::navigateToEntry(const OpdsEntry& entry) {
|
|
navigationHistory.push_back(currentPath);
|
|
currentPath = entry.href;
|
|
state = BrowserState::LOADING;
|
|
statusMessage = tr(STR_LOADING);
|
|
entryOffsets.clear();
|
|
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);
|
|
entryOffsets.clear();
|
|
selectorIndex = 0;
|
|
requestUpdate();
|
|
fetchFeed(currentPath);
|
|
}
|
|
}
|
|
|
|
// Opens a screen to allow the user to choose which format they want to download
|
|
// if multiple formats are available. If only one format is available, the
|
|
// download is started immediately.
|
|
void OpdsBookBrowserActivity::chooseBookFormat(const OpdsEntry& book) {
|
|
if (book.acquisitionLinks.empty()) {
|
|
state = BrowserState::ERROR;
|
|
errorMessage = tr(STR_DOWNLOAD_FAILED);
|
|
requestUpdate();
|
|
return;
|
|
}
|
|
|
|
if (book.acquisitionLinks.size() == 1) {
|
|
downloadBook(book, book.acquisitionLinks[0]);
|
|
return;
|
|
}
|
|
|
|
selectedBookIndex = selectorIndex;
|
|
formatSelectorIndex = 0;
|
|
formatSelectionLabels = buildOpdsFormatSelectionLabels(book.acquisitionLinks, server.url);
|
|
state = BrowserState::FORMAT_SELECTION;
|
|
requestUpdate();
|
|
}
|
|
|
|
// Downloads the selected book's acquisition link and saves it to the SD card.
|
|
void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book, const OpdsAcquisitionLink& acquisition) {
|
|
state = BrowserState::DOWNLOADING;
|
|
statusMessage = book.title;
|
|
downloadProgress = 0;
|
|
downloadTotal = 0;
|
|
requestUpdate(true);
|
|
|
|
std::string downloadUrl =
|
|
(acquisition.href.rfind("http", 0) == 0) ? acquisition.href : UrlUtils::buildUrl(server.url, acquisition.href);
|
|
std::string filename = "/" +
|
|
StringUtils::sanitizeFilename((book.author.empty() ? "" : book.author + " - ") + book.title) +
|
|
acquisition.fileExtension;
|
|
|
|
LOG_DBG("OPDS", "Downloading: %s -> %s", downloadUrl.c_str(), filename.c_str());
|
|
|
|
const auto result = HttpDownloader::downloadToFile(
|
|
downloadUrl, filename,
|
|
[this](const unsigned int downloaded, const unsigned int total) {
|
|
mappedInput.update();
|
|
downloadProgress = downloaded;
|
|
downloadTotal = total;
|
|
requestUpdate(true);
|
|
return !mappedInput.wasPressed(MappedInputManager::Button::Back);
|
|
},
|
|
server.username, server.password);
|
|
|
|
if (result == HttpDownloader::OK) {
|
|
FsFile downloadedFile;
|
|
if (!Storage.openFileForRead("OPDS", filename, downloadedFile) || downloadedFile.size() == 0) {
|
|
LOG_ERR("OPDS", "Downloaded file is empty or unreadable: %s", filename.c_str());
|
|
if (downloadedFile) {
|
|
downloadedFile.close();
|
|
}
|
|
Storage.remove(filename.c_str());
|
|
state = BrowserState::ERROR;
|
|
errorMessage = tr(STR_DOWNLOAD_FAILED);
|
|
requestUpdate();
|
|
return;
|
|
}
|
|
downloadedFile.close();
|
|
|
|
LOG_DBG("OPDS", "Download complete: %s", filename.c_str());
|
|
|
|
// Clear any existing cache for this book just in case it's a redownload of
|
|
// a previously opened book.
|
|
if (acquisition.mimeType == "application/epub+zip") {
|
|
if (!book.imageHref.empty()) {
|
|
const std::string coverUrl =
|
|
(book.imageHref.rfind("http", 0) == 0) ? book.imageHref : UrlUtils::buildUrl(server.url, book.imageHref);
|
|
|
|
std::string baseFilename = filename;
|
|
size_t dotPos = baseFilename.find_last_of('.');
|
|
if (dotPos != std::string::npos) {
|
|
baseFilename.resize(dotPos);
|
|
}
|
|
|
|
std::string ext = ".jpg";
|
|
if (book.imageHref.length() >= 4) {
|
|
std::string lowerHref = book.imageHref.substr(book.imageHref.length() - 4);
|
|
std::transform(lowerHref.begin(), lowerHref.end(), lowerHref.begin(), ::tolower);
|
|
if (lowerHref == ".png") {
|
|
ext = ".png";
|
|
}
|
|
}
|
|
std::string sidecarPath = baseFilename + ext;
|
|
|
|
const auto coverDlResult = HttpDownloader::downloadToFile(
|
|
coverUrl, sidecarPath,
|
|
[this](const unsigned int, const unsigned int) {
|
|
mappedInput.update();
|
|
requestUpdate(true);
|
|
return !mappedInput.wasPressed(MappedInputManager::Button::Back);
|
|
},
|
|
server.username, server.password);
|
|
if (coverDlResult != HttpDownloader::OK) {
|
|
LOG_ERR("OPDS", "Failed to download cover from %s (err %d)", coverUrl.c_str(), (int)coverDlResult);
|
|
Storage.remove(sidecarPath.c_str());
|
|
}
|
|
}
|
|
|
|
Epub epub(filename, "/.crosspoint");
|
|
epub.clearCache();
|
|
} else if (acquisition.formatKey == "xtc" || acquisition.formatKey == "xtch") {
|
|
Xtc(filename, "/.crosspoint").clearCache();
|
|
}
|
|
selectedBookIndex = -1;
|
|
formatSelectionLabels.clear();
|
|
state = BrowserState::BROWSING;
|
|
requestUpdate();
|
|
} else if (result == HttpDownloader::ABORTED) {
|
|
selectedBookIndex = -1;
|
|
formatSelectionLabels.clear();
|
|
state = BrowserState::BROWSING;
|
|
requestUpdate();
|
|
} else {
|
|
selectedBookIndex = -1;
|
|
formatSelectionLabels.clear();
|
|
state = BrowserState::ERROR;
|
|
errorMessage = tr(STR_DOWNLOAD_FAILED);
|
|
requestUpdate();
|
|
}
|
|
}
|
|
|
|
void OpdsBookBrowserActivity::fetchOsdTemplate(const std::string& osdUrl) {
|
|
std::string content;
|
|
if (!HttpDownloader::fetchUrl(osdUrl, content)) {
|
|
LOG_ERR("OPDS", "Failed to fetch OSD: %s", osdUrl.c_str());
|
|
return;
|
|
}
|
|
|
|
struct OsdState {
|
|
std::string templateUrl;
|
|
static void XMLCALL onStart(void* ud, const XML_Char* name, const XML_Char** atts) {
|
|
if (strcmp(name, "Url") != 0 && strstr(name, ":Url") == nullptr) return;
|
|
auto* state = static_cast<OsdState*>(ud);
|
|
for (int i = 0; atts[i]; i += 2) {
|
|
if (strcmp(atts[i], "template") == 0 && strstr(atts[i + 1], "{searchTerms}") != nullptr) {
|
|
state->templateUrl = atts[i + 1];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
} osdState;
|
|
|
|
XML_Parser p = XML_ParserCreate(nullptr);
|
|
if (!p) {
|
|
LOG_ERR("OPDS", "OSD parser alloc failed");
|
|
return;
|
|
}
|
|
XML_SetUserData(p, &osdState);
|
|
XML_SetElementHandler(p, OsdState::onStart, nullptr);
|
|
XML_Parse(p, content.c_str(), static_cast<int>(content.size()), XML_TRUE);
|
|
XML_ParserFree(p);
|
|
|
|
if (!osdState.templateUrl.empty()) {
|
|
searchTemplate = UrlUtils::buildUrl(osdUrl, osdState.templateUrl);
|
|
}
|
|
}
|
|
|
|
void OpdsBookBrowserActivity::launchSearch() {
|
|
consumeConfirm = true;
|
|
state = BrowserState::SEARCH_INPUT;
|
|
requestUpdate();
|
|
|
|
auto keyboard = std::make_unique<KeyboardEntryActivity>(renderer, mappedInput, tr(STR_SEARCH));
|
|
startActivityForResult(std::move(keyboard), [this](const ActivityResult& result) {
|
|
state = BrowserState::BROWSING;
|
|
if (!result.isCancelled) {
|
|
performSearch(std::get<KeyboardResult>(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<char>(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);
|
|
currentPath = url;
|
|
|
|
state = BrowserState::LOADING;
|
|
statusMessage = tr(STR_LOADING);
|
|
requestUpdate(true);
|
|
fetchFeed(url);
|
|
}
|
|
|
|
void OpdsBookBrowserActivity::checkAndConnectWifi() {
|
|
// Already connected? Verify connection is valid by checking IP
|
|
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<WifiSelectionActivity>(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 {
|
|
WiFi.disconnect();
|
|
WiFi.mode(WIFI_OFF);
|
|
state = BrowserState::ERROR;
|
|
errorMessage = tr(STR_WIFI_CONN_FAILED);
|
|
requestUpdate();
|
|
}
|
|
}
|