feat: add directory picker for OPDS downloads with per-server default path

When downloading a book via OPDS, a directory picker now lets the user
choose the save location instead of always saving to the SD root. Each
OPDS server has a configurable default download path (persisted in
opds.json) that the picker opens to. Falls back to "/" if the saved
path no longer exists on disk.

- Add DirectoryPickerActivity (browse-only directory view with "Save Here")
- Add PICKING_DIRECTORY state to OpdsBookBrowserActivity
- Add downloadPath field to OpdsServer with JSON serialization
- Add Download Path setting to OPDS server edit screen
- Extract sortFileList() to StringUtils for shared use
- Add i18n strings: STR_SAVE_HERE, STR_SELECT_FOLDER, STR_DOWNLOAD_PATH

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-02 04:28:57 -05:00
parent 2aa13ea2de
commit 42011d5977
20 changed files with 363 additions and 72 deletions

View File

@@ -80,6 +80,7 @@ bool OpdsServerStore::saveToFile() const {
obj["url"] = server.url;
obj["username"] = server.username;
obj["password_obf"] = obfuscateToBase64(server.password);
obj["download_path"] = server.downloadPath;
}
String json;
@@ -114,6 +115,7 @@ bool OpdsServerStore::loadFromFile() {
server.password = obj["password"] | std::string("");
if (!server.password.empty()) needsResave = true;
}
server.downloadPath = obj["download_path"] | std::string("/");
servers.push_back(std::move(server));
}

View File

