From 60a3e21c0e6c1dd592661c4fef82d5def93e1593 Mon Sep 17 00:00:00 2001 From: cottongin Date: Sat, 7 Mar 2026 16:15:42 -0500 Subject: [PATCH] =?UTF-8?q?mod:=20Phase=203=20=E2=80=94=20Re-port=20unmerg?= =?UTF-8?q?ed=20upstream=20PRs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-applied upstream PRs not yet merged to upstream/master: - #1055: Byte-level framebuffer writes (fillPhysicalHSpan*, optimized fillRect/drawLine/fillRectDither/fillPolygon) - #1027: Word-width cache (FNV-1a, 128-entry) and hyphenation early exit in ParsedText for 7-9% layout speedup - #1068: Already present in upstream — URL hyphenation fix - #1019: Already present in upstream — file extensions in browser - #1090/#1185/#1217: KOReader sync improvements — binary credential store, document hash caching, ChapterXPathIndexer integration - #1209: OPDS multi-server — OpdsBookBrowserActivity accepts OpdsServer, directory picker for downloads, download-complete prompt with open/back options - #857: Dictionary activities already ported in Phase 1/2 - #1003: Placeholder cover already integrated in Phase 2 Also fixed: STR_OFF i18n string, include paths, replaced Epub::isValidThumbnailBmp with Storage.exists, replaced StringUtils::checkFileExtension with FsHelpers equivalents. Made-with: Cursor --- lib/Epub/Epub/ParsedText.cpp | 91 +++++++- .../Epub/hyphenation/HyphenationCommon.cpp | 3 +- lib/Epub/Epub/hyphenation/Hyphenator.cpp | 58 +----- lib/GfxRenderer/GfxRenderer.cpp | 197 ++++++++++++++++-- lib/GfxRenderer/GfxRenderer.h | 8 + lib/I18n/translations/english.yaml | 1 + lib/KOReaderSync/KOReaderCredentialStore.cpp | 108 +++++----- lib/KOReaderSync/KOReaderCredentialStore.h | 17 +- lib/KOReaderSync/KOReaderDocumentId.cpp | 151 ++++++++++++++ lib/KOReaderSync/KOReaderDocumentId.h | 27 +++ lib/KOReaderSync/ProgressMapper.cpp | 114 ++++++---- lib/KOReaderSync/ProgressMapper.h | 23 +- lib/hal/HalStorage.cpp | 3 + lib/hal/HalStorage.h | 2 + src/JsonSettingsIO.cpp | 38 ---- src/JsonSettingsIO.h | 5 - src/activities/ActivityManager.cpp | 4 + src/activities/ActivityManager.h | 2 + .../browser/OpdsBookBrowserActivity.cpp | 139 +++++++++--- .../browser/OpdsBookBrowserActivity.h | 30 ++- .../home/BookManageMenuActivity.cpp | 2 +- src/activities/home/FileBrowserActivity.cpp | 12 +- src/activities/home/HomeActivity.cpp | 14 +- src/network/HttpDownloader.cpp | 42 ++-- src/network/HttpDownloader.h | 15 ++ 25 files changed, 811 insertions(+), 295 deletions(-) diff --git a/lib/Epub/Epub/ParsedText.cpp b/lib/Epub/Epub/ParsedText.cpp index 3c26fb29..efda8086 100644 --- a/lib/Epub/Epub/ParsedText.cpp +++ b/lib/Epub/Epub/ParsedText.cpp @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -74,6 +75,80 @@ uint16_t measureWordWidth(const GfxRenderer& renderer, const int fontId, const s return renderer.getTextAdvanceX(fontId, sanitized.c_str(), style); } +// --------------------------------------------------------------------------- +// Direct-mapped word-width cache +// +// Avoids redundant getTextAdvanceX calls when the same (word, style, fontId) +// triple appears across paragraphs. A fixed-size static array is used so +// that heap allocation and fragmentation are both zero. +// +// Eviction policy: hash-direct mapping — a word always occupies the single +// slot determined by its hash; a collision simply overwrites that slot. +// This gives O(1) lookup (one hash + one memcmp) regardless of how full the +// cache is, avoiding the O(n) linear-scan overhead that causes a regression +// on corpora with many unique words (e.g. German compound-heavy text). +// +// Words longer than 23 bytes bypass the cache entirely — they are uncommon, +// unlikely to repeat verbatim, and exceed the fixed-width key buffer. +// --------------------------------------------------------------------------- + +struct WordWidthCacheEntry { + char word[24]; // NUL-terminated; 23 usable bytes + terminator + int fontId; + uint16_t width; + uint8_t style; // EpdFontFamily::Style narrowed to one byte + bool valid; // false = slot empty (BSS-initialised to 0) +}; + +// Power-of-two size → slot selection via fast bitmask AND. +// 128 entries × 32 bytes = 4 KB in BSS; covers typical paragraph vocabulary +// with a low collision rate even for German compound-heavy prose. +static constexpr uint32_t WORD_WIDTH_CACHE_SIZE = 128; +static constexpr uint32_t WORD_WIDTH_CACHE_MASK = WORD_WIDTH_CACHE_SIZE - 1; +static WordWidthCacheEntry s_wordWidthCache[WORD_WIDTH_CACHE_SIZE]; + +// FNV-1a over the word bytes, then XOR-folded with fontId and style. +static uint32_t wordWidthCacheHash(const char* str, const size_t len, const int fontId, const uint8_t style) { + uint32_t h = 2166136261u; // FNV offset basis + for (size_t i = 0; i < len; ++i) { + h ^= static_cast(str[i]); + h *= 16777619u; // FNV prime + } + h ^= static_cast(fontId); + h *= 16777619u; + h ^= style; + return h; +} + +// Returns the cached width for (word, style, fontId), measuring and caching +// on a miss. Appending a hyphen is not supported — those measurements are +// word-fragment lookups that will not repeat and must not pollute the cache. +static uint16_t cachedMeasureWordWidth(const GfxRenderer& renderer, const int fontId, const std::string& word, + const EpdFontFamily::Style style) { + const size_t len = word.size(); + if (len >= 24) { + return measureWordWidth(renderer, fontId, word, style); + } + + const uint8_t styleByte = static_cast(style); + const char* const wordCStr = word.c_str(); + + const uint32_t slot = wordWidthCacheHash(wordCStr, len, fontId, styleByte) & WORD_WIDTH_CACHE_MASK; + auto& e = s_wordWidthCache[slot]; + + if (e.valid && e.fontId == fontId && e.style == styleByte && memcmp(e.word, wordCStr, len + 1) == 0) { + return e.width; // O(1) cache hit + } + + const uint16_t w = measureWordWidth(renderer, fontId, word, style); + memcpy(e.word, wordCStr, len + 1); + e.fontId = fontId; + e.width = w; + e.style = styleByte; + e.valid = true; + return w; +} + } // namespace void ParsedText::addWord(std::string word, const EpdFontFamily::Style fontStyle, const bool underline, @@ -131,7 +206,7 @@ std::vector ParsedText::calculateWordWidths(const GfxRenderer& rendere wordWidths.reserve(words.size()); for (size_t i = 0; i < words.size(); ++i) { - wordWidths.push_back(measureWordWidth(renderer, fontId, words[i], wordStyles[i])); + wordWidths.push_back(cachedMeasureWordWidth(renderer, fontId, words[i], wordStyles[i])); } return wordWidths; @@ -241,6 +316,7 @@ std::vector ParsedText::computeLineBreaks(const GfxRenderer& renderer, c // Stores the index of the word that starts the next line (last_word_index + 1) std::vector lineBreakIndices; + lineBreakIndices.reserve(totalWordCount / 8 + 1); size_t currentWordIndex = 0; while (currentWordIndex < totalWordCount) { @@ -376,8 +452,11 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl size_t chosenOffset = 0; int chosenWidth = -1; bool chosenNeedsHyphen = true; + std::string prefix; + prefix.reserve(word.size()); // Iterate over each legal breakpoint and retain the widest prefix that still fits. + // Breakpoints are in ascending order, so once a prefix is too wide, all subsequent ones will be too. for (const auto& info : breakInfos) { const size_t offset = info.byteOffset; if (offset == 0 || offset >= word.size()) { @@ -385,9 +464,13 @@ bool ParsedText::hyphenateWordAtIndex(const size_t wordIndex, const int availabl } const bool needsHyphen = info.requiresInsertedHyphen; - const int prefixWidth = measureWordWidth(renderer, fontId, word.substr(0, offset), style, needsHyphen); - if (prefixWidth > availableWidth || prefixWidth <= chosenWidth) { - continue; // Skip if too wide or not an improvement + prefix.assign(word, 0, offset); + const int prefixWidth = measureWordWidth(renderer, fontId, prefix, style, needsHyphen); + if (prefixWidth > availableWidth) { + break; // Ascending order: all subsequent breakpoints yield wider prefixes + } + if (prefixWidth <= chosenWidth) { + continue; // Not an improvement } chosenWidth = prefixWidth; diff --git a/lib/Epub/Epub/hyphenation/HyphenationCommon.cpp b/lib/Epub/Epub/hyphenation/HyphenationCommon.cpp index b402d5b9..17852ec8 100644 --- a/lib/Epub/Epub/hyphenation/HyphenationCommon.cpp +++ b/lib/Epub/Epub/hyphenation/HyphenationCommon.cpp @@ -95,8 +95,6 @@ bool isPunctuation(const uint32_t cp) { case '}': case '[': case ']': - case '/': - case 0x2039: // ‹ case 0x203A: // › case 0x2026: // … return true; @@ -109,6 +107,7 @@ bool isAsciiDigit(const uint32_t cp) { return cp >= '0' && cp <= '9'; } bool isExplicitHyphen(const uint32_t cp) { switch (cp) { + case '/': case '-': case 0x00AD: // soft hyphen case 0x058A: // Armenian hyphen diff --git a/lib/Epub/Epub/hyphenation/Hyphenator.cpp b/lib/Epub/Epub/hyphenation/Hyphenator.cpp index 4d86febe..aa558e4e 100644 --- a/lib/Epub/Epub/hyphenation/Hyphenator.cpp +++ b/lib/Epub/Epub/hyphenation/Hyphenator.cpp @@ -1,10 +1,8 @@ #include "Hyphenator.h" -#include #include #include "HyphenationCommon.h" -#include "LanguageHyphenator.h" #include "LanguageRegistry.h" const LanguageHyphenator* Hyphenator::cachedHyphenator_ = nullptr; @@ -34,25 +32,20 @@ size_t byteOffsetForIndex(const std::vector& cps, const size_t in } // Builds a vector of break information from explicit hyphen markers in the given codepoints. -// Only hyphens that appear between two alphabetic characters are considered valid breaks. -// -// Example: "US-Satellitensystems" (cps: U, S, -, S, a, t, ...) -// -> finds '-' at index 2 with alphabetic neighbors 'S' and 'S' -// -> returns one BreakInfo at the byte offset of 'S' (the char after '-'), -// with requiresInsertedHyphen=false because '-' is already visible. -// -// Example: "Satel\u00ADliten" (soft-hyphen between 'l' and 'l') -// -> returns one BreakInfo with requiresInsertedHyphen=true (soft-hyphen -// is invisible and needs a visible '-' when the break is used). std::vector buildExplicitBreakInfos(const std::vector& cps) { std::vector breaks; for (size_t i = 1; i + 1 < cps.size(); ++i) { const uint32_t cp = cps[i].value; - if (!isExplicitHyphen(cp) || !isAlphabetic(cps[i - 1].value) || !isAlphabetic(cps[i + 1].value)) { + if (!isExplicitHyphen(cp)) { + continue; + } + if ((cp == '/' || cp == '-') && cps[i + 1].value == cp) { + continue; + } + if (cp != '/' && cp != '-' && (!isAlphabetic(cps[i - 1].value) || !isAlphabetic(cps[i + 1].value))) { continue; } - // Offset points to the next codepoint so rendering starts after the hyphen marker. breaks.push_back({cps[i + 1].byteOffset, isSoftHyphen(cp)}); } @@ -74,43 +67,6 @@ std::vector Hyphenator::breakOffsets(const std::string& w // Explicit hyphen markers (soft or hard) take precedence over language breaks. auto explicitBreakInfos = buildExplicitBreakInfos(cps); if (!explicitBreakInfos.empty()) { - // When a word contains explicit hyphens we also run Liang patterns on each alphabetic - // segment between them. Without this, "US-Satellitensystems" would only offer one split - // point (after "US-"), making it impossible to break mid-"Satellitensystems" even when - // "US-Satelliten-" would fit on the line. - // - // Example: "US-Satellitensystems" - // Segments: ["US", "Satellitensystems"] - // Explicit break: after "US-" -> @3 (no inserted hyphen) - // Pattern breaks on "Satellitensystems" -> @5 Sa|tel (+hyphen) - // @8 Satel|li (+hyphen) - // @10 Satelli|ten (+hyphen) - // @13 Satelliten|sys (+hyphen) - // @16 Satellitensys|tems (+hyphen) - // Result: 6 sorted break points; the line-breaker picks the widest prefix that fits. - if (hyphenator) { - size_t segStart = 0; - for (size_t i = 0; i <= cps.size(); ++i) { - const bool atEnd = (i == cps.size()); - const bool atHyphen = !atEnd && isExplicitHyphen(cps[i].value); - if (atEnd || atHyphen) { - if (i > segStart) { - std::vector segment(cps.begin() + segStart, cps.begin() + i); - auto segIndexes = hyphenator->breakIndexes(segment); - for (const size_t idx : segIndexes) { - const size_t cpIdx = segStart + idx; - if (cpIdx < cps.size()) { - explicitBreakInfos.push_back({cps[cpIdx].byteOffset, true}); - } - } - } - segStart = i + 1; - } - } - // Merge explicit and pattern breaks into ascending byte-offset order. - std::sort(explicitBreakInfos.begin(), explicitBreakInfos.end(), - [](const BreakInfo& a, const BreakInfo& b) { return a.byteOffset < b.byteOffset; }); - } return explicitBreakInfos; } diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index e2e28fe8..5876e470 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -3,6 +3,8 @@ #include #include +#include + const uint8_t* GfxRenderer::getGlyphBitmap(const EpdFontData* fontData, const EpdGlyph* glyph) const { if (fontData->groups != nullptr) { if (!fontDecompressor) { @@ -271,15 +273,34 @@ void GfxRenderer::drawLine(int x1, int y1, int x2, int y2, const bool state) con if (y2 < y1) { std::swap(y1, y2); } - for (int y = y1; y <= y2; y++) { - drawPixel(x1, y, state); + // In Portrait/PortraitInverted a logical vertical line maps to a physical horizontal span. + switch (orientation) { + case Portrait: + fillPhysicalHSpan(HalDisplay::DISPLAY_HEIGHT - 1 - x1, y1, y2, state); + return; + case PortraitInverted: + fillPhysicalHSpan(x1, HalDisplay::DISPLAY_WIDTH - 1 - y2, HalDisplay::DISPLAY_WIDTH - 1 - y1, state); + return; + default: + for (int y = y1; y <= y2; y++) drawPixel(x1, y, state); + return; } } else if (y1 == y2) { if (x2 < x1) { std::swap(x1, x2); } - for (int x = x1; x <= x2; x++) { - drawPixel(x, y1, state); + // In Landscape a logical horizontal line maps to a physical horizontal span. + switch (orientation) { + case LandscapeCounterClockwise: + fillPhysicalHSpan(y1, x1, x2, state); + return; + case LandscapeClockwise: + fillPhysicalHSpan(HalDisplay::DISPLAY_HEIGHT - 1 - y1, HalDisplay::DISPLAY_WIDTH - 1 - x2, + HalDisplay::DISPLAY_WIDTH - 1 - x1, state); + return; + default: + for (int x = x1; x <= x2; x++) drawPixel(x, y1, state); + return; } } else { // Bresenham's line algorithm — integer arithmetic only @@ -408,9 +429,80 @@ void GfxRenderer::drawRoundedRect(const int x, const int y, const int width, con } } +// Write a patterned horizontal span directly into the physical framebuffer with byte-level operations. +// Handles partial left/right bytes and fills the aligned middle with memset. +// Bit layout: MSB-first (bit 7 = phyX=0, bit 0 = phyX=7); 0 bits = dark pixel, 1 bits = white pixel. +void GfxRenderer::fillPhysicalHSpanByte(const int phyY, const int phyX_start, const int phyX_end, + const uint8_t patternByte) const { + const int cX0 = std::max(phyX_start, 0); + const int cX1 = std::min(phyX_end, static_cast(HalDisplay::DISPLAY_WIDTH) - 1); + if (cX0 > cX1 || phyY < 0 || phyY >= static_cast(HalDisplay::DISPLAY_HEIGHT)) return; + + uint8_t* const row = frameBuffer + phyY * HalDisplay::DISPLAY_WIDTH_BYTES; + const int startByte = cX0 >> 3; + const int endByte = cX1 >> 3; + const int leftBits = cX0 & 7; + const int rightBits = cX1 & 7; + + if (startByte == endByte) { + const uint8_t fillMask = (0xFF >> leftBits) & ~(0xFF >> (rightBits + 1)); + row[startByte] = (row[startByte] & ~fillMask) | (patternByte & fillMask); + return; + } + + // Left partial byte + if (leftBits != 0) { + const uint8_t fillMask = 0xFF >> leftBits; + row[startByte] = (row[startByte] & ~fillMask) | (patternByte & fillMask); + } + + // Full bytes in the middle + const int fullStart = (leftBits == 0) ? startByte : startByte + 1; + const int fullEnd = (rightBits == 7) ? endByte : endByte - 1; + if (fullStart <= fullEnd) { + memset(row + fullStart, patternByte, static_cast(fullEnd - fullStart + 1)); + } + + // Right partial byte + if (rightBits != 7) { + const uint8_t fillMask = ~(0xFF >> (rightBits + 1)); + row[endByte] = (row[endByte] & ~fillMask) | (patternByte & fillMask); + } +} + +// Thin wrapper: state=true → 0x00 (all dark), false → 0xFF (all white). +void GfxRenderer::fillPhysicalHSpan(const int phyY, const int phyX_start, const int phyX_end, const bool state) const { + fillPhysicalHSpanByte(phyY, phyX_start, phyX_end, state ? 0x00 : 0xFF); +} + void GfxRenderer::fillRect(const int x, const int y, const int width, const int height, const bool state) const { - for (int fillY = y; fillY < y + height; fillY++) { - drawLine(x, fillY, x + width - 1, fillY, state); + if (width <= 0 || height <= 0) return; + + // For each orientation, one logical dimension maps to a constant physical row, allowing the + // perpendicular dimension to be written as a byte-level span — eliminating per-pixel overhead. + switch (orientation) { + case Portrait: + for (int lx = x; lx < x + width; lx++) { + fillPhysicalHSpan(HalDisplay::DISPLAY_HEIGHT - 1 - lx, y, y + height - 1, state); + } + return; + case PortraitInverted: + for (int lx = x; lx < x + width; lx++) { + fillPhysicalHSpan(lx, HalDisplay::DISPLAY_WIDTH - 1 - (y + height - 1), HalDisplay::DISPLAY_WIDTH - 1 - y, + state); + } + return; + case LandscapeCounterClockwise: + for (int ly = y; ly < y + height; ly++) { + fillPhysicalHSpan(ly, x, x + width - 1, state); + } + return; + case LandscapeClockwise: + for (int ly = y; ly < y + height; ly++) { + fillPhysicalHSpan(HalDisplay::DISPLAY_HEIGHT - 1 - ly, HalDisplay::DISPLAY_WIDTH - 1 - (x + width - 1), + HalDisplay::DISPLAY_WIDTH - 1 - x, state); + } + return; } } @@ -447,17 +539,77 @@ void GfxRenderer::fillRectDither(const int x, const int y, const int width, cons fillRect(x, y, width, height, true); } else if (color == Color::White) { fillRect(x, y, width, height, false); - } else if (color == Color::LightGray) { - for (int fillY = y; fillY < y + height; fillY++) { - for (int fillX = x; fillX < x + width; fillX++) { - drawPixelDither(fillX, fillY); - } - } } else if (color == Color::DarkGray) { - for (int fillY = y; fillY < y + height; fillY++) { - for (int fillX = x; fillX < x + width; fillX++) { - drawPixelDither(fillX, fillY); - } + // Pattern: dark where (phyX + phyY) % 2 == 0 (alternating checkerboard). + // Byte patterns (phyY even / phyY odd): + // Portrait / PortraitInverted: 0xAA / 0x55 + // LandscapeCW / LandscapeCCW: 0x55 / 0xAA + switch (orientation) { + case Portrait: + for (int lx = x; lx < x + width; lx++) { + const int phyY = HalDisplay::DISPLAY_HEIGHT - 1 - lx; + const uint8_t pb = (phyY % 2 == 0) ? 0xAA : 0x55; + fillPhysicalHSpanByte(phyY, y, y + height - 1, pb); + } + return; + case PortraitInverted: + for (int lx = x; lx < x + width; lx++) { + const int phyY = lx; + const uint8_t pb = (phyY % 2 == 0) ? 0xAA : 0x55; + fillPhysicalHSpanByte(phyY, HalDisplay::DISPLAY_WIDTH - 1 - (y + height - 1), + HalDisplay::DISPLAY_WIDTH - 1 - y, pb); + } + return; + case LandscapeCounterClockwise: + for (int ly = y; ly < y + height; ly++) { + const int phyY = ly; + const uint8_t pb = (phyY % 2 == 0) ? 0x55 : 0xAA; + fillPhysicalHSpanByte(phyY, x, x + width - 1, pb); + } + return; + case LandscapeClockwise: + for (int ly = y; ly < y + height; ly++) { + const int phyY = HalDisplay::DISPLAY_HEIGHT - 1 - ly; + const uint8_t pb = (phyY % 2 == 0) ? 0x55 : 0xAA; + fillPhysicalHSpanByte(phyY, HalDisplay::DISPLAY_WIDTH - 1 - (x + width - 1), + HalDisplay::DISPLAY_WIDTH - 1 - x, pb); + } + return; + } + } else if (color == Color::LightGray) { + // Pattern: dark where phyX % 2 == 0 && phyY % 2 == 0 (1-in-4 pixels dark). + // Rows that would be all-white are skipped entirely. + switch (orientation) { + case Portrait: + for (int lx = x; lx < x + width; lx++) { + const int phyY = HalDisplay::DISPLAY_HEIGHT - 1 - lx; + if (phyY % 2 == 0) continue; + fillPhysicalHSpanByte(phyY, y, y + height - 1, 0x55); + } + return; + case PortraitInverted: + for (int lx = x; lx < x + width; lx++) { + const int phyY = lx; + if (phyY % 2 != 0) continue; + fillPhysicalHSpanByte(phyY, HalDisplay::DISPLAY_WIDTH - 1 - (y + height - 1), + HalDisplay::DISPLAY_WIDTH - 1 - y, 0xAA); + } + return; + case LandscapeCounterClockwise: + for (int ly = y; ly < y + height; ly++) { + const int phyY = ly; + if (phyY % 2 != 0) continue; + fillPhysicalHSpanByte(phyY, x, x + width - 1, 0x55); + } + return; + case LandscapeClockwise: + for (int ly = y; ly < y + height; ly++) { + const int phyY = HalDisplay::DISPLAY_HEIGHT - 1 - ly; + if (phyY % 2 == 0) continue; + fillPhysicalHSpanByte(phyY, HalDisplay::DISPLAY_WIDTH - 1 - (x + width - 1), + HalDisplay::DISPLAY_WIDTH - 1 - x, 0xAA); + } + return; } } } @@ -799,9 +951,16 @@ void GfxRenderer::fillPolygon(const int* xPoints, const int* yPoints, int numPoi if (startX < 0) startX = 0; if (endX >= getScreenWidth()) endX = getScreenWidth() - 1; - // Draw horizontal line - for (int x = startX; x <= endX; x++) { - drawPixel(x, scanY, state); + // In Landscape orientations, horizontal scanlines map to physical horizontal spans. + if (orientation == LandscapeCounterClockwise) { + fillPhysicalHSpan(scanY, startX, endX, state); + } else if (orientation == LandscapeClockwise) { + fillPhysicalHSpan(HalDisplay::DISPLAY_HEIGHT - 1 - scanY, HalDisplay::DISPLAY_WIDTH - 1 - endX, + HalDisplay::DISPLAY_WIDTH - 1 - startX, state); + } else { + for (int px = startX; px <= endX; px++) { + drawPixel(px, scanY, state); + } } } } diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index f0a295ef..f94fbcbd 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -45,6 +45,14 @@ class GfxRenderer { void drawPixelDither(int x, int y) const; template void fillArc(int maxRadius, int cx, int cy, int xDir, int yDir) const; + // Write a patterned horizontal span directly to the physical framebuffer using byte-level operations. + // phyY: physical row; phyX_start/phyX_end: inclusive physical column range. + // patternByte is repeated across the span; partial edge bytes are blended with existing content. + // Bit layout: MSB-first (bit 7 = phyX=0); 0 bits = dark pixel, 1 bits = white pixel. + void fillPhysicalHSpanByte(int phyY, int phyX_start, int phyX_end, uint8_t patternByte) const; + // Write a solid horizontal span directly to the physical framebuffer using byte-level operations. + // Thin wrapper around fillPhysicalHSpanByte: state=true → 0x00 (dark), false → 0xFF (white). + void fillPhysicalHSpan(int phyY, int phyX_start, int phyX_end, bool state) const; public: explicit GfxRenderer(HalDisplay& halDisplay) diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index 8c4ba26d..becc9a6f 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -344,6 +344,7 @@ STR_AUTO_TURN_PAGES_PER_MIN: "Auto Turn (Pages Per Minute)" STR_CAT_CLOCK: "Clock" STR_CLOCK: "Clock" +STR_OFF: "Off" STR_CLOCK_AMPM: "AM/PM" STR_CLOCK_24H: "24 Hour" STR_SET_TIME: "Set Time" diff --git a/lib/KOReaderSync/KOReaderCredentialStore.cpp b/lib/KOReaderSync/KOReaderCredentialStore.cpp index 574f2b8e..d59afbce 100644 --- a/lib/KOReaderSync/KOReaderCredentialStore.cpp +++ b/lib/KOReaderSync/KOReaderCredentialStore.cpp @@ -3,81 +3,73 @@ #include #include #include -#include #include -#include "../../src/JsonSettingsIO.h" - // Initialize the static instance KOReaderCredentialStore KOReaderCredentialStore::instance; namespace { -// File format version (for binary migration) +// File format version constexpr uint8_t KOREADER_FILE_VERSION = 1; -// File paths -constexpr char KOREADER_FILE_BIN[] = "/.crosspoint/koreader.bin"; -constexpr char KOREADER_FILE_JSON[] = "/.crosspoint/koreader.json"; -constexpr char KOREADER_FILE_BAK[] = "/.crosspoint/koreader.bin.bak"; +// KOReader credentials file path +constexpr char KOREADER_FILE[] = "/.crosspoint/koreader.bin"; // Default sync server URL constexpr char DEFAULT_SERVER_URL[] = "https://sync.koreader.rocks:443"; -// Legacy obfuscation key - "KOReader" in ASCII (only used for binary migration) -constexpr uint8_t LEGACY_OBFUSCATION_KEY[] = {0x4B, 0x4F, 0x52, 0x65, 0x61, 0x64, 0x65, 0x72}; -constexpr size_t LEGACY_KEY_LENGTH = sizeof(LEGACY_OBFUSCATION_KEY); - -void legacyDeobfuscate(std::string& data) { - for (size_t i = 0; i < data.size(); i++) { - data[i] ^= LEGACY_OBFUSCATION_KEY[i % LEGACY_KEY_LENGTH]; - } -} +// Obfuscation key - "KOReader" in ASCII +// This is NOT cryptographic security, just prevents casual file reading +constexpr uint8_t OBFUSCATION_KEY[] = {0x4B, 0x4F, 0x52, 0x65, 0x61, 0x64, 0x65, 0x72}; +constexpr size_t KEY_LENGTH = sizeof(OBFUSCATION_KEY); } // namespace +void KOReaderCredentialStore::obfuscate(std::string& data) const { + for (size_t i = 0; i < data.size(); i++) { + data[i] ^= OBFUSCATION_KEY[i % KEY_LENGTH]; + } +} + bool KOReaderCredentialStore::saveToFile() const { + // Make sure the directory exists Storage.mkdir("/.crosspoint"); - return JsonSettingsIO::saveKOReader(*this, KOREADER_FILE_JSON); -} -bool KOReaderCredentialStore::loadFromFile() { - // Try JSON first - if (Storage.exists(KOREADER_FILE_JSON)) { - String json = Storage.readFile(KOREADER_FILE_JSON); - if (!json.isEmpty()) { - bool resave = false; - bool result = JsonSettingsIO::loadKOReader(*this, json.c_str(), &resave); - if (result && resave) { - saveToFile(); - LOG_DBG("KRS", "Resaved KOReader credentials to update format"); - } - return result; - } - } - - // Fall back to binary migration - if (Storage.exists(KOREADER_FILE_BIN)) { - if (loadFromBinaryFile()) { - if (saveToFile()) { - Storage.rename(KOREADER_FILE_BIN, KOREADER_FILE_BAK); - LOG_DBG("KRS", "Migrated koreader.bin to koreader.json"); - return true; - } else { - LOG_ERR("KRS", "Failed to save KOReader credentials during migration"); - return false; - } - } - } - - LOG_DBG("KRS", "No credentials file found"); - return false; -} - -bool KOReaderCredentialStore::loadFromBinaryFile() { FsFile file; - if (!Storage.openFileForRead("KRS", KOREADER_FILE_BIN, file)) { + if (!Storage.openFileForWrite("KRS", KOREADER_FILE, file)) { return false; } + // Write header + serialization::writePod(file, KOREADER_FILE_VERSION); + + // Write username (plaintext - not particularly sensitive) + serialization::writeString(file, username); + LOG_DBG("KRS", "Saving username: %s", username.c_str()); + + // Write password (obfuscated) + std::string obfuscatedPwd = password; + obfuscate(obfuscatedPwd); + serialization::writeString(file, obfuscatedPwd); + + // Write server URL + serialization::writeString(file, serverUrl); + + // Write match method + serialization::writePod(file, static_cast(matchMethod)); + + file.close(); + LOG_DBG("KRS", "Saved KOReader credentials to file"); + return true; +} + +bool KOReaderCredentialStore::loadFromFile() { + FsFile file; + if (!Storage.openFileForRead("KRS", KOREADER_FILE, file)) { + LOG_DBG("KRS", "No credentials file found"); + return false; + } + + // Read and verify version uint8_t version; serialization::readPod(file, version); if (version != KOREADER_FILE_VERSION) { @@ -86,25 +78,29 @@ bool KOReaderCredentialStore::loadFromBinaryFile() { return false; } + // Read username if (file.available()) { serialization::readString(file, username); } else { username.clear(); } + // Read and deobfuscate password if (file.available()) { serialization::readString(file, password); - legacyDeobfuscate(password); + obfuscate(password); // XOR is symmetric, so same function deobfuscates } else { password.clear(); } + // Read server URL if (file.available()) { serialization::readString(file, serverUrl); } else { serverUrl.clear(); } + // Read match method if (file.available()) { uint8_t method; serialization::readPod(file, method); @@ -114,7 +110,7 @@ bool KOReaderCredentialStore::loadFromBinaryFile() { } file.close(); - LOG_DBG("KRS", "Loaded KOReader credentials from binary for user: %s", username.c_str()); + LOG_DBG("KRS", "Loaded KOReader credentials for user: %s", username.c_str()); return true; } diff --git a/lib/KOReaderSync/KOReaderCredentialStore.h b/lib/KOReaderSync/KOReaderCredentialStore.h index 89e008d0..998101a2 100644 --- a/lib/KOReaderSync/KOReaderCredentialStore.h +++ b/lib/KOReaderSync/KOReaderCredentialStore.h @@ -8,17 +8,10 @@ enum class DocumentMatchMethod : uint8_t { BINARY = 1, // Match by partial MD5 of file content (more accurate, but files must be identical) }; -class KOReaderCredentialStore; -namespace JsonSettingsIO { -bool saveKOReader(const KOReaderCredentialStore& store, const char* path); -bool loadKOReader(KOReaderCredentialStore& store, const char* json, bool* needsResave); -} // namespace JsonSettingsIO - /** * Singleton class for storing KOReader sync credentials on the SD card. - * Passwords are XOR-obfuscated with the device's unique hardware MAC address - * and base64-encoded before writing to JSON (not cryptographically secure, - * but prevents casual reading and ties credentials to the specific device). + * Credentials are stored in /sd/.crosspoint/koreader.bin with basic + * XOR obfuscation to prevent casual reading (not cryptographically secure). */ class KOReaderCredentialStore { private: @@ -31,10 +24,8 @@ class KOReaderCredentialStore { // Private constructor for singleton KOReaderCredentialStore() = default; - bool loadFromBinaryFile(); - - friend bool JsonSettingsIO::saveKOReader(const KOReaderCredentialStore&, const char*); - friend bool JsonSettingsIO::loadKOReader(KOReaderCredentialStore&, const char*, bool*); + // XOR obfuscation (symmetric - same for encode/decode) + void obfuscate(std::string& data) const; public: // Delete copy constructor and assignment diff --git a/lib/KOReaderSync/KOReaderDocumentId.cpp b/lib/KOReaderSync/KOReaderDocumentId.cpp index efb18d1b..b60216a2 100644 --- a/lib/KOReaderSync/KOReaderDocumentId.cpp +++ b/lib/KOReaderSync/KOReaderDocumentId.cpp @@ -4,6 +4,8 @@ #include #include +#include + namespace { // Extract filename from path (everything after last '/') std::string getFilename(const std::string& path) { @@ -15,6 +17,131 @@ std::string getFilename(const std::string& path) { } } // namespace +std::string KOReaderDocumentId::getCacheFilePath(const std::string& filePath) { + // Mirror the Epub cache directory convention so the hash file shares the + // same per-book folder as other cached data. + return std::string("/.crosspoint/epub_") + std::to_string(std::hash{}(filePath)) + + "/koreader_docid.txt"; +} + +std::string KOReaderDocumentId::loadCachedHash(const std::string& cacheFilePath, const size_t fileSize, + const std::string& currentFingerprint) { + if (!Storage.exists(cacheFilePath.c_str())) { + return ""; + } + + const String content = Storage.readFile(cacheFilePath.c_str()); + if (content.isEmpty()) { + return ""; + } + + // Format: ":\n<32-char-hex-hash>" + const int newlinePos = content.indexOf('\n'); + if (newlinePos < 0) { + return ""; + } + + const String header = content.substring(0, newlinePos); + const int colonPos = header.indexOf(':'); + if (colonPos < 0) { + LOG_DBG("KODoc", "Hash cache invalidated: header missing fingerprint"); + return ""; + } + + const String sizeTok = header.substring(0, colonPos); + const String fpTok = header.substring(colonPos + 1); + + // Validate the filesize token – it must consist of ASCII digits and parse + // correctly to the expected size. + bool digitsOnly = true; + for (size_t i = 0; i < sizeTok.length(); ++i) { + const char ch = sizeTok[i]; + if (ch < '0' || ch > '9') { + digitsOnly = false; + break; + } + } + if (!digitsOnly) { + LOG_DBG("KODoc", "Hash cache invalidated: size token not numeric ('%s')", sizeTok.c_str()); + return ""; + } + + const long parsed = sizeTok.toInt(); + if (parsed < 0) { + LOG_DBG("KODoc", "Hash cache invalidated: size token parse error ('%s')", sizeTok.c_str()); + return ""; + } + const size_t cachedSize = static_cast(parsed); + if (cachedSize != fileSize) { + LOG_DBG("KODoc", "Hash cache invalidated: file size or fingerprint changed (%zu -> %zu)", cachedSize, fileSize); + return ""; + } + + // Validate stored fingerprint format (8 hex characters) + if (fpTok.length() != 8) { + LOG_DBG("KODoc", "Hash cache invalidated: bad fingerprint length (%zu)", fpTok.length()); + return ""; + } + for (size_t i = 0; i < fpTok.length(); ++i) { + char c = fpTok[i]; + bool hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + if (!hex) { + LOG_DBG("KODoc", "Hash cache invalidated: non-hex character '%c' in fingerprint", c); + return ""; + } + } + + { + String currentFpStr(currentFingerprint.c_str()); + if (fpTok != currentFpStr) { + LOG_DBG("KODoc", "Hash cache invalidated: fingerprint changed (%s != %s)", fpTok.c_str(), + currentFingerprint.c_str()); + return ""; + } + } + + std::string hash = content.substring(newlinePos + 1).c_str(); + // Trim any trailing whitespace / line endings + while (!hash.empty() && (hash.back() == '\n' || hash.back() == '\r' || hash.back() == ' ')) { + hash.pop_back(); + } + + // Hash must be exactly 32 hex characters. + if (hash.size() != 32) { + LOG_DBG("KODoc", "Hash cache invalidated: wrong hash length (%zu)", hash.size()); + return ""; + } + for (char c : hash) { + if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) { + LOG_DBG("KODoc", "Hash cache invalidated: non-hex character '%c' in hash", c); + return ""; + } + } + + LOG_DBG("KODoc", "Hash cache hit: %s", hash.c_str()); + return hash; +} + +void KOReaderDocumentId::saveCachedHash(const std::string& cacheFilePath, const size_t fileSize, + const std::string& fingerprint, const std::string& hash) { + // Ensure the book's cache directory exists before writing + const size_t lastSlash = cacheFilePath.rfind('/'); + if (lastSlash != std::string::npos) { + Storage.ensureDirectoryExists(cacheFilePath.substr(0, lastSlash).c_str()); + } + + // Format: ":\n" + String content(std::to_string(fileSize).c_str()); + content += ':'; + content += fingerprint.c_str(); + content += '\n'; + content += hash.c_str(); + + if (!Storage.writeFile(cacheFilePath.c_str(), content)) { + LOG_DBG("KODoc", "Failed to write hash cache to %s", cacheFilePath.c_str()); + } +} + std::string KOReaderDocumentId::calculateFromFilename(const std::string& filePath) { const std::string filename = getFilename(filePath); if (filename.empty()) { @@ -49,6 +176,28 @@ std::string KOReaderDocumentId::calculate(const std::string& filePath) { } const size_t fileSize = file.fileSize(); + + // Compute a lightweight fingerprint from the file's modification time. + // The underlying FsFile API provides getModifyDateTime which returns two + // packed 16-bit values (date and time). Concatenate these as eight hex + // digits to produce the token stored in the cache header. + uint16_t date = 0, time = 0; + if (!file.getModifyDateTime(&date, &time)) { + date = 0; + time = 0; + } + char fpBuf[9]; + snprintf(fpBuf, sizeof(fpBuf), "%04x%04x", date, time); + const std::string fingerprintTok(fpBuf); + + // Return persisted hash if the file size and fingerprint haven't changed. + const std::string cacheFilePath = getCacheFilePath(filePath); + const std::string cached = loadCachedHash(cacheFilePath, fileSize, fingerprintTok); + if (!cached.empty()) { + file.close(); + return cached; + } + LOG_DBG("KODoc", "Calculating hash for file: %s (size: %zu)", filePath.c_str(), fileSize); // Initialize MD5 builder @@ -92,5 +241,7 @@ std::string KOReaderDocumentId::calculate(const std::string& filePath) { LOG_DBG("KODoc", "Hash calculated: %s (from %zu bytes)", result.c_str(), totalBytesRead); + saveCachedHash(cacheFilePath, fileSize, fingerprintTok, result); + return result; } diff --git a/lib/KOReaderSync/KOReaderDocumentId.h b/lib/KOReaderSync/KOReaderDocumentId.h index 2b6189e2..5f226eb5 100644 --- a/lib/KOReaderSync/KOReaderDocumentId.h +++ b/lib/KOReaderSync/KOReaderDocumentId.h @@ -42,4 +42,31 @@ class KOReaderDocumentId { // Calculate offset for index i: 1024 << (2*i) static size_t getOffset(int i); + + // Hash cache helpers + // Returns the path to the per-book cache file that stores the precomputed hash. + // Uses the same directory convention as the Epub cache (/.crosspoint/epub_/). + static std::string getCacheFilePath(const std::string& filePath); + + // Returns the cached hash if the file size and fingerprint match, or empty + // string on miss/invalidation. + // + // The fingerprint is derived from the file's modification timestamp. We + // call `FsFile::getModifyDateTime` to retrieve two 16‑bit packed values + // supplied by the filesystem: one for the date and one for the time. These + // are concatenated and represented as eight hexadecimal digits in the form + //