Compare commits

...

16 Commits

Author SHA1 Message Date
Aleksejs Popovs
0e815e76c8
Merge 8e38e1bd49 into 4dd73a211a 2026-02-01 19:53:26 +11:00
Gaspar Fabrega Ragni
4dd73a211a
fix: custom sleep not showing image at index 0 (#639)
Some checks are pending
CI / build (push) Waiting to run
## Summary

* Fixing custom sleep behaviour where the first image in the /sleep
directory is not shown
* image at index 0 is not being rendered when more than 1 image is
stored in /sleep directory, because `APP_STATE.lastSleepImage` is always
0.

## Additional Context

* `APP_STATE.lastSleepImage` is reset to 0 when a epub is open, this
value is only used to compare it to the randomly selected one in
`renderCustomSleepScreen()` that should always be a valid index, since
the list of valid bmp images is colected from scratch. -> no need to
reset it and block image @ index 0 from being rendered

---

### 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? _**NO**_

Co-authored-by: Oyster <Oyster@home>
2026-02-01 19:22:52 +11:00
Thomas Foskolos
634f6279cb
docs: Update USER_GUIDE.md (#625)
Add Greek as not supported language atm on the use guide
2026-02-01 19:21:36 +11:00
nscheung
11b2a59233
fix: Hide button hints in landscape CW mode (#637)
## Summary

* This change hides the button hints from overlapping chapter titles
when in landscape CW mode.

Before

![Before](https://github.com/user-attachments/assets/3015a9b3-3fa5-443b-a641-3e65841a6fbc)
After

![After](https://github.com/user-attachments/assets/011de15d-5ae6-429c-8f91-d8f37abe52d9)

## Additional Context

* I initially considered implementing an offset fix, but with potential
UI changes on the horizon, hiding the button hints appears to be the
simplest solution for now.

---

### 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? < Partially >
2026-02-01 19:21:28 +11:00
Luke Stein
12c20bb09e
fix: WiFi error screen text clarifications (#612)
## Summary

* Clarify strings on Wifi connection error screens
* I have confirmed on device that these are short enough not to overflow
screen margins

## Additional Context

* Several screens give duplicative text (e.g., header "Connection
Failed" with contents text "Connection failed") or slightly confusing
text (header "Forget Network?" with text "Remove saved password?")

---

### AI Usage

Did you use AI tools to help write this code? **No**
2026-02-01 19:19:23 +11:00
Arthur Tazhitdinov
6b7065b986
fix: don't wake up after USB connect (#576)
## Summary

* fixes problem that if short power button press is enabled, connecting
device to usb leads to waking up
2026-02-01 18:51:31 +11:00
Arthur Tazhitdinov
f4df513bf3
feat(ui): change popup logic (#442)
## Summary

* refactors Indexing popups into ScreenComponents (they had different
implementations in different files)
* removes Indexing popup for small chapters
* only show Indexing popup (without progress bar) for large chapters
(using same minimum file size condition as for progress bar before)

## Additional Context

* Having to show even single popup message and redraw the screen slows
down the flow significantly
* Testing results:
    * Opening large chapter with progress bar - 11 seconds
* Same chapter without progress bar, only single Indexing popup - 5
seconds

---

### AI Usage

Did you use AI tools to help write this code? _**< PARTIALLY>**_
2026-02-01 18:41:24 +11:00
Jonas Diemer
f935b59a41
feat: Add reading menu and delete cache function (#433)
## Summary

* Adds a menu in the Epub reader
* The Chapter selection is moved there to pos 1 (so it can be reached by
double tapping the confirm button)
* A Go Home is there, too
* Most significantly, a function "Delete Book Cache" is added. This
returns to main (to avoid directly rebuilding cached items, eg. if this
is used to debug/develop other areas - and it's also easier ;))

Probably, the Sync function could now be moved from the Chapter
selection to this menu, too.

---

### 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? _**PARTIALLY**_
2026-02-01 18:34:30 +11:00
Aleksejs Popovs
8e38e1bd49 bin/clang-format-fix 2026-01-21 21:44:31 -05:00
Aleksejs Popovs
25ef2e4e5d appease harder (sorry) 2026-01-21 21:40:08 -05:00
Aleksejs Popovs
f337d7934f appease cppcheck 2026-01-21 21:32:42 -05:00
Aleksejs Popovs
2a1f7873f7 add autoopen 2026-01-21 20:57:06 -05:00
Aleksejs Popovs
6473689e3e avoid overwriting on filename conflicts 2026-01-21 20:15:28 -05:00
Aleksejs Popovs
687b0b5ac0 clean up logging, comments 2026-01-21 20:13:22 -05:00
Aleksejs Popovs
e45780674d port to nimble 2026-01-21 20:13:22 -05:00
Aleksejs Popovs
26b2dc27fa Initial implementation of Bluetooth file transfer 2026-01-21 20:13:21 -05:00
28 changed files with 877 additions and 183 deletions

View File

@ -201,7 +201,7 @@ CrossPoint renders text using the following Unicode character blocks, enabling s
* **Latin Script (Basic, Supplement, Extended-A):** Covers English, German, French, Spanish, Portuguese, Italian, Dutch, Swedish, Norwegian, Danish, Finnish, Polish, Czech, Hungarian, Romanian, Slovak, Slovenian, Turkish, and others. * **Latin Script (Basic, Supplement, Extended-A):** Covers English, German, French, Spanish, Portuguese, Italian, Dutch, Swedish, Norwegian, Danish, Finnish, Polish, Czech, Hungarian, Romanian, Slovak, Slovenian, Turkish, and others.
* **Cyrillic Script (Standard and Extended):** Covers Russian, Ukrainian, Belarusian, Bulgarian, Serbian, Macedonian, Kazakh, Kyrgyz, Mongolian, and others. * **Cyrillic Script (Standard and Extended):** Covers Russian, Ukrainian, Belarusian, Bulgarian, Serbian, Macedonian, Kazakh, Kyrgyz, Mongolian, and others.
What is not supported: Chinese, Japanese, Korean, Vietnamese, Hebrew, Arabic and Farsi. What is not supported: Chinese, Japanese, Korean, Vietnamese, Hebrew, Arabic, Greek and Farsi.
--- ---

View File

@ -123,9 +123,7 @@ bool Section::clearCache() const {
bool Section::createSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing, bool Section::createSectionFile(const int fontId, const float lineCompression, const bool extraParagraphSpacing,
const uint8_t paragraphAlignment, const uint16_t viewportWidth, const uint8_t paragraphAlignment, const uint16_t viewportWidth,
const uint16_t viewportHeight, const bool hyphenationEnabled, const uint16_t viewportHeight, const bool hyphenationEnabled,
const std::function<void()>& progressSetupFn, const std::function<void()>& popupFn) {
const std::function<void(int)>& progressFn) {
constexpr uint32_t MIN_SIZE_FOR_PROGRESS = 50 * 1024; // 50KB
const auto localPath = epub->getSpineItem(spineIndex).href; const auto localPath = epub->getSpineItem(spineIndex).href;
const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html"; const auto tmpHtmlPath = epub->getCachePath() + "/.tmp_" + std::to_string(spineIndex) + ".html";
@ -171,11 +169,6 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
Serial.printf("[%lu] [SCT] Streamed temp HTML to %s (%d bytes)\n", millis(), tmpHtmlPath.c_str(), fileSize); Serial.printf("[%lu] [SCT] Streamed temp HTML to %s (%d bytes)\n", millis(), tmpHtmlPath.c_str(), fileSize);
// Only show progress bar for larger chapters where rendering overhead is worth it
if (progressSetupFn && fileSize >= MIN_SIZE_FOR_PROGRESS) {
progressSetupFn();
}
if (!SdMan.openFileForWrite("SCT", filePath, file)) { if (!SdMan.openFileForWrite("SCT", filePath, file)) {
return false; return false;
} }
@ -186,8 +179,7 @@ bool Section::createSectionFile(const int fontId, const float lineCompression, c
ChapterHtmlSlimParser visitor( ChapterHtmlSlimParser visitor(
tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth, tmpHtmlPath, renderer, fontId, lineCompression, extraParagraphSpacing, paragraphAlignment, viewportWidth,
viewportHeight, hyphenationEnabled, viewportHeight, hyphenationEnabled,
[this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); }, [this, &lut](std::unique_ptr<Page> page) { lut.emplace_back(this->onPageComplete(std::move(page))); }, popupFn);
progressFn);
Hyphenator::setPreferredLanguage(epub->getLanguage()); Hyphenator::setPreferredLanguage(epub->getLanguage());
success = visitor.parseAndBuildPages(); success = visitor.parseAndBuildPages();

View File

@ -33,7 +33,6 @@ class Section {
bool clearCache() const; bool clearCache() const;
bool createSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment, bool createSectionFile(int fontId, float lineCompression, bool extraParagraphSpacing, uint8_t paragraphAlignment,
uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled, uint16_t viewportWidth, uint16_t viewportHeight, bool hyphenationEnabled,
const std::function<void()>& progressSetupFn = nullptr, const std::function<void()>& popupFn = nullptr);
const std::function<void(int)>& progressFn = nullptr);
std::unique_ptr<Page> loadPageFromSectionFile(); std::unique_ptr<Page> loadPageFromSectionFile();
}; };

View File

@ -10,8 +10,8 @@
const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"}; const char* HEADER_TAGS[] = {"h1", "h2", "h3", "h4", "h5", "h6"};
constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]); constexpr int NUM_HEADER_TAGS = sizeof(HEADER_TAGS) / sizeof(HEADER_TAGS[0]);
// Minimum file size (in bytes) to show progress bar - smaller chapters don't benefit from it // Minimum file size (in bytes) to show indexing popup - smaller chapters don't benefit from it
constexpr size_t MIN_SIZE_FOR_PROGRESS = 50 * 1024; // 50KB constexpr size_t MIN_SIZE_FOR_POPUP = 50 * 1024; // 50KB
const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"}; const char* BLOCK_TAGS[] = {"p", "li", "div", "br", "blockquote"};
constexpr int NUM_BLOCK_TAGS = sizeof(BLOCK_TAGS) / sizeof(BLOCK_TAGS[0]); constexpr int NUM_BLOCK_TAGS = sizeof(BLOCK_TAGS) / sizeof(BLOCK_TAGS[0]);
@ -289,10 +289,10 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
return false; return false;
} }
// Get file size for progress calculation // Get file size to decide whether to show indexing popup.
const size_t totalSize = file.size(); if (popupFn && file.size() >= MIN_SIZE_FOR_POPUP) {
size_t bytesRead = 0; popupFn();
int lastProgress = -1; }
XML_SetUserData(parser, this); XML_SetUserData(parser, this);
XML_SetElementHandler(parser, startElement, endElement); XML_SetElementHandler(parser, startElement, endElement);
@ -322,17 +322,6 @@ bool ChapterHtmlSlimParser::parseAndBuildPages() {
return false; return false;
} }
// Update progress (call every 10% change to avoid too frequent updates)
// Only show progress for larger chapters where rendering overhead is worth it
bytesRead += len;
if (progressFn && totalSize >= MIN_SIZE_FOR_PROGRESS) {
const int progress = static_cast<int>((bytesRead * 100) / totalSize);
if (lastProgress / 10 != progress / 10) {
lastProgress = progress;
progressFn(progress);
}
}
done = file.available() == 0; done = file.available() == 0;
if (XML_ParseBuffer(parser, static_cast<int>(len), done) == XML_STATUS_ERROR) { if (XML_ParseBuffer(parser, static_cast<int>(len), done) == XML_STATUS_ERROR) {

View File

@ -18,7 +18,7 @@ class ChapterHtmlSlimParser {
const std::string& filepath; const std::string& filepath;
GfxRenderer& renderer; GfxRenderer& renderer;
std::function<void(std::unique_ptr<Page>)> completePageFn; std::function<void(std::unique_ptr<Page>)> completePageFn;
std::function<void(int)> progressFn; // Progress callback (0-100) std::function<void()> popupFn; // Popup callback
int depth = 0; int depth = 0;
int skipUntilDepth = INT_MAX; int skipUntilDepth = INT_MAX;
int boldUntilDepth = INT_MAX; int boldUntilDepth = INT_MAX;
@ -52,7 +52,7 @@ class ChapterHtmlSlimParser {
const uint8_t paragraphAlignment, const uint16_t viewportWidth, const uint8_t paragraphAlignment, const uint16_t viewportWidth,
const uint16_t viewportHeight, const bool hyphenationEnabled, const uint16_t viewportHeight, const bool hyphenationEnabled,
const std::function<void(std::unique_ptr<Page>)>& completePageFn, const std::function<void(std::unique_ptr<Page>)>& completePageFn,
const std::function<void(int)>& progressFn = nullptr) const std::function<void()>& popupFn = nullptr)
: filepath(filepath), : filepath(filepath),
renderer(renderer), renderer(renderer),
fontId(fontId), fontId(fontId),
@ -63,7 +63,7 @@ class ChapterHtmlSlimParser {
viewportHeight(viewportHeight), viewportHeight(viewportHeight),
hyphenationEnabled(hyphenationEnabled), hyphenationEnabled(hyphenationEnabled),
completePageFn(completePageFn), completePageFn(completePageFn),
progressFn(progressFn) {} popupFn(popupFn) {}
~ChapterHtmlSlimParser() = default; ~ChapterHtmlSlimParser() = default;
bool parseAndBuildPages(); bool parseAndBuildPages();
void addLineToPage(std::shared_ptr<TextBlock> line); void addLineToPage(std::shared_ptr<TextBlock> line);

View File

@ -56,7 +56,7 @@ class GfxRenderer {
int getScreenHeight() const; int getScreenHeight() const;
void displayBuffer(HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH) const; void displayBuffer(HalDisplay::RefreshMode refreshMode = HalDisplay::FAST_REFRESH) const;
// EXPERIMENTAL: Windowed update - display only a rectangular region // EXPERIMENTAL: Windowed update - display only a rectangular region
void displayWindow(int x, int y, int width, int height) const; // void displayWindow(int x, int y, int width, int height) const;
void invertScreen() const; void invertScreen() const;
void clearScreen(uint8_t color = 0xFF) const; void clearScreen(uint8_t color = 0xFF) const;

View File

@ -24,12 +24,13 @@ bool HalGPIO::wasAnyReleased() const { return inputMgr.wasAnyReleased(); }
unsigned long HalGPIO::getHeldTime() const { return inputMgr.getHeldTime(); } unsigned long HalGPIO::getHeldTime() const { return inputMgr.getHeldTime(); }
void HalGPIO::startDeepSleep() { void HalGPIO::startDeepSleep() {
esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW);
// Ensure that the power button has been released to avoid immediately turning back on if you're holding it // Ensure that the power button has been released to avoid immediately turning back on if you're holding it
while (inputMgr.isPressed(BTN_POWER)) { while (inputMgr.isPressed(BTN_POWER)) {
delay(50); delay(50);
inputMgr.update(); inputMgr.update();
} }
// Arm the wakeup trigger *after* the button is released
esp_deep_sleep_enable_gpio_wakeup(1ULL << InputManager::POWER_BUTTON_PIN, ESP_GPIO_WAKEUP_GPIO_LOW);
// Enter Deep Sleep // Enter Deep Sleep
esp_deep_sleep_start(); esp_deep_sleep_start();
} }
@ -44,12 +45,20 @@ bool HalGPIO::isUsbConnected() const {
return digitalRead(UART0_RXD) == HIGH; return digitalRead(UART0_RXD) == HIGH;
} }
bool HalGPIO::isWakeupByPowerButton() const { HalGPIO::WakeupReason HalGPIO::getWakeupReason() const {
const bool usbConnected = isUsbConnected();
const auto wakeupCause = esp_sleep_get_wakeup_cause(); const auto wakeupCause = esp_sleep_get_wakeup_cause();
const auto resetReason = esp_reset_reason(); const auto resetReason = esp_reset_reason();
if (isUsbConnected()) {
return wakeupCause == ESP_SLEEP_WAKEUP_GPIO; if ((wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_POWERON && !usbConnected) ||
} else { (wakeupCause == ESP_SLEEP_WAKEUP_GPIO && resetReason == ESP_RST_DEEPSLEEP && usbConnected)) {
return (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED) && (resetReason == ESP_RST_POWERON); return WakeupReason::PowerButton;
} }
} if (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_UNKNOWN && usbConnected) {
return WakeupReason::AfterFlash;
}
if (wakeupCause == ESP_SLEEP_WAKEUP_UNDEFINED && resetReason == ESP_RST_POWERON && usbConnected) {
return WakeupReason::AfterUSBPower;
}
return WakeupReason::Other;
}

View File

@ -47,8 +47,9 @@ class HalGPIO {
// Check if USB is connected // Check if USB is connected
bool isUsbConnected() const; bool isUsbConnected() const;
// Check if wakeup was caused by power button press enum class WakeupReason { PowerButton, AfterFlash, AfterUSBPower, Other };
bool isWakeupByPowerButton() const;
WakeupReason getWakeupReason() const;
// Button indices // Button indices
static constexpr uint8_t BTN_BACK = 0; static constexpr uint8_t BTN_BACK = 0;

View File

@ -48,6 +48,7 @@ lib_deps =
bblanchon/ArduinoJson @ 7.4.2 bblanchon/ArduinoJson @ 7.4.2
ricmoo/QRCode @ 0.0.1 ricmoo/QRCode @ 0.0.1
links2004/WebSockets @ 2.7.3 links2004/WebSockets @ 2.7.3
h2zero/NimBLE-Arduino @ 2.3.7
[env:default] [env:default]
extends = base extends = base

View File

@ -42,6 +42,38 @@ void ScreenComponents::drawBattery(const GfxRenderer& renderer, const int left,
renderer.fillRect(x + 2, y + 2, filledWidth, batteryHeight - 4); renderer.fillRect(x + 2, y + 2, filledWidth, batteryHeight - 4);
} }
ScreenComponents::PopupLayout ScreenComponents::drawPopup(const GfxRenderer& renderer, const char* message) {
constexpr int margin = 15;
constexpr int y = 60;
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message, EpdFontFamily::BOLD);
const int textHeight = renderer.getLineHeight(UI_12_FONT_ID);
const int w = textWidth + margin * 2;
const int h = textHeight + margin * 2;
const int x = (renderer.getScreenWidth() - w) / 2;
renderer.fillRect(x - 2, y - 2, w + 4, h + 4, true); // frame thickness 2
renderer.fillRect(x, y, w, h, false);
const int textX = x + (w - textWidth) / 2;
const int textY = y + margin - 2;
renderer.drawText(UI_12_FONT_ID, textX, textY, message, true, EpdFontFamily::BOLD);
renderer.displayBuffer();
return {x, y, w, h};
}
void ScreenComponents::fillPopupProgress(const GfxRenderer& renderer, const PopupLayout& layout, const int progress) {
constexpr int barHeight = 4;
const int barWidth = layout.width - 30; // twice the margin in drawPopup to match text width
const int barX = layout.x + (layout.width - barWidth) / 2;
const int barY = layout.y + layout.height - 10;
int fillWidth = barWidth * progress / 100;
renderer.fillRect(barX, barY, fillWidth, barHeight, true);
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
}
void ScreenComponents::drawBookProgressBar(const GfxRenderer& renderer, const size_t bookProgress) { void ScreenComponents::drawBookProgressBar(const GfxRenderer& renderer, const size_t bookProgress) {
int vieweableMarginTop, vieweableMarginRight, vieweableMarginBottom, vieweableMarginLeft; int vieweableMarginTop, vieweableMarginRight, vieweableMarginBottom, vieweableMarginLeft;
renderer.getOrientedViewableTRBL(&vieweableMarginTop, &vieweableMarginRight, &vieweableMarginBottom, renderer.getOrientedViewableTRBL(&vieweableMarginTop, &vieweableMarginRight, &vieweableMarginBottom,

View File

@ -15,9 +15,20 @@ class ScreenComponents {
public: public:
static const int BOOK_PROGRESS_BAR_HEIGHT = 4; static const int BOOK_PROGRESS_BAR_HEIGHT = 4;
struct PopupLayout {
int x;
int y;
int width;
int height;
};
static void drawBattery(const GfxRenderer& renderer, int left, int top, bool showPercentage = true); static void drawBattery(const GfxRenderer& renderer, int left, int top, bool showPercentage = true);
static void drawBookProgressBar(const GfxRenderer& renderer, size_t bookProgress); static void drawBookProgressBar(const GfxRenderer& renderer, size_t bookProgress);
static PopupLayout drawPopup(const GfxRenderer& renderer, const char* message);
static void fillPopupProgress(const GfxRenderer& renderer, const PopupLayout& layout, int progress);
// Draw a horizontal tab bar with underline indicator for selected tab // Draw a horizontal tab bar with underline indicator for selected tab
// Returns the height of the tab bar (for positioning content below) // Returns the height of the tab bar (for positioning content below)
static int drawTabBar(const GfxRenderer& renderer, int y, const std::vector<TabInfo>& tabs); static int drawTabBar(const GfxRenderer& renderer, int y, const std::vector<TabInfo>& tabs);

View File

@ -0,0 +1,337 @@
#include "BluetoothActivity.h"
#include <GfxRenderer.h>
#include "MappedInputManager.h"
#include "fontIds.h"
#include "util/StringUtils.h"
#define DEVICE_NAME "EPaper"
#define SERVICE_UUID "4ae29d01-499a-480a-8c41-a82192105125"
#define REQUEST_CHARACTERISTIC_UUID "a00e530d-b48b-48c8-aadb-d062a1b91792"
#define RESPONSE_CHARACTERISTIC_UUID "0c656023-dee6-47c5-9afb-e601dfbdaa1d"
#define OUTPUT_DIRECTORY "/bt"
#define MAX_FILENAME_LENGTH 200
#define PROTOCOL_ASSERT(cond, fmt, ...) \
do { \
if (!(cond)) { \
snprintf(errorMessage, sizeof(errorMessage), fmt, ##__VA_ARGS__); \
intoState(STATE_ERROR); \
return; \
} \
} while (0)
void BluetoothActivity::displayTaskTrampoline(void* param) {
auto* self = static_cast<BluetoothActivity*>(param);
self->displayTaskLoop();
}
void BluetoothActivity::reportTaskTrampoline(void* param) {
auto* self = static_cast<BluetoothActivity*>(param);
self->report();
vTaskDelete(nullptr);
}
void BluetoothActivity::report() {
if (state != STATE_DONE) {
return;
}
onFileReceived(OUTPUT_DIRECTORY "/" + filename);
}
void BluetoothActivity::startAdvertising() { NimBLEDevice::startAdvertising(); }
void BluetoothActivity::stopAdvertising() { NimBLEDevice::stopAdvertising(); }
void BluetoothActivity::onEnter() {
Activity::onEnter();
NimBLEDevice::init(DEVICE_NAME);
NimBLEServer* pServer = NimBLEDevice::createServer();
pServer->setCallbacks(&serverCallbacks, false);
NimBLEService* pService = pServer->createService(SERVICE_UUID);
NimBLECharacteristic* pRequestChar =
pService->createCharacteristic(REQUEST_CHARACTERISTIC_UUID, NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::WRITE_NR);
pRequestChar->setCallbacks(&requestCallbacks);
pResponseChar = pService->createCharacteristic(RESPONSE_CHARACTERISTIC_UUID, NIMBLE_PROPERTY::INDICATE);
pService->start();
NimBLEAdvertising* pAdvertising = NimBLEDevice::getAdvertising();
pAdvertising->setName(DEVICE_NAME);
pAdvertising->addServiceUUID(pService->getUUID());
pAdvertising->enableScanResponse(true);
renderingMutex = xSemaphoreCreateMutex();
state = STATE_INITIALIZING;
intoState(STATE_WAITING);
xTaskCreate(&BluetoothActivity::displayTaskTrampoline, "BluetoothTask",
// TODO: figure out how much stack we actually need
4096, // Stack size
this, // Parameters
1, // Priority
&displayTaskHandle // Task handle
);
}
void BluetoothActivity::intoState(State newState) {
if (state == newState) {
return;
}
switch (newState) {
case STATE_WAITING:
file.close();
startAdvertising();
txnId = 0;
break;
case STATE_OFFERED:
// caller sets filename, totalBytes, file, txnId
receivedBytes = 0;
break;
case STATE_DONE:
// we cannot call onFileReceived here directly because it might cause onExit to be called,
// which calls NimBLEDevice::deinit, which cannot be called from inside a NimBLE callback.
xTaskCreate(&BluetoothActivity::reportTaskTrampoline, "BluetoothReportTask",
2048, // Stack size
this, // Parameters
1, // Priority,
nullptr);
break;
case STATE_ERROR: {
// caller sets errorMessage
file.close();
NimBLEServer* pServer = NimBLEDevice::getServer();
if (pServer != nullptr && pServer->getConnectedCount() > 0) {
// TODO: send back a response over BLE?
pServer->disconnect(pServer->getPeerInfo(0));
}
break;
}
}
state = newState;
updateRequired = true;
}
void BluetoothActivity::onExit() {
Activity::onExit();
file.close();
stopAdvertising();
NimBLEDevice::deinit(true);
// Wait until not rendering to delete task
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
}
void BluetoothActivity::loop() {
// Handle back button - cancel
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
onCancel();
return;
}
if (state == STATE_ERROR || state == STATE_DONE) {
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
// restart
intoState(STATE_WAITING);
}
}
}
void BluetoothActivity::displayTaskLoop() {
while (true) {
if (updateRequired) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
render();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void BluetoothActivity::render() const {
renderer.clearScreen();
renderer.drawCenteredText(UI_12_FONT_ID, 15, "Bluetooth", true, EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_10_FONT_ID, 50, "Use the Longform app to transfer files.");
std::string stateText;
switch (state) {
case STATE_WAITING:
stateText = "Waiting for a connection.";
break;
case STATE_CONNECTED:
stateText = "Connected.";
break;
case STATE_OFFERED:
stateText = "Ready to receive.";
break;
case STATE_RECEIVING:
stateText = "Receiving.";
break;
case STATE_DONE:
stateText = "Transfer complete.";
break;
case STATE_ERROR:
stateText = "An error occurred.";
break;
default:
stateText = "UNKNOWN STATE.";
}
renderer.drawCenteredText(UI_10_FONT_ID, 75, stateText.c_str());
if (state == STATE_OFFERED || state == STATE_RECEIVING || state == STATE_DONE) {
renderer.drawCenteredText(UI_12_FONT_ID, 110, filename.c_str());
} else if (state == STATE_ERROR) {
renderer.drawCenteredText(UI_10_FONT_ID, 110, errorMessage);
}
if (state == STATE_RECEIVING) {
const int percent = (totalBytes > 0) ? (receivedBytes * 100) / totalBytes : 0;
const int barWidth = renderer.getScreenWidth() * 3 / 4;
const int barHeight = 20;
const int boxX = (renderer.getScreenWidth() - barWidth) / 2;
const int boxY = 160;
renderer.drawRect(boxX, boxY, barWidth, barHeight);
const int fillWidth = (barWidth - 2) * percent / 100;
renderer.fillRect(boxX + 1, boxY + 1, fillWidth, barHeight - 2);
char dynamicText[64];
snprintf(dynamicText, sizeof(dynamicText), "Received %zu / %zu bytes (%d%%)", receivedBytes, totalBytes, percent);
renderer.drawCenteredText(UI_10_FONT_ID, 200, dynamicText);
}
// Draw help text at bottom
const auto labels =
mappedInput.mapLabels("« Back", (state == STATE_ERROR || state == STATE_DONE) ? "Restart" : "", "", "");
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
renderer.displayBuffer();
}
void BluetoothActivity::ServerCallbacks::onConnect(NimBLEServer* pServer, NimBLEConnInfo& connInfo) {
Serial.printf("[%lu] [BT] connected\n", millis());
activity->onConnected(true);
}
void BluetoothActivity::ServerCallbacks::onDisconnect(NimBLEServer* pServer, NimBLEConnInfo& connInfo, int reason) {
Serial.printf("[%lu] [BT] disconnected\n", millis());
activity->onConnected(false);
}
void BluetoothActivity::onConnected(bool isConnected) {
if (state == STATE_ERROR || state == STATE_DONE) {
// stay in error state so the user can read the error message even after disconnect.
// stay in done state so the user can see the transfer complete message.
return;
}
intoState(isConnected ? STATE_CONNECTED : STATE_WAITING);
}
void BluetoothActivity::onRequest(const lfbt_message* msg, size_t msg_len) {
if (state == STATE_ERROR) {
// ignore further messages in error state
return;
}
PROTOCOL_ASSERT((txnId == 0) || (txnId == msg->txnId), "Multiple transfers happening at once (%x != %x)", txnId,
msg->txnId);
switch (msg->type) {
case 0: // client_offer
{
PROTOCOL_ASSERT(state == STATE_CONNECTED, "Invalid state for client_offer: %d", state);
PROTOCOL_ASSERT(msg->body.clientOffer.version == 1, "Unsupported protocol version: %u",
msg->body.clientOffer.version);
totalBytes = msg->body.clientOffer.bodyLength;
size_t filenameLength = msg_len - 8 - sizeof(lfbt_msg_client_offer);
std::string originalFilename =
StringUtils::sanitizeFilename(std::string(msg->body.clientOffer.name, filenameLength), MAX_FILENAME_LENGTH);
PROTOCOL_ASSERT(SdMan.ensureDirectoryExists(OUTPUT_DIRECTORY), "Couldn't create output directory %s",
OUTPUT_DIRECTORY);
// generate unique filepath
auto splitName = StringUtils::splitFileName(originalFilename);
filename = originalFilename;
std::string filepath = OUTPUT_DIRECTORY "/" + filename;
uint32_t duplicateIndex = 0;
while (SdMan.exists(filepath.c_str())) {
duplicateIndex++;
if (splitName.second.empty()) {
// no extension
filename = splitName.first + "-" + std::to_string(duplicateIndex);
} else {
filename = splitName.first + "-" + std::to_string(duplicateIndex) + splitName.second;
}
filepath = OUTPUT_DIRECTORY "/" + filename;
}
PROTOCOL_ASSERT(SdMan.openFileForWrite("BT", filepath, file), "Couldn't open file %s for writing",
filepath.c_str());
// TODO: would be neat to check if we have enough space, but SDCardManager doesn't seem to expose that info
// currently
txnId = msg->txnId;
intoState(STATE_OFFERED);
lfbt_message response = {.type = 1, // server_response
.txnId = txnId,
.body = {.serverResponse = {.status = 0}}};
pResponseChar->setValue(reinterpret_cast<uint8_t*>(&response), 8 + sizeof(lfbt_msg_server_response));
pResponseChar->indicate();
updateRequired = true;
break;
}
case 2: // client_chunk
{
Serial.printf("[%lu] [BT] Received client_chunk, offset %u, length %zu\n", millis(), msg->body.clientChunk.offset,
msg_len - 8 - sizeof(lfbt_msg_client_chunk));
PROTOCOL_ASSERT(state == STATE_OFFERED || state == STATE_RECEIVING, "Invalid state for client_chunk: %d", state);
PROTOCOL_ASSERT(msg->body.clientChunk.offset == receivedBytes, "Expected chunk %zu, got %u", receivedBytes,
msg->body.clientChunk.offset);
size_t written = file.write(reinterpret_cast<const uint8_t*>(msg->body.clientChunk.body),
msg_len - 8 - sizeof(lfbt_msg_client_chunk));
PROTOCOL_ASSERT(written > 0, "Couldn't write to file");
receivedBytes += msg_len - 8 - sizeof(lfbt_msg_client_chunk);
if (receivedBytes >= totalBytes) {
PROTOCOL_ASSERT(receivedBytes == totalBytes, "Got more bytes than expected: %zu > %zu", receivedBytes,
totalBytes);
PROTOCOL_ASSERT(file.close(), "Couldn't finalize writing the file");
intoState(STATE_DONE);
} else {
intoState(STATE_RECEIVING);
}
updateRequired = true;
break;
}
}
}
void BluetoothActivity::RequestCallbacks::onWrite(NimBLECharacteristic* pCharacteristic, NimBLEConnInfo& connInfo) {
const lfbt_message* msg = reinterpret_cast<const lfbt_message*>(pCharacteristic->getValue().data());
Serial.printf("[%lu] [BT] Received BLE message of type %u, txnId %x, length %d\n", millis(), msg->type, msg->txnId,
pCharacteristic->getValue().length());
activity->onRequest(msg, pCharacteristic->getValue().length());
}

View File

@ -0,0 +1,126 @@
#pragma once
#include <NimBLEDevice.h>
#include <NimBLEServer.h>
#include <NimBLEUtils.h>
#include <SDCardManager.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <functional>
#include "../Activity.h"
typedef struct __attribute__((packed)) {
uint32_t version;
uint32_t bodyLength;
uint32_t nameLength;
char name[];
} lfbt_msg_client_offer; // msg type 0
typedef struct __attribute__((packed)) {
uint32_t status;
} lfbt_msg_server_response; // msg type 1
typedef struct __attribute__((packed)) {
uint32_t offset;
char body[];
} lfbt_msg_client_chunk; // msg type 2
typedef union {
lfbt_msg_client_offer clientOffer;
lfbt_msg_server_response serverResponse;
lfbt_msg_client_chunk clientChunk;
} lfbt_message_body;
typedef struct __attribute__((packed)) {
uint32_t type;
uint32_t txnId;
lfbt_message_body body;
} lfbt_message;
/**
* BluetoothActivity receives files over a custom BLE protocol and stores them on the SD card.
*
* The onCancel callback is called if the user presses back.
* onFileReceived is called when a file is successfully received with the path to the file.
*/
class BluetoothActivity final : public Activity {
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
bool updateRequired = false;
const std::function<void()> onCancel;
const std::function<void(const std::string&)> onFileReceived;
static void displayTaskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void render() const;
static void reportTaskTrampoline(void* param);
void report();
void onConnected(bool isConnected);
void onRequest(const lfbt_message* msg, size_t msg_len);
class ServerCallbacks : public NimBLEServerCallbacks {
friend class BluetoothActivity;
BluetoothActivity* activity;
void onConnect(NimBLEServer* pServer, NimBLEConnInfo& connInfo);
void onDisconnect(NimBLEServer* pServer, NimBLEConnInfo& connInfo, int reason);
protected:
explicit ServerCallbacks(BluetoothActivity* activity) : activity(activity) {}
};
ServerCallbacks serverCallbacks;
class RequestCallbacks : public NimBLECharacteristicCallbacks {
friend class BluetoothActivity;
BluetoothActivity* activity;
void onWrite(NimBLECharacteristic* pCharacteristic, NimBLEConnInfo& connInfo);
protected:
explicit RequestCallbacks(BluetoothActivity* activity) : activity(activity) {}
};
RequestCallbacks requestCallbacks;
NimBLECharacteristic* pResponseChar = nullptr;
void startAdvertising();
void stopAdvertising();
typedef enum {
STATE_INITIALIZING,
STATE_WAITING,
STATE_CONNECTED,
STATE_OFFERED,
STATE_RECEIVING,
STATE_DONE,
STATE_ERROR
} State;
State state = STATE_INITIALIZING;
std::string filename;
FsFile file;
size_t receivedBytes = 0;
size_t totalBytes = 0;
char errorMessage[256] = {};
uint32_t txnId = 0;
void intoState(State newState);
public:
explicit BluetoothActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void()>& onCancel,
const std::function<void(const std::string&)>& onFileReceived)
: Activity("Bluetooth", renderer, mappedInput),
onCancel(onCancel),
onFileReceived(onFileReceived),
serverCallbacks(this),
requestCallbacks(this) {}
void onEnter() override;
void onExit() override;
void loop() override;
};

View File

@ -8,13 +8,15 @@
#include "CrossPointSettings.h" #include "CrossPointSettings.h"
#include "CrossPointState.h" #include "CrossPointState.h"
#include "ScreenComponents.h"
#include "fontIds.h" #include "fontIds.h"
#include "images/CrossLarge.h" #include "images/CrossLarge.h"
#include "util/StringUtils.h" #include "util/StringUtils.h"
void SleepActivity::onEnter() { void SleepActivity::onEnter() {
Activity::onEnter(); Activity::onEnter();
renderPopup("Entering Sleep...");
ScreenComponents::drawPopup(renderer, "Entering Sleep...");
if (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::BLANK) { if (SETTINGS.sleepScreen == CrossPointSettings::SLEEP_SCREEN_MODE::BLANK) {
return renderBlankSleepScreen(); return renderBlankSleepScreen();
@ -31,20 +33,6 @@ void SleepActivity::onEnter() {
renderDefaultSleepScreen(); renderDefaultSleepScreen();
} }
void SleepActivity::renderPopup(const char* message) const {
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, message, EpdFontFamily::BOLD);
constexpr int margin = 20;
const int x = (renderer.getScreenWidth() - textWidth - margin * 2) / 2;
constexpr int y = 117;
const int w = textWidth + margin * 2;
const int h = renderer.getLineHeight(UI_12_FONT_ID) + margin * 2;
// renderer.clearScreen();
renderer.fillRect(x - 5, y - 5, w + 10, h + 10, true);
renderer.fillRect(x + 5, y + 5, w - 10, h - 10, false);
renderer.drawText(UI_12_FONT_ID, x + margin, y + margin, message, true, EpdFontFamily::BOLD);
renderer.displayBuffer();
}
void SleepActivity::renderCustomSleepScreen() const { void SleepActivity::renderCustomSleepScreen() const {
// Check if we have a /sleep directory // Check if we have a /sleep directory
auto dir = SdMan.open("/sleep"); auto dir = SdMan.open("/sleep");

View File

@ -10,7 +10,6 @@ class SleepActivity final : public Activity {
void onEnter() override; void onEnter() override;
private: private:
void renderPopup(const char* message) const;
void renderDefaultSleepScreen() const; void renderDefaultSleepScreen() const;
void renderCustomSleepScreen() const; void renderCustomSleepScreen() const;
void renderCoverSleepScreen() const; void renderCoverSleepScreen() const;

View File

@ -23,7 +23,7 @@ void HomeActivity::taskTrampoline(void* param) {
} }
int HomeActivity::getMenuItemCount() const { int HomeActivity::getMenuItemCount() const {
int count = 3; // My Library, File transfer, Settings int count = 4; // My Library, File transfer, Bluetooth, Settings
if (hasContinueReading) count++; if (hasContinueReading) count++;
if (hasOpdsUrl) count++; if (hasOpdsUrl) count++;
return count; return count;
@ -175,6 +175,7 @@ void HomeActivity::loop() {
const int myLibraryIdx = idx++; const int myLibraryIdx = idx++;
const int opdsLibraryIdx = hasOpdsUrl ? idx++ : -1; const int opdsLibraryIdx = hasOpdsUrl ? idx++ : -1;
const int fileTransferIdx = idx++; const int fileTransferIdx = idx++;
const int bluetoothIdx = idx++;
const int settingsIdx = idx; const int settingsIdx = idx;
if (selectorIndex == continueIdx) { if (selectorIndex == continueIdx) {
@ -185,6 +186,8 @@ void HomeActivity::loop() {
onOpdsBrowserOpen(); onOpdsBrowserOpen();
} else if (selectorIndex == fileTransferIdx) { } else if (selectorIndex == fileTransferIdx) {
onFileTransferOpen(); onFileTransferOpen();
} else if (selectorIndex == bluetoothIdx) {
onBluetoothOpen();
} else if (selectorIndex == settingsIdx) { } else if (selectorIndex == settingsIdx) {
onSettingsOpen(); onSettingsOpen();
} }
@ -503,7 +506,7 @@ void HomeActivity::render() {
// --- Bottom menu tiles --- // --- Bottom menu tiles ---
// Build menu items dynamically // Build menu items dynamically
std::vector<const char*> menuItems = {"My Library", "File Transfer", "Settings"}; std::vector<const char*> menuItems = {"My Library", "File Transfer", "Bluetooth", "Settings"};
if (hasOpdsUrl) { if (hasOpdsUrl) {
// Insert OPDS Browser after My Library // Insert OPDS Browser after My Library
menuItems.insert(menuItems.begin() + 1, "OPDS Browser"); menuItems.insert(menuItems.begin() + 1, "OPDS Browser");

View File

@ -25,6 +25,7 @@ class HomeActivity final : public Activity {
const std::function<void()> onMyLibraryOpen; const std::function<void()> onMyLibraryOpen;
const std::function<void()> onSettingsOpen; const std::function<void()> onSettingsOpen;
const std::function<void()> onFileTransferOpen; const std::function<void()> onFileTransferOpen;
const std::function<void()> onBluetoothOpen;
const std::function<void()> onOpdsBrowserOpen; const std::function<void()> onOpdsBrowserOpen;
static void taskTrampoline(void* param); static void taskTrampoline(void* param);
@ -39,12 +40,13 @@ class HomeActivity final : public Activity {
explicit HomeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, explicit HomeActivity(GfxRenderer& renderer, MappedInputManager& mappedInput,
const std::function<void()>& onContinueReading, const std::function<void()>& onMyLibraryOpen, const std::function<void()>& onContinueReading, const std::function<void()>& onMyLibraryOpen,
const std::function<void()>& onSettingsOpen, const std::function<void()>& onFileTransferOpen, const std::function<void()>& onSettingsOpen, const std::function<void()>& onFileTransferOpen,
const std::function<void()>& onOpdsBrowserOpen) const std::function<void()>& onBluetoothOpen, const std::function<void()>& onOpdsBrowserOpen)
: Activity("Home", renderer, mappedInput), : Activity("Home", renderer, mappedInput),
onContinueReading(onContinueReading), onContinueReading(onContinueReading),
onMyLibraryOpen(onMyLibraryOpen), onMyLibraryOpen(onMyLibraryOpen),
onSettingsOpen(onSettingsOpen), onSettingsOpen(onSettingsOpen),
onFileTransferOpen(onFileTransferOpen), onFileTransferOpen(onFileTransferOpen),
onBluetoothOpen(onBluetoothOpen),
onOpdsBrowserOpen(onOpdsBrowserOpen) {} onOpdsBrowserOpen(onOpdsBrowserOpen) {}
void onEnter() override; void onEnter() override;
void onExit() override; void onExit() override;

View File

@ -266,9 +266,9 @@ void WifiSelectionActivity::checkConnectionStatus() {
} }
if (status == WL_CONNECT_FAILED || status == WL_NO_SSID_AVAIL) { if (status == WL_CONNECT_FAILED || status == WL_NO_SSID_AVAIL) {
connectionError = "Connection failed"; connectionError = "Error: General failure";
if (status == WL_NO_SSID_AVAIL) { if (status == WL_NO_SSID_AVAIL) {
connectionError = "Network not found"; connectionError = "Error: Network not found";
} }
state = WifiSelectionState::CONNECTION_FAILED; state = WifiSelectionState::CONNECTION_FAILED;
updateRequired = true; updateRequired = true;
@ -278,7 +278,7 @@ void WifiSelectionActivity::checkConnectionStatus() {
// Check for timeout // Check for timeout
if (millis() - connectionStartTime > CONNECTION_TIMEOUT_MS) { if (millis() - connectionStartTime > CONNECTION_TIMEOUT_MS) {
WiFi.disconnect(); WiFi.disconnect();
connectionError = "Connection timeout"; connectionError = "Error: Connection timeout";
state = WifiSelectionState::CONNECTION_FAILED; state = WifiSelectionState::CONNECTION_FAILED;
updateRequired = true; updateRequired = true;
return; return;
@ -689,7 +689,7 @@ void WifiSelectionActivity::renderForgetPrompt() const {
const auto height = renderer.getLineHeight(UI_10_FONT_ID); const auto height = renderer.getLineHeight(UI_10_FONT_ID);
const auto top = (pageHeight - height * 3) / 2; const auto top = (pageHeight - height * 3) / 2;
renderer.drawCenteredText(UI_12_FONT_ID, top - 40, "Forget Network?", true, EpdFontFamily::BOLD); renderer.drawCenteredText(UI_12_FONT_ID, top - 40, "Connection Failed", true, EpdFontFamily::BOLD);
std::string ssidInfo = "Network: " + selectedSSID; std::string ssidInfo = "Network: " + selectedSSID;
if (ssidInfo.length() > 28) { if (ssidInfo.length() > 28) {
@ -697,7 +697,7 @@ void WifiSelectionActivity::renderForgetPrompt() const {
} }
renderer.drawCenteredText(UI_10_FONT_ID, top, ssidInfo.c_str()); renderer.drawCenteredText(UI_10_FONT_ID, top, ssidInfo.c_str());
renderer.drawCenteredText(UI_10_FONT_ID, top + 40, "Remove saved password?"); renderer.drawCenteredText(UI_10_FONT_ID, top + 40, "Forget network and remove saved password?");
// Draw Cancel/Forget network buttons // Draw Cancel/Forget network buttons
const int buttonY = top + 80; const int buttonY = top + 80;

View File

@ -130,31 +130,9 @@ void EpubReaderActivity::loop() {
const int currentPage = section ? section->currentPage : 0; const int currentPage = section ? section->currentPage : 0;
const int totalPages = section ? section->pageCount : 0; const int totalPages = section ? section->pageCount : 0;
exitActivity(); exitActivity();
enterNewActivity(new EpubReaderChapterSelectionActivity( enterNewActivity(new EpubReaderMenuActivity(
this->renderer, this->mappedInput, epub, epub->getPath(), currentSpineIndex, currentPage, totalPages, this->renderer, this->mappedInput, epub->getTitle(), [this]() { onReaderMenuBack(); },
[this] { [this](EpubReaderMenuActivity::MenuAction action) { onReaderMenuConfirm(action); }));
exitActivity();
updateRequired = true;
},
[this](const int newSpineIndex) {
if (currentSpineIndex != newSpineIndex) {
currentSpineIndex = newSpineIndex;
nextPageNumber = 0;
section.reset();
}
exitActivity();
updateRequired = true;
},
[this](const int newSpineIndex, const int newPage) {
// Handle sync position
if (currentSpineIndex != newSpineIndex || (section && section->currentPage != newPage)) {
currentSpineIndex = newSpineIndex;
nextPageNumber = newPage;
section.reset();
}
exitActivity();
updateRequired = true;
}));
xSemaphoreGive(renderingMutex); xSemaphoreGive(renderingMutex);
} }
@ -242,6 +220,89 @@ void EpubReaderActivity::loop() {
} }
} }
void EpubReaderActivity::onReaderMenuBack() {
exitActivity();
updateRequired = true;
}
void EpubReaderActivity::onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action) {
switch (action) {
case EpubReaderMenuActivity::MenuAction::SELECT_CHAPTER: {
// Calculate values BEFORE we start destroying things
const int currentP = section ? section->currentPage : 0;
const int totalP = section ? section->pageCount : 0;
const int spineIdx = currentSpineIndex;
const std::string path = epub->getPath();
xSemaphoreTake(renderingMutex, portMAX_DELAY);
// 1. Close the menu
exitActivity();
// 2. Open the Chapter Selector
enterNewActivity(new EpubReaderChapterSelectionActivity(
this->renderer, this->mappedInput, epub, path, spineIdx, currentP, totalP,
[this] {
exitActivity();
updateRequired = true;
},
[this](const int newSpineIndex) {
if (currentSpineIndex != newSpineIndex) {
currentSpineIndex = newSpineIndex;
nextPageNumber = 0;
section.reset();
}
exitActivity();
updateRequired = true;
},
[this](const int newSpineIndex, const int newPage) {
if (currentSpineIndex != newSpineIndex || (section && section->currentPage != newPage)) {
currentSpineIndex = newSpineIndex;
nextPageNumber = newPage;
section.reset();
}
exitActivity();
updateRequired = true;
}));
xSemaphoreGive(renderingMutex);
break;
}
case EpubReaderMenuActivity::MenuAction::GO_HOME: {
// 2. Trigger the reader's "Go Home" callback
if (onGoHome) {
onGoHome();
}
break;
}
case EpubReaderMenuActivity::MenuAction::DELETE_CACHE: {
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (epub) {
// 2. BACKUP: Read current progress
// We use the current variables that track our position
uint16_t backupSpine = currentSpineIndex;
uint16_t backupPage = section->currentPage;
uint16_t backupPageCount = section->pageCount;
section.reset();
// 3. WIPE: Clear the cache directory
epub->clearCache();
// 4. RESTORE: Re-setup the directory and rewrite the progress file
epub->setupCacheDir();
saveProgress(backupSpine, backupPage, backupPageCount);
}
exitActivity();
updateRequired = true;
xSemaphoreGive(renderingMutex);
if (onGoHome) onGoHome();
break;
}
}
}
void EpubReaderActivity::displayTaskLoop() { void EpubReaderActivity::displayTaskLoop() {
while (true) { while (true) {
if (updateRequired) { if (updateRequired) {
@ -308,49 +369,11 @@ void EpubReaderActivity::renderScreen() {
viewportHeight, SETTINGS.hyphenationEnabled)) { viewportHeight, SETTINGS.hyphenationEnabled)) {
Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis()); Serial.printf("[%lu] [ERS] Cache not found, building...\n", millis());
// Progress bar dimensions const auto popupFn = [this]() { ScreenComponents::drawPopup(renderer, "Indexing..."); };
constexpr int barWidth = 200;
constexpr int barHeight = 10;
constexpr int boxMargin = 20;
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, "Indexing...");
const int boxWidthWithBar = (barWidth > textWidth ? barWidth : textWidth) + boxMargin * 2;
const int boxWidthNoBar = textWidth + boxMargin * 2;
const int boxHeightWithBar = renderer.getLineHeight(UI_12_FONT_ID) + barHeight + boxMargin * 3;
const int boxHeightNoBar = renderer.getLineHeight(UI_12_FONT_ID) + boxMargin * 2;
const int boxXWithBar = (renderer.getScreenWidth() - boxWidthWithBar) / 2;
const int boxXNoBar = (renderer.getScreenWidth() - boxWidthNoBar) / 2;
constexpr int boxY = 50;
const int barX = boxXWithBar + (boxWidthWithBar - barWidth) / 2;
const int barY = boxY + renderer.getLineHeight(UI_12_FONT_ID) + boxMargin * 2;
// Always show "Indexing..." text first
{
renderer.fillRect(boxXNoBar, boxY, boxWidthNoBar, boxHeightNoBar, false);
renderer.drawText(UI_12_FONT_ID, boxXNoBar + boxMargin, boxY + boxMargin, "Indexing...");
renderer.drawRect(boxXNoBar + 5, boxY + 5, boxWidthNoBar - 10, boxHeightNoBar - 10);
renderer.displayBuffer();
pagesUntilFullRefresh = 0;
}
// Setup callback - only called for chapters >= 50KB, redraws with progress bar
auto progressSetup = [this, boxXWithBar, boxWidthWithBar, boxHeightWithBar, barX, barY] {
renderer.fillRect(boxXWithBar, boxY, boxWidthWithBar, boxHeightWithBar, false);
renderer.drawText(UI_12_FONT_ID, boxXWithBar + boxMargin, boxY + boxMargin, "Indexing...");
renderer.drawRect(boxXWithBar + 5, boxY + 5, boxWidthWithBar - 10, boxHeightWithBar - 10);
renderer.drawRect(barX, barY, barWidth, barHeight);
renderer.displayBuffer();
};
// Progress callback to update progress bar
auto progressCallback = [this, barX, barY, barWidth, barHeight](int progress) {
const int fillWidth = (barWidth - 2) * progress / 100;
renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true);
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
};
if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(), if (!section->createSectionFile(SETTINGS.getReaderFontId(), SETTINGS.getReaderLineCompression(),
SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth, SETTINGS.extraParagraphSpacing, SETTINGS.paragraphAlignment, viewportWidth,
viewportHeight, SETTINGS.hyphenationEnabled, progressSetup, progressCallback)) { viewportHeight, SETTINGS.hyphenationEnabled, popupFn)) {
Serial.printf("[%lu] [ERS] Failed to persist page data to SD\n", millis()); Serial.printf("[%lu] [ERS] Failed to persist page data to SD\n", millis());
section.reset(); section.reset();
return; return;
@ -407,21 +430,26 @@ void EpubReaderActivity::renderScreen() {
renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft); renderContents(std::move(p), orientedMarginTop, orientedMarginRight, orientedMarginBottom, orientedMarginLeft);
Serial.printf("[%lu] [ERS] Rendered page in %dms\n", millis(), millis() - start); Serial.printf("[%lu] [ERS] Rendered page in %dms\n", millis(), millis() - start);
} }
saveProgress(currentSpineIndex, section->currentPage, section->pageCount);
}
void EpubReaderActivity::saveProgress(int spineIndex, int currentPage, int pageCount) {
FsFile f; FsFile f;
if (SdMan.openFileForWrite("ERS", epub->getCachePath() + "/progress.bin", f)) { if (SdMan.openFileForWrite("ERS", epub->getCachePath() + "/progress.bin", f)) {
uint8_t data[6]; uint8_t data[6];
data[0] = currentSpineIndex & 0xFF; data[0] = currentSpineIndex & 0xFF;
data[1] = (currentSpineIndex >> 8) & 0xFF; data[1] = (currentSpineIndex >> 8) & 0xFF;
data[2] = section->currentPage & 0xFF; data[2] = currentPage & 0xFF;
data[3] = (section->currentPage >> 8) & 0xFF; data[3] = (currentPage >> 8) & 0xFF;
data[4] = section->pageCount & 0xFF; data[4] = pageCount & 0xFF;
data[5] = (section->pageCount >> 8) & 0xFF; data[5] = (pageCount >> 8) & 0xFF;
f.write(data, 6); f.write(data, 6);
f.close(); f.close();
Serial.printf("[ERS] Progress saved: Chapter %d, Page %d\n", spineIndex, currentPage);
} else {
Serial.printf("[ERS] Could not save progress!\n");
} }
} }
void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int orientedMarginTop, void EpubReaderActivity::renderContents(std::unique_ptr<Page> page, const int orientedMarginTop,
const int orientedMarginRight, const int orientedMarginBottom, const int orientedMarginRight, const int orientedMarginBottom,
const int orientedMarginLeft) { const int orientedMarginLeft) {

View File

@ -5,6 +5,7 @@
#include <freertos/semphr.h> #include <freertos/semphr.h>
#include <freertos/task.h> #include <freertos/task.h>
#include "EpubReaderMenuActivity.h"
#include "activities/ActivityWithSubactivity.h" #include "activities/ActivityWithSubactivity.h"
class EpubReaderActivity final : public ActivityWithSubactivity { class EpubReaderActivity final : public ActivityWithSubactivity {
@ -27,6 +28,9 @@ class EpubReaderActivity final : public ActivityWithSubactivity {
void renderContents(std::unique_ptr<Page> page, int orientedMarginTop, int orientedMarginRight, void renderContents(std::unique_ptr<Page> page, int orientedMarginTop, int orientedMarginRight,
int orientedMarginBottom, int orientedMarginLeft); int orientedMarginBottom, int orientedMarginLeft);
void renderStatusBar(int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft) const; void renderStatusBar(int orientedMarginRight, int orientedMarginBottom, int orientedMarginLeft) const;
void saveProgress(int spineIndex, int currentPage, int pageCount);
void onReaderMenuBack();
void onReaderMenuConfirm(EpubReaderMenuActivity::MenuAction action);
public: public:
explicit EpubReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr<Epub> epub, explicit EpubReaderActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, std::unique_ptr<Epub> epub,

View File

@ -181,9 +181,7 @@ void EpubReaderChapterSelectionActivity::renderScreen() {
const int pageItems = getPageItems(); const int pageItems = getPageItems();
const int totalItems = getTotalItems(); const int totalItems = getTotalItems();
const std::string title = renderer.drawCenteredText(UI_12_FONT_ID, 15, "Go to Chapter", true, EpdFontFamily::BOLD);
renderer.truncatedText(UI_12_FONT_ID, epub->getTitle().c_str(), pageWidth - 40, EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_12_FONT_ID, 15, title.c_str(), true, EpdFontFamily::BOLD);
const auto pageStartIndex = selectorIndex / pageItems * pageItems; const auto pageStartIndex = selectorIndex / pageItems * pageItems;
renderer.fillRect(0, 60 + (selectorIndex % pageItems) * 30 - 2, pageWidth - 1, 30); renderer.fillRect(0, 60 + (selectorIndex % pageItems) * 30 - 2, pageWidth - 1, 30);
@ -208,8 +206,11 @@ void EpubReaderChapterSelectionActivity::renderScreen() {
} }
} }
const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down"); // Skip button hints in landscape CW mode (they overlap content)
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); if (renderer.getOrientation() != GfxRenderer::LandscapeClockwise) {
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(); renderer.displayBuffer();
} }

View File

@ -0,0 +1,103 @@
#include "EpubReaderMenuActivity.h"
#include <GfxRenderer.h>
#include "fontIds.h"
void EpubReaderMenuActivity::onEnter() {
ActivityWithSubactivity::onEnter();
renderingMutex = xSemaphoreCreateMutex();
updateRequired = true;
xTaskCreate(&EpubReaderMenuActivity::taskTrampoline, "EpubMenuTask", 4096, this, 1, &displayTaskHandle);
}
void EpubReaderMenuActivity::onExit() {
ActivityWithSubactivity::onExit();
xSemaphoreTake(renderingMutex, portMAX_DELAY);
if (displayTaskHandle) {
vTaskDelete(displayTaskHandle);
displayTaskHandle = nullptr;
}
vSemaphoreDelete(renderingMutex);
renderingMutex = nullptr;
}
void EpubReaderMenuActivity::taskTrampoline(void* param) {
auto* self = static_cast<EpubReaderMenuActivity*>(param);
self->displayTaskLoop();
}
void EpubReaderMenuActivity::displayTaskLoop() {
while (true) {
if (updateRequired && !subActivity) {
updateRequired = false;
xSemaphoreTake(renderingMutex, portMAX_DELAY);
renderScreen();
xSemaphoreGive(renderingMutex);
}
vTaskDelay(10 / portTICK_PERIOD_MS);
}
}
void EpubReaderMenuActivity::loop() {
if (subActivity) {
subActivity->loop();
return;
}
// Use local variables for items we need to check after potential deletion
if (mappedInput.wasReleased(MappedInputManager::Button::Up) ||
mappedInput.wasReleased(MappedInputManager::Button::Left)) {
selectedIndex = (selectedIndex + menuItems.size() - 1) % menuItems.size();
updateRequired = true;
} else if (mappedInput.wasReleased(MappedInputManager::Button::Down) ||
mappedInput.wasReleased(MappedInputManager::Button::Right)) {
selectedIndex = (selectedIndex + 1) % menuItems.size();
updateRequired = true;
} else if (mappedInput.wasReleased(MappedInputManager::Button::Confirm)) {
// 1. Capture the callback and action locally
auto actionCallback = onAction;
auto selectedAction = menuItems[selectedIndex].action;
// 2. Execute the callback
actionCallback(selectedAction);
// 3. CRITICAL: Return immediately. 'this' is likely deleted now.
return;
} else if (mappedInput.wasReleased(MappedInputManager::Button::Back)) {
onBack();
return; // Also return here just in case
}
}
void EpubReaderMenuActivity::renderScreen() {
renderer.clearScreen();
const auto pageWidth = renderer.getScreenWidth();
// Title
const std::string truncTitle =
renderer.truncatedText(UI_12_FONT_ID, title.c_str(), pageWidth - 40, EpdFontFamily::BOLD);
renderer.drawCenteredText(UI_12_FONT_ID, 15, truncTitle.c_str(), true, EpdFontFamily::BOLD);
// Menu Items
constexpr int startY = 60;
constexpr int lineHeight = 30;
for (size_t i = 0; i < menuItems.size(); ++i) {
const int displayY = startY + (i * lineHeight);
const bool isSelected = (static_cast<int>(i) == selectedIndex);
if (isSelected) {
renderer.fillRect(0, displayY, pageWidth - 1, lineHeight, true);
}
renderer.drawText(UI_10_FONT_ID, 20, displayY, menuItems[i].label.c_str(), !isSelected);
}
// Footer / Hints
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();
}

View File

@ -0,0 +1,51 @@
#pragma once
#include <Epub.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <freertos/task.h>
#include <functional>
#include <string>
#include <vector>
#include "../ActivityWithSubactivity.h"
#include "MappedInputManager.h"
class EpubReaderMenuActivity final : public ActivityWithSubactivity {
public:
enum class MenuAction { SELECT_CHAPTER, GO_HOME, DELETE_CACHE };
explicit EpubReaderMenuActivity(GfxRenderer& renderer, MappedInputManager& mappedInput, const std::string& title,
const std::function<void()>& onBack, const std::function<void(MenuAction)>& onAction)
: ActivityWithSubactivity("EpubReaderMenu", renderer, mappedInput),
title(title),
onBack(onBack),
onAction(onAction) {}
void onEnter() override;
void onExit() override;
void loop() override;
private:
struct MenuItem {
MenuAction action;
std::string label;
};
const std::vector<MenuItem> menuItems = {{MenuAction::SELECT_CHAPTER, "Go to Chapter"},
{MenuAction::GO_HOME, "Go Home"},
{MenuAction::DELETE_CACHE, "Delete Book Cache"}};
int selectedIndex = 0;
bool updateRequired = false;
TaskHandle_t displayTaskHandle = nullptr;
SemaphoreHandle_t renderingMutex = nullptr;
std::string title = "Reader Menu";
const std::function<void()> onBack;
const std::function<void(MenuAction)> onAction;
static void taskTrampoline(void* param);
[[noreturn]] void displayTaskLoop();
void renderScreen();
};

View File

@ -207,28 +207,10 @@ void TxtReaderActivity::buildPageIndex() {
size_t offset = 0; size_t offset = 0;
const size_t fileSize = txt->getFileSize(); const size_t fileSize = txt->getFileSize();
int lastProgressPercent = -1;
Serial.printf("[%lu] [TRS] Building page index for %zu bytes...\n", millis(), fileSize); Serial.printf("[%lu] [TRS] Building page index for %zu bytes...\n", millis(), fileSize);
// Progress bar dimensions (matching EpubReaderActivity style) ScreenComponents::drawPopup(renderer, "Indexing...");
constexpr int barWidth = 200;
constexpr int barHeight = 10;
constexpr int boxMargin = 20;
const int textWidth = renderer.getTextWidth(UI_12_FONT_ID, "Indexing...");
const int boxWidth = (barWidth > textWidth ? barWidth : textWidth) + boxMargin * 2;
const int boxHeight = renderer.getLineHeight(UI_12_FONT_ID) + barHeight + boxMargin * 3;
const int boxX = (renderer.getScreenWidth() - boxWidth) / 2;
constexpr int boxY = 50;
const int barX = boxX + (boxWidth - barWidth) / 2;
const int barY = boxY + renderer.getLineHeight(UI_12_FONT_ID) + boxMargin * 2;
// Draw initial progress box
renderer.fillRect(boxX, boxY, boxWidth, boxHeight, false);
renderer.drawText(UI_12_FONT_ID, boxX + boxMargin, boxY + boxMargin, "Indexing...");
renderer.drawRect(boxX + 5, boxY + 5, boxWidth - 10, boxHeight - 10);
renderer.drawRect(barX, barY, barWidth, barHeight);
renderer.displayBuffer();
while (offset < fileSize) { while (offset < fileSize) {
std::vector<std::string> tempLines; std::vector<std::string> tempLines;
@ -248,17 +230,6 @@ void TxtReaderActivity::buildPageIndex() {
pageOffsets.push_back(offset); pageOffsets.push_back(offset);
} }
// Update progress bar every 10% (matching EpubReaderActivity logic)
int progressPercent = (offset * 100) / fileSize;
if (lastProgressPercent / 10 != progressPercent / 10) {
lastProgressPercent = progressPercent;
// Fill progress bar
const int fillWidth = (barWidth - 2) * progressPercent / 100;
renderer.fillRect(barX + 1, barY + 1, fillWidth, barHeight - 2, true);
renderer.displayBuffer(HalDisplay::FAST_REFRESH);
}
// Yield to other tasks periodically // Yield to other tasks periodically
if (pageOffsets.size() % 20 == 0) { if (pageOffsets.size() % 20 == 0) {
vTaskDelay(1); vTaskDelay(1);
@ -402,9 +373,6 @@ void TxtReaderActivity::renderScreen() {
// Initialize reader if not done // Initialize reader if not done
if (!initialized) { if (!initialized) {
renderer.clearScreen();
renderer.drawCenteredText(UI_12_FONT_ID, 300, "Indexing...", true, EpdFontFamily::BOLD);
renderer.displayBuffer();
initializeReader(); initializeReader();
} }

View File

@ -149,8 +149,11 @@ void XtcReaderChapterSelectionActivity::renderScreen() {
renderer.drawText(UI_10_FONT_ID, 20, 60 + (i % pageItems) * 30, title, i != selectorIndex); renderer.drawText(UI_10_FONT_ID, 20, 60 + (i % pageItems) * 30, title, i != selectorIndex);
} }
const auto labels = mappedInput.mapLabels("« Back", "Select", "Up", "Down"); // Skip button hints in landscape CW mode (they overlap content)
renderer.drawButtonHints(UI_10_FONT_ID, labels.btn1, labels.btn2, labels.btn3, labels.btn4); if (renderer.getOrientation() != GfxRenderer::LandscapeClockwise) {
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(); renderer.displayBuffer();
} }

View File

@ -15,6 +15,7 @@
#include "KOReaderCredentialStore.h" #include "KOReaderCredentialStore.h"
#include "MappedInputManager.h" #include "MappedInputManager.h"
#include "RecentBooksStore.h" #include "RecentBooksStore.h"
#include "activities/bluetooth/BluetoothActivity.h"
#include "activities/boot_sleep/BootActivity.h" #include "activities/boot_sleep/BootActivity.h"
#include "activities/boot_sleep/SleepActivity.h" #include "activities/boot_sleep/SleepActivity.h"
#include "activities/browser/OpdsBookBrowserActivity.h" #include "activities/browser/OpdsBookBrowserActivity.h"
@ -25,6 +26,7 @@
#include "activities/settings/SettingsActivity.h" #include "activities/settings/SettingsActivity.h"
#include "activities/util/FullScreenMessageActivity.h" #include "activities/util/FullScreenMessageActivity.h"
#include "fontIds.h" #include "fontIds.h"
#include "util/StringUtils.h"
HalDisplay display; HalDisplay display;
HalGPIO gpio; HalGPIO gpio;
@ -216,6 +218,16 @@ void onGoToFileTransfer() {
enterNewActivity(new CrossPointWebServerActivity(renderer, mappedInputManager, onGoHome)); enterNewActivity(new CrossPointWebServerActivity(renderer, mappedInputManager, onGoHome));
} }
void onGoToBluetooth() {
exitActivity();
enterNewActivity(new BluetoothActivity(renderer, mappedInputManager, onGoHome, [](const std::string& filepath) {
Serial.printf("[%lu] [ ] File received over Bluetooth: %s\n", millis(), filepath.c_str());
if (StringUtils::readableFileExtension(filepath)) {
onGoToReader(filepath, MyLibraryActivity::Tab::Recent);
}
}));
}
void onGoToSettings() { void onGoToSettings() {
exitActivity(); exitActivity();
enterNewActivity(new SettingsActivity(renderer, mappedInputManager, onGoHome)); enterNewActivity(new SettingsActivity(renderer, mappedInputManager, onGoHome));
@ -239,7 +251,7 @@ void onGoToBrowser() {
void onGoHome() { void onGoHome() {
exitActivity(); exitActivity();
enterNewActivity(new HomeActivity(renderer, mappedInputManager, onContinueReading, onGoToMyLibrary, onGoToSettings, enterNewActivity(new HomeActivity(renderer, mappedInputManager, onContinueReading, onGoToMyLibrary, onGoToSettings,
onGoToFileTransfer, onGoToBrowser)); onGoToFileTransfer, onGoToBluetooth, onGoToBrowser));
} }
void setupDisplayAndFonts() { void setupDisplayAndFonts() {
@ -294,10 +306,22 @@ void setup() {
SETTINGS.loadFromFile(); SETTINGS.loadFromFile();
KOREADER_STORE.loadFromFile(); KOREADER_STORE.loadFromFile();
if (gpio.isWakeupByPowerButton()) { switch (gpio.getWakeupReason()) {
// For normal wakeups, verify power button press duration case HalGPIO::WakeupReason::PowerButton:
Serial.printf("[%lu] [ ] Verifying power button press duration\n", millis()); // For normal wakeups, verify power button press duration
verifyPowerButtonDuration(); Serial.printf("[%lu] [ ] Verifying power button press duration\n", millis());
verifyPowerButtonDuration();
break;
case HalGPIO::WakeupReason::AfterUSBPower:
// If USB power caused a cold boot, go back to sleep
Serial.printf("[%lu] [ ] Wakeup reason: After USB Power\n", millis());
gpio.startDeepSleep();
break;
case HalGPIO::WakeupReason::AfterFlash:
// After flashing, just proceed to boot
case HalGPIO::WakeupReason::Other:
default:
break;
} }
// First serial output only here to avoid timing inconsistencies for power button press duration verification // First serial output only here to avoid timing inconsistencies for power button press duration verification
@ -317,7 +341,6 @@ void setup() {
// Clear app state to avoid getting into a boot loop if the epub doesn't load // Clear app state to avoid getting into a boot loop if the epub doesn't load
const auto path = APP_STATE.openEpubPath; const auto path = APP_STATE.openEpubPath;
APP_STATE.openEpubPath = ""; APP_STATE.openEpubPath = "";
APP_STATE.lastSleepImage = 0;
APP_STATE.saveToFile(); APP_STATE.saveToFile();
onGoToReader(path, MyLibraryActivity::Tab::Recent); onGoToReader(path, MyLibraryActivity::Tab::Recent);
} }

View File

@ -61,6 +61,19 @@ bool checkFileExtension(const String& fileName, const char* extension) {
return localFile.endsWith(localExtension); return localFile.endsWith(localExtension);
} }
bool readableFileExtension(const std::string& fileName) {
return (StringUtils::checkFileExtension(fileName, ".epub") || StringUtils::checkFileExtension(fileName, ".xtch") ||
StringUtils::checkFileExtension(fileName, ".xtc") || StringUtils::checkFileExtension(fileName, ".txt"));
}
std::pair<std::string, std::string> splitFileName(const std::string& name) {
size_t lastDot = name.find_last_of('.');
if (lastDot == std::string::npos) {
return std::make_pair(name, "");
}
return std::make_pair(name.substr(0, lastDot), name.substr(lastDot));
}
size_t utf8RemoveLastChar(std::string& str) { size_t utf8RemoveLastChar(std::string& str) {
if (str.empty()) return 0; if (str.empty()) return 0;
size_t pos = str.size() - 1; size_t pos = str.size() - 1;

View File

@ -19,6 +19,17 @@ std::string sanitizeFilename(const std::string& name, size_t maxLength = 100);
bool checkFileExtension(const std::string& fileName, const char* extension); bool checkFileExtension(const std::string& fileName, const char* extension);
bool checkFileExtension(const String& fileName, const char* extension); bool checkFileExtension(const String& fileName, const char* extension);
/**
* Check if the given filename ends with an extension we can open.
*/
bool readableFileExtension(const std::string& fileName);
/**
* Split a filename into base name and extension.
* If there is no extension, the second element of the pair will be an empty string.
*/
std::pair<std::string, std::string> splitFileName(const std::string& name);
// UTF-8 safe string truncation - removes one character from the end // UTF-8 safe string truncation - removes one character from the end
// Returns the new size after removing one UTF-8 character // Returns the new size after removing one UTF-8 character
size_t utf8RemoveLastChar(std::string& str); size_t utf8RemoveLastChar(std::string& str);