mod: Phase 3 — Re-port unmerged upstream PRs
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
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <functional>
|
||||
#include <limits>
|
||||
#include <vector>
|
||||
@@ -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<uint8_t>(str[i]);
|
||||
h *= 16777619u; // FNV prime
|
||||
}
|
||||
h ^= static_cast<uint32_t>(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<uint8_t>(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<uint16_t> 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<size_t> ParsedText::computeLineBreaks(const GfxRenderer& renderer, c
|
||||
|
||||
// Stores the index of the word that starts the next line (last_word_index + 1)
|
||||
std::vector<size_t> 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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
#include "Hyphenator.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <vector>
|
||||
|
||||
#include "HyphenationCommon.h"
|
||||
#include "LanguageHyphenator.h"
|
||||
#include "LanguageRegistry.h"
|
||||
|
||||
const LanguageHyphenator* Hyphenator::cachedHyphenator_ = nullptr;
|
||||
@@ -34,25 +32,20 @@ size_t byteOffsetForIndex(const std::vector<CodepointInfo>& 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<Hyphenator::BreakInfo> buildExplicitBreakInfos(const std::vector<CodepointInfo>& cps) {
|
||||
std::vector<Hyphenator::BreakInfo> 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::BreakInfo> 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<CodepointInfo> 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
#include <Logging.h>
|
||||
#include <Utf8.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
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<int>(HalDisplay::DISPLAY_WIDTH) - 1);
|
||||
if (cX0 > cX1 || phyY < 0 || phyY >= static_cast<int>(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<size_t>(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<Color::LightGray>(fillX, fillY);
|
||||
}
|
||||
}
|
||||
} else if (color == Color::DarkGray) {
|
||||
for (int fillY = y; fillY < y + height; fillY++) {
|
||||
for (int fillX = x; fillX < x + width; fillX++) {
|
||||
drawPixelDither<Color::DarkGray>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,14 @@ class GfxRenderer {
|
||||
void drawPixelDither(int x, int y) const;
|
||||
template <Color color>
|
||||
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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -3,81 +3,73 @@
|
||||
#include <HalStorage.h>
|
||||
#include <Logging.h>
|
||||
#include <MD5Builder.h>
|
||||
#include <ObfuscationUtils.h>
|
||||
#include <Serialization.h>
|
||||
|
||||
#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<uint8_t>(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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
#include <Logging.h>
|
||||
#include <MD5Builder.h>
|
||||
|
||||
#include <functional>
|
||||
|
||||
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<std::string>{}(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: "<filesize>:<fingerprint>\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<size_t>(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: "<filesize>:<fingerprint>\n<hash>"
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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_<hash>/).
|
||||
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
|
||||
// <date><time> (high 16 bits = packed date, low 16 bits = packed time).
|
||||
//
|
||||
// The resulting string serves as a lightweight change signal; any modification
|
||||
// to the file's mtime will alter the packed date/time combo and invalidate
|
||||
// the cache entry. Since the full document hash is expensive to compute,
|
||||
// using the packed timestamp gives us a quick way to detect modifications
|
||||
// without reading file contents.
|
||||
static std::string loadCachedHash(const std::string& cacheFilePath, size_t fileSize,
|
||||
const std::string& currentFingerprint);
|
||||
|
||||
// Persists the computed hash alongside the file size and fingerprint (the
|
||||
// modification-timestamp token) used to generate it.
|
||||
static void saveCachedHash(const std::string& cacheFilePath, size_t fileSize, const std::string& fingerprint,
|
||||
const std::string& hash);
|
||||
};
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
#include <Logging.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
#include "ChapterXPathIndexer.h"
|
||||
|
||||
KOReaderPosition ProgressMapper::toKOReader(const std::shared_ptr<Epub>& epub, const CrossPointPosition& pos) {
|
||||
KOReaderPosition result;
|
||||
|
||||
@@ -16,8 +19,13 @@ KOReaderPosition ProgressMapper::toKOReader(const std::shared_ptr<Epub>& epub, c
|
||||
// Calculate overall book progress (0.0-1.0)
|
||||
result.percentage = epub->calculateProgress(pos.spineIndex, intraSpineProgress);
|
||||
|
||||
// Generate XPath with estimated paragraph position based on page
|
||||
result.xpath = generateXPath(pos.spineIndex, pos.pageNumber, pos.totalPages);
|
||||
// Generate the best available XPath for the current chapter position.
|
||||
// Prefer element-level XPaths from a lightweight XHTML reparse; fall back
|
||||
// to a synthetic chapter-level path if parsing fails.
|
||||
result.xpath = ChapterXPathIndexer::findXPathForProgress(epub, pos.spineIndex, intraSpineProgress);
|
||||
if (result.xpath.empty()) {
|
||||
result.xpath = generateXPath(pos.spineIndex);
|
||||
}
|
||||
|
||||
// Get chapter info for logging
|
||||
const int tocIndex = epub->getTocIndexForSpineIndex(pos.spineIndex);
|
||||
@@ -36,34 +44,69 @@ CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epu
|
||||
result.pageNumber = 0;
|
||||
result.totalPages = 0;
|
||||
|
||||
const size_t bookSize = epub->getBookSize();
|
||||
if (bookSize == 0) {
|
||||
if (!epub || epub->getSpineItemsCount() <= 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Use percentage-based lookup for both spine and page positioning
|
||||
// XPath parsing is unreliable since CrossPoint doesn't preserve detailed HTML structure
|
||||
const size_t targetBytes = static_cast<size_t>(bookSize * koPos.percentage);
|
||||
|
||||
// Find the spine item that contains this byte position
|
||||
const int spineCount = epub->getSpineItemsCount();
|
||||
bool spineFound = false;
|
||||
for (int i = 0; i < spineCount; i++) {
|
||||
const size_t cumulativeSize = epub->getCumulativeSpineItemSize(i);
|
||||
if (cumulativeSize >= targetBytes) {
|
||||
result.spineIndex = i;
|
||||
spineFound = true;
|
||||
break;
|
||||
|
||||
float resolvedIntraSpineProgress = -1.0f;
|
||||
bool xpathExactMatch = false;
|
||||
bool usedXPathMapping = false;
|
||||
|
||||
int xpathSpineIndex = -1;
|
||||
if (ChapterXPathIndexer::tryExtractSpineIndexFromXPath(koPos.xpath, xpathSpineIndex) && xpathSpineIndex >= 0 &&
|
||||
xpathSpineIndex < spineCount) {
|
||||
float intraFromXPath = 0.0f;
|
||||
if (ChapterXPathIndexer::findProgressForXPath(epub, xpathSpineIndex, koPos.xpath, intraFromXPath,
|
||||
xpathExactMatch)) {
|
||||
result.spineIndex = xpathSpineIndex;
|
||||
resolvedIntraSpineProgress = intraFromXPath;
|
||||
usedXPathMapping = true;
|
||||
}
|
||||
}
|
||||
|
||||
// If no spine item was found (e.g., targetBytes beyond last cumulative size),
|
||||
// default to the last spine item so we map to the end of the book instead of the beginning.
|
||||
if (!spineFound && spineCount > 0) {
|
||||
result.spineIndex = spineCount - 1;
|
||||
if (!usedXPathMapping) {
|
||||
const size_t bookSize = epub->getBookSize();
|
||||
if (bookSize == 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!std::isfinite(koPos.percentage)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const float sanitizedPercentage = std::clamp(koPos.percentage, 0.0f, 1.0f);
|
||||
const size_t targetBytes = static_cast<size_t>(bookSize * sanitizedPercentage);
|
||||
|
||||
bool spineFound = false;
|
||||
for (int i = 0; i < spineCount; i++) {
|
||||
const size_t cumulativeSize = epub->getCumulativeSpineItemSize(i);
|
||||
if (cumulativeSize >= targetBytes) {
|
||||
result.spineIndex = i;
|
||||
spineFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!spineFound && spineCount > 0) {
|
||||
result.spineIndex = spineCount - 1;
|
||||
}
|
||||
|
||||
if (result.spineIndex < epub->getSpineItemsCount()) {
|
||||
const size_t prevCumSize = (result.spineIndex > 0) ? epub->getCumulativeSpineItemSize(result.spineIndex - 1) : 0;
|
||||
const size_t currentCumSize = epub->getCumulativeSpineItemSize(result.spineIndex);
|
||||
const size_t spineSize = currentCumSize - prevCumSize;
|
||||
|
||||
if (spineSize > 0) {
|
||||
const size_t bytesIntoSpine = (targetBytes > prevCumSize) ? (targetBytes - prevCumSize) : 0;
|
||||
resolvedIntraSpineProgress = static_cast<float>(bytesIntoSpine) / static_cast<float>(spineSize);
|
||||
resolvedIntraSpineProgress = std::max(0.0f, std::min(1.0f, resolvedIntraSpineProgress));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Estimate page number within the spine item using percentage
|
||||
// Estimate page number within the selected spine item
|
||||
if (result.spineIndex < epub->getSpineItemsCount()) {
|
||||
const size_t prevCumSize = (result.spineIndex > 0) ? epub->getCumulativeSpineItemSize(result.spineIndex - 1) : 0;
|
||||
const size_t currentCumSize = epub->getCumulativeSpineItemSize(result.spineIndex);
|
||||
@@ -71,12 +114,9 @@ CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epu
|
||||
|
||||
int estimatedTotalPages = 0;
|
||||
|
||||
// If we are in the same spine, use the known total pages
|
||||
if (result.spineIndex == currentSpineIndex && totalPagesInCurrentSpine > 0) {
|
||||
estimatedTotalPages = totalPagesInCurrentSpine;
|
||||
}
|
||||
// Otherwise try to estimate based on density from current spine
|
||||
else if (currentSpineIndex >= 0 && currentSpineIndex < epub->getSpineItemsCount() && totalPagesInCurrentSpine > 0) {
|
||||
} else if (currentSpineIndex >= 0 && currentSpineIndex < epub->getSpineItemsCount() && totalPagesInCurrentSpine > 0) {
|
||||
const size_t prevCurrCumSize =
|
||||
(currentSpineIndex > 0) ? epub->getCumulativeSpineItemSize(currentSpineIndex - 1) : 0;
|
||||
const size_t currCumSize = epub->getCumulativeSpineItemSize(currentSpineIndex);
|
||||
@@ -91,24 +131,24 @@ CrossPointPosition ProgressMapper::toCrossPoint(const std::shared_ptr<Epub>& epu
|
||||
|
||||
result.totalPages = estimatedTotalPages;
|
||||
|
||||
if (spineSize > 0 && estimatedTotalPages > 0) {
|
||||
const size_t bytesIntoSpine = (targetBytes > prevCumSize) ? (targetBytes - prevCumSize) : 0;
|
||||
const float intraSpineProgress = static_cast<float>(bytesIntoSpine) / static_cast<float>(spineSize);
|
||||
const float clampedProgress = std::max(0.0f, std::min(1.0f, intraSpineProgress));
|
||||
result.pageNumber = static_cast<int>(clampedProgress * estimatedTotalPages);
|
||||
if (estimatedTotalPages > 0 && resolvedIntraSpineProgress >= 0.0f) {
|
||||
const float clampedProgress = std::max(0.0f, std::min(1.0f, resolvedIntraSpineProgress));
|
||||
result.pageNumber = static_cast<int>(clampedProgress * static_cast<float>(estimatedTotalPages));
|
||||
result.pageNumber = std::max(0, std::min(result.pageNumber, estimatedTotalPages - 1));
|
||||
} else if (spineSize > 0 && estimatedTotalPages > 0) {
|
||||
result.pageNumber = 0;
|
||||
}
|
||||
}
|
||||
|
||||
LOG_DBG("ProgressMapper", "KOReader -> CrossPoint: %.2f%% at %s -> spine=%d, page=%d", koPos.percentage * 100,
|
||||
koPos.xpath.c_str(), result.spineIndex, result.pageNumber);
|
||||
LOG_DBG("ProgressMapper", "KOReader -> CrossPoint: %.2f%% at %s -> spine=%d, page=%d (%s, exact=%s)",
|
||||
koPos.percentage * 100, koPos.xpath.c_str(), result.spineIndex, result.pageNumber,
|
||||
usedXPathMapping ? "xpath" : "percentage", xpathExactMatch ? "yes" : "no");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string ProgressMapper::generateXPath(int spineIndex, int pageNumber, int totalPages) {
|
||||
// Use 0-based DocFragment indices for KOReader
|
||||
// Use a simple xpath pointing to the DocFragment - KOReader will use the percentage for fine positioning within it
|
||||
// Avoid specifying paragraph numbers as they may not exist in the target document
|
||||
return "/body/DocFragment[" + std::to_string(spineIndex) + "]/body";
|
||||
std::string ProgressMapper::generateXPath(int spineIndex) {
|
||||
// Fallback path when element-level XPath extraction is unavailable.
|
||||
// KOReader uses 1-based XPath predicates; spineIndex is 0-based internally.
|
||||
return "/body/DocFragment[" + std::to_string(spineIndex + 1) + "]/body";
|
||||
}
|
||||
|
||||
@@ -27,9 +27,16 @@ struct KOReaderPosition {
|
||||
* CrossPoint tracks position as (spineIndex, pageNumber).
|
||||
* KOReader uses XPath-like strings + percentage.
|
||||
*
|
||||
* Since CrossPoint discards HTML structure during parsing, we generate
|
||||
* synthetic XPath strings based on spine index, using percentage as the
|
||||
* primary sync mechanism.
|
||||
* Forward mapping (CrossPoint -> KOReader):
|
||||
* - Prefer element-level XPath extracted from current spine XHTML.
|
||||
* - Fallback to synthetic chapter XPath if extraction fails.
|
||||
*
|
||||
* Reverse mapping (KOReader -> CrossPoint):
|
||||
* - Prefer incoming XPath (DocFragment + element path) when resolvable.
|
||||
* - Fallback to percentage-based approximation when XPath is missing/invalid.
|
||||
*
|
||||
* This keeps behavior stable on low-memory devices while improving round-trip
|
||||
* sync precision when KOReader provides detailed paths.
|
||||
*/
|
||||
class ProgressMapper {
|
||||
public:
|
||||
@@ -45,8 +52,9 @@ class ProgressMapper {
|
||||
/**
|
||||
* Convert KOReader position to CrossPoint format.
|
||||
*
|
||||
* Note: The returned pageNumber may be approximate since different
|
||||
* rendering settings produce different page counts.
|
||||
* Uses XPath-first resolution when possible and percentage fallback otherwise.
|
||||
* Returned pageNumber can still be approximate because page counts differ
|
||||
* across renderer/font/layout settings.
|
||||
*
|
||||
* @param epub The EPUB book
|
||||
* @param koPos KOReader position
|
||||
@@ -60,8 +68,7 @@ class ProgressMapper {
|
||||
private:
|
||||
/**
|
||||
* Generate XPath for KOReader compatibility.
|
||||
* Format: /body/DocFragment[spineIndex+1]/body
|
||||
* Since CrossPoint doesn't preserve HTML structure, we rely on percentage for positioning.
|
||||
* Fallback format: /body/DocFragment[spineIndex + 1]/body
|
||||
*/
|
||||
static std::string generateXPath(int spineIndex, int pageNumber, int totalPages);
|
||||
static std::string generateXPath(int spineIndex);
|
||||
};
|
||||
|
||||
@@ -137,6 +137,9 @@ void HalFile::flush() { HAL_FILE_WRAPPED_CALL(flush, ); }
|
||||
size_t HalFile::getName(char* name, size_t len) { HAL_FILE_WRAPPED_CALL(getName, name, len); }
|
||||
size_t HalFile::size() { HAL_FILE_FORWARD_CALL(size, ); } // already thread-safe, no need to wrap
|
||||
size_t HalFile::fileSize() { HAL_FILE_FORWARD_CALL(fileSize, ); } // already thread-safe, no need to wrap
|
||||
bool HalFile::getModifyDateTime(uint16_t* pdate, uint16_t* ptime) {
|
||||
HAL_FILE_WRAPPED_CALL(getModifyDateTime, pdate, ptime);
|
||||
}
|
||||
bool HalFile::seek(size_t pos) { HAL_FILE_WRAPPED_CALL(seekSet, pos); }
|
||||
bool HalFile::seekCur(int64_t offset) { HAL_FILE_WRAPPED_CALL(seekCur, offset); }
|
||||
bool HalFile::seekSet(size_t offset) { HAL_FILE_WRAPPED_CALL(seekSet, offset); }
|
||||
|
||||
@@ -76,6 +76,8 @@ class HalFile : public Print {
|
||||
size_t getName(char* name, size_t len);
|
||||
size_t size();
|
||||
size_t fileSize();
|
||||
// Get modification date/time (FAT format: packed 16-bit date and time). Returns false if unavailable.
|
||||
bool getModifyDateTime(uint16_t* pdate, uint16_t* ptime);
|
||||
bool seek(size_t pos);
|
||||
bool seekCur(int64_t offset);
|
||||
bool seekSet(size_t offset);
|
||||
|
||||
@@ -213,44 +213,6 @@ bool JsonSettingsIO::loadSettings(CrossPointSettings& s, const char* json, bool*
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- KOReaderCredentialStore ----
|
||||
|
||||
bool JsonSettingsIO::saveKOReader(const KOReaderCredentialStore& store, const char* path) {
|
||||
JsonDocument doc;
|
||||
doc["username"] = store.getUsername();
|
||||
doc["password_obf"] = obfuscation::obfuscateToBase64(store.getPassword());
|
||||
doc["serverUrl"] = store.getServerUrl();
|
||||
doc["matchMethod"] = static_cast<uint8_t>(store.getMatchMethod());
|
||||
|
||||
String json;
|
||||
serializeJson(doc, json);
|
||||
return Storage.writeFile(path, json);
|
||||
}
|
||||
|
||||
bool JsonSettingsIO::loadKOReader(KOReaderCredentialStore& store, const char* json, bool* needsResave) {
|
||||
if (needsResave) *needsResave = false;
|
||||
JsonDocument doc;
|
||||
auto error = deserializeJson(doc, json);
|
||||
if (error) {
|
||||
LOG_ERR("KRS", "JSON parse error: %s", error.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
store.username = doc["username"] | std::string("");
|
||||
bool ok = false;
|
||||
store.password = obfuscation::deobfuscateFromBase64(doc["password_obf"] | "", &ok);
|
||||
if (!ok || store.password.empty()) {
|
||||
store.password = doc["password"] | std::string("");
|
||||
if (!store.password.empty() && needsResave) *needsResave = true;
|
||||
}
|
||||
store.serverUrl = doc["serverUrl"] | std::string("");
|
||||
uint8_t method = doc["matchMethod"] | (uint8_t)0;
|
||||
store.matchMethod = static_cast<DocumentMatchMethod>(method);
|
||||
|
||||
LOG_DBG("KRS", "Loaded KOReader credentials for user: %s", store.username.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- WifiCredentialStore ----
|
||||
|
||||
bool JsonSettingsIO::saveWifi(const WifiCredentialStore& store, const char* path) {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
class CrossPointSettings;
|
||||
class CrossPointState;
|
||||
class WifiCredentialStore;
|
||||
class KOReaderCredentialStore;
|
||||
class RecentBooksStore;
|
||||
|
||||
namespace JsonSettingsIO {
|
||||
@@ -20,10 +19,6 @@ bool loadState(CrossPointState& s, const char* json);
|
||||
bool saveWifi(const WifiCredentialStore& store, const char* path);
|
||||
bool loadWifi(WifiCredentialStore& store, const char* json, bool* needsResave = nullptr);
|
||||
|
||||
// KOReaderCredentialStore
|
||||
bool saveKOReader(const KOReaderCredentialStore& store, const char* path);
|
||||
bool loadKOReader(KOReaderCredentialStore& store, const char* json, bool* needsResave = nullptr);
|
||||
|
||||
// RecentBooksStore
|
||||
bool saveRecentBooks(const RecentBooksStore& store, const char* path);
|
||||
bool loadRecentBooks(RecentBooksStore& store, const char* json);
|
||||
|
||||
@@ -181,6 +181,10 @@ void ActivityManager::goToBrowser() {
|
||||
replaceActivity(std::make_unique<OpdsBookBrowserActivity>(renderer, mappedInput));
|
||||
}
|
||||
|
||||
void ActivityManager::goToBrowser(const OpdsServer& server) {
|
||||
replaceActivity(std::make_unique<OpdsBookBrowserActivity>(renderer, mappedInput, &server));
|
||||
}
|
||||
|
||||
void ActivityManager::goToReader(std::string path) {
|
||||
replaceActivity(std::make_unique<ReaderActivity>(renderer, mappedInput, std::move(path)));
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
#include "GfxRenderer.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "OpdsServerStore.h"
|
||||
|
||||
class Activity; // forward declaration
|
||||
class RenderLock; // forward declaration
|
||||
@@ -82,6 +83,7 @@ class ActivityManager {
|
||||
void goToFileBrowser(std::string path = {});
|
||||
void goToRecentBooks();
|
||||
void goToBrowser();
|
||||
void goToBrowser(const struct OpdsServer& server);
|
||||
void goToReader(std::string path);
|
||||
void goToSleep();
|
||||
void goToBoot();
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
#include <OpdsStream.h>
|
||||
#include <WiFi.h>
|
||||
|
||||
#include "CrossPointSettings.h"
|
||||
#include "activities/ActivityResult.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "OpdsServerStore.h"
|
||||
#include "activities/network/WifiSelectionActivity.h"
|
||||
#include "activities/util/DirectoryPickerActivity.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "network/HttpDownloader.h"
|
||||
@@ -23,16 +25,22 @@ constexpr int PAGE_ITEMS = 23;
|
||||
void OpdsBookBrowserActivity::onEnter() {
|
||||
Activity::onEnter();
|
||||
|
||||
OPDS_STORE.loadFromFile();
|
||||
// Resolve server from store if not provided at construction
|
||||
if (server.url.empty() && OPDS_STORE.hasServers()) {
|
||||
const auto* s = OPDS_STORE.getServer(0);
|
||||
if (s) server = *s;
|
||||
}
|
||||
|
||||
state = BrowserState::CHECK_WIFI;
|
||||
entries.clear();
|
||||
navigationHistory.clear();
|
||||
currentPath = ""; // Root path - user provides full URL in settings
|
||||
currentPath = "";
|
||||
selectorIndex = 0;
|
||||
errorMessage.clear();
|
||||
statusMessage = tr(STR_CHECKING_WIFI);
|
||||
requestUpdate();
|
||||
|
||||
// Check WiFi and connect if needed, then fetch feed
|
||||
checkAndConnectWifi();
|
||||
}
|
||||
|
||||
@@ -47,9 +55,7 @@ void OpdsBookBrowserActivity::onExit() {
|
||||
}
|
||||
|
||||
void OpdsBookBrowserActivity::loop() {
|
||||
// Handle WiFi selection subactivity
|
||||
if (state == BrowserState::WIFI_SELECTION) {
|
||||
// Should already handled by the WifiSelectionActivity
|
||||
if (state == BrowserState::WIFI_SELECTION || state == BrowserState::PICKING_DIRECTORY) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -91,18 +97,40 @@ void OpdsBookBrowserActivity::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle downloading state - no input allowed
|
||||
if (state == BrowserState::DOWNLOADING) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle browsing state
|
||||
if (state == BrowserState::DOWNLOAD_COMPLETE) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
|
||||
executePromptAction(0);
|
||||
return;
|
||||
}
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
executePromptAction(promptSelection);
|
||||
return;
|
||||
}
|
||||
buttonNavigator.onNextRelease([this] {
|
||||
if (promptSelection != 1) {
|
||||
promptSelection = 1;
|
||||
requestUpdate();
|
||||
}
|
||||
});
|
||||
buttonNavigator.onPreviousRelease([this] {
|
||||
if (promptSelection != 0) {
|
||||
promptSelection = 0;
|
||||
requestUpdate();
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (state == BrowserState::BROWSING) {
|
||||
if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
|
||||
if (!entries.empty()) {
|
||||
const auto& entry = entries[selectorIndex];
|
||||
if (entry.type == OpdsEntryType::BOOK) {
|
||||
downloadBook(entry);
|
||||
launchDirectoryPicker(entry);
|
||||
} else {
|
||||
navigateToEntry(entry);
|
||||
}
|
||||
@@ -142,7 +170,8 @@ void OpdsBookBrowserActivity::render(RenderLock&&) {
|
||||
const auto pageWidth = renderer.getScreenWidth();
|
||||
const auto pageHeight = renderer.getScreenHeight();
|
||||
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 15, tr(STR_OPDS_BROWSER), true, EpdFontFamily::BOLD);
|
||||
const char* headerTitle = server.name.empty() ? tr(STR_OPDS_BROWSER) : server.name.c_str();
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, 15, headerTitle, true, EpdFontFamily::BOLD);
|
||||
|
||||
if (state == BrowserState::CHECK_WIFI) {
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str());
|
||||
@@ -186,6 +215,31 @@ void OpdsBookBrowserActivity::render(RenderLock&&) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state == BrowserState::DOWNLOAD_COMPLETE) {
|
||||
renderer.drawCenteredText(UI_12_FONT_ID, pageHeight / 2 - 50, tr(STR_DOWNLOAD_COMPLETE), true, EpdFontFamily::BOLD);
|
||||
const auto maxWidth = pageWidth - 40;
|
||||
auto title = renderer.truncatedText(UI_10_FONT_ID, statusMessage.c_str(), maxWidth);
|
||||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 20, title.c_str());
|
||||
|
||||
const int buttonY = pageHeight / 2 + 20;
|
||||
const char* backText = tr(STR_BACK_TO_LISTING);
|
||||
const char* openText = tr(STR_OPEN_BOOK);
|
||||
std::string backLabel = promptSelection == 0 ? "[" + std::string(backText) + "]" : std::string(backText);
|
||||
std::string openLabel = promptSelection == 1 ? "[" + std::string(openText) + "]" : std::string(openText);
|
||||
const int backWidth = renderer.getTextWidth(UI_10_FONT_ID, backLabel.c_str());
|
||||
const int openWidth = renderer.getTextWidth(UI_10_FONT_ID, openLabel.c_str());
|
||||
constexpr int buttonSpacing = 40;
|
||||
const int totalWidth = backWidth + buttonSpacing + openWidth;
|
||||
const int startX = (pageWidth - totalWidth) / 2;
|
||||
renderer.drawText(UI_10_FONT_ID, startX, buttonY, backLabel.c_str());
|
||||
renderer.drawText(UI_10_FONT_ID, startX + backWidth + buttonSpacing, buttonY, openLabel.c_str());
|
||||
|
||||
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_CONFIRM), tr(STR_DIR_LEFT), tr(STR_DIR_RIGHT));
|
||||
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
||||
renderer.displayBuffer();
|
||||
return;
|
||||
}
|
||||
|
||||
// Browsing state
|
||||
// Show appropriate button hint based on selected entry type
|
||||
const char* confirmLabel = tr(STR_OPEN);
|
||||
@@ -228,22 +282,21 @@ void OpdsBookBrowserActivity::render(RenderLock&&) {
|
||||
}
|
||||
|
||||
void OpdsBookBrowserActivity::fetchFeed(const std::string& path) {
|
||||
const char* serverUrl = SETTINGS.opdsServerUrl;
|
||||
if (strlen(serverUrl) == 0) {
|
||||
if (server.url.empty()) {
|
||||
state = BrowserState::ERROR;
|
||||
errorMessage = tr(STR_NO_SERVER_URL);
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
std::string url = UrlUtils::buildUrl(serverUrl, path);
|
||||
std::string url = UrlUtils::buildUrl(server.url, path);
|
||||
LOG_DBG("OPDS", "Fetching: %s", url.c_str());
|
||||
|
||||
OpdsParser parser;
|
||||
|
||||
{
|
||||
OpdsParserStream stream{parser};
|
||||
if (!HttpDownloader::fetchUrl(url, stream)) {
|
||||
if (!HttpDownloader::fetchUrl(url, stream, server.username, server.password)) {
|
||||
state = BrowserState::ERROR;
|
||||
errorMessage = tr(STR_FETCH_FEED_FAILED);
|
||||
requestUpdate();
|
||||
@@ -306,41 +359,68 @@ void OpdsBookBrowserActivity::navigateBack() {
|
||||
}
|
||||
}
|
||||
|
||||
void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
|
||||
void OpdsBookBrowserActivity::launchDirectoryPicker(const OpdsEntry& book) {
|
||||
pendingBook = book;
|
||||
state = BrowserState::PICKING_DIRECTORY;
|
||||
requestUpdate();
|
||||
|
||||
startActivityForResult(
|
||||
std::make_unique<DirectoryPickerActivity>(renderer, mappedInput, server.downloadPath),
|
||||
[this](const ActivityResult& result) { onDirectoryPickerResult(result); });
|
||||
}
|
||||
|
||||
void OpdsBookBrowserActivity::onDirectoryPickerResult(const ActivityResult& result) {
|
||||
state = BrowserState::BROWSING;
|
||||
if (result.isCancelled) {
|
||||
requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (std::holds_alternative<KeyboardResult>(result.data)) {
|
||||
const std::string& directory = std::get<KeyboardResult>(result.data).text;
|
||||
downloadBook(pendingBook, directory);
|
||||
} else {
|
||||
requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book, const std::string& directory) {
|
||||
state = BrowserState::DOWNLOADING;
|
||||
statusMessage = book.title;
|
||||
downloadProgress = 0;
|
||||
downloadTotal = 0;
|
||||
requestUpdate(true);
|
||||
|
||||
// Build full download URL
|
||||
std::string downloadUrl = UrlUtils::buildUrl(SETTINGS.opdsServerUrl, book.href);
|
||||
std::string downloadUrl = UrlUtils::buildUrl(server.url, book.href);
|
||||
|
||||
// Create sanitized filename: "Title - Author.epub" or just "Title.epub" if no author
|
||||
std::string baseName = book.title;
|
||||
if (!book.author.empty()) {
|
||||
baseName += " - " + book.author;
|
||||
}
|
||||
std::string filename = "/" + StringUtils::sanitizeFilename(baseName) + ".epub";
|
||||
std::string dir = directory;
|
||||
if (dir.back() != '/') dir += '/';
|
||||
std::string filename = dir + StringUtils::sanitizeFilename(baseName) + ".epub";
|
||||
|
||||
LOG_DBG("OPDS", "Downloading: %s -> %s", downloadUrl.c_str(), filename.c_str());
|
||||
|
||||
const auto result =
|
||||
HttpDownloader::downloadToFile(downloadUrl, filename, [this](const size_t downloaded, const size_t total) {
|
||||
const auto result = HttpDownloader::downloadToFile(
|
||||
downloadUrl, filename,
|
||||
[this](const size_t downloaded, const size_t total) {
|
||||
downloadProgress = downloaded;
|
||||
downloadTotal = total;
|
||||
requestUpdate(true); // Force update to refresh progress bar
|
||||
});
|
||||
requestUpdate(true);
|
||||
},
|
||||
server.username, server.password);
|
||||
|
||||
if (result == HttpDownloader::OK) {
|
||||
LOG_DBG("OPDS", "Download complete: %s", filename.c_str());
|
||||
|
||||
// Invalidate any existing cache for this file to prevent stale metadata issues
|
||||
Epub epub(filename, "/.crosspoint");
|
||||
epub.clearCache();
|
||||
LOG_DBG("OPDS", "Cleared cache for: %s", filename.c_str());
|
||||
|
||||
state = BrowserState::BROWSING;
|
||||
downloadedFilePath = filename;
|
||||
promptSelection = server.afterDownloadAction;
|
||||
state = BrowserState::DOWNLOAD_COMPLETE;
|
||||
requestUpdate();
|
||||
} else {
|
||||
state = BrowserState::ERROR;
|
||||
@@ -349,6 +429,15 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) {
|
||||
}
|
||||
}
|
||||
|
||||
void OpdsBookBrowserActivity::executePromptAction(int action) {
|
||||
if (action == 1) {
|
||||
onSelectBook(downloadedFilePath);
|
||||
return;
|
||||
}
|
||||
state = BrowserState::BROWSING;
|
||||
requestUpdate();
|
||||
}
|
||||
|
||||
void OpdsBookBrowserActivity::checkAndConnectWifi() {
|
||||
// Already connected? Verify connection is valid by checking IP
|
||||
if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "../Activity.h"
|
||||
#include "OpdsServerStore.h"
|
||||
#include "util/ButtonNavigator.h"
|
||||
|
||||
/**
|
||||
@@ -16,16 +17,19 @@
|
||||
class OpdsBookBrowserActivity final : public Activity {
|
||||
public:
|
||||
enum class BrowserState {
|
||||
CHECK_WIFI, // Checking WiFi connection
|
||||
WIFI_SELECTION, // WiFi selection subactivity is active
|
||||
LOADING, // Fetching OPDS feed
|
||||
BROWSING, // Displaying entries (navigation or books)
|
||||
DOWNLOADING, // Downloading selected EPUB
|
||||
ERROR // Error state with message
|
||||
CHECK_WIFI, // Checking WiFi connection
|
||||
WIFI_SELECTION, // WiFi selection subactivity is active
|
||||
LOADING, // Fetching OPDS feed
|
||||
BROWSING, // Displaying entries (navigation or books)
|
||||
PICKING_DIRECTORY, // Directory picker subactivity is active
|
||||
DOWNLOADING, // Downloading selected EPUB
|
||||
DOWNLOAD_COMPLETE, // Prompt: open book or go back to listing
|
||||
ERROR // Error state with message
|
||||
};
|
||||
|
||||
explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput)
|
||||
: Activity("OpdsBookBrowser", renderer, mappedInput) {}
|
||||
explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
|
||||
const OpdsServer* server = nullptr)
|
||||
: Activity("OpdsBookBrowser", renderer, mappedInput), server(server ? *server : OpdsServer{}) {}
|
||||
|
||||
void onEnter() override;
|
||||
void onExit() override;
|
||||
@@ -43,6 +47,11 @@ class OpdsBookBrowserActivity final : public Activity {
|
||||
std::string statusMessage;
|
||||
size_t downloadProgress = 0;
|
||||
size_t downloadTotal = 0;
|
||||
std::string downloadedFilePath;
|
||||
int promptSelection = 0; // 0 = back to listing, 1 = open book
|
||||
|
||||
OpdsServer server; // Copied at construction — safe even if the store changes during browsing
|
||||
OpdsEntry pendingBook;
|
||||
|
||||
void checkAndConnectWifi();
|
||||
void launchWifiSelection();
|
||||
@@ -50,6 +59,9 @@ class OpdsBookBrowserActivity final : public Activity {
|
||||
void fetchFeed(const std::string& path);
|
||||
void navigateToEntry(const OpdsEntry& entry);
|
||||
void navigateBack();
|
||||
void downloadBook(const OpdsEntry& book);
|
||||
void launchDirectoryPicker(const OpdsEntry& book);
|
||||
void onDirectoryPickerResult(const ActivityResult& result);
|
||||
void downloadBook(const OpdsEntry& book, const std::string& directory);
|
||||
void executePromptAction(int action);
|
||||
bool preventAutoSleep() override { return true; }
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
#include <GfxRenderer.h>
|
||||
#include <I18n.h>
|
||||
|
||||
#include "ActivityResult.h"
|
||||
#include "activities/ActivityResult.h"
|
||||
#include "MappedInputManager.h"
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
|
||||
@@ -244,6 +244,15 @@ std::string getFileName(std::string filename) {
|
||||
return filename.substr(0, pos);
|
||||
}
|
||||
|
||||
std::string getFileExtension(std::string filename) {
|
||||
if (filename.back() == '/') {
|
||||
return "";
|
||||
}
|
||||
const auto pos = filename.rfind('.');
|
||||
if (pos == std::string::npos) return "";
|
||||
return filename.substr(pos);
|
||||
}
|
||||
|
||||
void FileBrowserActivity::render(RenderLock&&) {
|
||||
renderer.clearScreen();
|
||||
|
||||
@@ -262,7 +271,8 @@ void FileBrowserActivity::render(RenderLock&&) {
|
||||
GUI.drawList(
|
||||
renderer, Rect{0, contentTop, pageWidth, contentHeight}, files.size(), selectorIndex,
|
||||
[this](int index) { return getFileName(files[index]); }, nullptr,
|
||||
[this](int index) { return UITheme::getFileIcon(files[index]); });
|
||||
[this](int index) { return UITheme::getFileIcon(files[index]); },
|
||||
[this](int index) { return getFileExtension(files[index]); }, false);
|
||||
}
|
||||
|
||||
// Help text
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
#include "components/UITheme.h"
|
||||
#include "fontIds.h"
|
||||
#include "util/BookManager.h"
|
||||
#include "util/StringUtils.h"
|
||||
|
||||
|
||||
int HomeActivity::getMenuItemCount() const {
|
||||
int count = 4; // File Browser, Recents, File transfer, Settings
|
||||
@@ -66,7 +66,7 @@ void HomeActivity::loadRecentCovers(int coverHeight) {
|
||||
for (RecentBook& book : recentBooks) {
|
||||
if (!book.coverBmpPath.empty()) {
|
||||
std::string coverPath = UITheme::getCoverThumbPath(book.coverBmpPath, coverHeight);
|
||||
if (!Epub::isValidThumbnailBmp(coverPath)) {
|
||||
if (!Storage.exists(coverPath.c_str())) {
|
||||
if (!showingLoading) {
|
||||
showingLoading = true;
|
||||
popupRect = GUI.drawPopup(renderer, tr(STR_LOADING));
|
||||
@@ -75,7 +75,7 @@ void HomeActivity::loadRecentCovers(int coverHeight) {
|
||||
|
||||
bool success = false;
|
||||
|
||||
if (StringUtils::checkFileExtension(book.path, ".epub")) {
|
||||
if (FsHelpers::hasEpubExtension(book.path)) {
|
||||
Epub epub(book.path, "/.crosspoint");
|
||||
if (!epub.load(false, true)) {
|
||||
epub.load(true, true);
|
||||
@@ -87,13 +87,9 @@ void HomeActivity::loadRecentCovers(int coverHeight) {
|
||||
book.coverBmpPath = thumbPath;
|
||||
} else {
|
||||
const int thumbWidth = static_cast<int>(coverHeight * 0.6);
|
||||
success = PlaceholderCoverGenerator::generate(coverPath, book.title, book.author, thumbWidth, coverHeight);
|
||||
if (!success) {
|
||||
epub.generateInvalidFormatThumbBmp(coverHeight);
|
||||
}
|
||||
PlaceholderCoverGenerator::generate(coverPath, book.title, book.author, thumbWidth, coverHeight);
|
||||
}
|
||||
} else if (StringUtils::checkFileExtension(book.path, ".xtch") ||
|
||||
StringUtils::checkFileExtension(book.path, ".xtc")) {
|
||||
} else if (FsHelpers::hasXtcExtension(book.path)) {
|
||||
Xtc xtc(book.path, "/.crosspoint");
|
||||
if (xtc.load()) {
|
||||
success = xtc.generateThumbBmp(coverHeight);
|
||||
|
||||
@@ -52,8 +52,24 @@ class FileWriteStream final : public Stream {
|
||||
};
|
||||
} // namespace
|
||||
|
||||
static void addAuthHeader(HTTPClient& http, const std::string& username, const std::string& password) {
|
||||
if (!username.empty() || !password.empty()) {
|
||||
std::string credentials = username + ":" + password;
|
||||
String encoded = base64::encode(credentials.c_str());
|
||||
http.addHeader("Authorization", "Basic " + encoded);
|
||||
} else if (strlen(SETTINGS.opdsUsername) > 0 && strlen(SETTINGS.opdsPassword) > 0) {
|
||||
std::string credentials = std::string(SETTINGS.opdsUsername) + ":" + SETTINGS.opdsPassword;
|
||||
String encoded = base64::encode(credentials.c_str());
|
||||
http.addHeader("Authorization", "Basic " + encoded);
|
||||
}
|
||||
}
|
||||
|
||||
bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent) {
|
||||
// Use NetworkClientSecure for HTTPS, regular NetworkClient for HTTP
|
||||
return fetchUrl(url, outContent, "", "");
|
||||
}
|
||||
|
||||
bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent, const std::string& username,
|
||||
const std::string& password) {
|
||||
std::unique_ptr<NetworkClient> client;
|
||||
if (UrlUtils::isHttpsUrl(url)) {
|
||||
auto* secureClient = new NetworkClientSecure();
|
||||
@@ -69,13 +85,7 @@ bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent) {
|
||||
http.begin(*client, url.c_str());
|
||||
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
|
||||
http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION);
|
||||
|
||||
// Add Basic HTTP auth if credentials are configured
|
||||
if (strlen(SETTINGS.opdsUsername) > 0 && strlen(SETTINGS.opdsPassword) > 0) {
|
||||
std::string credentials = std::string(SETTINGS.opdsUsername) + ":" + SETTINGS.opdsPassword;
|
||||
String encoded = base64::encode(credentials.c_str());
|
||||
http.addHeader("Authorization", "Basic " + encoded);
|
||||
}
|
||||
addAuthHeader(http, username, password);
|
||||
|
||||
const int httpCode = http.GET();
|
||||
if (httpCode != HTTP_CODE_OK) {
|
||||
@@ -85,7 +95,6 @@ bool HttpDownloader::fetchUrl(const std::string& url, Stream& outContent) {
|
||||
}
|
||||
|
||||
http.writeToStream(&outContent);
|
||||
|
||||
http.end();
|
||||
|
||||
LOG_DBG("HTTP", "Fetch success");
|
||||
@@ -103,7 +112,12 @@ bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent) {
|
||||
|
||||
HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string& url, const std::string& destPath,
|
||||
ProgressCallback progress) {
|
||||
// Use NetworkClientSecure for HTTPS, regular NetworkClient for HTTP
|
||||
return downloadToFile(url, destPath, progress, "", "");
|
||||
}
|
||||
|
||||
HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string& url, const std::string& destPath,
|
||||
ProgressCallback progress, const std::string& username,
|
||||
const std::string& password) {
|
||||
std::unique_ptr<NetworkClient> client;
|
||||
if (UrlUtils::isHttpsUrl(url)) {
|
||||
auto* secureClient = new NetworkClientSecure();
|
||||
@@ -120,13 +134,7 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string&
|
||||
http.begin(*client, url.c_str());
|
||||
http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
|
||||
http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION);
|
||||
|
||||
// Add Basic HTTP auth if credentials are configured
|
||||
if (strlen(SETTINGS.opdsUsername) > 0 && strlen(SETTINGS.opdsPassword) > 0) {
|
||||
std::string credentials = std::string(SETTINGS.opdsUsername) + ":" + SETTINGS.opdsPassword;
|
||||
String encoded = base64::encode(credentials.c_str());
|
||||
http.addHeader("Authorization", "Basic " + encoded);
|
||||
}
|
||||
addAuthHeader(http, username, password);
|
||||
|
||||
const int httpCode = http.GET();
|
||||
if (httpCode != HTTP_CODE_OK) {
|
||||
|
||||
@@ -29,6 +29,13 @@ class HttpDownloader {
|
||||
|
||||
static bool fetchUrl(const std::string& url, Stream& stream);
|
||||
|
||||
/**
|
||||
* Fetch URL with optional HTTP Basic auth credentials.
|
||||
* When username and password are empty, falls back to CrossPointSettings credentials.
|
||||
*/
|
||||
static bool fetchUrl(const std::string& url, Stream& stream, const std::string& username,
|
||||
const std::string& password);
|
||||
|
||||
/**
|
||||
* Download a file to the SD card.
|
||||
* @param url The URL to download
|
||||
@@ -38,4 +45,12 @@ class HttpDownloader {
|
||||
*/
|
||||
static DownloadError downloadToFile(const std::string& url, const std::string& destPath,
|
||||
ProgressCallback progress = nullptr);
|
||||
|
||||
/**
|
||||
* Download a file with optional HTTP Basic auth credentials.
|
||||
* When username and password are empty, falls back to CrossPointSettings credentials.
|
||||
*/
|
||||
static DownloadError downloadToFile(const std::string& url, const std::string& destPath,
|
||||
ProgressCallback progress, const std::string& username,
|
||||
const std::string& password);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user