diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d14561a504..a7088088ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,37 +65,21 @@ jobs: run: pio check --fail-on-defect low --fail-on-defect medium --fail-on-defect high build: - runs-on: ubuntu-latest + # Never run untrusted fork PR code on the self-hosted runner. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: [self-hosted, linux, crossink-build] steps: - uses: actions/checkout@v6 with: submodules: recursive - - uses: actions/setup-python@v6 - with: - python-version: "3.14" - - - name: Install uv - uses: astral-sh/setup-uv@v7 - with: - version: "latest" - enable-cache: true - - - name: Cache PlatformIO packages - uses: actions/cache@v4 - with: - path: ~/.platformio - key: ${{ runner.os }}-platformio-${{ hashFiles('platformio.ini', 'platformio.local.example.ini') }} - restore-keys: | - ${{ runner.os }}-platformio- - - - name: Install PlatformIO Core - run: uv pip install --system -U https://github.com/pioarduino/platformio-core/archive/refs/tags/v6.1.19.zip + - name: Verify PlatformIO Core + run: pio --version - - name: Build CrossInk + - name: Build CrossInk tiny run: | set -euo pipefail - pio run | tee pio.log + pio run -e tiny | tee pio.log - name: Extract firmware stats run: | @@ -108,12 +92,9 @@ jobs: - name: Upload firmware artifacts uses: actions/upload-artifact@v7 with: - name: firmware + name: firmware-tiny path: | - .pio/build/teensy/firmware-teensy.bin .pio/build/tiny/firmware-tiny.bin - .pio/build/xlarge/firmware-xlarge.bin - .pio/build/no_emoji/firmware-no_emoji.bin if-no-files-found: error # This job is used as the PR required actions check, allows for changes to other steps in the future without breaking diff --git a/CHANGELOG.md b/CHANGELOG.md index dcf85c597d..cd5e6fe432 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,33 +3,41 @@ ## [Unreleased] ### Added +- Added an adjustable reader line-height setting with percent-based spacing for EPUB and TXT books. - Added a Recent Books long-press menu in both List and Grid views with delete, cache delete, completion, and remove-from-recents actions. - Added a Minimal sleep screen option that shows the current book cover and reading progress on a dark background. +- Added aggregate all-time Reading Stats support from peer-synced per-device stats files. - Added an in-reader confirmation message when a shortcut turns tilt-to-turn on or off. - Added a 9pt `Itty Bitty` reader font size, plus build flags for omitting Itty Bitty and Large reader font assets in size-constrained firmware variants. + +### Fixed +- Fixed Lyra Carousel popup rendering so loading, indexing, and sleep-entry popups appear in the right place again. +- Improved OPDS book download throughput by using a larger transfer buffer while keeping SD-card font downloads on the lower-memory path. +- Fixed OPDS feed errors so low-memory parser-buffer failures show the specific memory message instead of the generic parse error. +- Free the active SD-card reader font before opening OPDS catalogs so WiFi/feed parsing has more memory available. + +### Changed + +## [v1.3.0] - 2026-05-21 + +### Added - Added Back/Cancel support while downloading books from OPDS catalogs. +- Added a Recent Books long-press menu in both List and Grid views with delete, cache delete, completion, and remove-from-recents actions. +- Added a Minimal sleep screen option that shows the current book cover and reading progress on a dark background. +- Added more detailed WiFi connection debug logs for scans, selected networks, status changes, disconnect reasons, and timeouts. +- Added a 9pt `Itty Bitty` reader font size, plus build flags for omitting Itty Bitty and Large reader font assets in size-constrained firmware variants. +- Added an in-reader confirmation message when a shortcut turns tilt-to-turn on or off. ### Fixed -- Fixed the in-reader Customise Status Bar screen in landscape so the list no longer extends under the button labels. -- Fixed manual WiFi connections from Settings returning immediately to the settings list after a saved-password or open-network connection succeeded, so the connected status and IP address are shown first. -- Fixed copied or corrupted saved password files being treated as valid credentials by validating device-specific password data before using it. -- Fixed missing Vietnamese labels for the sleep timeout resume settings. -- Fixed File Browser and Lyra Carousel icon alignment issues in icon-based themes. -- Reduced grid-like and over-zoomed artifacts on Lyra Carousel and Minimal theme's EPUB cover thumbnails by cropping normal covers before dithering while containing unusual cover ratios. -- Reduced duplicate Home progress/stat loading when returning from another screen. -- Fixed EPUB cache folder keys so they use a stable path hash across firmware builds, migrate older cache folders when possible, and rebuild stale section caches for the latest low-memory layout fixes. -- Improved low-memory EPUB handling by laying out very long text blocks earlier, streaming table fallback content when heap is tight, failing safely on large OPDS feeds instead of rebooting, and clarifying the warning text. -- Reduced sleep-entry memory and battery risk by reusing cached sleep-screen assets, idling OPDS pages normally after load, and putting the X3 tilt sensor back to sleep outside the reader. -- Improved network and SD-card font download reliability by disabling WiFi power saving during transfers, reducing WebDAV stack usage, tolerating longer stalls, retrying interrupted font files, and freeing active reader fonts when needed. -- Fixed a crash when opening the XTC chapter selector on memory-constrained builds. -- Fixed the Font Size setting to follow the actual sizes installed for the selected SD-card font family. -- Relaxed KOReader Sync auth response validation so compatible self-hosted servers that return valid JSON on successful login can authenticate. +- Fixed WiFi and OPDS connection-flow edge cases so manual Settings connections show the connected status first, copied or corrupted saved-password files are rejected before use, OPDS retries show loading before requests, and large OPDS feeds fail safely under low memory instead of rebooting. +- Fixed reader and Home UI polish issues, including landscape status-bar settings, missing Vietnamese labels, File Browser and Lyra Carousel icon alignment, cover thumbnail artifacts, and duplicate Home progress/stat loading. +- Fixed EPUB cache and low-memory handling by using stable cache folder keys, migrating older cache folders where possible, rebuilding stale section caches, laying out very long text blocks earlier, streaming table fallback content when heap is tight, and clarifying the warning text. +- Fixed sleep-entry, network, and SD-card font download reliability issues by reusing cached sleep-screen assets, idling OPDS pages normally after load, putting the X3 tilt sensor back to sleep outside the reader, disabling WiFi power saving during transfers, reducing WebDAV stack usage, tolerating longer stalls, retrying interrupted font files, and freeing active reader fonts when needed. +- Fixed remaining reader service edge cases, including an XTC chapter selector crash on memory-constrained builds, SD-card font size selection, SD-card font-size shortcuts skipping manually installed sizes, and KOReader Sync login compatibility with self-hosted servers that return valid JSON on success. ### Changed -- Moved the full-time page-as-sleep behavior into a new `Sleep Screen > Quick Resume` option, which also keeps `Quick Resume on Timeout` on, and renamed the timeout-only toggle. -- Moved the in-reader Footnotes shortcut above Select Chapter when footnotes are available on the current page. -- Made book titles in the file browser's long-press action menu smaller and allowed them to wrap to two lines so longer book names are easier to read. -- Reduced unnecessary screen refresh and list-clearing work during OPDS browsing and SD font downloads so transfers spend more time downloading and less time repainting progress. +- Modified upstream "page-as-sleep" behavior into a new `Sleep Screen > Quick Resume` option, which also keeps `Quick Resume on Timeout` on, and renamed the timeout-only toggle. +- Improved reader and browser menu behavior by moving the Footnotes shortcut above Select Chapter, wrapping long book titles in action menus, and reducing progress-screen repaint work during OPDS and SD font downloads. ## [v1.2.11.1] - 2026-05-15 diff --git a/README.md b/README.md index ef89eae291..fc1ae5b56d 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,10 @@ My goal with this fork was to maintain the core Crosspoint firmware while integr - New reader fonts: ChareInk, Lexend Deca, and Bitter - Unicode emoji and miscellaneous symbols support (a limited subset) -- Adjusted font sizes: Teensy (8pt), Tiny (10pt), Small (12pt), Medium (14pt), Large (16pt), Extra Large (18pt), Huge (20pt). See [Font Sizes](#font-sizes) for more details. +- Adjusted font sizes: Teensy (8pt), Itty Bitty (9pt), Tiny (10pt), Small (12pt), Medium (14pt), Large (16pt), Extra Large (18pt), Huge (20pt). See [Font Sizes](#font-sizes) for more details. - Added ~~strikethrough~~ support - Made underlines thicker for better visibility +- Added a custom `Minimal` theme and sleep screen option for the minimalists out there. - Added support for `
` section breaks - Added support for "redaction" style rendering - Added improved support for tables with simple markup @@ -68,7 +69,7 @@ The UI now uses [Inter](https://fonts.google.com/specimen/Inter) as the display ### Font Sizes -There are 3 available build variants to choose from due to build size constraints: tiny, xlarge, and no_emoji +There are 4 available build variants to choose from due to build size constraints: tiny, xlarge, and no_emoji **teensy** > Only the small sized fonts. @@ -189,6 +190,13 @@ Some simple per-book reading stats are tracked automatically and displayed in tw - Average session time - All time reading stats including total number of books read +To include all-time totals from other CrossInk devices, create or sync a +`.crosspoint/synced_stats/` folder between devices. When that folder exists, +each reader writes its own `device_.bin` contribution file and ignores that +file while summing the folder, so any device can display the aggregate total +without becoming the main device. If the folder is not present, the reader only +uses its local `global_stats.bin`. + **Home screen book card (Lyra theme only):** - Total reading time @@ -371,6 +379,7 @@ The structure is roughly: .crosspoint/ ├── global_stats.bin # All-time reading stats, including total books read ├── global_stats.bin.bak # Backup used if the main global stats file is corrupt +├── synced_stats/ # One per-device stats contribution file for aggregate all-time totals ├── settings.bin # Device settings ├── state.bin # Last-opened book and sleep/session state ├── recent.bin # Recent books list diff --git a/docs/catalog b/docs/catalog index d6f0a02a29..806d5514bf 100644 --- a/docs/catalog +++ b/docs/catalog @@ -2,48 +2,64 @@ "schema_version": 1, "releases": [ { - "id": "stable-1.2.11.1-tiny", + "id": "stable-1.3.0-teensy", "channel": "stable", - "name": "1.2.11.1", - "version": "1.2.11.1", + "name": "1.3.0", + "version": "1.3.0", + "variant": "teensy", + "released_at": "2026-05-21T23:44:31Z", + "notes": "CrossInk 1.3.0 stable firmware", + "firmware_url": "https://github.com/uxjulia/CrossInk/releases/latest/download/firmware-teensy-v1.3.0.bin", + "firmware_sha256": "45df9a2839a25ec3f8fccf4955c321f4f3a5b09310fd68097bc6efcf27752047", + "size": 5569744, + "supported_devices": [ + "x4", + "x3" + ] + }, + { + "id": "stable-1.3.0-tiny", + "channel": "stable", + "name": "1.3.0", + "version": "1.3.0", "variant": "tiny", - "released_at": "2026-05-15T20:15:25Z", - "notes": "CrossInk 1.2.11.1 stable firmware", - "firmware_url": "https://github.com/uxjulia/CrossInk/releases/latest/download/firmware-tiny-v1.2.11.1.bin", - "firmware_sha256": "23f196428ac01e3fce78c17838914e7bb9969ae0d87540df60fc6a36abede506", - "size": 6491440, + "released_at": "2026-05-21T23:44:31Z", + "notes": "CrossInk 1.3.0 stable firmware", + "firmware_url": "https://github.com/uxjulia/CrossInk/releases/latest/download/firmware-tiny-v1.3.0.bin", + "firmware_sha256": "b4a046c56e5fbd7e1600499c2e97aa4311c727cebdbb3f8fd4c5a6bab3083b04", + "size": 6023264, "supported_devices": [ "x4", "x3" ] }, { - "id": "stable-1.2.11.1-xlarge", + "id": "stable-1.3.0-xlarge", "channel": "stable", - "name": "1.2.11.1", - "version": "1.2.11.1", + "name": "1.3.0", + "version": "1.3.0", "variant": "xlarge", - "released_at": "2026-05-15T20:15:25Z", - "notes": "CrossInk 1.2.11.1 stable firmware", - "firmware_url": "https://github.com/uxjulia/CrossInk/releases/latest/download/firmware-xlarge-v1.2.11.1.bin", - "firmware_sha256": "49f9b58ed4d845a6fe30da32f47caef2bbff6dd47195e84df57f6b5a95c0239c", - "size": 5829952, + "released_at": "2026-05-21T23:44:31Z", + "notes": "CrossInk 1.3.0 stable firmware", + "firmware_url": "https://github.com/uxjulia/CrossInk/releases/latest/download/firmware-xlarge-v1.3.0.bin", + "firmware_sha256": "2e69f685728fcb7b9245171e53fb0c1dd5c1dc805634e9458c1f54a7cef8f7c6", + "size": 5892288, "supported_devices": [ "x4", "x3" ] }, { - "id": "stable-1.2.11.1-no_emoji", + "id": "stable-1.3.0-no_emoji", "channel": "stable", - "name": "1.2.11.1", - "version": "1.2.11.1", + "name": "1.3.0", + "version": "1.3.0", "variant": "no_emoji", - "released_at": "2026-05-15T20:15:25Z", - "notes": "CrossInk 1.2.11.1 stable firmware", - "firmware_url": "https://github.com/uxjulia/CrossInk/releases/latest/download/firmware-no_emoji-v1.2.11.1.bin", - "firmware_sha256": "19a7997f4f636c3a56b52243c7290668b3b67d91ecf43525a2425ed22d1fb793", - "size": 6219440, + "released_at": "2026-05-21T23:44:31Z", + "notes": "CrossInk 1.3.0 stable firmware", + "firmware_url": "https://github.com/uxjulia/CrossInk/releases/latest/download/firmware-no_emoji-v1.3.0.bin", + "firmware_sha256": "9574828a5d36636aea1f4cf8ee67f263f13c4552f2893267bbf0e4c17c1010dd", + "size": 6281840, "supported_devices": [ "x4", "x3" diff --git a/docs/file-formats.md b/docs/file-formats.md index b6e98d8ea2..9fc274fd90 100644 --- a/docs/file-formats.md +++ b/docs/file-formats.md @@ -5,6 +5,38 @@ nav_order: 8 # File Formats +## `global_stats.bin` + +`/.crosspoint/global_stats.bin` stores this device's all-time reading counters. +If `/.crosspoint/synced_stats/` already exists, saves also mirror the same +counters to `/.crosspoint/synced_stats/device_.bin`, where `` is the +device's hardware MAC address without separators. The reader does not create +this folder on its own. + +The `/.crosspoint/synced_stats/` directory is designed for peer-to-peer folder +sync: each device owns one contribution file, and display-only Reading Stats +views add every other device's contribution to this device's local +`global_stats.bin`. This device's own contribution file is skipped while +aggregating so mirroring the folder back to the same device does not double +count its local stats. + +### Version 2 + +Adds `completedBooks` after the original counters. + +```text +[0] version (= 2) +[1-4] totalSessions uint32 little-endian +[5-8] totalReadingSeconds uint32 little-endian +[9-12] totalPagesTurned uint32 little-endian +[13-16] completedBooks uint32 little-endian +``` + +### Version 1 + +Version 1 files are still readable. They are 13 bytes long and do not include +`completedBooks`, so the reader treats that value as zero. + ## `book.bin` ### Version 6 diff --git a/lib/I18n/translations/english.yaml b/lib/I18n/translations/english.yaml index e7270b6263..67a52194e0 100644 --- a/lib/I18n/translations/english.yaml +++ b/lib/I18n/translations/english.yaml @@ -209,6 +209,7 @@ STR_UNNAMED: "Unnamed" STR_NO_SERVER_URL: "No server URL configured" STR_FETCH_FEED_FAILED: "Failed to fetch feed" STR_PARSE_FEED_FAILED: "Failed to parse feed" +STR_OPDS_FEED_BUFFER_MEMORY_ERROR: "Couldn't allocate memory for buffer" STR_NEXT_PAGE: "Next Page »" STR_PREV_PAGE: "« Previous Page" STR_NETWORK_PREFIX: "Network: " diff --git a/lib/OpdsParser/OpdsParser.cpp b/lib/OpdsParser/OpdsParser.cpp index b2737b0ce6..70073417d8 100644 --- a/lib/OpdsParser/OpdsParser.cpp +++ b/lib/OpdsParser/OpdsParser.cpp @@ -10,6 +10,7 @@ OpdsParser::OpdsParser(OpdsEntry* entries, const size_t entryCapacity) : entries(entries), entryCapacity(entryCapacity) { if (!entries || entryCapacity == 0) { errorOccured = true; + errorReason = OpdsParserError::NO_ENTRY_BUFFER; LOG_DBG("OPDS", "No entry buffer supplied"); } @@ -27,6 +28,7 @@ size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) { } if (!xmlData && length > 0) { errorOccured = true; + errorReason = OpdsParserError::INVALID_INPUT; return length; } @@ -38,6 +40,7 @@ size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) { void* const buf = XML_GetBuffer(parser, chunkSize); if (!buf) { errorOccured = true; + errorReason = OpdsParserError::BUFFER_MEMORY; LOG_DBG("OPDS", "Couldn't allocate memory for buffer"); destroyXmlParser(parser); parser = nullptr; @@ -49,6 +52,7 @@ size_t OpdsParser::write(const uint8_t* xmlData, const size_t length) { if (XML_ParseBuffer(parser, static_cast(toRead), 0) == XML_STATUS_ERROR) { errorOccured = true; + errorReason = OpdsParserError::XML_PARSE; LOG_DBG("OPDS", "Parse error at line %lu: %s", XML_GetCurrentLineNumber(parser), XML_ErrorString(XML_GetErrorCode(parser))); destroyXmlParser(parser); @@ -65,6 +69,7 @@ void OpdsParser::flush() { if (!parser) return; if (XML_Parse(parser, nullptr, 0, XML_TRUE) != XML_STATUS_OK) { errorOccured = true; + errorReason = OpdsParserError::XML_PARSE; destroyXmlParser(parser); parser = nullptr; } @@ -74,6 +79,7 @@ bool OpdsParser::parse(const uint8_t* xmlData, const size_t length) { clear(); if (!xmlData && length > 0) { errorOccured = true; + errorReason = OpdsParserError::INVALID_INPUT; return false; } @@ -96,6 +102,7 @@ void OpdsParser::clear() { currentText.clear(); inEntry = inTitle = inAuthor = inAuthorName = inId = false; errorOccured = !entries || entryCapacity == 0; + errorReason = errorOccured ? OpdsParserError::NO_ENTRY_BUFFER : OpdsParserError::NONE; resetXmlParser(); } @@ -110,6 +117,7 @@ bool OpdsParser::resetXmlParser() { parser = XML_ParserCreate(nullptr); if (!parser) { errorOccured = true; + errorReason = OpdsParserError::PARSER_MEMORY; LOG_DBG("OPDS", "Couldn't allocate memory for parser"); return false; } diff --git a/lib/OpdsParser/OpdsParser.h b/lib/OpdsParser/OpdsParser.h index a6bc680540..76d1b1d2be 100644 --- a/lib/OpdsParser/OpdsParser.h +++ b/lib/OpdsParser/OpdsParser.h @@ -15,6 +15,8 @@ enum class OpdsEntryType { BOOK // Downloadable book }; +enum class OpdsParserError { NONE, NO_ENTRY_BUFFER, INVALID_INPUT, PARSER_MEMORY, BUFFER_MEMORY, XML_PARSE }; + /** * Represents an entry from an OPDS feed (either a navigation link or a book). */ @@ -144,6 +146,7 @@ class OpdsParser final : public Print { bool parse(const char* xmlData, size_t length) { return parse(reinterpret_cast(xmlData), length); } bool error() const; + OpdsParserError getErrorReason() const { return errorReason; } operator bool() const { return !error(); } @@ -189,5 +192,6 @@ class OpdsParser final : public Print { bool inId = false; bool errorOccured = false; + OpdsParserError errorReason = OpdsParserError::NONE; bool truncated = false; }; diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 8ca8ea507f..78bae7937f 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -297,6 +297,52 @@ uint8_t CrossPointSettings::sleepScreenModeToStorage(const uint8_t mode) { return 0; } +uint8_t CrossPointSettings::legacyLineSpacingToPercent(const uint8_t legacyValue, const uint8_t fontFamily, + const bool sdFontSelected) { + if (sdFontSelected) { + switch (legacyValue) { + case TIGHT: + return 95; + case WIDE: + return 110; + case NORMAL: + default: + return 100; + } + } + + switch (fontFamily) { + case CHAREINK: + case BITTER: + switch (legacyValue) { + case TIGHT: + return 95; + case WIDE: + return 130; + case NORMAL: + default: + return 110; + } + case LEXENDDECA: + default: + switch (legacyValue) { + case TIGHT: + return 90; + case WIDE: + return 120; + case NORMAL: + default: + return 100; + } + } +} + +uint8_t CrossPointSettings::clampedLineHeightPercent(const uint8_t value) { + if (value < MIN_LINE_HEIGHT_PERCENT) return MIN_LINE_HEIGHT_PERCENT; + if (value > MAX_LINE_HEIGHT_PERCENT) return MAX_LINE_HEIGHT_PERCENT; + return value; +} + bool CrossPointSettings::saveToFile() const { Storage.mkdir("/.crosspoint"); return JsonSettingsIO::saveSettings(*this, SETTINGS_FILE_JSON); @@ -479,57 +525,14 @@ bool CrossPointSettings::loadFromBinaryFile() { applyLegacyFrontButtonLayout(*this); } + lineHeightPercent = legacyLineSpacingToPercent(lineSpacing, fontFamily, sdFontFamilyName[0] != '\0'); + LOG_DBG("CPS", "Settings loaded from binary file"); return true; } float CrossPointSettings::getReaderLineCompression() const { - // SD card fonts use same compression as Bookerly (the most neutral values) - if (sdFontFamilyName[0] != '\0') { - switch (lineSpacing) { - case TIGHT: - return 0.95f; - case NORMAL: - default: - return 1.0f; - case WIDE: - return 1.1f; - } - } - - switch (fontFamily) { - case LEXENDDECA: - default: - switch (lineSpacing) { - case TIGHT: - return 0.90f; - case NORMAL: - default: - return 1.0f; - case WIDE: - return 1.2f; - } - case CHAREINK: - switch (lineSpacing) { - case TIGHT: - return 0.95f; - case NORMAL: - default: - return 1.1f; - case WIDE: - return 1.3f; - } - case BITTER: - switch (lineSpacing) { - case TIGHT: - return 0.95f; - case NORMAL: - default: - return 1.1f; - case WIDE: - return 1.3f; - } - } + return static_cast(clampedLineHeightPercent(lineHeightPercent)) / 100.0f; } unsigned long CrossPointSettings::getSleepTimeoutMs() const { diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 963a5f8662..021c18935b 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -140,6 +140,7 @@ class CrossPointSettings { SD_FONT_RANGE_ALL = 4, SD_FONT_SIZE_RANGE_COUNT }; + // Legacy persisted values for the old Tight / Normal / Wide line-spacing setting. enum LINE_COMPRESSION { TIGHT = 0, NORMAL = 1, WIDE = 2, LINE_COMPRESSION_COUNT }; enum PARAGRAPH_ALIGNMENT { JUSTIFIED = 0, @@ -320,7 +321,8 @@ class CrossPointSettings { #else uint8_t sdFontSizeRange = SD_FONT_RANGE_TINY; #endif - uint8_t lineSpacing = NORMAL; + uint8_t lineSpacing = NORMAL; // migration only; new saves use lineHeightPercent + uint8_t lineHeightPercent = 100; uint8_t paragraphAlignment = JUSTIFIED; // Auto-sleep timeout setting (default 10 minutes). Legacy sleepTimeout enum values are migration-only. uint8_t sleepTimeoutMinutes = 10; @@ -379,6 +381,9 @@ class CrossPointSettings { static constexpr uint8_t MIN_SLEEP_TIMEOUT_MINUTES = 1; static constexpr uint8_t MAX_SLEEP_TIMEOUT_MINUTES = 30; static constexpr uint8_t SD_FONT_MAX_SIZE_STEPS = 8; + static constexpr uint8_t MIN_LINE_HEIGHT_PERCENT = 70; + static constexpr uint8_t MAX_LINE_HEIGHT_PERCENT = 200; + static constexpr uint8_t LINE_HEIGHT_PERCENT_STEP = 1; uint16_t getPowerButtonWakeDuration() const { return (shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP) ? POWER_BUTTON_WAKE_SHORT_MS @@ -414,6 +419,8 @@ class CrossPointSettings { static uint8_t sleepTimeoutEnumToMinutes(uint8_t legacyValue); static uint8_t sleepScreenStorageToMode(uint8_t storedValue); static uint8_t sleepScreenModeToStorage(uint8_t mode); + static uint8_t legacyLineSpacingToPercent(uint8_t legacyValue, uint8_t fontFamily, bool sdFontSelected); + static uint8_t clampedLineHeightPercent(uint8_t value); #ifdef SIMULATOR static bool verifySleepTimeoutMigrationContract(); static bool verifySleepScreenMigrationContract(); diff --git a/src/JsonSettingsIO.cpp b/src/JsonSettingsIO.cpp index fa7694c03b..7cb15c1d3d 100644 --- a/src/JsonSettingsIO.cpp +++ b/src/JsonSettingsIO.cpp @@ -315,6 +315,15 @@ bool JsonSettingsIO::loadSettings(CrossPointSettings& s, const char* json, bool* strncpy(s.sdFontFamilyName, sfn, sizeof(s.sdFontFamilyName) - 1); s.sdFontFamilyName[sizeof(s.sdFontFamilyName) - 1] = '\0'; + if (doc["lineHeightPercent"].isNull() && !doc["lineSpacing"].isNull()) { + const uint8_t legacyLineSpacing = clamp(doc["lineSpacing"] | static_cast(CrossPointSettings::NORMAL), + static_cast(CrossPointSettings::LINE_COMPRESSION_COUNT), + static_cast(CrossPointSettings::NORMAL)); + s.lineHeightPercent = + CrossPointSettings::legacyLineSpacingToPercent(legacyLineSpacing, s.fontFamily, s.sdFontFamilyName[0] != '\0'); + if (needsResave) *needsResave = true; + } + // Language -- stored as code string for stability across enum reorders. if (doc["language"].is()) { s.language = static_cast(I18n::languageFromCode(doc["language"].as())); diff --git a/src/SdCardFontSystem.cpp b/src/SdCardFontSystem.cpp index 451758b753..eba4a9caca 100644 --- a/src/SdCardFontSystem.cpp +++ b/src/SdCardFontSystem.cpp @@ -109,3 +109,26 @@ int SdCardFontSystem::resolveFontId(const char* familyName, uint8_t /*fontSizeEn // ensureLoaded() must have been called with the current settings before this. return manager_.getFontId(familyName); } + +bool SdCardFontSystem::changeReaderFontSize(const bool larger) { + refreshIfDirty(); + + if (SETTINGS.sdFontFamilyName[0] != '\0') { + const auto* family = registry_.findFamily(SETTINGS.sdFontFamilyName); + if (family) { + const auto sizes = family->availableSizes(); + if (sizes.size() > 1) { + uint8_t current = SETTINGS.fontSize < sizes.size() ? SETTINGS.fontSize : static_cast(sizes.size() - 1); + if (larger) { + current = static_cast((current + 1) % sizes.size()); + } else { + current = current == 0 ? static_cast(sizes.size() - 1) : static_cast(current - 1); + } + SETTINGS.fontSize = current; + return true; + } + } + } + + return SETTINGS.changeReaderFontSize(larger); +} diff --git a/src/SdCardFontSystem.h b/src/SdCardFontSystem.h index 6e46fba093..429b4e9f8a 100644 --- a/src/SdCardFontSystem.h +++ b/src/SdCardFontSystem.h @@ -30,6 +30,9 @@ class SdCardFontSystem { /// Returns 0 if not found. Used by CrossPointSettings::getReaderFontId(). int resolveFontId(const char* familyName, uint8_t fontSizeEnum) const; + /// Change the reader font size using the active SD family when one is selected. + bool changeReaderFontSize(bool larger); + /// Access the registry (e.g. for settings UI to enumerate available fonts). const SdCardFontRegistry& registry() const { return registry_; } diff --git a/src/SettingsList.h b/src/SettingsList.h index 806bc0fbdf..e0374496ac 100644 --- a/src/SettingsList.h +++ b/src/SettingsList.h @@ -305,9 +305,10 @@ inline std::vector getSettingsList(const SdCardFontRegistry* regist {StrId::STR_FONT_RANGE_TEENSY, StrId::STR_FONT_RANGE_TINY, StrId::STR_FONT_RANGE_XLARGE, StrId::STR_FONT_RANGE_NO_EMOJI, StrId::STR_FONT_RANGE_ALL}, "sdFontSizeRange", StrId::STR_CAT_READER)); - add(SettingInfo::Enum(StrId::STR_LINE_SPACING, &CrossPointSettings::lineSpacing, - {StrId::STR_TIGHT, StrId::STR_NORMAL, StrId::STR_WIDE}, "lineSpacing", - StrId::STR_CAT_READER)); + add(SettingInfo::Value(StrId::STR_LINE_SPACING, &CrossPointSettings::lineHeightPercent, + {CrossPointSettings::MIN_LINE_HEIGHT_PERCENT, CrossPointSettings::MAX_LINE_HEIGHT_PERCENT, + CrossPointSettings::LINE_HEIGHT_PERCENT_STEP}, + "lineHeightPercent", StrId::STR_CAT_READER)); add(SettingInfo::Enum(StrId::STR_ORIENTATION, &CrossPointSettings::orientation, {StrId::STR_PORTRAIT, StrId::STR_LANDSCAPE_CW, StrId::STR_INVERTED, StrId::STR_LANDSCAPE_CCW}, "orientation", StrId::STR_CAT_READER)); diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 40aa325592..dff02938de 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -586,7 +586,7 @@ void SleepActivity::renderCoverSleepScreen() const { void SleepActivity::renderReadingStatsSleepScreen() const { BookReadingStats bookStats; - GlobalReadingStats globalStats = GlobalReadingStats::load(); + GlobalReadingStats globalStats = GlobalReadingStats::loadAggregated(); std::string bookTitle = tr(STR_READING_STATS); const std::string& path = APP_STATE.openEpubPath; diff --git a/src/activities/browser/OpdsBookBrowserActivity.cpp b/src/activities/browser/OpdsBookBrowserActivity.cpp index c0f5713c8c..3c61cce688 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.cpp +++ b/src/activities/browser/OpdsBookBrowserActivity.cpp @@ -8,6 +8,7 @@ #include #include "MappedInputManager.h" +#include "SdCardFontSystem.h" #include "SilentRestart.h" #include "activities/network/WifiSelectionActivity.h" #include "activities/util/KeyboardEntryActivity.h" @@ -21,11 +22,14 @@ namespace { constexpr int PAGE_ITEMS = 23; constexpr size_t OPDS_BROWSER_ENTRY_CAPACITY = MAX_OPDS_FEED_ENTRIES + 2; +constexpr size_t OPDS_DOWNLOAD_BUFFER_SIZE = 4096; } // namespace void OpdsBookBrowserActivity::onEnter() { Activity::onEnter(); + sdFontSystem.releaseLoadedFont(renderer); + state = BrowserState::CHECK_WIFI; entryCount = 0; navigationHistory.clear(); @@ -78,9 +82,7 @@ void OpdsBookBrowserActivity::loop() { if (state == BrowserState::ERROR) { if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) { - state = BrowserState::LOADING; - statusMessage = tr(STR_LOADING); - requestUpdate(); + showLoadingBeforeFetch(); fetchFeed(currentPath); } else { launchWifiSelection(); @@ -212,6 +214,15 @@ void OpdsBookBrowserActivity::render(RenderLock&&) { renderer.displayBuffer(); } +void OpdsBookBrowserActivity::showLoadingBeforeFetch() { + state = BrowserState::LOADING; + statusMessage = tr(STR_LOADING); + if (requestUpdateAndWait() != RequestUpdateResult::Rendered) { + LOG_ERR("OPDS", "Loading screen could not be rendered before feed fetch"); + requestUpdate(true); + } +} + void OpdsBookBrowserActivity::fetchFeed(const std::string& path) { if (!ensureEntryBuffer()) { state = BrowserState::ERROR; @@ -243,7 +254,8 @@ void OpdsBookBrowserActivity::fetchFeed(const std::string& path) { if (!parser) { state = BrowserState::ERROR; - errorMessage = tr(STR_PARSE_FEED_FAILED); + errorMessage = parser.getErrorReason() == OpdsParserError::BUFFER_MEMORY ? tr(STR_OPDS_FEED_BUFFER_MEMORY_ERROR) + : tr(STR_PARSE_FEED_FAILED); requestUpdate(); return; } @@ -296,11 +308,9 @@ void OpdsBookBrowserActivity::navigateToEntry(const OpdsEntry& entry) { const std::string feedUrl = UrlUtils::buildUrl(server.url, currentPath); currentPath = UrlUtils::buildUrl(feedUrl, entry.href); - state = BrowserState::LOADING; - statusMessage = tr(STR_LOADING); clearEntries(); selectorIndex = 0; - requestUpdate(true); + showLoadingBeforeFetch(); fetchFeed(currentPath); } @@ -310,11 +320,9 @@ void OpdsBookBrowserActivity::navigateBack() { } else { currentPath = navigationHistory.back(); navigationHistory.pop_back(); - state = BrowserState::LOADING; - statusMessage = tr(STR_LOADING); clearEntries(); selectorIndex = 0; - requestUpdate(); + showLoadingBeforeFetch(); fetchFeed(currentPath); } } @@ -333,19 +341,30 @@ void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) { LOG_DBG("OPDS", "Downloading: %s -> %s", downloadUrl.c_str(), filename.c_str()); bool cancelRequested = false; + auto pollCancel = [this, &cancelRequested] { + if (cancelRequested) { + return true; + } + mappedInput.update(); + if (mappedInput.isPressed(MappedInputManager::Button::Back) || + mappedInput.wasPressed(MappedInputManager::Button::Back) || + mappedInput.wasReleased(MappedInputManager::Button::Back)) { + cancelRequested = true; + } + return cancelRequested; + }; + HttpDownloader::DownloadOptions downloadOptions; + downloadOptions.shouldCancel = pollCancel; + downloadOptions.bufferSize = OPDS_DOWNLOAD_BUFFER_SIZE; + const auto result = HttpDownloader::downloadToFile( downloadUrl, filename, - [this, &cancelRequested](const size_t downloaded, const size_t total) { + [this](const size_t downloaded, const size_t total) { downloadProgress = downloaded; downloadTotal = total; - mappedInput.update(); - if (mappedInput.isPressed(MappedInputManager::Button::Back) || - mappedInput.wasPressed(MappedInputManager::Button::Back)) { - cancelRequested = true; - } requestUpdate(true); }, - &cancelRequested, server.username, server.password); + &cancelRequested, server.username, server.password, downloadOptions); if (result == HttpDownloader::OK) { clearBookCache(filename); @@ -407,17 +426,13 @@ void OpdsBookBrowserActivity::performSearch(const std::string& query) { navigationHistory.push_back(currentPath); // <-- add this currentPath = url; // <-- add this - state = BrowserState::LOADING; - statusMessage = tr(STR_LOADING); - requestUpdate(true); + showLoadingBeforeFetch(); fetchFeed(url); } void OpdsBookBrowserActivity::checkAndConnectWifi() { if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) { - state = BrowserState::LOADING; - statusMessage = tr(STR_LOADING); - requestUpdate(); + showLoadingBeforeFetch(); fetchFeed(currentPath); return; } @@ -434,9 +449,7 @@ void OpdsBookBrowserActivity::launchWifiSelection() { void OpdsBookBrowserActivity::onWifiSelectionComplete(const bool connected) { if (connected) { - state = BrowserState::LOADING; - statusMessage = tr(STR_LOADING); - requestUpdate(true); + showLoadingBeforeFetch(); fetchFeed(currentPath); } else { // Leave WiFi up; onExit's silent reboot handles teardown without fragmenting. diff --git a/src/activities/browser/OpdsBookBrowserActivity.h b/src/activities/browser/OpdsBookBrowserActivity.h index 16e5848a5e..a8e661f686 100644 --- a/src/activities/browser/OpdsBookBrowserActivity.h +++ b/src/activities/browser/OpdsBookBrowserActivity.h @@ -47,6 +47,7 @@ class OpdsBookBrowserActivity final : public Activity { void checkAndConnectWifi(); void launchWifiSelection(); void onWifiSelectionComplete(bool connected); + void showLoadingBeforeFetch(); void fetchFeed(const std::string& path); bool ensureEntryBuffer(); void clearEntries(); diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 03a111c3d8..2f3ef3ae8b 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -316,6 +316,37 @@ void appendCarouselCoverStateToKey(std::string& key, const RecentBook& book) { } } +void appendSyncedStatsStateToKey(std::string& key) { + FsFile dir = Storage.open("/.crosspoint/synced_stats"); + if (!dir) { + key += "no-synced-stats"; + key += '\0'; + return; + } + + if (!dir.isDirectory()) { + dir.close(); + key += "synced-stats-not-dir"; + key += '\0'; + return; + } + + char name[128]; + for (FsFile file = dir.openNextFile(); file; file = dir.openNextFile()) { + const bool isDirectory = file.isDirectory(); + const size_t nameLen = file.getName(name, sizeof(name)); + if (!isDirectory && nameLen > 0) { + key += name; + key += '\0'; + file.close(); + appendHashedFileStateToKey(key, std::string("/.crosspoint/synced_stats/") + name); + continue; + } + file.close(); + } + dir.close(); +} + void buildCarouselCacheKey(const std::vector& recentBooks, std::string& key, uint64_t& keyHash) { key.clear(); key.reserve(512); @@ -323,6 +354,7 @@ void buildCarouselCacheKey(const std::vector& recentBooks, std::stri appendCarouselCoverStateToKey(key, book); } appendHashedFileStateToKey(key, "/.crosspoint/global_stats.bin"); + appendSyncedStatsStateToKey(key); keyHash = fnvHash64(key); } @@ -693,7 +725,7 @@ void HomeActivity::onEnter() { } } - globalStats = GlobalReadingStats::load(); + globalStats = GlobalReadingStats::loadAggregated(); if (isCarouselTheme) { loadAllBookStats(); } diff --git a/src/activities/network/WifiSelectionActivity.cpp b/src/activities/network/WifiSelectionActivity.cpp index 4bd14bb9c2..dd1b61ffd2 100644 --- a/src/activities/network/WifiSelectionActivity.cpp +++ b/src/activities/network/WifiSelectionActivity.cpp @@ -15,8 +15,137 @@ #include "components/UITheme.h" #include "fontIds.h" +namespace { + +#ifndef SIMULATOR +uint8_t sLastStaDisconnectReason = 0; +bool sConnectionAttemptLoggingActive = false; +bool sWifiEventLoggingRegistered = false; + +void logWifiStationEvent(WiFiEvent_t event, WiFiEventInfo_t info) { + if (!sConnectionAttemptLoggingActive) { + return; + } + + switch (event) { + case ARDUINO_EVENT_WIFI_STA_CONNECTED: + LOG_INF("WIFI", "STA event: connected to AP"); + break; + case ARDUINO_EVENT_WIFI_STA_GOT_IP: { + const uint8_t* ip = reinterpret_cast(&info.got_ip.ip_info.ip.addr); + LOG_INF("WIFI", "STA event: got IP %u.%u.%u.%u", ip[0], ip[1], ip[2], ip[3]); + break; + } + case ARDUINO_EVENT_WIFI_STA_DISCONNECTED: { + uint8_t reason = info.wifi_sta_disconnected.reason; + if (reason == 0) { + reason = WIFI_REASON_UNSPECIFIED; + } + sLastStaDisconnectReason = reason; + LOG_INF("WIFI", "STA event: disconnected reason=%u(%s)", reason, + WiFi.disconnectReasonName(static_cast(reason))); + break; + } + case ARDUINO_EVENT_WIFI_STA_LOST_IP: + LOG_INF("WIFI", "STA event: lost IP"); + break; + default: + break; + } +} + +void ensureWifiEventLoggingRegistered() { + if (sWifiEventLoggingRegistered) { + return; + } + WiFi.onEvent(logWifiStationEvent, ARDUINO_EVENT_WIFI_STA_CONNECTED); + WiFi.onEvent(logWifiStationEvent, ARDUINO_EVENT_WIFI_STA_GOT_IP); + WiFi.onEvent(logWifiStationEvent, ARDUINO_EVENT_WIFI_STA_DISCONNECTED); + WiFi.onEvent(logWifiStationEvent, ARDUINO_EVENT_WIFI_STA_LOST_IP); + sWifiEventLoggingRegistered = true; +} +#else +void ensureWifiEventLoggingRegistered() {} +#endif + +const char* wifiStatusName(const wl_status_t status) { + switch (status) { + case WL_IDLE_STATUS: + return "IDLE"; + case WL_NO_SSID_AVAIL: + return "NO_SSID_AVAIL"; + case WL_CONNECTED: + return "CONNECTED"; + case WL_CONNECT_FAILED: + return "CONNECT_FAILED"; +#ifndef SIMULATOR + case WL_CONNECTION_LOST: + return "CONNECTION_LOST"; +#endif + case WL_DISCONNECTED: + return "DISCONNECTED"; +#ifndef SIMULATOR + case WL_NO_SHIELD: + return "NO_SHIELD"; + case WL_STOPPED: + return "STOPPED"; + case WL_SCAN_COMPLETED: + return "SCAN_COMPLETED"; +#endif + default: + return "UNKNOWN"; + } +} + +bool wifiStatusIsConnectionFailure(const wl_status_t status) { + if (status == WL_CONNECT_FAILED || status == WL_NO_SSID_AVAIL) { + return true; + } +#ifndef SIMULATOR + return status == WL_CONNECTION_LOST; +#else + return false; +#endif +} + +const char* wifiAuthName(const int authMode) { + switch (authMode) { + case WIFI_AUTH_OPEN: + return "OPEN"; +#ifndef SIMULATOR + case WIFI_AUTH_WEP: + return "WEP"; + case WIFI_AUTH_WPA_PSK: + return "WPA_PSK"; +#endif + case WIFI_AUTH_WPA2_PSK: + return "WPA2_PSK"; +#ifndef SIMULATOR + case WIFI_AUTH_WPA_WPA2_PSK: + return "WPA_WPA2_PSK"; + case WIFI_AUTH_WPA2_ENTERPRISE: + return "WPA2_ENTERPRISE"; + case WIFI_AUTH_WPA3_PSK: + return "WPA3_PSK"; + case WIFI_AUTH_WPA2_WPA3_PSK: + return "WPA2_WPA3_PSK"; + case WIFI_AUTH_WAPI_PSK: + return "WAPI_PSK"; + case WIFI_AUTH_OWE: + return "OWE"; + case WIFI_AUTH_WPA3_ENT_192: + return "WPA3_ENT_192"; +#endif + default: + return "UNKNOWN"; + } +} + +} // namespace + void WifiSelectionActivity::onEnter() { Activity::onEnter(); + ensureWifiEventLoggingRegistered(); // Load saved WiFi credentials - SD card operations need lock as we use SPI // for both @@ -37,6 +166,8 @@ void WifiSelectionActivity::onEnter() { savePromptSelection = 0; forgetPromptSelection = 0; autoConnecting = false; + lastConnectionStatusLogTime = 0; + lastLoggedWifiStatus = -1; // Cache MAC address for display uint8_t mac[6]; @@ -55,7 +186,7 @@ void WifiSelectionActivity::onEnter() { if (!lastSsid.empty()) { const auto* cred = WIFI_STORE.findCredential(lastSsid); if (cred) { - LOG_DBG("WIFI", "Attempting to auto-connect to %s", lastSsid.c_str()); + LOG_INF("WIFI", "Auto-connect candidate: ssid=%s saved=1", lastSsid.c_str()); selectedSSID = cred->ssid; enteredPassword = cred->password; selectedRequiresPassword = !cred->password.empty(); @@ -96,12 +227,15 @@ void WifiSelectionActivity::startWifiScan() { requestUpdate(); // Set WiFi mode to station + LOG_INF("WIFI", "Starting WiFi scan (mode=%d status=%d/%s heap=%u maxAlloc=%u)", static_cast(WiFi.getMode()), + static_cast(WiFi.status()), wifiStatusName(WiFi.status()), ESP.getFreeHeap(), ESP.getMaxAllocHeap()); WiFi.mode(WIFI_STA); WiFi.disconnect(); delay(100); // Start async scan - WiFi.scanNetworks(true); // true = async scan + const int scanStartResult = WiFi.scanNetworks(true); // true = async scan + LOG_INF("WIFI", "WiFi scan requested (result=%d)", scanStartResult); } void WifiSelectionActivity::processWifiScanResults() { @@ -113,35 +247,48 @@ void WifiSelectionActivity::processWifiScanResults() { } if (scanResult == WIFI_SCAN_FAILED) { + LOG_INF("WIFI", "WiFi scan failed"); state = WifiSelectionState::NETWORK_LIST; requestUpdate(); return; } + LOG_INF("WIFI", "WiFi scan complete: rawNetworks=%d", scanResult); + // Scan complete, process results // Use a map to deduplicate networks by SSID, keeping the strongest signal std::map uniqueNetworks; + int hiddenNetworks = 0; + int duplicateNetworks = 0; for (int i = 0; i < scanResult; i++) { std::string ssid = WiFi.SSID(i).c_str(); const int32_t rssi = WiFi.RSSI(i); + const int authMode = WiFi.encryptionType(i); // Skip hidden networks (empty SSID) if (ssid.empty()) { + hiddenNetworks++; continue; } // Check if we've already seen this SSID auto it = uniqueNetworks.find(ssid); + if (it != uniqueNetworks.end()) { + duplicateNetworks++; + } if (it == uniqueNetworks.end() || rssi > it->second.rssi) { // New network or stronger signal than existing entry WifiNetworkInfo network; network.ssid = ssid; network.rssi = rssi; - network.isEncrypted = (WiFi.encryptionType(i) != WIFI_AUTH_OPEN); + network.isEncrypted = (authMode != WIFI_AUTH_OPEN); network.hasSavedPassword = WIFI_STORE.hasSavedCredential(network.ssid); uniqueNetworks[ssid] = network; } + + LOG_DBG("WIFI", "Scan result: ssid=%s rssi=%d auth=%s saved=%d", ssid.c_str(), rssi, wifiAuthName(authMode), + WIFI_STORE.hasSavedCredential(ssid)); } // Convert map to vector @@ -160,6 +307,8 @@ void WifiSelectionActivity::processWifiScanResults() { }); WiFi.scanDelete(); + LOG_INF("WIFI", "WiFi scan usable networks=%zu hidden=%d duplicates=%d", networks.size(), hiddenNetworks, + duplicateNetworks); state = WifiSelectionState::NETWORK_LIST; selectedNetworkIndex = 0; requestUpdate(); @@ -183,6 +332,8 @@ void WifiSelectionActivity::selectNetwork(const int index) { // Use saved password - connect directly enteredPassword = savedCred->password; usedSavedPassword = true; + LOG_INF("WIFI", "Selected network: ssid=%s encrypted=%d saved=1 rssi=%d", selectedSSID.c_str(), + selectedRequiresPassword, network.rssi); LOG_DBG("WiFi", "Using saved password for %s, length: %zu", selectedSSID.c_str(), enteredPassword.size()); attemptConnection(); return; @@ -206,6 +357,7 @@ void WifiSelectionActivity::selectNetwork(const int index) { }); } else { // Connect directly for open networks + LOG_INF("WIFI", "Selected open network: ssid=%s rssi=%d", selectedSSID.c_str(), network.rssi); attemptConnection(); } } @@ -215,12 +367,26 @@ void WifiSelectionActivity::attemptConnection() { connectionStartTime = millis(); connectedIP.clear(); connectionError.clear(); + lastConnectionStatusLogTime = 0; + lastLoggedWifiStatus = -1; +#ifndef SIMULATOR + sLastStaDisconnectReason = 0; + sConnectionAttemptLoggingActive = false; +#endif requestUpdate(); + LOG_INF("WIFI", "Connecting to ssid=%s auto=%d saved=%d encrypted=%d passProvided=%d heap=%u maxAlloc=%u", + selectedSSID.c_str(), autoConnecting, usedSavedPassword, selectedRequiresPassword, !enteredPassword.empty(), + ESP.getFreeHeap(), ESP.getMaxAllocHeap()); + WiFi.persistent(false); // Credentials are managed by WifiCredentialStore; suppress SDK NVS auto-connect WiFi.mode(WIFI_STA); WiFi.disconnect(true, true); // Abort any in-progress SDK auto-connect and clear NVS-saved SSID delay(100); +#ifndef SIMULATOR + sLastStaDisconnectReason = 0; + sConnectionAttemptLoggingActive = true; +#endif // Set hostname so routers show "CrossPoint-Reader-AABBCCDDEEFF" instead of "esp32-XXXXXXXXXXXX" String mac = WiFi.macAddress(); @@ -228,11 +394,13 @@ void WifiSelectionActivity::attemptConnection() { String hostname = "CrossPoint-Reader-" + mac; WiFi.setHostname(hostname.c_str()); + wl_status_t beginStatus = WL_IDLE_STATUS; if (selectedRequiresPassword && !enteredPassword.empty()) { - WiFi.begin(selectedSSID.c_str(), enteredPassword.c_str()); + beginStatus = WiFi.begin(selectedSSID.c_str(), enteredPassword.c_str()); } else { - WiFi.begin(selectedSSID.c_str()); + beginStatus = WiFi.begin(selectedSSID.c_str()); } + LOG_INF("WIFI", "WiFi.begin returned status=%d/%s", static_cast(beginStatus), wifiStatusName(beginStatus)); } void WifiSelectionActivity::checkConnectionStatus() { @@ -241,6 +409,15 @@ void WifiSelectionActivity::checkConnectionStatus() { } const wl_status_t status = WiFi.status(); + const unsigned long now = millis(); + + if (lastLoggedWifiStatus != static_cast(status) || + now - lastConnectionStatusLogTime >= CONNECTION_STATUS_LOG_INTERVAL_MS) { + LOG_INF("WIFI", "Connection poll: elapsed=%lums status=%d/%s rssi=%d", now - connectionStartTime, + static_cast(status), wifiStatusName(status), status == WL_CONNECTED ? WiFi.RSSI() : 0); + lastLoggedWifiStatus = static_cast(status); + lastConnectionStatusLogTime = now; + } if (status == WL_CONNECTED) { // Successfully connected @@ -249,6 +426,10 @@ void WifiSelectionActivity::checkConnectionStatus() { snprintf(ipStr, sizeof(ipStr), "%d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]); connectedIP = ipStr; autoConnecting = false; +#ifndef SIMULATOR + sConnectionAttemptLoggingActive = false; +#endif + LOG_INF("WIFI", "Connected to ssid=%s ip=%s rssi=%d", selectedSSID.c_str(), connectedIP.c_str(), WiFi.RSSI()); // Sync RTC from NTP on the first successful WiFi connection only. The DS3231 // drifts ~2 ppm so one sync is enough; users can force a re-sync from @@ -289,11 +470,20 @@ void WifiSelectionActivity::checkConnectionStatus() { return; } - if (status == WL_CONNECT_FAILED || status == WL_NO_SSID_AVAIL) { + if (wifiStatusIsConnectionFailure(status)) { connectionError = tr(STR_ERROR_GENERAL_FAILURE); if (status == WL_NO_SSID_AVAIL) { connectionError = tr(STR_ERROR_NETWORK_NOT_FOUND); } + LOG_INF("WIFI", "Connection failed: ssid=%s status=%d/%s elapsed=%lums", selectedSSID.c_str(), + static_cast(status), wifiStatusName(status), now - connectionStartTime); +#ifndef SIMULATOR + if (sLastStaDisconnectReason != 0) { + LOG_INF("WIFI", "Last disconnect reason: %u(%s)", sLastStaDisconnectReason, + WiFi.disconnectReasonName(static_cast(sLastStaDisconnectReason))); + } + sConnectionAttemptLoggingActive = false; +#endif state = WifiSelectionState::CONNECTION_FAILED; requestUpdate(); return; @@ -303,6 +493,15 @@ void WifiSelectionActivity::checkConnectionStatus() { if (millis() - connectionStartTime > CONNECTION_TIMEOUT_MS) { WiFi.disconnect(); connectionError = tr(STR_ERROR_CONNECTION_TIMEOUT); + LOG_INF("WIFI", "Connection timed out: ssid=%s elapsed=%lums lastStatus=%d/%s", selectedSSID.c_str(), + millis() - connectionStartTime, static_cast(status), wifiStatusName(status)); +#ifndef SIMULATOR + if (sLastStaDisconnectReason != 0) { + LOG_INF("WIFI", "Last disconnect reason before timeout: %u(%s)", sLastStaDisconnectReason, + WiFi.disconnectReasonName(static_cast(sLastStaDisconnectReason))); + } + sConnectionAttemptLoggingActive = false; +#endif state = WifiSelectionState::CONNECTION_FAILED; requestUpdate(); return; @@ -313,6 +512,9 @@ void WifiSelectionActivity::loop() { if ((state == WifiSelectionState::SCANNING || state == WifiSelectionState::CONNECTING || state == WifiSelectionState::AUTO_CONNECTING) && mappedInput.wasPressed(MappedInputManager::Button::Back)) { +#ifndef SIMULATOR + sConnectionAttemptLoggingActive = false; +#endif WiFi.disconnect(); onComplete(false); return; diff --git a/src/activities/network/WifiSelectionActivity.h b/src/activities/network/WifiSelectionActivity.h index baacf4da22..20ffa64e57 100644 --- a/src/activities/network/WifiSelectionActivity.h +++ b/src/activities/network/WifiSelectionActivity.h @@ -81,7 +81,10 @@ class WifiSelectionActivity final : public Activity { // Connection timeout static constexpr unsigned long CONNECTION_TIMEOUT_MS = 15000; + static constexpr unsigned long CONNECTION_STATUS_LOG_INTERVAL_MS = 2000; unsigned long connectionStartTime = 0; + unsigned long lastConnectionStatusLogTime = 0; + int lastLoggedWifiStatus = -1; void renderNetworkList(const Rect* screen, const ThemeMetrics* metrics) const; void renderPasswordEntry(const Rect* screen, const ThemeMetrics* metrics) const; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 6908c4410d..52f37b3657 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -586,7 +586,7 @@ void EpubReaderActivity::loop() { if (!sideButtonLongPressHandled && topLongPressed) { sideButtonLongPressHandled = !topReleased; if (sideLongPressChangesFont) { - if (SETTINGS.changeReaderFontSize(/*larger=*/true)) { + if (sdFontSystem.changeReaderFontSize(/*larger=*/true)) { reindexCurrentSection(); } } else { @@ -598,7 +598,7 @@ void EpubReaderActivity::loop() { if (!sideButtonLongPressHandled && bottomLongPressed) { sideButtonLongPressHandled = !bottomReleased; if (sideLongPressChangesFont) { - if (SETTINGS.changeReaderFontSize(/*larger=*/false)) { + if (sdFontSystem.changeReaderFontSize(/*larger=*/false)) { reindexCurrentSection(); } } else { @@ -914,9 +914,10 @@ void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction // Include elapsed time from the current session in the display stats. BookReadingStats displayStats = stats; displayStats.totalReadingSeconds += static_cast((millis() - sessionStartMs) / 1000UL); - startActivityForResult( - std::make_unique(renderer, mappedInput, epub->getTitle(), displayStats, globalStats), - [this](const ActivityResult&) { requestUpdate(); }); + GlobalReadingStats displayGlobalStats = GlobalReadingStats::loadAggregated(globalStats); + startActivityForResult(std::make_unique(renderer, mappedInput, epub->getTitle(), displayStats, + displayGlobalStats), + [this](const ActivityResult&) { requestUpdate(); }); break; } case EpubReaderMenuActivity::MenuAction::TOGGLE_COMPLETED: { diff --git a/src/activities/reader/EpubReaderMenuActivity.cpp b/src/activities/reader/EpubReaderMenuActivity.cpp index 69dce9e331..ba0e20f956 100644 --- a/src/activities/reader/EpubReaderMenuActivity.cpp +++ b/src/activities/reader/EpubReaderMenuActivity.cpp @@ -16,7 +16,7 @@ struct ReaderLayoutSettingsSnapshot { uint8_t fontFamily; uint8_t fontSize; uint8_t sdFontSizeRange; - uint8_t lineSpacing; + uint8_t lineHeightPercent; uint8_t orientation; uint8_t screenMargin; uint8_t paragraphAlignment; @@ -31,10 +31,10 @@ struct ReaderLayoutSettingsSnapshot { bool operator==(const ReaderLayoutSettingsSnapshot& other) const { return fontFamily == other.fontFamily && fontSize == other.fontSize && sdFontSizeRange == other.sdFontSizeRange && - lineSpacing == other.lineSpacing && orientation == other.orientation && screenMargin == other.screenMargin && - paragraphAlignment == other.paragraphAlignment && embeddedStyle == other.embeddedStyle && - hyphenationEnabled == other.hyphenationEnabled && imageRendering == other.imageRendering && - extraParagraphSpacing == other.extraParagraphSpacing && + lineHeightPercent == other.lineHeightPercent && orientation == other.orientation && + screenMargin == other.screenMargin && paragraphAlignment == other.paragraphAlignment && + embeddedStyle == other.embeddedStyle && hyphenationEnabled == other.hyphenationEnabled && + imageRendering == other.imageRendering && extraParagraphSpacing == other.extraParagraphSpacing && forceParagraphIndents == other.forceParagraphIndents && bionicReadingEnabled == other.bionicReadingEnabled && guideReadingEnabled == other.guideReadingEnabled && std::strncmp(sdFontFamilyName, other.sdFontFamilyName, sizeof(sdFontFamilyName)) == 0; @@ -47,7 +47,7 @@ ReaderLayoutSettingsSnapshot captureReaderLayoutSettings() { SETTINGS.fontFamily, SETTINGS.fontSize, SETTINGS.sdFontSizeRange, - SETTINGS.lineSpacing, + SETTINGS.lineHeightPercent, SETTINGS.orientation, SETTINGS.screenMargin, SETTINGS.paragraphAlignment, diff --git a/src/activities/reader/GlobalReadingStats.cpp b/src/activities/reader/GlobalReadingStats.cpp index cc7b30896d..bfd613ab19 100644 --- a/src/activities/reader/GlobalReadingStats.cpp +++ b/src/activities/reader/GlobalReadingStats.cpp @@ -2,6 +2,11 @@ #include #include +#include + +#include +#include +#include namespace { // Binary layout v1 (13 bytes): @@ -22,6 +27,7 @@ static constexpr int GLOBAL_STATS_FILE_SIZE_V1 = 13; static constexpr int GLOBAL_STATS_FILE_SIZE = 17; static constexpr char GLOBAL_STATS_PATH[] = "/.crosspoint/global_stats.bin"; static constexpr char GLOBAL_STATS_BAK_PATH[] = "/.crosspoint/global_stats.bin.bak"; +static constexpr char SYNCED_STATS_DIR[] = "/.crosspoint/synced_stats"; uint32_t readLe32(const uint8_t* data, const int offset) { return static_cast(data[offset]) | (static_cast(data[offset + 1]) << 8) | @@ -33,14 +39,42 @@ void loadCommonFields(const uint8_t* data, GlobalReadingStats& out) { out.totalReadingSeconds = readLe32(data, 5); out.totalPagesTurned = readLe32(data, 9); } -} // namespace -static bool loadFromFile(const char* path, GlobalReadingStats& out) { - FsFile f; - if (!Storage.openFileForRead("GSTATS", path, f)) return false; +uint32_t addSaturated(const uint32_t a, const uint32_t b) { + const uint32_t max = std::numeric_limits::max(); + return max - a < b ? max : a + b; +} + +void addStats(GlobalReadingStats& target, const GlobalReadingStats& source) { + target.totalSessions = addSaturated(target.totalSessions, source.totalSessions); + target.totalReadingSeconds = addSaturated(target.totalReadingSeconds, source.totalReadingSeconds); + target.totalPagesTurned = addSaturated(target.totalPagesTurned, source.totalPagesTurned); + target.completedBooks = addSaturated(target.completedBooks, source.completedBooks); +} + +void serializeStats(const GlobalReadingStats& stats, uint8_t* data) { + data[0] = GLOBAL_STATS_VERSION; + data[1] = stats.totalSessions & 0xFF; + data[2] = (stats.totalSessions >> 8) & 0xFF; + data[3] = (stats.totalSessions >> 16) & 0xFF; + data[4] = (stats.totalSessions >> 24) & 0xFF; + data[5] = stats.totalReadingSeconds & 0xFF; + data[6] = (stats.totalReadingSeconds >> 8) & 0xFF; + data[7] = (stats.totalReadingSeconds >> 16) & 0xFF; + data[8] = (stats.totalReadingSeconds >> 24) & 0xFF; + data[9] = stats.totalPagesTurned & 0xFF; + data[10] = (stats.totalPagesTurned >> 8) & 0xFF; + data[11] = (stats.totalPagesTurned >> 16) & 0xFF; + data[12] = (stats.totalPagesTurned >> 24) & 0xFF; + data[13] = stats.completedBooks & 0xFF; + data[14] = (stats.completedBooks >> 8) & 0xFF; + data[15] = (stats.completedBooks >> 16) & 0xFF; + data[16] = (stats.completedBooks >> 24) & 0xFF; +} + +bool loadFromOpenFile(FsFile& f, GlobalReadingStats& out) { uint8_t data[GLOBAL_STATS_FILE_SIZE] = {}; const int n = f.read(data, GLOBAL_STATS_FILE_SIZE); - f.close(); if (n == GLOBAL_STATS_FILE_SIZE_V1 && data[0] == GLOBAL_STATS_VERSION_V1) { loadCommonFields(data, out); @@ -54,68 +88,133 @@ static bool loadFromFile(const char* path, GlobalReadingStats& out) { return true; } -GlobalReadingStats GlobalReadingStats::load() { - GlobalReadingStats stats; - if (loadFromFile(GLOBAL_STATS_PATH, stats)) return stats; - if (loadFromFile(GLOBAL_STATS_BAK_PATH, stats)) { - LOG_DBG("GSTATS", "Recovered global stats from backup"); - return stats; - } - LOG_DBG("GSTATS", "Global stats missing or corrupt, starting fresh"); - return stats; +std::string localSyncedStatsFileName() { + uint8_t mac[6] = {}; + if (esp_efuse_mac_get_default(mac) != 0) return {}; + + char name[32]; + snprintf(name, sizeof(name), "device_%02x%02x%02x%02x%02x%02x.bin", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + return name; } -void GlobalReadingStats::save() const { - // Preserve previous file as .bak before truncating — openFileForWrite uses - // O_TRUNC, so a power failure mid-write would corrupt the primary file - // without this fallback. - if (Storage.exists(GLOBAL_STATS_PATH)) { - Storage.remove(GLOBAL_STATS_BAK_PATH); - Storage.rename(GLOBAL_STATS_PATH, GLOBAL_STATS_BAK_PATH); +std::string localSyncedStatsPath() { + const std::string fileName = localSyncedStatsFileName(); + if (fileName.empty()) return {}; + return std::string(SYNCED_STATS_DIR) + "/" + fileName; +} + +bool saveToFile(const GlobalReadingStats& stats, const char* path, const char* backupPath) { + if (backupPath != nullptr && Storage.exists(path)) { + Storage.remove(backupPath); + Storage.rename(path, backupPath); } FsFile f; - if (!Storage.openFileForWrite("GSTATS", GLOBAL_STATS_PATH, f)) { - LOG_ERR("GSTATS", "Could not write global_stats.bin"); - return; + if (!Storage.openFileForWrite("GSTATS", path, f)) { + LOG_ERR("GSTATS", "Could not write stats file: %s", path); + return false; } + uint8_t data[GLOBAL_STATS_FILE_SIZE]; - data[0] = GLOBAL_STATS_VERSION; - data[1] = totalSessions & 0xFF; - data[2] = (totalSessions >> 8) & 0xFF; - data[3] = (totalSessions >> 16) & 0xFF; - data[4] = (totalSessions >> 24) & 0xFF; - data[5] = totalReadingSeconds & 0xFF; - data[6] = (totalReadingSeconds >> 8) & 0xFF; - data[7] = (totalReadingSeconds >> 16) & 0xFF; - data[8] = (totalReadingSeconds >> 24) & 0xFF; - data[9] = totalPagesTurned & 0xFF; - data[10] = (totalPagesTurned >> 8) & 0xFF; - data[11] = (totalPagesTurned >> 16) & 0xFF; - data[12] = (totalPagesTurned >> 24) & 0xFF; - data[13] = completedBooks & 0xFF; - data[14] = (completedBooks >> 8) & 0xFF; - data[15] = (completedBooks >> 16) & 0xFF; - data[16] = (completedBooks >> 24) & 0xFF; + serializeStats(stats, data); const size_t bytesWritten = f.write(data, GLOBAL_STATS_FILE_SIZE); if (bytesWritten != GLOBAL_STATS_FILE_SIZE) { - LOG_ERR("GSTATS", "Short write for global stats: %u/%u bytes", static_cast(bytesWritten), + LOG_ERR("GSTATS", "Short write for stats file %s: %u/%u bytes", path, static_cast(bytesWritten), static_cast(GLOBAL_STATS_FILE_SIZE)); f.close(); - Storage.remove(GLOBAL_STATS_PATH); - return; + Storage.remove(path); + return false; } f.flush(); if (!f.sync()) { - LOG_ERR("GSTATS", "Failed to sync global_stats.bin"); + LOG_ERR("GSTATS", "Failed to sync stats file: %s", path); f.close(); - Storage.remove(GLOBAL_STATS_PATH); - return; + Storage.remove(path); + return false; } if (!f.close()) { - LOG_ERR("GSTATS", "Failed to close global_stats.bin after save"); - Storage.remove(GLOBAL_STATS_PATH); + LOG_ERR("GSTATS", "Failed to close stats file after save: %s", path); + Storage.remove(path); + return false; + } + + return true; +} +} // namespace + +static bool loadFromFile(const char* path, GlobalReadingStats& out) { + FsFile f; + if (!Storage.openFileForRead("GSTATS", path, f)) return false; + const bool ok = loadFromOpenFile(f, out); + f.close(); + return ok; +} + +GlobalReadingStats GlobalReadingStats::load() { + GlobalReadingStats stats; + if (loadFromFile(GLOBAL_STATS_PATH, stats)) return stats; + if (loadFromFile(GLOBAL_STATS_BAK_PATH, stats)) { + LOG_DBG("GSTATS", "Recovered global stats from backup"); + return stats; + } + LOG_DBG("GSTATS", "Global stats missing or corrupt, starting fresh"); + return stats; +} + +GlobalReadingStats GlobalReadingStats::loadAggregated() { return loadAggregated(load()); } + +GlobalReadingStats GlobalReadingStats::loadAggregated(const GlobalReadingStats& localStats) { + GlobalReadingStats stats = localStats; + FsFile dir = Storage.open(SYNCED_STATS_DIR); + if (!dir) return stats; + + if (!dir.isDirectory()) { + dir.close(); + return stats; + } + + char name[128]; + const std::string localFileName = localSyncedStatsFileName(); + uint16_t loadedCount = 0; + uint16_t skippedCount = 0; + for (FsFile file = dir.openNextFile(); file; file = dir.openNextFile()) { + const bool isDirectory = file.isDirectory(); + const size_t nameLen = file.getName(name, sizeof(name)); + + if (!isDirectory && nameLen > 0 && (localFileName.empty() || strcmp(name, localFileName.c_str()) != 0)) { + GlobalReadingStats syncedStats; + if (loadFromOpenFile(file, syncedStats)) { + addStats(stats, syncedStats); + loadedCount++; + } else { + skippedCount++; + LOG_DBG("GSTATS", "Skipping invalid synced stats file: %s", name); + } + } + + file.close(); + } + dir.close(); + + if (loadedCount > 0 || skippedCount > 0) { + LOG_DBG("GSTATS", "Aggregated %u synced stats file(s), skipped %u", static_cast(loadedCount), + static_cast(skippedCount)); + } + return stats; +} + +void GlobalReadingStats::save() const { + // Preserve previous file as .bak before truncating — openFileForWrite uses + // O_TRUNC, so a power failure mid-write would corrupt the primary file + // without this fallback. + if (!saveToFile(*this, GLOBAL_STATS_PATH, GLOBAL_STATS_BAK_PATH)) return; + + if (!Storage.exists(SYNCED_STATS_DIR)) return; + + const std::string contributionPath = localSyncedStatsPath(); + if (!contributionPath.empty()) { + saveToFile(*this, contributionPath.c_str(), nullptr); } } diff --git a/src/activities/reader/GlobalReadingStats.h b/src/activities/reader/GlobalReadingStats.h index ec78671909..c8cb0890fe 100644 --- a/src/activities/reader/GlobalReadingStats.h +++ b/src/activities/reader/GlobalReadingStats.h @@ -13,6 +13,16 @@ struct GlobalReadingStats { // stats if the file is missing or the version byte does not match. static GlobalReadingStats load(); - // Saves stats to /.crosspoint/global_stats.bin. + // Loads this device's local stats plus one synced stats file per other device + // from /.crosspoint/synced_stats/. This device's own contribution file is + // skipped to avoid double counting. + static GlobalReadingStats loadAggregated(); + + // Adds synced device stats to an already-loaded local stats snapshot. Use this + // when the local stats may include in-memory changes that are not saved yet. + static GlobalReadingStats loadAggregated(const GlobalReadingStats& localStats); + + // Saves stats to /.crosspoint/global_stats.bin. If /.crosspoint/synced_stats/ + // already exists, also mirrors this device's contribution there. void save() const; }; diff --git a/src/activities/reader/ReaderOptionsActivity.cpp b/src/activities/reader/ReaderOptionsActivity.cpp index 07ca0a5975..51fd5dd110 100644 --- a/src/activities/reader/ReaderOptionsActivity.cpp +++ b/src/activities/reader/ReaderOptionsActivity.cpp @@ -13,6 +13,7 @@ #include "activities/settings/FontDownloadActivity.h" #include "activities/settings/FontSelectionActivity.h" #include "activities/settings/StatusBarSettingsActivity.h" +#include "activities/util/IntervalSelectionActivity.h" #include "components/UITheme.h" #include "fontIds.h" @@ -38,6 +39,13 @@ uint8_t enumRawValueForDisplayIndex(const SettingInfo& setting, uint8_t displayI } return setting.enumRawValues[displayIndex]; } + +std::string formatSettingValue(const SettingInfo& setting) { + if (setting.valuePtr == &CrossPointSettings::lineHeightPercent) { + return std::to_string(SETTINGS.*(setting.valuePtr)) + "%"; + } + return std::to_string(SETTINGS.*(setting.valuePtr)); +} } // namespace void ReaderOptionsActivity::onEnter() { @@ -98,6 +106,10 @@ void ReaderOptionsActivity::toggleCurrentSetting() { const uint8_t cur = setting.valueGetter(); setting.valueSetter((cur + 1) % totalValues); } else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) { + if (setting.valuePtr == &CrossPointSettings::lineHeightPercent) { + openLineHeightPicker(); + return; + } const int8_t cur = SETTINGS.*(setting.valuePtr); if (cur + setting.valueRange.step > setting.valueRange.max) { SETTINGS.*(setting.valuePtr) = setting.valueRange.min; @@ -123,6 +135,23 @@ void ReaderOptionsActivity::toggleCurrentSetting() { } } +void ReaderOptionsActivity::openLineHeightPicker() { + startActivityForResult( + std::make_unique( + renderer, mappedInput, "ReaderOptionsLineHeightInterval", StrId::STR_LINE_SPACING, + StrId::STR_PERCENT_STEP_HINT, SETTINGS.lineHeightPercent, CrossPointSettings::MIN_LINE_HEIGHT_PERCENT, + CrossPointSettings::MAX_LINE_HEIGHT_PERCENT, 1, 10, StrId::STR_NONE_OPT, /*readerActivity=*/true, + /*allowPowerAsConfirm=*/true, /*ignoreInitialConfirmRelease=*/false, /*showPercentValue=*/true), + [this](const ActivityResult& result) { + if (!result.isCancelled) { + SETTINGS.lineHeightPercent = CrossPointSettings::clampedLineHeightPercent( + static_cast(std::get(result.data).value)); + SETTINGS.saveToFile(); + } + requestUpdate(); + }); +} + void ReaderOptionsActivity::loop() { buttonNavigator.onNextRelease([this] { selectedIndex = ButtonNavigator::nextIndex(selectedIndex, settingsCount); @@ -186,13 +215,16 @@ void ReaderOptionsActivity::render(RenderLock&&) { const uint8_t value = setting.valueGetter(); valueText = settingEnumOptionLabel(setting, value); } else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) { - valueText = std::to_string(SETTINGS.*(setting.valuePtr)); + valueText = formatSettingValue(setting); } return valueText; }, true); - const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_TOGGLE), tr(STR_DIR_UP), tr(STR_DIR_DOWN)); + const bool selectedLineHeight = selectedIndex >= 0 && selectedIndex < settingsCount && + settings[selectedIndex].valuePtr == &CrossPointSettings::lineHeightPercent; + const auto labels = mappedInput.mapLabels(tr(STR_BACK), selectedLineHeight ? tr(STR_SELECT) : tr(STR_TOGGLE), + tr(STR_DIR_UP), tr(STR_DIR_DOWN)); GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4, true); renderer.displayBuffer(); diff --git a/src/activities/reader/ReaderOptionsActivity.h b/src/activities/reader/ReaderOptionsActivity.h index 8f0d5c1f0f..1bc94341eb 100644 --- a/src/activities/reader/ReaderOptionsActivity.h +++ b/src/activities/reader/ReaderOptionsActivity.h @@ -15,6 +15,7 @@ class ReaderOptionsActivity final : public Activity { void rebuildSettingsList(); void toggleCurrentSetting(); + void openLineHeightPicker(); public: explicit ReaderOptionsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput) diff --git a/src/activities/reader/TxtReaderActivity.cpp b/src/activities/reader/TxtReaderActivity.cpp index 189a7f504c..62830ed773 100644 --- a/src/activities/reader/TxtReaderActivity.cpp +++ b/src/activities/reader/TxtReaderActivity.cpp @@ -85,6 +85,10 @@ size_t parseAndWrapLines(const uint8_t* buffer, size_t chunkSize, size_t fileOff } return pos; } + +int getReaderLineHeight(const GfxRenderer& renderer, const int fontId) { + return std::max(1, static_cast(renderer.getLineHeight(fontId) * SETTINGS.getReaderLineCompression() + 0.5f)); +} } // namespace void TxtReaderActivity::onEnter() { @@ -242,7 +246,7 @@ void TxtReaderActivity::initializeReader() { viewportWidth = renderer.getScreenWidth() - cachedOrientedMarginLeft - cachedOrientedMarginRight; const int viewportHeight = renderer.getScreenHeight() - cachedOrientedMarginTop - cachedOrientedMarginBottom; - const int lineHeight = renderer.getLineHeight(cachedFontId); + const int lineHeight = getReaderLineHeight(renderer, cachedFontId); linesPerPage = viewportHeight / lineHeight; if (linesPerPage < 1) linesPerPage = 1; @@ -378,7 +382,7 @@ void TxtReaderActivity::render(RenderLock&&) { } void TxtReaderActivity::renderPage() { - const int lineHeight = renderer.getLineHeight(cachedFontId); + const int lineHeight = getReaderLineHeight(renderer, cachedFontId); const int contentWidth = viewportWidth; // Render text lines with alignment @@ -644,7 +648,7 @@ bool TxtReaderActivity::drawCurrentPageToBuffer(const std::string& filePath, Gfx const int vw = renderer.getScreenWidth() - marginLeft - marginRight; const int vh = renderer.getScreenHeight() - marginTop - marginBottom; - const int lineHeight = renderer.getLineHeight(fontId); + const int lineHeight = getReaderLineHeight(renderer, fontId); const int linesPerPage = std::max(1, vh / lineHeight); // Step 1: Try to read the saved page and its file offset from progress.bin. diff --git a/src/activities/settings/FontDownloadActivity.cpp b/src/activities/settings/FontDownloadActivity.cpp index 98d2b6a2b6..5ff3e151ab 100644 --- a/src/activities/settings/FontDownloadActivity.cpp +++ b/src/activities/settings/FontDownloadActivity.cpp @@ -497,6 +497,8 @@ void FontDownloadActivity::downloadFamily(ManifestFamily& family) { if (attempt > 1) { LOG_DBG("FONT", "Retrying %s (%d/%d)", file.name.c_str(), attempt, FONT_DOWNLOAD_MAX_ATTEMPTS); } + LOG_DBG("FONT", "Download attempt %d/%d: %s (%zu bytes)", attempt, FONT_DOWNLOAD_MAX_ATTEMPTS, file.name.c_str(), + file.size); requestUpdateAndWait(); if (attempt > 1) delay(FONT_DOWNLOAD_RETRY_DELAY_MS); @@ -514,6 +516,7 @@ void FontDownloadActivity::downloadFamily(ManifestFamily& family) { }, &cancelRequested_, "", "", downloadOptions); if (result == HttpDownloader::ABORTED) { + LOG_INF("FONT", "Download cancelled: %s", file.name.c_str()); Storage.remove(tempPath); { RenderLock lock(*this); @@ -525,8 +528,12 @@ void FontDownloadActivity::downloadFamily(ManifestFamily& family) { return; } if (result == HttpDownloader::OK) { + LOG_DBG("FONT", "Download attempt succeeded: %s (%d/%d)", file.name.c_str(), attempt, + FONT_DOWNLOAD_MAX_ATTEMPTS); break; } + LOG_ERR("FONT", "Download attempt failed: %s (%d/%d, error=%d)", file.name.c_str(), attempt, + FONT_DOWNLOAD_MAX_ATTEMPTS, result); } if (result != HttpDownloader::OK) { diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index 3b7c19f90d..34d155985e 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -103,6 +103,19 @@ void drawSystemVersionFooter(const GfxRenderer& renderer, const int pageWidth, c drawCenteredTextLine(renderer, pageWidth, bottomLineY - lineHeight, firstLine); drawCenteredTextLine(renderer, pageWidth, bottomLineY, secondLine); } + +std::string formatSettingValue(const SettingInfo& setting) { + if (setting.nameId == StrId::STR_TIME_TO_SLEEP) { + char valueBuffer[32]; + snprintf(valueBuffer, sizeof(valueBuffer), tr(STR_SLEEP_TIMER_VALUE_FORMAT), + static_cast(SETTINGS.*(setting.valuePtr))); + return valueBuffer; + } + if (setting.valuePtr == &CrossPointSettings::lineHeightPercent) { + return std::to_string(SETTINGS.*(setting.valuePtr)) + "%"; + } + return std::to_string(SETTINGS.*(setting.valuePtr)); +} } // namespace void SettingsActivity::rebuildSettingsLists() { @@ -336,6 +349,10 @@ void SettingsActivity::toggleCurrentSetting() { openSleepTimeoutPicker(); return; } + if (setting.valuePtr == &CrossPointSettings::lineHeightPercent) { + openLineHeightPicker(); + return; + } if (setting.type == SettingType::TOGGLE && setting.valuePtr != nullptr) { // Toggle the boolean value using the member pointer @@ -462,6 +479,23 @@ void SettingsActivity::openSleepTimeoutPicker() { }); } +void SettingsActivity::openLineHeightPicker() { + startActivityForResult( + std::make_unique( + renderer, mappedInput, "LineHeightInterval", StrId::STR_LINE_SPACING, StrId::STR_PERCENT_STEP_HINT, + SETTINGS.lineHeightPercent, CrossPointSettings::MIN_LINE_HEIGHT_PERCENT, + CrossPointSettings::MAX_LINE_HEIGHT_PERCENT, 1, 10, StrId::STR_NONE_OPT, /*readerActivity=*/false, + /*allowPowerAsConfirm=*/false, /*ignoreInitialConfirmRelease=*/false, /*showPercentValue=*/true), + [this](const ActivityResult& result) { + if (!result.isCancelled) { + SETTINGS.lineHeightPercent = CrossPointSettings::clampedLineHeightPercent( + static_cast(std::get(result.data).value)); + SETTINGS.saveToFile(); + } + requestUpdate(); + }); +} + void SettingsActivity::render(RenderLock&&) { renderer.clearScreen(); @@ -504,14 +538,7 @@ void SettingsActivity::render(RenderLock&&) { const uint8_t value = setting.valueGetter(); valueText = settingEnumOptionLabel(setting, value); } else if (setting.type == SettingType::VALUE && setting.valuePtr != nullptr) { - if (setting.nameId == StrId::STR_TIME_TO_SLEEP) { - char valueBuffer[32]; - snprintf(valueBuffer, sizeof(valueBuffer), tr(STR_SLEEP_TIMER_VALUE_FORMAT), - static_cast(SETTINGS.*(setting.valuePtr))); - valueText = valueBuffer; - } else { - valueText = std::to_string(SETTINGS.*(setting.valuePtr)); - } + valueText = formatSettingValue(setting); } return valueText; }, @@ -526,7 +553,9 @@ void SettingsActivity::render(RenderLock&&) { const auto confirmLabel = (selectedSettingIndex == 0) ? I18N.get(categoryNames[(selectedCategoryIndex + 1) % categoryCount]) - : (selectedSettingIndex > 0 && (*currentSettings)[selectedSettingIndex - 1].nameId == StrId::STR_TIME_TO_SLEEP + : (selectedSettingIndex > 0 && + ((*currentSettings)[selectedSettingIndex - 1].nameId == StrId::STR_TIME_TO_SLEEP || + (*currentSettings)[selectedSettingIndex - 1].valuePtr == &CrossPointSettings::lineHeightPercent) ? tr(STR_SELECT) : tr(STR_TOGGLE)); const auto labels = mappedInput.mapLabels(tr(STR_BACK), confirmLabel, tr(STR_DIR_UP), tr(STR_DIR_DOWN)); diff --git a/src/activities/settings/SettingsActivity.h b/src/activities/settings/SettingsActivity.h index 8a2cf0fec1..99f28583a9 100644 --- a/src/activities/settings/SettingsActivity.h +++ b/src/activities/settings/SettingsActivity.h @@ -191,6 +191,7 @@ class SettingsActivity final : public Activity { void enterCategory(int categoryIndex); void toggleCurrentSetting(); void openSleepTimeoutPicker(); + void openLineHeightPicker(); void rebuildSettingsLists(); void syncQuickResumeTimeoutForSleepScreen(bool sleepScreenChanged, bool quickResumeTimeoutChanged); diff --git a/src/activities/util/IntervalSelectionActivity.cpp b/src/activities/util/IntervalSelectionActivity.cpp index 9a1bf81b53..f7bb5007ba 100644 --- a/src/activities/util/IntervalSelectionActivity.cpp +++ b/src/activities/util/IntervalSelectionActivity.cpp @@ -62,7 +62,9 @@ void IntervalSelectionActivity::render(RenderLock&&) { renderer.drawCenteredText(UI_12_FONT_ID, 15, I18N.get(titleId), true, EpdFontFamily::BOLD); char formattedValue[32]; - if (valueFormatId != StrId::STR_NONE_OPT) { + if (showPercentValue) { + snprintf(formattedValue, sizeof(formattedValue), "%d%%", value); + } else if (valueFormatId != StrId::STR_NONE_OPT) { snprintf(formattedValue, sizeof(formattedValue), I18N.get(valueFormatId), static_cast(value)); } else { snprintf(formattedValue, sizeof(formattedValue), "%d", value); diff --git a/src/activities/util/IntervalSelectionActivity.h b/src/activities/util/IntervalSelectionActivity.h index 1e7bbeecfd..781a34a7c7 100644 --- a/src/activities/util/IntervalSelectionActivity.h +++ b/src/activities/util/IntervalSelectionActivity.h @@ -14,7 +14,7 @@ class IntervalSelectionActivity final : public Activity { StrId titleId, StrId stepHintId, int initialValue, int minValue, int maxValue, int smallStep, int largeStep, StrId valueFormatId = StrId::STR_NONE_OPT, bool readerActivity = false, bool allowPowerAsConfirm = false, - bool ignoreInitialConfirmRelease = false) + bool ignoreInitialConfirmRelease = false, bool showPercentValue = false) : Activity(activityName, renderer, mappedInput), titleId(titleId), stepHintId(stepHintId), @@ -26,7 +26,8 @@ class IntervalSelectionActivity final : public Activity { largeStep(largeStep), readerActivity(readerActivity), allowPowerAsConfirm(allowPowerAsConfirm), - ignoreConfirmRelease(ignoreInitialConfirmRelease) {} + ignoreConfirmRelease(ignoreInitialConfirmRelease), + showPercentValue(showPercentValue) {} void onEnter() override; void loop() override; @@ -46,6 +47,7 @@ class IntervalSelectionActivity final : public Activity { bool readerActivity; bool allowPowerAsConfirm; bool ignoreConfirmRelease; + bool showPercentValue; ButtonNavigator buttonNavigator; void adjustValue(int delta); diff --git a/src/components/themes/lyra/LyraCarouselTheme.h b/src/components/themes/lyra/LyraCarouselTheme.h index 9aa9ddafa9..6e1bff40a1 100644 --- a/src/components/themes/lyra/LyraCarouselTheme.h +++ b/src/components/themes/lyra/LyraCarouselTheme.h @@ -8,46 +8,23 @@ class GfxRenderer; // Lyra Carousel theme metrics (zero runtime cost) namespace LyraCarouselMetrics { -constexpr ThemeMetrics values = {.batteryWidth = 16, - .batteryHeight = 12, - .topPadding = 5, - .batteryBarHeight = 40, - .headerHeight = 84, - .verticalSpacing = 16, - .contentSidePadding = 20, - .listRowHeight = 35, - .listWithSubtitleRowHeight = 60, - .menuRowHeight = 64, - .menuSpacing = 8, - .tabSpacing = 8, - .tabBarHeight = 40, - .scrollBarWidth = 4, - .scrollBarRightOffset = 5, - .homeTopPadding = 28, - .homeCoverHeight = 600, - .homeCoverTileHeight = 660, - .homeRecentBooksCount = 3, - .homeContinueReadingInMenu = false, - .homeMenuTopOffset = 16, - .buttonHintsHeight = 40, - .sideButtonHintsWidth = 30, - .progressBarHeight = 16, - .progressBarMarginTop = 1, - .statusBarHorizontalMargin = 5, - .statusBarVerticalMargin = 19, - .keyboardKeyWidth = 31, - .keyboardKeyHeight = 50, - .keyboardKeySpacing = 0, - .keyboardBottomKeyHeight = 35, - .keyboardBottomKeySpacing = 5, - .keyboardBottomAligned = true, - .keyboardCenteredText = true, - .keyboardVerticalOffset = -7, - .keyboardTextFieldWidthPercent = 85, - .keyboardWidthPercent = 90, - .keyboardKeyCornerRadius = 6}; +constexpr ThemeMetrics makeValues() { + ThemeMetrics v = LyraMetrics::values; + v.listRowHeight = 35; + v.menuRowHeight = 64; + v.menuSpacing = 8; + v.homeTopPadding = 28; + v.homeCoverHeight = 600; + v.homeCoverTileHeight = 660; + v.homeRecentBooksCount = 3; + v.keyboardKeyHeight = 50; + v.keyboardCenteredText = true; + return v; } +constexpr ThemeMetrics values = makeValues(); +} // namespace LyraCarouselMetrics + class LyraCarouselTheme : public LyraTheme { public: // Max cache geometry for the carousel artwork area. diff --git a/src/network/HttpDownloader.cpp b/src/network/HttpDownloader.cpp index 8d37eaba28..be350d2ce3 100644 --- a/src/network/HttpDownloader.cpp +++ b/src/network/HttpDownloader.cpp @@ -21,17 +21,36 @@ namespace { constexpr size_t PROGRESS_UPDATE_BYTES = 64 * 1024; constexpr uint32_t PROGRESS_UPDATE_MS = 250; -constexpr size_t DOWNLOAD_BUFFER_SIZE = 1024; +constexpr size_t DEFAULT_DOWNLOAD_BUFFER_SIZE = 1024; constexpr uint16_t HTTP_RESPONSE_TIMEOUT_MS = 15000; constexpr int32_t HTTP_CONNECT_TIMEOUT_MS = 10000; constexpr uint32_t HTTPS_HANDSHAKE_TIMEOUT_SECONDS = 10; -constexpr uint32_t DOWNLOAD_IDLE_TIMEOUT_MS = 15000; +constexpr uint32_t DOWNLOAD_IDLE_TIMEOUT_MS = 30000; void logNetworkState(const char* phase) { LOG_DBG("HTTP", "%s: heap free=%u maxAlloc=%u wifi=%d rssi=%d", phase, ESP.getFreeHeap(), ESP.getMaxAllocHeap(), static_cast(WiFi.status()), WiFi.status() == WL_CONNECTED ? WiFi.RSSI() : 0); } +void logDownloadState(const char* phase, const size_t downloaded, const size_t total, const uint32_t idleMs) { + LOG_ERR("HTTP", "%s after %zu/%zu bytes (idle=%lu ms, timeout=%lu ms)", phase, downloaded, total, + static_cast(idleMs), static_cast(DOWNLOAD_IDLE_TIMEOUT_MS)); + logNetworkState(phase); +} + +bool isCancelRequested(bool* cancelFlag, const HttpDownloader::CancelCallback& shouldCancel) { + if (cancelFlag && *cancelFlag) { + return true; + } + if (shouldCancel && shouldCancel()) { + if (cancelFlag) { + *cancelFlag = true; + } + return true; + } + return false; +} + class ProgressNotifier { public: ProgressNotifier(size_t total, HttpDownloader::ProgressCallback progress) @@ -58,8 +77,12 @@ class ProgressNotifier { class FileWriteStream final : public Stream { public: - FileWriteStream(FsFile& file, size_t total, HttpDownloader::ProgressCallback progress, const bool* cancelFlag) - : file_(file), progress_(total, std::move(progress)), cancelFlag_(cancelFlag) {} + FileWriteStream(FsFile& file, size_t total, HttpDownloader::ProgressCallback progress, bool* cancelFlag, + HttpDownloader::CancelCallback shouldCancel) + : file_(file), + progress_(total, std::move(progress)), + cancelFlag_(cancelFlag), + shouldCancel_(std::move(shouldCancel)) {} size_t write(uint8_t byte) override { return write(&byte, 1); } @@ -68,7 +91,7 @@ class FileWriteStream final : public Stream { return 0; } - if (cancelFlag_ && *cancelFlag_) { + if (isCancelRequested(cancelFlag_, shouldCancel_)) { writeOk_ = false; return 0; } @@ -95,28 +118,30 @@ class FileWriteStream final : public Stream { size_t downloaded_ = 0; bool writeOk_ = true; ProgressNotifier progress_; - const bool* cancelFlag_; + bool* cancelFlag_; + HttpDownloader::CancelCallback shouldCancel_; }; HttpDownloader::DownloadError downloadKnownLengthBody(HTTPClient& http, FsFile& file, const size_t contentLength, HttpDownloader::ProgressCallback progress, size_t& downloaded, - const bool* cancelFlag) { + bool* cancelFlag, const size_t bufferSize, + const HttpDownloader::CancelCallback& shouldCancel) { auto* stream = http.getStreamPtr(); if (!stream) { LOG_ERR("HTTP", "Failed to get response stream"); return HttpDownloader::HTTP_ERROR; } - std::unique_ptr buffer(new (std::nothrow) uint8_t[DOWNLOAD_BUFFER_SIZE]); + std::unique_ptr buffer(new (std::nothrow) uint8_t[bufferSize]); if (!buffer) { - LOG_ERR("HTTP", "Failed to allocate %zu byte download buffer", DOWNLOAD_BUFFER_SIZE); + LOG_ERR("HTTP", "Failed to allocate %zu byte download buffer", bufferSize); return HttpDownloader::HTTP_ERROR; } ProgressNotifier progressNotifier(contentLength, std::move(progress)); uint32_t lastProgressMs = millis(); while (downloaded < contentLength) { - if (cancelFlag && *cancelFlag) { + if (isCancelRequested(cancelFlag, shouldCancel)) { return HttpDownloader::ABORTED; } const size_t remaining = contentLength - downloaded; @@ -124,25 +149,25 @@ HttpDownloader::DownloadError downloadKnownLengthBody(HTTPClient& http, FsFile& int available = stream->available(); if (available <= 0) { if (!http.connected()) { - LOG_ERR("HTTP", "Connection closed after %zu of %zu bytes", downloaded, contentLength); + logDownloadState("Connection closed", downloaded, contentLength, millis() - lastProgressMs); return HttpDownloader::HTTP_ERROR; } if (millis() - lastProgressMs >= DOWNLOAD_IDLE_TIMEOUT_MS) { - LOG_ERR("HTTP", "Read timed out after %zu of %zu bytes", downloaded, contentLength); + logDownloadState("Read timed out", downloaded, contentLength, millis() - lastProgressMs); return HttpDownloader::HTTP_ERROR; } delay(1); continue; } - const size_t toRead = std::min({DOWNLOAD_BUFFER_SIZE, remaining, static_cast(available)}); + const size_t toRead = std::min({bufferSize, remaining, static_cast(available)}); const size_t bytesRead = stream->readBytes(buffer.get(), toRead); if (bytesRead == 0) { if (millis() - lastProgressMs < DOWNLOAD_IDLE_TIMEOUT_MS) { delay(1); continue; } - LOG_ERR("HTTP", "Read timed out after %zu of %zu bytes", downloaded, contentLength); + logDownloadState("Read timed out", downloaded, contentLength, millis() - lastProgressMs); return HttpDownloader::HTTP_ERROR; } @@ -152,7 +177,7 @@ HttpDownloader::DownloadError downloadKnownLengthBody(HTTPClient& http, FsFile& progressNotifier.notify(downloaded, false); if (accepted != bytesRead) { - LOG_ERR("HTTP", "Write failed after %zu of %zu bytes", downloaded, contentLength); + logDownloadState("Write failed", downloaded, contentLength, 0); return HttpDownloader::FILE_ERROR; } @@ -260,9 +285,13 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string& client.reset(plainClient); } HTTPClient http; + const size_t bufferSize = options.bufferSize > 0 ? options.bufferSize : DEFAULT_DOWNLOAD_BUFFER_SIZE; LOG_DBG("HTTP", "Downloading: %s", url.c_str()); LOG_DBG("HTTP", "Destination: %s", destPath.c_str()); + LOG_DBG("HTTP", "Timeouts: connect=%ld ms response=%u ms idle=%lu ms buffer=%zu bytes", + static_cast(HTTP_CONNECT_TIMEOUT_MS), HTTP_RESPONSE_TIMEOUT_MS, + static_cast(DOWNLOAD_IDLE_TIMEOUT_MS), bufferSize); http.begin(*client, url.c_str()); http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); @@ -318,6 +347,9 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string& } else { LOG_DBG("HTTP", "Content-Length: unknown"); } + if (resumeOffset > 0) { + LOG_DBG("HTTP", "Resume offset: %zu bytes", resumeOffset); + } // Remove existing file if present, unless this is a resumable append. if (resumeOffset == 0 && Storage.exists(destPath.c_str())) { @@ -344,10 +376,11 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string& int writeResult = 0; if (contentLength > 0) { - transferError = downloadKnownLengthBody(http, file, contentLength, std::move(progress), downloaded, cancelFlag); + transferError = downloadKnownLengthBody(http, file, contentLength, std::move(progress), downloaded, cancelFlag, + bufferSize, options.shouldCancel); } else { // Let HTTPClient handle chunked decoding and stream body bytes into the file. - FileWriteStream fileStream(file, contentLength, std::move(progress), cancelFlag); + FileWriteStream fileStream(file, contentLength, std::move(progress), cancelFlag, std::move(options.shouldCancel)); writeResult = http.writeToStream(&fileStream); fileStream.finishProgress(); downloaded = fileStream.downloaded(); @@ -370,6 +403,8 @@ HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string& if (writeResult < 0) { LOG_ERR("HTTP", "writeToStream error: %d (%s)", writeResult, HTTPClient::errorToString(writeResult).c_str()); } + LOG_ERR("HTTP", "Transfer failed: error=%d downloaded=%zu expected=%zu preservePartial=%d resumePartial=%d", + static_cast(transferError), downloaded, contentLength, options.preservePartial, options.resumePartial); if (transferError == ABORTED || !options.preservePartial) { Storage.remove(destPath.c_str()); } diff --git a/src/network/HttpDownloader.h b/src/network/HttpDownloader.h index 34dad4574d..b09fcb2332 100644 --- a/src/network/HttpDownloader.h +++ b/src/network/HttpDownloader.h @@ -4,6 +4,7 @@ #include #include +#include /** * HTTP client utility for fetching content and downloading files. @@ -12,6 +13,7 @@ class HttpDownloader { public: using ProgressCallback = std::function; + using CancelCallback = std::function; enum DownloadError { OK = 0, @@ -21,11 +23,17 @@ class HttpDownloader { }; struct DownloadOptions { - explicit constexpr DownloadOptions(bool preservePartial = false, bool resumePartial = false) - : preservePartial(preservePartial), resumePartial(resumePartial) {} + explicit DownloadOptions(bool preservePartial = false, bool resumePartial = false, + CancelCallback shouldCancel = nullptr, size_t bufferSize = 1024) + : preservePartial(preservePartial), + resumePartial(resumePartial), + shouldCancel(std::move(shouldCancel)), + bufferSize(bufferSize) {} bool preservePartial; bool resumePartial; + CancelCallback shouldCancel; + size_t bufferSize; }; /**