#include "FileBrowserActivity.h" #include #include #include #include #include #include #include "CrossPointSettings.h" #include "MappedInputManager.h" #include "activities/util/ConfirmationActivity.h" #include "components/UITheme.h" #include "fontIds.h" #include "util/BookCacheUtils.h" namespace { constexpr unsigned long GO_HOME_MS = 1000; constexpr size_t NAME_BUFFER_SIZE = 500; } // namespace void FileBrowserActivity::loadFiles() { files.clear(); auto root = Storage.open(basepath.c_str()); if (!root || !root.isDirectory()) { return; } root.rewindDirectory(); if (!fileNameBuffer) { LOG_ERR("FileBrowser", "fileNameBuffer not allocated"); root.close(); return; } for (auto file = root.openNextFile(); file; file = root.openNextFile()) { file.getName(fileNameBuffer.get(), NAME_BUFFER_SIZE); if ((!SETTINGS.showHiddenFiles && fileNameBuffer[0] == '.') || strcmp(fileNameBuffer.get(), "System Volume Information") == 0) { continue; } if (file.isDirectory()) { files.emplace_back(std::string(fileNameBuffer.get()) + "/"); } else { std::string_view filename{fileNameBuffer.get()}; if (mode == Mode::PickFirmware) { // Firmware picker: only show .bin files. if (FsHelpers::checkFileExtension(filename, ".bin")) { files.emplace_back(filename); } } else if (FsHelpers::hasEpubExtension(filename) || FsHelpers::hasXtcExtension(filename) || FsHelpers::hasTxtExtension(filename) || FsHelpers::hasMarkdownExtension(filename) || FsHelpers::hasBmpExtension(filename)) { files.emplace_back(filename); } } } root.close(); FsHelpers::sortFileList(files); } void FileBrowserActivity::onEnter() { Activity::onEnter(); fileNameBuffer = makeUniqueNoThrow(NAME_BUFFER_SIZE); if (!fileNameBuffer) { LOG_ERR("FileBrowser", "malloc failed for name buffer"); return; } selectorIndex = 0; // If Confirm was held while this activity opened (typical when launched from a menu), ignore // its release — otherwise we'd immediately auto-open whatever is at index 0. lockNextConfirmRelease = mappedInput.isPressed(MappedInputManager::Button::Confirm); auto root = Storage.open(basepath.c_str()); if (!root) { basepath = "/"; loadFiles(); } else if (!root.isDirectory()) { lockLongPressBack = mappedInput.isPressed(MappedInputManager::Button::Back); const std::string oldPath = basepath; basepath = FsHelpers::extractFolderPath(basepath); loadFiles(); const auto pos = oldPath.find_last_of('/'); const std::string fileName = oldPath.substr(pos + 1); selectorIndex = findEntry(fileName); } else { loadFiles(); } requestUpdate(); } void FileBrowserActivity::onExit() { Activity::onExit(); files.clear(); fileNameBuffer.reset(); } // To avoid traversing directories twice (once for cache clearing, once for deletion), // we do both in one pass here, instead of using Storage.removeDir bool FileBrowserActivity::removeDirFile(const std::string& fullPath) { auto file = Storage.open(fullPath.c_str()); if (!file) { LOG_ERR("FileBrowser", "Failed to open for metadata clearing: %s", fullPath.c_str()); return false; } if (!file.isDirectory()) { file.close(); clearBookCache(fullPath); return Storage.remove(fullPath.c_str()); } file.close(); if (!fileNameBuffer) { LOG_ERR("FileBrowser", "fileNameBuffer not allocated"); return false; } // Stack of (dirPath, postOrder): postOrder=true means rmdir this path after children are processed. std::vector> stack; stack.reserve(16); stack.push_back({fullPath, false}); while (!stack.empty()) { auto [currentPath, postOrder] = std::move(stack.back()); stack.pop_back(); if (postOrder) { if (!Storage.rmdir(currentPath.c_str())) { LOG_ERR("FileBrowser", "Failed to rmdir: %s", currentPath.c_str()); return false; } continue; } auto dir = Storage.open(currentPath.c_str()); if (!dir) { LOG_ERR("FileBrowser", "Failed to open dir: %s", currentPath.c_str()); return false; } if (!dir.isDirectory()) { LOG_ERR("FileBrowser", "Not a directory: %s", currentPath.c_str()); return false; } // Push this dir for post-order rmdir (after all children are processed). stack.push_back({currentPath, true}); dir.rewindDirectory(); for (auto entry = dir.openNextFile(); entry; entry = dir.openNextFile()) { entry.getName(fileNameBuffer.get(), NAME_BUFFER_SIZE); if (strcmp(fileNameBuffer.get(), ".") == 0 || strcmp(fileNameBuffer.get(), "..") == 0) { continue; } std::string entryPath = currentPath; if (entryPath.back() != '/') { entryPath += "/"; } entryPath += fileNameBuffer.get(); const bool isDir = entry.isDirectory(); entry.close(); if (isDir) { stack.push_back({std::move(entryPath), false}); } else { clearBookCache(entryPath); if (!Storage.remove(entryPath.c_str())) { LOG_ERR("FileBrowser", "Failed to remove file: %s", entryPath.c_str()); return false; } } } } return true; } void FileBrowserActivity::loop() { // Long press BACK (1s+) goes to root folder (Books mode only). // In firmware-pick mode we keep navigation simple: short Back = up dir / cancel. if (mode == Mode::Books && mappedInput.isPressed(MappedInputManager::Button::Back) && mappedInput.getHeldTime() >= GO_HOME_MS && basepath != "/" && !lockLongPressBack) { basepath = "/"; loadFiles(); selectorIndex = 0; requestUpdate(); return; } if (lockLongPressBack && mappedInput.wasReleased(MappedInputManager::Button::Back)) { lockLongPressBack = false; return; } const int pathReserved = renderer.getLineHeight(SMALL_FONT_ID) + UITheme::getInstance().getMetrics().verticalSpacing; const int pageItems = UITheme::getNumberOfItemsPerPage(renderer, true, false, true, false, pathReserved); // Vertical swipe page-scrolls the list (touch nav without the side buttons). int scrollIdx = static_cast(selectorIndex); if (mappedInput.wasListScroll(scrollIdx, static_cast(files.size()), pageItems)) { selectorIndex = scrollIdx; requestUpdate(); return; } int downId = -1; if (mappedInput.wasItemTouchedDown(downId) && downId >= 0 && downId < static_cast(files.size())) { selectorIndex = downId; requestUpdate(); } // A tap opens the tapped entry (held-time is 0 on a tap, so it takes the short-press // open path below, never the long-press delete). int tappedId = -1; const bool tapped = mappedInput.wasItemTapped(tappedId); if (tapped && tappedId >= 0 && tappedId < static_cast(files.size())) { selectorIndex = tappedId; } if (tapped || mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (lockNextConfirmRelease) { lockNextConfirmRelease = false; return; } if (files.empty()) return; const std::string& entry = files[selectorIndex]; bool isDirectory = (entry.back() == '/'); // Firmware picker: select file -> return path; navigate into directories normally. if (mode == Mode::PickFirmware && !isDirectory) { std::string cleanBasePath = basepath; if (cleanBasePath.back() != '/') cleanBasePath += "/"; ActivityResult res{FilePathResult{cleanBasePath + entry}}; res.isCancelled = false; setResult(std::move(res)); finish(); return; } if (mode == Mode::Books && mappedInput.getHeldTime() >= GO_HOME_MS) { // --- LONG PRESS ACTION: DELETE FILE OR DIRECTORY --- std::string cleanBasePath = basepath; if (cleanBasePath.back() != '/') cleanBasePath += "/"; const std::string fullPath = cleanBasePath + entry; auto handler = [this, fullPath](const ActivityResult& res) { if (!res.isCancelled) { LOG_DBG("FileBrowser", "Attempting to delete: %s", fullPath.c_str()); if (removeDirFile(fullPath)) { LOG_DBG("FileBrowser", "Deleted successfully"); loadFiles(); if (files.empty()) { selectorIndex = 0; } else if (selectorIndex >= files.size()) { // Move selection to the new "last" item selectorIndex = files.size() - 1; } requestUpdate(true); } else { LOG_ERR("FileBrowser", "Failed to delete: %s", fullPath.c_str()); } } else { LOG_DBG("FileBrowser", "Delete cancelled by user"); } }; std::string heading = tr(STR_DELETE) + std::string("? "); startActivityForResult(std::make_unique(renderer, mappedInput, heading, entry), handler); return; } else { // --- SHORT PRESS ACTION: OPEN/NAVIGATE --- if (basepath.back() != '/') basepath += "/"; if (isDirectory) { basepath += entry.substr(0, entry.length() - 1); loadFiles(); selectorIndex = 0; requestUpdate(); } else { onSelectBook(basepath + entry); } } return; } if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { // Short press: go up one directory, or go home if at root if (mappedInput.getHeldTime() < GO_HOME_MS) { if (basepath != "/") { const std::string oldPath = basepath; basepath.replace(basepath.find_last_of('/'), std::string::npos, ""); if (basepath.empty()) basepath = "/"; loadFiles(); const auto pos = oldPath.find_last_of('/'); const std::string dirName = oldPath.substr(pos + 1) + "/"; selectorIndex = findEntry(dirName); requestUpdate(); } else if (mode == Mode::PickFirmware) { // Firmware picker at root: cancel back to caller instead of going home. ActivityResult res; res.isCancelled = true; setResult(std::move(res)); finish(); } else { onGoHome(); } } } int listSize = static_cast(files.size()); buttonNavigator.onNextRelease([this, listSize] { selectorIndex = ButtonNavigator::nextIndex(static_cast(selectorIndex), listSize); requestUpdate(); }); buttonNavigator.onPreviousRelease([this, listSize] { selectorIndex = ButtonNavigator::previousIndex(static_cast(selectorIndex), listSize); requestUpdate(); }); buttonNavigator.onNextContinuous([this, listSize, pageItems] { selectorIndex = ButtonNavigator::nextPageIndex(static_cast(selectorIndex), listSize, pageItems); requestUpdate(); }); buttonNavigator.onPreviousContinuous([this, listSize, pageItems] { selectorIndex = ButtonNavigator::previousPageIndex(static_cast(selectorIndex), listSize, pageItems); requestUpdate(); }); } std::string getFileName(std::string filename) { if (filename.back() == '/') { filename.pop_back(); if (!UITheme::getInstance().getTheme().showsFileIcons()) { return "[" + filename + "]"; } return filename; } const auto pos = filename.rfind('.'); return filename.substr(0, pos); } std::string getFileExtension(std::string filename) { if (filename.back() == '/') { return ""; } const auto pos = filename.rfind('.'); return filename.substr(pos); } void FileBrowserActivity::render(RenderLock&&) { renderer.clearScreen(); const auto pageWidth = renderer.getScreenWidth(); const auto pageHeight = renderer.getScreenHeight(); const auto& metrics = UITheme::getInstance().getMetrics(); std::string folderName = (mode == Mode::PickFirmware) ? std::string(tr(STR_SELECT_FIRMWARE_FILE)) : ((basepath == "/") ? std::string(tr(STR_SD_CARD)) : basepath.substr(basepath.rfind('/') + 1)); GUI.drawHeader(renderer, Rect{0, metrics.topPadding, pageWidth, metrics.headerHeight}, folderName.c_str()); const int pathLineHeight = renderer.getLineHeight(SMALL_FONT_ID); const int pathReserved = pathLineHeight + metrics.verticalSpacing; const int contentTop = metrics.topPadding + metrics.headerHeight + metrics.verticalSpacing; const int contentHeight = pageHeight - contentTop - metrics.buttonHintsHeight - metrics.verticalSpacing - pathReserved; if (files.empty()) { const char* emptyMsg = (mode == Mode::PickFirmware) ? tr(STR_NO_BIN_FILES) : tr(STR_NO_FILES_FOUND); renderer.drawText(UI_10_FONT_ID, metrics.contentSidePadding, contentTop + 20, emptyMsg); } else { 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 getFileExtension(files[index]); }, false); } // Full path display { const int pathY = pageHeight - metrics.buttonHintsHeight - metrics.verticalSpacing - pathLineHeight; const int separatorY = pathY - metrics.verticalSpacing / 2; renderer.drawLine(0, separatorY, pageWidth - 1, separatorY, 3, true); const int pathMaxWidth = pageWidth - metrics.contentSidePadding * 2; // Left-truncate so the deepest directory is always visible const char* pathStr = basepath.c_str(); const char* pathDisplay = pathStr; char leftTruncBuf[256]; if (renderer.getTextWidth(SMALL_FONT_ID, pathStr) > pathMaxWidth) { const char ellipsis[] = "\xe2\x80\xa6"; // UTF-8 ellipsis (…) const int ellipsisWidth = renderer.getTextWidth(SMALL_FONT_ID, ellipsis); const int available = pathMaxWidth - ellipsisWidth; // Walk forward from the start until the suffix fits, skipping UTF-8 continuation bytes const char* p = pathStr; while (*p) { if (renderer.getTextWidth(SMALL_FONT_ID, p) <= available) break; ++p; while (*p && (static_cast(*p) & 0xC0) == 0x80) ++p; } snprintf(leftTruncBuf, sizeof(leftTruncBuf), "%s%s", ellipsis, p); pathDisplay = leftTruncBuf; } renderer.drawText(SMALL_FONT_ID, metrics.contentSidePadding, pathY, pathDisplay); } // Help text const char* backLabel = (basepath == "/") ? (mode == Mode::PickFirmware ? tr(STR_BACK) : tr(STR_HOME)) : tr(STR_BACK); // In PickFirmware mode, Confirm on a .bin returns the path to the caller (not "open"); show // STR_SELECT instead. Directories in the same picker still descend, so keep STR_OPEN there. const bool selectingFirmwareFile = mode == Mode::PickFirmware && !files.empty() && files[selectorIndex].back() != '/'; const char* confirmLabel = files.empty() ? "" : (selectingFirmwareFile ? tr(STR_SELECT) : tr(STR_OPEN)); const auto labels = mappedInput.mapLabels(backLabel, confirmLabel, files.empty() ? "" : tr(STR_DIR_UP), files.empty() ? "" : tr(STR_DIR_DOWN)); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4); renderer.displayBuffer(); } size_t FileBrowserActivity::findEntry(const std::string& name) const { for (size_t i = 0; i < files.size(); i++) if (files[i] == name) return i; return 0; }