Files
crosspoint-reader-mod/src/util/BookManager.cpp
cottongin 0e2440aea8 fix: resolve end-of-book deadlock, long-press guards, archive UX, and home screen refresh
- Fix device freeze at end-of-book by deferring EndOfBookMenuActivity
  creation from render() to loop() (avoids RenderLock deadlock) in
  EpubReaderActivity and XtcReaderActivity
- Add initialSkipRelease to BookManageMenuActivity to prevent stale
  Confirm release from triggering actions when opened via long-press
- Add initialSkipRelease to MyLibraryActivity for long-press Browse
  Files -> archive navigation
- Thread skip-release through HomeActivity callback and main.cpp
- Fix HomeActivity stale cover buffer after archive/delete by fully
  resetting render state (freeCoverBuffer, firstRenderDone, etc.)
- Swap short/long-press actions in .archive context: short-press opens
  manage menu, long-press unarchives and opens the book
- Add deferred open pattern (pendingOpenPath) to wait for Confirm
  release before navigating to reader after unarchive
- Add BookManager::cleanupEmptyArchiveDirs() to remove empty parent
  directories after unarchive/delete inside .archive
- Add optional unarchivedPath output parameter to BookManager::unarchiveBook
- Restyle EndOfBookMenuActivity to standard list layout with proper
  header, margins, and button hints matching other screens
- Change EndOfBookMenuActivity back button hint to "« Back"
- Add Table of Contents option to EndOfBookMenuActivity

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-21 07:37:36 -05:00

256 lines
8.0 KiB
C++

