Merge pull request #37 from jpirnay/refactor-koreader

refactor: refactor koreader to avoid OOM regressions
This commit is contained in:
jpirnay
2026-04-07 22:36:32 +02:00
committed by GitHub
26 changed files with 803 additions and 274 deletions
+1 -1
View File
@@ -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
+36 -54
View File
@@ -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<int16_t>(parent.marginLeft + child.marginLeft);
inherited.marginRight = static_cast<int16_t>(parent.marginRight + child.marginRight);
inherited.paddingLeft = static_cast<int16_t>(parent.paddingLeft + child.paddingLeft);
inherited.paddingRight = static_cast<int16_t>(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 <div style="margin-bottom:2em"><h1>text</h1></div> 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 <br> 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<int16_t>(renderer.getLineHeight(fontId) * lineCompression + 0.5f);
incoming.marginTop = static_cast<int16_t>(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 <br> separators.
// This lets consecutive <br> 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()) {
@@ -500,7 +484,8 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
}
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
// current container preserving requested ratio.
displayHeight = static_cast<int>(
imgStyle.imageHeight.toPixels(emSize, static_cast<float>(self->viewportHeight)) + 0.5f);
displayWidth =
@@ -544,7 +529,8 @@ 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.
// Use CSS width (resolve % against container width) and derive
// height from aspect ratio.
displayWidth =
static_cast<int>(imgStyle.imageWidth.toPixels(emSize, static_cast<float>(containerWidth)) + 0.5f);
if (displayWidth > containerWidth) displayWidth = containerWidth;
@@ -561,7 +547,7 @@ 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.
// Scale to fit current container while maintaining aspect ratio.
int maxWidth = containerWidth;
int maxHeight = self->viewportHeight;
float scaleX = (dims.width > maxWidth) ? (float)maxWidth / dims.width : 1.0f;
@@ -676,10 +662,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 +776,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 +788,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 +822,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 +1266,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 <h1> (default
// Center) followed by an image-only <p> 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 <br> 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<uint8_t>(CssTextAlign::None))
? CssTextAlign::Justify
: static_cast<CssTextAlign>(self->paragraphAlignment);
self->currentTextBlock->setBlockStyle(style);
}
}
}
}
bool ChapterHtmlSlimParser::parseAndBuildPages() {
BlockStyle rootBlockStyle;
rootBlockStyle.alignment = (this->paragraphAlignment == static_cast<uint8_t>(CssTextAlign::None))
? CssTextAlign::Justify
: static_cast<CssTextAlign>(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)
@@ -65,7 +65,6 @@ class ChapterHtmlSlimParser {
bool hasUnderline = false, underline = false;
};
std::vector<StyleStackEntry> inlineStyleStack;
std::vector<BlockStyle> blockStyleStack;
CssStyle currentCssStyle;
bool effectiveBold = false;
bool effectiveItalic = false;
+5
View File
@@ -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;
}
+3
View File
@@ -302,6 +302,8 @@ STR_HW_RIGHT_LABEL: "Right (4th button)"
STR_GO_TO_PERCENT: "Go to %"
STR_GO_HOME_BUTTON: "Go Home"
STR_SYNC_PROGRESS: "Sync Progress"
STR_PUSH_PROGRESS_FROM_THIS_DEVICE: "Push progress from this device"
STR_PULL_PROGRESS_FROM_OTHER_DEVICES: "Pull progress from other devices"
STR_DELETE_CACHE: "Delete Book Cache"
STR_DELETE: "Delete"
STR_REMOVE: "Remove"
@@ -331,6 +333,7 @@ STR_UPLOAD_LOCAL: "Upload local progress"
STR_NO_REMOTE_MSG: "No remote progress found"
STR_UPLOAD_PROMPT: "Upload current position?"
STR_UPLOAD_SUCCESS: "Progress uploaded!"
STR_PULL_SUCCESS: "Remote progress applied!"
STR_SYNC_FAILED_MSG: "Sync failed"
STR_SECTION_PREFIX: "Section "
STR_UPLOAD: "Upload"
+7
View File
@@ -82,6 +82,7 @@ STR_PARA_ALIGNMENT: "Alignement du texte"
STR_HYPHENATION: "Césure"
STR_TIME_TO_SLEEP: "Mise en veille auto"
STR_SHOW_HIDDEN_FILES: "Afficher les fichiers cachés"
STR_SHOW_FILE_EXTENSIONS: "Afficher les extensions"
STR_REFRESH_FREQ: "Fréquence rafraîchissement"
STR_KOREADER_SYNC: "Synchro KOReader"
STR_CHECK_UPDATES: "Mise à jour"
@@ -252,6 +253,8 @@ STR_HW_RIGHT_LABEL: "Droite (Bouton 4)"
STR_GO_TO_PERCENT: "Aller à %"
STR_GO_HOME_BUTTON: "Retour Accueil"
STR_SYNC_PROGRESS: "Synchro progression"
STR_PUSH_PROGRESS_FROM_THIS_DEVICE: "Envoyer la progression depuis cet appareil"
STR_PULL_PROGRESS_FROM_OTHER_DEVICES: "Récupérer la progression d'autres appareils"
STR_DELETE_CACHE: "Supprimer cache livre"
STR_DELETE: "Supprimer"
STR_DISPLAY_QR: "Afficher la page en QR"
@@ -278,6 +281,7 @@ STR_UPLOAD_LOCAL: "Envoyer progression locale"
STR_NO_REMOTE_MSG: "Aucune progression en ligne"
STR_UPLOAD_PROMPT: "Envoyer position actuelle ?"
STR_UPLOAD_SUCCESS: "Progression envoyée !"
STR_PULL_SUCCESS: "Progression distante appliquée !"
STR_SYNC_FAILED_MSG: "Échec de la synchro"
STR_SECTION_PREFIX: "Section "
STR_UPLOAD: "Envoyer"
@@ -307,6 +311,9 @@ STR_CREATE_FALLBACK_FOR_INVALID_TOC: "Créer une table des matières de secours"
STR_MAPPING_REMOTE: "Correspondance position distante…"
STR_MAPPING_LOCAL: "Calcul de la position locale…"
STR_SLEEP_COVER_OVERLAY: "Info en veille"
STR_SLEEP_IMAGE_PICK_MODE: "Sélection image veille"
STR_RANDOM: "Aléatoire"
STR_SEQUENTIAL: "Séquentiel"
STR_OVERLAY_WHITE: "Blanc"
STR_OVERLAY_GRAY: "Gris"
STR_OVERLAY_BLACK: "Noir"
+6
View File
@@ -256,6 +256,8 @@ STR_HW_RIGHT_LABEL: "Rechts (4. Taste)"
STR_GO_TO_PERCENT: "Gehe zu %"
STR_GO_HOME_BUTTON: "Zum Anfang"
STR_SYNC_PROGRESS: "Fortschritt synchronisieren"
STR_PUSH_PROGRESS_FROM_THIS_DEVICE: "Fortschritt von diesem Gerät senden"
STR_PULL_PROGRESS_FROM_OTHER_DEVICES: "Fortschritt von anderen Geräten holen"
STR_DELETE_CACHE: "Buch-Cache leeren"
STR_DISPLAY_QR: "Seite als QR anzeigen"
STR_DELETE: "Löschen"
@@ -283,6 +285,7 @@ STR_UPLOAD_LOCAL: "Lokalen Fortschritt hochladen"
STR_NO_REMOTE_MSG: "Kein externer Fortschritt"
STR_UPLOAD_PROMPT: "Aktuelle Position hochladen?"
STR_UPLOAD_SUCCESS: "Hochgeladen!"
STR_PULL_SUCCESS: "Fortschritt übernommen!"
STR_SYNC_FAILED_MSG: "Fehlgeschlagen"
STR_SECTION_PREFIX: "Abschnitt "
STR_UPLOAD: "Hochladen"
@@ -310,6 +313,9 @@ STR_AUTHOR: "Autor"
STR_SERIES: "Reihe"
STR_FILE_SIZE: "Größe"
STR_SLEEP_COVER_OVERLAY: "Standby-Info-Overlay"
STR_SLEEP_IMAGE_PICK_MODE: "Standby-Bildauswahl"
STR_RANDOM: "Zufällig"
STR_SEQUENTIAL: "Nacheinander"
STR_SYSTEM_INFO: "Systeminformationen"
STR_LOAD_XTC_FAILED: "XTC-Datei konnte nicht geladen werden"
STR_LOAD_EPUB_FAILED: "EPUB-Datei konnte nicht geladen werden"
+7
View File
@@ -82,6 +82,7 @@ STR_PARA_ALIGNMENT: "Allineamento paragrafo"
STR_HYPHENATION: "Sillabazione"
STR_TIME_TO_SLEEP: "Timeout sospensione"
STR_SHOW_HIDDEN_FILES: "Mostra file nascosti"
STR_SHOW_FILE_EXTENSIONS: "Mostra estensioni file"
STR_REFRESH_FREQ: "Frequenza di aggiornamento"
STR_KOREADER_SYNC: "Sincronizzazione KOReader"
STR_CHECK_UPDATES: "Cerca aggiornamenti"
@@ -252,6 +253,8 @@ STR_HW_RIGHT_LABEL: "Destra (4° pulsante)"
STR_GO_TO_PERCENT: "Vai al %"
STR_GO_HOME_BUTTON: "Vai alla home"
STR_SYNC_PROGRESS: "Sincronizza avanzamento"
STR_PUSH_PROGRESS_FROM_THIS_DEVICE: "Invia avanzamenti da questo dispositivo"
STR_PULL_PROGRESS_FROM_OTHER_DEVICES: "Ricevi avanzamenti da altri dispositivi"
STR_DELETE_CACHE: "Elimina cache libro"
STR_DELETE: "Elimina"
STR_DISPLAY_QR: "Mostra pagina come QR"
@@ -278,6 +281,7 @@ STR_UPLOAD_LOCAL: "Invia avanzamenti locali"
STR_NO_REMOTE_MSG: "Nessun avanzamento remoto trovato"
STR_UPLOAD_PROMPT: "Inviare la posizione attuale?"
STR_UPLOAD_SUCCESS: "Avanzamenti inviati!"
STR_PULL_SUCCESS: "Avanzamento remoto applicato!"
STR_SYNC_FAILED_MSG: "Sincronizzazione non riuscita"
STR_SECTION_PREFIX: "Sezione "
STR_UPLOAD: "Carica"
@@ -290,3 +294,6 @@ STR_LINK: "[link]"
STR_SCREENSHOT_BUTTON: "Screenshot"
STR_AUTO_TURN_ENABLED: "Cambio pagina automatico: "
STR_AUTO_TURN_PAGES_PER_MIN: "Cambio pagina automatico (pag/min)"
STR_SLEEP_IMAGE_PICK_MODE: "Selezione immagine standby"
STR_RANDOM: "Casuale"
STR_SEQUENTIAL: "Sequenziale"
+7
View File
@@ -82,6 +82,7 @@ STR_PARA_ALIGNMENT: "Alinhamento do parágrafo"
STR_HYPHENATION: "Hifenização"
STR_TIME_TO_SLEEP: "Tempo para entrar em repouso"
STR_SHOW_HIDDEN_FILES: "Mostrar arquivos ocultos"
STR_SHOW_FILE_EXTENSIONS: "Mostrar extensões de arquivo"
STR_REFRESH_FREQ: "Frequência de atualização"
STR_KOREADER_SYNC: "Sincronização KOReader"
STR_CHECK_UPDATES: "Verificar atualizações"
@@ -252,6 +253,8 @@ STR_HW_RIGHT_LABEL: "Direita (4º botão)"
STR_GO_TO_PERCENT: "Ir para %"
STR_GO_HOME_BUTTON: "Ir para o início"
STR_SYNC_PROGRESS: "Sincronizar progresso"
STR_PUSH_PROGRESS_FROM_THIS_DEVICE: "Enviar progresso deste dispositivo"
STR_PULL_PROGRESS_FROM_OTHER_DEVICES: "Receber progresso de outros dispositivos"
STR_DELETE_CACHE: "Excluir cache do livro"
STR_DELETE: "Excluir"
STR_DISPLAY_QR: "Mostrar página como QR"
@@ -278,6 +281,7 @@ STR_UPLOAD_LOCAL: "Enviar progresso local"
STR_NO_REMOTE_MSG: "Nenhum progresso remoto encontrado"
STR_UPLOAD_PROMPT: "Enviar posição atual?"
STR_UPLOAD_SUCCESS: "Progresso enviado!"
STR_PULL_SUCCESS: "Progresso remoto aplicado!"
STR_SYNC_FAILED_MSG: "Falha na sincronização"
STR_SECTION_PREFIX: "Seção "
STR_UPLOAD: "Enviar"
@@ -307,6 +311,9 @@ STR_CREATE_FALLBACK_FOR_INVALID_TOC: "Criar sumário alternativo se inválido"
STR_MAPPING_REMOTE: "Mapeando posição remota..."
STR_MAPPING_LOCAL: "Calculando posição local..."
STR_SLEEP_COVER_OVERLAY: "Info na tela de repouso"
STR_SLEEP_IMAGE_PICK_MODE: "Seleção de imagem de repouso"
STR_RANDOM: "Aleatório"
STR_SEQUENTIAL: "Sequencial"
STR_OVERLAY_WHITE: "Branco"
STR_OVERLAY_GRAY: "Cinza"
STR_OVERLAY_BLACK: "Preto"
+7
View File
@@ -82,6 +82,7 @@ STR_PARA_ALIGNMENT: "Выравнивание абзаца"
STR_HYPHENATION: "Перенос слов"
STR_TIME_TO_SLEEP: "Сон через"
STR_SHOW_HIDDEN_FILES: "Показать скрытые файлы"
STR_SHOW_FILE_EXTENSIONS: "Показать расширения файлов"
STR_REFRESH_FREQ: "Частота обновления"
STR_KOREADER_SYNC: "Синхронизация KOReader"
STR_CHECK_UPDATES: "Проверить обновления"
@@ -255,6 +256,8 @@ STR_HW_RIGHT_LABEL: "Вправо (4-я кнопка)"
STR_GO_TO_PERCENT: "Перейти к %"
STR_GO_HOME_BUTTON: "На главную"
STR_SYNC_PROGRESS: "Синхронизировать прогресс"
STR_PUSH_PROGRESS_FROM_THIS_DEVICE: "Отправить прогресс с этого устройства"
STR_PULL_PROGRESS_FROM_OTHER_DEVICES: "Получить прогресс с других устройств"
STR_DELETE_CACHE: "Удалить кэш книги"
STR_DELETE: "Удалить"
STR_CHAPTER_PREFIX: "Глава: "
@@ -281,6 +284,7 @@ STR_UPLOAD_LOCAL: "Отправить локальный прогресс"
STR_NO_REMOTE_MSG: "Удалённый прогресс не найден"
STR_UPLOAD_PROMPT: "Отправить текущую позицию?"
STR_UPLOAD_SUCCESS: "Прогресс отправлен!"
STR_PULL_SUCCESS: "Удалённый прогресс применён!"
STR_SYNC_FAILED_MSG: "Ошибка синхронизации"
STR_SECTION_PREFIX: "Раздел"
STR_UPLOAD: "Отправить"
@@ -307,6 +311,9 @@ STR_CREATE_FALLBACK_FOR_INVALID_TOC: "Создать запасное оглав
STR_MAPPING_REMOTE: "Сопоставление удалённой позиции..."
STR_MAPPING_LOCAL: "Вычисление локальной позиции..."
STR_SLEEP_COVER_OVERLAY: "Инфо на экране сна"
STR_SLEEP_IMAGE_PICK_MODE: "Выбор изображения сна"
STR_RANDOM: "Случайный"
STR_SEQUENTIAL: "Последовательный"
STR_OVERLAY_WHITE: "Белый"
STR_OVERLAY_GRAY: "Серый"
STR_OVERLAY_BLACK: "Чёрный"
+7
View File
@@ -82,6 +82,7 @@ STR_PARA_ALIGNMENT: "Ajuste de párrafo"
STR_HYPHENATION: "División de palabras"
STR_TIME_TO_SLEEP: "Auto suspensión"
STR_SHOW_HIDDEN_FILES: "Mostrar archivos ocultos"
STR_SHOW_FILE_EXTENSIONS: "Mostrar extensiones de archivo"
STR_REFRESH_FREQ: "Frecuencia de refresco"
STR_KOREADER_SYNC: "Sincronización de KOReader"
STR_CHECK_UPDATES: "Verificar actualizaciones"
@@ -252,6 +253,8 @@ STR_HW_RIGHT_LABEL: "Dcha. (cuarto botón)"
STR_GO_TO_PERCENT: "Ir a %"
STR_GO_HOME_BUTTON: "Volver al menú Inicio"
STR_SYNC_PROGRESS: "Sincronizar progreso de lectura"
STR_PUSH_PROGRESS_FROM_THIS_DEVICE: "Subir progreso desde este dispositivo"
STR_PULL_PROGRESS_FROM_OTHER_DEVICES: "Obtener progreso de otros dispositivos"
STR_DELETE_CACHE: "Borrar caché del libro"
STR_DELETE: "Borrar"
STR_DISPLAY_QR: "Mostrar página como QR"
@@ -278,6 +281,7 @@ STR_UPLOAD_LOCAL: "Subir progreso local"
STR_NO_REMOTE_MSG: "No se encontró progreso remoto"
STR_UPLOAD_PROMPT: "¿Subir posición actual?"
STR_UPLOAD_SUCCESS: "¡Progreso subido!"
STR_PULL_SUCCESS: "¡Progreso remoto aplicado!"
STR_SYNC_FAILED_MSG: "Fallo de sincronización"
STR_SECTION_PREFIX: "Secc.:"
STR_UPLOAD: "Subir"
@@ -307,6 +311,9 @@ STR_CREATE_FALLBACK_FOR_INVALID_TOC: "Crear índice alternativo si el original e
STR_MAPPING_REMOTE: "Mapeando posición remota..."
STR_MAPPING_LOCAL: "Calculando posición local..."
STR_SLEEP_COVER_OVERLAY: "Info en pantalla de suspensión"
STR_SLEEP_IMAGE_PICK_MODE: "Selección de imagen de suspensión"
STR_RANDOM: "Aleatorio"
STR_SEQUENTIAL: "Secuencial"
STR_OVERLAY_WHITE: "Blanco"
STR_OVERLAY_GRAY: "Gris"
STR_OVERLAY_BLACK: "Negro"
+234 -41
View File
@@ -3,6 +3,7 @@
#include <Arduino.h>
#include <ArduinoJson.h>
#include <Logging.h>
#include <WiFi.h>
#include <esp_crt_bundle.h>
#include <esp_err.h>
#include <esp_heap_caps.h>
@@ -11,6 +12,7 @@
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstring>
#include <ctime>
#include "KOReaderCredentialStore.h"
@@ -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,22 @@ 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;
// Keep strict thresholding here. A small tolerance caused repeated handshake
// attempts in borderline-fragmented states that still failed in mbedTLS.
constexpr unsigned TLS_CONTIG_HEAP_TOLERANCE = 0;
// 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<int>(status), static_cast<long>(rssi),
WiFi.localIP().toString().c_str());
}
// Response buffer for reading HTTP body
struct ResponseBuffer {
@@ -64,6 +81,30 @@ struct ResponseBuffer {
}
};
ResponseBuffer g_sessionResponseBuf;
void clearResponseBuffer(ResponseBuffer* buf) {
if (!buf) return;
if (buf->data) {
free(buf->data);
buf->data = nullptr;
}
buf->len = 0;
buf->capacity = 0;
}
void resetResponseBuffer(ResponseBuffer* buf) {
if (!buf) return;
buf->len = 0;
if (buf->data) {
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<ResponseBuffer*>(evt->user_data);
@@ -105,10 +146,22 @@ 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 = 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;
@@ -116,18 +169,60 @@ bool checkHeapForTls() {
return true;
}
void refreshHeapSnapshot() {
KOReaderSyncClient::lastHeapAtFailure = ESP.getFreeHeap();
KOReaderSyncClient::lastContigHeapAtFailure = heap_caps_get_largest_free_block(MALLOC_CAP_8BIT | MALLOC_CAP_DEFAULT);
}
void logTlsAttemptPlan(const char* operation, int attempt) {
const bool isUpload = (operation && strcmp(operation, "update progress") == 0);
const bool hasReusableSession = g_keepSessionOpen && g_sessionClient != nullptr;
const unsigned requiredContig = (isUpload && hasReusableSession) ? KOReaderSyncClient::MIN_CONTIG_HEAP_FOR_TLS_UPLOAD
: KOReaderSyncClient::MIN_CONTIG_HEAP_FOR_TLS;
LOG_DBG("KOSync", "%s attempt %d: keep_session=%s reusable_session=%s tls_mode=%s heap=%u contig=%u need=%u",
operation ? operation : "request", attempt, g_keepSessionOpen ? "yes" : "no",
hasReusableSession ? "yes" : "no", (isUpload && hasReusableSession) ? "reuse" : "handshake",
KOReaderSyncClient::lastHeapAtFailure, KOReaderSyncClient::lastContigHeapAtFailure, requiredContig);
}
void resetSessionClientForRetry() {
if (g_sessionClient) {
esp_http_client_cleanup(g_sessionClient);
g_sessionClient = nullptr;
}
}
// 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 +237,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 +281,8 @@ KOReaderSyncClient::Error KOReaderSyncClient::registerUser() {
LOG_DBG("KOSync", "Register request body: <redacted credentials>");
ResponseBuffer buf;
ResponseBuffer* activeBuf = effectiveResponseBuffer(&buf);
resetResponseBuffer(activeBuf);
esp_http_client_handle_t client = createClient(url.c_str(), &buf, HTTP_METHOD_POST);
if (!client) {
lastEspError = ESP_ERR_NO_MEM;
@@ -181,10 +296,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 +315,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<char>(std::tolower(c)); });
if (lowerBody.find("already") != std::string::npos) {
@@ -226,6 +343,8 @@ KOReaderSyncClient::Error KOReaderSyncClient::authenticate() {
lastContigHeapAtFailure);
ResponseBuffer buf;
ResponseBuffer* activeBuf = effectiveResponseBuffer(&buf);
resetResponseBuffer(activeBuf);
esp_http_client_handle_t client = createClient(url.c_str(), &buf);
if (!client) {
lastEspError = ESP_ERR_NO_MEM;
@@ -236,7 +355,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 +382,59 @@ 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++) {
// Retry attempts can happen after memory churn from a failed handshake.
// Refresh heap snapshot each pass so preflight and diagnostics use current values.
refreshHeapSnapshot();
logTlsAttemptPlan("get progress", attempt);
if (!checkHeapForTls()) {
return NETWORK_ERROR;
}
resetResponseBuffer(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;
}
// Failed connect can leave a persistent client handle in a bad state.
// Recreate it before retry so we don't repeat work on a stale transport.
resetSessionClientForRetry();
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 +484,56 @@ 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++) {
// Retry attempts can happen after memory churn from a failed handshake.
// Refresh heap snapshot each pass so preflight and diagnostics use current values.
refreshHeapSnapshot();
logTlsAttemptPlan("update progress", attempt);
if (!checkHeapForTls()) {
return NETWORK_ERROR;
}
resetResponseBuffer(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;
}
// Failed connect can leave a persistent client handle in a bad state.
// Recreate it before retry so we don't repeat work on a stale transport.
resetSessionClientForRetry();
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 +541,16 @@ KOReaderSyncClient::Error KOReaderSyncClient::updateProgress(const KOReaderProgr
}
const char* KOReaderSyncClient::lastFailureDetail() {
const bool isUpload = (lastOperation && strcmp(lastOperation, "update progress") == 0);
const bool hasReusableSession = g_keepSessionOpen && g_sessionClient != nullptr;
const unsigned requiredContig =
(isUpload && hasReusableSession) ? 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.
+15 -1
View File
@@ -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,8 @@ 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;
// Relaxed threshold only for upload when reusing an already-established session.
// Uploads that must perform a fresh handshake still require MIN_CONTIG_HEAP_FOR_TLS.
static constexpr unsigned MIN_CONTIG_HEAP_FOR_TLS_UPLOAD = 34 * 1024;
};
+1 -1
View File
@@ -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
+3 -104
View File
@@ -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' <item id="{img_filename.replace(".", "_")}" href="images/{img_filename}" media-type="{media_type}"/>'
)
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"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head><title>{title}</title>{head_content}</head>
<head><title>{title}</title></head>
<body>
<h1>{title}</h1>
{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",
"""
<p>This EPUB exercises recent parser edge cases around nested block styles and image wrappers.</p>
<p><strong>Recommended settings:</strong> Paragraph Alignment set to Book Style or Justify.</p>
<p>This regression EPUB uses inline style attributes rather than a head &lt;style&gt; block so it works even if chapter-local embedded CSS is not loaded.</p>
<ul>
<li>Nested horizontal margin inheritance for sibling blocks</li>
<li>Vertical paragraph spacing should not explode with nested wrappers</li>
<li>Image wrapper spacing should apply to the image, not leak into following text</li>
<li>Hidden images should not leave a large blank gap before the next paragraph</li>
</ul>
""",
),
[],
),
(
"1. Nested Horizontal Margins",
make_chapter(
"Nested Horizontal Margin Inheritance",
"""
<p style="margin-top: 0.7em; margin-bottom: 0.7em;">This chapter mirrors the c1/c2/c3/c4 example behind PR 1582.</p>
<p style="margin-top: 0.7em; margin-bottom: 0.7em;">Expected: C3 is indented the most. C4 is indented less than C3, but still more than the baseline paragraph outside the wrapper.</p>
<p style="margin-top: 0.7em; margin-bottom: 0.7em;">Expected order of indentation: C3 &gt; C4 &gt; baseline paragraph.</p>
<div style="margin-left: 24px;">
<div style="margin-left: 48px;">
<p style="margin-top: 0.7em; margin-bottom: 0.7em; margin-left: 12px;">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.</p>
</div>
<p style="margin-top: 0.7em; margin-bottom: 0.7em; margin-left: 12px;">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.</p>
<p style="margin-top: 0.7em; margin-bottom: 0.7em;">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.</p>
</div>
<p style="margin-top: 0.7em; margin-bottom: 0.7em;">Baseline paragraph outside the wrappers. This paragraph should align with the normal body text and should be the least indented paragraph on this page.</p>
""",
),
[],
),
(
"2. Nested Vertical Margins",
make_chapter(
"Nested Vertical Margin Sanity",
"""
<p style="margin-top: 0.7em; margin-bottom: 0.7em;">Expected: wrapper nesting should not create an oversized blank vertical gulf between these paragraphs.</p>
<div style="margin-top: 1.6em; margin-bottom: 1.6em;">
<div style="margin-top: 1.2em; margin-bottom: 1.2em;">
<p style="margin-top: 0.8em; margin-bottom: 0.8em;">Nested vertical spacing paragraph. There should be some breathing room above and below, but not dramatically more than a normal section break.</p>
</div>
<p style="margin-top: 0.8em; margin-bottom: 0.8em;">Sibling paragraph after the nested block. Spacing before this paragraph should feel normal and should not keep growing with every ancestor wrapper.</p>
</div>
<p style="margin-top: 0.7em; margin-bottom: 0.7em;">Baseline paragraph after the wrapper section. This should not be pushed far down the page.</p>
""",
),
[],
),
(
"3. Image Wrapper Spacing",
make_chapter(
"Image Wrapper Spacing",
"""
<p style="margin-top: 0.7em; margin-bottom: 0.7em;">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.</p>
<div style="margin-top: 1.4em; margin-bottom: 1.4em;">
<div style="margin-top: 1.4em; margin-bottom: 1.4em; margin-left: 60px; margin-right: 60px;">
<p style="margin-top: 0; margin-bottom: 0;"><img src="images/centering_test.jpg" alt="Wrapped image spacing test" style="width: 100%;"/></p>
</div>
</div>
<p style="margin-top: 0.7em; margin-bottom: 0.7em;">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.</p>
""",
),
[("centering_test.jpg", images["centering_test.jpg"])],
),
(
"4. Hidden Image Spacing",
make_chapter(
"Hidden Image Spacing Reset",
"""
<p style="margin-top: 0.7em; margin-bottom: 0.7em;">Expected: the hidden image wrapper should not leave a large blank gap before the following paragraph.</p>
<div style="margin-top: 1.4em; margin-bottom: 1.4em;">
<p style="margin-top: 0; margin-bottom: 0;"><img src="images/centering_test.jpg" alt="This image is intentionally hidden by CSS" style="display: none;"/></p>
</div>
<p style="margin-top: 0.7em; margin-bottom: 0.7em;">Paragraph after hidden image. This should follow with near-normal spacing, not the large gap that would be appropriate for a visible wrapped image.</p>
""",
),
[("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 = [
(
+74 -11
View File
@@ -145,14 +145,12 @@ void EpubReaderActivity::loop() {
}
// Long press CONFIRM (1s+) goes directly to KOReader sync when credentials are configured.
// We intentionally keep long-press on the richer compare flow so advanced
// conflict-resolution behavior stays available even after simplifying menu UX.
// 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<KOReaderSyncActivity>(renderer, mappedInput, epub, epub->getPath(),
currentSpineIndex, currentPage, totalPages),
[this](const ActivityResult& result) { handleSyncResult(result); });
launchKOReaderSync(SyncLaunchMode::COMPARE);
return;
}
@@ -464,20 +462,85 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction
requestUpdate();
break;
}
case EpubReaderMenuActivity::MenuAction::SYNC: {
case EpubReaderMenuActivity::MenuAction::PULL_REMOTE: {
// One-tap pull path: run network preconditions and apply remote progress
// directly instead of showing an intermediate chooser screen.
if (KOREADER_STORE.hasCredentials()) {
const int currentPage = section ? section->currentPage : 0;
const int totalPages = section ? section->pageCount : 0;
startActivityForResult(std::make_unique<KOReaderSyncActivity>(renderer, mappedInput, epub, epub->getPath(),
currentSpineIndex, currentPage, totalPages),
[this](const ActivityResult& result) { handleSyncResult(result); });
launchKOReaderSync(SyncLaunchMode::PULL_REMOTE);
}
break;
}
case EpubReaderMenuActivity::MenuAction::PUSH_LOCAL: {
// One-tap push path: run network preconditions and upload local progress
// directly for KOReader-like "sync now" behavior.
if (KOREADER_STORE.hasCredentials()) {
launchKOReaderSync(SyncLaunchMode::PUSH_LOCAL);
}
break;
}
}
}
void EpubReaderActivity::launchKOReaderSync(const SyncLaunchMode mode) {
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<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;
if (mode == SyncLaunchMode::PULL_REMOTE) {
syncIntent = KOReaderSyncActivity::SyncIntent::PULL_REMOTE;
} else if (mode == SyncLaunchMode::PUSH_LOCAL) {
syncIntent = KOReaderSyncActivity::SyncIntent::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); });
}
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();
}
if (!result.isCancelled) {
const auto& sync = std::get<SyncResult>(result.data);
if (currentSpineIndex != sync.spineIndex || (section && section->currentPage != sync.page)) {
@@ -9,6 +9,17 @@
#include "activities/Activity.h"
class EpubReaderActivity final : public Activity {
// Reader can launch sync in three UX modes:
// - COMPARE: legacy chooser (apply/upload) for power users.
// - PULL_REMOTE / PUSH_LOCAL: direct one-step actions from menu entries.
// Keeping this split in the caller avoids branching on menu semantics deep
// inside generic reader state handling.
enum class SyncLaunchMode {
COMPARE,
PULL_REMOTE,
PUSH_LOCAL,
};
std::shared_ptr<Epub> epub;
std::unique_ptr<Section> section = nullptr;
int currentSpineIndex = 0;
@@ -35,6 +46,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 +69,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(SyncLaunchMode mode = SyncLaunchMode::COMPARE);
void handleSyncResult(const ActivityResult& result);
void applyOrientation(uint8_t orientation);
void toggleAutoPageTurn(uint8_t selectedPageTurnOption);
@@ -25,7 +25,7 @@ EpubReaderMenuActivity::EpubReaderMenuActivity(GfxRenderer& renderer, MappedInpu
std::vector<EpubReaderMenuActivity::MenuItem> EpubReaderMenuActivity::buildMenuItems(bool hasFootnotes) {
std::vector<MenuItem> items;
items.reserve(10);
items.reserve(12);
items.push_back({MenuAction::SELECT_CHAPTER, StrId::STR_SELECT_CHAPTER});
if (hasFootnotes) {
items.push_back({MenuAction::FOOTNOTES, StrId::STR_FOOTNOTES});
@@ -39,7 +39,8 @@ std::vector<EpubReaderMenuActivity::MenuItem> EpubReaderMenuActivity::buildMenuI
items.push_back({MenuAction::DISPLAY_QR, StrId::STR_DISPLAY_QR});
items.push_back({MenuAction::GO_HOME, StrId::STR_GO_HOME_BUTTON});
if (KOREADER_STORE.hasCredentials()) {
items.push_back({MenuAction::SYNC, StrId::STR_SYNC_PROGRESS});
items.push_back({MenuAction::PULL_REMOTE, StrId::STR_PULL_PROGRESS_FROM_OTHER_DEVICES});
items.push_back({MenuAction::PUSH_LOCAL, StrId::STR_PUSH_PROGRESS_FROM_THIS_DEVICE});
}
items.push_back({MenuAction::DELETE_CACHE, StrId::STR_DELETE_CACHE});
return items;
@@ -22,7 +22,8 @@ class EpubReaderMenuActivity final : public Activity {
SCREENSHOT,
DISPLAY_QR,
GO_HOME,
SYNC,
PULL_REMOTE,
PUSH_LOCAL,
DELETE_CACHE
};
+335 -50
View File
@@ -1,10 +1,13 @@
#include "KOReaderSyncActivity.h"
#include <FontCacheManager.h>
#include <GfxRenderer.h>
#include <HalClock.h>
#include <I18n.h>
#include <Logging.h>
#include <WiFi.h>
#include <esp_heap_caps.h>
#include <esp_system.h>
#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(const 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,88 @@ void KOReaderSyncActivity::performSync() {
LOG_DBG("KOSync", "Document hash: %s", documentHash.c_str());
// 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) {
// 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();
if (!computeLocalProgressAndChapter()) {
{
RenderLock lock(*this);
state = SYNC_FAILED;
statusMessage = tr(STR_SYNC_FAILED_MSG);
}
requestUpdate(true);
return;
}
}
// Drop EPUB state before HTTPS to maximize contiguous heap for TLS.
releaseEpubForMapping();
// 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) {
// 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
// same reuse path without showing comparison UI.
KOReaderSyncClient::beginPersistentSession();
KOReaderProgress warmupProgress;
const auto warmupResult = KOReaderSyncClient::getProgress(documentHash, warmupProgress);
if (warmupResult != KOReaderSyncClient::OK && warmupResult != KOReaderSyncClient::NOT_FOUND) {
KOReaderSyncClient::endPersistentSession();
{
RenderLock lock(*this);
state = SYNC_FAILED;
statusMessage = KOReaderSyncClient::errorString(warmupResult);
const char* detail = KOReaderSyncClient::lastFailureDetail();
if (detail && detail[0]) {
statusMessage += "";
statusMessage += detail;
}
}
requestUpdate(true);
return;
}
performUpload();
return;
}
{
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) {
if (syncIntent == SyncIntent::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();
{
RenderLock lock(*this);
state = SYNC_FAILED;
statusMessage = tr(STR_NO_REMOTE_MSG);
}
requestUpdate(true);
return;
}
// Keep session open so an immediate upload can reuse the same connection.
// No remote progress - offer to upload
{
RenderLock lock(*this);
@@ -84,6 +209,7 @@ void KOReaderSyncActivity::performSync() {
}
if (result != KOReaderSyncClient::OK) {
KOReaderSyncClient::endPersistentSession();
{
RenderLock lock(*this);
state = SYNC_FAILED;
@@ -100,27 +226,48 @@ void KOReaderSyncActivity::performSync() {
return;
}
// Convert remote progress to CrossPoint position
hasRemoteProgress = true;
{
RenderLock lock(*this);
statusMessage = tr(STR_MAPPING_REMOTE);
// 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 = false;
remotePositionMapped = false;
remotePosition.spineIndex = -1;
remotePosition.pageNumber = -1;
remotePosition.totalPages = 0;
remotePosition.paragraphIndex = 0;
remotePosition.hasParagraphIndex = false;
remoteChapterLabel.clear();
if (syncIntent == SyncIntent::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()) {
{
RenderLock lock(*this);
state = SYNC_FAILED;
statusMessage = tr(STR_SYNC_FAILED_MSG);
}
requestUpdate(true);
return;
}
// 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});
{
RenderLock lock(*this);
state = APPLY_COMPLETE;
uploadCompleteTime = millis();
}
requestUpdate(true);
return;
}
requestUpdateAndWait();
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);
// Compare intent keeps the legacy chooser flow (apply vs upload), which is
// still useful for manual conflict decisions.
// Local progress was precomputed before network; keep using the cached value.
releaseEpubForMapping();
{
RenderLock lock(*this);
@@ -144,17 +291,55 @@ 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()) {
if (!computeLocalProgressAndChapter()) {
{
RenderLock lock(*this);
state = SYNC_FAILED;
statusMessage = tr(STR_SYNC_FAILED_MSG);
}
requestUpdate(true);
return;
}
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. In compare flow this comes from the
// earlier GET; in direct-push flow it comes from the warmup GET above.
// In both cases, reuse avoids a second full TLS 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 +394,7 @@ void KOReaderSyncActivity::onEnter() {
void KOReaderSyncActivity::onExit() {
Activity::onExit();
KOReaderSyncClient::endPersistentSession();
HalClock::wifiOff(true);
}
@@ -241,7 +427,7 @@ void KOReaderSyncActivity::render(RenderLock&&) {
}
if (state == SYNCING || state == UPLOADING) {
renderer.drawCenteredText(UI_10_FONT_ID, 300, statusMessage.c_str(), true, EpdFontFamily::BOLD);
GUI.drawPopup(renderer, statusMessage.c_str());
renderer.displayBuffer();
return;
}
@@ -251,24 +437,20 @@ 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);
char remoteChapterStr[128];
snprintf(remoteChapterStr, sizeof(remoteChapterStr), " %s", remoteChapter.c_str());
renderer.drawText(UI_10_FONT_ID, contentRect.x + 20, 185, remoteChapterStr);
char remotePageStr[64];
snprintf(remotePageStr, sizeof(remotePageStr), tr(STR_PAGE_OVERALL_FORMAT), remotePosition.pageNumber + 1,
remoteProgress.percentage * 100);
renderer.drawText(UI_10_FONT_ID, contentRect.x + 20, 210, remotePageStr);
if (hasRemoteProgress) {
char remoteChapterStr[128];
snprintf(remoteChapterStr, sizeof(remoteChapterStr), " %s", remoteChapter.c_str());
renderer.drawText(UI_10_FONT_ID, contentRect.x + 20, 185, remoteChapterStr);
char remotePageStr[64];
snprintf(remotePageStr, sizeof(remotePageStr), tr(STR_PAGE_OVERALL_FORMAT), remotePosition.pageNumber + 1,
remoteProgress.percentage * 100);
renderer.drawText(UI_10_FONT_ID, contentRect.x + 20, 210, remotePageStr);
}
if (!remoteProgress.device.empty()) {
char deviceStr[64];
@@ -328,6 +510,15 @@ void KOReaderSyncActivity::render(RenderLock&&) {
return;
}
if (state == APPLY_COMPLETE) {
renderer.drawCenteredText(UI_10_FONT_ID, 300, tr(STR_PULL_SUCCESS), true, EpdFontFamily::BOLD);
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
return;
}
if (state == SYNC_FAILED) {
renderer.drawCenteredText(UI_10_FONT_ID, 280, tr(STR_SYNC_FAILED_MSG), true, EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_10_FONT_ID, 320, statusMessage.c_str());
@@ -339,15 +530,100 @@ 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<Epub>(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();
hasRemoteProgress = true;
remotePositionMapped = true;
return true;
}
void KOReaderSyncActivity::releaseEpubForMapping() { epub.reset(); }
bool KOReaderSyncActivity::computeLocalProgressAndChapter() {
if (!ensureEpubLoadedForMapping()) {
localProgress = KOReaderPosition{};
localChapterLabel.clear();
return false;
}
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));
return true;
}
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 (state == NO_CREDENTIALS || state == SYNC_FAILED || state == UPLOAD_COMPLETE || state == APPLY_COMPLETE) {
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
closeCancelled();
// 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();
} else {
closeCancelled();
}
return;
}
if (state == UPLOAD_COMPLETE && millis() - uploadCompleteTime >= 3000) {
closeCancelled();
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();
} else {
closeCancelled();
}
}
return;
}
@@ -366,6 +642,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});
+37 -7
View File
@@ -4,6 +4,7 @@
#include <functional>
#include <memory>
#include "ChapterXPathIndexer.h"
#include "KOReaderSyncClient.h"
#include "ProgressMapper.h"
#include "activities/Activity.h"
@@ -11,19 +12,37 @@
/**
* Activity for syncing reading progress with KOReader sync server.
*
* Flow:
* Shared pipeline:
* 1. Connect to WiFi (if not connected)
* 2. Calculate document hash
* 3. Fetch remote progress
* 4. Show comparison and options (Apply/Upload)
* 5. Apply or upload progress
* 2. Optionally sync NTP (if stale)
* 3. Calculate document hash
*
* Intent-specific behavior:
* - 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.
* - 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)
bool hasParagraphIndex = false, SyncIntent syncIntent = SyncIntent::COMPARE)
: Activity("KOReaderSync", renderer, mappedInput),
epub(epub),
epubPath(epubPath),
@@ -32,6 +51,7 @@ class KOReaderSyncActivity final : public Activity {
totalPagesInSpine(totalPagesInSpine),
localParagraphIndex(paragraphIndex),
hasLocalParagraphIndex(hasParagraphIndex),
syncIntent(syncIntent),
remoteProgress{},
remotePosition{},
localProgress{} {}
@@ -50,6 +70,7 @@ class KOReaderSyncActivity final : public Activity {
SHOWING_RESULT,
UPLOADING,
UPLOAD_COMPLETE,
APPLY_COMPLETE,
NO_REMOTE_PROGRESS,
SYNC_FAILED,
NO_CREDENTIALS
@@ -62,6 +83,7 @@ class KOReaderSyncActivity final : public Activity {
int totalPagesInSpine;
uint16_t localParagraphIndex;
bool hasLocalParagraphIndex;
SyncIntent syncIntent = SyncIntent::COMPARE;
State state = WIFI_SELECTION;
std::string statusMessage;
@@ -69,16 +91,19 @@ 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;
// Timestamp when UPLOAD_COMPLETE state was entered (for auto-close)
// Timestamp when completion state was entered (for auto-close)
unsigned long uploadCompleteTime = 0;
bool closeRequested = false;
@@ -86,4 +111,9 @@ class KOReaderSyncActivity final : public Activity {
void performSync();
void performUpload();
void closeCancelled();
bool ensureEpubLoadedForMapping();
void releaseEpubForMapping();
bool computeLocalProgressAndChapter();
void computeRemoteChapter();
bool ensureRemotePositionMapped();
};
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.