@@ -7,6 +7,7 @@ struct OpdsServer {
std::string url;
std::string username;
std::string password; // Plaintext in memory; obfuscated with hardware key on disk
std::string downloadPath = "/";
};
/**

View File

@@ -9,6 +9,7 @@
#include "MappedInputManager.h"
#include "activities/network/WifiSelectionActivity.h"
#include "activities/util/DirectoryPickerActivity.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "network/HttpDownloader.h"
@@ -52,6 +53,12 @@ void OpdsBookBrowserActivity::loop() {
return;
}
// Handle directory picker subactivity
if (state == BrowserState::PICKING_DIRECTORY) {
ActivityWithSubactivity::loop();
return;
}
// Handle error state - Confirm retries, Back goes back or home
if (state == BrowserState::ERROR) {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
@@ -101,7 +108,7 @@ void OpdsBookBrowserActivity::loop() {
if (!entries.empty()) {
const auto& entry = entries[selectorIndex];
if (entry.type == OpdsEntryType::BOOK) {
downloadBook(entry);
launchDirectoryPicker(entry);
} else {
navigateToEntry(entry);
}
@@ -304,22 +311,45 @@ void OpdsBookBrowserActivity::navigateBack() {
}
}
void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
void OpdsBookBrowserActivity::launchDirectoryPicker(const OpdsEntry& book) {
pendingBook = book;
state = BrowserState::PICKING_DIRECTORY;
requestUpdate();
enterNewActivity(new DirectoryPickerActivity(
renderer, mappedInput, [this](const std::string& dir) { onDirectorySelected(dir); },
[this] { onDirectoryPickerCancelled(); }, server.downloadPath));
}
void OpdsBookBrowserActivity::onDirectorySelected(const std::string& directory) {
// Copy before exitActivity() destroys the subactivity (and the referenced string)
std::string dir = directory;
exitActivity();
downloadBook(pendingBook, dir);
}
void OpdsBookBrowserActivity::onDirectoryPickerCancelled() {
exitActivity();
state = BrowserState::BROWSING;
requestUpdate();
}
void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book, const std::string& directory) {
state = BrowserState::DOWNLOADING;
statusMessage = book.title;
downloadProgress = 0;
downloadTotal = 0;
requestUpdate();
// Build full download URL
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());
@@ -335,7 +365,6 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
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());

View File

@@ -17,12 +17,13 @@
class OpdsBookBrowserActivity final : public ActivityWithSubactivity {
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
ERROR // Error state with message
};
explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
@@ -55,6 +56,11 @@ class OpdsBookBrowserActivity final : public ActivityWithSubactivity {
void fetchFeed(const std::string& path);
void navigateToEntry(const OpdsEntry& entry);
void navigateBack();
void downloadBook(const OpdsEntry& book);
void launchDirectoryPicker(const OpdsEntry& book);
void onDirectorySelected(const std::string& directory);
void onDirectoryPickerCancelled();
void downloadBook(const OpdsEntry& book, const std::string& directory);
bool preventAutoSleep() override { return true; }
OpdsEntry pendingBook;
};

View File

@@ -4,8 +4,6 @@
#include <HalStorage.h>
#include <I18n.h>
#include <algorithm>
#include "BookManageMenuActivity.h"
#include "MappedInputManager.h"
#include "components/UITheme.h"
@@ -17,58 +15,6 @@ namespace {
constexpr unsigned long GO_HOME_MS = 1000;
} // namespace
void sortFileList(std::vector<std::string>& strs) {
std::sort(begin(strs), end(strs), [](const std::string& str1, const std::string& str2) {
// Directories first
bool isDir1 = str1.back() == '/';
bool isDir2 = str2.back() == '/';
if (isDir1 != isDir2) return isDir1;
// Start naive natural sort
const char* s1 = str1.c_str();
const char* s2 = str2.c_str();
// Iterate while both strings have characters
while (*s1 && *s2) {
// Check if both are at the start of a number
if (isdigit(*s1) && isdigit(*s2)) {
// Skip leading zeros and track them
const char* start1 = s1;
const char* start2 = s2;
while (*s1 == '0') s1++;
while (*s2 == '0') s2++;
// Count digits to compare lengths first
int len1 = 0, len2 = 0;
while (isdigit(s1[len1])) len1++;
while (isdigit(s2[len2])) len2++;
// Different length so return smaller integer value
if (len1 != len2) return len1 < len2;
// Same length so compare digit by digit
for (int i = 0; i < len1; i++) {
if (s1[i] != s2[i]) return s1[i] < s2[i];
}
// Numbers equal so advance pointers
s1 += len1;
s2 += len2;
} else {
// Regular case-insensitive character comparison
char c1 = tolower(*s1);
char c2 = tolower(*s2);
if (c1 != c2) return c1 < c2;
s1++;
s2++;
}
}
// One string is prefix of other
return *s1 == '\0' && *s2 != '\0';
});
}
void MyLibraryActivity::loadFiles() {
files.clear();
@@ -101,7 +47,7 @@ void MyLibraryActivity::loadFiles() {
file.close();
}
root.close();
sortFileList(files);
StringUtils::sortFileList(files);
}
void MyLibraryActivity::onEnter() {

View File

@@ -7,14 +7,15 @@
#include "MappedInputManager.h"
#include "OpdsServerStore.h"
#include "activities/util/DirectoryPickerActivity.h"
#include "activities/util/KeyboardEntryActivity.h"
#include "components/UITheme.h"
#include "fontIds.h"
namespace {
// Editable fields: Name, URL, Username, Password.
// Editable fields: Name, URL, Username, Password, Download Path.
// Existing servers also show a Delete option (BASE_ITEMS + 1).
constexpr int BASE_ITEMS = 4;
constexpr int BASE_ITEMS = 5;
} // namespace
int OpdsSettingsActivity::getMenuItemCount() const {
@@ -144,7 +145,24 @@ void OpdsSettingsActivity::handleSelection() {
exitActivity();
requestUpdate();
}));
} else if (selectedIndex == 4 && !isNewServer) {
} else if (selectedIndex == 4) {
// Download Path
exitActivity();
enterNewActivity(new DirectoryPickerActivity(
renderer, mappedInput,
[this](const std::string& path) {
std::string dir = path;
editServer.downloadPath = dir;
saveServer();
exitActivity();
requestUpdate();
},
[this]() {
exitActivity();
requestUpdate();
},
editServer.downloadPath));
} else if (selectedIndex == 5 && !isNewServer) {
// Delete server
OPDS_STORE.removeServer(static_cast<size_t>(serverIndex));
onBack();
@@ -167,7 +185,7 @@ void OpdsSettingsActivity::render(Activity::RenderLock&&) {
const int menuItems = getMenuItemCount();
const StrId fieldNames[] = {StrId::STR_SERVER_NAME, StrId::STR_OPDS_SERVER_URL, StrId::STR_USERNAME,
StrId::STR_PASSWORD};
StrId::STR_PASSWORD, StrId::STR_DOWNLOAD_PATH};
GUI.drawList(
renderer, Rect{0, contentTop, pageWidth, contentHeight}, menuItems, static_cast<int>(selectedIndex),
@@ -187,6 +205,8 @@ void OpdsSettingsActivity::render(Activity::RenderLock&&) {
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("******");
} else if (index == 4) {
return editServer.downloadPath;
}
return std::string("");
},

View File

@@ -0,0 +1,166 @@
#include "DirectoryPickerActivity.h"
#include <HalStorage.h>
#include <I18n.h>
#include <cstring>
#include "MappedInputManager.h"
#include "components/UITheme.h"
#include "fontIds.h"
#include "util/StringUtils.h"
void DirectoryPickerActivity::onEnter() {
Activity::onEnter();
basepath = initialPath;
if (basepath.empty()) basepath = "/";
// Validate the initial path exists; fall back to root if not
auto dir = Storage.open(basepath.c_str());
if (!dir || !dir.isDirectory()) {
if (dir) dir.close();
basepath = "/";
} else {
dir.close();
}
selectorIndex = 0;
loadDirectories();
requestUpdate();
}
void DirectoryPickerActivity::onExit() {
directories.clear();
Activity::onExit();
}
void DirectoryPickerActivity::loadDirectories() {
directories.clear();
auto root = Storage.open(basepath.c_str());
if (!root || !root.isDirectory()) {
if (root) root.close();
return;
}
root.rewindDirectory();
char name[256];
for (auto file = root.openNextFile(); file; file = root.openNextFile()) {
file.getName(name, sizeof(name));
if (name[0] == '.' || strcmp(name, "System Volume Information") == 0) {
file.close();
continue;
}
if (file.isDirectory()) {
directories.emplace_back(std::string(name) + "/");
}
file.close();
}
root.close();
StringUtils::sortFileList(directories);
}
void DirectoryPickerActivity::loop() {
// Absorb the Confirm release from the parent activity that launched us
if (waitForConfirmRelease) {
if (!mappedInput.isPressed(MappedInputManager::Button::Confirm)) {
waitForConfirmRelease = false;
}
return;
}
// Index 0 = "Save Here", indices 1..N = directory entries
const int totalItems = 1 + static_cast<int>(directories.size());
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (selectorIndex == 0) {
onSelect(basepath);
} else {
const auto& dirName = directories[selectorIndex - 1];
// Strip trailing '/'
std::string folderName = dirName.substr(0, dirName.length() - 1);
basepath = (basepath.back() == '/' ? basepath : basepath + "/") + folderName;
selectorIndex = 0;
loadDirectories();
requestUpdate();
}
return;
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
if (basepath == "/") {
onCancel();
} else {
auto slash = basepath.find_last_of('/');
basepath = (slash == 0) ? "/" : basepath.substr(0, slash);
selectorIndex = 0;
loadDirectories();
requestUpdate();
}
return;
}
buttonNavigator.onNextRelease([this, totalItems] {
selectorIndex = ButtonNavigator::nextIndex(selectorIndex, totalItems);
requestUpdate();
});
buttonNavigator.onPreviousRelease([this, totalItems] {
selectorIndex = ButtonNavigator::previousIndex(selectorIndex, totalItems);
requestUpdate();
});
const int pageItems = UITheme::getInstance().getNumberOfItemsPerPage(renderer, true, false, true, false);
buttonNavigator.onNextContinuous([this, totalItems, pageItems] {
selectorIndex = ButtonNavigator::nextPageIndex(selectorIndex, totalItems, pageItems);
requestUpdate();
});
buttonNavigator.onPreviousContinuous([this, totalItems, pageItems] {
selectorIndex = ButtonNavigator::previousPageIndex(selectorIndex, totalItems, pageItems);
requestUpdate();
});
}
void DirectoryPickerActivity::render(Activity::RenderLock&&) {
renderer.clearScreen();
const auto& metrics = UITheme::getInstance().getMetrics();
const auto pageWidth = renderer.getScreenWidth();
const auto pageHeight = renderer.getScreenHeight();
std::string folderName = (basepath == "/") ? tr(STR_SD_CARD) : basepath.substr(basepath.rfind('/') + 1);
GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, tr(STR_SELECT_FOLDER));
const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing;
const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing;
const int totalItems = 1 + static_cast<int>(directories.size());
GUI.drawList(
renderer, Rect{0, contentTop, pageWidth, contentHeight}, totalItems, selectorIndex,
[this](int index) -> std::string {
if (index == 0) {
std::string label = std::string(tr(STR_SAVE_HERE)) + " (" + basepath + ")";
return label;
}
// Strip trailing '/' for display
const auto& dir = directories[index - 1];
return dir.substr(0, dir.length() - 1);
},
nullptr,
[this](int index) -> UIIcon {
return (index == 0) ? UIIcon::File : UIIcon::Folder;
});
const char* backLabel = (basepath == "/") ? tr(STR_CANCEL) : tr(STR_BACK);
const char* confirmLabel = (selectorIndex == 0) ? tr(STR_SAVE_HERE) : tr(STR_OPEN);
const auto labels = mappedInput.mapLabels(backLabel, confirmLabel, tr(STR_DIR_UP), tr(STR_DIR_DOWN));
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
}

View File

@@ -0,0 +1,44 @@
#pragma once
#include <functional>
#include <string>
#include <vector>
#include "../Activity.h"
#include "util/ButtonNavigator.h"
/**
* Directory picker subactivity for selecting a save location on the SD card.
* Shows only directories and a "Save Here" option at index 0.
* Navigating into a subdirectory updates the current path; Back goes up.
* Pressing Back at root calls onCancel.
*/
class DirectoryPickerActivity final : public Activity {
public:
explicit DirectoryPickerActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
std::function<void(const std::string& path)> onSelect,
std::function<void()> onCancel,
std::string initialPath = "/")
: Activity("DirectoryPicker", renderer, mappedInput),
initialPath(std::move(initialPath)),
onSelect(std::move(onSelect)),
onCancel(std::move(onCancel)) {}
void onEnter() override;
void onExit() override;
void loop() override;
void render(RenderLock&&) override;
private:
ButtonNavigator buttonNavigator;
std::string initialPath;
std::string basepath = "/";
std::vector<std::string> directories;
int selectorIndex = 0;
bool waitForConfirmRelease = true;
std::function<void(const std::string& path)> onSelect;
std::function<void()> onCancel;
void loadDirectories();
};

