#include "EpubReader.h"
#include "HtmlStrip.h" // stripHtmlToText
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
#include
static const char* TAG = "EpubReader";
static const char* EPUB_FILE_ARGUMENT = "file";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
std::string EpubReader::getAppDataRoot(AppHandle app) {
char path[128] = {0};
size_t sz = sizeof(path);
tt_app_get_user_data_path(app, path, &sz);
// path = /sdcard/user/app/one.tactility.epubreader → extract "/sdcard"
// path = /data/user/app/one.tactility.epubreader → extract "/data"
std::string s(path);
size_t pos = s.find('/', 1);
return (pos != std::string::npos) ? s.substr(0, pos) : "/sdcard";
}
// ---------------------------------------------------------------------------
// Books folder persistence
// ---------------------------------------------------------------------------
void EpubReader::loadBooksPath() {
if (!appHandle_) return;
char path[128]; size_t sz = sizeof(path);
tt_app_get_user_data_child_path(appHandle_, "books_folder.txt", path, &sz);
FILE* f = fopen(path, "r");
if (!f) return;
char buf[256] = {};
if (fgets(buf, sizeof(buf), f)) {
size_t len = strlen(buf);
if (len > 0 && buf[len - 1] == '\n') buf[len - 1] = '\0';
if (buf[0] != '\0') booksPath_ = buf;
}
fclose(f);
}
void EpubReader::saveBooksPath() {
if (!appHandle_) return;
char path[128]; size_t sz = sizeof(path);
tt_app_get_user_data_child_path(appHandle_, "books_folder.txt", path, &sz);
FILE* f = fopen(path, "w");
if (!f) return;
fprintf(f, "%s\n", booksPath_.c_str());
fclose(f);
}
// ---------------------------------------------------------------------------
// File type helpers (static members - used by UI and Async TUs via the class)
// ---------------------------------------------------------------------------
bool EpubReader::isSupportedFile(const std::string& name) {
auto pos = name.rfind('.');
if (pos == std::string::npos) return false;
std::string ext = name.substr(pos);
std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower);
return ext == ".epub" || ext == ".txt";
}
bool EpubReader::isTextFile(const std::string& path) {
auto pos = path.rfind('.');
if (pos == std::string::npos) return false;
std::string ext = path.substr(pos);
std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower);
return ext == ".txt";
}
// ---------------------------------------------------------------------------
// Persistence - plain text file in the app user-data directory
// ---------------------------------------------------------------------------
void EpubReader::saveProgress() {
if (currentFilePath_.empty() || !appHandle_) return;
char path[128]; size_t sz = sizeof(path);
tt_app_get_user_data_child_path(appHandle_, "progress.txt", path, &sz);
FILE* f = fopen(path, "w");
if (!f) return;
// Text mode: pageOffset_ is a scroll-Y pixel position; epub: spine chapter index.
// The mode tag makes the file self-describing so the value's semantics are unambiguous.
int savedIndex = textMode_ ? (int)pageOffset_ : currentSpineIndex_;
fprintf(f, "%s\n%d\n%s\n", currentFilePath_.c_str(), savedIndex,
textMode_ ? "text" : "epub");
fclose(f);
}
bool EpubReader::loadProgress(std::string& outPath, int& outChapter, bool& outIsText) {
if (!appHandle_) return false;
char path[128]; size_t sz = sizeof(path);
tt_app_get_user_data_child_path(appHandle_, "progress.txt", path, &sz);
FILE* f = fopen(path, "r");
if (!f) return false;
char filePath[256] = {};
bool ok = (fgets(filePath, sizeof(filePath), f) != nullptr);
int chapter = 0;
char modeStr[8] = "epub"; // default for backward-compat with old progress files
if (ok) {
size_t len = strlen(filePath);
if (len > 0 && filePath[len - 1] == '\n') filePath[len - 1] = '\0';
if (fscanf(f, "%d", &chapter) != 1) chapter = 0;
fscanf(f, " %7s", modeStr); // optional - old files won't have it
}
fclose(f);
if (!ok || filePath[0] == '\0') return false;
outPath = filePath;
outChapter = chapter;
outIsText = (strcmp(modeStr, "text") == 0);
return true;
}
// ---------------------------------------------------------------------------
// Chapter loading and pagination
// ---------------------------------------------------------------------------
// Render all of pageContent_ as per-paragraph labels, then restore saved scroll position.
// Prev/Next navigate by scrolling one viewport height within the chapter; at chapter
// boundaries they load the adjacent chapter (like text mode, but across chapters).
void EpubReader::renderPage() {
if (!contentWidget_) return;
lv_obj_clean(contentWidget_);
if (pageContent_.empty()) {
lv_obj_t* lbl = lv_label_create(contentWidget_);
lv_label_set_text(lbl, "(Empty chapter)");
lv_obj_scroll_to_y(lv_obj_get_parent(contentWidget_), 0, LV_ANIM_OFF);
return;
}
renderSlice(pageContent_);
lv_coord_t scrollY = (pageOffset_ > (size_t)LV_COORD_MAX) ? LV_COORD_MAX : (lv_coord_t)pageOffset_;
lv_obj_scroll_to_y(lv_obj_get_parent(contentWidget_), scrollY, LV_ANIM_OFF);
}
void EpubReader::loadChapter(int index, int direction) {
if (!epub_ || !contentWidget_) return;
const auto& spine = epub_->getSpine();
// Auto-skip chapters whose HTML strips to nothing (image-only, boilerplate, etc.)
// currentSpineIndex_ is NOT updated until we confirm the chapter has content -
// if we exhaust the spine before finding any, we return with it unchanged so
// subsequent navigation calls aren't confused by a corrupted index.
while (true) {
if (index < 0 || index >= (int)spine.size()) return;
std::string html = epub_->readFile(spine[index].href, MAX_CHAPTER_HTML);
stripHtmlToText(html, pageContent_);
html = {}; // release raw HTML before rendering
if (!pageContent_.empty()) break;
// Chapter stripped to nothing - advance in the navigation direction
if (direction == 0) {
currentSpineIndex_ = index;
pageOffset_ = 0;
LOG_W(TAG, "Chapter %d stripped to nothing and no direction to skip - blank chapter", index);
lv_obj_clean(contentWidget_);
lv_obj_t* lbl = lv_label_create(contentWidget_);
lv_label_set_text(lbl, "(Chapter content unavailable)");
return;
}
index += direction;
}
currentSpineIndex_ = index;
pageOffset_ = 0;
renderPage();
// When arriving from a later chapter (going backward), jump to the end of this chapter
// after LVGL has laid out the labels (deferred so content height is known).
if (direction < 0) lv_async_call(asyncScrollToEnd, this);
saveProgress();
}
void EpubReader::openTocDialog() {
if (!epub_) return;
const auto& toc = epub_->getToc();
if (toc.empty()) return;
std::vector titles;
titles.reserve(toc.size());
for (const auto& item : toc) {
titles.push_back(item.title.c_str());
}
tocDialogId_ = tt_app_selectiondialog_start(
"Table of Contents", (int)titles.size(), titles.data()
);
}
// ---------------------------------------------------------------------------
// App lifecycle
// ---------------------------------------------------------------------------
void EpubReader::onShow(AppHandle app, lv_obj_t* parent) {
appHandle_ = app;
// Hard requirement: without PSRAM this app cannot parse EPUBs or hold fonts.
// Show a one-shot alert then stop; psramAlertId_ prevents re-launching the dialog
// if onShow is called again before tt_app_stop() takes effect.
if (heap_caps_get_total_size(MALLOC_CAP_SPIRAM) == 0) {
if (psramAlertId_ == 0) {
static const char* kButtons[] = { "OK" };
psramAlertId_ = tt_app_alertdialog_start(
"PSRAM Required",
"Epub Reader requires a device with PSRAM and cannot run on this hardware.",
kButtons, 1
);
}
return;
}
loadFonts();
if (dataRoot_.empty()) {
dataRoot_ = getAppDataRoot(app);
// Ensure the app data directory exists (needed for progress.txt and books_folder.txt)
char dir[128]; size_t sz = sizeof(dir);
tt_app_get_user_data_path(app, dir, &sz);
for (char* p = dir + 1; *p; ++p) {
if (*p == '/') { *p = '\0'; mkdir(dir, 0755); *p = '/'; }
}
mkdir(dir, 0755);
// Load saved books folder; start browser there if set, otherwise dataRoot_
loadBooksPath();
browsePath_ = booksPath_.empty() ? dataRoot_ : booksPath_;
}
lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN);
lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE);
toolbar_ = tt_lvgl_toolbar_create_for_app(parent, app);
wrapperWidget_ = lv_obj_create(parent);
lv_obj_set_width(wrapperWidget_, LV_PCT(100));
lv_obj_set_flex_grow(wrapperWidget_, 1);
lv_obj_set_flex_flow(wrapperWidget_, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_all(wrapperWidget_, 0, 0);
lv_obj_set_style_border_width(wrapperWidget_, 0, 0);
lv_obj_set_style_bg_opa(wrapperWidget_, LV_OPA_TRANSP, 0);
lv_obj_remove_flag(wrapperWidget_, LV_OBJ_FLAG_SCROLLABLE);
// Apply chapter jump from TOC dialog (stored in onResult)
if (pendingChapterIndex_ >= 0) {
currentSpineIndex_ = pendingChapterIndex_;
pendingChapterIndex_ = -1;
}
// Attempt to open a book: launch parameter → saved progress (first show only).
// Both paths use lv_async_call so EpubService::open runs at fresh stack
// depth (not nested inside the deep GuiService→onShow call chain).
bool asyncOpen = false;
if (!epub_) {
BundleHandle bundle = tt_app_get_parameters(app);
if (bundle) {
char filepath[256] = {0};
if (tt_bundle_opt_string(bundle, EPUB_FILE_ARGUMENT, filepath, (uint32_t)sizeof(filepath))) {
LOG_I(TAG, "Opening from parameter: %s", filepath);
pendingFilePath_ = filepath;
lv_async_call(asyncOpenEpub, this);
asyncOpen = true;
}
}
}
bool asyncRestore = false;
if (!epub_ && !asyncOpen) {
std::string savedPath;
int savedChapter = 0;
bool savedIsText = false;
if (loadProgress(savedPath, savedChapter, savedIsText)) {
// Schedule open via lv_async_call so it runs at the same call-stack
// depth as asyncOpenEpub - calling EpubService::open directly here
// (from GuiService→onShow) adds extra frames that overflow the task stack.
pendingFilePath_ = savedPath;
currentSpineIndex_ = savedChapter;
lv_async_call(asyncRestoreEpub, this);
asyncRestore = true;
LOG_I(TAG, "Scheduling restore: %s ch%d", savedPath.c_str(), savedChapter);
}
}
if (epub_ && epub_->isValid()) {
setReaderToolbarButtons();
buildReaderUI(wrapperWidget_);
} else if (!asyncRestore && !asyncOpen) {
epub_ = nullptr;
setBrowserToolbarButtons();
buildBrowserUI(wrapperWidget_);
} else {
// asyncOpenEpub / asyncRestoreEpub will replace the wrapper contents when
// they fire; show an empty placeholder for now (no browser flash on ePaper)
}
}
void EpubReader::onHide(AppHandle /*app*/) {
++openToken_; // invalidate any in-flight background open task
// Skip font unload during a dialog roundtrip (tocDialogId_ is non-zero while
// the TOC selection dialog is open). loadFonts() guards against double-loading,
// so fonts will be reused as-is when onShow fires after the dialog closes.
// When truly exiting, no dialog is open and fonts are freed as normal.
if (tocDialogId_ == 0 && psramAlertId_ == 0) {
unloadFonts();
}
contentWidget_ = nullptr;
wrapperWidget_ = nullptr;
toolbar_ = nullptr;
appHandle_ = nullptr;
pageContent_ = {}; // release chapter/text memory
pageOffset_ = 0;
textMode_ = false;
}
void EpubReader::onResult(AppHandle /*app*/, void* /*data*/, AppLaunchId launchId,
AppResult /*result*/, BundleHandle resultData) {
// Never touch LVGL objects here - store state for onShow
if (launchId == psramAlertId_ && psramAlertId_ != 0) {
tt_app_stop();
return;
}
if (launchId == tocDialogId_ && tocDialogId_ != 0) {
tocDialogId_ = 0;
if (resultData) {
int32_t selection = tt_app_selectiondialog_get_result_index(resultData);
if (selection >= 0 && epub_) {
const auto& toc = epub_->getToc();
const auto& spine = epub_->getSpine();
if (selection < (int32_t)toc.size()) {
pendingChapterIndex_ = -1; // Reset before searching
for (size_t i = 0; i < spine.size(); ++i) {
if (spine[i].href == toc[selection].href) {
pendingChapterIndex_ = (int)i;
break;
}
}
}
}
}
}
}