mod: Phase 3 — Re-port unmerged upstream PRs

Re-applied upstream PRs not yet merged to upstream/master:

- #1055: Byte-level framebuffer writes (fillPhysicalHSpan*,
  optimized fillRect/drawLine/fillRectDither/fillPolygon)
- #1027: Word-width cache (FNV-1a, 128-entry) and hyphenation
  early exit in ParsedText for 7-9% layout speedup
- #1068: Already present in upstream — URL hyphenation fix
- #1019: Already present in upstream — file extensions in browser
- #1090/#1185/#1217: KOReader sync improvements — binary credential
  store, document hash caching, ChapterXPathIndexer integration
- #1209: OPDS multi-server — OpdsBookBrowserActivity accepts
  OpdsServer, directory picker for downloads, download-complete
  prompt with open/back options
- #857: Dictionary activities already ported in Phase 1/2
- #1003: Placeholder cover already integrated in Phase 2

Also fixed: STR_OFF i18n string, include paths, replaced
Epub::isValidThumbnailBmp with Storage.exists, replaced
StringUtils::checkFileExtension with FsHelpers equivalents.

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-07 16:15:42 -05:00
parent 30473c27d3
commit 60a3e21c0e
25 changed files with 811 additions and 295 deletions

View File

@@ -181,6 +181,10 @@ void ActivityManager::goToBrowser() {
replaceActivity(std::make_unique<OpdsBookBrowserActivity>(renderer, mappedInput));
}
void ActivityManager::goToBrowser(const OpdsServer& server) {
replaceActivity(std::make_unique<OpdsBookBrowserActivity>(renderer, mappedInput, &server));
}
void ActivityManager::goToReader(std::string path) {
replaceActivity(std::make_unique<ReaderActivity>(renderer, mappedInput, std::move(path)));
}

View File