View File

@@ -1,5 +1,6 @@
#include "StringUtils.h"
#include <algorithm>
#include <cstring>
namespace StringUtils {
@@ -61,4 +62,43 @@ bool checkFileExtension(const String& fileName, const char* extension) {
return localFile.endsWith(localExtension);
}
void sortFileList(std::vector<std::string>& entries) {
std::sort(begin(entries), end(entries), [](const std::string& str1, const std::string& str2) {
bool isDir1 = str1.back() == '/';
bool isDir2 = str2.back() == '/';
if (isDir1 != isDir2) return isDir1;
const char* s1 = str1.c_str();
const char* s2 = str2.c_str();
while (*s1 && *s2) {
if (isdigit(*s1) && isdigit(*s2)) {
while (*s1 == '0') s1++;
while (*s2 == '0') s2++;
int len1 = 0, len2 = 0;
while (isdigit(s1[len1])) len1++;
while (isdigit(s2[len2])) len2++;
if (len1 != len2) return len1 < len2;
for (int i = 0; i < len1; i++) {
if (s1[i] != s2[i]) return s1[i] < s2[i];
}
s1 += len1;
s2 += len2;
} else {
char c1 = tolower(*s1);
char c2 = tolower(*s2);
if (c1 != c2) return c1 < c2;
s1++;
s2++;
}
}
return *s1 == '\0' && *s2 != '\0';
});
}
} // namespace StringUtils

View File

@@ -3,6 +3,7 @@
#include <WString.h>
#include <string>
#include <vector>
namespace StringUtils {
@@ -19,4 +20,10 @@ std::string sanitizeFilename(const std::string& name, size_t maxLength = 100);
bool checkFileExtension(const std::string& fileName, const char* extension);
bool checkFileExtension(const String& fileName, const char* extension);
/**
* Sort a file/directory list with directories first, using case-insensitive natural sort.
* Directory entries are identified by a trailing '/'.
*/
void sortFileList(std::vector<std::string>& entries);
} // namespace StringUtils