#include "BookManager.h"
#include <HalStorage.h>
#include <Logging.h>
#include <functional>
#include "RecentBooksStore.h"
#include "StringUtils.h"
namespace {
constexpr char ARCHIVE_ROOT[] = "/.archive";
constexpr char CACHE_ROOT[] = "/.crosspoint";
std::string getCachePrefix(const std::string& bookPath) {
if (StringUtils::checkFileExtension(bookPath, ".epub")) return "epub_";
if (StringUtils::checkFileExtension(bookPath, ".xtc") || StringUtils::checkFileExtension(bookPath, ".xtch"))
return "xtc_";
if (StringUtils::checkFileExtension(bookPath, ".txt") || StringUtils::checkFileExtension(bookPath, ".md"))
return "txt_";
return "epub_";
}
std::string computeCachePath(const std::string& bookPath) {
const auto prefix = getCachePrefix(bookPath);
return std::string(CACHE_ROOT) + "/" + prefix + std::to_string(std::hash<std::string>{}(bookPath));
}
// Ensure all parent directories of a path exist.
void ensureParentDirs(const std::string& path) {
for (size_t i = 1; i < path.length(); i++) {
if (path[i] == '/') {
Storage.mkdir(path.substr(0, i).c_str());
}
}
}
// Delete cover and thumbnail BMP files from a cache directory.
void deleteCoverFiles(const std::string& cachePath) {
auto dir = Storage.open(cachePath.c_str());
if (!dir || !dir.isDirectory()) {
if (dir) dir.close();
return;
}
dir.rewindDirectory();
char name[256];
for (auto file = dir.openNextFile(); file; file = dir.openNextFile()) {
file.getName(name, sizeof(name));
const std::string fname(name);
file.close();
const bool isCover = (fname == "cover.bmp" || fname == "cover_crop.bmp");
const bool isThumb = (fname.rfind("thumb_", 0) == 0 && StringUtils::checkFileExtension(fname, ".bmp"));
if (isCover || isThumb) {
const std::string fullPath = cachePath + "/" + fname;
Storage.remove(fullPath.c_str());
}
}
dir.close();
}
} // namespace
namespace BookManager {
std::string getBookCachePath(const std::string& bookPath) { return computeCachePath(bookPath); }
bool isArchived(const std::string& bookPath) {
return bookPath.rfind(std::string(ARCHIVE_ROOT) + "/", 0) == 0;
}
bool archiveBook(const std::string& bookPath) {
if (isArchived(bookPath)) {
LOG_ERR("BKMGR", "Book is already archived: %s", bookPath.c_str());
return false;
}
const std::string destPath = std::string(ARCHIVE_ROOT) + bookPath;
ensureParentDirs(destPath);
if (!Storage.rename(bookPath.c_str(), destPath.c_str())) {
LOG_ERR("BKMGR", "Failed to move book to archive: %s -> %s", bookPath.c_str(), destPath.c_str());
return false;
}
// Rename cache directory to match the new book path hash
const std::string oldCache = computeCachePath(bookPath);
const std::string newCache = computeCachePath(destPath);
if (oldCache != newCache && Storage.exists(oldCache.c_str())) {
if (!Storage.rename(oldCache.c_str(), newCache.c_str())) {
LOG_ERR("BKMGR", "Failed to rename cache dir: %s -> %s", oldCache.c_str(), newCache.c_str());
}
}
RECENT_BOOKS.removeBook(bookPath);
LOG_DBG("BKMGR", "Archived: %s -> %s", bookPath.c_str(), destPath.c_str());
return true;
}
bool unarchiveBook(const std::string& archivePath, std::string* unarchivedPath) {
if (!isArchived(archivePath)) {
LOG_ERR("BKMGR", "Book is not in archive: %s", archivePath.c_str());
return false;
}
// Strip "/.archive" prefix to get original path
std::string destPath = archivePath.substr(strlen(ARCHIVE_ROOT));
if (destPath.empty() || destPath[0] != '/') {
destPath = "/" + destPath;
}
// Check if original parent directory exists, fall back to root
const auto lastSlash = destPath.find_last_of('/');
std::string parentDir = (lastSlash != std::string::npos && lastSlash > 0) ? destPath.substr(0, lastSlash) : "/";
if (!Storage.exists(parentDir.c_str())) {
const auto filename = destPath.substr(lastSlash + 1);
destPath = "/" + filename;
LOG_DBG("BKMGR", "Original dir gone, unarchiving to root: %s", destPath.c_str());
}
ensureParentDirs(destPath);
if (!Storage.rename(archivePath.c_str(), destPath.c_str())) {
LOG_ERR("BKMGR", "Failed to move book from archive: %s -> %s", archivePath.c_str(), destPath.c_str());
return false;
}
// Rename cache directory
const std::string oldCache = computeCachePath(archivePath);
const std::string newCache = computeCachePath(destPath);
if (oldCache != newCache && Storage.exists(oldCache.c_str())) {
if (!Storage.rename(oldCache.c_str(), newCache.c_str())) {
LOG_ERR("BKMGR", "Failed to rename cache dir: %s -> %s", oldCache.c_str(), newCache.c_str());
}
}
RECENT_BOOKS.removeBook(archivePath);
LOG_DBG("BKMGR", "Unarchived: %s -> %s", archivePath.c_str(), destPath.c_str());
if (unarchivedPath) *unarchivedPath = destPath;
return true;
}
bool deleteBook(const std::string& bookPath) {
// Delete the book file
if (Storage.exists(bookPath.c_str())) {
if (!Storage.remove(bookPath.c_str())) {
LOG_ERR("BKMGR", "Failed to delete book file: %s", bookPath.c_str());
return false;
}
}
// Delete cache directory
const std::string cachePath = computeCachePath(bookPath);
if (Storage.exists(cachePath.c_str())) {
Storage.removeDir(cachePath.c_str());
}
RECENT_BOOKS.removeBook(bookPath);
LOG_DBG("BKMGR", "Deleted book: %s", bookPath.c_str());
return true;
}
bool deleteBookCache(const std::string& bookPath) {
const std::string cachePath = computeCachePath(bookPath);
if (Storage.exists(cachePath.c_str())) {
if (!Storage.removeDir(cachePath.c_str())) {
LOG_ERR("BKMGR", "Failed to delete cache: %s", cachePath.c_str());
return false;
}
}
RECENT_BOOKS.removeBook(bookPath);
LOG_DBG("BKMGR", "Deleted cache for: %s", bookPath.c_str());
return true;
}
bool reindexBook(const std::string& bookPath, bool alsoRegenerateCovers) {
const std::string cachePath = computeCachePath(bookPath);
if (!Storage.exists(cachePath.c_str())) {
LOG_DBG("BKMGR", "No cache to reindex for: %s", bookPath.c_str());
RECENT_BOOKS.removeBook(bookPath);
return true;
}
const auto prefix = getCachePrefix(bookPath);
if (prefix == "epub_") {
// Delete sections directory
const std::string sectionsPath = cachePath + "/sections";
if (Storage.exists(sectionsPath.c_str())) {
Storage.removeDir(sectionsPath.c_str());
}
// Delete book.bin (spine/TOC metadata)
const std::string bookBin = cachePath + "/book.bin";
if (Storage.exists(bookBin.c_str())) {
Storage.remove(bookBin.c_str());
}
// Delete CSS cache
const std::string cssCache = cachePath + "/css_rules.cache";
if (Storage.exists(cssCache.c_str())) {
Storage.remove(cssCache.c_str());
}
} else if (prefix == "txt_") {
// Delete page index
const std::string indexBin = cachePath + "/index.bin";
if (Storage.exists(indexBin.c_str())) {
Storage.remove(indexBin.c_str());
}
} else if (prefix == "xtc_") {
// XTC is pre-indexed; only covers/thumbs are cached
// Nothing to delete for sections
}
if (alsoRegenerateCovers) {
deleteCoverFiles(cachePath);
}
RECENT_BOOKS.removeBook(bookPath);
LOG_DBG("BKMGR", "Reindexed (covers=%d): %s", alsoRegenerateCovers, bookPath.c_str());
return true;
}
void cleanupEmptyArchiveDirs(const std::string& bookPath) {
if (!isArchived(bookPath)) return;
// Walk up from the book's parent directory, removing empty dirs
std::string dir = bookPath.substr(0, bookPath.find_last_of('/'));
const std::string archiveRoot(ARCHIVE_ROOT);
while (dir.length() > archiveRoot.length()) {
auto d = Storage.open(dir.c_str());
if (!d || !d.isDirectory()) {
if (d) d.close();
break;
}
auto child = d.openNextFile();
const bool empty = !child;
if (child) child.close();
d.close();
if (!empty) break;
Storage.rmdir(dir.c_str());
LOG_DBG("BKMGR", "Removed empty archive dir: %s", dir.c_str());
auto slash = dir.find_last_of('/');
if (slash == std::string::npos || slash == 0) break;
dir = dir.substr(0, slash);
}
}
} // namespace BookManager