@@ -11,6 +11,7 @@
#include "GfxRenderer.h"
#include "MappedInputManager.h"
#include "OpdsServerStore.h"
class Activity; // forward declaration
class RenderLock; // forward declaration
@@ -82,6 +83,7 @@ class ActivityManager {
void goToFileBrowser(std::string path = {});
void goToRecentBooks();
void goToBrowser();
void goToBrowser(const struct OpdsServer& server);
void goToReader(std::string path);
void goToSleep();
void goToBoot();

View File

@@ -7,9 +7,11 @@
#include <OpdsStream.h>
#include <WiFi.h>
#include "CrossPointSettings.h"
#include "activities/ActivityResult.h"
#include "MappedInputManager.h"
#include "OpdsServerStore.h"
#include "activities/network/WifiSelectionActivity.h"
#include "activities/util/DirectoryPickerActivity.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "network/HttpDownloader.h"
@@ -23,16 +25,22 @@ constexpr int PAGE_ITEMS = 23;
void OpdsBookBrowserActivity::onEnter() {
Activity::onEnter();
OPDS_STORE.loadFromFile();
// Resolve server from store if not provided at construction
if (server.url.empty() && OPDS_STORE.hasServers()) {
const auto* s = OPDS_STORE.getServer(0);
if (s) server = *s;
}
state = BrowserState::CHECK_WIFI;
entries.clear();
navigationHistory.clear();
currentPath = ""; // Root path - user provides full URL in settings
currentPath = "";
selectorIndex = 0;
errorMessage.clear();
statusMessage = tr(STR_CHECKING_WIFI);
requestUpdate();
// Check WiFi and connect if needed, then fetch feed
checkAndConnectWifi();
}
@@ -47,9 +55,7 @@ void OpdsBookBrowserActivity::onExit() {
}
void OpdsBookBrowserActivity::loop() {
// Handle WiFi selection subactivity
if (state == BrowserState::WIFI_SELECTION) {
// Should already handled by the WifiSelectionActivity
if (state == BrowserState::WIFI_SELECTION || state == BrowserState::PICKING_DIRECTORY) {
return;
}
@@ -91,18 +97,40 @@ void OpdsBookBrowserActivity::loop() {
return;
}
// Handle downloading state - no input allowed
if (state == BrowserState::DOWNLOADING) {
return;
}
// Handle browsing state
if (state == BrowserState::DOWNLOAD_COMPLETE) {
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
executePromptAction(0);
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
executePromptAction(promptSelection);
return;
}
buttonNavigator.onNextRelease([this] {
if (promptSelection != 1) {
promptSelection = 1;
requestUpdate();
}
});
buttonNavigator.onPreviousRelease([this] {
if (promptSelection != 0) {
promptSelection = 0;
requestUpdate();
}
});
return;
}
if (state == BrowserState::BROWSING) {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (!entries.empty()) {
const auto& entry = entries[selectorIndex];
if (entry.type == OpdsEntryType::BOOK) {
downloadBook(entry);
launchDirectoryPicker(entry);
} else {
navigateToEntry(entry);
}
@@ -142,7 +170,8 @@ 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);
const char* headerTitle = server.name.empty() ? tr(STR_OPDS_BROWSER) : server.name.c_str();
renderer.drawCenteredText(UI_12_FONT_ID, 15, headerTitle, true, EpdFontFamily::BOLD);
if (state == BrowserState::CHECK_WIFI) {
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str());
@@ -186,6 +215,31 @@ void OpdsBookBrowserActivity::render(RenderLock&&) {
return;
}
if (state == BrowserState::DOWNLOAD_COMPLETE) {
renderer.drawCenteredText(UI_12_FONT_ID, pageHeight / 2 - 50, tr(STR_DOWNLOAD_COMPLETE), true, EpdFontFamily::BOLD);
const auto maxWidth = pageWidth - 40;
auto title = renderer.truncatedText(UI_10_FONT_ID, statusMessage.c_str(), maxWidth);
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 20, title.c_str());
const int buttonY = pageHeight / 2 + 20;
const char* backText = tr(STR_BACK_TO_LISTING);
const char* openText = tr(STR_OPEN_BOOK);
std::string backLabel = promptSelection == 0 ? "[" + std::string(backText) + "]" : std::string(backText);
std::string openLabel = promptSelection == 1 ? "[" + std::string(openText) + "]" : std::string(openText);
const int backWidth = renderer.getTextWidth(UI_10_FONT_ID, backLabel.c_str());
const int openWidth = renderer.getTextWidth(UI_10_FONT_ID, openLabel.c_str());
constexpr int buttonSpacing = 40;
const int totalWidth = backWidth + buttonSpacing + openWidth;
const int startX = (pageWidth - totalWidth) / 2;
renderer.drawText(UI_10_FONT_ID, startX, buttonY, backLabel.c_str());
renderer.drawText(UI_10_FONT_ID, startX + backWidth + buttonSpacing, buttonY, openLabel.c_str());
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_CONFIRM), tr(STR_DIR_LEFT), tr(STR_DIR_RIGHT));
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 = tr(STR_OPEN);
@@ -228,22 +282,21 @@ void OpdsBookBrowserActivity::render(RenderLock&&) {
}
void OpdsBookBrowserActivity::fetchFeed(const std::string& path) {
const char* serverUrl = SETTINGS.opdsServerUrl;
if (strlen(serverUrl) == 0) {
if (server.url.empty()) {
state = BrowserState::ERROR;
errorMessage = tr(STR_NO_SERVER_URL);
requestUpdate();
return;
}
std::string url = UrlUtils::buildUrl(serverUrl, path);
std::string url = UrlUtils::buildUrl(server.url, path);
LOG_DBG("OPDS", "Fetching: %s", url.c_str());
OpdsParser parser;
{
OpdsParserStream stream{parser};
if (!HttpDownloader::fetchUrl(url, stream)) {
if (!HttpDownloader::fetchUrl(url, stream, server.username, server.password)) {
state = BrowserState::ERROR;
errorMessage = tr(STR_FETCH_FEED_FAILED);
requestUpdate();
@@ -306,41 +359,68 @@ void OpdsBookBrowserActivity::navigateBack() {
}
}
void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
void OpdsBookBrowserActivity::launchDirectoryPicker(const OpdsEntry& book) {
pendingBook = book;
state = BrowserState::PICKING_DIRECTORY;
requestUpdate();
startActivityForResult(
std::make_unique<DirectoryPickerActivity>(renderer, mappedInput, server.downloadPath),
[this](const ActivityResult& result) { onDirectoryPickerResult(result); });
}
void OpdsBookBrowserActivity::onDirectoryPickerResult(const ActivityResult& result) {
state = BrowserState::BROWSING;
if (result.isCancelled) {
requestUpdate();
return;
}
if (std::holds_alternative<KeyboardResult>(result.data)) {
const std::string& directory = std::get<KeyboardResult>(result.data).text;
downloadBook(pendingBook, directory);
} else {
requestUpdate();
}
}
void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book, const std::string& directory) {
state = BrowserState::DOWNLOADING;
statusMessage = book.title;
downloadProgress = 0;
downloadTotal = 0;
requestUpdate(true);
// Build full download URL
std::string downloadUrl = UrlUtils::buildUrl(SETTINGS.opdsServerUrl, book.href);
std::string downloadUrl = UrlUtils::buildUrl(server.url, book.href);
// Create sanitized filename: "Title - Author.epub" or just "Title.epub" if no author
std::string baseName = book.title;
if (!book.author.empty()) {
baseName += " - " + book.author;
}
std::string filename = "/" + StringUtils::sanitizeFilename(baseName) + ".epub";
std::string dir = directory;
if (dir.back() != '/') dir += '/';
std::string filename = dir + StringUtils::sanitizeFilename(baseName) + ".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); // Force update to refresh progress bar
});
requestUpdate(true);
},
server.username, server.password);
if (result == HttpDownloader::OK) {
LOG_DBG("OPDS", "Download complete: %s", filename.c_str());
// Invalidate any existing cache for this file to prevent stale metadata issues
Epub epub(filename, "/.crosspoint");
epub.clearCache();
LOG_DBG("OPDS", "Cleared cache for: %s", filename.c_str());
state = BrowserState::BROWSING;
downloadedFilePath = filename;
promptSelection = server.afterDownloadAction;
state = BrowserState::DOWNLOAD_COMPLETE;
requestUpdate();
} else {
state = BrowserState::ERROR;
@@ -349,6 +429,15 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
}
}
void OpdsBookBrowserActivity::executePromptAction(int action) {
if (action == 1) {
onSelectBook(downloadedFilePath);
return;
}
state = BrowserState::BROWSING;
requestUpdate();
}
void OpdsBookBrowserActivity::checkAndConnectWifi() {
// Already connected? Verify connection is valid by checking IP
if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) {

View File

@@ -6,6 +6,7 @@
#include <vector>
#include "../Activity.h"
#include "OpdsServerStore.h"
#include "util/ButtonNavigator.h"
/**
@@ -16,16 +17,19 @@
class OpdsBookBrowserActivity final : public Activity {
public:
enum class BrowserState {
CHECK_WIFI, // Checking WiFi connection
WIFI_SELECTION, // WiFi selection subactivity is active
LOADING, // Fetching OPDS feed
BROWSING, // Displaying entries (navigation or books)
DOWNLOADING, // Downloading selected EPUB
ERROR // Error state with message
CHECK_WIFI, // Checking WiFi connection
WIFI_SELECTION, // WiFi selection subactivity is active
LOADING, // Fetching OPDS feed
BROWSING, // Displaying entries (navigation or books)
PICKING_DIRECTORY, // Directory picker subactivity is active
DOWNLOADING, // Downloading selected EPUB
DOWNLOAD_COMPLETE, // Prompt: open book or go back to listing
ERROR // Error state with message
};
explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
: Activity("OpdsBookBrowser", renderer, mappedInput) {}
explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const OpdsServer* server = nullptr)
: Activity("OpdsBookBrowser", renderer, mappedInput), server(server ? *server : OpdsServer{}) {}
void onEnter() override;
void onExit() override;
@@ -43,6 +47,11 @@ class OpdsBookBrowserActivity final : public Activity {
std::string statusMessage;
size_t downloadProgress = 0;
size_t downloadTotal = 0;
std::string downloadedFilePath;
int promptSelection = 0; // 0 = back to listing, 1 = open book
OpdsServer server; // Copied at construction — safe even if the store changes during browsing
OpdsEntry pendingBook;
void checkAndConnectWifi();
void launchWifiSelection();
@@ -50,6 +59,9 @@ class OpdsBookBrowserActivity final : public Activity {
void fetchFeed(const std::string& path);
void navigateToEntry(const OpdsEntry& entry);
void navigateBack();
void downloadBook(const OpdsEntry& book);
void launchDirectoryPicker(const OpdsEntry& book);
void onDirectoryPickerResult(const ActivityResult& result);
void downloadBook(const OpdsEntry& book, const std::string& directory);
void executePromptAction(int action);
bool preventAutoSleep() override { return true; }
};

View File

@@ -3,7 +3,7 @@
#include <GfxRenderer.h>
#include <I18n.h>
#include "ActivityResult.h"
#include "activities/ActivityResult.h"
#include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h"

View File

@@ -244,6 +244,15 @@ std::string getFileName(std::string filename) {
return filename.substr(0, pos);
}
std::string getFileExtension(std::string filename) {
if (filename.back() == '/') {
return "";
}
const auto pos = filename.rfind('.');
if (pos == std::string::npos) return "";
return filename.substr(pos);
}
void FileBrowserActivity::render(RenderLock&&) {
renderer.clearScreen();
@@ -262,7 +271,8 @@ void FileBrowserActivity::render(RenderLock&&) {
GUI.drawList(
renderer, Rect{0, contentTop, pageWidth, contentHeight}, files.size(), selectorIndex,
[this](int index) { return getFileName(files[index]); }, nullptr,
[this](int index) { return UITheme::getFileIcon(files[index]); });
[this](int index) { return UITheme::getFileIcon(files[index]); },
[this](int index) { return getFileExtension(files[index]); }, false);
}
// Help text

View File

@@ -24,7 +24,7 @@
#include "components/UITheme.h"
#include "fontIds.h"
#include "util/BookManager.h"
#include "util/StringUtils.h"
int HomeActivity::getMenuItemCount() const {
int count = 4; // File Browser, Recents, File transfer, Settings
@@ -66,7 +66,7 @@ void HomeActivity::loadRecentCovers(int coverHeight) {
for (RecentBook& book : recentBooks) {
if (!book.coverBmpPath.empty()) {
std::string coverPath = UITheme::getCoverThumbPath(book.coverBmpPath, coverHeight);
if (!Epub::isValidThumbnailBmp(coverPath)) {
if (!Storage.exists(coverPath.c_str())) {
if (!showingLoading) {
showingLoading = true;
popupRect = GUI.drawPopup(renderer, tr(STR_LOADING));
@@ -75,7 +75,7 @@ void HomeActivity::loadRecentCovers(int coverHeight) {
bool success = false;
if (StringUtils::checkFileExtension(book.path, ".epub")) {
if (FsHelpers::hasEpubExtension(book.path)) {
Epub epub(book.path, "/.crosspoint");
if (!epub.load(false, true)) {
epub.load(true, true);
@@ -87,13 +87,9 @@ void HomeActivity::loadRecentCovers(int coverHeight) {
book.coverBmpPath = thumbPath;
} else {
const int thumbWidth = static_cast<int>(coverHeight * 0.6);
success = PlaceholderCoverGenerator::generate(coverPath, book.title, book.author, thumbWidth, coverHeight);
if (!success) {
epub.generateInvalidFormatThumbBmp(coverHeight);
}
PlaceholderCoverGenerator::generate(coverPath, book.title, book.author, thumbWidth, coverHeight);
}
} else if (StringUtils::checkFileExtension(book.path, ".xtch") ||
StringUtils::checkFileExtension(book.path, ".xtc")) {
} else if (FsHelpers::hasXtcExtension(book.path)) {
Xtc xtc(book.path, "/.crosspoint");
if (xtc.load()) {
success = xtc.generateThumbBmp(coverHeight);