diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index ae9fbf63..499e9437 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -12,7 +12,7 @@ #include "parsers/ChapterHtmlSlimParser.h" namespace { -constexpr uint8_t SECTION_FILE_VERSION = 22; +constexpr uint8_t SECTION_FILE_VERSION = 20; constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + // SECTION_FILE_VERSION sizeof(int) + // fontId sizeof(float) + // lineCompression diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index bd3f3182..a7982a53 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -122,28 +122,6 @@ bool isZeroHeightSpacerParagraph(const char* name, const std::string& styleAttr) return hasZeroHeight && hasZeroMargin && hasZeroBorder; } -BlockStyle getInheritedBlockStyle(const BlockStyle& parent, const BlockStyle& child) { - BlockStyle inherited = child; - - inherited.marginLeft = static_cast(parent.marginLeft + child.marginLeft); - inherited.marginRight = static_cast(parent.marginRight + child.marginRight); - inherited.paddingLeft = static_cast(parent.paddingLeft + child.paddingLeft); - inherited.paddingRight = static_cast(parent.paddingRight + child.paddingRight); - - if (!child.textIndentDefined) { - inherited.textIndent = parent.textIndent; - inherited.textIndentDefined = parent.textIndentDefined; - } - - if (!child.textAlignDefined) { - inherited.alignment = parent.alignment; - inherited.textAlignDefined = parent.textAlignDefined; - } - - inherited.fromBrElement = false; - return inherited; -} - // Update effective bold/italic/underline based on block style and inline style stack void ChapterHtmlSlimParser::updateEffectiveInlineStyle() { // Start with block-level styles @@ -198,9 +176,11 @@ void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) { if (currentTextBlock) { // already have a text block running and it is empty - just reuse it if (currentTextBlock->isEmpty()) { + // Merge with existing block style to accumulate CSS styling from parent block elements. + // This handles cases like

text

