diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index c3866016..21b09aaf 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,9 +1,18 @@ ## Summary -* **What is the goal of this PR?** (e.g., Fixes a bug in the user authentication module, Implements the new feature for - file uploading.) +* **What is the goal of this PR?** (e.g., Implements the new feature for file uploading.) * **What changes are included?** ## Additional Context -* Add any other information that might be helpful for the reviewer (e.g., performance implications, potential risks, specific areas to focus on). +* Add any other information that might be helpful for the reviewer (e.g., performance implications, potential risks, + specific areas to focus on). + +--- + +### AI Usage + +While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it +helps set the right context for reviewers. + +Did you use AI tools to help write this code? _**< YES | PARTIALLY | NO >**_ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be9a6e59..286f14aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,11 +7,11 @@ name: CI jobs: build: runs-on: ubuntu-latest - steps: - uses: actions/checkout@v6 with: submodules: recursive + - uses: actions/setup-python@v6 with: python-version: '3.14' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c8d1c830..df8d6679 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,17 +7,18 @@ on: jobs: build-release: runs-on: ubuntu-latest - steps: - uses: actions/checkout@v6 with: submodules: recursive + - uses: actions/cache@v5 with: path: | ~/.cache/pip ~/.platformio/.cache key: ${{ runner.os }}-pio + - uses: actions/setup-python@v6 with: python-version: '3.14' diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 26ff1075..70a765ba 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1,17 +1,16 @@ # CrossPoint User Guide -Welcome to the **CrossPoint** firmware. This guide outlines the hardware controls, navigation, and reading features of -the device. +Welcome to the **CrossPoint** firmware. This guide outlines the hardware controls, navigation, and reading features of the device. ## 1. Hardware Overview The device utilises the standard buttons on the Xtink X4 (in the same layout as the manufacturer firmware, by default): ### Button Layout -| Location | Buttons | -|-----------------|--------------------------------------------| -| **Bottom Edge** | **Back**, **Confirm**, **Left**, **Right** | -| **Right Side** | **Power**, **Volume Up**, **Volume Down** | +| Location | Buttons | +| --------------- | ---------------------------------------------------- | +| **Bottom Edge** | **Back**, **Confirm**, **Left**, **Right** | +| **Right Side** | **Power**, **Volume Up**, **Volume Down**, **Reset** | Button layout can be customized in **[Settings](#35-settings)**. @@ -21,8 +20,9 @@ Button layout can be customized in **[Settings](#35-settings)**. ### Power On / Off -To turn the device on or off, **press and hold the Power button for half a second**. In **[Settings](#35-settings)** you can configure -the power button to trigger on a short press instead of a long one. +To turn the device on or off, **press and hold the Power button for half a second**. In **[Settings](#35-settings)** you can configure the power button to trigger on a short press instead of a long one. + +To reboot the device (for example if it's frozen, or after a firmware update), press and release the Reset button, and then hold the Power button for a few seconds. ### First Launch @@ -37,15 +37,13 @@ Upon turning the device on for the first time, you will be placed on the **[Home ### 3.1 Home Screen -The Home Screen is the main entry point to the firmware. From here you can navigate to **[Reading Mode](#4-reading-mode)** with the most recently read book, **[Book Selection](#32-book-selection)**, -**[Settings](#35-settings)**, or the **[File Upload](#34-file-upload-screen)** screen. +The Home Screen is the main entry point to the firmware. From here you can navigate to **[Reading Mode](#4-reading-mode)** with the most recently read book, **[Book Selection](#32-book-selection)**, **[Settings](#35-settings)**, or the **[File Upload](#34-file-upload-screen)** screen. ### 3.2 Book Selection The Book Selection acts as a folder and file browser. -* **Navigate List:** Use **Left** (or **Volume Up**), or **Right** (or **Volume Down**) to move the selection cursor up - and down through folders and books. +* **Navigate List:** Use **Left** (or **Volume Up**), or **Right** (or **Volume Down**) to move the selection cursor up and down through folders and books. You can also long-press these buttons to scroll a full page up or down. * **Open Selection:** Press **Confirm** to open a folder or read a selected book. ### 3.3 Reading Mode @@ -54,42 +52,47 @@ See [Reading Mode](#4-reading-mode) below for more information. ### 3.4 File Upload Screen -The File Upload screen allows you to upload new e-books to the device. When you enter the screen, you'll be prompted with -a WiFi selection dialog and then your X4 will start hosting a web server. +The File Upload screen allows you to upload new e-books to the device. When you enter the screen, you'll be prompted with a WiFi selection dialog and then your X4 will start hosting a web server. See the [webserver docs](./docs/webserver.md) for more information on how to connect to the web server and upload files. +> [!TIP] +> Advanced users can also manage files programmatically or via the command line using `curl`. See the [webserver docs](./docs/webserver.md) for details. + ### 3.5 Settings The Settings screen allows you to configure the device's behavior. There are a few settings you can adjust: -- **Sleep Screen**: Which sleep screen to display when the device sleeps, options are: +- **Sleep Screen**: Which sleep screen to display when the device sleeps: - "Dark" (default) - The default dark sleep screen - "Light" - The same default sleep screen, on a white background - "Custom" - Custom images from the SD card, see [Sleep Screen](#36-sleep-screen) below for more information - "Cover" - The book cover image (Note: this is experimental and may not work as expected) -- **Status Bar**: Configure the status bar displayed while reading, options are: + - "Blank" - A blank screen +- **Status Bar**: Configure the status bar displayed while reading: - "None" - No status bar - "No Progress" - Show status bar without reading progress - "Full" - Show status bar with reading progress -- **Extra Paragraph Spacing**: If enabled, vertical space will be added between paragraphs in the book, if disabled, - paragraphs will not have vertical space between them, but will have first word indentation. +- **Extra Paragraph Spacing**: If enabled, vertical space will be added between paragraphs in the book. If disabled, paragraphs will not have vertical space between them, but will have first-line indentation. - **Short Power Button Click**: Whether to trigger the power button on a short press or a long press. -- **Reading Orientation**: Set the screen orientation for reading, options are: +- **Reading Orientation**: Set the screen orientation for reading: - "Portrait" (default) - Standard portrait orientation - "Landscape CW" - Landscape, rotated clockwise - "Inverted" - Portrait, upside down - "Landscape CCW" - Landscape, rotated counter-clockwise -- **Front Button Layout**: Configure the order of the bottom edge buttons, options are: - - "Bck, Cnfrm, Lft, Rght" (default) - Back, Confirm, Left, Right - - "Lft, Rght, Bck, Cnfrm" - Left, Right, Back, Confirm - - "Lft, Bck, Cnfrm, Rght" - Left, Back, Confirm, Right -- **Side Button Layout**: Swap the order of the volume buttons from Previous/Next to Next/Previous. This change is only in effect when reading. -- **Reader Font Family**: Choose the font used for reading, options are: +- **Front Button Layout**: Configure the order of the bottom edge buttons: + - Back, Confirm, Left, Right (default) + - Left, Right, Back, Confirm + - Left, Back, Confirm, Right +- **Side Button Layout**: Swap the order of the up and down volume buttons from Previous/Next to Next/Previous. This change is only in effect when reading. +- **Reader Font Family**: Choose the font used for reading: - "Bookerly" (default) - Amazon's reading font - "Noto Sans" - Google's sans-serif font - "Open Dyslexic" - Font designed for readers with dyslexia -- **Reader Font Size**: Adjust the text size for reading, options are "Small", "Medium", "Large", or "X Large". -- **Reader Line Spacing**: Adjust the spacing between lines, options are "Tight", "Normal", or "Wide". +- **Reader Font Size**: Adjust the text size for reading; options are "Small", "Medium", "Large", or "X Large". +- **Reader Line Spacing**: Adjust the spacing between lines; options are "Tight", "Normal", or "Wide". +- **Reader Paragraph Alignment**: Set the alignment of paragraphs; options are "Justified" (default), "Left", "Center", or "Right". +- **Time to Sleep**: Set the duration of inactivity before the device automatically goes to sleep. +- **Refresh Frequency**: Set how often the screen does a full refresh while reading to reduce ghosting. - **Check for updates**: Check for firmware updates over WiFi. ### 3.6 Sleep Screen @@ -97,9 +100,7 @@ The Settings screen allows you to configure the device's behavior. There are a f You can customize the sleep screen by placing custom images in specific locations on the SD card: - **Single Image:** Place a file named `sleep.bmp` in the root directory. -- **Multiple Images:** Create a `sleep` directory in the root of the SD card and place any number of `.bmp` images - inside. If images are found in this directory, they will take priority over the `sleep.bmp` file, and one will be - randomly selected each time the device sleeps. +- **Multiple Images:** Create a `sleep` directory in the root of the SD card and place any number of `.bmp` images inside. If images are found in this directory, they will take priority over the `sleep.bmp` file, and one will be randomly selected each time the device sleeps. > [!NOTE] > You'll need to set the **Sleep Screen** setting to **Custom** in order to use these images. @@ -117,17 +118,19 @@ Once you have opened a book, the button layout changes to facilitate reading. ### Page Turning | Action | Buttons | -|-------------------|--------------------------------------| +| ----------------- | ------------------------------------ | | **Previous Page** | Press **Left** _or_ **Volume Up** | | **Next Page** | Press **Right** _or_ **Volume Down** | +The role of the volume (side) buttons can be swapped in **[Settings](#35-settings)**. + ### Chapter Navigation * **Next Chapter:** Press and **hold** the **Right** (or **Volume Down**) button briefly, then release. * **Previous Chapter:** Press and **hold** the **Left** (or **Volume Up**) button briefly, then release. ### System Navigation * **Return to Book Selection:** Press **Back** to close the book and return to the **[Book Selection](#32-book-selection)** screen. -* **Return to Home:** Press and hold **Back** to close the book and return to the **[Home](#31-home-screen)** screen. +* **Return to Home:** Press and **hold** the **Back** button to close the book and return to the **[Home](#31-home-screen)** screen. * **Chapter Menu:** Press **Confirm** to open the **[Table of Contents/Chapter Selection](#5-chapter-selection-screen)**. --- @@ -144,7 +147,6 @@ Accessible by pressing **Confirm** while inside a book. ## 6. Current Limitations & Roadmap -Please note that this firmware is currently in active development. The following features are **not yet supported** but -are planned for future updates: +Please note that this firmware is currently in active development. The following features are **not yet supported** but are planned for future updates: * **Images:** Embedded images in e-books will not render. diff --git a/docs/webserver.md b/docs/webserver.md index 2c96b8eb..2285a927 100644 --- a/docs/webserver.md +++ b/docs/webserver.md @@ -170,6 +170,40 @@ This is useful for organizing your ebooks by genre, author, or series. --- +## Command Line File Management + +For power users, you can manage files directly from your terminal using `curl` while the device is in File Upload mode. + +### Uploading a File +To upload a file to the root directory, use the following command: +```bash +curl -F "file=@book.epub" "http://crosspoint.local/upload?path=/" +``` + +* **`-F "file=@filename"`**: Points to the local file on your computer. +* **`path=/`**: The destination folder on the device SD card. + +### Deleting a File + +To delete a specific file, provide the full path on the SD card: + +```bash +curl -F "path=/folder/file.epub" "http://crosspoint.local/delete" +``` + +### Advanced Flags + +For more reliable transfers of large EPUB files, consider adding these flags: + +* `-#`: Shows a simple progress bar. +* `--connect-timeout 30`: Limits how long curl waits to establish a connection (in seconds). +* `--max-time 300`: Sets a maximum duration for the entire transfer (5 minutes). + +> [!NOTE] +> These examples use `crosspoint.local`. If your network does not support mDNS or the address does not resolve, replace it with the specific **IP Address** displayed on your device screen (e.g., `http://192.168.1.102/`). + +--- + ## Troubleshooting ### Cannot See the Device on the Network diff --git a/lib/Epub/Epub.cpp b/lib/Epub/Epub.cpp index 7c6e0658..7218317f 100644 --- a/lib/Epub/Epub.cpp +++ b/lib/Epub/Epub.cpp @@ -167,7 +167,10 @@ bool Epub::parseTocNavFile() const { } const auto navSize = tempNavFile.size(); - TocNavParser navParser(contentBasePath, navSize, bookMetadataCache.get()); + // Note: We can't use `contentBasePath` here as the nav file may be in a different folder to the content.opf + // and the HTMLX nav file will have hrefs relative to itself + const std::string navContentBasePath = tocNavItem.substr(0, tocNavItem.find_last_of('/') + 1); + TocNavParser navParser(navContentBasePath, navSize, bookMetadataCache.get()); if (!navParser.setup()) { Serial.printf("[%lu] [EBP] Could not setup toc nav parser\n", millis()); @@ -345,11 +348,14 @@ const std::string& Epub::getAuthor() const { return bookMetadataCache->coreMetadata.author; } -std::string Epub::getCoverBmpPath() const { return cachePath + "/cover.bmp"; } +std::string Epub::getCoverBmpPath(bool cropped) const { + const auto coverFileName = "cover" + cropped ? "_crop" : ""; + return cachePath + "/" + coverFileName + ".bmp"; +} -bool Epub::generateCoverBmp() const { +bool Epub::generateCoverBmp(bool cropped) const { // Already generated, return true - if (SdMan.exists(getCoverBmpPath().c_str())) { + if (SdMan.exists(getCoverBmpPath(cropped).c_str())) { return true; } @@ -381,7 +387,7 @@ bool Epub::generateCoverBmp() const { } FsFile coverBmp; - if (!SdMan.openFileForWrite("EBP", getCoverBmpPath(), coverBmp)) { + if (!SdMan.openFileForWrite("EBP", getCoverBmpPath(cropped), coverBmp)) { coverJpg.close(); return false; } @@ -392,7 +398,7 @@ bool Epub::generateCoverBmp() const { if (!success) { Serial.printf("[%lu] [EBP] Failed to generate BMP from JPG cover image\n", millis()); - SdMan.remove(getCoverBmpPath().c_str()); + SdMan.remove(getCoverBmpPath(cropped).c_str()); } Serial.printf("[%lu] [EBP] Generated BMP from JPG cover image, success: %s\n", millis(), success ? "yes" : "no"); return success; diff --git a/lib/Epub/Epub.h b/lib/Epub/Epub.h index e18acfd5..3b84c18a 100644 --- a/lib/Epub/Epub.h +++ b/lib/Epub/Epub.h @@ -44,8 +44,8 @@ class Epub { const std::string& getPath() const; const std::string& getTitle() const; const std::string& getAuthor() const; - std::string getCoverBmpPath() const; - bool generateCoverBmp() const; + std::string getCoverBmpPath(bool cropped = false) const; + bool generateCoverBmp(bool cropped = false) const; uint8_t* readItemContentsToBytes(const std::string& itemHref, size_t* size = nullptr, bool trailingNullByte = false) const; bool readItemContentsToStream(const std::string& itemHref, Print& out, size_t chunkSize) const; diff --git a/lib/Epub/Epub/BookMetadataCache.cpp b/lib/Epub/Epub/BookMetadataCache.cpp index 06b4f458..52e48098 100644 --- a/lib/Epub/Epub/BookMetadataCache.cpp +++ b/lib/Epub/Epub/BookMetadataCache.cpp @@ -9,7 +9,7 @@ #include "FsHelpers.h" namespace { -constexpr uint8_t BOOK_CACHE_VERSION = 3; +constexpr uint8_t BOOK_CACHE_VERSION = 4; constexpr char bookBinFile[] = "/book.bin"; constexpr char tmpSpineBinFile[] = "/spine.bin.tmp"; constexpr char tmpTocBinFile[] = "/toc.bin.tmp"; diff --git a/lib/Epub/Epub/htmlEntities.cpp b/lib/Epub/Epub/htmlEntities.cpp deleted file mode 100644 index f44a1584..00000000 --- a/lib/Epub/Epub/htmlEntities.cpp +++ /dev/null @@ -1,163 +0,0 @@ -// from -// https://github.com/atomic14/diy-esp32-epub-reader/blob/2c2f57fdd7e2a788d14a0bcb26b9e845a47aac42/lib/Epub/RubbishHtmlParser/htmlEntities.cpp - -#include "htmlEntities.h" - -#include -#include - -const int MAX_ENTITY_LENGTH = 10; - -// Use book: entities_ww2.epub to test this (Page 7: Entities parser test) -// Note the supported keys are only in lowercase -// Store the mappings in a unordered hash map -static std::unordered_map entity_lookup( - {{""", "\""}, {"⁄", "⁄"}, {"&", "&"}, {"<", "<"}, {">", ">"}, - {"À", "À"}, {"Á", "Á"}, {"Â", "Â"}, {"Ã", "Ã"}, {"Ä", "Ä"}, - {"Å", "Å"}, {"Æ", "Æ"}, {"Ç", "Ç"}, {"È", "È"}, {"É", "É"}, - {"Ê", "Ê"}, {"Ë", "Ë"}, {"Ì", "Ì"}, {"Í", "Í"}, {"Î", "Î"}, - {"Ï", "Ï"}, {"Ð", "Ð"}, {"Ñ", "Ñ"}, {"Ò", "Ò"}, {"Ó", "Ó"}, - {"Ô", "Ô"}, {"Õ", "Õ"}, {"Ö", "Ö"}, {"Ø", "Ø"}, {"Ù", "Ù"}, - {"Ú", "Ú"}, {"Û", "Û"}, {"Ü", "Ü"}, {"Ý", "Ý"}, {"Þ", "Þ"}, - {"ß", "ß"}, {"à", "à"}, {"á", "á"}, {"â", "â"}, {"ã", "ã"}, - {"ä", "ä"}, {"å", "å"}, {"æ", "æ"}, {"ç", "ç"}, {"è", "è"}, - {"é", "é"}, {"ê", "ê"}, {"ë", "ë"}, {"ì", "ì"}, {"í", "í"}, - {"î", "î"}, {"ï", "ï"}, {"ð", "ð"}, {"ñ", "ñ"}, {"ò", "ò"}, - {"ó", "ó"}, {"ô", "ô"}, {"õ", "õ"}, {"ö", "ö"}, {"ø", "ø"}, - {"ù", "ù"}, {"ú", "ú"}, {"û", "û"}, {"ü", "ü"}, {"ý", "ý"}, - {"þ", "þ"}, {"ÿ", "ÿ"}, {" ", " "}, {"¡", "¡"}, {"¢", "¢"}, - {"£", "£"}, {"¤", "¤"}, {"¥", "¥"}, {"¦", "¦"}, {"§", "§"}, - {"¨", "¨"}, {"©", "©"}, {"ª", "ª"}, {"«", "«"}, {"¬", "¬"}, - {"­", "­"}, {"®", "®"}, {"¯", "¯"}, {"°", "°"}, {"±", "±"}, - {"²", "²"}, {"³", "³"}, {"´", "´"}, {"µ", "µ"}, {"¶", "¶"}, - {"¸", "¸"}, {"¹", "¹"}, {"º", "º"}, {"»", "»"}, {"¼", "¼"}, - {"½", "½"}, {"¾", "¾"}, {"¿", "¿"}, {"×", "×"}, {"÷", "÷"}, - {"∀", "∀"}, {"∂", "∂"}, {"∃", "∃"}, {"∅", "∅"}, {"∇", "∇"}, - {"∈", "∈"}, {"∉", "∉"}, {"∋", "∋"}, {"∏", "∏"}, {"∑", "∑"}, - {"−", "−"}, {"∗", "∗"}, {"√", "√"}, {"∝", "∝"}, {"∞", "∞"}, - {"∠", "∠"}, {"∧", "∧"}, {"∨", "∨"}, {"∩", "∩"}, {"∪", "∪"}, - {"∫", "∫"}, {"∴", "∴"}, {"∼", "∼"}, {"≅", "≅"}, {"≈", "≈"}, - {"≠", "≠"}, {"≡", "≡"}, {"≤", "≤"}, {"≥", "≥"}, {"⊂", "⊂"}, - {"⊃", "⊃"}, {"⊄", "⊄"}, {"⊆", "⊆"}, {"⊇", "⊇"}, {"⊕", "⊕"}, - {"⊗", "⊗"}, {"⊥", "⊥"}, {"⋅", "⋅"}, {"Α", "Α"}, {"Β", "Β"}, - {"Γ", "Γ"}, {"Δ", "Δ"}, {"Ε", "Ε"}, {"Ζ", "Ζ"}, {"Η", "Η"}, - {"Θ", "Θ"}, {"Ι", "Ι"}, {"Κ", "Κ"}, {"Λ", "Λ"}, {"Μ", "Μ"}, - {"Ν", "Ν"}, {"Ξ", "Ξ"}, {"Ο", "Ο"}, {"Π", "Π"}, {"Ρ", "Ρ"}, - {"Σ", "Σ"}, {"Τ", "Τ"}, {"Υ", "Υ"}, {"Φ", "Φ"}, {"Χ", "Χ"}, - {"Ψ", "Ψ"}, {"Ω", "Ω"}, {"α", "α"}, {"β", "β"}, {"γ", "γ"}, - {"δ", "δ"}, {"ε", "ε"}, {"ζ", "ζ"}, {"η", "η"}, {"θ", "θ"}, - {"ι", "ι"}, {"κ", "κ"}, {"λ", "λ"}, {"μ", "μ"}, {"ν", "ν"}, - {"ξ", "ξ"}, {"ο", "ο"}, {"π", "π"}, {"ρ", "ρ"}, {"ς", "ς"}, - {"σ", "σ"}, {"τ", "τ"}, {"υ", "υ"}, {"φ", "φ"}, {"χ", "χ"}, - {"ψ", "ψ"}, {"ω", "ω"}, {"ϑ", "ϑ"}, {"ϒ", "ϒ"}, {"ϖ", "ϖ"}, - {"Œ", "Œ"}, {"œ", "œ"}, {"Š", "Š"}, {"š", "š"}, {"Ÿ", "Ÿ"}, - {"ƒ", "ƒ"}, {"ˆ", "ˆ"}, {"˜", "˜"}, {" ", ""}, {" ", ""}, - {" ", ""}, {"‌", "‌"}, {"‍", "‍"}, {"‎", "‎"}, {"‏", "‏"}, - {"–", "–"}, {"—", "—"}, {"‘", "‘"}, {"’", "’"}, {"‚", "‚"}, - {"“", "“"}, {"”", "”"}, {"„", "„"}, {"†", "†"}, {"‡", "‡"}, - {"•", "•"}, {"…", "…"}, {"‰", "‰"}, {"′", "′"}, {"″", "″"}, - {"‹", "‹"}, {"›", "›"}, {"‾", "‾"}, {"€", "€"}, {"™", "™"}, - {"←", "←"}, {"↑", "↑"}, {"→", "→"}, {"↓", "↓"}, {"↔", "↔"}, - {"↵", "↵"}, {"⌈", "⌈"}, {"⌉", "⌉"}, {"⌊", "⌊"}, {"⌋", "⌋"}, - {"◊", "◊"}, {"♠", "♠"}, {"♣", "♣"}, {"♥", "♥"}, {"♦", "♦"}}); - -// converts from a unicode code point to the utf8 equivalent -void convert_to_utf8(const int code, std::string& res) { - // convert to a utf8 sequence - if (code < 0x80) { - res += static_cast(code); - } else if (code < 0x800) { - res += static_cast(0xc0 | (code >> 6)); - res += static_cast(0x80 | (code & 0x3f)); - } else if (code < 0x10000) { - res += static_cast(0xe0 | (code >> 12)); - res += static_cast(0x80 | ((code >> 6) & 0x3f)); - res += static_cast(0x80 | (code & 0x3f)); - } else if (code < 0x200000) { - res += static_cast(0xf0 | (code >> 18)); - res += static_cast(0x80 | ((code >> 12) & 0x3f)); - res += static_cast(0x80 | ((code >> 6) & 0x3f)); - res += static_cast(0x80 | (code & 0x3f)); - } else if (code < 0x4000000) { - res += static_cast(0xf8 | (code >> 24)); - res += static_cast(0x80 | ((code >> 18) & 0x3f)); - res += static_cast(0x80 | ((code >> 12) & 0x3f)); - res += static_cast(0x80 | ((code >> 6) & 0x3f)); - res += static_cast(0x80 | (code & 0x3f)); - } else if (code < 0x80000000) { - res += static_cast(0xfc | (code >> 30)); - res += static_cast(0x80 | ((code >> 24) & 0x3f)); - res += static_cast(0x80 | ((code >> 18) & 0x3f)); - res += static_cast(0x80 | ((code >> 12) & 0x3f)); - res += static_cast(0x80 | ((code >> 6) & 0x3f)); - } -} - -// handles numeric entities - e.g. Ӓ or ሴ -bool process_numeric_entity(const std::string& entity, std::string& res) { - int code = 0; - // is it hex? - if (entity[2] == 'x' || entity[2] == 'X') { - // parse the hex code - code = strtol(entity.substr(3, entity.size() - 3).c_str(), nullptr, 16); - } else { - code = strtol(entity.substr(2, entity.size() - 3).c_str(), nullptr, 10); - } - if (code != 0) { - // special handling for nbsp - if (code == 0xA0) { - res += " "; - } else { - convert_to_utf8(code, res); - } - return true; - } - return false; -} - -// handles named entities - e.g. & -bool process_string_entity(const std::string& entity, std::string& res) { - // it's a named entity - find it in the lookup table - // find it in the map - const auto it = entity_lookup.find(entity); - if (it != entity_lookup.end()) { - res += it->second; - return true; - } - return false; -} - -// replace all the entities in the string -std::string replaceHtmlEntities(const char* text) { - std::string res; - res.reserve(strlen(text)); - for (int i = 0; i < strlen(text); ++i) { - bool flag = false; - // do we have a potential entity? - if (text[i] == '&') { - // find the end of the entity - int j = i + 1; - while (j < strlen(text) && text[j] != ';' && j - i < MAX_ENTITY_LENGTH) { - j++; - } - if (j - i > 2) { - char entity[j - i + 1]; - strncpy(entity, text + i, j - i); - // is it a numeric code? - if (entity[1] == '#') { - flag = process_numeric_entity(entity, res); - } else { - flag = process_string_entity(entity, res); - } - // skip past the entity if we successfully decoded it - if (flag) { - i = j; - } - } - } - if (!flag) { - res += text[i]; - } - } - return res; -} diff --git a/lib/Epub/Epub/htmlEntities.h b/lib/Epub/Epub/htmlEntities.h deleted file mode 100644 index 109f717a..00000000 --- a/lib/Epub/Epub/htmlEntities.h +++ /dev/null @@ -1,7 +0,0 @@ -// from -// https://github.com/atomic14/diy-esp32-epub-reader/blob/2c2f57fdd7e2a788d14a0bcb26b9e845a47aac42/lib/Epub/RubbishHtmlParser/htmlEntities.cpp - -#pragma once -#include - -std::string replaceHtmlEntities(const char* text); diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index e5eb4d10..b96d28f8 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -6,7 +6,6 @@ #include #include "../Page.h" -#include "../htmlEntities.h" const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"}; constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]); @@ -130,7 +129,7 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char // Currently looking at whitespace, if there's anything in the partWordBuffer, flush it if (self->partWordBufferIndex > 0) { self->partWordBuffer[self->partWordBufferIndex] = '\0'; - self->currentTextBlock->addWord(std::move(replaceHtmlEntities(self->partWordBuffer)), fontStyle); + self->currentTextBlock->addWord(self->partWordBuffer, fontStyle); self->partWordBufferIndex = 0; } // Skip the whitespace char @@ -155,7 +154,7 @@ void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char // If we're about to run out of space, then cut the word off and start a new one if (self->partWordBufferIndex >= MAX_WORD_SIZE) { self->partWordBuffer[self->partWordBufferIndex] = '\0'; - self->currentTextBlock->addWord(std::move(replaceHtmlEntities(self->partWordBuffer)), fontStyle); + self->currentTextBlock->addWord(self->partWordBuffer, fontStyle); self->partWordBufferIndex = 0; } @@ -197,7 +196,7 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n } self->partWordBuffer[self->partWordBufferIndex] = '\0'; - self->currentTextBlock->addWord(std::move(replaceHtmlEntities(self->partWordBuffer)), fontStyle); + self->currentTextBlock->addWord(self->partWordBuffer, fontStyle); self->partWordBufferIndex = 0; } } diff --git a/lib/Epub/Epub/parsers/ContentOpfParser.cpp b/lib/Epub/Epub/parsers/ContentOpfParser.cpp index 2c90d01d..aee7e57b 100644 --- a/lib/Epub/Epub/parsers/ContentOpfParser.cpp +++ b/lib/Epub/Epub/parsers/ContentOpfParser.cpp @@ -167,7 +167,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name if (strcmp(atts[i], "id") == 0) { itemId = atts[i + 1]; } else if (strcmp(atts[i], "href") == 0) { - href = self->baseContentPath + atts[i + 1]; + href = FsHelpers::normalisePath(self->baseContentPath + atts[i + 1]); } else if (strcmp(atts[i], "media-type") == 0) { mediaType = atts[i + 1]; } else if (strcmp(atts[i], "properties") == 0) { @@ -243,7 +243,7 @@ void XMLCALL ContentOpfParser::startElement(void* userData, const XML_Char* name break; } } else if (strcmp(atts[i], "href") == 0) { - textHref = self->baseContentPath + atts[i + 1]; + textHref = FsHelpers::normalisePath(self->baseContentPath + atts[i + 1]); } } if ((type == "text" || (type == "start" && !self->textReferenceHref.empty())) && (textHref.length() > 0)) { diff --git a/lib/Epub/Epub/parsers/TocNavParser.cpp b/lib/Epub/Epub/parsers/TocNavParser.cpp index b8a4e7fb..454b2437 100644 --- a/lib/Epub/Epub/parsers/TocNavParser.cpp +++ b/lib/Epub/Epub/parsers/TocNavParser.cpp @@ -1,5 +1,6 @@ #include "TocNavParser.h" +#include #include #include "../BookMetadataCache.h" @@ -140,7 +141,7 @@ void XMLCALL TocNavParser::endElement(void* userData, const XML_Char* name) { if (strcmp(name, "a") == 0 && self->state == IN_ANCHOR) { // Create TOC entry when closing anchor tag (we have all data now) if (!self->currentLabel.empty() && !self->currentHref.empty()) { - std::string href = self->baseContentPath + self->currentHref; + std::string href = FsHelpers::normalisePath(self->baseContentPath + self->currentHref); std::string anchor; const size_t pos = href.find('#'); diff --git a/lib/Epub/Epub/parsers/TocNcxParser.cpp b/lib/Epub/Epub/parsers/TocNcxParser.cpp index b1fbb2fe..3e59451e 100644 --- a/lib/Epub/Epub/parsers/TocNcxParser.cpp +++ b/lib/Epub/Epub/parsers/TocNcxParser.cpp @@ -1,5 +1,6 @@ #include "TocNcxParser.h" +#include #include #include "../BookMetadataCache.h" @@ -159,7 +160,7 @@ void XMLCALL TocNcxParser::endElement(void* userData, const XML_Char* name) { // This is the safest place to push the data, assuming always comes before . // NCX spec says navLabel comes before content. if (!self->currentLabel.empty() && !self->currentSrc.empty()) { - std::string href = self->baseContentPath + self->currentSrc; + std::string href = FsHelpers::normalisePath(self->baseContentPath + self->currentSrc); std::string anchor; const size_t pos = href.find('#'); diff --git a/lib/GfxRenderer/Bitmap.cpp b/lib/GfxRenderer/Bitmap.cpp index 8cc8a5f3..1a3b4406 100644 --- a/lib/GfxRenderer/Bitmap.cpp +++ b/lib/GfxRenderer/Bitmap.cpp @@ -8,119 +8,15 @@ // ============================================================================ // Note: For cover images, dithering is done in JpegToBmpConverter.cpp // This file handles BMP reading - use simple quantization to avoid double-dithering -constexpr bool USE_FLOYD_STEINBERG = false; // Disabled - dithering done at JPEG conversion -constexpr bool USE_NOISE_DITHERING = false; // Hash-based noise dithering -// Brightness adjustments: -constexpr bool USE_BRIGHTNESS = false; // true: apply brightness/gamma adjustments -constexpr int BRIGHTNESS_BOOST = 20; // Brightness offset (0-50), only if USE_BRIGHTNESS=true -constexpr bool GAMMA_CORRECTION = false; // Gamma curve, only if USE_BRIGHTNESS=true +constexpr bool USE_ATKINSON = true; // Use Atkinson dithering instead of Floyd-Steinberg // ============================================================================ -// Integer approximation of gamma correction (brightens midtones) -static inline int applyGamma(int gray) { - if (!GAMMA_CORRECTION) return gray; - const int product = gray * 255; - int x = gray; - if (x > 0) { - x = (x + product / x) >> 1; - x = (x + product / x) >> 1; - } - return x > 255 ? 255 : x; -} - -// Simple quantization without dithering - just divide into 4 levels -static inline uint8_t quantizeSimple(int gray) { - if (USE_BRIGHTNESS) { - gray += BRIGHTNESS_BOOST; - if (gray > 255) gray = 255; - gray = applyGamma(gray); - } - return static_cast(gray >> 6); -} - -// Hash-based noise dithering - survives downsampling without moiré artifacts -static inline uint8_t quantizeNoise(int gray, int x, int y) { - if (USE_BRIGHTNESS) { - gray += BRIGHTNESS_BOOST; - if (gray > 255) gray = 255; - gray = applyGamma(gray); - } - - uint32_t hash = static_cast(x) * 374761393u + static_cast(y) * 668265263u; - hash = (hash ^ (hash >> 13)) * 1274126177u; - const int threshold = static_cast(hash >> 24); - - const int scaled = gray * 3; - if (scaled < 255) { - return (scaled + threshold >= 255) ? 1 : 0; - } else if (scaled < 510) { - return ((scaled - 255) + threshold >= 255) ? 2 : 1; - } else { - return ((scaled - 510) + threshold >= 255) ? 3 : 2; - } -} - -// Main quantization function -static inline uint8_t quantize(int gray, int x, int y) { - if (USE_NOISE_DITHERING) { - return quantizeNoise(gray, x, y); - } else { - return quantizeSimple(gray); - } -} - -// Floyd-Steinberg quantization with error diffusion and serpentine scanning -// Returns 2-bit value (0-3) and updates error buffers -static inline uint8_t quantizeFloydSteinberg(int gray, int x, int width, int16_t* errorCurRow, int16_t* errorNextRow, - bool reverseDir) { - // Add accumulated error to this pixel - int adjusted = gray + errorCurRow[x + 1]; - - // Clamp to valid range - if (adjusted < 0) adjusted = 0; - if (adjusted > 255) adjusted = 255; - - // Quantize to 4 levels (0, 85, 170, 255) - uint8_t quantized; - int quantizedValue; - if (adjusted < 43) { - quantized = 0; - quantizedValue = 0; - } else if (adjusted < 128) { - quantized = 1; - quantizedValue = 85; - } else if (adjusted < 213) { - quantized = 2; - quantizedValue = 170; - } else { - quantized = 3; - quantizedValue = 255; - } - - // Calculate error - int error = adjusted - quantizedValue; - - // Distribute error to neighbors (serpentine: direction-aware) - if (!reverseDir) { - // Left to right - errorCurRow[x + 2] += (error * 7) >> 4; // Right: 7/16 - errorNextRow[x] += (error * 3) >> 4; // Bottom-left: 3/16 - errorNextRow[x + 1] += (error * 5) >> 4; // Bottom: 5/16 - errorNextRow[x + 2] += (error) >> 4; // Bottom-right: 1/16 - } else { - // Right to left (mirrored) - errorCurRow[x] += (error * 7) >> 4; // Left: 7/16 - errorNextRow[x + 2] += (error * 3) >> 4; // Bottom-right: 3/16 - errorNextRow[x + 1] += (error * 5) >> 4; // Bottom: 5/16 - errorNextRow[x] += (error) >> 4; // Bottom-left: 1/16 - } - - return quantized; -} - Bitmap::~Bitmap() { delete[] errorCurRow; delete[] errorNextRow; + + delete atkinsonDitherer; + delete fsDitherer; } uint16_t Bitmap::readLE16(FsFile& f) { @@ -244,13 +140,14 @@ BmpReaderError Bitmap::parseHeaders() { return BmpReaderError::SeekPixelDataFailed; } - // Allocate Floyd-Steinberg error buffers if enabled - if (USE_FLOYD_STEINBERG) { - delete[] errorCurRow; - delete[] errorNextRow; - errorCurRow = new int16_t[width + 2](); // +2 for boundary handling - errorNextRow = new int16_t[width + 2](); - prevRowY = -1; + // Create ditherer if enabled (only for 2-bit output) + // Use OUTPUT dimensions for dithering (after prescaling) + if (bpp > 2 && dithering) { + if (USE_ATKINSON) { + atkinsonDitherer = new AtkinsonDitherer(width); + } else { + fsDitherer = new FloydSteinbergDitherer(width); + } } return BmpReaderError::Ok; @@ -261,17 +158,6 @@ BmpReaderError Bitmap::readNextRow(uint8_t* data, uint8_t* rowBuffer) const { // Note: rowBuffer should be pre-allocated by the caller to size 'rowBytes' if (file.read(rowBuffer, rowBytes) != rowBytes) return BmpReaderError::ShortReadRow; - // Handle Floyd-Steinberg error buffer progression - const bool useFS = USE_FLOYD_STEINBERG && errorCurRow && errorNextRow; - if (useFS) { - if (prevRowY != -1) { - // Sequential access - swap buffers - int16_t* temp = errorCurRow; - errorCurRow = errorNextRow; - errorNextRow = temp; - memset(errorNextRow, 0, (width + 2) * sizeof(int16_t)); - } - } prevRowY += 1; uint8_t* outPtr = data; @@ -282,12 +168,18 @@ BmpReaderError Bitmap::readNextRow(uint8_t* data, uint8_t* rowBuffer) const { // Helper lambda to pack 2bpp color into the output stream auto packPixel = [&](const uint8_t lum) { uint8_t color; - if (useFS) { - // Floyd-Steinberg error diffusion - color = quantizeFloydSteinberg(lum, currentX, width, errorCurRow, errorNextRow, false); + if (atkinsonDitherer) { + color = atkinsonDitherer->processPixel(adjustPixel(lum), currentX); + } else if (fsDitherer) { + color = fsDitherer->processPixel(adjustPixel(lum), currentX); } else { - // Simple quantization or noise dithering - color = quantize(lum, currentX, prevRowY); + if (bpp > 2) { + // Simple quantization or noise dithering + color = quantize(adjustPixel(lum), currentX, prevRowY); + } else { + // do not quantize 2bpp image + color = static_cast(lum >> 6); + } } currentOutByte |= (color << bitShift); if (bitShift == 0) { @@ -345,6 +237,11 @@ BmpReaderError Bitmap::readNextRow(uint8_t* data, uint8_t* rowBuffer) const { return BmpReaderError::UnsupportedBpp; } + if (atkinsonDitherer) + atkinsonDitherer->nextRow(); + else if (fsDitherer) + fsDitherer->nextRow(); + // Flush remaining bits if width is not a multiple of 4 if (bitShift != 6) *outPtr = currentOutByte; @@ -356,12 +253,9 @@ BmpReaderError Bitmap::rewindToData() const { return BmpReaderError::SeekPixelDataFailed; } - // Reset Floyd-Steinberg error buffers when rewinding - if (USE_FLOYD_STEINBERG && errorCurRow && errorNextRow) { - memset(errorCurRow, 0, (width + 2) * sizeof(int16_t)); - memset(errorNextRow, 0, (width + 2) * sizeof(int16_t)); - prevRowY = -1; - } + // Reset dithering when rewinding + if (fsDitherer) fsDitherer->reset(); + if (atkinsonDitherer) atkinsonDitherer->reset(); return BmpReaderError::Ok; } diff --git a/lib/GfxRenderer/Bitmap.h b/lib/GfxRenderer/Bitmap.h index a3f2e00c..9ac7cfbb 100644 --- a/lib/GfxRenderer/Bitmap.h +++ b/lib/GfxRenderer/Bitmap.h @@ -2,6 +2,10 @@ #include +#include + +#include "BitmapHelpers.h" + enum class BmpReaderError : uint8_t { Ok = 0, FileInvalid, @@ -28,7 +32,7 @@ class Bitmap { public: static const char* errorToString(BmpReaderError err); - explicit Bitmap(FsFile& file) : file(file) {} + explicit Bitmap(FsFile& file, bool dithering = false) : file(file), dithering(dithering) {} ~Bitmap(); BmpReaderError parseHeaders(); BmpReaderError readNextRow(uint8_t* data, uint8_t* rowBuffer) const; @@ -44,6 +48,7 @@ class Bitmap { static uint32_t readLE32(FsFile& f); FsFile& file; + bool dithering = false; int width = 0; int height = 0; bool topDown = false; @@ -56,4 +61,7 @@ class Bitmap { mutable int16_t* errorCurRow = nullptr; mutable int16_t* errorNextRow = nullptr; mutable int prevRowY = -1; // Track row progression for error propagation + + mutable AtkinsonDitherer* atkinsonDitherer = nullptr; + mutable FloydSteinbergDitherer* fsDitherer = nullptr; }; diff --git a/lib/GfxRenderer/BitmapHelpers.cpp b/lib/GfxRenderer/BitmapHelpers.cpp new file mode 100644 index 00000000..b0d9dc06 --- /dev/null +++ b/lib/GfxRenderer/BitmapHelpers.cpp @@ -0,0 +1,90 @@ +#include "BitmapHelpers.h" + +#include + +// Brightness/Contrast adjustments: +constexpr bool USE_BRIGHTNESS = false; // true: apply brightness/gamma adjustments +constexpr int BRIGHTNESS_BOOST = 10; // Brightness offset (0-50) +constexpr bool GAMMA_CORRECTION = false; // Gamma curve (brightens midtones) +constexpr float CONTRAST_FACTOR = 1.15f; // Contrast multiplier (1.0 = no change, >1 = more contrast) +constexpr bool USE_NOISE_DITHERING = false; // Hash-based noise dithering + +// Integer approximation of gamma correction (brightens midtones) +// Uses a simple curve: out = 255 * sqrt(in/255) ≈ sqrt(in * 255) +static inline int applyGamma(int gray) { + if (!GAMMA_CORRECTION) return gray; + // Fast integer square root approximation for gamma ~0.5 (brightening) + // This brightens dark/mid tones while preserving highlights + const int product = gray * 255; + // Newton-Raphson integer sqrt (2 iterations for good accuracy) + int x = gray; + if (x > 0) { + x = (x + product / x) >> 1; + x = (x + product / x) >> 1; + } + return x > 255 ? 255 : x; +} + +// Apply contrast adjustment around midpoint (128) +// factor > 1.0 increases contrast, < 1.0 decreases +static inline int applyContrast(int gray) { + // Integer-based contrast: (gray - 128) * factor + 128 + // Using fixed-point: factor 1.15 ≈ 115/100 + constexpr int factorNum = static_cast(CONTRAST_FACTOR * 100); + int adjusted = ((gray - 128) * factorNum) / 100 + 128; + if (adjusted < 0) adjusted = 0; + if (adjusted > 255) adjusted = 255; + return adjusted; +} +// Combined brightness/contrast/gamma adjustment +int adjustPixel(int gray) { + if (!USE_BRIGHTNESS) return gray; + + // Order: contrast first, then brightness, then gamma + gray = applyContrast(gray); + gray += BRIGHTNESS_BOOST; + if (gray > 255) gray = 255; + if (gray < 0) gray = 0; + gray = applyGamma(gray); + + return gray; +} +// Simple quantization without dithering - divide into 4 levels +// The thresholds are fine-tuned to the X4 display +uint8_t quantizeSimple(int gray) { + if (gray < 45) { + return 0; + } else if (gray < 70) { + return 1; + } else if (gray < 140) { + return 2; + } else { + return 3; + } +} + +// Hash-based noise dithering - survives downsampling without moiré artifacts +// Uses integer hash to generate pseudo-random threshold per pixel +static inline uint8_t quantizeNoise(int gray, int x, int y) { + uint32_t hash = static_cast(x) * 374761393u + static_cast(y) * 668265263u; + hash = (hash ^ (hash >> 13)) * 1274126177u; + const int threshold = static_cast(hash >> 24); + + const int scaled = gray * 3; + if (scaled < 255) { + return (scaled + threshold >= 255) ? 1 : 0; + } else if (scaled < 510) { + return ((scaled - 255) + threshold >= 255) ? 2 : 1; + } else { + return ((scaled - 510) + threshold >= 255) ? 3 : 2; + } +} + +// Main quantization function - selects between methods based on config +uint8_t quantize(int gray, int x, int y) { + if (USE_NOISE_DITHERING) { + return quantizeNoise(gray, x, y); + } else { + return quantizeSimple(gray); + } +} diff --git a/lib/GfxRenderer/BitmapHelpers.h b/lib/GfxRenderer/BitmapHelpers.h new file mode 100644 index 00000000..300527e0 --- /dev/null +++ b/lib/GfxRenderer/BitmapHelpers.h @@ -0,0 +1,233 @@ +#pragma once + +#include + +// Helper functions +uint8_t quantize(int gray, int x, int y); +uint8_t quantizeSimple(int gray); +int adjustPixel(int gray); + +// Atkinson dithering - distributes only 6/8 (75%) of error for cleaner results +// Error distribution pattern: +// X 1/8 1/8 +// 1/8 1/8 1/8 +// 1/8 +// Less error buildup = fewer artifacts than Floyd-Steinberg +class AtkinsonDitherer { + public: + explicit AtkinsonDitherer(int width) : width(width) { + errorRow0 = new int16_t[width + 4](); // Current row + errorRow1 = new int16_t[width + 4](); // Next row + errorRow2 = new int16_t[width + 4](); // Row after next + } + + ~AtkinsonDitherer() { + delete[] errorRow0; + delete[] errorRow1; + delete[] errorRow2; + } + // **1. EXPLICITLY DELETE THE COPY CONSTRUCTOR** + AtkinsonDitherer(const AtkinsonDitherer& other) = delete; + + // **2. EXPLICITLY DELETE THE COPY ASSIGNMENT OPERATOR** + AtkinsonDitherer& operator=(const AtkinsonDitherer& other) = delete; + + uint8_t processPixel(int gray, int x) { + // Add accumulated error + int adjusted = gray + errorRow0[x + 2]; + if (adjusted < 0) adjusted = 0; + if (adjusted > 255) adjusted = 255; + + // Quantize to 4 levels + uint8_t quantized; + int quantizedValue; + if (false) { // original thresholds + if (adjusted < 43) { + quantized = 0; + quantizedValue = 0; + } else if (adjusted < 128) { + quantized = 1; + quantizedValue = 85; + } else if (adjusted < 213) { + quantized = 2; + quantizedValue = 170; + } else { + quantized = 3; + quantizedValue = 255; + } + } else { // fine-tuned to X4 eink display + if (adjusted < 30) { + quantized = 0; + quantizedValue = 15; + } else if (adjusted < 50) { + quantized = 1; + quantizedValue = 30; + } else if (adjusted < 140) { + quantized = 2; + quantizedValue = 80; + } else { + quantized = 3; + quantizedValue = 210; + } + } + + // Calculate error (only distribute 6/8 = 75%) + int error = (adjusted - quantizedValue) >> 3; // error/8 + + // Distribute 1/8 to each of 6 neighbors + errorRow0[x + 3] += error; // Right + errorRow0[x + 4] += error; // Right+1 + errorRow1[x + 1] += error; // Bottom-left + errorRow1[x + 2] += error; // Bottom + errorRow1[x + 3] += error; // Bottom-right + errorRow2[x + 2] += error; // Two rows down + + return quantized; + } + + void nextRow() { + int16_t* temp = errorRow0; + errorRow0 = errorRow1; + errorRow1 = errorRow2; + errorRow2 = temp; + memset(errorRow2, 0, (width + 4) * sizeof(int16_t)); + } + + void reset() { + memset(errorRow0, 0, (width + 4) * sizeof(int16_t)); + memset(errorRow1, 0, (width + 4) * sizeof(int16_t)); + memset(errorRow2, 0, (width + 4) * sizeof(int16_t)); + } + + private: + int width; + int16_t* errorRow0; + int16_t* errorRow1; + int16_t* errorRow2; +}; + +// Floyd-Steinberg error diffusion dithering with serpentine scanning +// Serpentine scanning alternates direction each row to reduce "worm" artifacts +// Error distribution pattern (left-to-right): +// X 7/16 +// 3/16 5/16 1/16 +// Error distribution pattern (right-to-left, mirrored): +// 1/16 5/16 3/16 +// 7/16 X +class FloydSteinbergDitherer { + public: + explicit FloydSteinbergDitherer(int width) : width(width), rowCount(0) { + errorCurRow = new int16_t[width + 2](); // +2 for boundary handling + errorNextRow = new int16_t[width + 2](); + } + + ~FloydSteinbergDitherer() { + delete[] errorCurRow; + delete[] errorNextRow; + } + + // **1. EXPLICITLY DELETE THE COPY CONSTRUCTOR** + FloydSteinbergDitherer(const FloydSteinbergDitherer& other) = delete; + + // **2. EXPLICITLY DELETE THE COPY ASSIGNMENT OPERATOR** + FloydSteinbergDitherer& operator=(const FloydSteinbergDitherer& other) = delete; + + // Process a single pixel and return quantized 2-bit value + // x is the logical x position (0 to width-1), direction handled internally + uint8_t processPixel(int gray, int x) { + // Add accumulated error to this pixel + int adjusted = gray + errorCurRow[x + 1]; + + // Clamp to valid range + if (adjusted < 0) adjusted = 0; + if (adjusted > 255) adjusted = 255; + + // Quantize to 4 levels (0, 85, 170, 255) + uint8_t quantized; + int quantizedValue; + if (false) { // original thresholds + if (adjusted < 43) { + quantized = 0; + quantizedValue = 0; + } else if (adjusted < 128) { + quantized = 1; + quantizedValue = 85; + } else if (adjusted < 213) { + quantized = 2; + quantizedValue = 170; + } else { + quantized = 3; + quantizedValue = 255; + } + } else { // fine-tuned to X4 eink display + if (adjusted < 30) { + quantized = 0; + quantizedValue = 15; + } else if (adjusted < 50) { + quantized = 1; + quantizedValue = 30; + } else if (adjusted < 140) { + quantized = 2; + quantizedValue = 80; + } else { + quantized = 3; + quantizedValue = 210; + } + } + + // Calculate error + int error = adjusted - quantizedValue; + + // Distribute error to neighbors (serpentine: direction-aware) + if (!isReverseRow()) { + // Left to right: standard distribution + // Right: 7/16 + errorCurRow[x + 2] += (error * 7) >> 4; + // Bottom-left: 3/16 + errorNextRow[x] += (error * 3) >> 4; + // Bottom: 5/16 + errorNextRow[x + 1] += (error * 5) >> 4; + // Bottom-right: 1/16 + errorNextRow[x + 2] += (error) >> 4; + } else { + // Right to left: mirrored distribution + // Left: 7/16 + errorCurRow[x] += (error * 7) >> 4; + // Bottom-right: 3/16 + errorNextRow[x + 2] += (error * 3) >> 4; + // Bottom: 5/16 + errorNextRow[x + 1] += (error * 5) >> 4; + // Bottom-left: 1/16 + errorNextRow[x] += (error) >> 4; + } + + return quantized; + } + + // Call at the end of each row to swap buffers + void nextRow() { + // Swap buffers + int16_t* temp = errorCurRow; + errorCurRow = errorNextRow; + errorNextRow = temp; + // Clear the next row buffer + memset(errorNextRow, 0, (width + 2) * sizeof(int16_t)); + rowCount++; + } + + // Check if current row should be processed in reverse + bool isReverseRow() const { return (rowCount & 1) != 0; } + + // Reset for a new image or MCU block + void reset() { + memset(errorCurRow, 0, (width + 2) * sizeof(int16_t)); + memset(errorNextRow, 0, (width + 2) * sizeof(int16_t)); + rowCount = 0; + } + + private: + int width; + int rowCount; + int16_t* errorCurRow; + int16_t* errorNextRow; +}; diff --git a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp index 8c8db889..30c1314f 100644 --- a/lib/JpegToBmpConverter/JpegToBmpConverter.cpp +++ b/lib/JpegToBmpConverter/JpegToBmpConverter.cpp @@ -7,6 +7,8 @@ #include #include +#include "BitmapHelpers.h" + // Context structure for picojpeg callback struct JpegReadContext { FsFile& file; @@ -23,282 +25,12 @@ constexpr bool USE_8BIT_OUTPUT = false; // true: 8-bit grayscale (no quantizati constexpr bool USE_ATKINSON = true; // Atkinson dithering (cleaner than F-S, less error diffusion) constexpr bool USE_FLOYD_STEINBERG = false; // Floyd-Steinberg error diffusion (can cause "worm" artifacts) constexpr bool USE_NOISE_DITHERING = false; // Hash-based noise dithering (good for downsampling) -// Brightness/Contrast adjustments: -constexpr bool USE_BRIGHTNESS = true; // true: apply brightness/gamma adjustments -constexpr int BRIGHTNESS_BOOST = 10; // Brightness offset (0-50) -constexpr bool GAMMA_CORRECTION = true; // Gamma curve (brightens midtones) -constexpr float CONTRAST_FACTOR = 1.15f; // Contrast multiplier (1.0 = no change, >1 = more contrast) // Pre-resize to target display size (CRITICAL: avoids dithering artifacts from post-downsampling) constexpr bool USE_PRESCALE = true; // true: scale image to target size before dithering constexpr int TARGET_MAX_WIDTH = 480; // Max width for cover images (portrait display width) constexpr int TARGET_MAX_HEIGHT = 800; // Max height for cover images (portrait display height) // ============================================================================ -// Integer approximation of gamma correction (brightens midtones) -// Uses a simple curve: out = 255 * sqrt(in/255) ≈ sqrt(in * 255) -static inline int applyGamma(int gray) { - if (!GAMMA_CORRECTION) return gray; - // Fast integer square root approximation for gamma ~0.5 (brightening) - // This brightens dark/mid tones while preserving highlights - const int product = gray * 255; - // Newton-Raphson integer sqrt (2 iterations for good accuracy) - int x = gray; - if (x > 0) { - x = (x + product / x) >> 1; - x = (x + product / x) >> 1; - } - return x > 255 ? 255 : x; -} - -// Apply contrast adjustment around midpoint (128) -// factor > 1.0 increases contrast, < 1.0 decreases -static inline int applyContrast(int gray) { - // Integer-based contrast: (gray - 128) * factor + 128 - // Using fixed-point: factor 1.15 ≈ 115/100 - constexpr int factorNum = static_cast(CONTRAST_FACTOR * 100); - int adjusted = ((gray - 128) * factorNum) / 100 + 128; - if (adjusted < 0) adjusted = 0; - if (adjusted > 255) adjusted = 255; - return adjusted; -} - -// Combined brightness/contrast/gamma adjustment -static inline int adjustPixel(int gray) { - if (!USE_BRIGHTNESS) return gray; - - // Order: contrast first, then brightness, then gamma - gray = applyContrast(gray); - gray += BRIGHTNESS_BOOST; - if (gray > 255) gray = 255; - if (gray < 0) gray = 0; - gray = applyGamma(gray); - - return gray; -} - -// Simple quantization without dithering - just divide into 4 levels -static inline uint8_t quantizeSimple(int gray) { - gray = adjustPixel(gray); - // Simple 2-bit quantization: 0-63=0, 64-127=1, 128-191=2, 192-255=3 - return static_cast(gray >> 6); -} - -// Hash-based noise dithering - survives downsampling without moiré artifacts -// Uses integer hash to generate pseudo-random threshold per pixel -static inline uint8_t quantizeNoise(int gray, int x, int y) { - gray = adjustPixel(gray); - - // Generate noise threshold using integer hash (no regular pattern to alias) - uint32_t hash = static_cast(x) * 374761393u + static_cast(y) * 668265263u; - hash = (hash ^ (hash >> 13)) * 1274126177u; - const int threshold = static_cast(hash >> 24); // 0-255 - - // Map gray (0-255) to 4 levels with dithering - const int scaled = gray * 3; - - if (scaled < 255) { - return (scaled + threshold >= 255) ? 1 : 0; - } else if (scaled < 510) { - return ((scaled - 255) + threshold >= 255) ? 2 : 1; - } else { - return ((scaled - 510) + threshold >= 255) ? 3 : 2; - } -} - -// Main quantization function - selects between methods based on config -static inline uint8_t quantize(int gray, int x, int y) { - if (USE_NOISE_DITHERING) { - return quantizeNoise(gray, x, y); - } else { - return quantizeSimple(gray); - } -} - -// Atkinson dithering - distributes only 6/8 (75%) of error for cleaner results -// Error distribution pattern: -// X 1/8 1/8 -// 1/8 1/8 1/8 -// 1/8 -// Less error buildup = fewer artifacts than Floyd-Steinberg -class AtkinsonDitherer { - public: - AtkinsonDitherer(int width) : width(width) { - errorRow0 = new int16_t[width + 4](); // Current row - errorRow1 = new int16_t[width + 4](); // Next row - errorRow2 = new int16_t[width + 4](); // Row after next - } - - ~AtkinsonDitherer() { - delete[] errorRow0; - delete[] errorRow1; - delete[] errorRow2; - } - - uint8_t processPixel(int gray, int x) { - // Apply brightness/contrast/gamma adjustments - gray = adjustPixel(gray); - - // Add accumulated error - int adjusted = gray + errorRow0[x + 2]; - if (adjusted < 0) adjusted = 0; - if (adjusted > 255) adjusted = 255; - - // Quantize to 4 levels - uint8_t quantized; - int quantizedValue; - if (adjusted < 43) { - quantized = 0; - quantizedValue = 0; - } else if (adjusted < 128) { - quantized = 1; - quantizedValue = 85; - } else if (adjusted < 213) { - quantized = 2; - quantizedValue = 170; - } else { - quantized = 3; - quantizedValue = 255; - } - - // Calculate error (only distribute 6/8 = 75%) - int error = (adjusted - quantizedValue) >> 3; // error/8 - - // Distribute 1/8 to each of 6 neighbors - errorRow0[x + 3] += error; // Right - errorRow0[x + 4] += error; // Right+1 - errorRow1[x + 1] += error; // Bottom-left - errorRow1[x + 2] += error; // Bottom - errorRow1[x + 3] += error; // Bottom-right - errorRow2[x + 2] += error; // Two rows down - - return quantized; - } - - void nextRow() { - int16_t* temp = errorRow0; - errorRow0 = errorRow1; - errorRow1 = errorRow2; - errorRow2 = temp; - memset(errorRow2, 0, (width + 4) * sizeof(int16_t)); - } - - void reset() { - memset(errorRow0, 0, (width + 4) * sizeof(int16_t)); - memset(errorRow1, 0, (width + 4) * sizeof(int16_t)); - memset(errorRow2, 0, (width + 4) * sizeof(int16_t)); - } - - private: - int width; - int16_t* errorRow0; - int16_t* errorRow1; - int16_t* errorRow2; -}; - -// Floyd-Steinberg error diffusion dithering with serpentine scanning -// Serpentine scanning alternates direction each row to reduce "worm" artifacts -// Error distribution pattern (left-to-right): -// X 7/16 -// 3/16 5/16 1/16 -// Error distribution pattern (right-to-left, mirrored): -// 1/16 5/16 3/16 -// 7/16 X -class FloydSteinbergDitherer { - public: - FloydSteinbergDitherer(int width) : width(width), rowCount(0) { - errorCurRow = new int16_t[width + 2](); // +2 for boundary handling - errorNextRow = new int16_t[width + 2](); - } - - ~FloydSteinbergDitherer() { - delete[] errorCurRow; - delete[] errorNextRow; - } - - // Process a single pixel and return quantized 2-bit value - // x is the logical x position (0 to width-1), direction handled internally - uint8_t processPixel(int gray, int x, bool reverseDirection) { - // Add accumulated error to this pixel - int adjusted = gray + errorCurRow[x + 1]; - - // Clamp to valid range - if (adjusted < 0) adjusted = 0; - if (adjusted > 255) adjusted = 255; - - // Quantize to 4 levels (0, 85, 170, 255) - uint8_t quantized; - int quantizedValue; - if (adjusted < 43) { - quantized = 0; - quantizedValue = 0; - } else if (adjusted < 128) { - quantized = 1; - quantizedValue = 85; - } else if (adjusted < 213) { - quantized = 2; - quantizedValue = 170; - } else { - quantized = 3; - quantizedValue = 255; - } - - // Calculate error - int error = adjusted - quantizedValue; - - // Distribute error to neighbors (serpentine: direction-aware) - if (!reverseDirection) { - // Left to right: standard distribution - // Right: 7/16 - errorCurRow[x + 2] += (error * 7) >> 4; - // Bottom-left: 3/16 - errorNextRow[x] += (error * 3) >> 4; - // Bottom: 5/16 - errorNextRow[x + 1] += (error * 5) >> 4; - // Bottom-right: 1/16 - errorNextRow[x + 2] += (error) >> 4; - } else { - // Right to left: mirrored distribution - // Left: 7/16 - errorCurRow[x] += (error * 7) >> 4; - // Bottom-right: 3/16 - errorNextRow[x + 2] += (error * 3) >> 4; - // Bottom: 5/16 - errorNextRow[x + 1] += (error * 5) >> 4; - // Bottom-left: 1/16 - errorNextRow[x] += (error) >> 4; - } - - return quantized; - } - - // Call at the end of each row to swap buffers - void nextRow() { - // Swap buffers - int16_t* temp = errorCurRow; - errorCurRow = errorNextRow; - errorNextRow = temp; - // Clear the next row buffer - memset(errorNextRow, 0, (width + 2) * sizeof(int16_t)); - rowCount++; - } - - // Check if current row should be processed in reverse - bool isReverseRow() const { return (rowCount & 1) != 0; } - - // Reset for a new image or MCU block - void reset() { - memset(errorCurRow, 0, (width + 2) * sizeof(int16_t)); - memset(errorNextRow, 0, (width + 2) * sizeof(int16_t)); - rowCount = 0; - } - - private: - int width; - int rowCount; - int16_t* errorCurRow; - int16_t* errorNextRow; -}; - inline void write16(Print& out, const uint16_t value) { out.write(value & 0xFF); out.write((value >> 8) & 0xFF); @@ -623,12 +355,12 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) { } } else { for (int x = 0; x < outWidth; x++) { - const uint8_t gray = mcuRowBuffer[bufferY * imageInfo.m_width + x]; + const uint8_t gray = adjustPixel(mcuRowBuffer[bufferY * imageInfo.m_width + x]); uint8_t twoBit; if (atkinsonDitherer) { twoBit = atkinsonDitherer->processPixel(gray, x); } else if (fsDitherer) { - twoBit = fsDitherer->processPixel(gray, x, fsDitherer->isReverseRow()); + twoBit = fsDitherer->processPixel(gray, x); } else { twoBit = quantize(gray, x, y); } @@ -686,12 +418,12 @@ bool JpegToBmpConverter::jpegFileToBmpStream(FsFile& jpegFile, Print& bmpOut) { } } else { for (int x = 0; x < outWidth; x++) { - const uint8_t gray = (rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0; + const uint8_t gray = adjustPixel((rowCount[x] > 0) ? (rowAccum[x] / rowCount[x]) : 0); uint8_t twoBit; if (atkinsonDitherer) { twoBit = atkinsonDitherer->processPixel(gray, x); } else if (fsDitherer) { - twoBit = fsDitherer->processPixel(gray, x, fsDitherer->isReverseRow()); + twoBit = fsDitherer->processPixel(gray, x); } else { twoBit = quantize(gray, x, currentOutY); } diff --git a/lib/OpdsParser/OpdsParser.cpp b/lib/OpdsParser/OpdsParser.cpp new file mode 100644 index 00000000..da4042f0 --- /dev/null +++ b/lib/OpdsParser/OpdsParser.cpp @@ -0,0 +1,219 @@ +#include "OpdsParser.h" + +#include + +#include + +OpdsParser::~OpdsParser() { + if (parser) { + XML_StopParser(parser, XML_FALSE); + XML_SetElementHandler(parser, nullptr, nullptr); + XML_SetCharacterDataHandler(parser, nullptr); + XML_ParserFree(parser); + parser = nullptr; + } +} + +bool OpdsParser::parse(const char* xmlData, const size_t length) { + clear(); + + parser = XML_ParserCreate(nullptr); + if (!parser) { + Serial.printf("[%lu] [OPDS] Couldn't allocate memory for parser\n", millis()); + return false; + } + + XML_SetUserData(parser, this); + XML_SetElementHandler(parser, startElement, endElement); + XML_SetCharacterDataHandler(parser, characterData); + + // Parse in chunks to avoid large buffer allocations + const char* currentPos = xmlData; + size_t remaining = length; + constexpr size_t chunkSize = 1024; + + while (remaining > 0) { + void* const buf = XML_GetBuffer(parser, chunkSize); + if (!buf) { + Serial.printf("[%lu] [OPDS] Couldn't allocate memory for buffer\n", millis()); + XML_ParserFree(parser); + parser = nullptr; + return false; + } + + const size_t toRead = remaining < chunkSize ? remaining : chunkSize; + memcpy(buf, currentPos, toRead); + + const bool isFinal = (remaining == toRead); + if (XML_ParseBuffer(parser, static_cast(toRead), isFinal) == XML_STATUS_ERROR) { + Serial.printf("[%lu] [OPDS] Parse error at line %lu: %s\n", millis(), XML_GetCurrentLineNumber(parser), + XML_ErrorString(XML_GetErrorCode(parser))); + XML_ParserFree(parser); + parser = nullptr; + return false; + } + + currentPos += toRead; + remaining -= toRead; + } + + // Clean up parser + XML_ParserFree(parser); + parser = nullptr; + + Serial.printf("[%lu] [OPDS] Parsed %zu entries\n", millis(), entries.size()); + return true; +} + +void OpdsParser::clear() { + entries.clear(); + currentEntry = OpdsEntry{}; + currentText.clear(); + inEntry = false; + inTitle = false; + inAuthor = false; + inAuthorName = false; + inId = false; +} + +std::vector OpdsParser::getBooks() const { + std::vector books; + for (const auto& entry : entries) { + if (entry.type == OpdsEntryType::BOOK) { + books.push_back(entry); + } + } + return books; +} + +const char* OpdsParser::findAttribute(const XML_Char** atts, const char* name) { + for (int i = 0; atts[i]; i += 2) { + if (strcmp(atts[i], name) == 0) { + return atts[i + 1]; + } + } + return nullptr; +} + +void XMLCALL OpdsParser::startElement(void* userData, const XML_Char* name, const XML_Char** atts) { + auto* self = static_cast(userData); + + // Check for entry element (with or without namespace prefix) + if (strcmp(name, "entry") == 0 || strstr(name, ":entry") != nullptr) { + self->inEntry = true; + self->currentEntry = OpdsEntry{}; + return; + } + + if (!self->inEntry) return; + + // Check for title element + if (strcmp(name, "title") == 0 || strstr(name, ":title") != nullptr) { + self->inTitle = true; + self->currentText.clear(); + return; + } + + // Check for author element + if (strcmp(name, "author") == 0 || strstr(name, ":author") != nullptr) { + self->inAuthor = true; + return; + } + + // Check for author name element + if (self->inAuthor && (strcmp(name, "name") == 0 || strstr(name, ":name") != nullptr)) { + self->inAuthorName = true; + self->currentText.clear(); + return; + } + + // Check for id element + if (strcmp(name, "id") == 0 || strstr(name, ":id") != nullptr) { + self->inId = true; + self->currentText.clear(); + return; + } + + // Check for link element + if (strcmp(name, "link") == 0 || strstr(name, ":link") != nullptr) { + const char* rel = findAttribute(atts, "rel"); + const char* type = findAttribute(atts, "type"); + const char* href = findAttribute(atts, "href"); + + if (href) { + // Check for acquisition link with epub type (this is a downloadable book) + if (rel && type && strstr(rel, "opds-spec.org/acquisition") != nullptr && + strcmp(type, "application/epub+zip") == 0) { + self->currentEntry.type = OpdsEntryType::BOOK; + self->currentEntry.href = href; + } + // Check for navigation link (subsection or no rel specified with atom+xml type) + else if (type && strstr(type, "application/atom+xml") != nullptr) { + // Only set navigation link if we don't already have an epub link + if (self->currentEntry.type != OpdsEntryType::BOOK) { + self->currentEntry.type = OpdsEntryType::NAVIGATION; + self->currentEntry.href = href; + } + } + } + } +} + +void XMLCALL OpdsParser::endElement(void* userData, const XML_Char* name) { + auto* self = static_cast(userData); + + // Check for entry end + if (strcmp(name, "entry") == 0 || strstr(name, ":entry") != nullptr) { + // Only add entry if it has required fields (title and href) + if (!self->currentEntry.title.empty() && !self->currentEntry.href.empty()) { + self->entries.push_back(self->currentEntry); + } + self->inEntry = false; + self->currentEntry = OpdsEntry{}; + return; + } + + if (!self->inEntry) return; + + // Check for title end + if (strcmp(name, "title") == 0 || strstr(name, ":title") != nullptr) { + if (self->inTitle) { + self->currentEntry.title = self->currentText; + } + self->inTitle = false; + return; + } + + // Check for author end + if (strcmp(name, "author") == 0 || strstr(name, ":author") != nullptr) { + self->inAuthor = false; + return; + } + + // Check for author name end + if (self->inAuthor && (strcmp(name, "name") == 0 || strstr(name, ":name") != nullptr)) { + if (self->inAuthorName) { + self->currentEntry.author = self->currentText; + } + self->inAuthorName = false; + return; + } + + // Check for id end + if (strcmp(name, "id") == 0 || strstr(name, ":id") != nullptr) { + if (self->inId) { + self->currentEntry.id = self->currentText; + } + self->inId = false; + return; + } +} + +void XMLCALL OpdsParser::characterData(void* userData, const XML_Char* s, const int len) { + auto* self = static_cast(userData); + + // Only accumulate text when in a text element + if (self->inTitle || self->inAuthorName || self->inId) { + self->currentText.append(s, len); + } +} diff --git a/lib/OpdsParser/OpdsParser.h b/lib/OpdsParser/OpdsParser.h new file mode 100644 index 00000000..acb4b694 --- /dev/null +++ b/lib/OpdsParser/OpdsParser.h @@ -0,0 +1,99 @@ +#pragma once +#include + +#include +#include + +/** + * Type of OPDS entry. + */ +enum class OpdsEntryType { + NAVIGATION, // Link to another catalog + BOOK // Downloadable book +}; + +/** + * Represents an entry from an OPDS feed (either a navigation link or a book). + */ +struct OpdsEntry { + OpdsEntryType type = OpdsEntryType::NAVIGATION; + std::string title; + std::string author; // Only for books + std::string href; // Navigation URL or epub download URL + std::string id; +}; + +// Legacy alias for backward compatibility +using OpdsBook = OpdsEntry; + +/** + * Parser for OPDS (Open Publication Distribution System) Atom feeds. + * Uses the Expat XML parser to parse OPDS catalog entries. + * + * Usage: + * OpdsParser parser; + * if (parser.parse(xmlData, xmlLength)) { + * for (const auto& entry : parser.getEntries()) { + * if (entry.type == OpdsEntryType::BOOK) { + * // Downloadable book + * } else { + * // Navigation link to another catalog + * } + * } + * } + */ +class OpdsParser { + public: + OpdsParser() = default; + ~OpdsParser(); + + // Disable copy + OpdsParser(const OpdsParser&) = delete; + OpdsParser& operator=(const OpdsParser&) = delete; + + /** + * Parse an OPDS XML feed. + * @param xmlData Pointer to the XML data + * @param length Length of the XML data + * @return true if parsing succeeded, false on error + */ + bool parse(const char* xmlData, size_t length); + + /** + * Get the parsed entries (both navigation and book entries). + * @return Vector of OpdsEntry entries + */ + const std::vector& getEntries() const { return entries; } + + /** + * Get only book entries (legacy compatibility). + * @return Vector of book entries + */ + std::vector getBooks() const; + + /** + * Clear all parsed entries. + */ + void clear(); + + private: + // Expat callbacks + static void XMLCALL startElement(void* userData, const XML_Char* name, const XML_Char** atts); + static void XMLCALL endElement(void* userData, const XML_Char* name); + static void XMLCALL characterData(void* userData, const XML_Char* s, int len); + + // Helper to find attribute value + static const char* findAttribute(const XML_Char** atts, const char* name); + + XML_Parser parser = nullptr; + std::vector entries; + OpdsEntry currentEntry; + std::string currentText; + + // Parser state + bool inEntry = false; + bool inTitle = false; + bool inAuthor = false; + bool inAuthorName = false; + bool inId = false; +}; diff --git a/platformio.ini b/platformio.ini index 75d1a77b..703b9348 100644 --- a/platformio.ini +++ b/platformio.ini @@ -1,7 +1,9 @@ [platformio] -crosspoint_version = 0.12.0 default_envs = default +[crosspoint] +version = 0.13.1 + [base] platform = espressif32 @ 6.12.0 board = esp32-c3-devkitm-1 @@ -50,10 +52,10 @@ lib_deps = extends = base build_flags = ${base.build_flags} - -DCROSSPOINT_VERSION=\"${platformio.crosspoint_version}-dev\" + -DCROSSPOINT_VERSION=\"${crosspoint.version}-dev\" [env:gh_release] extends = base build_flags = ${base.build_flags} - -DCROSSPOINT_VERSION=\"${platformio.crosspoint_version}\" + -DCROSSPOINT_VERSION=\"${crosspoint.version}\" diff --git a/src/CrossPointSettings.cpp b/src/CrossPointSettings.cpp index 572bac41..1ca9ea74 100644 --- a/src/CrossPointSettings.cpp +++ b/src/CrossPointSettings.cpp @@ -4,6 +4,8 @@ #include #include +#include + #include "fontIds.h" // Initialize the static instance @@ -12,7 +14,7 @@ CrossPointSettings CrossPointSettings::instance; namespace { constexpr uint8_t SETTINGS_FILE_VERSION = 1; // Increment this when adding new persisted settings fields -constexpr uint8_t SETTINGS_COUNT = 15; +constexpr uint8_t SETTINGS_COUNT = 17; constexpr char SETTINGS_FILE[] = "/.crosspoint/settings.bin"; } // namespace @@ -42,6 +44,9 @@ bool CrossPointSettings::saveToFile() const { serialization::writePod(outputFile, refreshFrequency); serialization::writePod(outputFile, screenMargin); serialization::writePod(outputFile, sleepScreenCoverMode); + serialization::writeString(outputFile, std::string(opdsServerUrl)); + serialization::writePod(outputFile, textAntiAliasing); + serialization::writePod(outputFile, hideBatteryPercentage); outputFile.close(); Serial.printf("[%lu] [CPS] Settings saved to file\n", millis()); @@ -98,6 +103,16 @@ bool CrossPointSettings::loadFromFile() { if (++settingsRead >= fileSettingsCount) break; serialization::readPod(inputFile, sleepScreenCoverMode); if (++settingsRead >= fileSettingsCount) break; + { + std::string urlStr; + serialization::readString(inputFile, urlStr); + strncpy(opdsServerUrl, urlStr.c_str(), sizeof(opdsServerUrl) - 1); + opdsServerUrl[sizeof(opdsServerUrl) - 1] = '\0'; + } + serialization::readPod(inputFile, textAntiAliasing); + if (++settingsRead >= fileSettingsCount) break; + serialization::readPod(inputFile, hideBatteryPercentage); + if (++settingsRead >= fileSettingsCount) break; } while (false); inputFile.close(); diff --git a/src/CrossPointSettings.h b/src/CrossPointSettings.h index 5394c4e3..d5f91039 100644 --- a/src/CrossPointSettings.h +++ b/src/CrossPointSettings.h @@ -52,6 +52,12 @@ class CrossPointSettings { // E-ink refresh frequency (pages between full refreshes) enum REFRESH_FREQUENCY { REFRESH_1 = 0, REFRESH_5 = 1, REFRESH_10 = 2, REFRESH_15 = 3, REFRESH_30 = 4 }; + // Short power button press actions + enum SHORT_PWRBTN { IGNORE = 0, SLEEP = 1, PAGE_TURN = 2 }; + + // Hide battery percentage + enum HIDE_BATTERY_PERCENTAGE { HIDE_NEVER = 0, HIDE_READER = 1, HIDE_ALWAYS = 2 }; + // Sleep screen settings uint8_t sleepScreen = DARK; // Sleep screen cover mode settings @@ -60,8 +66,9 @@ class CrossPointSettings { uint8_t statusBar = FULL; // Text rendering settings uint8_t extraParagraphSpacing = 1; - // Duration of the power button press - uint8_t shortPwrBtn = 0; + uint8_t textAntiAliasing = 1; + // Short power button click behaviour + uint8_t shortPwrBtn = IGNORE; // EPUB reading orientation settings // 0 = portrait (default), 1 = landscape clockwise, 2 = inverted, 3 = landscape counter-clockwise uint8_t orientation = PORTRAIT; @@ -77,16 +84,21 @@ class CrossPointSettings { uint8_t sleepTimeout = SLEEP_10_MIN; // E-ink refresh frequency (default 15 pages) uint8_t refreshFrequency = REFRESH_15; - // Reader screen margin settings uint8_t screenMargin = 5; + // OPDS browser settings + char opdsServerUrl[128] = ""; + // Hide battery percentage + uint8_t hideBatteryPercentage = HIDE_NEVER; ~CrossPointSettings() = default; // Get singleton instance static CrossPointSettings& getInstance() { return instance; } - uint16_t getPowerButtonDuration() const { return shortPwrBtn ? 10 : 400; } + uint16_t getPowerButtonDuration() const { + return (shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::SLEEP) ? 10 : 400; + } int getReaderFontId() const; bool saveToFile() const; @@ -95,7 +107,6 @@ class CrossPointSettings { float getReaderLineCompression() const; unsigned long getSleepTimeoutMs() const; int getRefreshFrequency() const; - int getReaderScreenMargin() const; }; // Helper macro to access settings diff --git a/src/ScreenComponents.cpp b/src/ScreenComponents.cpp index 2900f3e4..42b6ef7b 100644 --- a/src/ScreenComponents.cpp +++ b/src/ScreenComponents.cpp @@ -2,15 +2,17 @@ #include +#include #include #include "Battery.h" #include "fontIds.h" -void ScreenComponents::drawBattery(const GfxRenderer& renderer, const int left, const int top) { +void ScreenComponents::drawBattery(const GfxRenderer& renderer, const int left, const int top, + const bool showPercentage) { // Left aligned battery icon and percentage const uint16_t percentage = battery.readPercentage(); - const auto percentageText = std::to_string(percentage) + "%"; + const auto percentageText = showPercentage ? std::to_string(percentage) + "%" : ""; renderer.drawText(SMALL_FONT_ID, left + 20, top, percentageText.c_str()); // 1 column on left, 2 columns on right, 5 columns of battery body @@ -39,3 +41,26 @@ void ScreenComponents::drawBattery(const GfxRenderer& renderer, const int left, renderer.fillRect(x + 2, y + 2, filledWidth, batteryHeight - 4); } + +void ScreenComponents::drawProgressBar(const GfxRenderer& renderer, const int x, const int y, const int width, + const int height, const size_t current, const size_t total) { + if (total == 0) { + return; + } + + // Use 64-bit arithmetic to avoid overflow for large files + const int percent = static_cast((static_cast(current) * 100) / total); + + // Draw outline + renderer.drawRect(x, y, width, height); + + // Draw filled portion + const int fillWidth = (width - 4) * percent / 100; + if (fillWidth > 0) { + renderer.fillRect(x + 2, y + 2, fillWidth, height - 4); + } + + // Draw percentage text centered below bar + const std::string percentText = std::to_string(percent) + "%"; + renderer.drawCenteredText(UI_10_FONT_ID, y + height + 15, percentText.c_str()); +} diff --git a/src/ScreenComponents.h b/src/ScreenComponents.h index 2598a3e3..150fb0c8 100644 --- a/src/ScreenComponents.h +++ b/src/ScreenComponents.h @@ -1,8 +1,24 @@ #pragma once +#include +#include + class GfxRenderer; class ScreenComponents { public: - static void drawBattery(const GfxRenderer& renderer, int left, int top); + static void drawBattery(const GfxRenderer& renderer, int left, int top, bool showPercentage = true); + + /** + * Draw a progress bar with percentage text. + * @param renderer The graphics renderer + * @param x Left position of the bar + * @param y Top position of the bar + * @param width Width of the bar + * @param height Height of the bar + * @param current Current progress value + * @param total Total value for 100% progress + */ + static void drawProgressBar(const GfxRenderer& renderer, int x, int y, int width, int height, size_t current, + size_t total); }; diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 8d8fd791..3305a16d 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -9,20 +9,7 @@ #include "CrossPointState.h" #include "fontIds.h" #include "images/CrossLarge.h" - -namespace { -// Check if path has XTC extension (.xtc or .xtch) -bool isXtcFile(const std::string& path) { - if (path.length() < 4) return false; - std::string ext4 = path.substr(path.length() - 4); - if (ext4 == ".xtc") return true; - if (path.length() >= 5) { - std::string ext5 = path.substr(path.length() - 5); - if (ext5 == ".xtch") return true; - } - return false; -} -} // namespace +#include "util/StringUtils.h" void SleepActivity::onEnter() { Activity::onEnter(); @@ -62,7 +49,7 @@ void SleepActivity::renderCustomSleepScreen() const { auto dir = SdMan.open("/sleep"); if (dir && dir.isDirectory()) { std::vector files; - char name[128]; + char name[500]; // collect all valid BMP files for (auto file = dir.openNextFile(); file; file = dir.openNextFile()) { if (file.isDirectory()) { @@ -99,7 +86,7 @@ void SleepActivity::renderCustomSleepScreen() const { if (SdMan.openFileForRead("SLP", filename, file)) { Serial.printf("[%lu] [SLP] Randomly loading: /sleep/%s\n", millis(), files[randomFileIndex].c_str()); delay(100); - Bitmap bitmap(file); + Bitmap bitmap(file, true); if (bitmap.parseHeaders() == BmpReaderError::Ok) { renderBitmapSleepScreen(bitmap); dir.close(); @@ -114,7 +101,7 @@ void SleepActivity::renderCustomSleepScreen() const { // render a custom sleep screen instead of the default. FsFile file; if (SdMan.openFileForRead("SLP", "/sleep.bmp", file)) { - Bitmap bitmap(file); + Bitmap bitmap(file, true); if (bitmap.parseHeaders() == BmpReaderError::Ok) { Serial.printf("[%lu] [SLP] Loading: /sleep.bmp\n", millis()); renderBitmapSleepScreen(bitmap); @@ -212,9 +199,10 @@ void SleepActivity::renderCoverSleepScreen() const { } std::string coverBmpPath; + bool cropped = SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP; - // Check if the current book is XTC or EPUB - if (isXtcFile(APP_STATE.openEpubPath)) { + if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".xtc") || + StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".xtch")) { // Handle XTC file Xtc lastXtc(APP_STATE.openEpubPath, "/.crosspoint"); if (!lastXtc.load()) { @@ -228,7 +216,7 @@ void SleepActivity::renderCoverSleepScreen() const { } coverBmpPath = lastXtc.getCoverBmpPath(); - } else { + } else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".epub")) { // Handle EPUB file Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint"); if (!lastEpub.load()) { @@ -236,12 +224,14 @@ void SleepActivity::renderCoverSleepScreen() const { return renderDefaultSleepScreen(); } - if (!lastEpub.generateCoverBmp()) { + if (!lastEpub.generateCoverBmp(cropped)) { Serial.println("[SLP] Failed to generate cover bmp"); return renderDefaultSleepScreen(); } - coverBmpPath = lastEpub.getCoverBmpPath(); + coverBmpPath = lastEpub.getCoverBmpPath(cropped); + } else { + return renderDefaultSleepScreen(); } FsFile file; diff --git a/src/activities/browser/OpdsBookBrowserActivity.cpp b/src/activities/browser/OpdsBookBrowserActivity.cpp new file mode 100644 index 00000000..4e0a08d2 --- /dev/null +++ b/src/activities/browser/OpdsBookBrowserActivity.cpp @@ -0,0 +1,408 @@ +#include "OpdsBookBrowserActivity.h" + +#include +#include +#include + +#include "CrossPointSettings.h" +#include "MappedInputManager.h" +#include "ScreenComponents.h" +#include "activities/network/WifiSelectionActivity.h" +#include "fontIds.h" +#include "network/HttpDownloader.h" +#include "util/StringUtils.h" +#include "util/UrlUtils.h" + +namespace { +constexpr int PAGE_ITEMS = 23; +constexpr int SKIP_PAGE_MS = 700; +constexpr char OPDS_ROOT_PATH[] = "opds"; // No leading slash - relative to server URL +} // namespace + +void OpdsBookBrowserActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void OpdsBookBrowserActivity::onEnter() { + ActivityWithSubactivity::onEnter(); + + renderingMutex = xSemaphoreCreateMutex(); + state = BrowserState::CHECK_WIFI; + entries.clear(); + navigationHistory.clear(); + currentPath = OPDS_ROOT_PATH; + selectorIndex = 0; + errorMessage.clear(); + statusMessage = "Checking WiFi..."; + updateRequired = true; + + xTaskCreate(&OpdsBookBrowserActivity::taskTrampoline, "OpdsBookBrowserTask", + 4096, // Stack size (larger for HTTP operations) + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); + + // Check WiFi and connect if needed, then fetch feed + checkAndConnectWifi(); +} + +void OpdsBookBrowserActivity::onExit() { + ActivityWithSubactivity::onExit(); + + // Turn off WiFi when exiting + WiFi.mode(WIFI_OFF); + + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; + entries.clear(); + navigationHistory.clear(); +} + +void OpdsBookBrowserActivity::loop() { + // Handle WiFi selection subactivity + if (state == BrowserState::WIFI_SELECTION) { + ActivityWithSubactivity::loop(); + return; + } + + // Handle error state - Confirm retries, Back goes back or home + if (state == BrowserState::ERROR) { + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + // Check if WiFi is still connected + if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) { + // WiFi connected - just retry fetching the feed + Serial.printf("[%lu] [OPDS] Retry: WiFi connected, retrying fetch\n", millis()); + state = BrowserState::LOADING; + statusMessage = "Loading..."; + updateRequired = true; + fetchFeed(currentPath); + } else { + // WiFi not connected - launch WiFi selection + Serial.printf("[%lu] [OPDS] Retry: WiFi not connected, launching selection\n", millis()); + launchWifiSelection(); + } + } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + navigateBack(); + } + return; + } + + // Handle WiFi check state - only Back works + if (state == BrowserState::CHECK_WIFI) { + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + onGoHome(); + } + return; + } + + // Handle loading state - only Back works + if (state == BrowserState::LOADING) { + if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + navigateBack(); + } + return; + } + + // Handle downloading state - no input allowed + if (state == BrowserState::DOWNLOADING) { + return; + } + + // Handle browsing state + if (state == BrowserState::BROWSING) { + const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::Up) || + mappedInput.wasReleased(MappedInputManager::Button::Left); + const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::Down) || + mappedInput.wasReleased(MappedInputManager::Button::Right); + const bool skipPage = mappedInput.getHeldTime() > SKIP_PAGE_MS; + + if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { + if (!entries.empty()) { + const auto& entry = entries[selectorIndex]; + if (entry.type == OpdsEntryType::BOOK) { + downloadBook(entry); + } else { + navigateToEntry(entry); + } + } + } else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) { + navigateBack(); + } else if (prevReleased && !entries.empty()) { + if (skipPage) { + selectorIndex = ((selectorIndex / PAGE_ITEMS - 1) * PAGE_ITEMS + entries.size()) % entries.size(); + } else { + selectorIndex = (selectorIndex + entries.size() - 1) % entries.size(); + } + updateRequired = true; + } else if (nextReleased && !entries.empty()) { + if (skipPage) { + selectorIndex = ((selectorIndex / PAGE_ITEMS + 1) * PAGE_ITEMS) % entries.size(); + } else { + selectorIndex = (selectorIndex + 1) % entries.size(); + } + updateRequired = true; + } + } +} + +void OpdsBookBrowserActivity::displayTaskLoop() { + while (true) { + if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void OpdsBookBrowserActivity::render() const { + renderer.clearScreen(); + + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + renderer.drawCenteredText(UI_12_FONT_ID, 15, "Calibre Library", true, EpdFontFamily::BOLD); + + if (state == BrowserState::CHECK_WIFI) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str()); + const auto labels = mappedInput.mapLabels("« Back", "", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); + return; + } + + if (state == BrowserState::LOADING) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, statusMessage.c_str()); + const auto labels = mappedInput.mapLabels("« Back", "", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); + return; + } + + if (state == BrowserState::ERROR) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 20, "Error:"); + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 10, errorMessage.c_str()); + const auto labels = mappedInput.mapLabels("« Back", "Retry", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); + return; + } + + if (state == BrowserState::DOWNLOADING) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 40, "Downloading..."); + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 - 10, statusMessage.c_str()); + if (downloadTotal > 0) { + const int barWidth = pageWidth - 100; + constexpr int barHeight = 20; + constexpr int barX = 50; + const int barY = pageHeight / 2 + 20; + ScreenComponents::drawProgressBar(renderer, barX, barY, barWidth, barHeight, downloadProgress, downloadTotal); + } + renderer.displayBuffer(); + return; + } + + // Browsing state + // Show appropriate button hint based on selected entry type + const char* confirmLabel = "Open"; + if (!entries.empty() && entries[selectorIndex].type == OpdsEntryType::BOOK) { + confirmLabel = "Download"; + } + const auto labels = mappedInput.mapLabels("« Back", confirmLabel, "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + if (entries.empty()) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2, "No entries found"); + renderer.displayBuffer(); + return; + } + + const auto pageStartIndex = selectorIndex / PAGE_ITEMS * PAGE_ITEMS; + renderer.fillRect(0, 60 + (selectorIndex % PAGE_ITEMS) * 30 - 2, pageWidth - 1, 30); + + for (size_t i = pageStartIndex; i < entries.size() && i < static_cast(pageStartIndex + PAGE_ITEMS); i++) { + const auto& entry = entries[i]; + + // Format display text with type indicator + std::string displayText; + if (entry.type == OpdsEntryType::NAVIGATION) { + displayText = "> " + entry.title; // Folder/navigation indicator + } else { + // Book: "Title - Author" or just "Title" + displayText = entry.title; + if (!entry.author.empty()) { + displayText += " - " + entry.author; + } + } + + auto item = renderer.truncatedText(UI_10_FONT_ID, displayText.c_str(), renderer.getScreenWidth() - 40); + renderer.drawText(UI_10_FONT_ID, 20, 60 + (i % PAGE_ITEMS) * 30, item.c_str(), + i != static_cast(selectorIndex)); + } + + renderer.displayBuffer(); +} + +void OpdsBookBrowserActivity::fetchFeed(const std::string& path) { + const char* serverUrl = SETTINGS.opdsServerUrl; + if (strlen(serverUrl) == 0) { + state = BrowserState::ERROR; + errorMessage = "No server URL configured"; + updateRequired = true; + return; + } + + std::string url = UrlUtils::buildUrl(serverUrl, path); + Serial.printf("[%lu] [OPDS] Fetching: %s\n", millis(), url.c_str()); + + std::string content; + if (!HttpDownloader::fetchUrl(url, content)) { + state = BrowserState::ERROR; + errorMessage = "Failed to fetch feed"; + updateRequired = true; + return; + } + + OpdsParser parser; + if (!parser.parse(content.c_str(), content.size())) { + state = BrowserState::ERROR; + errorMessage = "Failed to parse feed"; + updateRequired = true; + return; + } + + entries = parser.getEntries(); + selectorIndex = 0; + + if (entries.empty()) { + state = BrowserState::ERROR; + errorMessage = "No entries found"; + updateRequired = true; + return; + } + + state = BrowserState::BROWSING; + updateRequired = true; +} + +void OpdsBookBrowserActivity::navigateToEntry(const OpdsEntry& entry) { + // Push current path to history before navigating + navigationHistory.push_back(currentPath); + currentPath = entry.href; + + state = BrowserState::LOADING; + statusMessage = "Loading..."; + entries.clear(); + selectorIndex = 0; + updateRequired = true; + + fetchFeed(currentPath); +} + +void OpdsBookBrowserActivity::navigateBack() { + if (navigationHistory.empty()) { + // At root, go home + onGoHome(); + } else { + // Go back to previous catalog + currentPath = navigationHistory.back(); + navigationHistory.pop_back(); + + state = BrowserState::LOADING; + statusMessage = "Loading..."; + entries.clear(); + selectorIndex = 0; + updateRequired = true; + + fetchFeed(currentPath); + } +} + +void OpdsBookBrowserActivity::downloadBook(const OpdsEntry& book) { + state = BrowserState::DOWNLOADING; + statusMessage = book.title; + downloadProgress = 0; + downloadTotal = 0; + updateRequired = true; + + // Build full download URL + std::string downloadUrl = UrlUtils::buildUrl(SETTINGS.opdsServerUrl, 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"; + + Serial.printf("[%lu] [OPDS] Downloading: %s -> %s\n", millis(), downloadUrl.c_str(), filename.c_str()); + + const auto result = + HttpDownloader::downloadToFile(downloadUrl, filename, [this](const size_t downloaded, const size_t total) { + downloadProgress = downloaded; + downloadTotal = total; + updateRequired = true; + }); + + if (result == HttpDownloader::OK) { + Serial.printf("[%lu] [OPDS] Download complete: %s\n", millis(), filename.c_str()); + state = BrowserState::BROWSING; + updateRequired = true; + } else { + state = BrowserState::ERROR; + errorMessage = "Download failed"; + updateRequired = true; + } +} + +void OpdsBookBrowserActivity::checkAndConnectWifi() { + // Already connected? Verify connection is valid by checking IP + if (WiFi.status() == WL_CONNECTED && WiFi.localIP() != IPAddress(0, 0, 0, 0)) { + state = BrowserState::LOADING; + statusMessage = "Loading..."; + updateRequired = true; + fetchFeed(currentPath); + return; + } + + // Not connected - launch WiFi selection screen directly + launchWifiSelection(); +} + +void OpdsBookBrowserActivity::launchWifiSelection() { + state = BrowserState::WIFI_SELECTION; + updateRequired = true; + + enterNewActivity(new WifiSelectionActivity(renderer, mappedInput, + [this](const bool connected) { onWifiSelectionComplete(connected); })); +} + +void OpdsBookBrowserActivity::onWifiSelectionComplete(const bool connected) { + exitActivity(); + + if (connected) { + Serial.printf("[%lu] [OPDS] WiFi connected via selection, fetching feed\n", millis()); + state = BrowserState::LOADING; + statusMessage = "Loading..."; + updateRequired = true; + fetchFeed(currentPath); + } else { + Serial.printf("[%lu] [OPDS] WiFi selection cancelled/failed\n", millis()); + // Force disconnect to ensure clean state for next retry + // This prevents stale connection status from interfering + WiFi.disconnect(); + WiFi.mode(WIFI_OFF); + state = BrowserState::ERROR; + errorMessage = "WiFi connection failed"; + updateRequired = true; + } +} diff --git a/src/activities/browser/OpdsBookBrowserActivity.h b/src/activities/browser/OpdsBookBrowserActivity.h new file mode 100644 index 00000000..b08d9c2a --- /dev/null +++ b/src/activities/browser/OpdsBookBrowserActivity.h @@ -0,0 +1,65 @@ +#pragma once +#include +#include +#include +#include + +#include +#include +#include + +#include "../ActivityWithSubactivity.h" + +/** + * Activity for browsing and downloading books from an OPDS server. + * Supports navigation through catalog hierarchy and downloading EPUBs. + * When WiFi connection fails, launches WiFi selection to let user connect. + */ +class OpdsBookBrowserActivity final : public ActivityWithSubactivity { + 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 + }; + + explicit OpdsBookBrowserActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + const std::function& onGoHome) + : ActivityWithSubactivity("OpdsBookBrowser", renderer, mappedInput), onGoHome(onGoHome) {} + + void onEnter() override; + void onExit() override; + void loop() override; + + private: + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + bool updateRequired = false; + + BrowserState state = BrowserState::LOADING; + std::vector entries; + std::vector navigationHistory; // Stack of previous feed paths for back navigation + std::string currentPath; // Current feed path being displayed + int selectorIndex = 0; + std::string errorMessage; + std::string statusMessage; + size_t downloadProgress = 0; + size_t downloadTotal = 0; + + const std::function onGoHome; + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void render() const; + + void checkAndConnectWifi(); + void launchWifiSelection(); + void onWifiSelectionComplete(bool connected); + void fetchFeed(const std::string& path); + void navigateToEntry(const OpdsEntry& entry); + void navigateBack(); + void downloadBook(const OpdsEntry& book); +}; diff --git a/src/activities/home/HomeActivity.cpp b/src/activities/home/HomeActivity.cpp index 11107fdc..dc031fde 100644 --- a/src/activities/home/HomeActivity.cpp +++ b/src/activities/home/HomeActivity.cpp @@ -4,17 +4,28 @@ #include #include +#include +#include + +#include "Battery.h" +#include "CrossPointSettings.h" #include "CrossPointState.h" #include "MappedInputManager.h" #include "ScreenComponents.h" #include "fontIds.h" +#include "util/StringUtils.h" void HomeActivity::taskTrampoline(void* param) { auto* self = static_cast(param); self->displayTaskLoop(); } -int HomeActivity::getMenuItemCount() const { return hasContinueReading ? 4 : 3; } +int HomeActivity::getMenuItemCount() const { + int count = 3; // Browse files, File transfer, Settings + if (hasContinueReading) count++; + if (hasOpdsUrl) count++; + return count; +} void HomeActivity::onEnter() { Activity::onEnter(); @@ -24,6 +35,9 @@ void HomeActivity::onEnter() { // Check if we have a book to continue reading hasContinueReading = !APP_STATE.openEpubPath.empty() && SdMan.exists(APP_STATE.openEpubPath.c_str()); + // Check if OPDS browser URL is configured + hasOpdsUrl = strlen(SETTINGS.opdsServerUrl) > 0; + if (hasContinueReading) { // Extract filename from path for display lastBookTitle = APP_STATE.openEpubPath; @@ -32,10 +46,8 @@ void HomeActivity::onEnter() { lastBookTitle = lastBookTitle.substr(lastSlash + 1); } - const std::string ext4 = lastBookTitle.length() >= 4 ? lastBookTitle.substr(lastBookTitle.length() - 4) : ""; - const std::string ext5 = lastBookTitle.length() >= 5 ? lastBookTitle.substr(lastBookTitle.length() - 5) : ""; // If epub, try to load the metadata for title/author - if (ext5 == ".epub") { + if (StringUtils::checkFileExtension(lastBookTitle, ".epub")) { Epub epub(APP_STATE.openEpubPath, "/.crosspoint"); epub.load(false); if (!epub.getTitle().empty()) { @@ -44,9 +56,9 @@ void HomeActivity::onEnter() { if (!epub.getAuthor().empty()) { lastBookAuthor = std::string(epub.getAuthor()); } - } else if (ext5 == ".xtch") { + } else if (StringUtils::checkFileExtension(lastBookTitle, ".xtch")) { lastBookTitle.resize(lastBookTitle.length() - 5); - } else if (ext4 == ".xtc") { + } else if (StringUtils::checkFileExtension(lastBookTitle, ".xtc")) { lastBookTitle.resize(lastBookTitle.length() - 4); } } @@ -57,7 +69,7 @@ void HomeActivity::onEnter() { updateRequired = true; xTaskCreate(&HomeActivity::taskTrampoline, "HomeActivityTask", - 2048, // Stack size + 4096, // Stack size this, // Parameters 1, // Priority &displayTaskHandle // Task handle @@ -86,26 +98,24 @@ void HomeActivity::loop() { const int menuCount = getMenuItemCount(); if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) { - if (hasContinueReading) { - // Menu: Continue Reading, Browse, File transfer, Settings - if (selectorIndex == 0) { - onContinueReading(); - } else if (selectorIndex == 1) { - onReaderOpen(); - } else if (selectorIndex == 2) { - onFileTransferOpen(); - } else if (selectorIndex == 3) { - onSettingsOpen(); - } - } else { - // Menu: Browse, File transfer, Settings - if (selectorIndex == 0) { - onReaderOpen(); - } else if (selectorIndex == 1) { - onFileTransferOpen(); - } else if (selectorIndex == 2) { - onSettingsOpen(); - } + // Calculate dynamic indices based on which options are available + int idx = 0; + const int continueIdx = hasContinueReading ? idx++ : -1; + const int browseFilesIdx = idx++; + const int opdsLibraryIdx = hasOpdsUrl ? idx++ : -1; + const int fileTransferIdx = idx++; + const int settingsIdx = idx; + + if (selectorIndex == continueIdx) { + onContinueReading(); + } else if (selectorIndex == browseFilesIdx) { + onReaderOpen(); + } else if (selectorIndex == opdsLibraryIdx) { + onOpdsBrowserOpen(); + } else if (selectorIndex == fileTransferIdx) { + onFileTransferOpen(); + } else if (selectorIndex == settingsIdx) { + onSettingsOpen(); } } else if (prevPressed) { selectorIndex = (selectorIndex + menuCount - 1) % menuCount; @@ -277,24 +287,31 @@ void HomeActivity::render() const { renderer.drawCenteredText(UI_10_FONT_ID, y + renderer.getLineHeight(UI_12_FONT_ID), "Start reading below"); } - // --- Bottom menu tiles (indices 1-3) --- - const int menuTileWidth = pageWidth - 2 * margin; - constexpr int menuTileHeight = 50; - constexpr int menuSpacing = 10; - constexpr int totalMenuHeight = 3 * menuTileHeight + 2 * menuSpacing; + // --- Bottom menu tiles --- + // Build menu items dynamically + std::vector menuItems = {"Browse Files", "File Transfer", "Settings"}; + if (hasOpdsUrl) { + // Insert Calibre Library after Browse Files + menuItems.insert(menuItems.begin() + 1, "Calibre Library"); + } - int menuStartY = bookY + bookHeight + 20; + const int menuTileWidth = pageWidth - 2 * margin; + constexpr int menuTileHeight = 45; + constexpr int menuSpacing = 8; + const int totalMenuHeight = + static_cast(menuItems.size()) * menuTileHeight + (static_cast(menuItems.size()) - 1) * menuSpacing; + + int menuStartY = bookY + bookHeight + 15; // Ensure we don't collide with the bottom button legend const int maxMenuStartY = pageHeight - bottomMargin - totalMenuHeight - margin; if (menuStartY > maxMenuStartY) { menuStartY = maxMenuStartY; } - for (int i = 0; i < 3; ++i) { - constexpr const char* items[3] = {"Browse files", "File transfer", "Settings"}; - const int overallIndex = i + (getMenuItemCount() - 3); + for (size_t i = 0; i < menuItems.size(); ++i) { + const int overallIndex = static_cast(i) + (hasContinueReading ? 1 : 0); constexpr int tileX = margin; - const int tileY = menuStartY + i * (menuTileHeight + menuSpacing); + const int tileY = menuStartY + static_cast(i) * (menuTileHeight + menuSpacing); const bool selected = selectorIndex == overallIndex; if (selected) { @@ -303,7 +320,7 @@ void HomeActivity::render() const { renderer.drawRect(tileX, tileY, menuTileWidth, menuTileHeight); } - const char* label = items[i]; + const char* label = menuItems[i]; const int textWidth = renderer.getTextWidth(UI_10_FONT_ID, label); const int textX = tileX + (menuTileWidth - textWidth) / 2; const int lineHeight = renderer.getLineHeight(UI_10_FONT_ID); @@ -316,7 +333,13 @@ void HomeActivity::render() const { const auto labels = mappedInput.mapLabels("", "Confirm", "Up", "Down"); renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); - ScreenComponents::drawBattery(renderer, 20, pageHeight - 70); + const bool showBatteryPercentage = + SETTINGS.hideBatteryPercentage != CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_ALWAYS; + // get percentage so we can align text properly + const uint16_t percentage = battery.readPercentage(); + const auto percentageText = showBatteryPercentage ? std::to_string(percentage) + "%" : ""; + const auto batteryX = pageWidth - 25 - renderer.getTextWidth(SMALL_FONT_ID, percentageText.c_str()); + ScreenComponents::drawBattery(renderer, batteryX, 10, showBatteryPercentage); renderer.displayBuffer(); } diff --git a/src/activities/home/HomeActivity.h b/src/activities/home/HomeActivity.h index b6c9767d..84cb5bfd 100644 --- a/src/activities/home/HomeActivity.h +++ b/src/activities/home/HomeActivity.h @@ -13,12 +13,14 @@ class HomeActivity final : public Activity { int selectorIndex = 0; bool updateRequired = false; bool hasContinueReading = false; + bool hasOpdsUrl = false; std::string lastBookTitle; std::string lastBookAuthor; const std::function onContinueReading; const std::function onReaderOpen; const std::function onSettingsOpen; const std::function onFileTransferOpen; + const std::function onOpdsBrowserOpen; static void taskTrampoline(void* param); [[noreturn]] void displayTaskLoop(); @@ -28,12 +30,14 @@ class HomeActivity final : public Activity { public: explicit HomeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::function& onContinueReading, const std::function& onReaderOpen, - const std::function& onSettingsOpen, const std::function& onFileTransferOpen) + const std::function& onSettingsOpen, const std::function& onFileTransferOpen, + const std::function& onOpdsBrowserOpen) : Activity("Home", renderer, mappedInput), onContinueReading(onContinueReading), onReaderOpen(onReaderOpen), onSettingsOpen(onSettingsOpen), - onFileTransferOpen(onFileTransferOpen) {} + onFileTransferOpen(onFileTransferOpen), + onOpdsBrowserOpen(onOpdsBrowserOpen) {} void onEnter() override; void onExit() override; void loop() override; diff --git a/src/activities/network/CalibreWirelessActivity.cpp b/src/activities/network/CalibreWirelessActivity.cpp new file mode 100644 index 00000000..0ad9094a --- /dev/null +++ b/src/activities/network/CalibreWirelessActivity.cpp @@ -0,0 +1,756 @@ +#include "CalibreWirelessActivity.h" + +#include +#include +#include +#include + +#include + +#include "MappedInputManager.h" +#include "ScreenComponents.h" +#include "fontIds.h" +#include "util/StringUtils.h" + +namespace { +constexpr uint16_t UDP_PORTS[] = {54982, 48123, 39001, 44044, 59678}; +constexpr uint16_t LOCAL_UDP_PORT = 8134; // Port to receive responses +} // namespace + +void CalibreWirelessActivity::displayTaskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void CalibreWirelessActivity::networkTaskTrampoline(void* param) { + auto* self = static_cast(param); + self->networkTaskLoop(); +} + +void CalibreWirelessActivity::onEnter() { + Activity::onEnter(); + + renderingMutex = xSemaphoreCreateMutex(); + stateMutex = xSemaphoreCreateMutex(); + + state = WirelessState::DISCOVERING; + statusMessage = "Discovering Calibre..."; + errorMessage.clear(); + calibreHostname.clear(); + calibreHost.clear(); + calibrePort = 0; + calibreAltPort = 0; + currentFilename.clear(); + currentFileSize = 0; + bytesReceived = 0; + inBinaryMode = false; + recvBuffer.clear(); + + updateRequired = true; + + // Start UDP listener for Calibre responses + udp.begin(LOCAL_UDP_PORT); + + // Create display task + xTaskCreate(&CalibreWirelessActivity::displayTaskTrampoline, "CalDisplayTask", 2048, this, 1, &displayTaskHandle); + + // Create network task with larger stack for JSON parsing + xTaskCreate(&CalibreWirelessActivity::networkTaskTrampoline, "CalNetworkTask", 12288, this, 2, &networkTaskHandle); +} + +void CalibreWirelessActivity::onExit() { + Activity::onExit(); + + // Turn off WiFi when exiting + WiFi.mode(WIFI_OFF); + + // Stop UDP listening + udp.stop(); + + // Close TCP client if connected + if (tcpClient.connected()) { + tcpClient.stop(); + } + + // Close any open file + if (currentFile) { + currentFile.close(); + } + + // Acquire stateMutex before deleting network task to avoid race condition + xSemaphoreTake(stateMutex, portMAX_DELAY); + if (networkTaskHandle) { + vTaskDelete(networkTaskHandle); + networkTaskHandle = nullptr; + } + xSemaphoreGive(stateMutex); + + // Acquire renderingMutex before deleting display task + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; + + vSemaphoreDelete(stateMutex); + stateMutex = nullptr; +} + +void CalibreWirelessActivity::loop() { + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + onComplete(); + return; + } +} + +void CalibreWirelessActivity::displayTaskLoop() { + while (true) { + if (updateRequired) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(50 / portTICK_PERIOD_MS); + } +} + +void CalibreWirelessActivity::networkTaskLoop() { + while (true) { + xSemaphoreTake(stateMutex, portMAX_DELAY); + const auto currentState = state; + xSemaphoreGive(stateMutex); + + switch (currentState) { + case WirelessState::DISCOVERING: + listenForDiscovery(); + break; + + case WirelessState::CONNECTING: + case WirelessState::WAITING: + case WirelessState::RECEIVING: + handleTcpClient(); + break; + + case WirelessState::COMPLETE: + case WirelessState::DISCONNECTED: + case WirelessState::ERROR: + // Just wait, user will exit + vTaskDelay(100 / portTICK_PERIOD_MS); + break; + } + + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void CalibreWirelessActivity::listenForDiscovery() { + // Broadcast "hello" on all UDP discovery ports to find Calibre + for (const uint16_t port : UDP_PORTS) { + udp.beginPacket("255.255.255.255", port); + udp.write(reinterpret_cast("hello"), 5); + udp.endPacket(); + } + + // Wait for Calibre's response + vTaskDelay(500 / portTICK_PERIOD_MS); + + // Check for response + const int packetSize = udp.parsePacket(); + if (packetSize > 0) { + char buffer[256]; + const int len = udp.read(buffer, sizeof(buffer) - 1); + if (len > 0) { + buffer[len] = '\0'; + + // Parse Calibre's response format: + // "calibre wireless device client (on hostname);port,content_server_port" + // or just the hostname and port info + std::string response(buffer); + + // Try to extract host and port + // Format: "calibre wireless device client (on HOSTNAME);PORT,..." + size_t onPos = response.find("(on "); + size_t closePos = response.find(')'); + size_t semiPos = response.find(';'); + size_t commaPos = response.find(',', semiPos); + + if (semiPos != std::string::npos) { + // Get ports after semicolon (format: "port1,port2") + std::string portStr; + if (commaPos != std::string::npos && commaPos > semiPos) { + portStr = response.substr(semiPos + 1, commaPos - semiPos - 1); + // Get alternative port after comma + std::string altPortStr = response.substr(commaPos + 1); + // Trim whitespace and non-digits from alt port + size_t altEnd = 0; + while (altEnd < altPortStr.size() && altPortStr[altEnd] >= '0' && altPortStr[altEnd] <= '9') { + altEnd++; + } + if (altEnd > 0) { + calibreAltPort = static_cast(std::stoi(altPortStr.substr(0, altEnd))); + } + } else { + portStr = response.substr(semiPos + 1); + } + + // Trim whitespace from main port + while (!portStr.empty() && (portStr[0] == ' ' || portStr[0] == '\t')) { + portStr = portStr.substr(1); + } + + if (!portStr.empty()) { + calibrePort = static_cast(std::stoi(portStr)); + } + + // Get hostname if present, otherwise use sender IP + if (onPos != std::string::npos && closePos != std::string::npos && closePos > onPos + 4) { + calibreHostname = response.substr(onPos + 4, closePos - onPos - 4); + } + } + + // Use the sender's IP as the host to connect to + calibreHost = udp.remoteIP().toString().c_str(); + if (calibreHostname.empty()) { + calibreHostname = calibreHost; + } + + if (calibrePort > 0) { + // Connect to Calibre's TCP server - try main port first, then alt port + setState(WirelessState::CONNECTING); + setStatus("Connecting to " + calibreHostname + "..."); + + // Small delay before connecting + vTaskDelay(100 / portTICK_PERIOD_MS); + + bool connected = false; + + // Try main port first + if (tcpClient.connect(calibreHost.c_str(), calibrePort, 5000)) { + connected = true; + } + + // Try alternative port if main failed + if (!connected && calibreAltPort > 0) { + vTaskDelay(200 / portTICK_PERIOD_MS); + if (tcpClient.connect(calibreHost.c_str(), calibreAltPort, 5000)) { + connected = true; + } + } + + if (connected) { + setState(WirelessState::WAITING); + setStatus("Connected to " + calibreHostname + "\nWaiting for commands..."); + } else { + // Don't set error yet, keep trying discovery + setState(WirelessState::DISCOVERING); + setStatus("Discovering Calibre...\n(Connection failed, retrying)"); + calibrePort = 0; + calibreAltPort = 0; + } + } + } + } +} + +void CalibreWirelessActivity::handleTcpClient() { + if (!tcpClient.connected()) { + setState(WirelessState::DISCONNECTED); + setStatus("Calibre disconnected"); + return; + } + + if (inBinaryMode) { + receiveBinaryData(); + return; + } + + std::string message; + if (readJsonMessage(message)) { + // Parse opcode from JSON array format: [opcode, {...}] + // Find the opcode (first number after '[') + size_t start = message.find('['); + if (start != std::string::npos) { + start++; + size_t end = message.find(',', start); + if (end != std::string::npos) { + const int opcodeInt = std::stoi(message.substr(start, end - start)); + if (opcodeInt < 0 || opcodeInt >= OpCode::ERROR) { + Serial.printf("[%lu] [CAL] Invalid opcode: %d\n", millis(), opcodeInt); + sendJsonResponse(OpCode::OK, "{}"); + return; + } + const auto opcode = static_cast(opcodeInt); + + // Extract data object (everything after the comma until the last ']') + size_t dataStart = end + 1; + size_t dataEnd = message.rfind(']'); + std::string data = ""; + if (dataEnd != std::string::npos && dataEnd > dataStart) { + data = message.substr(dataStart, dataEnd - dataStart); + } + + handleCommand(opcode, data); + } + } + } +} + +bool CalibreWirelessActivity::readJsonMessage(std::string& message) { + // Read available data into buffer + int available = tcpClient.available(); + if (available > 0) { + // Limit buffer growth to prevent memory issues + if (recvBuffer.size() > 100000) { + recvBuffer.clear(); + return false; + } + // Read in chunks + char buf[1024]; + while (available > 0) { + int toRead = std::min(available, static_cast(sizeof(buf))); + int bytesRead = tcpClient.read(reinterpret_cast(buf), toRead); + if (bytesRead > 0) { + recvBuffer.append(buf, bytesRead); + available -= bytesRead; + } else { + break; + } + } + } + + if (recvBuffer.empty()) { + return false; + } + + // Find '[' which marks the start of JSON + size_t bracketPos = recvBuffer.find('['); + if (bracketPos == std::string::npos) { + // No '[' found - if buffer is getting large, something is wrong + if (recvBuffer.size() > 1000) { + recvBuffer.clear(); + } + return false; + } + + // Try to extract length from digits before '[' + // Calibre ALWAYS sends a length prefix, so if it's not valid digits, it's garbage + size_t msgLen = 0; + bool validPrefix = false; + + if (bracketPos > 0 && bracketPos <= 12) { + // Check if prefix is all digits + bool allDigits = true; + for (size_t i = 0; i < bracketPos; i++) { + char c = recvBuffer[i]; + if (c < '0' || c > '9') { + allDigits = false; + break; + } + } + if (allDigits) { + msgLen = std::stoul(recvBuffer.substr(0, bracketPos)); + validPrefix = true; + } + } + + if (!validPrefix) { + // Not a valid length prefix - discard everything up to '[' and treat '[' as start + if (bracketPos > 0) { + recvBuffer = recvBuffer.substr(bracketPos); + } + // Without length prefix, we can't reliably parse - wait for more data + // that hopefully starts with a proper length prefix + return false; + } + + // Sanity check the message length + if (msgLen > 1000000) { + recvBuffer = recvBuffer.substr(bracketPos + 1); // Skip past this '[' and try again + return false; + } + + // Check if we have the complete message + size_t totalNeeded = bracketPos + msgLen; + if (recvBuffer.size() < totalNeeded) { + // Not enough data yet - wait for more + return false; + } + + // Extract the message + message = recvBuffer.substr(bracketPos, msgLen); + + // Keep the rest in buffer (may contain binary data or next message) + if (recvBuffer.size() > totalNeeded) { + recvBuffer = recvBuffer.substr(totalNeeded); + } else { + recvBuffer.clear(); + } + + return true; +} + +void CalibreWirelessActivity::sendJsonResponse(const OpCode opcode, const std::string& data) { + // Format: length + [opcode, {data}] + std::string json = "[" + std::to_string(opcode) + "," + data + "]"; + const std::string lengthPrefix = std::to_string(json.length()); + json.insert(0, lengthPrefix); + + tcpClient.write(reinterpret_cast(json.c_str()), json.length()); + tcpClient.flush(); +} + +void CalibreWirelessActivity::handleCommand(const OpCode opcode, const std::string& data) { + switch (opcode) { + case OpCode::GET_INITIALIZATION_INFO: + handleGetInitializationInfo(data); + break; + case OpCode::GET_DEVICE_INFORMATION: + handleGetDeviceInformation(); + break; + case OpCode::FREE_SPACE: + handleFreeSpace(); + break; + case OpCode::GET_BOOK_COUNT: + handleGetBookCount(); + break; + case OpCode::SEND_BOOK: + handleSendBook(data); + break; + case OpCode::SEND_BOOK_METADATA: + handleSendBookMetadata(data); + break; + case OpCode::DISPLAY_MESSAGE: + handleDisplayMessage(data); + break; + case OpCode::NOOP: + handleNoop(data); + break; + case OpCode::SET_CALIBRE_DEVICE_INFO: + case OpCode::SET_CALIBRE_DEVICE_NAME: + // These set metadata about the connected Calibre instance. + // We don't need this info, just acknowledge receipt. + sendJsonResponse(OpCode::OK, "{}"); + break; + case OpCode::SET_LIBRARY_INFO: + // Library metadata (name, UUID) - not needed for receiving books + sendJsonResponse(OpCode::OK, "{}"); + break; + case OpCode::SEND_BOOKLISTS: + // Calibre asking us to send our book list. We report 0 books in + // handleGetBookCount, so this is effectively a no-op. + sendJsonResponse(OpCode::OK, "{}"); + break; + case OpCode::TOTAL_SPACE: + handleFreeSpace(); + break; + default: + Serial.printf("[%lu] [CAL] Unknown opcode: %d\n", millis(), opcode); + sendJsonResponse(OpCode::OK, "{}"); + break; + } +} + +void CalibreWirelessActivity::handleGetInitializationInfo(const std::string& data) { + setState(WirelessState::WAITING); + setStatus("Connected to " + calibreHostname + + "\nWaiting for transfer...\n\nIf transfer fails, enable\n'Ignore free space' in Calibre's\nSmartDevice " + "plugin settings."); + + // Build response with device capabilities + // Format must match what Calibre expects from a smart device + std::string response = "{"; + response += "\"appName\":\"CrossPoint\","; + response += "\"acceptedExtensions\":[\"epub\"],"; + response += "\"cacheUsesLpaths\":true,"; + response += "\"canAcceptLibraryInfo\":true,"; + response += "\"canDeleteMultipleBooks\":true,"; + response += "\"canReceiveBookBinary\":true,"; + response += "\"canSendOkToSendbook\":true,"; + response += "\"canStreamBooks\":true,"; + response += "\"canStreamMetadata\":true,"; + response += "\"canUseCachedMetadata\":true,"; + // ccVersionNumber: Calibre Companion protocol version. 212 matches CC 5.4.20+. + // Using a known version ensures compatibility with Calibre's feature detection. + response += "\"ccVersionNumber\":212,"; + // coverHeight: Max cover image height. We don't process covers, so this is informational only. + response += "\"coverHeight\":800,"; + response += "\"deviceKind\":\"CrossPoint\","; + response += "\"deviceName\":\"CrossPoint\","; + response += "\"extensionPathLengths\":{\"epub\":37},"; + response += "\"maxBookContentPacketLen\":4096,"; + response += "\"passwordHash\":\"\","; + response += "\"useUuidFileNames\":false,"; + response += "\"versionOK\":true"; + response += "}"; + + sendJsonResponse(OpCode::OK, response); +} + +void CalibreWirelessActivity::handleGetDeviceInformation() { + std::string response = "{"; + response += "\"device_info\":{"; + response += "\"device_store_uuid\":\"" + getDeviceUuid() + "\","; + response += "\"device_name\":\"CrossPoint Reader\","; + response += "\"device_version\":\"" CROSSPOINT_VERSION "\""; + response += "},"; + response += "\"version\":1,"; + response += "\"device_version\":\"" CROSSPOINT_VERSION "\""; + response += "}"; + + sendJsonResponse(OpCode::OK, response); +} + +void CalibreWirelessActivity::handleFreeSpace() { + // TODO: Report actual SD card free space instead of hardcoded value + // Report 10GB free space for now + sendJsonResponse(OpCode::OK, "{\"free_space_on_device\":10737418240}"); +} + +void CalibreWirelessActivity::handleGetBookCount() { + // We report 0 books - Calibre will send books without checking for duplicates + std::string response = "{\"count\":0,\"willStream\":true,\"willScan\":false}"; + sendJsonResponse(OpCode::OK, response); +} + +void CalibreWirelessActivity::handleSendBook(const std::string& data) { + // Manually extract lpath and length from SEND_BOOK data + // Full JSON parsing crashes on large metadata, so we just extract what we need + + // Extract "lpath" field - format: "lpath": "value" + std::string lpath; + size_t lpathPos = data.find("\"lpath\""); + if (lpathPos != std::string::npos) { + size_t colonPos = data.find(':', lpathPos + 7); + if (colonPos != std::string::npos) { + size_t quoteStart = data.find('"', colonPos + 1); + if (quoteStart != std::string::npos) { + size_t quoteEnd = data.find('"', quoteStart + 1); + if (quoteEnd != std::string::npos) { + lpath = data.substr(quoteStart + 1, quoteEnd - quoteStart - 1); + } + } + } + } + + // Extract top-level "length" field - must track depth to skip nested objects + // The metadata contains nested "length" fields (e.g., cover image length) + size_t length = 0; + int depth = 0; + for (size_t i = 0; i < data.size(); i++) { + char c = data[i]; + if (c == '{' || c == '[') { + depth++; + } else if (c == '}' || c == ']') { + depth--; + } else if (depth == 1 && c == '"') { + // At top level, check if this is "length" + if (i + 9 < data.size() && data.substr(i, 8) == "\"length\"") { + // Found top-level "length" - extract the number after ':' + size_t colonPos = data.find(':', i + 8); + if (colonPos != std::string::npos) { + size_t numStart = colonPos + 1; + while (numStart < data.size() && (data[numStart] == ' ' || data[numStart] == '\t')) { + numStart++; + } + size_t numEnd = numStart; + while (numEnd < data.size() && data[numEnd] >= '0' && data[numEnd] <= '9') { + numEnd++; + } + if (numEnd > numStart) { + length = std::stoul(data.substr(numStart, numEnd - numStart)); + break; + } + } + } + } + } + + if (lpath.empty() || length == 0) { + sendJsonResponse(OpCode::ERROR, "{\"message\":\"Invalid book data\"}"); + return; + } + + // Extract filename from lpath + std::string filename = lpath; + const size_t lastSlash = filename.rfind('/'); + if (lastSlash != std::string::npos) { + filename = filename.substr(lastSlash + 1); + } + + // Sanitize and create full path + currentFilename = "/" + StringUtils::sanitizeFilename(filename); + if (!StringUtils::checkFileExtension(currentFilename, ".epub")) { + currentFilename += ".epub"; + } + currentFileSize = length; + bytesReceived = 0; + + setState(WirelessState::RECEIVING); + setStatus("Receiving: " + filename); + + // Open file for writing + if (!SdMan.openFileForWrite("CAL", currentFilename.c_str(), currentFile)) { + setError("Failed to create file"); + sendJsonResponse(OpCode::ERROR, "{\"message\":\"Failed to create file\"}"); + return; + } + + // Send OK to start receiving binary data + sendJsonResponse(OpCode::OK, "{}"); + + // Switch to binary mode + inBinaryMode = true; + binaryBytesRemaining = length; + + // Check if recvBuffer has leftover data (binary file data that arrived with the JSON) + if (!recvBuffer.empty()) { + size_t toWrite = std::min(recvBuffer.size(), binaryBytesRemaining); + size_t written = currentFile.write(reinterpret_cast(recvBuffer.data()), toWrite); + bytesReceived += written; + binaryBytesRemaining -= written; + recvBuffer = recvBuffer.substr(toWrite); + updateRequired = true; + } +} + +void CalibreWirelessActivity::handleSendBookMetadata(const std::string& data) { + // We receive metadata after the book - just acknowledge + sendJsonResponse(OpCode::OK, "{}"); +} + +void CalibreWirelessActivity::handleDisplayMessage(const std::string& data) { + // Calibre may send messages to display + // Check messageKind - 1 means password error + if (data.find("\"messageKind\":1") != std::string::npos) { + setError("Password required"); + } + sendJsonResponse(OpCode::OK, "{}"); +} + +void CalibreWirelessActivity::handleNoop(const std::string& data) { + // Check for ejecting flag + if (data.find("\"ejecting\":true") != std::string::npos) { + setState(WirelessState::DISCONNECTED); + setStatus("Calibre disconnected"); + } + sendJsonResponse(OpCode::NOOP, "{}"); +} + +void CalibreWirelessActivity::receiveBinaryData() { + const int available = tcpClient.available(); + if (available == 0) { + // Check if connection is still alive + if (!tcpClient.connected()) { + currentFile.close(); + inBinaryMode = false; + setError("Transfer interrupted"); + } + return; + } + + uint8_t buffer[1024]; + const size_t toRead = std::min(sizeof(buffer), binaryBytesRemaining); + const size_t bytesRead = tcpClient.read(buffer, toRead); + + if (bytesRead > 0) { + currentFile.write(buffer, bytesRead); + bytesReceived += bytesRead; + binaryBytesRemaining -= bytesRead; + updateRequired = true; + + if (binaryBytesRemaining == 0) { + // Transfer complete + currentFile.flush(); + currentFile.close(); + inBinaryMode = false; + + setState(WirelessState::WAITING); + setStatus("Received: " + currentFilename + "\nWaiting for more..."); + + // Send OK to acknowledge completion + sendJsonResponse(OpCode::OK, "{}"); + } + } +} + +void CalibreWirelessActivity::render() const { + renderer.clearScreen(); + + const auto pageWidth = renderer.getScreenWidth(); + const auto pageHeight = renderer.getScreenHeight(); + + // Draw header + renderer.drawCenteredText(UI_12_FONT_ID, 30, "Calibre Wireless", true, EpdFontFamily::BOLD); + + // Draw IP address + const std::string ipAddr = WiFi.localIP().toString().c_str(); + renderer.drawCenteredText(UI_10_FONT_ID, 60, ("IP: " + ipAddr).c_str()); + + // Draw status message + int statusY = pageHeight / 2 - 40; + + // Split status message by newlines and draw each line + std::string status = statusMessage; + size_t pos = 0; + while ((pos = status.find('\n')) != std::string::npos) { + renderer.drawCenteredText(UI_10_FONT_ID, statusY, status.substr(0, pos).c_str()); + statusY += 25; + status = status.substr(pos + 1); + } + if (!status.empty()) { + renderer.drawCenteredText(UI_10_FONT_ID, statusY, status.c_str()); + statusY += 25; + } + + // Draw progress if receiving + if (state == WirelessState::RECEIVING && currentFileSize > 0) { + const int barWidth = pageWidth - 100; + constexpr int barHeight = 20; + constexpr int barX = 50; + const int barY = statusY + 20; + ScreenComponents::drawProgressBar(renderer, barX, barY, barWidth, barHeight, bytesReceived, currentFileSize); + } + + // Draw error if present + if (!errorMessage.empty()) { + renderer.drawCenteredText(UI_10_FONT_ID, pageHeight - 120, errorMessage.c_str()); + } + + // Draw button hints + const auto labels = mappedInput.mapLabels("Back", "", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(); +} + +std::string CalibreWirelessActivity::getDeviceUuid() const { + // Generate a consistent UUID based on MAC address + uint8_t mac[6]; + WiFi.macAddress(mac); + + char uuid[37]; + snprintf(uuid, sizeof(uuid), "%02x%02x%02x%02x-%02x%02x-4000-8000-%02x%02x%02x%02x%02x%02x", mac[0], mac[1], mac[2], + mac[3], mac[4], mac[5], mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + + return std::string(uuid); +} + +void CalibreWirelessActivity::setState(WirelessState newState) { + xSemaphoreTake(stateMutex, portMAX_DELAY); + state = newState; + xSemaphoreGive(stateMutex); + updateRequired = true; +} + +void CalibreWirelessActivity::setStatus(const std::string& message) { + statusMessage = message; + updateRequired = true; +} + +void CalibreWirelessActivity::setError(const std::string& message) { + errorMessage = message; + setState(WirelessState::ERROR); +} diff --git a/src/activities/network/CalibreWirelessActivity.h b/src/activities/network/CalibreWirelessActivity.h new file mode 100644 index 00000000..ae2b1767 --- /dev/null +++ b/src/activities/network/CalibreWirelessActivity.h @@ -0,0 +1,135 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "activities/Activity.h" + +/** + * CalibreWirelessActivity implements Calibre's "wireless device" protocol. + * This allows Calibre desktop to send books directly to the device over WiFi. + * + * Protocol specification sourced from Calibre's smart device driver: + * https://github.com/kovidgoyal/calibre/blob/master/src/calibre/devices/smart_device_app/driver.py + * + * Protocol overview: + * 1. Device broadcasts "hello" on UDP ports 54982, 48123, 39001, 44044, 59678 + * 2. Calibre responds with its TCP server address + * 3. Device connects to Calibre's TCP server + * 4. Calibre sends JSON commands with length-prefixed messages + * 5. Books are transferred as binary data after SEND_BOOK command + */ +class CalibreWirelessActivity final : public Activity { + // Calibre wireless device states + enum class WirelessState { + DISCOVERING, // Listening for Calibre server broadcasts + CONNECTING, // Establishing TCP connection + WAITING, // Connected, waiting for commands + RECEIVING, // Receiving a book file + COMPLETE, // Transfer complete + DISCONNECTED, // Calibre disconnected + ERROR // Connection/transfer error + }; + + // Calibre protocol opcodes (from calibre/devices/smart_device_app/driver.py) + enum OpCode : uint8_t { + OK = 0, + SET_CALIBRE_DEVICE_INFO = 1, + SET_CALIBRE_DEVICE_NAME = 2, + GET_DEVICE_INFORMATION = 3, + TOTAL_SPACE = 4, + FREE_SPACE = 5, + GET_BOOK_COUNT = 6, + SEND_BOOKLISTS = 7, + SEND_BOOK = 8, + GET_INITIALIZATION_INFO = 9, + BOOK_DONE = 11, + NOOP = 12, // Was incorrectly 18 + DELETE_BOOK = 13, + GET_BOOK_FILE_SEGMENT = 14, + GET_BOOK_METADATA = 15, + SEND_BOOK_METADATA = 16, + DISPLAY_MESSAGE = 17, + CALIBRE_BUSY = 18, + SET_LIBRARY_INFO = 19, + ERROR = 20, + }; + + TaskHandle_t displayTaskHandle = nullptr; + TaskHandle_t networkTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + SemaphoreHandle_t stateMutex = nullptr; + bool updateRequired = false; + + WirelessState state = WirelessState::DISCOVERING; + const std::function onComplete; + + // UDP discovery + WiFiUDP udp; + + // TCP connection (we connect to Calibre) + WiFiClient tcpClient; + std::string calibreHost; + uint16_t calibrePort = 0; + uint16_t calibreAltPort = 0; // Alternative port (content server) + std::string calibreHostname; + + // Transfer state + std::string currentFilename; + size_t currentFileSize = 0; + size_t bytesReceived = 0; + std::string statusMessage; + std::string errorMessage; + + // Protocol state + bool inBinaryMode = false; + size_t binaryBytesRemaining = 0; + FsFile currentFile; + std::string recvBuffer; // Buffer for incoming data (like KOReader) + + static void displayTaskTrampoline(void* param); + static void networkTaskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + [[noreturn]] void networkTaskLoop(); + void render() const; + + // Network operations + void listenForDiscovery(); + void handleTcpClient(); + bool readJsonMessage(std::string& message); + void sendJsonResponse(OpCode opcode, const std::string& data); + void handleCommand(OpCode opcode, const std::string& data); + void receiveBinaryData(); + + // Protocol handlers + void handleGetInitializationInfo(const std::string& data); + void handleGetDeviceInformation(); + void handleFreeSpace(); + void handleGetBookCount(); + void handleSendBook(const std::string& data); + void handleSendBookMetadata(const std::string& data); + void handleDisplayMessage(const std::string& data); + void handleNoop(const std::string& data); + + // Utility + std::string getDeviceUuid() const; + void setState(WirelessState newState); + void setStatus(const std::string& message); + void setError(const std::string& message); + + public: + explicit CalibreWirelessActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + const std::function& onComplete) + : Activity("CalibreWireless", renderer, mappedInput), onComplete(onComplete) {} + void onEnter() override; + void onExit() override; + void loop() override; + bool preventAutoSleep() override { return true; } + bool skipLoopDelay() override { return true; } +}; diff --git a/src/activities/reader/EpubReaderActivity.cpp b/src/activities/reader/EpubReaderActivity.cpp index 18a3d0a9..8378f4e9 100644 --- a/src/activities/reader/EpubReaderActivity.cpp +++ b/src/activities/reader/EpubReaderActivity.cpp @@ -164,6 +164,8 @@ void EpubReaderActivity::loop() { const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::PageBack) || mappedInput.wasReleased(MappedInputManager::Button::Left); const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::PageForward) || + (SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::PAGE_TURN && + mappedInput.wasReleased(MappedInputManager::Button::Power)) || mappedInput.wasReleased(MappedInputManager::Button::Right); if (!prevReleased && !nextReleased) { @@ -400,7 +402,7 @@ void EpubReaderActivity::renderContents(std::unique_ptr page, const int or // grayscale rendering // TODO: Only do this if font supports it - { + if (SETTINGS.textAntiAliasing) { renderer.clearScreen(0x00); renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); page->render(renderer, SETTINGS.getReaderFontId(), orientedMarginLeft, orientedMarginTop); @@ -429,6 +431,8 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL; const bool showChapterTitle = SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::NO_PROGRESS || SETTINGS.statusBar == CrossPointSettings::STATUS_BAR_MODE::FULL; + const bool showBatteryPercentage = + SETTINGS.hideBatteryPercentage == CrossPointSettings::HIDE_BATTERY_PERCENTAGE::HIDE_NEVER; // Position status bar near the bottom of the logical screen, regardless of orientation const auto screenHeight = renderer.getScreenHeight(); @@ -451,7 +455,7 @@ void EpubReaderActivity::renderStatusBar(const int orientedMarginRight, const in } if (showBattery) { - ScreenComponents::drawBattery(renderer, orientedMarginLeft, textY); + ScreenComponents::drawBattery(renderer, orientedMarginLeft + 1, textY, showBatteryPercentage); } if (showChapterTitle) { diff --git a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp index d20c1645..f9a1aa69 100644 --- a/src/activities/reader/EpubReaderChapterSelectionActivity.cpp +++ b/src/activities/reader/EpubReaderChapterSelectionActivity.cpp @@ -38,7 +38,9 @@ int EpubReaderChapterSelectionActivity::getPageItems() const { constexpr int lineHeight = 30; const int screenHeight = renderer.getScreenHeight(); - const int availableHeight = screenHeight - startY; + const int endY = screenHeight - lineHeight; + + const int availableHeight = endY - startY; int items = availableHeight / lineHeight; // Ensure we always have at least one item per page to avoid division by zero @@ -201,5 +203,8 @@ void EpubReaderChapterSelectionActivity::renderScreen() { } } + const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down"); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); } diff --git a/src/activities/reader/FileSelectionActivity.cpp b/src/activities/reader/FileSelectionActivity.cpp index f87cc97c..33c2c3e4 100644 --- a/src/activities/reader/FileSelectionActivity.cpp +++ b/src/activities/reader/FileSelectionActivity.cpp @@ -5,6 +5,7 @@ #include "MappedInputManager.h" #include "fontIds.h" +#include "util/StringUtils.h" namespace { constexpr int PAGE_ITEMS = 23; @@ -29,7 +30,6 @@ void FileSelectionActivity::taskTrampoline(void* param) { void FileSelectionActivity::loadFiles() { files.clear(); - selectorIndex = 0; auto root = SdMan.open(basepath.c_str()); if (!root || !root.isDirectory()) { @@ -39,7 +39,7 @@ void FileSelectionActivity::loadFiles() { root.rewindDirectory(); - char name[128]; + char name[500]; for (auto file = root.openNextFile(); file; file = root.openNextFile()) { file.getName(name, sizeof(name)); if (name[0] == '.' || strcmp(name, "System Volume Information") == 0) { @@ -51,9 +51,8 @@ void FileSelectionActivity::loadFiles() { files.emplace_back(std::string(name) + "/"); } else { auto filename = std::string(name); - std::string ext4 = filename.length() >= 4 ? filename.substr(filename.length() - 4) : ""; - std::string ext5 = filename.length() >= 5 ? filename.substr(filename.length() - 5) : ""; - if (ext5 == ".epub" || ext5 == ".xtch" || ext4 == ".xtc") { + if (StringUtils::checkFileExtension(filename, ".epub") || StringUtils::checkFileExtension(filename, ".xtch") || + StringUtils::checkFileExtension(filename, ".xtc")) { files.emplace_back(filename); } } @@ -124,6 +123,7 @@ void FileSelectionActivity::loop() { if (files[selectorIndex].back() == '/') { basepath += files[selectorIndex].substr(0, files[selectorIndex].length() - 1); loadFiles(); + selectorIndex = 0; updateRequired = true; } else { onSelect(basepath + files[selectorIndex]); @@ -132,9 +132,16 @@ void FileSelectionActivity::loop() { // Short press: go up one directory, or go home if at root if (mappedInput.getHeldTime() < GO_HOME_MS) { if (basepath != "/") { + const std::string oldPath = basepath; + basepath.replace(basepath.find_last_of('/'), std::string::npos, ""); if (basepath.empty()) basepath = "/"; loadFiles(); + + const auto pos = oldPath.find_last_of('/'); + const std::string dirName = oldPath.substr(pos + 1) + "/"; + selectorIndex = findEntry(dirName); + updateRequired = true; } else { onGoHome(); @@ -187,10 +194,16 @@ void FileSelectionActivity::render() const { const auto pageStartIndex = selectorIndex / PAGE_ITEMS * PAGE_ITEMS; renderer.fillRect(0, 60 + (selectorIndex % PAGE_ITEMS) * 30 - 2, pageWidth - 1, 30); - for (int i = pageStartIndex; i < files.size() && i < pageStartIndex + PAGE_ITEMS; i++) { + for (size_t i = pageStartIndex; i < files.size() && i < pageStartIndex + PAGE_ITEMS; i++) { auto item = renderer.truncatedText(UI_10_FONT_ID, files[i].c_str(), renderer.getScreenWidth() - 40); renderer.drawText(UI_10_FONT_ID, 20, 60 + (i % PAGE_ITEMS) * 30, item.c_str(), i != selectorIndex); } renderer.displayBuffer(); } + +size_t FileSelectionActivity::findEntry(const std::string& name) const { + for (size_t i = 0; i < files.size(); i++) + if (files[i] == name) return i; + return 0; +} diff --git a/src/activities/reader/FileSelectionActivity.h b/src/activities/reader/FileSelectionActivity.h index 88e97d0c..3c71968a 100644 --- a/src/activities/reader/FileSelectionActivity.h +++ b/src/activities/reader/FileSelectionActivity.h @@ -14,7 +14,7 @@ class FileSelectionActivity final : public Activity { SemaphoreHandle_t renderingMutex = nullptr; std::string basepath = "/"; std::vector files; - int selectorIndex = 0; + size_t selectorIndex = 0; bool updateRequired = false; const std::function onSelect; const std::function onGoHome; @@ -24,6 +24,8 @@ class FileSelectionActivity final : public Activity { void render() const; void loadFiles(); + size_t findEntry(const std::string& name) const; + public: explicit FileSelectionActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::function& onSelect, diff --git a/src/activities/reader/ReaderActivity.cpp b/src/activities/reader/ReaderActivity.cpp index 1829218a..cb123e1c 100644 --- a/src/activities/reader/ReaderActivity.cpp +++ b/src/activities/reader/ReaderActivity.cpp @@ -6,6 +6,7 @@ #include "Xtc.h" #include "XtcReaderActivity.h" #include "activities/util/FullScreenMessageActivity.h" +#include "util/StringUtils.h" std::string ReaderActivity::extractFolderPath(const std::string& filePath) { const auto lastSlash = filePath.find_last_of('/'); @@ -16,14 +17,7 @@ std::string ReaderActivity::extractFolderPath(const std::string& filePath) { } bool ReaderActivity::isXtcFile(const std::string& path) { - if (path.length() < 4) return false; - std::string ext4 = path.substr(path.length() - 4); - if (ext4 == ".xtc") return true; - if (path.length() >= 5) { - std::string ext5 = path.substr(path.length() - 5); - if (ext5 == ".xtch") return true; - } - return false; + return StringUtils::checkFileExtension(path, ".xtc") || StringUtils::checkFileExtension(path, ".xtch"); } std::unique_ptr ReaderActivity::loadEpub(const std::string& path) { diff --git a/src/activities/reader/XtcReaderActivity.cpp b/src/activities/reader/XtcReaderActivity.cpp index c0580cf6..9cdf5c97 100644 --- a/src/activities/reader/XtcReaderActivity.cpp +++ b/src/activities/reader/XtcReaderActivity.cpp @@ -112,6 +112,8 @@ void XtcReaderActivity::loop() { const bool prevReleased = mappedInput.wasReleased(MappedInputManager::Button::PageBack) || mappedInput.wasReleased(MappedInputManager::Button::Left); const bool nextReleased = mappedInput.wasReleased(MappedInputManager::Button::PageForward) || + (SETTINGS.shortPwrBtn == CrossPointSettings::SHORT_PWRBTN::PAGE_TURN && + mappedInput.wasReleased(MappedInputManager::Button::Power)) || mappedInput.wasReleased(MappedInputManager::Button::Right); if (!prevReleased && !nextReleased) { diff --git a/src/activities/reader/XtcReaderChapterSelectionActivity.cpp b/src/activities/reader/XtcReaderChapterSelectionActivity.cpp index fd732924..b2cfecaa 100644 --- a/src/activities/reader/XtcReaderChapterSelectionActivity.cpp +++ b/src/activities/reader/XtcReaderChapterSelectionActivity.cpp @@ -14,7 +14,9 @@ int XtcReaderChapterSelectionActivity::getPageItems() const { constexpr int lineHeight = 30; const int screenHeight = renderer.getScreenHeight(); - const int availableHeight = screenHeight - startY; + const int endY = screenHeight - lineHeight; + + const int availableHeight = endY - startY; int items = availableHeight / lineHeight; if (items < 1) { items = 1; @@ -147,5 +149,8 @@ void XtcReaderChapterSelectionActivity::renderScreen() { renderer.drawText(UI_10_FONT_ID, 20, 60 + (i % pageItems) * 30, title, i != selectorIndex); } + const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down"); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + renderer.displayBuffer(); } diff --git a/src/activities/settings/CalibreSettingsActivity.cpp b/src/activities/settings/CalibreSettingsActivity.cpp new file mode 100644 index 00000000..4f614ffc --- /dev/null +++ b/src/activities/settings/CalibreSettingsActivity.cpp @@ -0,0 +1,169 @@ +#include "CalibreSettingsActivity.h" + +#include +#include + +#include + +#include "CrossPointSettings.h" +#include "MappedInputManager.h" +#include "activities/network/CalibreWirelessActivity.h" +#include "activities/network/WifiSelectionActivity.h" +#include "activities/util/KeyboardEntryActivity.h" +#include "fontIds.h" + +namespace { +constexpr int MENU_ITEMS = 2; +const char* menuNames[MENU_ITEMS] = {"Calibre Web URL", "Connect as Wireless Device"}; +} // namespace + +void CalibreSettingsActivity::taskTrampoline(void* param) { + auto* self = static_cast(param); + self->displayTaskLoop(); +} + +void CalibreSettingsActivity::onEnter() { + ActivityWithSubactivity::onEnter(); + + renderingMutex = xSemaphoreCreateMutex(); + selectedIndex = 0; + updateRequired = true; + + xTaskCreate(&CalibreSettingsActivity::taskTrampoline, "CalibreSettingsTask", + 4096, // Stack size + this, // Parameters + 1, // Priority + &displayTaskHandle // Task handle + ); +} + +void CalibreSettingsActivity::onExit() { + ActivityWithSubactivity::onExit(); + + xSemaphoreTake(renderingMutex, portMAX_DELAY); + if (displayTaskHandle) { + vTaskDelete(displayTaskHandle); + displayTaskHandle = nullptr; + } + vSemaphoreDelete(renderingMutex); + renderingMutex = nullptr; +} + +void CalibreSettingsActivity::loop() { + if (subActivity) { + subActivity->loop(); + return; + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Back)) { + onBack(); + return; + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) { + handleSelection(); + return; + } + + if (mappedInput.wasPressed(MappedInputManager::Button::Up) || + mappedInput.wasPressed(MappedInputManager::Button::Left)) { + selectedIndex = (selectedIndex + MENU_ITEMS - 1) % MENU_ITEMS; + updateRequired = true; + } else if (mappedInput.wasPressed(MappedInputManager::Button::Down) || + mappedInput.wasPressed(MappedInputManager::Button::Right)) { + selectedIndex = (selectedIndex + 1) % MENU_ITEMS; + updateRequired = true; + } +} + +void CalibreSettingsActivity::handleSelection() { + xSemaphoreTake(renderingMutex, portMAX_DELAY); + + if (selectedIndex == 0) { + // Calibre Web URL + exitActivity(); + enterNewActivity(new KeyboardEntryActivity( + renderer, mappedInput, "Calibre Web URL", SETTINGS.opdsServerUrl, 10, + 127, // maxLength + false, // not password + [this](const std::string& url) { + strncpy(SETTINGS.opdsServerUrl, url.c_str(), sizeof(SETTINGS.opdsServerUrl) - 1); + SETTINGS.opdsServerUrl[sizeof(SETTINGS.opdsServerUrl) - 1] = '\0'; + SETTINGS.saveToFile(); + exitActivity(); + updateRequired = true; + }, + [this]() { + exitActivity(); + updateRequired = true; + })); + } else if (selectedIndex == 1) { + // Wireless Device - launch the activity (handles WiFi connection internally) + exitActivity(); + if (WiFi.status() != WL_CONNECTED) { + enterNewActivity(new WifiSelectionActivity(renderer, mappedInput, [this](bool connected) { + exitActivity(); + if (connected) { + enterNewActivity(new CalibreWirelessActivity(renderer, mappedInput, [this] { + exitActivity(); + updateRequired = true; + })); + } else { + updateRequired = true; + } + })); + } else { + enterNewActivity(new CalibreWirelessActivity(renderer, mappedInput, [this] { + exitActivity(); + updateRequired = true; + })); + } + } + + xSemaphoreGive(renderingMutex); +} + +void CalibreSettingsActivity::displayTaskLoop() { + while (true) { + if (updateRequired && !subActivity) { + updateRequired = false; + xSemaphoreTake(renderingMutex, portMAX_DELAY); + render(); + xSemaphoreGive(renderingMutex); + } + vTaskDelay(10 / portTICK_PERIOD_MS); + } +} + +void CalibreSettingsActivity::render() { + renderer.clearScreen(); + + const auto pageWidth = renderer.getScreenWidth(); + + // Draw header + renderer.drawCenteredText(UI_12_FONT_ID, 15, "Calibre", true, EpdFontFamily::BOLD); + + // Draw selection highlight + renderer.fillRect(0, 60 + selectedIndex * 30 - 2, pageWidth - 1, 30); + + // Draw menu items + for (int i = 0; i < MENU_ITEMS; i++) { + const int settingY = 60 + i * 30; + const bool isSelected = (i == selectedIndex); + + renderer.drawText(UI_10_FONT_ID, 20, settingY, menuNames[i], !isSelected); + + // Draw status for URL setting + if (i == 0) { + const char* status = (strlen(SETTINGS.opdsServerUrl) > 0) ? "[Set]" : "[Not Set]"; + const auto width = renderer.getTextWidth(UI_10_FONT_ID, status); + renderer.drawText(UI_10_FONT_ID, pageWidth - 20 - width, settingY, status, !isSelected); + } + } + + // Draw button hints + const auto labels = mappedInput.mapLabels("« Back", "Select", "", ""); + renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); + + renderer.displayBuffer(); +} diff --git a/src/activities/settings/CalibreSettingsActivity.h b/src/activities/settings/CalibreSettingsActivity.h new file mode 100644 index 00000000..77b9218c --- /dev/null +++ b/src/activities/settings/CalibreSettingsActivity.h @@ -0,0 +1,36 @@ +#pragma once +#include +#include +#include + +#include + +#include "activities/ActivityWithSubactivity.h" + +/** + * Submenu for Calibre settings. + * Shows Calibre Web URL and Calibre Wireless Device options. + */ +class CalibreSettingsActivity final : public ActivityWithSubactivity { + public: + explicit CalibreSettingsActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, + const std::function& onBack) + : ActivityWithSubactivity("CalibreSettings", renderer, mappedInput), onBack(onBack) {} + + void onEnter() override; + void onExit() override; + void loop() override; + + private: + TaskHandle_t displayTaskHandle = nullptr; + SemaphoreHandle_t renderingMutex = nullptr; + bool updateRequired = false; + + int selectedIndex = 0; + const std::function onBack; + + static void taskTrampoline(void* param); + [[noreturn]] void displayTaskLoop(); + void render(); + void handleSelection(); +}; diff --git a/src/activities/settings/SettingsActivity.cpp b/src/activities/settings/SettingsActivity.cpp index d96dd781..94155ebe 100644 --- a/src/activities/settings/SettingsActivity.cpp +++ b/src/activities/settings/SettingsActivity.cpp @@ -5,6 +5,7 @@ #include +#include "CalibreSettingsActivity.h" #include "CrossPointSettings.h" #include "KOReaderSettingsActivity.h" #include "MappedInputManager.h" @@ -13,14 +14,16 @@ // Define the static settings list namespace { -constexpr int settingsCount = 17; +constexpr int settingsCount = 20; const SettingInfo settingsList[settingsCount] = { // Should match with SLEEP_SCREEN_MODE SettingInfo::Enum("Sleep Screen", &CrossPointSettings::sleepScreen, {"Dark", "Light", "Custom", "Cover", "None"}), SettingInfo::Enum("Sleep Screen Cover Mode", &CrossPointSettings::sleepScreenCoverMode, {"Fit", "Crop"}), SettingInfo::Enum("Status Bar", &CrossPointSettings::statusBar, {"None", "No Progress", "Full"}), + SettingInfo::Enum("Hide Battery %", &CrossPointSettings::hideBatteryPercentage, {"Never", "In Reader", "Always"}), SettingInfo::Toggle("Extra Paragraph Spacing", &CrossPointSettings::extraParagraphSpacing), - SettingInfo::Toggle("Short Power Button Click", &CrossPointSettings::shortPwrBtn), + SettingInfo::Toggle("Text Anti-Aliasing", &CrossPointSettings::textAntiAliasing), + SettingInfo::Enum("Short Power Button Click", &CrossPointSettings::shortPwrBtn, {"Ignore", "Sleep", "Page Turn"}), SettingInfo::Enum("Reading Orientation", &CrossPointSettings::orientation, {"Portrait", "Landscape CW", "Inverted", "Landscape CCW"}), SettingInfo::Enum("Front Button Layout", &CrossPointSettings::frontButtonLayout, @@ -39,6 +42,7 @@ const SettingInfo settingsList[settingsCount] = { SettingInfo::Enum("Refresh Frequency", &CrossPointSettings::refreshFrequency, {"1 page", "5 pages", "10 pages", "15 pages", "30 pages"}), SettingInfo::Action("KOReader Sync"), + SettingInfo::Action("Calibre Settings"), SettingInfo::Action("Check for updates")}; } // namespace @@ -135,18 +139,26 @@ void SettingsActivity::toggleCurrentSetting() { SETTINGS.*(setting.valuePtr) = currentValue + setting.valueRange.step; } } else if (setting.type == SettingType::ACTION) { - if (strcmp(setting.name, "Check for updates") == 0) { + if (strcmp(setting.name, "KOReader Sync") == 0) { xSemaphoreTake(renderingMutex, portMAX_DELAY); exitActivity(); - enterNewActivity(new OtaUpdateActivity(renderer, mappedInput, [this] { + enterNewActivity(new KOReaderSettingsActivity(renderer, mappedInput, [this] { exitActivity(); updateRequired = true; })); xSemaphoreGive(renderingMutex); - } else if (strcmp(setting.name, "KOReader Sync") == 0) { + } else if (strcmp(setting.name, "Calibre Settings") == 0) { xSemaphoreTake(renderingMutex, portMAX_DELAY); exitActivity(); - enterNewActivity(new KOReaderSettingsActivity(renderer, mappedInput, [this] { + enterNewActivity(new CalibreSettingsActivity(renderer, mappedInput, [this] { + exitActivity(); + updateRequired = true; + })); + xSemaphoreGive(renderingMutex); + } else if (strcmp(setting.name, "Check for updates") == 0) { + xSemaphoreTake(renderingMutex, portMAX_DELAY); + exitActivity(); + enterNewActivity(new OtaUpdateActivity(renderer, mappedInput, [this] { exitActivity(); updateRequired = true; })); diff --git a/src/main.cpp b/src/main.cpp index 73de236c..6ab7b4d7 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -7,6 +7,8 @@ #include #include +#include + #include "Battery.h" #include "CrossPointSettings.h" #include "CrossPointState.h" @@ -14,6 +16,7 @@ #include "MappedInputManager.h" #include "activities/boot_sleep/BootActivity.h" #include "activities/boot_sleep/SleepActivity.h" +#include "activities/browser/OpdsBookBrowserActivity.h" #include "activities/home/HomeActivity.h" #include "activities/network/CrossPointWebServerActivity.h" #include "activities/reader/ReaderActivity.h" @@ -223,10 +226,15 @@ void onGoToSettings() { enterNewActivity(new SettingsActivity(renderer, mappedInputManager, onGoHome)); } +void onGoToBrowser() { + exitActivity(); + enterNewActivity(new OpdsBookBrowserActivity(renderer, mappedInputManager, onGoHome)); +} + void onGoHome() { exitActivity(); enterNewActivity(new HomeActivity(renderer, mappedInputManager, onContinueReading, onGoToReaderHome, onGoToSettings, - onGoToFileTransfer)); + onGoToFileTransfer, onGoToBrowser)); } void setupDisplayAndFonts() { diff --git a/src/network/CrossPointWebServer.cpp b/src/network/CrossPointWebServer.cpp index 3a26a736..8703c2ae 100644 --- a/src/network/CrossPointWebServer.cpp +++ b/src/network/CrossPointWebServer.cpp @@ -194,7 +194,7 @@ void CrossPointWebServer::scanFiles(const char* path, const std::function +#include +#include + +#include + +bool HttpDownloader::fetchUrl(const std::string& url, std::string& outContent) { + const std::unique_ptr client(new WiFiClientSecure()); + client->setInsecure(); + HTTPClient http; + + Serial.printf("[%lu] [HTTP] Fetching: %s\n", millis(), url.c_str()); + + http.begin(*client, url.c_str()); + http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); + http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION); + + const int httpCode = http.GET(); + if (httpCode != HTTP_CODE_OK) { + Serial.printf("[%lu] [HTTP] Fetch failed: %d\n", millis(), httpCode); + http.end(); + return false; + } + + outContent = http.getString().c_str(); + http.end(); + + Serial.printf("[%lu] [HTTP] Fetched %zu bytes\n", millis(), outContent.size()); + return true; +} + +HttpDownloader::DownloadError HttpDownloader::downloadToFile(const std::string& url, const std::string& destPath, + ProgressCallback progress) { + const std::unique_ptr client(new WiFiClientSecure()); + client->setInsecure(); + HTTPClient http; + + Serial.printf("[%lu] [HTTP] Downloading: %s\n", millis(), url.c_str()); + Serial.printf("[%lu] [HTTP] Destination: %s\n", millis(), destPath.c_str()); + + http.begin(*client, url.c_str()); + http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); + http.addHeader("User-Agent", "CrossPoint-ESP32-" CROSSPOINT_VERSION); + + const int httpCode = http.GET(); + if (httpCode != HTTP_CODE_OK) { + Serial.printf("[%lu] [HTTP] Download failed: %d\n", millis(), httpCode); + http.end(); + return HTTP_ERROR; + } + + const size_t contentLength = http.getSize(); + Serial.printf("[%lu] [HTTP] Content-Length: %zu\n", millis(), contentLength); + + // Remove existing file if present + if (SdMan.exists(destPath.c_str())) { + SdMan.remove(destPath.c_str()); + } + + // Open file for writing + FsFile file; + if (!SdMan.openFileForWrite("HTTP", destPath.c_str(), file)) { + Serial.printf("[%lu] [HTTP] Failed to open file for writing\n", millis()); + http.end(); + return FILE_ERROR; + } + + // Get the stream for chunked reading + WiFiClient* stream = http.getStreamPtr(); + if (!stream) { + Serial.printf("[%lu] [HTTP] Failed to get stream\n", millis()); + file.close(); + SdMan.remove(destPath.c_str()); + http.end(); + return HTTP_ERROR; + } + + // Download in chunks + uint8_t buffer[DOWNLOAD_CHUNK_SIZE]; + size_t downloaded = 0; + const size_t total = contentLength > 0 ? contentLength : 0; + + while (http.connected() && (contentLength == 0 || downloaded < contentLength)) { + const size_t available = stream->available(); + if (available == 0) { + delay(1); + continue; + } + + const size_t toRead = available < DOWNLOAD_CHUNK_SIZE ? available : DOWNLOAD_CHUNK_SIZE; + const size_t bytesRead = stream->readBytes(buffer, toRead); + + if (bytesRead == 0) { + break; + } + + const size_t written = file.write(buffer, bytesRead); + if (written != bytesRead) { + Serial.printf("[%lu] [HTTP] Write failed: wrote %zu of %zu bytes\n", millis(), written, bytesRead); + file.close(); + SdMan.remove(destPath.c_str()); + http.end(); + return FILE_ERROR; + } + + downloaded += bytesRead; + + if (progress && total > 0) { + progress(downloaded, total); + } + } + + file.close(); + http.end(); + + Serial.printf("[%lu] [HTTP] Downloaded %zu bytes\n", millis(), downloaded); + + // Verify download size if known + if (contentLength > 0 && downloaded != contentLength) { + Serial.printf("[%lu] [HTTP] Size mismatch: got %zu, expected %zu\n", millis(), downloaded, contentLength); + SdMan.remove(destPath.c_str()); + return HTTP_ERROR; + } + + return OK; +} diff --git a/src/network/HttpDownloader.h b/src/network/HttpDownloader.h new file mode 100644 index 00000000..e6e0f163 --- /dev/null +++ b/src/network/HttpDownloader.h @@ -0,0 +1,42 @@ +#pragma once +#include + +#include +#include + +/** + * HTTP client utility for fetching content and downloading files. + * Wraps WiFiClientSecure and HTTPClient for HTTPS requests. + */ +class HttpDownloader { + public: + using ProgressCallback = std::function; + + enum DownloadError { + OK = 0, + HTTP_ERROR, + FILE_ERROR, + ABORTED, + }; + + /** + * Fetch text content from a URL. + * @param url The URL to fetch + * @param outContent The fetched content (output) + * @return true if fetch succeeded, false on error + */ + static bool fetchUrl(const std::string& url, std::string& outContent); + + /** + * Download a file to the SD card. + * @param url The URL to download + * @param destPath The destination path on SD card + * @param progress Optional progress callback + * @return DownloadError indicating success or failure type + */ + static DownloadError downloadToFile(const std::string& url, const std::string& destPath, + ProgressCallback progress = nullptr); + + private: + static constexpr size_t DOWNLOAD_CHUNK_SIZE = 1024; +}; diff --git a/src/util/StringUtils.cpp b/src/util/StringUtils.cpp new file mode 100644 index 00000000..e296d378 --- /dev/null +++ b/src/util/StringUtils.cpp @@ -0,0 +1,52 @@ +#include "StringUtils.h" + +#include + +namespace StringUtils { + +std::string sanitizeFilename(const std::string& name, size_t maxLength) { + std::string result; + result.reserve(name.size()); + + for (char c : name) { + // Replace invalid filename characters with underscore + if (c == '/' || c == '\\' || c == ':' || c == '*' || c == '?' || c == '"' || c == '<' || c == '>' || c == '|') { + result += '_'; + } else if (c >= 32 && c < 127) { + // Keep printable ASCII characters + result += c; + } + // Skip non-printable characters + } + + // Trim leading/trailing spaces and dots + size_t start = result.find_first_not_of(" ."); + if (start == std::string::npos) { + return "book"; // Fallback if name is all invalid characters + } + size_t end = result.find_last_not_of(" ."); + result = result.substr(start, end - start + 1); + + // Limit filename length + if (result.length() > maxLength) { + result.resize(maxLength); + } + + return result.empty() ? "book" : result; +} + +bool checkFileExtension(const std::string& fileName, const char* extension) { + if (fileName.length() < strlen(extension)) { + return false; + } + + const std::string fileExt = fileName.substr(fileName.length() - strlen(extension)); + for (size_t i = 0; i < fileExt.length(); i++) { + if (tolower(fileExt[i]) != tolower(extension[i])) { + return false; + } + } + return true; +} + +} // namespace StringUtils diff --git a/src/util/StringUtils.h b/src/util/StringUtils.h new file mode 100644 index 00000000..f846cf17 --- /dev/null +++ b/src/util/StringUtils.h @@ -0,0 +1,19 @@ +#pragma once + +#include + +namespace StringUtils { + +/** + * Sanitize a string for use as a filename. + * Replaces invalid characters with underscores, trims spaces/dots, + * and limits length to maxLength characters. + */ +std::string sanitizeFilename(const std::string& name, size_t maxLength = 100); + +/** + * Check if the given filename ends with the specified extension (case-insensitive). + */ +bool checkFileExtension(const std::string& fileName, const char* extension); + +} // namespace StringUtils diff --git a/src/util/UrlUtils.cpp b/src/util/UrlUtils.cpp new file mode 100644 index 00000000..0eeeae3a --- /dev/null +++ b/src/util/UrlUtils.cpp @@ -0,0 +1,41 @@ +#include "UrlUtils.h" + +namespace UrlUtils { + +std::string ensureProtocol(const std::string& url) { + if (url.find("://") == std::string::npos) { + return "http://" + url; + } + return url; +} + +std::string extractHost(const std::string& url) { + const size_t protocolEnd = url.find("://"); + if (protocolEnd == std::string::npos) { + // No protocol, find first slash + const size_t firstSlash = url.find('/'); + return firstSlash == std::string::npos ? url : url.substr(0, firstSlash); + } + // Find the first slash after the protocol + const size_t hostStart = protocolEnd + 3; + const size_t pathStart = url.find('/', hostStart); + return pathStart == std::string::npos ? url : url.substr(0, pathStart); +} + +std::string buildUrl(const std::string& serverUrl, const std::string& path) { + const std::string urlWithProtocol = ensureProtocol(serverUrl); + if (path.empty()) { + return urlWithProtocol; + } + if (path[0] == '/') { + // Absolute path - use just the host + return extractHost(urlWithProtocol) + path; + } + // Relative path - append to server URL + if (urlWithProtocol.back() == '/') { + return urlWithProtocol + path; + } + return urlWithProtocol + "/" + path; +} + +} // namespace UrlUtils diff --git a/src/util/UrlUtils.h b/src/util/UrlUtils.h new file mode 100644 index 00000000..d88ca13c --- /dev/null +++ b/src/util/UrlUtils.h @@ -0,0 +1,23 @@ +#pragma once +#include + +namespace UrlUtils { + +/** + * Prepend http:// if no protocol specified (server will redirect to https if needed) + */ +std::string ensureProtocol(const std::string& url); + +/** + * Extract host with protocol from URL (e.g., "http://example.com" from "http://example.com/path") + */ +std::string extractHost(const std::string& url); + +/** + * Build full URL from server URL and path. + * If path starts with /, it's an absolute path from the host root. + * Otherwise, it's relative to the server URL. + */ +std::string buildUrl(const std::string& serverUrl, const std::string& path); + +} // namespace UrlUtils