fix: free Epub RAM and simplify KOSync navigation via ActivityManager (#1860)

## Summary

* **What is the goal of this PR?** Fix KOSync failing with "Network
error" on large/complex EPUBs, and simplify the sync navigation flow by
removing the callback/result pattern.

* **What changes are included?**

This PR combines the approaches from #1855 and #1760 into a single,
cleaner solution:

  **Memory fix (from #1855):**
- `EpubReaderActivity` pre-computes the local KOReader position and
chapter name, then explicitly releases `epub` and `section` before
launching `KOReaderSyncActivity`. This frees ~65KB measured on device,
giving the TLS handshake sufficient heap. The root cause was
`MBEDTLS_ERR_X509_ALLOC_FAILED` (-0x2880) when a 3-cert chain consumed
~48KB during the handshake with only ~50KB available.
- `KOReaderSyncActivity` no longer receives a `shared_ptr<Epub>` at
construction — it lazy-loads the Epub after TLS only if remote progress
is found (`ensureEpubLoaded()`).
- Added `MIN_HEAP_FOR_TLS = 55000` guard in `KOReaderSyncClient` —
returns `LOW_MEMORY` early if aggregate free heap is too low before
attempting a TLS connection.

  **Navigation simplification (from #1760):**
- Replaced `startActivityForResult` + callback with
`activityManager.replaceActivity` / `activityManager.goToReader`.
Progress is saved to `progress.bin` before the epub is released
(cancel/upload paths) and in `saveProgressAndReturn` (apply remote
path). The reader re-launches from the saved position naturally via
`goToReader`, eliminating the need to reload the epub in a callback.
- Extracted `ReaderUtils::saveProgress()` as a shared helper used by
both `EpubReaderActivity` and `KOReaderSyncActivity`.
- Added `STR_SAVE_PROGRESS_FAILED` to all 22 language files for the case
where writing the synced position to SD fails.

  **Orientation fix (found during device testing):**
- `EpubReaderActivity::onExit()` resets the renderer to portrait before
destruction. With `replaceActivity` the reader is fully torn down before
KOSync starts, so KOSync was always rendering in portrait even when
reading in landscape. Fixed by calling `ReaderUtils::applyOrientation`
in `KOReaderSyncActivity::onEnter()`.

## Additional Context

Heap measurements on device (large EPUB with complex CSS):

| Metric | Before | After |
|---|---|---|
| Heap before Epub release | 88,156 bytes | — |
| Heap after Epub release | — | 153,892 bytes (+65,736) |
| Heap at TLS handshake | ~50,000 bytes (fails) | ~116,384 bytes
(passes) |
| Min-free-ever during sync session | 2,600 bytes | 33,052 bytes |
| TLS result | `MBEDTLS_ERR_X509_ALLOC_FAILED` | HTTP 200 |

Tested on device: sync from inside a large EPUB in both portrait and
landscape, cancel, apply remote progress, upload local progress.

---

### AI Usage

Did you use AI tools to help write this code? _**YES**_

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Wylan Swets
2026-05-08 22:01:31 -05:00
committed by GitHub
co-authored by Claude Sonnet 4.6
parent 7993b2bb97
commit e64155ed63
29 changed files with 221 additions and 91 deletions
+65 -46
View File
@@ -1,30 +1,26 @@
#include "KOReaderSyncActivity.h"
#include <GfxRenderer.h>
#include <HalStorage.h>
#include <I18n.h>
#include <Logging.h>
#include <WiFi.h>
#include <esp_sntp.h>
#include <cassert>
#include "Epub/Section.h"
#include "EpubReaderUtils.h"
#include "KOReaderCredentialStore.h"
#include "KOReaderDocumentId.h"
#include "MappedInputManager.h"
#include "ReaderUtils.h"
#include "activities/ActivityManager.h"
#include "activities/network/WifiSelectionActivity.h"
#include "components/UITheme.h"
#include "fontIds.h"
namespace {
CrossPointPosition makeLocalPositionWithParagraph(const int spineIndex, const int page, const int totalPages,
const std::optional<uint16_t>& paragraphIndex) {
CrossPointPosition pos = {spineIndex, page, totalPages};
if (paragraphIndex.has_value()) {
pos.paragraphIndex = *paragraphIndex;
pos.hasParagraphIndex = true;
}
return pos;
}
void syncTimeWithNTP() {
// Stop SNTP if already running (can't reconfigure while running)
if (esp_sntp_enabled()) {
@@ -61,13 +57,43 @@ void wifiOff() {
}
} // namespace
void KOReaderSyncActivity::ensureEpubLoaded() {
if (!epub) {
LOG_DBG("KOSync", "Loading epub for progress mapping (heap: %u)", (unsigned)ESP.getFreeHeap());
epub = std::make_shared<Epub>(epubPath, "/.crosspoint");
epub->setupCacheDir();
// Load metadata only (no CSS needed for progress mapping, don't rebuild if cache is missing).
if (!epub->load(false, true)) {
LOG_ERR("KOSync", "Failed to load epub for progress mapping");
epub.reset();
return;
}
LOG_DBG("KOSync", "Epub loaded (heap: %u)", (unsigned)ESP.getFreeHeap());
}
}
void KOReaderSyncActivity::saveProgressAndReturn(int spineIndex, int page) {
// epub is guaranteed non-null here: ensureEpubLoaded() was called in performSync() before
// SHOWING_RESULT state is entered, and this method is only called from that state.
assert(epub);
if (!EpubReaderUtils::saveProgress(*epub, spineIndex, page, 0)) {
{
RenderLock lock(*this);
state = SYNC_FAILED;
statusMessage = tr(STR_SAVE_PROGRESS_FAILED);
}
requestUpdate(true);
return;
}
returnToReader();
}
void KOReaderSyncActivity::returnToReader() { activityManager.goToReader(epubPath); }
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();
returnToReader();
return;
}
@@ -141,8 +167,19 @@ void KOReaderSyncActivity::performSync() {
return;
}
// Convert remote progress to CrossPoint position
// Epub was released before sync to free RAM for the TLS handshake — reload it now.
hasRemoteProgress = true;
ensureEpubLoaded();
if (!epub) {
{
RenderLock lock(*this);
state = SYNC_FAILED;
statusMessage = "";
}
requestUpdate(true);
return;
}
KOReaderPosition koPos = {remoteProgress.progress, remoteProgress.percentage};
remotePosition = ProgressMapper::toCrossPoint(epub, koPos, currentSpineIndex, totalPagesInSpine);
@@ -157,11 +194,7 @@ void KOReaderSyncActivity::performSync() {
remotePosition.pageNumber = *paragraphPage;
}
}
// Calculate local progress in KOReader format (for display)
CrossPointPosition localPos =
makeLocalPositionWithParagraph(currentSpineIndex, currentPage, totalPagesInSpine, currentParagraphIndex);
localProgress = ProgressMapper::toKOReader(epub, localPos);
// localProgress was pre-computed in EpubReaderActivity before the Epub was released.
{
RenderLock lock(*this);
@@ -185,15 +218,11 @@ void KOReaderSyncActivity::performUpload() {
}
requestUpdateAndWait();
// Convert current position to KOReader format
CrossPointPosition localPos =
makeLocalPositionWithParagraph(currentSpineIndex, currentPage, totalPagesInSpine, currentParagraphIndex);
KOReaderPosition koPos = ProgressMapper::toKOReader(epub, localPos);
// localProgress was pre-computed in EpubReaderActivity before the Epub was released.
KOReaderProgress progress;
progress.document = documentHash;
progress.progress = koPos.xpath;
progress.percentage = koPos.percentage;
progress.progress = localProgress.xpath;
progress.percentage = localProgress.percentage;
const auto result = KOReaderSyncClient::updateProgress(progress);
@@ -218,6 +247,7 @@ void KOReaderSyncActivity::performUpload() {
void KOReaderSyncActivity::onEnter() {
Activity::onEnter();
ReaderUtils::applyOrientation(renderer, SETTINGS.orientation);
// Check for credentials first
if (!KOREADER_STORE.hasCredentials()) {
@@ -271,15 +301,15 @@ void KOReaderSyncActivity::render(RenderLock&&) {
// Show comparison
renderer.drawCenteredText(UI_10_FONT_ID, 120, tr(STR_PROGRESS_FOUND), true, EpdFontFamily::BOLD);
// Get chapter names from TOC
// Remote chapter name requires Epub (loaded lazily in performSync before this state).
const int remoteTocIndex = epub->getTocIndexForSpineIndex(remotePosition.spineIndex);
const int localTocIndex = epub->getTocIndexForSpineIndex(currentSpineIndex);
const std::string remoteChapter =
(remoteTocIndex >= 0) ? epub->getTocItem(remoteTocIndex).title
: (std::string(tr(STR_SECTION_PREFIX)) + std::to_string(remotePosition.spineIndex + 1));
// Local chapter name was pre-computed before Epub was released.
const std::string localChapter =
(localTocIndex >= 0) ? epub->getTocItem(localTocIndex).title
: (std::string(tr(STR_SECTION_PREFIX)) + std::to_string(currentSpineIndex + 1));
!localChapterName.empty() ? localChapterName
: (std::string(tr(STR_SECTION_PREFIX)) + std::to_string(currentSpineIndex + 1));
// Remote progress - chapter and page
renderer.drawText(UI_10_FONT_ID, 20, 160, tr(STR_REMOTE_LABEL), true);
@@ -362,10 +392,7 @@ void KOReaderSyncActivity::render(RenderLock&&) {
void KOReaderSyncActivity::loop() {
if (state == NO_CREDENTIALS || state == SYNC_FAILED || state == UPLOAD_COMPLETE) {
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
ActivityResult result;
result.isCancelled = true;
setResult(std::move(result));
finish();
returnToReader();
}
return;
}
@@ -384,9 +411,7 @@ void KOReaderSyncActivity::loop() {
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
if (selectedOption == 0) {
// Wifi will be turned off in onExit()
setResult(SyncResult{remotePosition.spineIndex, remotePosition.pageNumber});
finish();
saveProgressAndReturn(remotePosition.spineIndex, remotePosition.pageNumber);
} else if (selectedOption == 1) {
// Upload local progress
performUpload();
@@ -394,10 +419,7 @@ void KOReaderSyncActivity::loop() {
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
ActivityResult result;
result.isCancelled = true;
setResult(std::move(result));
finish();
returnToReader();
}
return;
}
@@ -416,10 +438,7 @@ void KOReaderSyncActivity::loop() {
}
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
ActivityResult result;
result.isCancelled = true;
setResult(std::move(result));
finish();
returnToReader();
}
return;
}