where the + // div's margin should be preserved, even though it has no direct text content. BlockStyle incoming = blockStyle; - const BlockStyle& currentStyle = currentTextBlock->getBlockStyle(); - const bool brGapPending = currentStyle.fromBrElement; + const bool brGapPending = currentTextBlock->getBlockStyle().fromBrElement; if (brGapPending) { // The empty block was created by a
section separator. Inject a full line of // blank space before the following paragraph so the scene/section break is visible. @@ -208,8 +188,12 @@ void ChapterHtmlSlimParser::startNewTextBlock(const BlockStyle& blockStyle) { const int16_t lineHeight = static_cast(renderer.getLineHeight(fontId) * lineCompression + 0.5f); incoming.marginTop = static_cast(incoming.marginTop + lineHeight); } - incoming.fromBrElement = blockStyle.fromBrElement; - currentTextBlock->setBlockStyle(incoming); + + BlockStyle merged = currentTextBlock->getBlockStyle().getCombinedBlockStyle(incoming); + // Preserve only whether the current empty block still represents
separators. + // This lets consecutive
accumulate one line each without leaking the flag to real content blocks. + merged.fromBrElement = blockStyle.fromBrElement; + currentTextBlock->setBlockStyle(merged); if (!pendingAnchorId.empty()) { if (std::find(tocAnchors.begin(), tocAnchors.end(), pendingAnchorId) != tocAnchors.end()) { @@ -491,25 +475,19 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* } const bool hasCssHeight = imgStyle.hasImageHeight(); const bool hasCssWidth = imgStyle.hasImageWidth(); - int containerWidth = self->viewportWidth; - if (self->currentTextBlock) { - const int inset = self->currentTextBlock->getBlockStyle().totalHorizontalInset(); - if (inset > 0 && inset < self->viewportWidth) { - containerWidth = self->viewportWidth - inset; - } - } if (hasCssHeight && hasCssWidth && dims.width > 0 && dims.height > 0) { - // Both CSS height and width set: resolve both, then clamp to the current container preserving ratio. + // Both CSS height and width set: resolve both, then clamp to viewport preserving requested ratio displayHeight = static_cast( imgStyle.imageHeight.toPixels(emSize, static_cast(self->viewportHeight)) + 0.5f); - displayWidth = - static_cast(imgStyle.imageWidth.toPixels(emSize, static_cast(containerWidth)) + 0.5f); + displayWidth = static_cast( + imgStyle.imageWidth.toPixels(emSize, static_cast(self->viewportWidth)) + 0.5f); if (displayHeight < 1) displayHeight = 1; if (displayWidth < 1) displayWidth = 1; - if (displayWidth > containerWidth || displayHeight > self->viewportHeight) { - float scaleX = - (displayWidth > containerWidth) ? static_cast(containerWidth) / displayWidth : 1.0f; + if (displayWidth > self->viewportWidth || displayHeight > self->viewportHeight) { + float scaleX = (displayWidth > self->viewportWidth) + ? static_cast(self->viewportWidth) / displayWidth + : 1.0f; float scaleY = (displayHeight > self->viewportHeight) ? static_cast(self->viewportHeight) / displayHeight : 1.0f; @@ -534,8 +512,8 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* static_cast(displayHeight * (static_cast(dims.width) / dims.height) + 0.5f); if (displayWidth < 1) displayWidth = 1; } - if (displayWidth > containerWidth) { - displayWidth = containerWidth; + if (displayWidth > self->viewportWidth) { + displayWidth = self->viewportWidth; // Rescale height to preserve aspect ratio when width is clamped displayHeight = static_cast(displayWidth * (static_cast(dims.height) / dims.width) + 0.5f); @@ -544,10 +522,10 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* if (displayWidth < 1) displayWidth = 1; LOG_DBG("EHP", "Display size from CSS height: %dx%d", displayWidth, displayHeight); } else if (hasCssWidth && !hasCssHeight && dims.width > 0 && dims.height > 0) { - // Use CSS width (resolve % against container width) and derive height from aspect ratio. - displayWidth = - static_cast(imgStyle.imageWidth.toPixels(emSize, static_cast(containerWidth)) + 0.5f); - if (displayWidth > containerWidth) displayWidth = containerWidth; + // Use CSS width (resolve % against viewport width) and derive height from aspect ratio + displayWidth = static_cast( + imgStyle.imageWidth.toPixels(emSize, static_cast(self->viewportWidth)) + 0.5f); + if (displayWidth > self->viewportWidth) displayWidth = self->viewportWidth; if (displayWidth < 1) displayWidth = 1; displayHeight = static_cast(displayWidth * (static_cast(dims.height) / dims.width) + 0.5f); @@ -561,8 +539,8 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* if (displayHeight < 1) displayHeight = 1; LOG_DBG("EHP", "Display size from CSS width: %dx%d", displayWidth, displayHeight); } else { - // Scale to fit the current container while maintaining aspect ratio. - int maxWidth = containerWidth; + // Scale to fit viewport while maintaining aspect ratio + int maxWidth = self->viewportWidth; int maxHeight = self->viewportHeight; float scaleX = (dims.width > maxWidth) ? (float)maxWidth / dims.width : 1.0f; float scaleY = (dims.height > maxHeight) ? (float)maxHeight / dims.height : 1.0f; @@ -676,10 +654,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* // Fallback to alt text if image processing fails if (!alt.empty()) { alt = "[Image: " + alt + "]"; - const BlockStyle altBlockStyle = self->blockStyleStack.empty() - ? centeredBlockStyle - : getInheritedBlockStyle(self->blockStyleStack.back(), centeredBlockStyle); - self->startNewTextBlock(altBlockStyle); + self->startNewTextBlock(centeredBlockStyle); self->italicUntilDepth = std::min(self->italicUntilDepth, self->depth); self->depth += 1; self->characterData(userData, alt.c_str(), alt.length()); @@ -793,9 +768,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* if (self->embeddedStyle && cssStyle.hasTextAlign()) { headerBlockStyle.alignment = cssStyle.textAlign; } - const BlockStyle inheritedHeaderBlockStyle = getInheritedBlockStyle(self->blockStyleStack.back(), headerBlockStyle); - self->blockStyleStack.push_back(inheritedHeaderBlockStyle); - self->startNewTextBlock(inheritedHeaderBlockStyle); + self->startNewTextBlock(headerBlockStyle); self->boldUntilDepth = std::min(self->boldUntilDepth, self->depth); self->updateEffectiveInlineStyle(); } else if (matches(name, BLOCK_TAGS, NUM_BLOCK_TAGS)) { @@ -807,9 +780,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* blockStyle.alignment = cssStyle.textAlign; blockStyle.textAlignDefined = true; } - const BlockStyle inheritedBlockStyle = getInheritedBlockStyle(self->blockStyleStack.back(), blockStyle); - self->blockStyleStack.push_back(inheritedBlockStyle); - self->startNewTextBlock(inheritedBlockStyle); + self->startNewTextBlock(blockStyle); self->updateEffectiveInlineStyle(); self->skipTextUntilDepth = self->depth; @@ -843,9 +814,7 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char* blockStyle.alignment = cssStyle.textAlign; blockStyle.textAlignDefined = true; } - const BlockStyle inheritedBlockStyle = getInheritedBlockStyle(self->blockStyleStack.back(), blockStyle); - self->blockStyleStack.push_back(inheritedBlockStyle); - self->startNewTextBlock(inheritedBlockStyle); + self->startNewTextBlock(blockStyle); self->updateEffectiveInlineStyle(); if (strcmp(name, "li") == 0) { @@ -1289,24 +1258,29 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n self->currentCssStyle.reset(); self->updateEffectiveInlineStyle(); - if (strcmp(name, "br") != 0 && self->blockStyleStack.size() > 1) { - self->blockStyleStack.pop_back(); - if (self->currentTextBlock && self->currentTextBlock->isEmpty()) { - self->currentTextBlock->setBlockStyle(self->blockStyleStack.back()); + // Reset alignment on empty text blocks to prevent stale alignment from bleeding + // into the next sibling element. This fixes issue #1026 where an empty

(default + // Center) followed by an image-only

causes Center to persist through the chain + // of empty block reuse into subsequent text paragraphs. + // Margins/padding are preserved so parent element spacing still accumulates correctly. + if (self->currentTextBlock && self->currentTextBlock->isEmpty()) { + auto style = self->currentTextBlock->getBlockStyle(); + // Keep alignment only when closing the
separator itself so subsequent text + // within the same block container stays aligned. Reset alignment when closing + // other block tags (e.g. div/p) to avoid leaking centered/right alignment globally. + const bool preserveForBrClose = style.fromBrElement && strcmp(name, "br") == 0; + if (!preserveForBrClose) { + style.textAlignDefined = false; + style.alignment = (self->paragraphAlignment == static_cast(CssTextAlign::None)) + ? CssTextAlign::Justify + : static_cast(self->paragraphAlignment); + self->currentTextBlock->setBlockStyle(style); } } } } bool ChapterHtmlSlimParser::parseAndBuildPages() { - BlockStyle rootBlockStyle; - rootBlockStyle.alignment = (this->paragraphAlignment == static_cast(CssTextAlign::None)) - ? CssTextAlign::Justify - : static_cast(this->paragraphAlignment); - blockStyleStack.clear(); - blockStyleStack.reserve(8); - blockStyleStack.push_back(rootBlockStyle); - auto paragraphAlignmentBlockStyle = BlockStyle(); paragraphAlignmentBlockStyle.textAlignDefined = true; // Resolve None sentinel to Justify for initial block (no CSS context yet) diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h index f63b7fb3..ecda8da1 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h @@ -65,7 +65,6 @@ class ChapterHtmlSlimParser { bool hasUnderline = false, underline = false; }; std::vector inlineStyleStack; - std::vector blockStyleStack; CssStyle currentCssStyle; bool effectiveBold = false; bool effectiveItalic = false; diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 97f0e696..bce22e08 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -1917,7 +1917,12 @@ void GfxRenderer::restoreBwBuffer() { } if (missingChunks) { + // Store failed part-way (or was skipped), so we cannot restore BW bytes safely. + // Still cleanup grayscale staging buffers to avoid retaining large temporary + // allocations that can later starve TLS handshakes. + display.cleanupGrayscaleBuffers(frameBuffer); freeBwBufferChunks(); + LOG_ERR("GFX", "BW restore skipped due to missing chunks; cleaned grayscale buffers only"); return; } diff --git a/lib/KOReaderSync/KOReaderSyncClient.cpp b/lib/KOReaderSync/KOReaderSyncClient.cpp index ac5136cf..d98a0d89 100644 --- a/lib/KOReaderSync/KOReaderSyncClient.cpp +++ b/lib/KOReaderSync/KOReaderSyncClient.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -10,6 +11,7 @@ #include #include +#include #include #include @@ -22,6 +24,9 @@ unsigned KOReaderSyncClient::lastContigHeapAtFailure = 0; const char* KOReaderSyncClient::lastOperation = ""; namespace { +bool g_keepSessionOpen = false; +esp_http_client_handle_t g_sessionClient = nullptr; + // Static buffer for the detail string returned by lastFailureDetail() — sized to fit // the longest expected message including esp_err name (~32 chars), opcode (~10), heap // numbers, and HTTP status. Single-threaded sync flow makes static safe. @@ -41,10 +46,20 @@ void beginRequest(const char* operation) { constexpr char DEVICE_NAME[] = "CrossPoint"; constexpr char DEVICE_ID[] = "crosspoint-reader"; -// Small TLS buffers to fit in ESP32-C3's limited heap (~46KB free after WiFi). -// KOSync payloads are tiny JSON (<1KB), so 2KB buffers are sufficient. -// Default 16KB buffers cause OOM during TLS handshake. -constexpr int HTTP_BUF_SIZE = 2048; +// Use small HTTP/TLS buffers to reduce peak handshake memory on ESP32-C3. +// Payloads are tiny JSON, so throughput impact is minimal while avoiding +// large transient allocations from default client buffer sizes. +constexpr int HTTP_BUF_SIZE = 1024; +constexpr unsigned TLS_CONTIG_HEAP_TOLERANCE = 512; + +// Captures radio/link state around failed connects. +// Why: many field failures look like TLS errors but are actually weak WiFi. +void logWifiSnapshot(const char* stage) { + const wl_status_t status = WiFi.status(); + const int32_t rssi = WiFi.RSSI(); + LOG_DBG("KOSync", "%s: wifi_status=%d rssi=%ld ip=%s", stage, static_cast(status), static_cast(rssi), + WiFi.localIP().toString().c_str()); +} // Response buffer for reading HTTP body struct ResponseBuffer { @@ -64,6 +79,20 @@ struct ResponseBuffer { } }; +ResponseBuffer g_sessionResponseBuf; + +void clearResponseBuffer(ResponseBuffer* buf) { + if (!buf) return; + if (buf->data) { + buf->len = 0; + buf->data[0] = '\0'; + } +} + +ResponseBuffer* effectiveResponseBuffer(ResponseBuffer* localBuf) { + return g_keepSessionOpen ? &g_sessionResponseBuf : localBuf; +} + // HTTP event handler to collect response body esp_err_t httpEventHandler(esp_http_client_event_t* evt) { auto* buf = static_cast(evt->user_data); @@ -105,10 +134,23 @@ std::string base64Encode(const std::string& input) { // we should proceed; false means caller must abort with NETWORK_ERROR — in which case // lastFailureDetail() will report the heap shortage instead of attempting a doomed handshake. bool checkHeapForTls() { + const bool hasReusableSession = g_keepSessionOpen && g_sessionClient != nullptr; + const bool isUpload = (KOReaderSyncClient::lastOperation && + strcmp(KOReaderSyncClient::lastOperation, "update progress") == 0); + const unsigned requiredContig = + isUpload ? KOReaderSyncClient::MIN_CONTIG_HEAP_FOR_TLS_UPLOAD : KOReaderSyncClient::MIN_CONTIG_HEAP_FOR_TLS; + + // Upload can often reuse the already-established GET connection. In that case + // a full handshake allocation is typically unnecessary, so avoid failing fast + // on contiguous-heap threshold and let the HTTP client attempt reuse. + if (isUpload && hasReusableSession) { + return true; + } + // beginRequest() already populated lastContigHeapAtFailure for the diagnostic path. - if (KOReaderSyncClient::lastContigHeapAtFailure < KOReaderSyncClient::MIN_CONTIG_HEAP_FOR_TLS) { + if (KOReaderSyncClient::lastContigHeapAtFailure + TLS_CONTIG_HEAP_TOLERANCE < requiredContig) { LOG_ERR("KOSync", "Insufficient contiguous heap for TLS: %u available, %u required", - KOReaderSyncClient::lastContigHeapAtFailure, KOReaderSyncClient::MIN_CONTIG_HEAP_FOR_TLS); + KOReaderSyncClient::lastContigHeapAtFailure, requiredContig); // Synthesize an esp_err_t-shaped value so the diagnostic detail string is uniform. KOReaderSyncClient::lastEspError = ESP_ERR_NO_MEM; return false; @@ -119,15 +161,33 @@ bool checkHeapForTls() { // Create configured esp_http_client with small TLS buffers esp_http_client_handle_t createClient(const char* url, ResponseBuffer* buf, esp_http_client_method_t method = HTTP_METHOD_GET) { + ResponseBuffer* activeBuf = effectiveResponseBuffer(buf); + + if (g_keepSessionOpen && g_sessionClient) { + esp_http_client_set_url(g_sessionClient, url); + esp_http_client_set_method(g_sessionClient, method); + + // KOSync auth headers + esp_http_client_set_header(g_sessionClient, "Accept", "application/vnd.koreader.v1+json"); + esp_http_client_set_header(g_sessionClient, "x-auth-user", KOREADER_STORE.getUsername().c_str()); + esp_http_client_set_header(g_sessionClient, "x-auth-key", KOREADER_STORE.getMd5Password().c_str()); + + std::string credentials = KOREADER_STORE.getUsername() + ":" + KOREADER_STORE.getPassword(); + std::string authHeader = "Basic " + base64Encode(credentials); + esp_http_client_set_header(g_sessionClient, "Authorization", authHeader.c_str()); + return g_sessionClient; + } + esp_http_client_config_t config = {}; config.url = url; config.event_handler = httpEventHandler; - config.user_data = buf; + config.user_data = activeBuf; config.method = method; config.timeout_ms = 15000; config.buffer_size = HTTP_BUF_SIZE; config.buffer_size_tx = HTTP_BUF_SIZE; config.crt_bundle_attach = esp_crt_bundle_attach; + config.keep_alive_enable = g_keepSessionOpen; esp_http_client_handle_t client = esp_http_client_init(&config); if (!client) return nullptr; @@ -142,10 +202,28 @@ esp_http_client_handle_t createClient(const char* url, ResponseBuffer* buf, std::string authHeader = "Basic " + base64Encode(credentials); esp_http_client_set_header(client, "Authorization", authHeader.c_str()); + if (g_keepSessionOpen) { + g_sessionClient = client; + } + return client; } } // namespace +void KOReaderSyncClient::beginPersistentSession() { + g_keepSessionOpen = true; + clearResponseBuffer(&g_sessionResponseBuf); +} + +void KOReaderSyncClient::endPersistentSession() { + g_keepSessionOpen = false; + if (g_sessionClient) { + esp_http_client_cleanup(g_sessionClient); + g_sessionClient = nullptr; + } + clearResponseBuffer(&g_sessionResponseBuf); +} + KOReaderSyncClient::Error KOReaderSyncClient::registerUser() { if (!KOREADER_STORE.hasCredentials()) { LOG_DBG("KOSync", "No credentials configured"); @@ -168,6 +246,8 @@ KOReaderSyncClient::Error KOReaderSyncClient::registerUser() { LOG_DBG("KOSync", "Register request body: "); ResponseBuffer buf; + ResponseBuffer* activeBuf = effectiveResponseBuffer(&buf); + clearResponseBuffer(activeBuf); esp_http_client_handle_t client = createClient(url.c_str(), &buf, HTTP_METHOD_POST); if (!client) { lastEspError = ESP_ERR_NO_MEM; @@ -181,10 +261,12 @@ KOReaderSyncClient::Error KOReaderSyncClient::registerUser() { const int httpCode = esp_http_client_get_status_code(client); lastHttpCode = httpCode; lastEspError = err; - esp_http_client_cleanup(client); + if (!g_keepSessionOpen) { + esp_http_client_cleanup(client); + } LOG_DBG("KOSync", "Register response: %d (err: %s) | body: %s", httpCode, esp_err_to_name(err), - buf.data ? buf.data : ""); + activeBuf->data ? activeBuf->data : ""); if (err != ESP_OK) { return NETWORK_ERROR; @@ -198,7 +280,7 @@ KOReaderSyncClient::Error KOReaderSyncClient::registerUser() { } else if (httpCode == 402) { // Both "user already exists" (error 2002) and "registration disabled" (error 2005) // return HTTP 402 on the original kosync server. Distinguish them by body text. - std::string lowerBody = buf.data ? buf.data : ""; + std::string lowerBody = activeBuf->data ? activeBuf->data : ""; std::transform(lowerBody.begin(), lowerBody.end(), lowerBody.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); if (lowerBody.find("already") != std::string::npos) { @@ -226,6 +308,8 @@ KOReaderSyncClient::Error KOReaderSyncClient::authenticate() { lastContigHeapAtFailure); ResponseBuffer buf; + ResponseBuffer* activeBuf = effectiveResponseBuffer(&buf); + clearResponseBuffer(activeBuf); esp_http_client_handle_t client = createClient(url.c_str(), &buf); if (!client) { lastEspError = ESP_ERR_NO_MEM; @@ -236,7 +320,9 @@ KOReaderSyncClient::Error KOReaderSyncClient::authenticate() { const int httpCode = esp_http_client_get_status_code(client); lastHttpCode = httpCode; lastEspError = err; - esp_http_client_cleanup(client); + if (!g_keepSessionOpen) { + esp_http_client_cleanup(client); + } LOG_DBG("KOSync", "Auth response: %d (err: %s)", httpCode, esp_err_to_name(err)); @@ -261,25 +347,47 @@ KOReaderSyncClient::Error KOReaderSyncClient::getProgress(const std::string& doc lastContigHeapAtFailure); ResponseBuffer buf; - esp_http_client_handle_t client = createClient(url.c_str(), &buf); - if (!client) { - lastEspError = ESP_ERR_NO_MEM; - return NETWORK_ERROR; + ResponseBuffer* activeBuf = effectiveResponseBuffer(&buf); + esp_err_t err = ESP_FAIL; + int httpCode = 0; + + for (int attempt = 1; attempt <= 2; attempt++) { + clearResponseBuffer(activeBuf); + + esp_http_client_handle_t client = createClient(url.c_str(), &buf); + if (!client) { + lastEspError = ESP_ERR_NO_MEM; + return NETWORK_ERROR; + } + + logWifiSnapshot("WiFi before getProgress"); + err = esp_http_client_perform(client); + httpCode = esp_http_client_get_status_code(client); + lastHttpCode = httpCode; + lastEspError = err; + if (!g_keepSessionOpen) { + esp_http_client_cleanup(client); + } + + LOG_DBG("KOSync", "Get progress response: %d (err: %s) [attempt %d]", httpCode, esp_err_to_name(err), attempt); + + // Retry exactly once for connect-level failures only. + // Why: this recovers short AP/roaming hiccups without masking persistent + // TLS/auth/server errors that should be surfaced immediately. + if (err == ESP_OK || err != ESP_ERR_HTTP_CONNECT || attempt == 2) { + break; + } + + LOG_ERR("KOSync", "getProgress connect failed on attempt %d, retrying once", attempt); + logWifiSnapshot("WiFi before getProgress retry"); + delay(400); } - esp_err_t err = esp_http_client_perform(client); - const int httpCode = esp_http_client_get_status_code(client); - lastHttpCode = httpCode; - lastEspError = err; - esp_http_client_cleanup(client); - - LOG_DBG("KOSync", "Get progress response: %d (err: %s)", httpCode, esp_err_to_name(err)); - if (err != ESP_OK) return NETWORK_ERROR; - if (httpCode == 200 && buf.data) { + if (httpCode == 200 && activeBuf->data) { JsonDocument doc; - const DeserializationError error = deserializeJson(doc, buf.data); + const DeserializationError error = deserializeJson(doc, activeBuf->data); if (error) { LOG_ERR("KOSync", "JSON parse failed: %s", error.c_str()); @@ -329,23 +437,45 @@ KOReaderSyncClient::Error KOReaderSyncClient::updateProgress(const KOReaderProgr LOG_DBG("KOSync", "Request body: %s", body.c_str()); ResponseBuffer buf; - esp_http_client_handle_t client = createClient(url.c_str(), &buf, HTTP_METHOD_PUT); - if (!client) { - lastEspError = ESP_ERR_NO_MEM; - return NETWORK_ERROR; + ResponseBuffer* activeBuf = effectiveResponseBuffer(&buf); + esp_err_t err = ESP_FAIL; + int httpCode = 0; + + for (int attempt = 1; attempt <= 2; attempt++) { + clearResponseBuffer(activeBuf); + + esp_http_client_handle_t client = createClient(url.c_str(), &buf, HTTP_METHOD_PUT); + if (!client) { + lastEspError = ESP_ERR_NO_MEM; + return NETWORK_ERROR; + } + + esp_http_client_set_header(client, "Content-Type", "application/json"); + esp_http_client_set_post_field(client, body.c_str(), body.length()); + + logWifiSnapshot("WiFi before updateProgress"); + err = esp_http_client_perform(client); + httpCode = esp_http_client_get_status_code(client); + lastHttpCode = httpCode; + lastEspError = err; + if (!g_keepSessionOpen) { + esp_http_client_cleanup(client); + } + + LOG_DBG("KOSync", "Update progress response: %d (err: %s) [attempt %d]", httpCode, esp_err_to_name(err), + attempt); + + // Retry exactly once for connect-level failures only. + // Why: same policy as GET keeps behavior predictable across both endpoints. + if (err == ESP_OK || err != ESP_ERR_HTTP_CONNECT || attempt == 2) { + break; + } + + LOG_ERR("KOSync", "updateProgress connect failed on attempt %d, retrying once", attempt); + logWifiSnapshot("WiFi before updateProgress retry"); + delay(400); } - esp_http_client_set_header(client, "Content-Type", "application/json"); - esp_http_client_set_post_field(client, body.c_str(), body.length()); - - esp_err_t err = esp_http_client_perform(client); - const int httpCode = esp_http_client_get_status_code(client); - lastHttpCode = httpCode; - lastEspError = err; - esp_http_client_cleanup(client); - - LOG_DBG("KOSync", "Update progress response: %d (err: %s)", httpCode, esp_err_to_name(err)); - if (err != ESP_OK) return NETWORK_ERROR; if (httpCode == 200 || httpCode == 202) return OK; if (httpCode == 401) return AUTH_FAILED; @@ -353,11 +483,14 @@ KOReaderSyncClient::Error KOReaderSyncClient::updateProgress(const KOReaderProgr } const char* KOReaderSyncClient::lastFailureDetail() { + const bool isUpload = (lastOperation && strcmp(lastOperation, "update progress") == 0); + const unsigned requiredContig = isUpload ? MIN_CONTIG_HEAP_FOR_TLS_UPLOAD : MIN_CONTIG_HEAP_FOR_TLS; + // Heap-pressure case: surfaced when checkHeapForTls() refused before any TCP/TLS work happened. if (lastEspError == ESP_ERR_NO_MEM && lastHttpCode == 0) { snprintf(g_failureDetailBuf, sizeof(g_failureDetailBuf), "%s: low memory (%u free, %u contig, need %u). Reboot device.", lastOperation, lastHeapAtFailure, - lastContigHeapAtFailure, MIN_CONTIG_HEAP_FOR_TLS); + lastContigHeapAtFailure, requiredContig); return g_failureDetailBuf; } // Network/TLS case: esp_http_client_perform() failed before getting a status code. diff --git a/lib/KOReaderSync/KOReaderSyncClient.h b/lib/KOReaderSync/KOReaderSyncClient.h index 3697c34a..d98e0423 100644 --- a/lib/KOReaderSync/KOReaderSyncClient.h +++ b/lib/KOReaderSync/KOReaderSyncClient.h @@ -70,6 +70,17 @@ class KOReaderSyncClient { */ static Error updateProgress(const KOReaderProgress& progress); + /** + * Keep HTTP/TLS session alive across multiple sync requests (GET/PUT). + * Intended for KOReaderSyncActivity to reduce repeated handshake churn. + */ + static void beginPersistentSession(); + + /** + * Close and release any persistent HTTP/TLS session. + */ + static void endPersistentSession(); + /** * Get human-readable error message (short, for status line). */ @@ -100,5 +111,6 @@ class KOReaderSyncClient { * a request. Below this, the client refuses with NETWORK_ERROR and lastFailureDetail * reports a heap-pressure message instead of attempting (and crashing) the TLS handshake. */ - static constexpr unsigned MIN_CONTIG_HEAP_FOR_TLS = 32 * 1024; + static constexpr unsigned MIN_CONTIG_HEAP_FOR_TLS = 36 * 1024; + static constexpr unsigned MIN_CONTIG_HEAP_FOR_TLS_UPLOAD = 34 * 1024; }; diff --git a/platformio.ini b/platformio.ini index 66bdf599..10696034 100644 --- a/platformio.ini +++ b/platformio.ini @@ -25,7 +25,7 @@ build_flags = -DEINK_DISPLAY_SINGLE_BUFFER_MODE=1 -DDISABLE_FS_H_WARNING=1 -DDESTRUCTOR_CLOSES_FILE=1 - ; -DENABLE_IMAGE_DITHERING_EXTENSION ; dont enable by default, as it increases code size and may not be needed for all images + ;-DENABLE_IMAGE_DITHERING_EXTENSION ; dont enable by default, as it increases code size and may not be needed for all images # https://libexpat.github.io/doc/api/latest/#XML_GE -DXML_GE=0 -DXML_CONTEXT_BYTES=1024 diff --git a/scripts/generate_test_epub.py b/scripts/generate_test_epub.py index 6f8594d8..d8a7f4db 100644 --- a/scripts/generate_test_epub.py +++ b/scripts/generate_test_epub.py @@ -5,7 +5,6 @@ Generate test EPUBs for rendering verification. Creates EPUBs to verify: - Image: Grayscale rendering (4 levels), scaling, centering, cache performance - Text: pre element line breaks, blank lines, nested code element -- Layout: nested block margins, sibling style restoration, image wrapper spacing """ import os @@ -47,7 +46,7 @@ def get_font(size=20): for path in candidates: try: return ImageFont.truetype(path, size) - except Exception: + except: continue return ImageFont.load_default() @@ -555,7 +554,6 @@ def create_epub(epub_path, title, chapters): # Collect all images and chapters manifest_items = [] spine_items = [] - written_images = set() # Add chapters and images for i, (chapter_title, html_content, images) in enumerate(chapters): @@ -564,8 +562,6 @@ def create_epub(epub_path, title, chapters): # Add images for this chapter for img_filename, img_data in images: - if img_filename in written_images: - continue media_type = ( "image/png" if img_filename.endswith(".png") else "image/jpeg" ) @@ -573,7 +569,6 @@ def create_epub(epub_path, title, chapters): f' ' ) epub.writestr(f"OEBPS/images/{img_filename}", img_data) - written_images.add(img_filename) # Add chapter manifest_items.append( @@ -623,12 +618,12 @@ def create_epub(epub_path, title, chapters): epub.writestr("OEBPS/nav.xhtml", nav_xhtml) -def make_chapter(title, body_content, head_content=""): +def make_chapter(title, body_content): """Create XHTML chapter content.""" return f""" -{title}{head_content} +{title}

{title}

{body_content} @@ -1006,102 +1001,6 @@ def main(): OUTPUT_DIR / "test_mixed_images.epub", "Mixed Format Tests", mixed_chapters ) - print("Creating layout regression test EPUB...") - - layout_chapters = [ - ( - "Introduction", - make_chapter( - "Layout Regression Tests", - """ -

This EPUB exercises recent parser edge cases around nested block styles and image wrappers.

-

Recommended settings: Paragraph Alignment set to Book Style or Justify.

-

This regression EPUB uses inline style attributes rather than a head <style> block so it works even if chapter-local embedded CSS is not loaded.

-
    -
  • Nested horizontal margin inheritance for sibling blocks
  • -
  • Vertical paragraph spacing should not explode with nested wrappers
  • -
  • Image wrapper spacing should apply to the image, not leak into following text
  • -
  • Hidden images should not leave a large blank gap before the next paragraph
  • -
-""", - ), - [], - ), - ( - "1. Nested Horizontal Margins", - make_chapter( - "Nested Horizontal Margin Inheritance", - """ -

This chapter mirrors the c1/c2/c3/c4 example behind PR 1582.

-

Expected: C3 is indented the most. C4 is indented less than C3, but still more than the baseline paragraph outside the wrapper.

-

Expected order of indentation: C3 > C4 > baseline paragraph.

-
-
-

C3 paragraph. This text should have the largest left indent because it inherits the outer wrapper, the inner wrapper, and its own left margin. Repeat text to make the paragraph wrap across multiple lines and make the effective left inset obvious while reading.

-
-

C4 paragraph. This text should still inherit the outer wrapper indent, but not the inner wrapper indent. It should therefore appear less indented than the paragraph above, not flush with the body text.

-

Outer-wrapper-only control paragraph. This paragraph should still be indented relative to the page body because it inherits the outer wrapper margin, even though it has no paragraph-level margin-left of its own.

-
-

Baseline paragraph outside the wrappers. This paragraph should align with the normal body text and should be the least indented paragraph on this page.

-""", - ), - [], - ), - ( - "2. Nested Vertical Margins", - make_chapter( - "Nested Vertical Margin Sanity", - """ -

Expected: wrapper nesting should not create an oversized blank vertical gulf between these paragraphs.

-
-
-

Nested vertical spacing paragraph. There should be some breathing room above and below, but not dramatically more than a normal section break.

-
-

Sibling paragraph after the nested block. Spacing before this paragraph should feel normal and should not keep growing with every ancestor wrapper.

-
-

Baseline paragraph after the wrapper section. This should not be pushed far down the page.

-""", - ), - [], - ), - ( - "3. Image Wrapper Spacing", - make_chapter( - "Image Wrapper Spacing", - """ -

Expected: the wrapper's margins should create space around the image, and the paragraph after the image should start with normal spacing rather than inheriting a second copy of that gap.

-
-
-

Wrapped image spacing test

-
-
-

Paragraph after wrapped image. If the wrapper spacing leaks, this paragraph will begin too far down the page. If container width is ignored, the image may also appear too wide for the wrapper.

-""", - ), - [("centering_test.jpg", images["centering_test.jpg"])], - ), - ( - "4. Hidden Image Spacing", - make_chapter( - "Hidden Image Spacing Reset", - """ -

Expected: the hidden image wrapper should not leave a large blank gap before the following paragraph.

-
-

This image is intentionally hidden by CSS

-
-

Paragraph after hidden image. This should follow with near-normal spacing, not the large gap that would be appropriate for a visible wrapped image.

-""", - ), - [("centering_test.jpg", images["centering_test.jpg"])], - ), - ] - - create_epub( - OUTPUT_DIR / "test_layout_regressions.epub", - "Layout Regression Tests", - layout_chapters, - ) - print("Creating text rendering test EPUB...") text_chapters = [ ( diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 833600d1..2cce6c0d 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -148,11 +148,7 @@ void EpubReaderActivity::loop() { // Without credentials, fall through to the regular menu on release. if (mappedInput.isPressed(MappedInputManager::Button::Confirm) && mappedInput.getHeldTime() >= ReaderUtils::GO_HOME_MS && KOREADER_STORE.hasCredentials()) { - const int currentPage = section ? section->currentPage : 0; - const int totalPages = section ? section->pageCount : 0; - startActivityForResult(std::make_unique(renderer, mappedInput, epub, epub->getPath(), - currentSpineIndex, currentPage, totalPages), - [this](const ActivityResult& result) { handleSyncResult(result); }); + launchKOReaderSync(); return; } @@ -464,18 +460,64 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction } case EpubReaderMenuActivity::MenuAction::SYNC: { if (KOREADER_STORE.hasCredentials()) { - const int currentPage = section ? section->currentPage : 0; - const int totalPages = section ? section->pageCount : 0; - startActivityForResult(std::make_unique(renderer, mappedInput, epub, epub->getPath(), - currentSpineIndex, currentPage, totalPages), - [this](const ActivityResult& result) { handleSyncResult(result); }); + launchKOReaderSync(); } break; } } } +void EpubReaderActivity::launchKOReaderSync() { + if (!epub) { + 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(esp_get_free_heap_size())); + + startActivityForResult(std::make_unique(renderer, mappedInput, std::shared_ptr{}, + syncEpubPath, + currentSpineIndex, currentPage, totalPages), + [this](const ActivityResult& result) { handleSyncResult(result); }); +} + void EpubReaderActivity::handleSyncResult(const ActivityResult& result) { + if (!epub && !deferredSyncEpubPath.empty()) { + epub = std::make_shared(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(); + } + if (!result.isCancelled) { const auto& sync = std::get(result.data); if (currentSpineIndex != sync.spineIndex || (section && section->currentPage != sync.page)) { diff --git a/src/activities/reader/EpubReaderActivity.h b/src/activities/reader/EpubReaderActivity.h index 3467828b..5d6f41c8 100644 --- a/src/activities/reader/EpubReaderActivity.h +++ b/src/activities/reader/EpubReaderActivity.h @@ -35,6 +35,7 @@ class EpubReaderActivity final : public Activity { bool pendingScreenshot = false; bool skipNextButtonCheck = false; // Skip button processing for one frame after subactivity exit bool automaticPageTurnActive = false; + std::string deferredSyncEpubPath; // -1 means use global SETTINGS value. int8_t bookEmbeddedStyleOverride = -1; int8_t bookImageRenderingOverride = -1; @@ -57,6 +58,7 @@ class EpubReaderActivity final : public Activity { // Jump to a percentage of the book (0-100), mapping it to spine and page. void jumpToPercent(int percent); void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action); + void launchKOReaderSync(); void handleSyncResult(const ActivityResult& result); void applyOrientation(uint8_t orientation); void toggleAutoPageTurn(uint8_t selectedPageTurnOption); diff --git a/src/activities/reader/KOReaderSyncActivity.cpp b/src/activities/reader/KOReaderSyncActivity.cpp index 46333b14..9167db42 100644 --- a/src/activities/reader/KOReaderSyncActivity.cpp +++ b/src/activities/reader/KOReaderSyncActivity.cpp @@ -4,7 +4,10 @@ #include #include #include +#include #include +#include +#include #include "KOReaderCredentialStore.h" #include "KOReaderDocumentId.h" @@ -13,6 +16,45 @@ #include "components/UITheme.h" #include "fontIds.h" +namespace { +constexpr time_t NTP_RESYNC_MIN_INTERVAL_SEC = 15 * 60; + +// Emits heap snapshots around sync stages so we can correlate TLS failures with +// fragmentation and not just total free heap. +void logSyncMemSnapshot(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); + const bool integrityOk = heap_caps_check_integrity_all(true); + LOG_DBG("KOSync", "Sync mem[%s]: free=%lu contig=%lu integrity=%s", stage, freeHeap, contigHeap, + 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. +void trimMemoryBeforeTls(GfxRenderer& renderer) { + if (auto* cacheManager = renderer.getFontCacheManager()) { + cacheManager->clearCache(); + cacheManager->resetStats(); + LOG_DBG("KOSync", "Cleared font cache before TLS"); + } +} + +bool shouldSyncNtpNow() { + const time_t lastSync = HalClock::lastSyncTime(); + const time_t now = HalClock::now(); + if (lastSync <= 0 || now <= 0) { + return true; + } + + const time_t age = now - lastSync; + if (age < 0) { + return true; + } + return age >= NTP_RESYNC_MIN_INTERVAL_SEC; +} +} // namespace + void KOReaderSyncActivity::onWifiSelectionComplete(const bool success) { if (!success) { LOG_DBG("KOSync", "WiFi connection failed, exiting"); @@ -30,18 +72,29 @@ void KOReaderSyncActivity::onWifiSelectionComplete(const bool success) { state = SYNCING; statusMessage = tr(STR_SYNCING_TIME); } - requestUpdate(true); + requestUpdate(); - // Sync time with NTP before making API requests - HalClock::syncNtp(); + // Avoid repeated NTP churn during rapid sync retries; it can fragment heap + // right before TLS. Re-sync only when clock is stale. + if (shouldSyncNtpNow()) { + HalClock::syncNtp(); + } else { + LOG_DBG("KOSync", "Skipping NTP sync (recently synced)"); + } { RenderLock lock(*this); statusMessage = tr(STR_CALC_HASH); } - requestUpdate(true); + requestUpdate(); + + logSyncMemSnapshot("before_performSync"); + trimMemoryBeforeTls(renderer); + logSyncMemSnapshot("after_trim_before_performSync"); performSync(); + + logSyncMemSnapshot("after_performSync"); } void KOReaderSyncActivity::performSync() { @@ -63,16 +116,34 @@ void KOReaderSyncActivity::performSync() { LOG_DBG("KOSync", "Document hash: %s", documentHash.c_str()); + // 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. + { + RenderLock lock(*this); + statusMessage = tr(STR_MAPPING_LOCAL); + } + requestUpdateAndWait(); + computeLocalProgressAndChapter(); + + // Drop EPUB state before HTTPS to maximize contiguous heap for TLS. + releaseEpubForMapping(); + { RenderLock lock(*this); statusMessage = tr(STR_FETCH_PROGRESS); } - requestUpdateAndWait(); + requestUpdate(); + + // Keep the GET connection alive so Upload can reuse the same session and + // avoid a second TLS handshake under fragmented heap. + KOReaderSyncClient::beginPersistentSession(); // Fetch remote progress const auto result = KOReaderSyncClient::getProgress(documentHash, remoteProgress); if (result == KOReaderSyncClient::NOT_FOUND) { + // Keep session open so an immediate upload can reuse the same connection. // No remote progress - offer to upload { RenderLock lock(*this); @@ -84,6 +155,7 @@ void KOReaderSyncActivity::performSync() { } if (result != KOReaderSyncClient::OK) { + KOReaderSyncClient::endPersistentSession(); { RenderLock lock(*this); state = SYNC_FAILED; @@ -100,27 +172,25 @@ void KOReaderSyncActivity::performSync() { return; } - // Convert remote progress to CrossPoint position + // Defer remote EPUB mapping until user chooses Apply. Upload only needs the + // precomputed local XPath, so this avoids post-fetch inflate churn and keeps + // the GET session reusable for PUT. hasRemoteProgress = true; - { - RenderLock lock(*this); - statusMessage = tr(STR_MAPPING_REMOTE); + remotePositionMapped = false; + remotePosition.spineIndex = currentSpineIndex; + remotePosition.pageNumber = 0; + remotePosition.totalPages = 0; + remotePosition.paragraphIndex = 0; + remotePosition.hasParagraphIndex = false; + int xpathSpineIndex = -1; + if (ChapterXPathIndexer::tryExtractSpineIndexFromXPath(remoteProgress.progress, xpathSpineIndex) && + xpathSpineIndex >= 0) { + remotePosition.spineIndex = xpathSpineIndex; } - requestUpdateAndWait(); + remoteChapterLabel = tr(STR_UNNAMED); - KOReaderPosition koPos = {remoteProgress.progress, remoteProgress.percentage}; - remotePosition = ProgressMapper::toCrossPoint(epub, koPos, currentSpineIndex, totalPagesInSpine); - - // Calculate local progress in KOReader format (for display) - { - RenderLock lock(*this); - statusMessage = tr(STR_MAPPING_LOCAL); - } - requestUpdateAndWait(); - - CrossPointPosition localPos = {currentSpineIndex, currentPage, totalPagesInSpine, localParagraphIndex, - hasLocalParagraphIndex}; - localProgress = ProgressMapper::toKOReader(epub, localPos); + // Local progress was precomputed before network; keep using the cached value. + releaseEpubForMapping(); { RenderLock lock(*this); @@ -144,17 +214,46 @@ void KOReaderSyncActivity::performUpload() { } requestUpdateAndWait(); - // Convert current position to KOReader format - CrossPointPosition localPos = {currentSpineIndex, currentPage, totalPagesInSpine, localParagraphIndex, - hasLocalParagraphIndex}; - KOReaderPosition koPos = ProgressMapper::toKOReader(epub, localPos); + // If sync reached this screen without cached local progress, compute it now. + // This keeps upload robust when UI flow changes or retries happen. + if (localProgress.xpath.empty()) { + computeLocalProgressAndChapter(); + releaseEpubForMapping(); + } + + // Hard-stop if we still have no xpath: sending an empty progress payload would + // be ambiguous server-side and hides the real local mapping failure. + if (localProgress.xpath.empty()) { + { + RenderLock lock(*this); + state = SYNC_FAILED; + statusMessage = tr(STR_SYNC_FAILED_MSG); + } + requestUpdate(true); + return; + } + + // Result screen rendering repopulates glyph caches; trim again right before + // the upload handshake to maximize contiguous heap for TLS. + trimMemoryBeforeTls(renderer); + logSyncMemSnapshot("after_trim_before_updateProgress"); + + // Capture upload-phase memory separately from fetch phase to diagnose failures + // that only appear on PUT due to allocator state changes. + logSyncMemSnapshot("before_updateProgress"); + + // Ensure a session exists for upload. When GET succeeded, this should reuse + // the existing connection and typically skip a second handshake. + KOReaderSyncClient::beginPersistentSession(); 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); + KOReaderSyncClient::endPersistentSession(); + logSyncMemSnapshot("after_updateProgress"); if (result != KOReaderSyncClient::OK) { HalClock::wifiOff(true); @@ -209,6 +308,7 @@ void KOReaderSyncActivity::onEnter() { void KOReaderSyncActivity::onExit() { Activity::onExit(); + KOReaderSyncClient::endPersistentSession(); HalClock::wifiOff(true); } @@ -251,14 +351,8 @@ void KOReaderSyncActivity::render(RenderLock&&) { renderer.drawCenteredText(UI_10_FONT_ID, 120, tr(STR_PROGRESS_FOUND), true, EpdFontFamily::BOLD); // Get chapter names from TOC - 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)); - const std::string localChapter = - (localTocIndex >= 0) ? epub->getTocItem(localTocIndex).title - : (std::string(tr(STR_SECTION_PREFIX)) + std::to_string(currentSpineIndex + 1)); + const std::string& remoteChapter = remoteChapterLabel; + const std::string& localChapter = localChapterLabel; // Remote progress - chapter and page renderer.drawText(UI_10_FONT_ID, contentRect.x + 20, 160, tr(STR_REMOTE_LABEL), true); @@ -339,6 +433,76 @@ void KOReaderSyncActivity::render(RenderLock&&) { } } +bool KOReaderSyncActivity::ensureEpubLoadedForMapping() { + if (epub) { + return true; + } + + // Reload on demand to keep steady-state sync memory low. Mapping and chapter + // lookup need EPUB metadata; TLS steps do not. + epub = std::make_shared(epubPath, "/.crosspoint"); + if (!epub->load(true, true)) { + LOG_ERR("KOSync", "Failed to reload EPUB for mapping: %s", epubPath.c_str()); + epub.reset(); + return false; + } + epub->setupCacheDir(); + return true; +} + +bool KOReaderSyncActivity::ensureRemotePositionMapped() { + if (remotePositionMapped) { + return true; + } + + // Apply needs remote->local mapping, which triggers EPUB inflate work. + // Release HTTP/TLS session first so mapping has maximum heap headroom. + KOReaderSyncClient::endPersistentSession(); + + { + RenderLock lock(*this); + statusMessage = tr(STR_MAPPING_REMOTE); + } + requestUpdateAndWait(); + + KOReaderPosition koPos = {remoteProgress.progress, remoteProgress.percentage}; + if (!ensureEpubLoadedForMapping()) { + return false; + } + remotePosition = ProgressMapper::toCrossPoint(epub, koPos, currentSpineIndex, totalPagesInSpine); + computeRemoteChapter(); + releaseEpubForMapping(); + remotePositionMapped = true; + return true; +} + +void KOReaderSyncActivity::releaseEpubForMapping() { epub.reset(); } + +void KOReaderSyncActivity::computeLocalProgressAndChapter() { + if (!ensureEpubLoadedForMapping()) { + return; + } + + CrossPointPosition localPos = {currentSpineIndex, currentPage, totalPagesInSpine, localParagraphIndex, + hasLocalParagraphIndex}; + localProgress = ProgressMapper::toKOReader(epub, localPos); + + const int localTocIndex = epub->getTocIndexForSpineIndex(currentSpineIndex); + localChapterLabel = + (localTocIndex >= 0) ? epub->getTocItem(localTocIndex).title + : (std::string(tr(STR_SECTION_PREFIX)) + std::to_string(currentSpineIndex + 1)); +} + +void KOReaderSyncActivity::computeRemoteChapter() { + if (!epub) { + return; + } + const int remoteTocIndex = epub->getTocIndexForSpineIndex(remotePosition.spineIndex); + remoteChapterLabel = + (remoteTocIndex >= 0) ? epub->getTocItem(remoteTocIndex).title + : (std::string(tr(STR_SECTION_PREFIX)) + std::to_string(remotePosition.spineIndex + 1)); +} + void KOReaderSyncActivity::loop() { if (state == NO_CREDENTIALS || state == SYNC_FAILED || state == UPLOAD_COMPLETE) { if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { @@ -366,6 +530,15 @@ void KOReaderSyncActivity::loop() { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (selectedOption == 0) { + if (!ensureRemotePositionMapped()) { + { + RenderLock lock(*this); + state = SYNC_FAILED; + statusMessage = tr(STR_SYNC_FAILED_MSG); + } + requestUpdate(true); + return; + } // Wifi will be turned off in onExit() setResult(SyncResult{remotePosition.spineIndex, remotePosition.pageNumber, remotePosition.paragraphIndex, remotePosition.hasParagraphIndex}); diff --git a/src/activities/reader/KOReaderSyncActivity.h b/src/activities/reader/KOReaderSyncActivity.h index fd02d861..6edb8fc2 100644 --- a/src/activities/reader/KOReaderSyncActivity.h +++ b/src/activities/reader/KOReaderSyncActivity.h @@ -4,6 +4,7 @@ #include #include +#include "ChapterXPathIndexer.h" #include "KOReaderSyncClient.h" #include "ProgressMapper.h" #include "activities/Activity.h" @@ -69,11 +70,14 @@ class KOReaderSyncActivity final : public Activity { // Remote progress data bool hasRemoteProgress = false; + bool remotePositionMapped = false; KOReaderProgress remoteProgress; CrossPointPosition remotePosition; // Local progress as KOReader format (for display) KOReaderPosition localProgress; + std::string remoteChapterLabel; + std::string localChapterLabel; // Selection in result screen (0=Apply, 1=Upload) int selectedOption = 0; @@ -86,4 +90,9 @@ class KOReaderSyncActivity final : public Activity { void performSync(); void performUpload(); void closeCancelled(); + bool ensureEpubLoadedForMapping(); + void releaseEpubForMapping(); + void computeLocalProgressAndChapter(); + void computeRemoteChapter(); + bool ensureRemotePositionMapped(); }; diff --git a/test/epubs/test_jpeg_images.epub b/test/epubs/test_jpeg_images.epub index 2bfc04c7..a99251f0 100644 Binary files a/test/epubs/test_jpeg_images.epub and b/test/epubs/test_jpeg_images.epub differ diff --git a/test/epubs/test_layout_regressions.epub b/test/epubs/test_layout_regressions.epub deleted file mode 100644 index ee54e93c..00000000 Binary files a/test/epubs/test_layout_regressions.epub and /dev/null differ diff --git a/test/epubs/test_mixed_images.epub b/test/epubs/test_mixed_images.epub index d6f9a0f9..6d0a41f1 100644 Binary files a/test/epubs/test_mixed_images.epub and b/test/epubs/test_mixed_images.epub differ diff --git a/test/epubs/test_png_images.epub b/test/epubs/test_png_images.epub index c3301787..5a405f88 100644 Binary files a/test/epubs/test_png_images.epub and b/test/epubs/test_png_images.epub differ diff --git a/test/epubs/test_text_rendering.epub b/test/epubs/test_text_rendering.epub index ecbe399f..2067f55c 100644 Binary files a/test/epubs/test_text_rendering.epub and b/test/epubs/test_text_rendering.epub differ