Merge pull request #73 from jpirnay/fix-oom-kosync

refactor: EPub-Reader closes down for KOReader-Synchronisation to save memory
This commit is contained in:
jpirnay
2026-04-13 20:55:40 +02:00
committed by GitHub
14 changed files with 428 additions and 114 deletions
+1 -1
View File
@@ -147,7 +147,7 @@ are documented in:
Two singletons are central:
- `src/CrossPointSettings.h` (`SETTINGS`): user preferences and behavior flags
- `src/CrossPointState.h` (`APP_STATE`): runtime/session state such as current book and sleep context
- `src/CrossPointState.h` (`APP_STATE`): runtime/session state such as current book, sleep context, and standalone KOReader sync handoff/outcome state
Typical persisted areas on SD:
@@ -17,6 +17,39 @@ The synchronization layer is designed to:
- Keep transport/client logic separated from parsing/mapping logic.
- Prefer precise anchors when available, but degrade gracefully.
## Standalone Sync Lifecycle
KOReader sync no longer runs as a child activity on top of a live EPUB reader.
Instead, the reader persists a compact handoff record in `APP_STATE`, then the
activity stack is replaced with `KOReaderSyncActivity`.
Why this exists:
- HTTPS/TLS setup on ESP32-C3 is sensitive to heap fragmentation.
- Keeping the full reader activity alive underneath sync reclaimed too little
memory and made failures harder to reason about.
- A standalone sync screen makes memory snapshots and failure modes easier to
interpret.
Current lifecycle:
1. `EpubReaderActivity` stores the EPUB path, current local position, sync
intent, and any future sync result slots in `APP_STATE.koReaderSyncSession`.
2. The reader activity stack is replaced with `KOReaderSyncActivity`.
3. `KOReaderSyncActivity` lazily reloads EPUB data only for mapping work and
releases it again before network-heavy phases.
4. On completion, cancel, or failure, sync persists an outcome and reopens the
book through the normal `ReaderActivity -> EpubReaderActivity` path.
5. `EpubReaderActivity::applyPendingSyncSession()` consumes that outcome:
- remote-apply writes the reopen position into `progress.bin` before normal
reader startup loads it
- upload-complete keeps the existing local `progress.bin` unchanged
- paragraph-level correction metadata is still carried separately because
`progress.bin` stores only spine/page/pageCount
Memory notes:
- Reader-owned state is reclaimed by fully exiting the reader before sync.
- Sync still trims its own renderer/font caches right before TLS because sync UI
rendering can repopulate those caches after the reader is gone.
## Data Model Mismatch
CrossPoint stores position as chapter/page-centric state.
+69 -5
View File
@@ -31,6 +31,37 @@ esp_http_client_handle_t g_sessionClient = nullptr;
// the longest expected message including esp_err name (~32 chars), opcode (~10), heap
// numbers, and HTTP status. Single-threaded sync flow makes static safe.
char g_failureDetailBuf[160] = {0};
char g_lastResponsePreview[160] = {0};
std::string previewBody(const char* body, const size_t maxLen = 120) {
if (!body || !*body) {
return "<empty>";
}
std::string preview;
preview.reserve(maxLen);
for (const char* p = body; *p && preview.size() < maxLen; ++p) {
const unsigned char c = static_cast<unsigned char>(*p);
if (c == '\r' || c == '\n' || c == '\t') {
preview.push_back(' ');
} else if (std::isprint(c)) {
preview.push_back(static_cast<char>(c));
} else {
preview.push_back('?');
}
}
if (strlen(body) > preview.size()) {
preview += "...";
}
return preview;
}
void rememberResponsePreview(const char* body) {
const std::string preview = previewBody(body, sizeof(g_lastResponsePreview) - 1);
strncpy(g_lastResponsePreview, preview.c_str(), sizeof(g_lastResponsePreview) - 1);
g_lastResponsePreview[sizeof(g_lastResponsePreview) - 1] = '\0';
}
// Reset the static diagnostic state at the start of each request and capture pre-flight
// heap so failure reporting always reflects what was available when the request started.
@@ -40,6 +71,7 @@ void beginRequest(const char* operation) {
KOReaderSyncClient::lastHttpCode = 0;
KOReaderSyncClient::lastHeapAtFailure = ESP.getFreeHeap();
KOReaderSyncClient::lastContigHeapAtFailure = heap_caps_get_largest_free_block(MALLOC_CAP_8BIT | MALLOC_CAP_DEFAULT);
g_lastResponsePreview[0] = '\0';
}
// Skip a leading UTF-8 BOM (EF BB BF) and ASCII whitespace, returning a pointer
@@ -240,9 +272,9 @@ esp_http_client_handle_t createClient(const char* url, ResponseBuffer* buf,
config.event_handler = httpEventHandler;
config.user_data = activeBuf;
config.method = method;
config.timeout_ms = 15000;
config.timeout_ms = 5000;
config.buffer_size = HTTP_BUF_SIZE;
config.buffer_size_tx = HTTP_BUF_SIZE;
config.buffer_size_tx = 512;
config.crt_bundle_attach = esp_crt_bundle_attach;
config.keep_alive_enable = g_keepSessionOpen;
// Follow up to 3 redirects (e.g. HTTP→HTTPS, path normalization, DuckDNS proxy).
@@ -447,7 +479,13 @@ KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& doc
esp_http_client_cleanup(client);
}
LOG_DBG("KOSync", "Get progress response: %d (err: %s) [attempt %d]", httpCode, esp_err_to_name(err), attempt);
const size_t bodyLen = activeBuf->data ? strlen(activeBuf->data) : 0;
LOG_DBG("KOSync", "GET %s -> %d (err: %s) [attempt %d body_len=%u]", url.c_str(), httpCode, esp_err_to_name(err),
attempt, static_cast<unsigned>(bodyLen));
if (err == ESP_OK && (httpCode < 200 || httpCode >= 300)) {
rememberResponsePreview(activeBuf->data);
LOG_ERR("KOSync", "GET failure body preview: %s", g_lastResponsePreview);
}
// Retry exactly once for connect-level failures only.
// Why: this recovers short AP/roaming hiccups without masking persistent
@@ -497,7 +535,10 @@ KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& doc
}
if (httpCode == 401) return AUTH_FAILED;
if (httpCode == 404) return NOT_FOUND;
if (httpCode == 404) {
LOG_DBG("KOSync", "GET progress returned 404 for %s - treating as NOT_FOUND", url.c_str());
return NOT_FOUND;
}
return SERVER_ERROR;
}
@@ -561,7 +602,15 @@ KOReaderSyncClient::Error KOReaderSyncClient::updateProgress(const KOReaderProgr
esp_http_client_cleanup(client);
}
LOG_DBG("KOSync", "Update progress response: %d (err: %s) [attempt %d]", httpCode, esp_err_to_name(err), attempt);
const size_t bodyLen = activeBuf->data ? strlen(activeBuf->data) : 0;
LOG_DBG("KOSync", "PUT %s -> %d (err: %s) [attempt %d body_len=%u]", url.c_str(), httpCode, esp_err_to_name(err),
attempt, static_cast<unsigned>(bodyLen));
if (err == ESP_OK && (httpCode < 200 || httpCode >= 300)) {
rememberResponsePreview(activeBuf->data);
LOG_ERR("KOSync", "PUT failure body preview: %s", g_lastResponsePreview);
LOG_ERR("KOSync", "PUT failure request summary: document=%s percentage=%.4f progress=%s",
progress.document.c_str(), progress.percentage, progress.progress.c_str());
}
// Retry exactly once for connect-level failures only.
// Why: same policy as GET keeps behavior predictable across both endpoints.
@@ -631,6 +680,21 @@ const char* KOReaderSyncClient::lastFailureDetail() {
}
// Server case: got an HTTP status the client didn't recognize as success.
if (lastHttpCode != 0) {
if (lastHttpCode == 404 && lastOperation && strcmp(lastOperation, "update progress") == 0) {
std::string lowerPreview = g_lastResponsePreview;
std::transform(lowerPreview.begin(), lowerPreview.end(), lowerPreview.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
if (lowerPreview.find("book not found") != std::string::npos) {
snprintf(g_failureDetailBuf, sizeof(g_failureDetailBuf),
"%s: server does not know this book yet; server expects the same file to already exist there, usually "
"downloaded via OPDS",
lastOperation);
return g_failureDetailBuf;
}
snprintf(g_failureDetailBuf, sizeof(g_failureDetailBuf),
"%s: HTTP 404 (upload rejected; server may require book to be known)", lastOperation);
return g_failureDetailBuf;
}
snprintf(g_failureDetailBuf, sizeof(g_failureDetailBuf), "%s: HTTP %d", lastOperation, lastHttpCode);
return g_failureDetailBuf;
}
+5
View File
@@ -0,0 +1,5 @@
CONFIG_MBEDTLS_SSL_IN_CONTENT_LEN=4096
CONFIG_MBEDTLS_SSL_OUT_CONTENT_LEN=2048
CONFIG_MBEDTLS_DYNAMIC_BUFFER_ALLOCATION=y
CONFIG_MBEDTLS_DYNAMIC_FREE_CA_CERT=y
CONFIG_MBEDTLS_DYNAMIC_FREE_CONFIG_DATA=y
+2
View File
@@ -76,6 +76,8 @@ bool CrossPointState::loadFromBinaryFile() {
lastSleepFromReader = false;
}
koReaderSyncSession.clear();
inputFile.close();
return true;
}
+48
View File
@@ -3,6 +3,53 @@
#include <iosfwd>
#include <string>
enum class KOReaderSyncIntentState : uint8_t {
COMPARE = 0,
PULL_REMOTE = 1,
PUSH_LOCAL = 2,
};
enum class KOReaderSyncOutcomeState : uint8_t {
NONE = 0,
PENDING = 1,
CANCELLED = 2,
FAILED = 3,
UPLOAD_COMPLETE = 4,
APPLIED_REMOTE = 5,
};
struct KOReaderSyncSessionState {
bool active = false;
std::string epubPath;
int spineIndex = 0;
int page = 0;
int totalPagesInSpine = 0;
uint16_t paragraphIndex = 0;
bool hasParagraphIndex = false;
KOReaderSyncIntentState intent = KOReaderSyncIntentState::COMPARE;
KOReaderSyncOutcomeState outcome = KOReaderSyncOutcomeState::NONE;
int resultSpineIndex = 0;
int resultPage = 0;
uint16_t resultParagraphIndex = 0;
bool resultHasParagraphIndex = false;
void clear() {
active = false;
epubPath.clear();
spineIndex = 0;
page = 0;
totalPagesInSpine = 0;
paragraphIndex = 0;
hasParagraphIndex = false;
intent = KOReaderSyncIntentState::COMPARE;
outcome = KOReaderSyncOutcomeState::NONE;
resultSpineIndex = 0;
resultPage = 0;
resultParagraphIndex = 0;
resultHasParagraphIndex = false;
}
};
class CrossPointState {
// Static instance
static CrossPointState instance;
@@ -12,6 +59,7 @@ class CrossPointState {
size_t lastSleepImage = SIZE_MAX; // SIZE_MAX = unset sentinel
uint8_t readerActivityLoadCount = 0;
bool lastSleepFromReader = false;
KOReaderSyncSessionState koReaderSyncSession;
~CrossPointState() = default;
// Get singleton instance
+30
View File
@@ -73,6 +73,20 @@ bool JsonSettingsIO::saveState(const CrossPointState& s, const char* path) {
doc["lastSleepImage"] = s.lastSleepImage;
doc["readerActivityLoadCount"] = s.readerActivityLoadCount;
doc["lastSleepFromReader"] = s.lastSleepFromReader;
JsonObject sync = doc["koReaderSyncSession"].to<JsonObject>();
sync["active"] = s.koReaderSyncSession.active;
sync["epubPath"] = s.koReaderSyncSession.epubPath;
sync["spineIndex"] = s.koReaderSyncSession.spineIndex;
sync["page"] = s.koReaderSyncSession.page;
sync["totalPagesInSpine"] = s.koReaderSyncSession.totalPagesInSpine;
sync["paragraphIndex"] = s.koReaderSyncSession.paragraphIndex;
sync["hasParagraphIndex"] = s.koReaderSyncSession.hasParagraphIndex;
sync["intent"] = static_cast<uint8_t>(s.koReaderSyncSession.intent);
sync["outcome"] = static_cast<uint8_t>(s.koReaderSyncSession.outcome);
sync["resultSpineIndex"] = s.koReaderSyncSession.resultSpineIndex;
sync["resultPage"] = s.koReaderSyncSession.resultPage;
sync["resultParagraphIndex"] = s.koReaderSyncSession.resultParagraphIndex;
sync["resultHasParagraphIndex"] = s.koReaderSyncSession.resultHasParagraphIndex;
String json;
serializeJson(doc, json);
@@ -91,6 +105,22 @@ bool JsonSettingsIO::loadState(CrossPointState& s, const char* json) {
s.lastSleepImage = doc["lastSleepImage"] | SIZE_MAX;
s.readerActivityLoadCount = doc["readerActivityLoadCount"] | (uint8_t)0;
s.lastSleepFromReader = doc["lastSleepFromReader"] | false;
JsonObject sync = doc["koReaderSyncSession"].as<JsonObject>();
s.koReaderSyncSession.active = sync["active"] | false;
s.koReaderSyncSession.epubPath = sync["epubPath"] | std::string("");
s.koReaderSyncSession.spineIndex = sync["spineIndex"] | 0;
s.koReaderSyncSession.page = sync["page"] | 0;
s.koReaderSyncSession.totalPagesInSpine = sync["totalPagesInSpine"] | 0;
s.koReaderSyncSession.paragraphIndex = sync["paragraphIndex"] | (uint16_t)0;
s.koReaderSyncSession.hasParagraphIndex = sync["hasParagraphIndex"] | false;
s.koReaderSyncSession.intent =
static_cast<KOReaderSyncIntentState>(sync["intent"] | static_cast<uint8_t>(KOReaderSyncIntentState::COMPARE));
s.koReaderSyncSession.outcome =
static_cast<KOReaderSyncOutcomeState>(sync["outcome"] | static_cast<uint8_t>(KOReaderSyncOutcomeState::NONE));
s.koReaderSyncSession.resultSpineIndex = sync["resultSpineIndex"] | 0;
s.koReaderSyncSession.resultPage = sync["resultPage"] | 0;
s.koReaderSyncSession.resultParagraphIndex = sync["resultParagraphIndex"] | (uint16_t)0;
s.koReaderSyncSession.resultHasParagraphIndex = sync["resultHasParagraphIndex"] | false;
return true;
}
+15
View File
@@ -4,6 +4,7 @@
#include <HalClock.h>
#include <HalPowerManager.h>
#include "CrossPointState.h"
#include "boot_sleep/BootActivity.h"
#include "boot_sleep/SleepActivity.h"
#include "browser/OpdsBookBrowserActivity.h"
@@ -11,6 +12,7 @@
#include "home/HomeActivity.h"
#include "home/RecentBooksActivity.h"
#include "network/CrossPointWebServerActivity.h"
#include "reader/KOReaderSyncActivity.h"
#include "reader/ReaderActivity.h"
#include "settings/SettingsActivity.h"
#include "util/FullScreenMessageActivity.h"
@@ -227,6 +229,19 @@ void ActivityManager::goToReader(std::string path) {
replaceActivity(std::make_unique<ReaderActivity>(renderer, mappedInput, std::move(path)));
}
void ActivityManager::goToKOReaderSync() {
const auto& sync = APP_STATE.koReaderSyncSession;
if (!sync.active || sync.epubPath.empty()) {
LOG_ERR("ACT", "Cannot launch KOReader sync without an active EPUB handoff");
goHome();
return;
}
replaceActivity(std::make_unique<KOReaderSyncActivity>(renderer, mappedInput, sync.epubPath, sync.spineIndex,
sync.page, sync.totalPagesInSpine, sync.paragraphIndex,
sync.hasParagraphIndex, sync.intent));
}
void ActivityManager::pushReader(std::string path) {
pushActivity(std::make_unique<ReaderActivity>(renderer, mappedInput, std::move(path)));
}
+1
View File
@@ -89,6 +89,7 @@ class ActivityManager {
void goToRecentBooks();
void goToBrowser();
void goToReader(std::string path);
void goToKOReaderSync();
void pushReader(std::string path);
void goToSleep();
void goToBoot();
+122 -56
View File
@@ -8,6 +8,7 @@
#include <HalStorage.h>
#include <I18n.h>
#include <Logging.h>
#include <esp_heap_caps.h>
#include <esp_system.h>
#include <memory>
@@ -18,7 +19,6 @@
#include "EpubReaderFootnotesActivity.h"
#include "EpubReaderPercentSelectionActivity.h"
#include "KOReaderCredentialStore.h"
#include "KOReaderSyncActivity.h"
#include "MappedInputManager.h"
#include "QrDisplayActivity.h"
#include "ReaderUtils.h"
@@ -33,6 +33,32 @@ constexpr unsigned long skipChapterMs = 700;
// pages per minute, first item is 1 to prevent division by zero if accessed
const std::vector<int> PAGE_TURN_LABELS = {1, 1, 3, 6, 12};
void logReaderMemSnapshot(const char* stage) {
const uint32_t freeHeap = esp_get_free_heap_size();
const uint32_t contigHeap = heap_caps_get_largest_free_block(MALLOC_CAP_8BIT | MALLOC_CAP_DEFAULT);
LOG_DBG("ERS", "Reader mem[%s]: free=%lu contig=%lu", stage, freeHeap, contigHeap);
}
bool writeReaderProgressCache(const std::string& cachePath, const int spineIndex, const int currentPage,
const int pageCount) {
FsFile f;
if (!Storage.openFileForWrite("ERS", cachePath + "/progress.bin", f)) {
LOG_ERR("ERS", "Failed to open progress cache for sync restore: %s", cachePath.c_str());
return false;
}
uint8_t data[6];
data[0] = spineIndex & 0xFF;
data[1] = (spineIndex >> 8) & 0xFF;
data[2] = currentPage & 0xFF;
data[3] = (currentPage >> 8) & 0xFF;
data[4] = pageCount & 0xFF;
data[5] = (pageCount >> 8) & 0xFF;
f.write(data, 6);
f.close();
return true;
}
int clampPercent(int percent) {
if (percent < 0) {
return 0;
@@ -47,6 +73,7 @@ int clampPercent(int percent) {
void EpubReaderActivity::onEnter() {
Activity::onEnter();
logReaderMemSnapshot("onEnter_begin");
// Drop any input events that arrived from the activity that launched us (e.g. a wake-up power
// button hold) before they reach detectPageTurn() — see ReaderUtils::InputDrainGuard.
@@ -64,6 +91,7 @@ void EpubReaderActivity::onEnter() {
}
epub->setupCacheDir();
applyPendingSyncSession();
FsFile f;
if (Storage.openFileForRead("ERS", epub->getCachePath() + "/progress.bin", f)) {
@@ -104,10 +132,12 @@ void EpubReaderActivity::onEnter() {
// Trigger first update
requestUpdate();
logReaderMemSnapshot("onEnter_ready");
}
void EpubReaderActivity::onExit() {
Activity::onExit();
logReaderMemSnapshot("onExit_before_release");
// Reset orientation back to portrait for the rest of the UI
renderer.setOrientation(GfxRenderer::Orientation::Portrait);
@@ -116,6 +146,9 @@ void EpubReaderActivity::onExit() {
APP_STATE.saveToFile();
section.reset();
epub.reset();
currentPageFootnotes.clear();
currentPageFootnotes.shrink_to_fit();
logReaderMemSnapshot("onExit_after_release");
}
void EpubReaderActivity::loop() {
@@ -498,70 +531,95 @@ void EpubReaderActivity::launchKOReaderSync(const SyncLaunchMode mode) {
return;
}
const std::string syncEpubPath = epub->getPath();
const int currentPage = section ? section->currentPage : 0;
const int totalPages = section ? section->pageCount : 0;
{
// Drop large reader state before TLS-heavy sync to improve contiguous heap
// and reduce long-run fragmentation across repeated sync attempts.
RenderLock lock(*this);
nextPageNumber = currentPage;
cachedSpineIndex = currentSpineIndex;
cachedChapterTotalPageCount = totalPages;
section.reset();
epub.reset();
currentPageFootnotes.clear();
currentPageFootnotes.shrink_to_fit();
}
deferredSyncEpubPath = syncEpubPath;
renderer.cleanupGrayscaleWithFrameBuffer();
if (auto* cacheManager = renderer.getFontCacheManager()) {
cacheManager->clearCache();
cacheManager->resetStats();
}
LOG_DBG("ERS", "Pre-sync trim: spine=%d page=%d/%d heap=%lu", currentSpineIndex, currentPage, totalPages,
static_cast<unsigned long>(esp_get_free_heap_size()));
// Map reader-level launch mode to activity-level intent once, then pass a
// stable intent into KOReaderSyncActivity so it can own the sync state machine.
KOReaderSyncActivity::SyncIntent syncIntent = KOReaderSyncActivity::SyncIntent::COMPARE;
KOReaderSyncIntentState syncIntent = KOReaderSyncIntentState::COMPARE;
if (mode == SyncLaunchMode::PULL_REMOTE) {
syncIntent = KOReaderSyncActivity::SyncIntent::PULL_REMOTE;
syncIntent = KOReaderSyncIntentState::PULL_REMOTE;
} else if (mode == SyncLaunchMode::PUSH_LOCAL) {
syncIntent = KOReaderSyncActivity::SyncIntent::PUSH_LOCAL;
syncIntent = KOReaderSyncIntentState::PUSH_LOCAL;
}
startActivityForResult(
std::make_unique<KOReaderSyncActivity>(renderer, mappedInput, std::shared_ptr<Epub>{}, syncEpubPath,
currentSpineIndex, currentPage, totalPages, 0, false, syncIntent),
[this](const ActivityResult& result) { handleSyncResult(result); });
auto& sync = APP_STATE.koReaderSyncSession;
sync.active = true;
sync.epubPath = epub->getPath();
sync.spineIndex = currentSpineIndex;
sync.page = currentPage;
sync.totalPagesInSpine = totalPages;
sync.paragraphIndex = 0;
sync.hasParagraphIndex = false;
sync.intent = syncIntent;
sync.outcome = KOReaderSyncOutcomeState::PENDING;
sync.resultSpineIndex = 0;
sync.resultPage = 0;
sync.resultParagraphIndex = 0;
sync.resultHasParagraphIndex = false;
APP_STATE.saveToFile();
LOG_DBG("ERS", "Standalone sync handoff: spine=%d page=%d/%d", currentSpineIndex, currentPage, totalPages);
logReaderMemSnapshot("before_replace_with_sync");
activityManager.goToKOReaderSync();
}
void EpubReaderActivity::handleSyncResult(const ActivityResult& result) {
if (!epub && !deferredSyncEpubPath.empty()) {
epub = std::make_shared<Epub>(deferredSyncEpubPath, "/.crosspoint");
if (!epub->load(true, true)) {
LOG_ERR("ERS", "Failed to reload EPUB after sync: %s", deferredSyncEpubPath.c_str());
finish();
return;
}
epub->setupCacheDir();
LOG_DBG("ERS", "Reloaded EPUB after sync: %s", deferredSyncEpubPath.c_str());
deferredSyncEpubPath.clear();
void EpubReaderActivity::applyPendingSyncSession() {
auto& sync = APP_STATE.koReaderSyncSession;
if (!sync.active || !epub || sync.epubPath != epub->getPath()) {
return;
}
if (!result.isCancelled) {
const auto& sync = std::get<SyncResult>(result.data);
if (currentSpineIndex != sync.spineIndex || (section && section->currentPage != sync.page)) {
RenderLock lock(*this);
currentSpineIndex = sync.spineIndex;
nextPageNumber = sync.page;
section.reset();
}
LOG_DBG("ERS", "Applying pending sync session outcome=%d path=%s", static_cast<int>(sync.outcome),
sync.epubPath.c_str());
// Upload-complete returns to the same local position the reader already persisted
// before sync launched, so there is no need to rewrite progress.bin here.
if (sync.outcome == KOReaderSyncOutcomeState::UPLOAD_COMPLETE) {
LOG_DBG("ERS", "Upload-complete resume keeps existing local progress.bin unchanged");
sync.clear();
APP_STATE.saveToFile();
logReaderMemSnapshot("after_apply_pending_sync_session");
return;
}
int restoreSpineIndex = sync.spineIndex;
int restorePage = sync.page;
pendingParagraphLookup = sync.hasParagraphIndex;
pendingParagraphIndex = sync.paragraphIndex;
if (sync.outcome == KOReaderSyncOutcomeState::APPLIED_REMOTE) {
restoreSpineIndex = sync.resultSpineIndex;
restorePage = sync.resultPage;
pendingParagraphLookup = sync.resultHasParagraphIndex;
pendingParagraphIndex = sync.resultParagraphIndex;
LOG_DBG("ERS", "Applied synced remote position: spine=%d page=%d paragraph=%u hasParagraph=%s", restoreSpineIndex,
restorePage, pendingParagraphIndex, pendingParagraphLookup ? "yes" : "no");
} else {
LOG_DBG("ERS", "Restored local pre-sync position: spine=%d page=%d paragraph=%u hasParagraph=%s", restoreSpineIndex,
restorePage, pendingParagraphIndex, pendingParagraphLookup ? "yes" : "no");
}
// sync.totalPagesInSpine is the page count of the local spine at launch time.
// When the restore targets a different spine, that count is meaningless for the
// rescaling logic in render() and can cause out-of-bounds pages (the estimated
// page number may exceed the local spine's count, producing progress > 1.0).
// Store 0 to disable rescaling; the paragraph lookup handles precise positioning.
const int restorePageCount = (restoreSpineIndex == sync.spineIndex) ? sync.totalPagesInSpine : 0;
if (writeReaderProgressCache(epub->getCachePath(), restoreSpineIndex, restorePage, restorePageCount)) {
cachedSpineIndex = restoreSpineIndex;
cachedChapterTotalPageCount = restorePageCount;
LOG_DBG("ERS", "Prepared progress.bin for sync restore: spine=%d page=%d/%d", restoreSpineIndex, restorePage,
sync.totalPagesInSpine);
} else {
// Fall back to directly seeding live state if cache write fails.
currentSpineIndex = restoreSpineIndex;
nextPageNumber = restorePage;
cachedSpineIndex = restoreSpineIndex;
cachedChapterTotalPageCount = restorePageCount;
}
sync.clear();
APP_STATE.saveToFile();
logReaderMemSnapshot("after_apply_pending_sync_session");
}
void EpubReaderActivity::applyOrientation(const uint8_t orientation) {
@@ -818,6 +876,14 @@ void EpubReaderActivity::render(RenderLock&& lock) {
cachedChapterTotalPageCount = 0; // resets to 0 to prevent reading cached progress again
}
// Safety clamp: estimated page numbers from sync or progress.bin may exceed
// the actual page count when the section was built with different settings or
// the estimate was based on a different spine's density.
if (section->pageCount > 0 && section->currentPage >= section->pageCount) {
LOG_DBG("ERS", "Clamping page %d to last page %d", section->currentPage, section->pageCount - 1);
section->currentPage = section->pageCount - 1;
}
if (pendingPercentJump && section->pageCount > 0) {
// Apply the pending percent jump now that we know the new section's page count.
int newPage = static_cast<int>(pendingSpineProgress * static_cast<float>(section->pageCount));
@@ -914,8 +980,8 @@ void EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageC
FsFile f;
if (Storage.openFileForWrite("ERS", epub->getCachePath() + "/progress.bin", f)) {
uint8_t data[6];
data[0] = currentSpineIndex & 0xFF;
data[1] = (currentSpineIndex >> 8) & 0xFF;
data[0] = spineIndex & 0xFF;
data[1] = (spineIndex >> 8) & 0xFF;
data[2] = currentPage & 0xFF;
data[3] = (currentPage >> 8) & 0xFF;
data[4] = pageCount & 0xFF;
+5 -2
View File
@@ -48,7 +48,6 @@ class EpubReaderActivity final : public Activity {
bool skipNextButtonCheck = false; // Skip button processing for one frame after subactivity exit
ReaderUtils::InputDrainGuard inputDrainGuard;
bool automaticPageTurnActive = false;
std::string deferredSyncEpubPath;
// -1 means use global SETTINGS value.
int8_t bookEmbeddedStyleOverride = -1;
int8_t bookImageRenderingOverride = -1;
@@ -72,7 +71,11 @@ class EpubReaderActivity final : public Activity {
void jumpToPercent(int percent);
void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action);
void launchKOReaderSync(SyncLaunchMode mode = SyncLaunchMode::COMPARE);
void handleSyncResult(const ActivityResult& result);
// Consume a persisted standalone KOReader sync session for this EPUB. Remote
// apply writes the mapped reopen position into progress.bin before the normal
// reader startup path reads it. Upload-complete leaves the existing local
// progress.bin untouched and simply clears the pending session marker.
void applyPendingSyncSession();
void applyOrientation(uint8_t orientation);
void applyTextDarkness(uint8_t textDarkness);
void toggleAutoPageTurn(uint8_t selectedPageTurnOption);
+65 -30
View File
@@ -29,9 +29,10 @@ void logSyncMemSnapshot(const char* stage) {
integrityOk ? "ok" : "fail");
}
// Frees renderer-owned caches right before network work.
// Why: TLS handshake needs a large contiguous block, and font cache memory can
// increase fragmentation even when total free heap looks acceptable.
// Frees renderer-owned caches inside the standalone sync activity right before
// network work. The reader activity is already gone by this point, but sync UI
// rendering (status popups, compare screen, result screen) can repopulate font
// caches and chip away at the largest free block needed for TLS.
void trimMemoryBeforeTls(const GfxRenderer& renderer) {
if (auto* cacheManager = renderer.getFontCacheManager()) {
cacheManager->clearCache();
@@ -57,11 +58,8 @@ bool shouldSyncNtpNow() {
void KOReaderSyncActivity::onWifiSelectionComplete(const bool success) {
if (!success) {
LOG_DBG("KOSync", "WiFi connection failed, exiting");
ActivityResult result;
result.isCancelled = true;
setResult(std::move(result));
finish();
LOG_DBG("KOSync", "WiFi connection failed, resuming reader");
resumeReader(KOReaderSyncOutcomeState::CANCELLED);
return;
}
@@ -118,7 +116,7 @@ void KOReaderSyncActivity::performSync() {
// Local mapping is only needed for compare/upload paths.
// Pull-only mode can skip this expensive step and go straight to remote fetch.
if (syncIntent != SyncIntent::PULL_REMOTE) {
if (syncIntent != KOReaderSyncIntentState::PULL_REMOTE) {
// Precompute local mapping before first network request so the expensive
// inflate/index work happens before TLS. This avoids a second local mapping
// pass later and keeps the upload path lightweight.
@@ -143,7 +141,7 @@ void KOReaderSyncActivity::performSync() {
// Push intent skips comparison UI but still warms an HTTP/TLS session first
// so PUT can reuse the connection instead of forcing a fresh handshake.
if (syncIntent == SyncIntent::PUSH_LOCAL) {
if (syncIntent == KOReaderSyncIntentState::PUSH_LOCAL) {
// Direct push previously started with no reusable HTTP/TLS session, forcing
// a fresh handshake in updateProgress. Compare flow often succeeds because
// upload reuses the GET session. Warm the session here so push can take the
@@ -184,7 +182,7 @@ void KOReaderSyncActivity::performSync() {
const auto result = KOReaderSyncClient::getProgress(documentHash, remoteProgress);
if (result == KOReaderSyncClient::NOT_FOUND) {
if (syncIntent == SyncIntent::PULL_REMOTE) {
if (syncIntent == KOReaderSyncIntentState::PULL_REMOTE) {
// Pull intent must not silently fall back to upload when server has no
// remote progress. Failing explicitly keeps action semantics predictable.
KOReaderSyncClient::endPersistentSession();
@@ -236,7 +234,7 @@ void KOReaderSyncActivity::performSync() {
remotePosition.hasParagraphIndex = false;
remoteChapterLabel.clear();
if (syncIntent == SyncIntent::PULL_REMOTE) {
if (syncIntent == KOReaderSyncIntentState::PULL_REMOTE) {
// Pull intent applies immediately and exits. We bypass chooser UI to keep
// reader menu actions deterministic ("pull" always means apply remote).
if (!ensureRemotePositionMapped()) {
@@ -251,8 +249,13 @@ void KOReaderSyncActivity::performSync() {
// Preserve the apply result and show explicit confirmation before returning
// to the reader so users can tell pull succeeded.
setResult(SyncResult{remotePosition.spineIndex, remotePosition.pageNumber, remotePosition.paragraphIndex,
remotePosition.hasParagraphIndex});
auto& sync = APP_STATE.koReaderSyncSession;
sync.outcome = KOReaderSyncOutcomeState::APPLIED_REMOTE;
sync.resultSpineIndex = remotePosition.spineIndex;
sync.resultPage = remotePosition.pageNumber;
sync.resultParagraphIndex = remotePosition.paragraphIndex;
sync.resultHasParagraphIndex = remotePosition.hasParagraphIndex;
APP_STATE.saveToFile();
{
RenderLock lock(*this);
state = APPLY_COMPLETE;
@@ -328,8 +331,8 @@ void KOReaderSyncActivity::performUpload() {
return;
}
// Result screen rendering repopulates glyph caches; trim again right before
// the upload handshake to maximize contiguous heap for TLS.
// Sync UI rendering can repopulate glyph caches after the initial GET / compare
// phase, so trim again right before the upload request.
trimMemoryBeforeTls(renderer);
logSyncMemSnapshot("after_trim_before_updateProgress");
@@ -370,6 +373,8 @@ void KOReaderSyncActivity::performUpload() {
}
HalClock::wifiOff(true);
APP_STATE.koReaderSyncSession.outcome = KOReaderSyncOutcomeState::UPLOAD_COMPLETE;
APP_STATE.saveToFile();
{
RenderLock lock(*this);
state = UPLOAD_COMPLETE;
@@ -380,6 +385,9 @@ void KOReaderSyncActivity::performUpload() {
void KOReaderSyncActivity::onEnter() {
Activity::onEnter();
logSyncMemSnapshot("onEnter_begin");
LOG_DBG("KOSync", "Standalone sync start: path=%s spine=%d page=%d/%d intent=%d", epubPath.c_str(), currentSpineIndex,
currentPage, totalPagesInSpine, static_cast<int>(syncIntent));
// Check for credentials first
if (!KOREADER_STORE.hasCredentials()) {
@@ -404,8 +412,11 @@ void KOReaderSyncActivity::onEnter() {
void KOReaderSyncActivity::onExit() {
Activity::onExit();
logSyncMemSnapshot("onExit_before_cleanup");
KOReaderSyncClient::endPersistentSession();
HalClock::wifiOff(true);
releaseEpubForMapping();
logSyncMemSnapshot("onExit_after_cleanup");
}
void KOReaderSyncActivity::closeCancelled() {
@@ -413,11 +424,34 @@ void KOReaderSyncActivity::closeCancelled() {
return;
}
resumeReader(KOReaderSyncOutcomeState::CANCELLED);
}
void KOReaderSyncActivity::resumeReader(const KOReaderSyncOutcomeState outcome, const SyncResult* appliedResult) {
if (closeRequested) {
return;
}
closeRequested = true;
ActivityResult result;
result.isCancelled = true;
setResult(std::move(result));
finish();
auto& sync = APP_STATE.koReaderSyncSession;
sync.outcome = outcome;
if (appliedResult) {
sync.resultSpineIndex = appliedResult->spineIndex;
sync.resultPage = appliedResult->page;
sync.resultParagraphIndex = appliedResult->paragraphIndex;
sync.resultHasParagraphIndex = appliedResult->hasParagraphIndex;
} else if (outcome != KOReaderSyncOutcomeState::APPLIED_REMOTE) {
// Only zero the result fields when not resuming an already-applied remote
// position. The PULL_REMOTE path pre-saves the mapped result into APP_STATE
// before entering APPLY_COMPLETE; zeroing here would overwrite it.
sync.resultSpineIndex = 0;
sync.resultPage = 0;
sync.resultParagraphIndex = 0;
sync.resultHasParagraphIndex = false;
}
APP_STATE.saveToFile();
logSyncMemSnapshot("before_resume_reader");
activityManager.goToReader(epubPath);
}
void KOReaderSyncActivity::render(RenderLock&&) {
@@ -626,22 +660,23 @@ void KOReaderSyncActivity::computeRemoteChapter() {
void KOReaderSyncActivity::loop() {
if (state == NO_CREDENTIALS || state == SYNC_FAILED || state == UPLOAD_COMPLETE || state == APPLY_COMPLETE) {
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
// APPLY_COMPLETE already has a valid SyncResult, so exit normally.
// Other terminal states are treated as cancelled when backing out.
if (state == APPLY_COMPLETE) {
finish();
resumeReader(KOReaderSyncOutcomeState::APPLIED_REMOTE);
} else if (state == UPLOAD_COMPLETE) {
resumeReader(KOReaderSyncOutcomeState::UPLOAD_COMPLETE);
} else if (state == SYNC_FAILED || state == NO_CREDENTIALS) {
resumeReader(KOReaderSyncOutcomeState::FAILED);
} else {
closeCancelled();
resumeReader(KOReaderSyncOutcomeState::CANCELLED);
}
return;
}
if ((state == UPLOAD_COMPLETE || state == APPLY_COMPLETE) && millis() - uploadCompleteTime >= 3000) {
// Keep pull/apply result on auto-close; upload-complete remains cancel-style.
if (state == APPLY_COMPLETE) {
finish();
resumeReader(KOReaderSyncOutcomeState::APPLIED_REMOTE);
} else {
closeCancelled();
resumeReader(KOReaderSyncOutcomeState::UPLOAD_COMPLETE);
}
}
return;
@@ -671,9 +706,9 @@ void KOReaderSyncActivity::loop() {
return;
}
// Wifi will be turned off in onExit()
setResult(SyncResult{remotePosition.spineIndex, remotePosition.pageNumber, remotePosition.paragraphIndex,
remotePosition.hasParagraphIndex});
finish();
const SyncResult result = {remotePosition.spineIndex, remotePosition.pageNumber, remotePosition.paragraphIndex,
remotePosition.hasParagraphIndex};
resumeReader(KOReaderSyncOutcomeState::APPLIED_REMOTE, &result);
} else if (selectedOption == 1) {
// Upload local progress
performUpload();
+13 -20
View File
@@ -1,10 +1,10 @@
#pragma once
#include <Epub.h>
#include <functional>
#include <memory>
#include "ChapterXPathIndexer.h"
#include "CrossPointState.h"
#include "KOReaderSyncClient.h"
#include "ProgressMapper.h"
#include "activities/Activity.h"
@@ -12,6 +12,11 @@
/**
* Activity for syncing reading progress with KOReader sync server.
*
* This activity is launched as a standalone replacement screen, not as a
* child activity of the reader. The reader persists a compact handoff record,
* is destroyed to reclaim memory before WiFi/TLS work begins, and a fresh
* reader instance is reopened after sync completes or is cancelled.
*
* Shared pipeline:
* 1. Connect to WiFi (if not connected)
* 2. Optionally sync NTP (if stale)
@@ -21,30 +26,17 @@
* - COMPARE: fetch remote progress, show full comparison screen, let user
* choose Apply or Upload.
* - PULL_REMOTE: fetch and map remote progress, show success feedback, then
* return applied SyncResult to reader.
* persist an applied SyncResult for the reopened reader.
* - PUSH_LOCAL: compute local mapping, warm session with GET, then upload via
* reused connection to avoid a second full TLS handshake.
*/
class KOReaderSyncActivity final : public Activity {
public:
// Intent controls UI/behavior split for the same sync pipeline.
// - COMPARE: fetch then let user choose apply/upload.
// - PULL_REMOTE: fetch and apply immediately.
// - PUSH_LOCAL: upload immediately.
// This keeps WiFi/NTP/hash/memory handling centralized while enabling a
// simpler KOReader-like reader menu UX.
enum class SyncIntent {
COMPARE,
PULL_REMOTE,
PUSH_LOCAL,
};
explicit KOReaderSyncActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::shared_ptr<Epub>& epub, const std::string& epubPath, int currentSpineIndex,
int currentPage, int totalPagesInSpine, uint16_t paragraphIndex = 0,
bool hasParagraphIndex = false, SyncIntent syncIntent = SyncIntent::COMPARE)
explicit KOReaderSyncActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& epubPath,
int currentSpineIndex, int currentPage, int totalPagesInSpine,
uint16_t paragraphIndex = 0, bool hasParagraphIndex = false,
KOReaderSyncIntentState syncIntent = KOReaderSyncIntentState::COMPARE)
: Activity("KOReaderSync", renderer, mappedInput),
epub(epub),
epubPath(epubPath),
currentSpineIndex(currentSpineIndex),
currentPage(currentPage),
@@ -83,7 +75,7 @@ class KOReaderSyncActivity final : public Activity {
int totalPagesInSpine;
uint16_t localParagraphIndex;
bool hasLocalParagraphIndex;
SyncIntent syncIntent = SyncIntent::COMPARE;
KOReaderSyncIntentState syncIntent = KOReaderSyncIntentState::COMPARE;
State state = WIFI_SELECTION;
std::string statusMessage;
@@ -111,6 +103,7 @@ class KOReaderSyncActivity final : public Activity {
void performSync();
void performUpload();
void closeCancelled();
void resumeReader(KOReaderSyncOutcomeState outcome, const SyncResult* appliedResult = nullptr);
bool ensureEpubLoadedForMapping();
void releaseEpubForMapping();
bool computeLocalProgressAndChapter();
+19
View File
@@ -3,8 +3,12 @@
#include <FsHelpers.h>
#include <HalStorage.h>
#include <I18n.h>
#include <Logging.h>
#include <esp_heap_caps.h>
#include <esp_system.h>
#include "CrossPointSettings.h"
#include "CrossPointState.h"
#include "Epub.h"
#include "EpubReaderActivity.h"
#include "Txt.h"
@@ -16,6 +20,14 @@
#include "components/UITheme.h"
#include "fontIds.h"
namespace {
void logReaderLaunchMemSnapshot(const char* stage) {
const uint32_t freeHeap = esp_get_free_heap_size();
const uint32_t contigHeap = heap_caps_get_largest_free_block(MALLOC_CAP_8BIT | MALLOC_CAP_DEFAULT);
LOG_DBG("READER", "Reader mem[%s]: free=%lu contig=%lu", stage, freeHeap, contigHeap);
}
} // namespace
std::string ReaderActivity::extractFolderPath(const std::string& filePath) {
const auto lastSlash = filePath.find_last_of('/');
if (lastSlash == std::string::npos || lastSlash == 0) {
@@ -90,6 +102,7 @@ void ReaderActivity::goToLibrary(const std::string& fromBookPath) {
void ReaderActivity::onGoToEpubReader(std::unique_ptr<Epub> epub) {
const auto epubPath = epub->getPath();
currentBookPath = epubPath;
logReaderLaunchMemSnapshot("before_push_epub_reader");
startActivityForResult(std::make_unique<EpubReaderActivity>(renderer, mappedInput, std::move(epub)),
[this](const ActivityResult&) { finish(); });
}
@@ -115,12 +128,18 @@ void ReaderActivity::onGoToTxtReader(std::unique_ptr<Txt> txt) {
void ReaderActivity::onEnter() {
Activity::onEnter();
logReaderLaunchMemSnapshot("onEnter_begin");
if (initialBookPath.empty()) {
goToLibrary(); // Start from root when entering via Browse
return;
}
if (APP_STATE.koReaderSyncSession.active && APP_STATE.koReaderSyncSession.epubPath == initialBookPath) {
LOG_DBG("READER", "Opening EPUB with pending KOReader sync outcome=%d",
static_cast<int>(APP_STATE.koReaderSyncSession.outcome));
}
currentBookPath = initialBookPath;
if (isImageFile(initialBookPath)) {
onGoToBmpViewer(initialBookPath);